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,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jwt"
4
+ require "jwt/eddsa"
5
+
6
+ require_relative "../jose"
7
+ require_relative "../errors"
8
+ require_relative "../util/base64url"
9
+ require_relative "algorithms"
10
+
11
+ module OpenID4VP
12
+ module JOSE
13
+ # JSON Web Signature (RFC 7515): compact and (flattened-free) general
14
+ # JSON serialization, signing and verification.
15
+ module JWS
16
+ # A verified (or peeked, unverified) JWS. `payload` is the parsed JSON
17
+ # Hash when `payload_bytes` parses as a JSON object, otherwise nil.
18
+ # `compact` is nil for signatures produced from JSON serialization.
19
+ # rubocop:disable-next Lint/StructNewOverride -- :compact is the interface name, not Array's method.
20
+ Verified = Struct.new(:header, :payload, :payload_bytes, :compact, keyword_init: true)
21
+
22
+ # A JWS in general JSON serialization, parsed but not verified.
23
+ ParsedJSON = Struct.new(:payload_bytes, :payload, :signatures, keyword_init: true)
24
+
25
+ # One entry of a ParsedJSON's `signatures` array.
26
+ SignatureEntry = Struct.new(:protected_header, :signature_b64, :protected_b64, keyword_init: true)
27
+
28
+ class << self
29
+ # Signs `payload` (a Hash, JSON-encoded; or a String, used as raw
30
+ # payload bytes) with `key`, returning the compact serialization.
31
+ def sign(payload, key:, alg: nil, header: {})
32
+ alg ||= Algorithms.for_key(key)
33
+ protected_header = { "alg" => alg }.merge(header)
34
+ protected_b64 = Util::Base64url.encode_json(protected_header)
35
+ payload_b64 = Util::Base64url.encode(payload_bytes_for(payload))
36
+ signature = sign_raw(alg, key, "#{protected_b64}.#{payload_b64}")
37
+ [protected_b64, payload_b64, Util::Base64url.encode(signature)].join(".")
38
+ end
39
+
40
+ # Verifies `compact` against `key`, rejecting `alg: none` and any
41
+ # alg not in `algorithms` or not matching `key`.
42
+ def verify(compact, key:, algorithms: Algorithms::SIGNING)
43
+ protected_b64, payload_b64, = split_compact(compact)
44
+ header = decode_header(protected_b64)
45
+ alg = header["alg"]
46
+ check_alg!(alg, algorithms, key)
47
+
48
+ payload_bytes = Util::Base64url.decode(payload_b64)
49
+ verify_compact_signature!(compact, alg, key)
50
+
51
+ Verified.new(header: header, payload: parse_payload(payload_bytes), payload_bytes: payload_bytes,
52
+ compact: compact)
53
+ end
54
+
55
+ # Parses `compact` into header/payload WITHOUT verifying the
56
+ # signature. Raises InvalidRequest on malformed input.
57
+ def peek(compact)
58
+ protected_b64, payload_b64, = split_compact(compact)
59
+ header = decode_header(protected_b64)
60
+ payload_bytes = Util::Base64url.decode(payload_b64)
61
+ Verified.new(header: header, payload: parse_payload(payload_bytes), payload_bytes: payload_bytes,
62
+ compact: compact)
63
+ end
64
+
65
+ # Produces a JWS in general JSON serialization, signed by every
66
+ # entry of `signers` (each a Hash with `key:`, optional `alg:` and
67
+ # `header:`).
68
+ def sign_json(payload, signers:)
69
+ payload_b64 = Util::Base64url.encode(payload_bytes_for(payload))
70
+ signatures = signers.map { |signer| sign_json_entry(signer, payload_b64) }
71
+ { "payload" => payload_b64, "signatures" => signatures }
72
+ end
73
+
74
+ # Parses a JWS in general JSON serialization (a Hash, or a JSON
75
+ # String) into a ParsedJSON, WITHOUT verifying any signature.
76
+ def parse_json(hash_or_string)
77
+ hash = coerce_json(hash_or_string)
78
+ payload_b64 = hash["payload"]
79
+ raw_signatures = hash["signatures"]
80
+ unless payload_b64.is_a?(String) && raw_signatures.is_a?(Array) && !raw_signatures.empty?
81
+ raise OpenID4VP::InvalidRequest, "jws: malformed JSON serialization"
82
+ end
83
+
84
+ payload_bytes = Util::Base64url.decode(payload_b64)
85
+ ParsedJSON.new(payload_bytes: payload_bytes, payload: parse_payload(payload_bytes),
86
+ signatures: raw_signatures.map { |entry| build_signature_entry(entry) })
87
+ end
88
+
89
+ # Verifies the signature at `index` of a ParsedJSON against `key`.
90
+ def verify_json_signature(parsed, index, key:, algorithms: Algorithms::SIGNING)
91
+ entry = parsed.signatures[index]
92
+ raise OpenID4VP::InvalidRequest, "jws: no signature at index #{index}" unless entry
93
+
94
+ alg = entry.protected_header["alg"]
95
+ check_alg!(alg, algorithms, key)
96
+ payload_b64 = Util::Base64url.encode(parsed.payload_bytes)
97
+ signature = Util::Base64url.decode(entry.signature_b64)
98
+ signing_input = "#{entry.protected_b64}.#{payload_b64}"
99
+ unless verify_raw(alg, key, signing_input, signature)
100
+ raise OpenID4VP::VerificationError, "signature verification failed"
101
+ end
102
+
103
+ Verified.new(header: entry.protected_header, payload: parsed.payload, payload_bytes: parsed.payload_bytes,
104
+ compact: nil)
105
+ end
106
+
107
+ private
108
+
109
+ def sign_json_entry(signer, payload_b64)
110
+ key = signer.fetch(:key)
111
+ alg = signer[:alg] || Algorithms.for_key(key)
112
+ protected_header = { "alg" => alg }.merge(signer[:header] || {})
113
+ protected_b64 = Util::Base64url.encode_json(protected_header)
114
+ signature = sign_raw(alg, key, "#{protected_b64}.#{payload_b64}")
115
+ { "protected" => protected_b64, "signature" => Util::Base64url.encode(signature) }
116
+ end
117
+
118
+ def build_signature_entry(entry)
119
+ raise OpenID4VP::InvalidRequest, "jws: malformed signature entry" unless entry.is_a?(Hash)
120
+
121
+ protected_b64 = entry["protected"]
122
+ signature_b64 = entry["signature"]
123
+ unless protected_b64.is_a?(String) && signature_b64.is_a?(String)
124
+ raise OpenID4VP::InvalidRequest, "jws: malformed signature entry"
125
+ end
126
+
127
+ SignatureEntry.new(protected_header: decode_header(protected_b64), signature_b64: signature_b64,
128
+ protected_b64: protected_b64)
129
+ end
130
+
131
+ def coerce_json(hash_or_string)
132
+ hash = hash_or_string.is_a?(String) ? parse_json_string(hash_or_string) : hash_or_string
133
+ raise OpenID4VP::InvalidRequest, "jws: malformed JSON serialization" unless hash.is_a?(Hash)
134
+
135
+ hash
136
+ end
137
+
138
+ def parse_json_string(str)
139
+ ::JSON.parse(str)
140
+ rescue ::JSON::ParserError
141
+ raise OpenID4VP::InvalidRequest, "jws: invalid JSON"
142
+ end
143
+
144
+ def split_compact(compact)
145
+ parts = compact.to_s.split(".", -1)
146
+ raise OpenID4VP::InvalidRequest, "jws: malformed compact serialization" unless parts.size == 3
147
+
148
+ parts
149
+ end
150
+
151
+ def decode_header(protected_b64)
152
+ header = Util::Base64url.decode_json(protected_b64)
153
+ raise OpenID4VP::InvalidRequest, "jws: header is not a JSON object" unless header.is_a?(Hash)
154
+
155
+ header
156
+ end
157
+
158
+ def payload_bytes_for(payload)
159
+ payload.is_a?(String) ? payload.b : ::JSON.generate(payload).b
160
+ end
161
+
162
+ def parse_payload(bytes)
163
+ parsed = ::JSON.parse(bytes)
164
+ parsed.is_a?(Hash) ? parsed : nil
165
+ rescue ::JSON::ParserError, EncodingError
166
+ nil
167
+ end
168
+
169
+ def check_alg!(alg, algorithms, key)
170
+ raise OpenID4VP::VerificationError, "alg none rejected" if alg.nil? || alg == "none"
171
+ raise OpenID4VP::VerificationError, "algorithm not allowed" unless algorithms.include?(alg)
172
+ raise OpenID4VP::VerificationError, "algorithm does not match key" unless Algorithms.key_matches?(alg, key)
173
+ end
174
+
175
+ # Signs `signing_input` (the compact `protected_b64.payload_b64`)
176
+ # with `jwk`, via jwt's JWA registry (jwt-eddsa registers "EdDSA").
177
+ # jwt itself handles the ECDSA DER<->raw conversion and RSA-PSS.
178
+ def sign_raw(alg, jwk, signing_input)
179
+ jwa = ::JWT::JWA.resolve(alg)
180
+ jwa.sign(data: signing_input, signing_key: signing_key_for(alg, jwk))
181
+ rescue ::JWT::Error, ArgumentError, ::OpenSSL::PKey::PKeyError => e
182
+ raise OpenID4VP::InvalidRequest, "jws: #{e.message}"
183
+ end
184
+
185
+ def verify_raw(alg, jwk, signing_input, signature)
186
+ jwa = ::JWT::JWA.resolve(alg)
187
+ jwa.verify(data: signing_input, signature: signature, verification_key: verification_key_for(alg, jwk))
188
+ rescue ::JWT::Error, OpenSSL::PKey::PKeyError, ArgumentError
189
+ false
190
+ end
191
+
192
+ # Verifies `compact`'s signature via jwt's public JWT::EncodedToken
193
+ # API instead of the private JWT::JWA.resolve `verify_raw` uses
194
+ # (kept for `verify_json_signature`, whose per-signer signing input
195
+ # isn't a plain compact string EncodedToken can parse). `alg` has
196
+ # already been checked against our allow-list and against `key` by
197
+ # `check_alg!`, so only that single resolved alg is passed here.
198
+ # `EncodedToken#verify_signature!` only reads the header and the raw
199
+ # encoded payload bytes to build the signing input -- it never
200
+ # parses the payload as JSON (only claim verification/`#payload`
201
+ # would) -- so this is safe for both JSON-object and raw-string JWS
202
+ # payloads (e.g. SD-JWT's Issuer-signed JWT and KB-JWT bodies).
203
+ def verify_compact_signature!(compact, alg, key)
204
+ ::JWT::EncodedToken.new(compact).verify_signature!(algorithm: alg, key: verification_key_for(alg, key))
205
+ rescue ::JWT::VerificationError, ::JWT::DecodeError, ::ArgumentError, ::OpenSSL::OpenSSLError
206
+ raise OpenID4VP::VerificationError, "signature verification failed"
207
+ end
208
+
209
+ # jwt-eddsa's algorithm requires an Ed25519::SigningKey/VerifyKey
210
+ # (from the `ed25519` gem), not an OpenSSL::PKey.
211
+ def signing_key_for(alg, jwk)
212
+ return jwk.to_openssl unless alg == "EdDSA"
213
+
214
+ ::Ed25519::SigningKey.new(jwk.to_openssl.raw_private_key)
215
+ end
216
+
217
+ def verification_key_for(alg, jwk)
218
+ return jwk.to_openssl unless alg == "EdDSA"
219
+
220
+ ::Ed25519::VerifyKey.new(jwk.to_openssl.raw_public_key)
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenID4VP
4
+ # JSON Object Signing and Encryption (JOSE) primitives: JWK and JWKSet
5
+ # (this task), plus JWS/JWE in later tasks. Built on stdlib OpenSSL 3.
6
+ module JOSE
7
+ end
8
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "cose"
5
+ require_relative "../mdoc"
6
+ require_relative "../errors"
7
+ require_relative "../x509"
8
+ require_relative "cose_key"
9
+ require_relative "issuer_signed"
10
+ require_relative "mso"
11
+ require_relative "device_signed"
12
+ require_relative "document"
13
+ require_relative "device_response"
14
+
15
+ module OpenID4VP
16
+ module Mdoc
17
+ # Test/wallet-side mdoc issuance and presentation builder: issues an
18
+ # IssuerSigned credential (Builder.issue) and, given a set of disclosed
19
+ # elements, builds a signed DeviceResponse (Builder.device_response).
20
+ module Builder
21
+ RANDOM_SALT_BYTES = 16
22
+
23
+ module_function
24
+
25
+ # name_spaces: {namespace => {element_identifier => value}}, where
26
+ # value may be a Date (encoded as full-date), a Time (encoded as
27
+ # tdate) or any other CBOR-encodable value. issuer_key: the issuer's
28
+ # private OpenID4VP::JOSE::JWK. issuer_chain: [OpenSSL::X509::Certificate,
29
+ # ...] (leaf first) embedded as IssuerAuth's x5chain. device_key: the
30
+ # mdoc holder's OpenID4VP::JOSE::JWK (its public part is bound into the
31
+ # MSO's deviceKeyInfo).
32
+ def issue(
33
+ doc_type:, name_spaces:, issuer_key:, issuer_chain:, device_key:,
34
+ valid_from:, valid_until:, digest_alg: "SHA-256", alg: -7, signed: ::Time.now, kid: nil
35
+ )
36
+ items_by_namespace = build_items(name_spaces)
37
+ value_digests = digests_for(items_by_namespace, digest_alg)
38
+
39
+ CoseKey.check_alg_matches_key!(alg, issuer_key)
40
+
41
+ mso_bytes = MSO.build(
42
+ doc_type: doc_type, digest_algorithm: digest_alg, value_digests: value_digests,
43
+ device_key: device_key, valid_from: valid_from, valid_until: valid_until, signed: signed
44
+ )
45
+ protected_headers = { 1 => alg }
46
+ protected_headers[4] = kid if kid
47
+ issuer_auth = ::COSE::Sign1.new(
48
+ protected_headers: protected_headers,
49
+ unprotected_headers: { IssuerSigned::LABEL_X5CHAIN => x5chain_header(issuer_chain) },
50
+ payload: mso_bytes
51
+ )
52
+ issuer_auth.sign(CoseKey.from_jwk(issuer_key))
53
+
54
+ IssuerSigned.new(name_spaces: items_by_namespace, issuer_auth: issuer_auth)
55
+ end
56
+
57
+ # issuer_signed: an IssuerSigned as returned by `.issue`. disclose:
58
+ # {namespace => [element_identifier, ...]}, the elements the holder
59
+ # chooses to reveal. device_key: the mdoc holder's private
60
+ # OpenID4VP::JOSE::JWK (must match the public key bound in the MSO).
61
+ # session_transcript: the SessionTranscript array (e.g. from
62
+ # SessionTranscript.for_request). device_name_spaces: elements
63
+ # authenticated by, but not part of, the issuer-signed credential
64
+ # (rarely used; defaults to none).
65
+ def device_response(
66
+ issuer_signed:, doc_type:, disclose:, device_key:, session_transcript:, device_name_spaces: {}, alg: -7,
67
+ device_kid: nil
68
+ )
69
+ disclosed_issuer_signed = IssuerSigned.new(
70
+ name_spaces: filter_disclosed(issuer_signed.name_spaces, disclose), issuer_auth: issuer_signed.issuer_auth
71
+ )
72
+ device_signed = DeviceSigned.build(
73
+ device_name_spaces: device_name_spaces, device_key: device_key,
74
+ session_transcript: session_transcript, doc_type: doc_type, alg: alg, kid: device_kid
75
+ )
76
+ document = Document.new(
77
+ doc_type: doc_type, issuer_signed: disclosed_issuer_signed, device_signed: device_signed
78
+ )
79
+
80
+ DeviceResponse.new(version: "1.0", documents: [document], status: 0)
81
+ end
82
+
83
+ def build_items(name_spaces)
84
+ name_spaces.to_h do |namespace, elements|
85
+ used_ids = {}
86
+ items = elements.map do |element_identifier, element_value|
87
+ IssuerSignedItem.build(
88
+ digest_id: unique_digest_id(used_ids), random: ::SecureRandom.random_bytes(RANDOM_SALT_BYTES),
89
+ element_identifier: element_identifier.to_s, element_value: element_value
90
+ )
91
+ end
92
+ [namespace, items]
93
+ end
94
+ end
95
+ private_class_method :build_items
96
+
97
+ def unique_digest_id(used_ids)
98
+ loop do
99
+ candidate = ::SecureRandom.random_number(1_000_000)
100
+ next if used_ids.key?(candidate)
101
+
102
+ used_ids[candidate] = true
103
+ return candidate
104
+ end
105
+ end
106
+ private_class_method :unique_digest_id
107
+
108
+ def digests_for(items_by_namespace, digest_alg)
109
+ items_by_namespace.transform_values do |items|
110
+ items.to_h { |item| [item.digest_id, item.digest(digest_alg)] }
111
+ end
112
+ end
113
+ private_class_method :digests_for
114
+
115
+ def x5chain_header(chain)
116
+ ders = Array(chain).map { |cert| OpenID4VP::X509.parse(cert).to_der.b }
117
+ ders.length == 1 ? ders.first : ders
118
+ end
119
+ private_class_method :x5chain_header
120
+
121
+ def filter_disclosed(name_spaces, disclose)
122
+ disclose.to_h do |namespace, element_identifiers|
123
+ items = name_spaces.fetch(namespace) do
124
+ raise OpenID4VP::InvalidRequest, "mdoc: unknown namespace #{namespace.inspect}"
125
+ end
126
+ [namespace, items.select { |item| element_identifiers.include?(item.element_identifier) }]
127
+ end
128
+ end
129
+ private_class_method :filter_disclosed
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "../mdoc"
5
+
6
+ module OpenID4VP
7
+ module Mdoc
8
+ # Shared CBOR elementValue encode/decode helpers for mdoc namespace
9
+ # elements (ISO 18013-5 §7.2.1 date/time types), used by both
10
+ # IssuerSignedItem (issuerSigned nameSpaces) and DeviceSigned
11
+ # (deviceSigned nameSpaces): a Ruby Date encodes as full-date (tag 1004),
12
+ # a Time encodes as tdate (tag 0); any other value is CBOR-encodable
13
+ # as-is. Both tag forms decode back to their raw String (never a
14
+ # Date/Time instance), matching how ISO 18013-5 element values are
15
+ # exposed in `claims`.
16
+ module CborValues
17
+ module_function
18
+
19
+ def encode(value)
20
+ case value
21
+ when ::Date then CBOR::Tagged.new(1004, value.strftime("%Y-%m-%d"))
22
+ when ::Time then CBOR::Tagged.new(0, value.utc.strftime("%Y-%m-%dT%H:%M:%SZ"))
23
+ else value
24
+ end
25
+ end
26
+
27
+ def decode(value)
28
+ value.is_a?(CBOR::Tagged) ? value.value : value
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cose"
4
+ require_relative "../mdoc"
5
+ require_relative "../errors"
6
+ require_relative "../jose/jwk"
7
+
8
+ module OpenID4VP
9
+ module Mdoc
10
+ # OpenID4VP::JOSE::JWK <-> COSE::Key::EC2 (the `cose` gem) conversions,
11
+ # used internally by MSO (deviceKeyInfo.deviceKey), Builder and Verifier
12
+ # to bridge this gem's JWK-based key representation with cose-ruby's
13
+ # COSE_Sign1/COSE_Key API. EC keys only (the only kty used by ISO mdoc
14
+ # COSE_Sign1 structures).
15
+ module CoseKey
16
+ # COSE alg identifier (protected header label 1 value) -> expected JOSE
17
+ # EC curve name, for the ECDSA algs ISO mdoc COSE_Sign1 structures use.
18
+ CURVE_FOR_ALG = { -7 => "P-256", -35 => "P-384", -36 => "P-521" }.freeze
19
+
20
+ module_function
21
+
22
+ # jwk: an OpenID4VP::JOSE::JWK with kty "EC" (private or public).
23
+ # Raises OpenID4VP::InvalidRequest (not a bare NoMethodError/
24
+ # RuntimeError out of COSE::Key::EC2.from_pkey) for a non-EC key.
25
+ def from_jwk(jwk)
26
+ ::COSE::Key::EC2.from_pkey(jwk.to_openssl)
27
+ rescue ::NoMethodError, ::RuntimeError
28
+ raise OpenID4VP::InvalidRequest, "mdoc requires an EC key"
29
+ end
30
+
31
+ # cose_key: a ::COSE::Key::EC2 (public or private).
32
+ def to_jwk(cose_key)
33
+ OpenID4VP::JOSE::JWK.from_openssl(cose_key.to_pkey)
34
+ end
35
+
36
+ # Rebuilds `cose_key` (a ::COSE::Key::EC2) carrying `kid` (may be nil),
37
+ # preserving its key material and other COSE::Key::Base attributes
38
+ # (alg, key_ops, base_iv). Used at verification time so the
39
+ # verification key's kid always matches the signed message's own kid
40
+ # header (label 4): cose-ruby's COSE::Sign1#verify requires
41
+ # `key.kid == headers.kid`, but our verification keys are derived
42
+ # independently of any header value -- from a chain-validated X.509
43
+ # leaf certificate (IssuerAuth) or an already-verified MSO
44
+ # deviceKeyInfo (DeviceSignature) -- so leaving kid unset would
45
+ # incorrectly reject a spec-valid IssuerAuth/DeviceSignature that
46
+ # happens to carry a kid header.
47
+ def with_kid(cose_key, kid)
48
+ return cose_key if cose_key.kid == kid
49
+
50
+ ::COSE::Key::EC2.new(
51
+ crv: cose_key.crv, x: cose_key.x, y: cose_key.y, d: cose_key.d, kid: kid,
52
+ alg: cose_key.alg, key_ops: cose_key.key_ops, base_iv: cose_key.base_iv
53
+ )
54
+ end
55
+
56
+ # Raises OpenID4VP::InvalidRequest unless `jwk` is an EC key on the
57
+ # curve COSE alg `alg` expects (e.g. alg -7/ES256 requires a P-256
58
+ # key), so a mismatched alg/key pairing is rejected before signing
59
+ # rather than either silently producing a spec-violating signature or
60
+ # failing later, more confusingly, on verification.
61
+ def check_alg_matches_key!(alg, jwk)
62
+ expected_curve = CURVE_FOR_ALG.fetch(alg) { raise OpenID4VP::InvalidRequest, "mdoc: unsupported alg #{alg.inspect}" }
63
+ return if jwk.kty == "EC" && jwk.crv == expected_curve
64
+
65
+ raise OpenID4VP::InvalidRequest,
66
+ "mdoc: alg #{alg.inspect} requires an EC key on curve #{expected_curve.inspect}, " \
67
+ "got kty #{jwk.kty.inspect} crv #{jwk.crv.inspect}"
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../mdoc"
4
+
5
+ module OpenID4VP
6
+ module Mdoc
7
+ # DeviceAuthenticationBytes (ISO 18013-5 §9.1.3.4): the payload covered
8
+ # by a DeviceSignature/DeviceMac, binding the SessionTranscript, docType
9
+ # and disclosed DeviceSigned nameSpaces together.
10
+ #
11
+ # DeviceAuthentication = [
12
+ # "DeviceAuthentication", SessionTranscript, DocType, DeviceNameSpacesBytes
13
+ # ]
14
+ # DeviceAuthenticationBytes = #6.24(bstr .cbor DeviceAuthentication)
15
+ #
16
+ # Used identically by both DeviceSigned.build (to compute the bytes to
17
+ # sign) and OpenID4VP::Mdoc::Verifier (to recompute the same bytes as the
18
+ # detached payload to verify against).
19
+ module DeviceAuthentication
20
+ module_function
21
+
22
+ # session_transcript: the SessionTranscript array (e.g. from
23
+ # SessionTranscript.for_request). name_spaces_bytes: the
24
+ # DeviceNameSpacesBytes value itself (a CBOR::Tagged(24, ...)), as
25
+ # exposed by DeviceSigned#name_spaces_bytes.
26
+ def bytes(session_transcript:, doc_type:, name_spaces_bytes:)
27
+ array = ["DeviceAuthentication", session_transcript, doc_type, name_spaces_bytes]
28
+ CBOR.encode(CBOR::Tagged.new(24, CBOR.encode(array)))
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../mdoc"
4
+ require_relative "../errors"
5
+ require_relative "../util/base64url"
6
+ require_relative "document"
7
+
8
+ module OpenID4VP
9
+ module Mdoc
10
+ # DeviceResponse = {"version" => "1.0", "documents" => [Document],
11
+ # "status" => 0} (ISO 18013-5 §8.3.2.1.2.2), the CBOR structure carried
12
+ # base64url-encoded in a `mso_mdoc` `vp_token` entry (OpenID4VP §B.2.5).
13
+ class DeviceResponse
14
+ DEFAULT_VERSION = "1.0"
15
+
16
+ attr_reader :version, :documents, :status
17
+
18
+ # A base64url string only ever contains these characters; raw CBOR
19
+ # bytes for any non-trivial DeviceResponse (digests, signatures, ...)
20
+ # are effectively guaranteed to contain a byte outside this set.
21
+ B64URL_CONTENT = /\A[A-Za-z0-9_-]+\z/
22
+
23
+ class << self
24
+ # bytes: raw CBOR bytes (a binary String). Tolerant: a String whose
25
+ # content matches base64url's charset is treated as base64url (e.g.
26
+ # a vp_token entry passed here directly), so this also accepts what
27
+ # `parse_b64url` accepts. Prefer `parse_b64url` when the input is
28
+ # known to be base64url-encoded.
29
+ def parse(bytes)
30
+ build(decode(looks_like_b64url?(bytes) ? Util::Base64url.decode(bytes) : bytes))
31
+ end
32
+
33
+ # str: a base64url-encoded String (as received in a vp_token entry).
34
+ def parse_b64url(str)
35
+ build(decode(Util::Base64url.decode(str)))
36
+ end
37
+
38
+ def encode(documents, version: DEFAULT_VERSION, status: 0)
39
+ CBOR.encode({ "version" => version, "documents" => documents.map(&:to_map), "status" => status })
40
+ end
41
+
42
+ private
43
+
44
+ def looks_like_b64url?(input)
45
+ input.is_a?(::String) && B64URL_CONTENT.match?(input)
46
+ end
47
+
48
+ def build(map)
49
+ raise OpenID4VP::InvalidResponse, "mdoc: invalid DeviceResponse" unless map.is_a?(::Hash)
50
+
51
+ documents = Array(map["documents"]).map { |d| Document.parse(d) }
52
+ new(
53
+ version: map.fetch("version") { raise OpenID4VP::InvalidResponse, "mdoc: missing version" },
54
+ documents: documents,
55
+ status: map.fetch("status") { raise OpenID4VP::InvalidResponse, "mdoc: missing status" }
56
+ )
57
+ end
58
+
59
+ def decode(bytes)
60
+ CBOR.decode(bytes)
61
+ rescue CBOR::MalformedFormatError, EOFError, TypeError
62
+ raise OpenID4VP::InvalidResponse, "mdoc: malformed DeviceResponse"
63
+ end
64
+ end
65
+
66
+ def initialize(version:, documents:, status:)
67
+ @version = version
68
+ @documents = documents
69
+ @status = status
70
+ end
71
+
72
+ def to_b64url
73
+ Util::Base64url.encode(self.class.encode(documents, version: version, status: status))
74
+ end
75
+ end
76
+ end
77
+ end