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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +39 -0
- data/LICENSE.txt +21 -0
- data/README.md +278 -0
- data/lib/openid4vp/authorization_request/validator.rb +177 -0
- data/lib/openid4vp/authorization_request.rb +302 -0
- data/lib/openid4vp/authorization_response.rb +151 -0
- data/lib/openid4vp/client_id.rb +90 -0
- data/lib/openid4vp/credential.rb +51 -0
- data/lib/openid4vp/dc_api.rb +116 -0
- data/lib/openid4vp/dcql/claims_path_pointer.rb +109 -0
- data/lib/openid4vp/dcql/claims_query.rb +76 -0
- data/lib/openid4vp/dcql/credential_query.rb +178 -0
- data/lib/openid4vp/dcql/credential_set_query.rb +49 -0
- data/lib/openid4vp/dcql/evaluator.rb +228 -0
- data/lib/openid4vp/dcql/matcher.rb +79 -0
- data/lib/openid4vp/dcql/meta.rb +99 -0
- data/lib/openid4vp/dcql/query.rb +93 -0
- data/lib/openid4vp/dcql/selection.rb +45 -0
- data/lib/openid4vp/dcql/trusted_authorities_query.rb +48 -0
- data/lib/openid4vp/dcql.rb +10 -0
- data/lib/openid4vp/encoded_response.rb +10 -0
- data/lib/openid4vp/error_response.rb +82 -0
- data/lib/openid4vp/errors.rb +73 -0
- data/lib/openid4vp/formats/base.rb +74 -0
- data/lib/openid4vp/formats/context.rb +56 -0
- data/lib/openid4vp/formats/jwt_vc_json.rb +274 -0
- data/lib/openid4vp/formats/ldp_vc.rb +160 -0
- data/lib/openid4vp/formats/mso_mdoc.rb +204 -0
- data/lib/openid4vp/formats/registry.rb +53 -0
- data/lib/openid4vp/formats/sd_jwt_vc.rb +132 -0
- data/lib/openid4vp/formats/verified_presentation.rb +45 -0
- data/lib/openid4vp/formats.rb +12 -0
- data/lib/openid4vp/jose/algorithms.rb +46 -0
- data/lib/openid4vp/jose/jwe.rb +184 -0
- data/lib/openid4vp/jose/jwk.rb +253 -0
- data/lib/openid4vp/jose/jwk_set.rb +57 -0
- data/lib/openid4vp/jose/jws.rb +225 -0
- data/lib/openid4vp/jose.rb +8 -0
- data/lib/openid4vp/mdoc/builder.rb +132 -0
- data/lib/openid4vp/mdoc/cbor_values.rb +32 -0
- data/lib/openid4vp/mdoc/cose_key.rb +71 -0
- data/lib/openid4vp/mdoc/device_authentication.rb +32 -0
- data/lib/openid4vp/mdoc/device_response.rb +77 -0
- data/lib/openid4vp/mdoc/device_signed.rb +111 -0
- data/lib/openid4vp/mdoc/document.rb +44 -0
- data/lib/openid4vp/mdoc/handover.rb +51 -0
- data/lib/openid4vp/mdoc/issuer_signed.rb +157 -0
- data/lib/openid4vp/mdoc/mso.rb +133 -0
- data/lib/openid4vp/mdoc/session_transcript.rb +49 -0
- data/lib/openid4vp/mdoc/sign1_decoder.rb +60 -0
- data/lib/openid4vp/mdoc/verifier.rb +169 -0
- data/lib/openid4vp/mdoc.rb +9 -0
- data/lib/openid4vp/metadata/verifier_metadata.rb +109 -0
- data/lib/openid4vp/metadata/vp_formats.rb +112 -0
- data/lib/openid4vp/metadata/wallet_metadata.rb +139 -0
- data/lib/openid4vp/metadata.rb +10 -0
- data/lib/openid4vp/request_object.rb +152 -0
- data/lib/openid4vp/request_verification/base.rb +63 -0
- data/lib/openid4vp/request_verification/config.rb +65 -0
- data/lib/openid4vp/request_verification/decentralized_identifier.rb +56 -0
- data/lib/openid4vp/request_verification/openid_federation.rb +52 -0
- data/lib/openid4vp/request_verification/pre_registered.rb +54 -0
- data/lib/openid4vp/request_verification/redirect_uri.rb +29 -0
- data/lib/openid4vp/request_verification/resolver.rb +76 -0
- data/lib/openid4vp/request_verification/verified_request.rb +12 -0
- data/lib/openid4vp/request_verification/verifier_attestation.rb +54 -0
- data/lib/openid4vp/request_verification/x509_hash.rb +40 -0
- data/lib/openid4vp/request_verification/x509_san_dns.rb +60 -0
- data/lib/openid4vp/request_verification.rb +10 -0
- data/lib/openid4vp/response_encryption.rb +51 -0
- data/lib/openid4vp/response_endpoint_result.rb +33 -0
- data/lib/openid4vp/response_mode.rb +44 -0
- data/lib/openid4vp/response_type.rb +7 -0
- data/lib/openid4vp/sd_jwt/digest.rb +30 -0
- data/lib/openid4vp/sd_jwt/disclosure.rb +104 -0
- data/lib/openid4vp/sd_jwt/issuer.rb +132 -0
- data/lib/openid4vp/sd_jwt/kb_jwt.rb +82 -0
- data/lib/openid4vp/sd_jwt/parser.rb +48 -0
- data/lib/openid4vp/sd_jwt/presentation.rb +333 -0
- data/lib/openid4vp/sd_jwt/reconstruct.rb +132 -0
- data/lib/openid4vp/sd_jwt.rb +10 -0
- data/lib/openid4vp/transaction_data/registry.rb +42 -0
- data/lib/openid4vp/transaction_data/sd_jwt_hashes.rb +124 -0
- data/lib/openid4vp/transaction_data.rb +138 -0
- data/lib/openid4vp/util/base64url.rb +44 -0
- data/lib/openid4vp/util/form.rb +24 -0
- data/lib/openid4vp/util/random.rb +29 -0
- data/lib/openid4vp/util/uri.rb +48 -0
- data/lib/openid4vp/util.rb +23 -0
- data/lib/openid4vp/verifier/config.rb +216 -0
- data/lib/openid4vp/verifier/created_request.rb +25 -0
- data/lib/openid4vp/verifier/http_result.rb +34 -0
- data/lib/openid4vp/verifier/memory_session_store.rb +102 -0
- data/lib/openid4vp/verifier/request_builder.rb +216 -0
- data/lib/openid4vp/verifier/request_signer.rb +59 -0
- data/lib/openid4vp/verifier/request_uri_endpoint.rb +151 -0
- data/lib/openid4vp/verifier/response_endpoint.rb +197 -0
- data/lib/openid4vp/verifier/response_validator.rb +258 -0
- data/lib/openid4vp/verifier/session.rb +109 -0
- data/lib/openid4vp/verifier/session_store.rb +48 -0
- data/lib/openid4vp/verifier/verified_response.rb +53 -0
- data/lib/openid4vp/verifier.rb +155 -0
- data/lib/openid4vp/verifier_attestation.rb +130 -0
- data/lib/openid4vp/verifier_info.rb +102 -0
- data/lib/openid4vp/version.rb +5 -0
- data/lib/openid4vp/vp_token.rb +81 -0
- data/lib/openid4vp/wallet/config.rb +49 -0
- data/lib/openid4vp/wallet/http_client.rb +73 -0
- data/lib/openid4vp/wallet/presenter.rb +90 -0
- data/lib/openid4vp/wallet/processed_request.rb +14 -0
- data/lib/openid4vp/wallet/request_processor.rb +237 -0
- data/lib/openid4vp/wallet/response_builder.rb +44 -0
- data/lib/openid4vp/wallet/submission.rb +68 -0
- data/lib/openid4vp/wallet.rb +142 -0
- data/lib/openid4vp/x509/chain_validator.rb +42 -0
- data/lib/openid4vp/x509.rb +103 -0
- data/lib/openid4vp.rb +18 -0
- 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,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
|