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,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jose"
|
|
4
|
+
|
|
5
|
+
require_relative "../jose"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
require_relative "../util"
|
|
8
|
+
require_relative "../util/base64url"
|
|
9
|
+
require_relative "algorithms"
|
|
10
|
+
require_relative "jwk"
|
|
11
|
+
|
|
12
|
+
module OpenID4VP
|
|
13
|
+
module JOSE
|
|
14
|
+
# JSON Web Encryption (RFC 7516), compact serialization, restricted to
|
|
15
|
+
# the ECDH-ES key management family (RFC 7518 SS4.6) used for OpenID4VP
|
|
16
|
+
# SS8.3 encrypted Authorization Responses. A thin adapter over
|
|
17
|
+
# `JOSE::JWE` (the `jose` gem), which does the actual ECDH key
|
|
18
|
+
# agreement, Concat KDF, key wrapping and content encryption.
|
|
19
|
+
module JWE
|
|
20
|
+
# A decrypted JWE. `payload` is the parsed JSON Hash when `plaintext`
|
|
21
|
+
# parses as a JSON object, otherwise nil. `key` is the JWK used.
|
|
22
|
+
Decrypted = Struct.new(:header, :plaintext, :payload, :key, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
# `jose`'s ECDH-ES key agreement for OKP (X25519/X448) recipients goes
|
|
25
|
+
# through JOSE::JWA::Curve25519/448, whose only implementation that
|
|
26
|
+
# ships without an extra runtime gem dependency (rbnacl/x25519) is the
|
|
27
|
+
# pure-Ruby fallback -- WITHOUT `rbnacl` or `x25519` installed, that
|
|
28
|
+
# fallback is NOT constant-time (a timing side-channel risk for X25519/
|
|
29
|
+
# X448 key agreement specifically). EC (P-256/384/521) recipients --
|
|
30
|
+
# the default and most common case -- go through OpenSSL and are
|
|
31
|
+
# unaffected. See README "Cryptography".
|
|
32
|
+
::JOSE.crypto_fallback = true
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# Encrypts `payload` (a Hash, JSON-encoded; or a String, used as raw
|
|
36
|
+
# plaintext bytes) to `recipient`'s public key, returning the
|
|
37
|
+
# compact serialization.
|
|
38
|
+
def encrypt(payload, recipient:, alg: nil, enc: "A128GCM", header: {}, apu: nil, apv: nil)
|
|
39
|
+
alg ||= recipient.alg || "ECDH-ES"
|
|
40
|
+
validate_algorithms!(alg, enc)
|
|
41
|
+
|
|
42
|
+
ephemeral = ephemeral_key_for(recipient)
|
|
43
|
+
protected_header = build_header(alg, enc, header, recipient, ephemeral, apu, apv)
|
|
44
|
+
|
|
45
|
+
::JOSE::JWE.block_encrypt(
|
|
46
|
+
[jose_jwk(recipient.to_public), jose_jwk(ephemeral)], plaintext_bytes(payload), protected_header
|
|
47
|
+
).compact
|
|
48
|
+
rescue ArgumentError, OpenSSL::OpenSSLError => e
|
|
49
|
+
raise OpenID4VP::InvalidRequest, "jwe: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Decrypts `compact` using the first of `keys` that both matches the
|
|
53
|
+
# header (by `kid`, or by `epk`'s `crv` when no `kid` is present) and
|
|
54
|
+
# successfully decrypts. Raises OpenID4VP::VerificationError when no
|
|
55
|
+
# key succeeds, and OpenID4VP::InvalidRequest on malformed input or
|
|
56
|
+
# an unsupported/unknown alg or enc.
|
|
57
|
+
def decrypt(compact, keys:)
|
|
58
|
+
protected_b64, = split_compact(compact)
|
|
59
|
+
header = decode_header(protected_b64)
|
|
60
|
+
validate_algorithms!(header["alg"], header["enc"])
|
|
61
|
+
epk = build_epk(header)
|
|
62
|
+
|
|
63
|
+
try_candidates(candidate_keys(keys, header, epk)) do |key|
|
|
64
|
+
plaintext, = ::JOSE::JWE.block_decrypt([jose_jwk(epk), jose_jwk(key)], compact)
|
|
65
|
+
Decrypted.new(header: header, plaintext: plaintext, payload: parse_payload(plaintext), key: key)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Parses `compact` into its protected header WITHOUT decrypting.
|
|
70
|
+
# Raises OpenID4VP::InvalidRequest on malformed input.
|
|
71
|
+
def peek(compact)
|
|
72
|
+
protected_b64, = split_compact(compact)
|
|
73
|
+
decode_header(protected_b64)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# -- shared ---------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def jose_jwk(our_jwk)
|
|
81
|
+
::JOSE::JWK.from_map(our_jwk.to_h)
|
|
82
|
+
rescue ArgumentError => e
|
|
83
|
+
raise OpenID4VP::InvalidRequest, "jwe: #{e.message}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# -- encrypt helpers ---------------------------------------------
|
|
87
|
+
|
|
88
|
+
def validate_algorithms!(alg, enc)
|
|
89
|
+
unless alg.is_a?(String) && Algorithms::KEY_MANAGEMENT.include?(alg)
|
|
90
|
+
raise OpenID4VP::InvalidRequest, "jwe: unsupported alg #{alg.inspect}"
|
|
91
|
+
end
|
|
92
|
+
unless enc.is_a?(String) && Algorithms::CONTENT_ENCRYPTION.include?(enc)
|
|
93
|
+
raise OpenID4VP::InvalidRequest, "jwe: unsupported enc #{enc.inspect}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
[alg, enc]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def ephemeral_key_for(recipient)
|
|
100
|
+
case recipient.kty
|
|
101
|
+
when "EC" then JWK.generate(:ec, crv: recipient.crv)
|
|
102
|
+
when "OKP" then JWK.generate(:okp, crv: recipient.crv)
|
|
103
|
+
else raise OpenID4VP::InvalidRequest, "jwe: unsupported recipient kty #{recipient.kty.inspect}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def build_header(alg, enc, header, recipient, ephemeral, apu, apv)
|
|
108
|
+
protected_header = { "alg" => alg, "enc" => enc }.merge(header)
|
|
109
|
+
protected_header["kid"] = recipient.kid if recipient.kid
|
|
110
|
+
protected_header["epk"] = ephemeral.to_public.to_h
|
|
111
|
+
protected_header["apu"] = Util::Base64url.encode(apu) if apu
|
|
112
|
+
protected_header["apv"] = Util::Base64url.encode(apv) if apv
|
|
113
|
+
protected_header
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def plaintext_bytes(payload)
|
|
117
|
+
payload.is_a?(String) ? payload.b : ::JSON.generate(payload).b
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# -- decrypt helpers ----------------------------------------------
|
|
121
|
+
|
|
122
|
+
def split_compact(compact)
|
|
123
|
+
parts = compact.to_s.split(".", -1)
|
|
124
|
+
raise OpenID4VP::InvalidRequest, "jwe: malformed compact serialization" unless parts.size == 5
|
|
125
|
+
|
|
126
|
+
parts
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def decode_header(protected_b64)
|
|
130
|
+
header = Util::Base64url.decode_json(protected_b64)
|
|
131
|
+
raise OpenID4VP::InvalidRequest, "jwe: header is not a JSON object" unless header.is_a?(Hash)
|
|
132
|
+
|
|
133
|
+
header
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Strips any private member (e.g. "d") from the header's epk before
|
|
137
|
+
# it is used for ECDH key agreement: epk is attacker-controlled
|
|
138
|
+
# (RFC 7518 SS4.6.1.1 -- it's carried in the unauthenticated JWE
|
|
139
|
+
# header), and its only legitimate role is as the sender's
|
|
140
|
+
# EPHEMERAL PUBLIC key. Passing a private member through -- even
|
|
141
|
+
# though our own private key material is what's actually used for
|
|
142
|
+
# agreement -- is needless: it should never be present, let alone
|
|
143
|
+
# echoed or relied upon.
|
|
144
|
+
def build_epk(header)
|
|
145
|
+
epk = header["epk"]
|
|
146
|
+
raise OpenID4VP::InvalidRequest, "jwe: missing epk" unless epk.is_a?(Hash)
|
|
147
|
+
|
|
148
|
+
JWK.new(epk).to_public
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Restricts candidates to those whose `kty`/`crv` are actually
|
|
152
|
+
# compatible with `epk`'s -- a `kid` match alone does not guarantee
|
|
153
|
+
# that (a misconfigured or attacker-substituted `epk` can carry any
|
|
154
|
+
# curve) -- so an incompatible key never reaches key agreement,
|
|
155
|
+
# whose failure on mismatched domain parameters `try_candidates`
|
|
156
|
+
# also rescues, as defense in depth.
|
|
157
|
+
def candidate_keys(keys, header, epk)
|
|
158
|
+
matched = header["kid"] ? keys.select { |k| k.kid == header["kid"] } : keys
|
|
159
|
+
raise OpenID4VP::VerificationError, "no key for kid" if header["kid"] && matched.empty?
|
|
160
|
+
|
|
161
|
+
matched.select { |k| k.kty == epk.kty && k.crv == epk.crv }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def try_candidates(candidates)
|
|
165
|
+
raise OpenID4VP::VerificationError, "no matching key" if candidates.empty?
|
|
166
|
+
|
|
167
|
+
candidates.each do |key|
|
|
168
|
+
return yield(key)
|
|
169
|
+
rescue OpenID4VP::VerificationError, OpenSSL::OpenSSLError, ArgumentError
|
|
170
|
+
next
|
|
171
|
+
end
|
|
172
|
+
raise OpenID4VP::VerificationError, "decryption failed"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def parse_payload(bytes)
|
|
176
|
+
parsed = ::JSON.parse(bytes)
|
|
177
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
178
|
+
rescue ::JSON::ParserError, EncodingError
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jose"
|
|
4
|
+
|
|
5
|
+
require_relative "../jose"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
require_relative "../util/base64url"
|
|
8
|
+
|
|
9
|
+
module OpenID4VP
|
|
10
|
+
module JOSE
|
|
11
|
+
# A single JSON Web Key (RFC 7517): a thin adapter over `JOSE::JWK` (the
|
|
12
|
+
# `jose` gem), which does the actual key material parsing/serialization
|
|
13
|
+
# (including ASN.1 construction) for EC and RSA. Supports EC, OKP
|
|
14
|
+
# (Ed25519/X25519/X448) and RSA keys. Instances are immutable.
|
|
15
|
+
#
|
|
16
|
+
# OKP keys are converted to/from OpenSSL directly via OpenSSL's own raw
|
|
17
|
+
# key API (`JOSE::JWK#to_key` returns a raw byte String, not an
|
|
18
|
+
# OpenSSL::PKey, for OKP kty's, so it is not useful here).
|
|
19
|
+
class JWK
|
|
20
|
+
SUPPORTED_KTYS = %w[EC OKP RSA].freeze
|
|
21
|
+
|
|
22
|
+
EC_CURVE_TO_OPENSSL = {
|
|
23
|
+
"P-256" => "prime256v1",
|
|
24
|
+
"P-384" => "secp384r1",
|
|
25
|
+
"P-521" => "secp521r1"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
EC_COORD_SIZES = { "P-256" => 32, "P-384" => 48, "P-521" => 66 }.freeze
|
|
29
|
+
|
|
30
|
+
OKP_CURVES = %w[Ed25519 X25519 X448].freeze
|
|
31
|
+
OKP_OID_TO_CRV = { "ED25519" => "Ed25519", "X25519" => "X25519", "X448" => "X448" }.freeze
|
|
32
|
+
OKP_KEY_LENGTH = { "Ed25519" => 32, "X25519" => 32, "X448" => 56 }.freeze
|
|
33
|
+
|
|
34
|
+
PRIVATE_ONLY_MEMBERS = {
|
|
35
|
+
"EC" => %w[d],
|
|
36
|
+
"OKP" => %w[d],
|
|
37
|
+
"RSA" => %w[d p q dp dq qi]
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
%w[kty crv kid alg use].each do |member|
|
|
41
|
+
define_method(member) { @members[member] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# kty is :ec, :okp or :rsa. params (kid:, use:, alg:, ...) are merged
|
|
46
|
+
# into the resulting JWK's members.
|
|
47
|
+
def generate(kty, **params)
|
|
48
|
+
case kty
|
|
49
|
+
when :ec then generate_ec(params)
|
|
50
|
+
when :okp then generate_okp(params)
|
|
51
|
+
when :rsa then from_openssl(OpenSSL::PKey::RSA.new(params.delete(:bits) || 2048), **params)
|
|
52
|
+
else raise OpenID4VP::InvalidRequest, "jwk: unsupported kty for generate #{kty.inspect}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Any EC members hash reaching .new (directly, or built here) must
|
|
57
|
+
# have `d` left-padded to the curve's fixed coordinate length -- see
|
|
58
|
+
# #pad_ec_d below for why; a future constructor that bypasses it
|
|
59
|
+
# would reintroduce the P-521 truncation bug it fixes.
|
|
60
|
+
def from_openssl(pkey, **params)
|
|
61
|
+
members =
|
|
62
|
+
case pkey
|
|
63
|
+
when OpenSSL::PKey::EC then pad_ec_d(::JOSE::JWK.from_key(pkey).to_map.to_h)
|
|
64
|
+
when OpenSSL::PKey::RSA then ::JOSE::JWK.from_key(pkey).to_map.to_h
|
|
65
|
+
when OpenSSL::PKey::PKey then members_from_okp(pkey)
|
|
66
|
+
else raise OpenID4VP::InvalidRequest, "jwk: unsupported OpenSSL key type #{pkey.class}"
|
|
67
|
+
end
|
|
68
|
+
params.each { |k, v| members[k.to_s] = v unless v.nil? }
|
|
69
|
+
new(members)
|
|
70
|
+
rescue ArgumentError => e
|
|
71
|
+
raise OpenID4VP::InvalidRequest, "jwk: #{e.message}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# JOSE::JWK::KTY_EC#to_map encodes `d` from the raw OpenSSL::BN,
|
|
77
|
+
# which strips leading zero bytes -- pad it back to the curve's
|
|
78
|
+
# fixed coordinate size (RFC 7518 SS6.2.1) so it round-trips through
|
|
79
|
+
# #decode_fixed_length! below.
|
|
80
|
+
def pad_ec_d(members)
|
|
81
|
+
size = EC_COORD_SIZES[members["crv"]]
|
|
82
|
+
return members unless size && members["d"]
|
|
83
|
+
|
|
84
|
+
bytes = Util::Base64url.decode(members["d"])
|
|
85
|
+
members["d"] = Util::Base64url.encode(bytes.rjust(size, "\x00".b))
|
|
86
|
+
members
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def generate_ec(params)
|
|
90
|
+
crv = params.delete(:crv)
|
|
91
|
+
openssl_name = EC_CURVE_TO_OPENSSL.fetch(crv) do
|
|
92
|
+
raise OpenID4VP::InvalidRequest, "jwk: unsupported EC curve #{crv.inspect}"
|
|
93
|
+
end
|
|
94
|
+
from_openssl(OpenSSL::PKey::EC.generate(openssl_name), **params)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def generate_okp(params)
|
|
98
|
+
crv = params.delete(:crv)
|
|
99
|
+
raise OpenID4VP::InvalidRequest, "jwk: unsupported OKP curve #{crv.inspect}" unless OKP_CURVES.include?(crv)
|
|
100
|
+
|
|
101
|
+
from_openssl(OpenSSL::PKey.generate_key(crv), **params)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def members_from_okp(pkey)
|
|
105
|
+
crv = OKP_OID_TO_CRV.fetch(pkey.oid) do
|
|
106
|
+
raise OpenID4VP::InvalidRequest, "jwk: unsupported OKP key #{pkey.oid}"
|
|
107
|
+
end
|
|
108
|
+
members = { "kty" => "OKP", "crv" => crv, "x" => Util::Base64url.encode(pkey.raw_public_key) }
|
|
109
|
+
begin
|
|
110
|
+
members["d"] = Util::Base64url.encode(pkey.raw_private_key)
|
|
111
|
+
rescue OpenSSL::PKey::PKeyError
|
|
112
|
+
# public-only key
|
|
113
|
+
end
|
|
114
|
+
members
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Note for callers building an EC members Hash directly (rather than
|
|
119
|
+
# via .from_openssl/.generate): `d` must be left-padded with zero
|
|
120
|
+
# bytes to the curve's fixed coordinate length (see #pad_ec_d) --
|
|
121
|
+
# JOSE::JWK::KTY_EC#to_map does not pad it, so a `d` sourced from
|
|
122
|
+
# there needs the same fixup .from_openssl already applies.
|
|
123
|
+
def initialize(members)
|
|
124
|
+
@members = members.each_with_object({}) { |(k, v), h| h[k.to_s] = v }.freeze
|
|
125
|
+
raise OpenID4VP::InvalidRequest, "jwk: missing kty" if @members["kty"].nil?
|
|
126
|
+
unless SUPPORTED_KTYS.include?(@members["kty"])
|
|
127
|
+
raise OpenID4VP::InvalidRequest, "jwk: unsupported kty #{@members['kty'].inspect}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
freeze
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def private?
|
|
134
|
+
@members.key?("d")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def public?
|
|
138
|
+
!private?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def to_public
|
|
142
|
+
return self if public?
|
|
143
|
+
|
|
144
|
+
self.class.new(@members.except(*PRIVATE_ONLY_MEMBERS.fetch(kty, [])))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def to_h
|
|
148
|
+
@members.dup
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def to_json(*)
|
|
152
|
+
::JSON.generate(to_h, *)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# RFC 7638 JWK thumbprint: raw 32-byte SHA-256 digest, computed over
|
|
156
|
+
# JOSE::JWK's canonical (sorted-key) member selection for `kty`.
|
|
157
|
+
def thumbprint
|
|
158
|
+
Util.sha256(::JOSE.encode(jose_jwk.to_thumbprint_map))
|
|
159
|
+
rescue ArgumentError, OpenSSL::OpenSSLError => e
|
|
160
|
+
raise OpenID4VP::InvalidRequest, "jwk: cannot compute thumbprint for kty #{kty}: #{e.message}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def thumbprint_b64
|
|
164
|
+
Util::Base64url.encode(thumbprint)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def to_openssl
|
|
168
|
+
case kty
|
|
169
|
+
when "EC" then openssl_ec
|
|
170
|
+
when "OKP" then openssl_okp
|
|
171
|
+
when "RSA" then openssl_via_jose("RSA")
|
|
172
|
+
else raise OpenID4VP::InvalidRequest, "jwk: cannot convert kty #{kty} to an OpenSSL key"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Equality is by public key material (the RFC 7638 thumbprint members),
|
|
177
|
+
# not full to_public.to_h: a private key equals its own public form,
|
|
178
|
+
# and a key equals itself after a round trip through OpenSSL even
|
|
179
|
+
# though such a round trip cannot preserve metadata like kid/use/alg.
|
|
180
|
+
def ==(other)
|
|
181
|
+
other.is_a?(JWK) && kty == other.kty && thumbprint == other.thumbprint
|
|
182
|
+
rescue OpenID4VP::InvalidRequest
|
|
183
|
+
false
|
|
184
|
+
end
|
|
185
|
+
alias eql? ==
|
|
186
|
+
|
|
187
|
+
def hash
|
|
188
|
+
[kty, thumbprint].hash
|
|
189
|
+
rescue OpenID4VP::InvalidRequest
|
|
190
|
+
kty.hash
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
# JOSE::JWK.from_map itself constructs OpenSSL key material (e.g. an
|
|
196
|
+
# EC::Point for the x/y coordinates), so a structurally well-formed
|
|
197
|
+
# but invalid member (an off-curve point of valid length, say) can
|
|
198
|
+
# raise a raw OpenSSL::OpenSSLError here, not just ArgumentError.
|
|
199
|
+
def jose_jwk
|
|
200
|
+
::JOSE::JWK.from_map(@members)
|
|
201
|
+
rescue ArgumentError, OpenSSL::OpenSSLError => e
|
|
202
|
+
raise OpenID4VP::InvalidRequest, "jwk: #{e.message}"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def openssl_via_jose(label)
|
|
206
|
+
jose_jwk.to_key
|
|
207
|
+
rescue ArgumentError, OpenSSL::OpenSSLError => e
|
|
208
|
+
raise OpenID4VP::InvalidRequest, "jwk: invalid #{label} key material: #{e.message}"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def fetch!(key)
|
|
212
|
+
@members.fetch(key) { raise OpenID4VP::InvalidRequest, "jwk: missing #{key}" }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Raises OpenID4VP::InvalidRequest unless the decoded member is exactly
|
|
216
|
+
# `expected` bytes long, so malformed key material never reaches
|
|
217
|
+
# OpenSSL's raw key constructors as a raw ArgumentError/OpenSSLError.
|
|
218
|
+
def decode_fixed_length!(member, expected)
|
|
219
|
+
bytes = Util::Base64url.decode(fetch!(member))
|
|
220
|
+
return bytes if bytes.bytesize == expected
|
|
221
|
+
|
|
222
|
+
raise OpenID4VP::InvalidRequest,
|
|
223
|
+
"jwk: #{member} must be #{expected} bytes for #{crv}, got #{bytes.bytesize}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# EC key material is length-checked here (rather than left to jose/
|
|
227
|
+
# OpenSSL) because OpenSSL 3 can construct an EVP_PKEY from an
|
|
228
|
+
# inconsistent SEC1 private key (wrong-length or out-of-range `d`)
|
|
229
|
+
# without raising until the key is later used, which would leak a
|
|
230
|
+
# not-yet-invalid-looking key out of this method.
|
|
231
|
+
def openssl_ec
|
|
232
|
+
size = EC_COORD_SIZES.fetch(crv) { raise OpenID4VP::InvalidRequest, "jwk: unsupported EC curve #{crv.inspect}" }
|
|
233
|
+
decode_fixed_length!("x", size)
|
|
234
|
+
decode_fixed_length!("y", size)
|
|
235
|
+
decode_fixed_length!("d", size) if @members.key?("d")
|
|
236
|
+
openssl_via_jose("EC")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def openssl_okp
|
|
240
|
+
raise OpenID4VP::InvalidRequest, "jwk: unsupported OKP curve #{crv.inspect}" unless OKP_CURVES.include?(crv)
|
|
241
|
+
|
|
242
|
+
size = OKP_KEY_LENGTH.fetch(crv)
|
|
243
|
+
if @members.key?("d")
|
|
244
|
+
OpenSSL::PKey.new_raw_private_key(crv, decode_fixed_length!("d", size))
|
|
245
|
+
else
|
|
246
|
+
OpenSSL::PKey.new_raw_public_key(crv, decode_fixed_length!("x", size))
|
|
247
|
+
end
|
|
248
|
+
rescue OpenSSL::OpenSSLError, ArgumentError => e
|
|
249
|
+
raise OpenID4VP::InvalidRequest, "jwk: invalid OKP key material: #{e.message}"
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../jose"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
require_relative "jwk"
|
|
6
|
+
|
|
7
|
+
module OpenID4VP
|
|
8
|
+
module JOSE
|
|
9
|
+
# A JSON Web Key Set (RFC 7517 §5): an ordered collection of JWKs.
|
|
10
|
+
class JWKSet
|
|
11
|
+
attr_reader :keys
|
|
12
|
+
|
|
13
|
+
def self.from_h(hash)
|
|
14
|
+
keys = (hash["keys"] || hash[:keys] || []).map { |member| JWK.new(member) }
|
|
15
|
+
new(keys: keys)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(keys:)
|
|
19
|
+
@keys = keys.dup.freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def find(kid)
|
|
23
|
+
@keys.find { |k| k.kid == kid }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_h
|
|
27
|
+
{ "keys" => @keys.map(&:to_h) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_json(*)
|
|
31
|
+
::JSON.generate(to_h, *)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Picks a key usable for content encryption key agreement/wrapping:
|
|
35
|
+
# keys without an `alg` are ignored, a key restricted to
|
|
36
|
+
# `supported_algs` (when given) is preferred, and a key with
|
|
37
|
+
# `use: "enc"` is preferred over the first remaining match.
|
|
38
|
+
def select_encryption_key(supported_algs: nil)
|
|
39
|
+
candidates = @keys.select do |key|
|
|
40
|
+
next false unless key.alg
|
|
41
|
+
next false if supported_algs && !supported_algs.include?(key.alg)
|
|
42
|
+
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
candidates.find { |key| key.use == "enc" } || candidates.first
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# OID4VP §5.1: every JWK in a client_metadata.jwks JWK Set must carry a
|
|
49
|
+
# `kid` so it can be referenced unambiguously.
|
|
50
|
+
def require_kids!
|
|
51
|
+
return if @keys.all?(&:kid)
|
|
52
|
+
|
|
53
|
+
raise OpenID4VP::InvalidRequest, "jwks: every key must have a kid"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|