openid4vp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +39 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +278 -0
  5. data/lib/openid4vp/authorization_request/validator.rb +177 -0
  6. data/lib/openid4vp/authorization_request.rb +302 -0
  7. data/lib/openid4vp/authorization_response.rb +151 -0
  8. data/lib/openid4vp/client_id.rb +90 -0
  9. data/lib/openid4vp/credential.rb +51 -0
  10. data/lib/openid4vp/dc_api.rb +116 -0
  11. data/lib/openid4vp/dcql/claims_path_pointer.rb +109 -0
  12. data/lib/openid4vp/dcql/claims_query.rb +76 -0
  13. data/lib/openid4vp/dcql/credential_query.rb +178 -0
  14. data/lib/openid4vp/dcql/credential_set_query.rb +49 -0
  15. data/lib/openid4vp/dcql/evaluator.rb +228 -0
  16. data/lib/openid4vp/dcql/matcher.rb +79 -0
  17. data/lib/openid4vp/dcql/meta.rb +99 -0
  18. data/lib/openid4vp/dcql/query.rb +93 -0
  19. data/lib/openid4vp/dcql/selection.rb +45 -0
  20. data/lib/openid4vp/dcql/trusted_authorities_query.rb +48 -0
  21. data/lib/openid4vp/dcql.rb +10 -0
  22. data/lib/openid4vp/encoded_response.rb +10 -0
  23. data/lib/openid4vp/error_response.rb +82 -0
  24. data/lib/openid4vp/errors.rb +73 -0
  25. data/lib/openid4vp/formats/base.rb +74 -0
  26. data/lib/openid4vp/formats/context.rb +56 -0
  27. data/lib/openid4vp/formats/jwt_vc_json.rb +274 -0
  28. data/lib/openid4vp/formats/ldp_vc.rb +160 -0
  29. data/lib/openid4vp/formats/mso_mdoc.rb +204 -0
  30. data/lib/openid4vp/formats/registry.rb +53 -0
  31. data/lib/openid4vp/formats/sd_jwt_vc.rb +132 -0
  32. data/lib/openid4vp/formats/verified_presentation.rb +45 -0
  33. data/lib/openid4vp/formats.rb +12 -0
  34. data/lib/openid4vp/jose/algorithms.rb +46 -0
  35. data/lib/openid4vp/jose/jwe.rb +184 -0
  36. data/lib/openid4vp/jose/jwk.rb +253 -0
  37. data/lib/openid4vp/jose/jwk_set.rb +57 -0
  38. data/lib/openid4vp/jose/jws.rb +225 -0
  39. data/lib/openid4vp/jose.rb +8 -0
  40. data/lib/openid4vp/mdoc/builder.rb +132 -0
  41. data/lib/openid4vp/mdoc/cbor_values.rb +32 -0
  42. data/lib/openid4vp/mdoc/cose_key.rb +71 -0
  43. data/lib/openid4vp/mdoc/device_authentication.rb +32 -0
  44. data/lib/openid4vp/mdoc/device_response.rb +77 -0
  45. data/lib/openid4vp/mdoc/device_signed.rb +111 -0
  46. data/lib/openid4vp/mdoc/document.rb +44 -0
  47. data/lib/openid4vp/mdoc/handover.rb +51 -0
  48. data/lib/openid4vp/mdoc/issuer_signed.rb +157 -0
  49. data/lib/openid4vp/mdoc/mso.rb +133 -0
  50. data/lib/openid4vp/mdoc/session_transcript.rb +49 -0
  51. data/lib/openid4vp/mdoc/sign1_decoder.rb +60 -0
  52. data/lib/openid4vp/mdoc/verifier.rb +169 -0
  53. data/lib/openid4vp/mdoc.rb +9 -0
  54. data/lib/openid4vp/metadata/verifier_metadata.rb +109 -0
  55. data/lib/openid4vp/metadata/vp_formats.rb +112 -0
  56. data/lib/openid4vp/metadata/wallet_metadata.rb +139 -0
  57. data/lib/openid4vp/metadata.rb +10 -0
  58. data/lib/openid4vp/request_object.rb +152 -0
  59. data/lib/openid4vp/request_verification/base.rb +63 -0
  60. data/lib/openid4vp/request_verification/config.rb +65 -0
  61. data/lib/openid4vp/request_verification/decentralized_identifier.rb +56 -0
  62. data/lib/openid4vp/request_verification/openid_federation.rb +52 -0
  63. data/lib/openid4vp/request_verification/pre_registered.rb +54 -0
  64. data/lib/openid4vp/request_verification/redirect_uri.rb +29 -0
  65. data/lib/openid4vp/request_verification/resolver.rb +76 -0
  66. data/lib/openid4vp/request_verification/verified_request.rb +12 -0
  67. data/lib/openid4vp/request_verification/verifier_attestation.rb +54 -0
  68. data/lib/openid4vp/request_verification/x509_hash.rb +40 -0
  69. data/lib/openid4vp/request_verification/x509_san_dns.rb +60 -0
  70. data/lib/openid4vp/request_verification.rb +10 -0
  71. data/lib/openid4vp/response_encryption.rb +51 -0
  72. data/lib/openid4vp/response_endpoint_result.rb +33 -0
  73. data/lib/openid4vp/response_mode.rb +44 -0
  74. data/lib/openid4vp/response_type.rb +7 -0
  75. data/lib/openid4vp/sd_jwt/digest.rb +30 -0
  76. data/lib/openid4vp/sd_jwt/disclosure.rb +104 -0
  77. data/lib/openid4vp/sd_jwt/issuer.rb +132 -0
  78. data/lib/openid4vp/sd_jwt/kb_jwt.rb +82 -0
  79. data/lib/openid4vp/sd_jwt/parser.rb +48 -0
  80. data/lib/openid4vp/sd_jwt/presentation.rb +333 -0
  81. data/lib/openid4vp/sd_jwt/reconstruct.rb +132 -0
  82. data/lib/openid4vp/sd_jwt.rb +10 -0
  83. data/lib/openid4vp/transaction_data/registry.rb +42 -0
  84. data/lib/openid4vp/transaction_data/sd_jwt_hashes.rb +124 -0
  85. data/lib/openid4vp/transaction_data.rb +138 -0
  86. data/lib/openid4vp/util/base64url.rb +44 -0
  87. data/lib/openid4vp/util/form.rb +24 -0
  88. data/lib/openid4vp/util/random.rb +29 -0
  89. data/lib/openid4vp/util/uri.rb +48 -0
  90. data/lib/openid4vp/util.rb +23 -0
  91. data/lib/openid4vp/verifier/config.rb +216 -0
  92. data/lib/openid4vp/verifier/created_request.rb +25 -0
  93. data/lib/openid4vp/verifier/http_result.rb +34 -0
  94. data/lib/openid4vp/verifier/memory_session_store.rb +102 -0
  95. data/lib/openid4vp/verifier/request_builder.rb +216 -0
  96. data/lib/openid4vp/verifier/request_signer.rb +59 -0
  97. data/lib/openid4vp/verifier/request_uri_endpoint.rb +151 -0
  98. data/lib/openid4vp/verifier/response_endpoint.rb +197 -0
  99. data/lib/openid4vp/verifier/response_validator.rb +258 -0
  100. data/lib/openid4vp/verifier/session.rb +109 -0
  101. data/lib/openid4vp/verifier/session_store.rb +48 -0
  102. data/lib/openid4vp/verifier/verified_response.rb +53 -0
  103. data/lib/openid4vp/verifier.rb +155 -0
  104. data/lib/openid4vp/verifier_attestation.rb +130 -0
  105. data/lib/openid4vp/verifier_info.rb +102 -0
  106. data/lib/openid4vp/version.rb +5 -0
  107. data/lib/openid4vp/vp_token.rb +81 -0
  108. data/lib/openid4vp/wallet/config.rb +49 -0
  109. data/lib/openid4vp/wallet/http_client.rb +73 -0
  110. data/lib/openid4vp/wallet/presenter.rb +90 -0
  111. data/lib/openid4vp/wallet/processed_request.rb +14 -0
  112. data/lib/openid4vp/wallet/request_processor.rb +237 -0
  113. data/lib/openid4vp/wallet/response_builder.rb +44 -0
  114. data/lib/openid4vp/wallet/submission.rb +68 -0
  115. data/lib/openid4vp/wallet.rb +142 -0
  116. data/lib/openid4vp/x509/chain_validator.rb +42 -0
  117. data/lib/openid4vp/x509.rb +103 -0
  118. data/lib/openid4vp.rb +18 -0
  119. metadata +233 -0
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cose"
4
+ require_relative "../mdoc"
5
+ require_relative "../errors"
6
+ require_relative "cbor_values"
7
+ require_relative "cose_key"
8
+ require_relative "device_authentication"
9
+ require_relative "sign1_decoder"
10
+
11
+ module OpenID4VP
12
+ module Mdoc
13
+ # DeviceSigned = {"nameSpaces" => DeviceNameSpacesBytes, "deviceAuth" =>
14
+ # {"deviceSignature" => COSE_Sign1}} (ISO 18013-5 §9.1.3). DeviceMac
15
+ # authentication is not supported.
16
+ class DeviceSigned
17
+ attr_reader :name_spaces_bytes, :name_spaces, :device_auth
18
+
19
+ class << self
20
+ def parse(map)
21
+ raise OpenID4VP::InvalidResponse, "mdoc: invalid DeviceSigned" unless map.is_a?(::Hash)
22
+
23
+ tagged = map.fetch("nameSpaces") { raise OpenID4VP::InvalidResponse, "mdoc: missing DeviceSigned.nameSpaces" }
24
+ unless tagged.is_a?(CBOR::Tagged) && tagged.tag == 24
25
+ raise OpenID4VP::InvalidResponse, "mdoc: expected DeviceNameSpacesBytes (tag 24)"
26
+ end
27
+
28
+ device_name_spaces = decode(tagged.value)
29
+ raise OpenID4VP::InvalidResponse, "mdoc: invalid DeviceNameSpaces" unless device_name_spaces.is_a?(::Hash)
30
+
31
+ name_spaces = device_name_spaces.transform_values do |elements|
32
+ raise OpenID4VP::InvalidResponse, "mdoc: invalid DeviceNameSpaces" unless elements.is_a?(::Hash)
33
+
34
+ elements.transform_values { |v| CborValues.decode(v) }
35
+ end
36
+
37
+ new(name_spaces_bytes: tagged, name_spaces: name_spaces, device_auth: parse_device_auth(map))
38
+ end
39
+
40
+ # device_name_spaces: {ns => {element_identifier => value}}, values
41
+ # may be a Date/Time (encoded as tag 1004/0) or any other
42
+ # CBOR-encodable value. `device_key` is the device's private
43
+ # OpenID4VP::JOSE::JWK; the deviceSignature is a detached signature
44
+ # over DeviceAuthenticationBytes.
45
+ def build(device_name_spaces:, device_key:, session_transcript:, doc_type:, alg: -7, kid: nil)
46
+ CoseKey.check_alg_matches_key!(alg, device_key)
47
+
48
+ tagged = CBOR::Tagged.new(24, CBOR.encode(encode_name_spaces(device_name_spaces)))
49
+ device_authentication_bytes = DeviceAuthentication.bytes(
50
+ session_transcript: session_transcript, doc_type: doc_type, name_spaces_bytes: tagged
51
+ )
52
+ protected_headers = { 1 => alg }
53
+ protected_headers[4] = kid if kid
54
+ signing_sign1 = ::COSE::Sign1.new(
55
+ protected_headers: protected_headers, unprotected_headers: {}, payload: device_authentication_bytes
56
+ )
57
+ signing_sign1.sign(CoseKey.from_jwk(device_key))
58
+ signature = ::COSE::Sign1.new(
59
+ protected_headers: protected_headers, unprotected_headers: {},
60
+ payload: nil, signature: signing_sign1.signature
61
+ )
62
+
63
+ new(
64
+ name_spaces_bytes: tagged, name_spaces: device_name_spaces,
65
+ device_auth: { "deviceSignature" => signature }
66
+ )
67
+ end
68
+
69
+ private
70
+
71
+ def parse_device_auth(map)
72
+ device_auth = map.fetch("deviceAuth") { raise OpenID4VP::InvalidResponse, "mdoc: missing deviceAuth" }
73
+ raise OpenID4VP::InvalidResponse, "mdoc: invalid deviceAuth" unless device_auth.is_a?(::Hash)
74
+ raise OpenID4VP::PresentationInvalid, "DeviceMac not supported" if device_auth.key?("deviceMac")
75
+
76
+ raw_signature = device_auth.fetch("deviceSignature") do
77
+ raise OpenID4VP::InvalidResponse, "mdoc: missing deviceAuth.deviceSignature"
78
+ end
79
+ { "deviceSignature" => decode_sign1(raw_signature) }
80
+ end
81
+
82
+ def decode_sign1(raw)
83
+ Sign1Decoder.decode(raw, "deviceSignature")
84
+ end
85
+
86
+ def decode(bytes)
87
+ CBOR.decode(bytes)
88
+ rescue CBOR::MalformedFormatError, EOFError, TypeError
89
+ raise OpenID4VP::InvalidResponse, "mdoc: malformed DeviceNameSpaces"
90
+ end
91
+
92
+ def encode_name_spaces(name_spaces)
93
+ name_spaces.transform_values { |elements| elements.transform_values { |v| CborValues.encode(v) } }
94
+ end
95
+ end
96
+
97
+ def initialize(name_spaces_bytes:, name_spaces:, device_auth:)
98
+ @name_spaces_bytes = name_spaces_bytes
99
+ @name_spaces = name_spaces
100
+ @device_auth = device_auth
101
+ end
102
+
103
+ def to_map
104
+ {
105
+ "nameSpaces" => @name_spaces_bytes,
106
+ "deviceAuth" => { "deviceSignature" => @device_auth.fetch("deviceSignature").to_array }
107
+ }
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../mdoc"
4
+ require_relative "../errors"
5
+ require_relative "issuer_signed"
6
+ require_relative "device_signed"
7
+
8
+ module OpenID4VP
9
+ module Mdoc
10
+ # Document = {"docType" => tstr, "issuerSigned" => IssuerSigned,
11
+ # "deviceSigned" => DeviceSigned, ? "errors" => ...} (ISO 18013-5 §8.3.2.1.2.2).
12
+ class Document
13
+ attr_reader :doc_type, :issuer_signed, :device_signed, :errors
14
+
15
+ def self.parse(map)
16
+ raise OpenID4VP::InvalidResponse, "mdoc: invalid Document" unless map.is_a?(::Hash)
17
+
18
+ doc_type = map.fetch("docType") { raise OpenID4VP::InvalidResponse, "mdoc: missing docType" }
19
+ issuer_signed_map = map.fetch("issuerSigned") { raise OpenID4VP::InvalidResponse, "mdoc: missing issuerSigned" }
20
+ device_signed_map = map.fetch("deviceSigned") { raise OpenID4VP::InvalidResponse, "mdoc: missing deviceSigned" }
21
+
22
+ new(
23
+ doc_type: doc_type,
24
+ issuer_signed: IssuerSigned.parse(issuer_signed_map),
25
+ device_signed: DeviceSigned.parse(device_signed_map),
26
+ errors: map["errors"]
27
+ )
28
+ end
29
+
30
+ def initialize(doc_type:, issuer_signed:, device_signed:, errors: nil)
31
+ @doc_type = doc_type
32
+ @issuer_signed = issuer_signed
33
+ @device_signed = device_signed
34
+ @errors = errors
35
+ end
36
+
37
+ def to_map
38
+ map = { "docType" => doc_type, "issuerSigned" => issuer_signed.to_map, "deviceSigned" => device_signed.to_map }
39
+ map["errors"] = errors if errors
40
+ map
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../mdoc"
4
+ require_relative "../util"
5
+
6
+ module OpenID4VP
7
+ module Mdoc
8
+ # OpenID4VPHandover and OpenID4VPDCAPIHandover (OpenID4VP §B.2.6): the
9
+ # CBOR structures that bind an ISO mdoc DeviceResponse to an OpenID4VP
10
+ # request, for redirect-based and Digital Credentials API invocation
11
+ # respectively.
12
+ module Handover
13
+ module_function
14
+
15
+ # The OpenID4VPHandoverInfo array, CBOR-encoded (OpenID4VP §B.2.6.1).
16
+ # `client_id` may be a String or an object responding to `#to_s` (e.g.
17
+ # OpenID4VP::ClientId). `jwk_thumbprint`, when present, must be the raw
18
+ # (binary-encoded) 32-byte SHA-256 JWK thumbprint; nil encodes as CBOR
19
+ # null.
20
+ def info_bytes_openid4vp(client_id:, nonce:, response_uri:, jwk_thumbprint: nil)
21
+ CBOR.encode([client_id.to_s, nonce, jwk_thumbprint&.b, response_uri])
22
+ end
23
+
24
+ # The OpenID4VPHandover array: ["OpenID4VPHandover", sha256(info_bytes)].
25
+ def openid4vp(client_id:, nonce:, response_uri:, jwk_thumbprint: nil)
26
+ info = info_bytes_openid4vp(
27
+ client_id: client_id, nonce: nonce, response_uri: response_uri, jwk_thumbprint: jwk_thumbprint
28
+ )
29
+ ["OpenID4VPHandover", Util.sha256(info)]
30
+ end
31
+
32
+ # The OpenID4VPDCAPIHandoverInfo array, CBOR-encoded (OpenID4VP
33
+ # §B.2.6.2). `origin` MUST NOT be prefixed with `origin:`.
34
+ def info_bytes_dc_api(origin:, nonce:, jwk_thumbprint: nil)
35
+ CBOR.encode([origin, nonce, jwk_thumbprint&.b])
36
+ end
37
+
38
+ # The OpenID4VPDCAPIHandover array: ["OpenID4VPDCAPIHandover", sha256(info_bytes)].
39
+ def dc_api(origin:, nonce:, jwk_thumbprint: nil)
40
+ info = info_bytes_dc_api(origin: origin, nonce: nonce, jwk_thumbprint: jwk_thumbprint)
41
+ ["OpenID4VPDCAPIHandover", Util.sha256(info)]
42
+ end
43
+
44
+ # CBOR encoding of a handover array (as returned by `.openid4vp` or
45
+ # `.dc_api`).
46
+ def encode(handover_array)
47
+ CBOR.encode(handover_array)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cose"
4
+ require_relative "../mdoc"
5
+ require_relative "../errors"
6
+ require_relative "../x509"
7
+ require_relative "cbor_values"
8
+ require_relative "sign1_decoder"
9
+
10
+ module OpenID4VP
11
+ module Mdoc
12
+ # A single ISO 18013-5 §9.1.2.4 IssuerSignedItem: one disclosed data
13
+ # element from an mdoc namespace. The original IssuerSignedItemBytes
14
+ # (`#6.24(bstr .cbor IssuerSignedItem)`) are preserved verbatim in
15
+ # `#bytes` so that `#digest` recomputes exactly the bytes the issuer
16
+ # digested into the MSO's valueDigests, regardless of Ruby Hash
17
+ # re-encoding order.
18
+ class IssuerSignedItem
19
+ attr_reader :digest_id, :random, :element_identifier, :element_value, :bytes
20
+
21
+ class << self
22
+ # tagged: a CBOR::Tagged(24, bstr), as decoded from an IssuerSigned
23
+ # nameSpaces entry.
24
+ def from_tagged(tagged)
25
+ bytes = unwrap_tag24(tagged)
26
+ map = decode_item(bytes)
27
+
28
+ new(
29
+ digest_id: fetch!(map, "digestID"),
30
+ random: fetch!(map, "random"),
31
+ element_identifier: fetch!(map, "elementIdentifier"),
32
+ element_value: CborValues.decode(fetch!(map, "elementValue")),
33
+ bytes: bytes
34
+ )
35
+ end
36
+
37
+ # Builds a fresh item (used by Builder), encoding `element_value`
38
+ # immediately so `#bytes`/`#digest` are available. `element_value`
39
+ # may be a Date (encoded as full-date, tag 1004), a Time (encoded as
40
+ # tdate, tag 0), or any other CBOR-encodable value (kept as-is).
41
+ def build(digest_id:, random:, element_identifier:, element_value:)
42
+ map = {
43
+ "digestID" => digest_id, "random" => random,
44
+ "elementIdentifier" => element_identifier, "elementValue" => CborValues.encode(element_value)
45
+ }
46
+ new(
47
+ digest_id: digest_id, random: random, element_identifier: element_identifier,
48
+ element_value: element_value, bytes: CBOR.encode(map)
49
+ )
50
+ end
51
+
52
+ private
53
+
54
+ def unwrap_tag24(tagged)
55
+ unless tagged.is_a?(CBOR::Tagged) && tagged.tag == 24
56
+ raise OpenID4VP::InvalidResponse, "mdoc: expected IssuerSignedItemBytes (tag 24)"
57
+ end
58
+
59
+ tagged.value
60
+ end
61
+
62
+ def decode_item(bytes)
63
+ map = CBOR.decode(bytes)
64
+ raise OpenID4VP::InvalidResponse, "mdoc: invalid IssuerSignedItem" unless map.is_a?(::Hash)
65
+
66
+ map
67
+ rescue CBOR::MalformedFormatError, EOFError, TypeError
68
+ raise OpenID4VP::InvalidResponse, "mdoc: malformed IssuerSignedItem"
69
+ end
70
+
71
+ def fetch!(map, key)
72
+ map.fetch(key) { raise OpenID4VP::InvalidResponse, "mdoc: missing IssuerSignedItem.#{key}" }
73
+ end
74
+ end
75
+
76
+ def initialize(digest_id:, random:, element_identifier:, element_value:, bytes:)
77
+ @digest_id = digest_id
78
+ @random = random
79
+ @element_identifier = element_identifier
80
+ @element_value = element_value
81
+ @bytes = bytes.b
82
+ end
83
+
84
+ # SHA-256/384/512 digest of the tag-24 IssuerSignedItemBytes, INCLUDING
85
+ # the tag, over the exact original bytes.
86
+ def digest(alg)
87
+ OpenSSL::Digest.digest(alg, CBOR.encode(CBOR::Tagged.new(24, @bytes)))
88
+ end
89
+
90
+ def to_tagged
91
+ CBOR::Tagged.new(24, @bytes)
92
+ end
93
+ end
94
+
95
+ # IssuerSigned = {"nameSpaces" => {ns => [IssuerSignedItemBytes]}, "issuerAuth" => COSE_Sign1}
96
+ # (ISO 18013-5 §9.1.2.4).
97
+ class IssuerSigned
98
+ # COSE header label for the x5chain header (RFC 9360 §2).
99
+ LABEL_X5CHAIN = 33
100
+
101
+ attr_reader :name_spaces, :issuer_auth
102
+
103
+ class << self
104
+ def parse(map)
105
+ raise OpenID4VP::InvalidResponse, "mdoc: invalid IssuerSigned" unless map.is_a?(::Hash)
106
+
107
+ raw_name_spaces = map.fetch("nameSpaces") { raise OpenID4VP::InvalidResponse, "mdoc: missing nameSpaces" }
108
+ raise OpenID4VP::InvalidResponse, "mdoc: invalid nameSpaces" unless raw_name_spaces.is_a?(::Hash)
109
+
110
+ name_spaces = raw_name_spaces.transform_values do |items|
111
+ Array(items).map { |t| IssuerSignedItem.from_tagged(t) }
112
+ end
113
+ raw_issuer_auth = map.fetch("issuerAuth") { raise OpenID4VP::InvalidResponse, "mdoc: missing issuerAuth" }
114
+ issuer_auth = decode_sign1(raw_issuer_auth)
115
+
116
+ new(name_spaces: name_spaces, issuer_auth: issuer_auth)
117
+ end
118
+
119
+ private
120
+
121
+ # `raw` is the already-decoded issuerAuth value: either a plain
122
+ # 4-element Array (untagged COSE_Sign1, the common mdoc encoding) or
123
+ # a CBOR::Tagged(18, array).
124
+ def decode_sign1(raw)
125
+ Sign1Decoder.decode(raw, "issuerAuth")
126
+ end
127
+ end
128
+
129
+ def initialize(name_spaces:, issuer_auth:)
130
+ @name_spaces = name_spaces
131
+ @issuer_auth = issuer_auth
132
+ end
133
+
134
+ def mso
135
+ @mso ||= MSO.parse(@issuer_auth.payload)
136
+ end
137
+
138
+ # The certificate chain from header label 33 (protected header takes
139
+ # precedence over unprotected), as OpenSSL::X509::Certificate objects.
140
+ # A single byte string is one certificate; an array of byte strings is
141
+ # several, leaf first. Returns [] when there is no x5chain header.
142
+ def x5chain
143
+ raw = @issuer_auth.protected_headers[LABEL_X5CHAIN] || @issuer_auth.unprotected_headers[LABEL_X5CHAIN]
144
+ return [] if raw.nil?
145
+
146
+ (raw.is_a?(::Array) ? raw : [raw]).map { |der| OpenID4VP::X509.parse(der) }
147
+ end
148
+
149
+ def to_map
150
+ {
151
+ "nameSpaces" => @name_spaces.transform_values { |items| items.map(&:to_tagged) },
152
+ "issuerAuth" => @issuer_auth.to_array
153
+ }
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../mdoc"
5
+ require_relative "../errors"
6
+ require_relative "cose_key"
7
+
8
+ module OpenID4VP
9
+ module Mdoc
10
+ # The ISO 18013-5 §9.1.2.4 MobileSecurityObject (MSO): the IssuerAuth
11
+ # payload, binding each namespace's disclosed-element digests and the
12
+ # mdoc's device public key to a docType and validity window.
13
+ class MSO
14
+ DIGEST_ALGORITHMS = %w[SHA-256 SHA-384 SHA-512].freeze
15
+
16
+ attr_reader :version, :digest_algorithm, :value_digests, :device_key, :doc_type, :validity_info
17
+
18
+ class << self
19
+ # bytes_or_tagged: MobileSecurityObjectBytes (`#6.24(bstr .cbor MSO)`),
20
+ # either raw CBOR bytes (typically `IssuerAuth#payload`) or an
21
+ # already-decoded CBOR::Tagged(24, bstr).
22
+ def parse(bytes_or_tagged)
23
+ tagged = bytes_or_tagged.is_a?(CBOR::Tagged) ? bytes_or_tagged : decode(bytes_or_tagged)
24
+ unless tagged.is_a?(CBOR::Tagged) && tagged.tag == 24
25
+ raise OpenID4VP::InvalidResponse, "mdoc: expected MobileSecurityObjectBytes (tag 24)"
26
+ end
27
+
28
+ map = decode(tagged.value)
29
+ raise OpenID4VP::InvalidResponse, "mdoc: invalid MobileSecurityObject" unless map.is_a?(::Hash)
30
+
31
+ from_map(map)
32
+ end
33
+
34
+ # Builds MobileSecurityObjectBytes (tag-24 CBOR bytes), suitable as
35
+ # an IssuerAuth payload. `device_key` is a public
36
+ # OpenID4VP::JOSE::JWK.
37
+ def build(
38
+ doc_type:, digest_algorithm:, value_digests:, device_key:, valid_from:, valid_until:, signed: ::Time.now
39
+ )
40
+ unless DIGEST_ALGORITHMS.include?(digest_algorithm)
41
+ raise OpenID4VP::InvalidRequest, "mdoc: unsupported digestAlgorithm #{digest_algorithm.inspect}"
42
+ end
43
+
44
+ map = {
45
+ "version" => "1.0",
46
+ "digestAlgorithm" => digest_algorithm,
47
+ "valueDigests" => value_digests,
48
+ "deviceKeyInfo" => { "deviceKey" => CoseKey.from_jwk(device_key.to_public).map },
49
+ "docType" => doc_type,
50
+ "validityInfo" => {
51
+ "signed" => tdate(signed), "validFrom" => tdate(valid_from), "validUntil" => tdate(valid_until)
52
+ }
53
+ }
54
+ CBOR.encode(CBOR::Tagged.new(24, CBOR.encode(map)))
55
+ end
56
+
57
+ private
58
+
59
+ def from_map(map)
60
+ validity = fetch!(map, "validityInfo")
61
+ raise OpenID4VP::InvalidResponse, "mdoc: invalid validityInfo" unless validity.is_a?(::Hash)
62
+
63
+ device_key_info = fetch!(map, "deviceKeyInfo")
64
+ raise OpenID4VP::InvalidResponse, "mdoc: invalid deviceKeyInfo" unless device_key_info.is_a?(::Hash)
65
+
66
+ new(
67
+ version: fetch!(map, "version"),
68
+ digest_algorithm: validate_digest_algorithm!(fetch!(map, "digestAlgorithm")),
69
+ value_digests: fetch!(map, "valueDigests"),
70
+ device_key: parse_device_key(fetch!(device_key_info, "deviceKey")),
71
+ doc_type: fetch!(map, "docType"),
72
+ validity_info: {
73
+ "signed" => parse_tdate(fetch!(validity, "signed")),
74
+ "validFrom" => parse_tdate(fetch!(validity, "validFrom")),
75
+ "validUntil" => parse_tdate(fetch!(validity, "validUntil"))
76
+ }
77
+ )
78
+ end
79
+
80
+ def fetch!(map, key)
81
+ map.fetch(key) { raise OpenID4VP::InvalidResponse, "mdoc: missing MobileSecurityObject.#{key}" }
82
+ end
83
+
84
+ # map: a deviceKeyInfo.deviceKey COSE_Key map (Integer label =>
85
+ # value), as decoded from CBOR. Raises OpenID4VP::InvalidResponse for
86
+ # anything COSE::Key::EC2/OpenSSL rejects, rather than leaking a bare
87
+ # COSE::Error/RuntimeError/ArgumentError/OpenSSL error.
88
+ def parse_device_key(map)
89
+ CoseKey.to_jwk(::COSE::Key::EC2.from_map(map))
90
+ rescue ::COSE::Error, ::RuntimeError, ::ArgumentError, ::TypeError, ::OpenSSL::OpenSSLError => e
91
+ raise OpenID4VP::InvalidResponse, "mdoc: invalid deviceKey: #{e.message}"
92
+ end
93
+
94
+ # ISO 18013-5 §9.1.2.4: `digestAlgorithm` must be one of
95
+ # DIGEST_ALGORITHMS -- an unsupported value would otherwise only
96
+ # surface later, as a bare RuntimeError out of OpenSSL::Digest, the
97
+ # first time a digest is computed with it (`IssuerSignedItem#digest`).
98
+ def validate_digest_algorithm!(digest_algorithm)
99
+ return digest_algorithm if DIGEST_ALGORITHMS.include?(digest_algorithm)
100
+
101
+ raise OpenID4VP::PresentationInvalid, "mdoc: unsupported digestAlgorithm #{digest_algorithm.inspect}"
102
+ end
103
+
104
+ def decode(bytes)
105
+ CBOR.decode(bytes)
106
+ rescue CBOR::MalformedFormatError, EOFError, TypeError
107
+ raise OpenID4VP::InvalidResponse, "mdoc: malformed MobileSecurityObject"
108
+ end
109
+
110
+ def tdate(time)
111
+ CBOR::Tagged.new(0, time.utc.strftime("%Y-%m-%dT%H:%M:%SZ"))
112
+ end
113
+
114
+ def parse_tdate(tagged)
115
+ unless tagged.is_a?(CBOR::Tagged) && tagged.tag.zero?
116
+ raise OpenID4VP::InvalidResponse, "mdoc: expected tdate (tag 0)"
117
+ end
118
+
119
+ ::Time.parse(tagged.value).utc
120
+ end
121
+ end
122
+
123
+ def initialize(version:, digest_algorithm:, value_digests:, device_key:, doc_type:, validity_info:)
124
+ @version = version
125
+ @digest_algorithm = digest_algorithm
126
+ @value_digests = value_digests
127
+ @device_key = device_key
128
+ @doc_type = doc_type
129
+ @validity_info = validity_info
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../mdoc"
4
+ require_relative "handover"
5
+
6
+ module OpenID4VP
7
+ module Mdoc
8
+ # The ISO/IEC 18013-5 §9.1.5.1 SessionTranscript CBOR structure, as
9
+ # adapted by OpenID4VP §B.2.6 for OpenID4VP-invoked mdoc presentations:
10
+ # DeviceEngagementBytes and EReaderKeyBytes are always null, and Handover
11
+ # is an OpenID4VPHandover or OpenID4VPDCAPIHandover array (see
12
+ # OpenID4VP::Mdoc::Handover).
13
+ module SessionTranscript
14
+ module_function
15
+
16
+ # [nil, nil, handover], where `handover` is a Handover array as
17
+ # returned by `Handover.openid4vp` or `Handover.dc_api`.
18
+ def build(handover)
19
+ [nil, nil, handover]
20
+ end
21
+
22
+ # CBOR encoding of `build(handover)`.
23
+ def encode(handover)
24
+ CBOR.encode(build(handover))
25
+ end
26
+
27
+ # The SessionTranscript array for a redirect-based (OpenID4VPHandover)
28
+ # presentation. `encryption_jwk`, when the response is encrypted (e.g.
29
+ # response_mode `direct_post.jwt`), is the Verifier's public
30
+ # OpenID4VP::JOSE::JWK used to encrypt the response.
31
+ def for_request(client_id:, nonce:, response_uri:, encryption_jwk: nil)
32
+ handover = Handover.openid4vp(
33
+ client_id: client_id, nonce: nonce, response_uri: response_uri,
34
+ jwk_thumbprint: encryption_jwk&.thumbprint
35
+ )
36
+ build(handover)
37
+ end
38
+
39
+ # The SessionTranscript array for a Digital Credentials API
40
+ # (OpenID4VPDCAPIHandover) presentation. `encryption_jwk`, when the
41
+ # response is encrypted (response_mode `dc_api.jwt`), is the
42
+ # Verifier's public OpenID4VP::JOSE::JWK used to encrypt the response.
43
+ def for_dc_api(origin:, nonce:, encryption_jwk: nil)
44
+ handover = Handover.dc_api(origin: origin, nonce: nonce, jwk_thumbprint: encryption_jwk&.thumbprint)
45
+ build(handover)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cose"
4
+ require_relative "../mdoc"
5
+ require_relative "../errors"
6
+
7
+ module OpenID4VP
8
+ module Mdoc
9
+ # Decodes an already CBOR-decoded COSE_Sign1 value -- either a bare
10
+ # 4-element Array or a CBOR::Tagged(18, array), as found inside a parsed
11
+ # IssuerSigned.issuerAuth / DeviceSigned.deviceAuth.deviceSignature --
12
+ # into a ::COSE::Sign1. Validates the array's shape first (element
13
+ # count/types) so a malformed or adversarial structure (wrong element
14
+ # count, a non-map unprotected header, a non-string signature, a
15
+ # protected header bstr whose content decodes to deeply nested CBOR)
16
+ # raises OpenID4VP::InvalidResponse instead of a bare
17
+ # NoMethodError/CBOR::StackError/... out of cose-ruby, which only
18
+ # validates COSE_Sign1 shape loosely (it never checks that the
19
+ # unprotected header is actually a Hash, for example).
20
+ module Sign1Decoder
21
+ module_function
22
+
23
+ # raw: the already-decoded issuerAuth/deviceSignature value. label: a
24
+ # short description used in the error message (e.g. "issuerAuth",
25
+ # "deviceSignature").
26
+ def decode(raw, label)
27
+ array = validate!(unwrap(raw), label)
28
+ sign1 = ::COSE::Sign1.from_array(array)
29
+ raise OpenID4VP::InvalidResponse, "mdoc: malformed #{label}" unless sign1.protected_headers.is_a?(::Hash)
30
+
31
+ sign1
32
+ rescue CBOR::UnpackError, CBOR::TypeError, EOFError, ::TypeError, ::ArgumentError, ::NoMethodError,
33
+ ::COSE::Error => e
34
+ raise OpenID4VP::InvalidResponse, "mdoc: malformed #{label}: #{e.message}"
35
+ end
36
+
37
+ def unwrap(raw)
38
+ raw.is_a?(CBOR::Tagged) ? raw.value : raw
39
+ end
40
+
41
+ # array: the (possibly tag-18-unwrapped) COSE_Sign1 candidate. Requires
42
+ # exactly 4 elements: protected (a binary String, possibly empty),
43
+ # unprotected (a Hash), payload (a binary String or nil, for a
44
+ # detached-content COSE_Sign1 like mdoc DeviceSignature) and signature
45
+ # (a binary String). Returns `array` unchanged once validated.
46
+ def validate!(array, label)
47
+ raise OpenID4VP::InvalidResponse, "mdoc: malformed #{label}" unless array.is_a?(::Array) && array.size == 4
48
+
49
+ protected_bstr, unprotected, payload, signature = array
50
+ valid_shape =
51
+ protected_bstr.is_a?(::String) && unprotected.is_a?(::Hash) &&
52
+ (payload.nil? || payload.is_a?(::String)) && signature.is_a?(::String)
53
+
54
+ raise OpenID4VP::InvalidResponse, "mdoc: malformed #{label}" unless valid_shape
55
+
56
+ array
57
+ end
58
+ end
59
+ end
60
+ end