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,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "../formats"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
require_relative "verified_presentation"
|
|
7
|
+
require_relative "registry"
|
|
8
|
+
require_relative "../errors"
|
|
9
|
+
require_relative "../credential"
|
|
10
|
+
|
|
11
|
+
module OpenID4VP
|
|
12
|
+
module Formats
|
|
13
|
+
# OpenID4VP Appendix B.1.3.2: the `ldp_vc` (W3C Verifiable Credential
|
|
14
|
+
# secured with a Data Integrity proof, using JSON-LD) Credential Format
|
|
15
|
+
# handler.
|
|
16
|
+
#
|
|
17
|
+
# A real deployment MUST inject the `:ldp_proof_signer` (holder-side)
|
|
18
|
+
# and `:ldp_proof_verifier` (verifier-side) `Context#trust` hooks: this
|
|
19
|
+
# gem does not implement JSON-LD normalization / Data Integrity
|
|
20
|
+
# cryptosuites itself. Without `:ldp_proof_signer`,
|
|
21
|
+
# `#build_presentation` emits a placeholder, unverifiable proof.
|
|
22
|
+
class LdpVc < Base
|
|
23
|
+
FORMAT_ID = "ldp_vc"
|
|
24
|
+
VP_CONTEXT = ["https://www.w3.org/2018/credentials/v1"].freeze
|
|
25
|
+
|
|
26
|
+
def self.format_id
|
|
27
|
+
FORMAT_ID
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Wraps an already-issued LDP VC Hash into a wallet-side
|
|
31
|
+
# `OpenID4VP::Credential`.
|
|
32
|
+
def self.credential_from(vc_hash, holder_key: nil)
|
|
33
|
+
raise OpenID4VP::PresentationInvalid, "ldp_vc: credential must be an object" unless vc_hash.is_a?(::Hash)
|
|
34
|
+
|
|
35
|
+
OpenID4VP::Credential.new(
|
|
36
|
+
format: FORMAT_ID,
|
|
37
|
+
claims: vc_hash,
|
|
38
|
+
raw: vc_hash,
|
|
39
|
+
holder_key: holder_key,
|
|
40
|
+
metadata: { "type" => vc_hash["type"], "issuer" => vc_hash["issuer"] }
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Builds a Verifiable Presentation Hash for `credential`. The Data
|
|
45
|
+
# Integrity proof comes from the `:ldp_proof_signer` trust hook
|
|
46
|
+
# (callable `(vp_without_proof, challenge:, domain:) -> proof Hash`)
|
|
47
|
+
# when configured, else a placeholder, unverifiable proof.
|
|
48
|
+
def build_presentation(credential, _claims_queries, context)
|
|
49
|
+
holder_id = credential.metadata["holder"] || holder_id_from_key(credential.holder_key)
|
|
50
|
+
vp = {
|
|
51
|
+
"@context" => VP_CONTEXT,
|
|
52
|
+
"type" => ["VerifiablePresentation"],
|
|
53
|
+
"verifiableCredential" => [credential.raw],
|
|
54
|
+
"holder" => holder_id
|
|
55
|
+
}.compact
|
|
56
|
+
|
|
57
|
+
signer = context.trust_hook(:ldp_proof_signer)
|
|
58
|
+
proof = signer&.call(vp, challenge: context.nonce, domain: context.audience) ||
|
|
59
|
+
placeholder_proof(context)
|
|
60
|
+
vp.merge("proof" => proof)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Verifies `presentation` (a Verifiable Presentation Hash) against
|
|
64
|
+
# `context`. Raises ConfigurationError when holder binding is
|
|
65
|
+
# required but no `:ldp_proof_verifier` trust hook is configured.
|
|
66
|
+
def verify_presentation(presentation, context)
|
|
67
|
+
raise OpenID4VP::PresentationInvalid, "ldp_vc: presentation must be an object" unless presentation.is_a?(::Hash)
|
|
68
|
+
|
|
69
|
+
proof = presentation["proof"]
|
|
70
|
+
verified = verify_proof!(proof, presentation, context)
|
|
71
|
+
|
|
72
|
+
vc = single_verifiable_credential(presentation)
|
|
73
|
+
|
|
74
|
+
VerifiedPresentation.new(
|
|
75
|
+
format: FORMAT_ID,
|
|
76
|
+
claims: vc,
|
|
77
|
+
holder_bound: verified,
|
|
78
|
+
issuer: issuer_id(vc["issuer"]),
|
|
79
|
+
raw: presentation,
|
|
80
|
+
metadata: { "type" => vc["type"], "issuer" => vc["issuer"] },
|
|
81
|
+
details: { proof_type: proof.is_a?(::Hash) ? proof["type"] : nil,
|
|
82
|
+
cryptosuite: proof.is_a?(::Hash) ? proof["cryptosuite"] : nil }
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# OpenID4VP Appendix B.1.3.2.3: `proof_type_values` restricts the
|
|
87
|
+
# Data Integrity proof's `type`; `cryptosuite_values` restricts its
|
|
88
|
+
# `cryptosuite` (when present).
|
|
89
|
+
def check_vp_formats!(verified, vp_formats)
|
|
90
|
+
return if vp_formats.nil?
|
|
91
|
+
|
|
92
|
+
params = vp_formats.params_for(FORMAT_ID)
|
|
93
|
+
return if params.nil?
|
|
94
|
+
|
|
95
|
+
alg_allowed!(params["proof_type_values"], verified.details[:proof_type], "proof_type_values")
|
|
96
|
+
alg_allowed!(params["cryptosuite_values"], verified.details[:cryptosuite], "cryptosuite_values")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def holder_id_from_key(holder_key)
|
|
102
|
+
return nil if holder_key.nil?
|
|
103
|
+
|
|
104
|
+
"did:jwk:#{holder_key.thumbprint_b64}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def placeholder_proof(context)
|
|
108
|
+
{
|
|
109
|
+
"type" => "DataIntegrityProof",
|
|
110
|
+
"proofPurpose" => "authentication",
|
|
111
|
+
"challenge" => context.nonce,
|
|
112
|
+
"domain" => context.audience,
|
|
113
|
+
"created" => Time.now.utc.iso8601,
|
|
114
|
+
"proofValue" => "unverified"
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Raises on a malformed/mismatched proof; returns whether the proof
|
|
119
|
+
# was cryptographically verified (bang convention, matching
|
|
120
|
+
# OpenID4VP::TransactionData::SdJwtHashes.verify!).
|
|
121
|
+
# rubocop:disable Naming/PredicateMethod -- bang convention (raise on
|
|
122
|
+
# failure, return a boolean), matching TransactionData::SdJwtHashes.verify!.
|
|
123
|
+
def verify_proof!(proof, presentation, context)
|
|
124
|
+
raise OpenID4VP::PresentationInvalid, "ldp_vc: presentation has no proof" unless proof.is_a?(::Hash)
|
|
125
|
+
raise OpenID4VP::HolderBindingInvalid, "ldp_vc: challenge mismatch" unless proof["challenge"] == context.nonce
|
|
126
|
+
raise OpenID4VP::HolderBindingInvalid, "ldp_vc: domain mismatch" unless proof["domain"] == context.audience
|
|
127
|
+
|
|
128
|
+
# Not cryptographically verified: the proof's presence/shape is
|
|
129
|
+
# confirmed above, but without :ldp_proof_verifier no Data
|
|
130
|
+
# Integrity signature has actually been checked.
|
|
131
|
+
return false unless context.require_holder_binding
|
|
132
|
+
|
|
133
|
+
verifier = context.trust_hook(:ldp_proof_verifier)
|
|
134
|
+
raise OpenID4VP::ConfigurationError, "ldp_proof_verifier trust hook required" if verifier.nil?
|
|
135
|
+
|
|
136
|
+
unless verifier.call(presentation, context)
|
|
137
|
+
raise OpenID4VP::HolderBindingInvalid, "ldp_vc: proof verification failed"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
true
|
|
141
|
+
end
|
|
142
|
+
# rubocop:enable Naming/PredicateMethod
|
|
143
|
+
|
|
144
|
+
def single_verifiable_credential(presentation)
|
|
145
|
+
vcs = presentation["verifiableCredential"]
|
|
146
|
+
unless vcs.is_a?(::Array) && vcs.size == 1 && vcs.first.is_a?(::Hash)
|
|
147
|
+
raise OpenID4VP::PresentationInvalid, "ldp_vc: verifiableCredential must be an array of exactly one object"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
vcs.first
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def issuer_id(issuer)
|
|
154
|
+
issuer.is_a?(::Hash) ? issuer["id"] : issuer
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
Registry.default_handlers << LdpVc unless Registry.default_handlers.include?(LdpVc)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../formats"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
require_relative "verified_presentation"
|
|
6
|
+
require_relative "registry"
|
|
7
|
+
require_relative "../errors"
|
|
8
|
+
require_relative "../credential"
|
|
9
|
+
require_relative "../jose/jwk"
|
|
10
|
+
require_relative "../mdoc/device_response"
|
|
11
|
+
require_relative "../mdoc/verifier"
|
|
12
|
+
require_relative "../mdoc/builder"
|
|
13
|
+
require_relative "../mdoc/session_transcript"
|
|
14
|
+
require_relative "../x509/chain_validator"
|
|
15
|
+
|
|
16
|
+
module OpenID4VP
|
|
17
|
+
module Formats
|
|
18
|
+
# OpenID4VP Appendix B.2: the `mso_mdoc` (ISO/IEC 18013-5 mdoc) Credential
|
|
19
|
+
# Format handler.
|
|
20
|
+
class MsoMdoc < Base
|
|
21
|
+
FORMAT_ID = "mso_mdoc"
|
|
22
|
+
|
|
23
|
+
# OpenID4VP §B.2.2: a value in `issuerauth_alg_values`/
|
|
24
|
+
# `deviceauth_alg_values` also matches when it is the "fully specified
|
|
25
|
+
# algorithm" ([I-D.ietf-jose-fully-specified-algorithms]) equivalent of
|
|
26
|
+
# the actual COSE `alg`, for the curve of the signing key: -7 (ES256)
|
|
27
|
+
# <=> -9 on a P-256 key, -35 (ES384) <=> -51 on a P-384 key, -36
|
|
28
|
+
# (ES512) <=> -52 on a P-521 key.
|
|
29
|
+
FULLY_SPECIFIED_EQUIVALENTS = {
|
|
30
|
+
"P-256" => { -7 => -9, -9 => -7 },
|
|
31
|
+
"P-384" => { -35 => -51, -51 => -35 },
|
|
32
|
+
"P-521" => { -36 => -52, -52 => -36 }
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
def self.format_id
|
|
36
|
+
FORMAT_ID
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Wraps a freshly-issued IssuerSigned (e.g. from
|
|
40
|
+
# `Mdoc::Builder.issue`) into a wallet-side `OpenID4VP::Credential`.
|
|
41
|
+
def self.credential_from(issuer_signed, doc_type:, device_key:)
|
|
42
|
+
claims = issuer_signed.name_spaces.transform_values do |items|
|
|
43
|
+
items.to_h { |item| [item.element_identifier, item.element_value] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
OpenID4VP::Credential.new(
|
|
47
|
+
format: FORMAT_ID, claims: claims, raw: issuer_signed, holder_key: device_key,
|
|
48
|
+
x5chain: issuer_signed.x5chain, metadata: { "doctype" => doc_type }
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Builds a base64url-encoded DeviceResponse for `credential` (an
|
|
53
|
+
# IssuerSigned wrapped via `.credential_from`), disclosing exactly the
|
|
54
|
+
# namespace/element pairs addressed by `claims_queries` (empty/nil:
|
|
55
|
+
# discloses nothing). Requires `credential.holder_key` (the mdoc
|
|
56
|
+
# device's private key).
|
|
57
|
+
def build_presentation(credential, claims_queries, context)
|
|
58
|
+
raise OpenID4VP::HolderBindingInvalid, "credential has no holder key" if credential.holder_key.nil?
|
|
59
|
+
|
|
60
|
+
OpenID4VP::Mdoc::Builder.device_response(
|
|
61
|
+
issuer_signed: credential.raw,
|
|
62
|
+
doc_type: credential.metadata["doctype"],
|
|
63
|
+
disclose: disclose_map(claims_queries),
|
|
64
|
+
device_key: credential.holder_key,
|
|
65
|
+
session_transcript: session_transcript(context)
|
|
66
|
+
).to_b64url
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Verifies `presentation` (a base64url-encoded DeviceResponse String,
|
|
70
|
+
# OpenID4VP §B.2.5) against `context`. Raises ConfigurationError when
|
|
71
|
+
# no `:mdoc_trust_anchors` trust hook is configured.
|
|
72
|
+
def verify_presentation(presentation, context)
|
|
73
|
+
unless presentation.is_a?(::String)
|
|
74
|
+
raise OpenID4VP::PresentationInvalid, "mso_mdoc: presentation must be a string"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
document = single_document(parse_device_response(presentation))
|
|
78
|
+
result = verify_document(document, context)
|
|
79
|
+
|
|
80
|
+
VerifiedPresentation.new(
|
|
81
|
+
format: FORMAT_ID,
|
|
82
|
+
claims: result.claims,
|
|
83
|
+
holder_bound: true,
|
|
84
|
+
issuer: result.issuer_certs.first.subject.to_s,
|
|
85
|
+
raw: presentation,
|
|
86
|
+
x5chain: result.issuer_certs,
|
|
87
|
+
metadata: { "doctype" => result.doc_type },
|
|
88
|
+
details: { issuer_alg: result.issuer_alg, device_alg: result.device_alg, device_key: result.device_key }
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# OpenID4VP §B.2.2: `issuerauth_alg_values` restricts the IssuerAuth
|
|
93
|
+
# COSE_Sign1's `alg`; `deviceauth_alg_values` restricts the
|
|
94
|
+
# DeviceSignature COSE_Sign1's `alg`. Both also accept the alg's fully
|
|
95
|
+
# specified equivalent for the actual signing key's curve.
|
|
96
|
+
def check_vp_formats!(verified, vp_formats)
|
|
97
|
+
return if vp_formats.nil?
|
|
98
|
+
|
|
99
|
+
params = vp_formats.params_for(FORMAT_ID)
|
|
100
|
+
return if params.nil?
|
|
101
|
+
|
|
102
|
+
check_alg!(params["issuerauth_alg_values"], verified.details[:issuer_alg], issuer_curve(verified),
|
|
103
|
+
"issuerauth_alg_values")
|
|
104
|
+
check_alg!(params["deviceauth_alg_values"], verified.details[:device_alg], device_curve(verified),
|
|
105
|
+
"deviceauth_alg_values")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def disclose_map(claims_queries)
|
|
111
|
+
(claims_queries || []).each_with_object({}) do |claims_query, disclose|
|
|
112
|
+
namespace, element = claims_query.path.mdoc_components
|
|
113
|
+
(disclose[namespace] ||= []) << element
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# OpenID4VP §B.2.6: the SessionTranscript array binding the
|
|
118
|
+
# DeviceResponse to this OpenID4VP exchange -- OpenID4VPHandover for a
|
|
119
|
+
# redirect-based request, OpenID4VPDCAPIHandover for a Digital
|
|
120
|
+
# Credentials API request.
|
|
121
|
+
def session_transcript(context)
|
|
122
|
+
if context.dc_api
|
|
123
|
+
if context.origin.nil?
|
|
124
|
+
raise OpenID4VP::ConfigurationError, "mso_mdoc: origin is required for dc_api presentations"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
OpenID4VP::Mdoc::SessionTranscript.for_dc_api(
|
|
128
|
+
origin: context.origin, nonce: context.nonce, encryption_jwk: context.encryption_jwk
|
|
129
|
+
)
|
|
130
|
+
else
|
|
131
|
+
if context.client_id.nil? || context.response_uri.nil?
|
|
132
|
+
raise OpenID4VP::ConfigurationError,
|
|
133
|
+
"mso_mdoc: client_id and response_uri are required for redirect-based presentations"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
OpenID4VP::Mdoc::SessionTranscript.for_request(
|
|
137
|
+
client_id: context.client_id.to_s, nonce: context.nonce, response_uri: context.response_uri,
|
|
138
|
+
encryption_jwk: context.encryption_jwk
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def parse_device_response(presentation)
|
|
144
|
+
OpenID4VP::Mdoc::DeviceResponse.parse_b64url(presentation)
|
|
145
|
+
rescue OpenID4VP::InvalidRequest, OpenID4VP::InvalidResponse => e
|
|
146
|
+
raise OpenID4VP::PresentationInvalid, "mso_mdoc: #{e.message}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def single_document(device_response)
|
|
150
|
+
documents = device_response.documents
|
|
151
|
+
if documents.size != 1
|
|
152
|
+
raise OpenID4VP::PresentationInvalid,
|
|
153
|
+
"mso_mdoc: DeviceResponse must contain exactly one document, got #{documents.size}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
documents.first
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def verify_document(document, context)
|
|
160
|
+
trust_anchors = context.trust_hook(:mdoc_trust_anchors)
|
|
161
|
+
if trust_anchors.nil? || Array(trust_anchors).empty?
|
|
162
|
+
raise OpenID4VP::ConfigurationError, "mdoc_trust_anchors trust hook required"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
validator = lambda do |chain|
|
|
166
|
+
OpenID4VP::X509::ChainValidator.new(trust_anchors: trust_anchors, time: context.now).validate!(chain)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Mdoc::Verifier.verify itself raises correctly-typed
|
|
170
|
+
# OpenID4VP::PresentationInvalid / OpenID4VP::HolderBindingInvalid
|
|
171
|
+
# for every Credential-side/Holder Binding failure -- nothing to map
|
|
172
|
+
# here.
|
|
173
|
+
OpenID4VP::Mdoc::Verifier.verify(
|
|
174
|
+
document, session_transcript: session_transcript(context), issuer_cert_validator: validator,
|
|
175
|
+
now: context.now
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Substitutes `alg`'s fully specified equivalent (for `curve`) when
|
|
180
|
+
# `values` does not already include `alg` directly, then delegates to
|
|
181
|
+
# `Base#alg_allowed!` for the actual restriction check/error.
|
|
182
|
+
def check_alg!(values, alg, curve, param_name)
|
|
183
|
+
return if values.nil? || alg.nil?
|
|
184
|
+
|
|
185
|
+
candidate = values.include?(alg) ? alg : (FULLY_SPECIFIED_EQUIVALENTS.dig(curve, alg) || alg)
|
|
186
|
+
alg_allowed!(values, candidate, param_name)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def issuer_curve(verified)
|
|
190
|
+
leaf = verified.x5chain.first
|
|
191
|
+
return nil if leaf.nil?
|
|
192
|
+
|
|
193
|
+
OpenID4VP::JOSE::JWK.from_openssl(leaf.public_key).crv
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def device_curve(verified)
|
|
197
|
+
verified.details[:device_key]&.crv
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
OpenID4VP::Formats::Registry.default_handlers << OpenID4VP::Formats::MsoMdoc unless
|
|
202
|
+
OpenID4VP::Formats::Registry.default_handlers.include?(OpenID4VP::Formats::MsoMdoc)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../formats"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
|
|
6
|
+
module OpenID4VP
|
|
7
|
+
module Formats
|
|
8
|
+
# A collection of `Base` handler instances, keyed by Credential Format
|
|
9
|
+
# Identifier (`#format_id`).
|
|
10
|
+
class Registry
|
|
11
|
+
# Handler CLASSES (not instances) registered by `.default`. Each
|
|
12
|
+
# format handler's own file appends itself here at load time; a
|
|
13
|
+
# caller assembling a custom Registry may also append/remove entries
|
|
14
|
+
# before calling `.default`.
|
|
15
|
+
def self.default_handlers
|
|
16
|
+
@default_handlers ||= []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A new Registry with one instance of each of `default_handlers`
|
|
20
|
+
# registered.
|
|
21
|
+
def self.default
|
|
22
|
+
registry = new
|
|
23
|
+
default_handlers.each { |handler_class| registry.register(handler_class.new) }
|
|
24
|
+
registry
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@handlers = {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def register(handler)
|
|
32
|
+
@handlers[handler.format_id] = handler
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The handler for `format` (a Credential Format Identifier String).
|
|
37
|
+
# Raises VpFormatsNotSupported when none is registered.
|
|
38
|
+
def for(format)
|
|
39
|
+
@handlers.fetch(format) do
|
|
40
|
+
raise OpenID4VP::VpFormatsNotSupported, "unsupported credential format: #{format}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def supports?(format)
|
|
45
|
+
@handlers.key?(format)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def formats
|
|
49
|
+
@handlers.keys
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../formats"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
require_relative "verified_presentation"
|
|
6
|
+
require_relative "registry"
|
|
7
|
+
require_relative "../errors"
|
|
8
|
+
require_relative "../credential"
|
|
9
|
+
require_relative "../x509"
|
|
10
|
+
require_relative "../jose/jws"
|
|
11
|
+
require_relative "../sd_jwt/presentation"
|
|
12
|
+
require_relative "../sd_jwt/parser"
|
|
13
|
+
require_relative "../sd_jwt/reconstruct"
|
|
14
|
+
|
|
15
|
+
module OpenID4VP
|
|
16
|
+
module Formats
|
|
17
|
+
# OpenID4VP Appendix B.3: the `dc+sd-jwt` (IETF SD-JWT VC) Credential
|
|
18
|
+
# Format handler.
|
|
19
|
+
class SdJwtVc < Base
|
|
20
|
+
FORMAT_ID = "dc+sd-jwt"
|
|
21
|
+
|
|
22
|
+
def self.format_id
|
|
23
|
+
FORMAT_ID
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Parses+reconstructs the FULL (unverified) claims of an issued SD-JWT
|
|
27
|
+
# (Issuer + all disclosures, no signature/holder-binding check) into a
|
|
28
|
+
# wallet-side `OpenID4VP::Credential`, e.g. right after issuance.
|
|
29
|
+
def self.credential_from(sd_jwt_string, holder_key: nil)
|
|
30
|
+
parsed = OpenID4VP::SdJwt::Parser.parse(sd_jwt_string)
|
|
31
|
+
peeked = OpenID4VP::JOSE::JWS.peek(parsed.issuer_jwt)
|
|
32
|
+
payload = peeked.payload
|
|
33
|
+
raise OpenID4VP::PresentationInvalid, "sd-jwt: payload is not a JSON object" unless payload.is_a?(::Hash)
|
|
34
|
+
|
|
35
|
+
sd_alg = payload["_sd_alg"] || "sha-256"
|
|
36
|
+
claims, = OpenID4VP::SdJwt::Reconstruct.claims(payload, parsed.disclosures, sd_alg)
|
|
37
|
+
|
|
38
|
+
OpenID4VP::Credential.new(
|
|
39
|
+
format: FORMAT_ID,
|
|
40
|
+
claims: claims,
|
|
41
|
+
raw: sd_jwt_string,
|
|
42
|
+
holder_key: holder_key,
|
|
43
|
+
x5chain: x5chain_from_header(peeked.header),
|
|
44
|
+
metadata: { "vct" => payload["vct"], "issuer" => payload["iss"] }
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.x5chain_from_header(header)
|
|
49
|
+
x5c = header["x5c"]
|
|
50
|
+
return [] unless x5c.is_a?(::Array)
|
|
51
|
+
|
|
52
|
+
OpenID4VP::X509.certs_from_x5c(x5c)
|
|
53
|
+
end
|
|
54
|
+
private_class_method :x5chain_from_header
|
|
55
|
+
|
|
56
|
+
# Verifies `presentation` (a `"<issuer-jwt>~<disclosures>~[<kb-jwt>]"`
|
|
57
|
+
# String) against `context`. Raises ConfigurationError when no
|
|
58
|
+
# `:sd_jwt_issuer_keys` trust hook is configured.
|
|
59
|
+
def verify_presentation(presentation, context)
|
|
60
|
+
unless presentation.is_a?(::String)
|
|
61
|
+
raise OpenID4VP::PresentationInvalid, "dc+sd-jwt: presentation must be a string"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
resolver = context.trust_hook(:sd_jwt_issuer_keys)
|
|
65
|
+
raise OpenID4VP::ConfigurationError, "sd_jwt_issuer_keys trust hook required" if resolver.nil?
|
|
66
|
+
|
|
67
|
+
result = OpenID4VP::SdJwt::Presentation.verify(
|
|
68
|
+
presentation,
|
|
69
|
+
issuer_key_resolver: resolver,
|
|
70
|
+
expected_nonce: context.nonce,
|
|
71
|
+
expected_aud: context.audience,
|
|
72
|
+
require_kb: context.require_holder_binding,
|
|
73
|
+
now: context.now,
|
|
74
|
+
leeway: context.leeway,
|
|
75
|
+
transaction_data: context.transaction_data
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
VerifiedPresentation.new(
|
|
79
|
+
format: FORMAT_ID,
|
|
80
|
+
claims: result.claims,
|
|
81
|
+
holder_bound: !result.kb_payload.nil?,
|
|
82
|
+
issuer: result.issuer,
|
|
83
|
+
raw: presentation,
|
|
84
|
+
x5chain: result.x5chain || [],
|
|
85
|
+
metadata: { "vct" => result.vct },
|
|
86
|
+
details: { alg: result.alg, kb_alg: result.kb_alg, kb_payload: result.kb_payload }
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# OpenID4VP Appendix B.3.4: `sd-jwt_alg_values` restricts the
|
|
91
|
+
# Issuer-signed JWT's `alg`; `kb-jwt_alg_values` restricts the Key
|
|
92
|
+
# Binding JWT's `alg` (only enforced when the Presentation is holder
|
|
93
|
+
# bound).
|
|
94
|
+
def check_vp_formats!(verified, vp_formats)
|
|
95
|
+
return if vp_formats.nil?
|
|
96
|
+
|
|
97
|
+
params = vp_formats.params_for(FORMAT_ID)
|
|
98
|
+
return if params.nil?
|
|
99
|
+
|
|
100
|
+
alg_allowed!(params["sd-jwt_alg_values"], verified.details[:alg], "sd-jwt_alg_values")
|
|
101
|
+
return unless verified.holder_bound
|
|
102
|
+
|
|
103
|
+
alg_allowed!(params["kb-jwt_alg_values"], verified.details[:kb_alg], "kb-jwt_alg_values")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Builds a Presentation for `credential` disclosing the claims
|
|
107
|
+
# addressed by `claims_queries` (may be empty/nil: only mandatory,
|
|
108
|
+
# always-disclosed claims are revealed). Binds with a Key Binding JWT
|
|
109
|
+
# whenever `credential.holder_key` is available (regardless of
|
|
110
|
+
# `context.require_holder_binding` -- binding when possible is always
|
|
111
|
+
# at least as strong as not binding); otherwise falls back to a plain
|
|
112
|
+
# (unbound) Presentation when `context.require_holder_binding` is
|
|
113
|
+
# false, or raises `HolderBindingInvalid` when it is true.
|
|
114
|
+
def build_presentation(credential, claims_queries, context)
|
|
115
|
+
paths = (claims_queries || []).map(&:path)
|
|
116
|
+
|
|
117
|
+
if credential.holder_key
|
|
118
|
+
OpenID4VP::SdJwt::Presentation.present(
|
|
119
|
+
credential.raw, paths: paths, holder_key: credential.holder_key, aud: context.audience,
|
|
120
|
+
nonce: context.nonce, transaction_data: context.transaction_data
|
|
121
|
+
)
|
|
122
|
+
elsif !context.require_holder_binding
|
|
123
|
+
OpenID4VP::SdJwt::Presentation.select(credential.raw, paths: paths)
|
|
124
|
+
else
|
|
125
|
+
raise OpenID4VP::HolderBindingInvalid, "credential has no holder key"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
Registry.default_handlers << SdJwtVc unless Registry.default_handlers.include?(SdJwtVc)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../formats"
|
|
4
|
+
require_relative "../credential"
|
|
5
|
+
|
|
6
|
+
module OpenID4VP
|
|
7
|
+
module Formats
|
|
8
|
+
# The result of `Base#verify_presentation`: a single Presentation's
|
|
9
|
+
# verified content, format-agnostic enough for `DCQL::Evaluator` (via
|
|
10
|
+
# `#to_credential`) and for the Verifier's response handling. Immutable.
|
|
11
|
+
class VerifiedPresentation
|
|
12
|
+
attr_reader :format, :claims, :holder_bound, :issuer, :raw, :x5chain, :metadata, :details
|
|
13
|
+
|
|
14
|
+
# format: the Credential Format Identifier, e.g. "dc+sd-jwt".
|
|
15
|
+
# claims: JSON-like Hash of the Credential's (disclosed) claims; for
|
|
16
|
+
# mso_mdoc, {namespace => {element => value}}.
|
|
17
|
+
# holder_bound: whether this Presentation carried a verified proof of
|
|
18
|
+
# Holder Binding (a Key Binding JWT, a Data Integrity proof, ...).
|
|
19
|
+
# raw: the exact Presentation that was verified.
|
|
20
|
+
# x5chain: Array of OpenSSL::X509::Certificate, leaf first.
|
|
21
|
+
# metadata: format-specific descriptors, e.g. {"vct"=>...}.
|
|
22
|
+
# details: format-specific extras later tasks read, e.g. {alg:,
|
|
23
|
+
# kb_alg:, kb_payload:} for dc+sd-jwt.
|
|
24
|
+
def initialize(format:, claims:, holder_bound:, raw:, issuer: nil, x5chain: [], metadata: {}, details: {})
|
|
25
|
+
@format = format
|
|
26
|
+
@claims = claims
|
|
27
|
+
@holder_bound = holder_bound
|
|
28
|
+
@issuer = issuer
|
|
29
|
+
@raw = raw
|
|
30
|
+
@x5chain = x5chain.dup.freeze
|
|
31
|
+
@metadata = metadata.dup.freeze
|
|
32
|
+
@details = details.dup.freeze
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def holder_bound?
|
|
37
|
+
holder_bound
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_credential
|
|
41
|
+
OpenID4VP::Credential.new(format: format, claims: claims, raw: raw, x5chain: x5chain, metadata: metadata)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenID4VP
|
|
4
|
+
# The pluggable layer between the OpenID4VP protocol (Verifier/Wallet) and
|
|
5
|
+
# each Credential Format (`dc+sd-jwt`, `jwt_vc_json`, `ldp_vc`,
|
|
6
|
+
# `mso_mdoc`, ...): a `Registry` of `Base` subclass instances, one per
|
|
7
|
+
# supported Credential Format Identifier, each translating a raw
|
|
8
|
+
# Presentation into a `VerifiedPresentation` / `OpenID4VP::Credential` (or
|
|
9
|
+
# the reverse, when building one).
|
|
10
|
+
module Formats
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../jose"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
|
|
6
|
+
module OpenID4VP
|
|
7
|
+
module JOSE
|
|
8
|
+
# JWS algorithm helpers: default alg selection per key and alg/key
|
|
9
|
+
# compatibility checks.
|
|
10
|
+
module Algorithms
|
|
11
|
+
SIGNING = %w[ES256 ES384 ES512 RS256 PS256 EdDSA].freeze
|
|
12
|
+
|
|
13
|
+
KEY_MANAGEMENT = %w[ECDH-ES ECDH-ES+A128KW ECDH-ES+A256KW].freeze
|
|
14
|
+
|
|
15
|
+
CONTENT_ENCRYPTION = %w[A128GCM A256GCM A128CBC-HS256].freeze
|
|
16
|
+
|
|
17
|
+
EC_ALG_FOR_CRV = { "P-256" => "ES256", "P-384" => "ES384", "P-521" => "ES512" }.freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# The default JWS `alg` to use for signing with `jwk`.
|
|
22
|
+
def for_key(jwk)
|
|
23
|
+
case jwk.kty
|
|
24
|
+
when "EC" then EC_ALG_FOR_CRV.fetch(jwk.crv) { raise OpenID4VP::InvalidRequest, "unsupported EC curve #{jwk.crv}" }
|
|
25
|
+
when "OKP" then "EdDSA"
|
|
26
|
+
when "RSA" then "RS256"
|
|
27
|
+
else raise OpenID4VP::InvalidRequest, "jose: unsupported kty #{jwk.kty.inspect} for signing"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Whether `alg` is a JWS algorithm that can be used with `jwk`.
|
|
32
|
+
def key_matches?(alg, jwk)
|
|
33
|
+
case alg
|
|
34
|
+
when "ES256", "ES384", "ES512"
|
|
35
|
+
jwk.kty == "EC" && EC_ALG_FOR_CRV[jwk.crv] == alg
|
|
36
|
+
when "RS256", "PS256"
|
|
37
|
+
jwk.kty == "RSA"
|
|
38
|
+
when "EdDSA"
|
|
39
|
+
jwk.kty == "OKP" && jwk.crv == "Ed25519"
|
|
40
|
+
else
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|