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,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "response_mode"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "util/uri"
|
|
6
|
+
require_relative "util/form"
|
|
7
|
+
require_relative "encoded_response"
|
|
8
|
+
|
|
9
|
+
module OpenID4VP
|
|
10
|
+
# OpenID4VP §8.5: the Authorization Error Response. Per §8.3.1, error
|
|
11
|
+
# responses are always sent unencrypted, even for `.jwt` Response Modes.
|
|
12
|
+
class ErrorResponse
|
|
13
|
+
Encoded = EncodedResponse
|
|
14
|
+
|
|
15
|
+
attr_reader :error, :error_description, :state
|
|
16
|
+
|
|
17
|
+
def initialize(error:, error_description: nil, state: nil)
|
|
18
|
+
@error = error
|
|
19
|
+
@error_description = error_description
|
|
20
|
+
@state = state
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Builds an ErrorResponse from a raised OpenID4VP::Error (or any
|
|
25
|
+
# exception): `error` is `exception.code` when present, else
|
|
26
|
+
# "invalid_request"; `error_description` is `exception.description` when
|
|
27
|
+
# the exception responds to it, else `exception.message`.
|
|
28
|
+
def self.from_exception(exception, state: nil)
|
|
29
|
+
code = exception.respond_to?(:code) && exception.code ? exception.code : "invalid_request"
|
|
30
|
+
description = exception.respond_to?(:description) ? exception.description : exception.message
|
|
31
|
+
new(error: code, error_description: description, state: state)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_params
|
|
35
|
+
params = { "error" => @error }
|
|
36
|
+
params["error_description"] = @error_description unless @error_description.nil?
|
|
37
|
+
params.merge(state_param)
|
|
38
|
+
end
|
|
39
|
+
alias to_payload to_params
|
|
40
|
+
|
|
41
|
+
# Encodes this error response for `response_mode`. Never encrypts, even
|
|
42
|
+
# for `.jwt` Response Modes (OpenID4VP §8.3.1): those are treated the
|
|
43
|
+
# same as their unencrypted counterpart. `encryption` is accepted for
|
|
44
|
+
# signature parity with AuthorizationResponse#encode and ignored.
|
|
45
|
+
def encode(response_mode, redirect_uri: nil, encryption: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
46
|
+
validate_response_mode!(response_mode)
|
|
47
|
+
return encode_redirect(response_mode, redirect_uri) if ResponseMode.redirect?(response_mode)
|
|
48
|
+
|
|
49
|
+
if ResponseMode.direct_post?(response_mode)
|
|
50
|
+
Encoded.new(mode: response_mode, form_params: to_params, form_body: Util::Form.encode(to_params))
|
|
51
|
+
else
|
|
52
|
+
Encoded.new(mode: response_mode, data: to_payload)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def state_param
|
|
59
|
+
@state.nil? ? {} : { "state" => @state }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_response_mode!(response_mode)
|
|
63
|
+
return if ResponseMode::ALL.include?(response_mode)
|
|
64
|
+
|
|
65
|
+
raise OpenID4VP::ConfigurationError, "unsupported response_mode #{response_mode.inspect}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def encode_redirect(response_mode, redirect_uri)
|
|
69
|
+
if redirect_uri.nil?
|
|
70
|
+
raise OpenID4VP::ConfigurationError, "redirect_uri is required for response_mode #{response_mode}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
url =
|
|
74
|
+
if response_mode == ResponseMode::FRAGMENT
|
|
75
|
+
Util::URI.with_fragment(redirect_uri, to_params)
|
|
76
|
+
else
|
|
77
|
+
Util::URI.with_query(redirect_uri, to_params)
|
|
78
|
+
end
|
|
79
|
+
Encoded.new(mode: response_mode, url: url)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenID4VP
|
|
4
|
+
# Base class for all errors raised by this gem. Subclasses that represent
|
|
5
|
+
# OAuth 2.0 / OpenID4VP protocol errors declare an `oauth_code`; a subclass
|
|
6
|
+
# without its own code inherits its superclass's code.
|
|
7
|
+
class Error < StandardError
|
|
8
|
+
class << self
|
|
9
|
+
def oauth_code(code)
|
|
10
|
+
@code = code
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def code
|
|
14
|
+
@code || (superclass.respond_to?(:code) ? superclass.code : nil)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def code
|
|
19
|
+
self.class.code
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def description
|
|
23
|
+
message
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class InvalidRequest < Error
|
|
28
|
+
oauth_code "invalid_request"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class InvalidClient < Error
|
|
32
|
+
oauth_code "invalid_client"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class InvalidScope < Error
|
|
36
|
+
oauth_code "invalid_scope"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class AccessDenied < Error
|
|
40
|
+
oauth_code "access_denied"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class VpFormatsNotSupported < Error
|
|
44
|
+
oauth_code "vp_formats_not_supported"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class InvalidRequestUriMethod < Error
|
|
48
|
+
oauth_code "invalid_request_uri_method"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class InvalidTransactionData < Error
|
|
52
|
+
oauth_code "invalid_transaction_data"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class WalletUnavailable < Error
|
|
56
|
+
oauth_code "wallet_unavailable"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Raised for verifier-side response/presentation verification failures that
|
|
60
|
+
# are reported to the wallet as an OAuth "invalid_request" error.
|
|
61
|
+
class VerificationError < InvalidRequest; end
|
|
62
|
+
|
|
63
|
+
# Raised for wallet-side response validation problems. Has no OAuth error
|
|
64
|
+
# code of its own since it is not sent back over the wire as such.
|
|
65
|
+
class InvalidResponse < Error; end
|
|
66
|
+
|
|
67
|
+
class PresentationInvalid < InvalidResponse; end
|
|
68
|
+
class HolderBindingInvalid < InvalidResponse; end
|
|
69
|
+
class QueryNotSatisfied < InvalidResponse; end
|
|
70
|
+
|
|
71
|
+
class SessionNotFound < Error; end
|
|
72
|
+
class ConfigurationError < Error; end
|
|
73
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../formats"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
require_relative "../dcql/meta"
|
|
6
|
+
|
|
7
|
+
module OpenID4VP
|
|
8
|
+
module Formats
|
|
9
|
+
# Abstract base for a Credential Format handler. A concrete subclass
|
|
10
|
+
# implements `.format_id`, `#verify_presentation`, `#build_presentation`
|
|
11
|
+
# and `.credential_from`; `#check_vp_formats!` is overridden by formats
|
|
12
|
+
# that define `vp_formats_supported` algorithm-restriction parameters
|
|
13
|
+
# (OpenID4VP Appendix B).
|
|
14
|
+
class Base
|
|
15
|
+
def self.format_id
|
|
16
|
+
raise NotImplementedError, "#{self} must implement .format_id"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.credential_from(*, **)
|
|
20
|
+
raise NotImplementedError, "#{self} must implement .credential_from"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def format_id
|
|
24
|
+
self.class.format_id
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Verifies a single raw Presentation (a String, or a format-specific
|
|
28
|
+
# structure) against `context` (a `Context`), returning a
|
|
29
|
+
# `VerifiedPresentation`. Raises `PresentationInvalid` /
|
|
30
|
+
# `HolderBindingInvalid` on failure.
|
|
31
|
+
def verify_presentation(_presentation, _context)
|
|
32
|
+
raise NotImplementedError, "#{self.class} must implement #verify_presentation"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Builds a raw Presentation (a String, or a format-specific Hash) for
|
|
36
|
+
# `credential` (an `OpenID4VP::Credential`), disclosing the claims
|
|
37
|
+
# addressed by `claims_queries` (an Array of `DCQL::ClaimsQuery`),
|
|
38
|
+
# against `context` (a `Context`).
|
|
39
|
+
def build_presentation(_credential, _claims_queries, _context)
|
|
40
|
+
raise NotImplementedError, "#{self.class} must implement #build_presentation"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# OpenID4VP Appendix B: validates the REQUIRED shape of this format's
|
|
44
|
+
# `meta` parameter in a DCQL Credential Query.
|
|
45
|
+
def validate_meta!(meta)
|
|
46
|
+
OpenID4VP::DCQL::Meta.validate!(format_id, meta)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Whether `credential` (an `OpenID4VP::Credential`) matches `meta`.
|
|
50
|
+
def meta_matches?(meta, credential)
|
|
51
|
+
OpenID4VP::DCQL::Meta.matches?(format_id, meta, credential)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Enforces this format's `vp_formats_supported` algorithm
|
|
55
|
+
# restrictions (OpenID4VP Appendix B) against a `VerifiedPresentation`.
|
|
56
|
+
# No-op by default: `vp_formats` is nil, or carries no parameters for
|
|
57
|
+
# this format. Formats with restrictable algorithms override this,
|
|
58
|
+
# using `alg_allowed!` below.
|
|
59
|
+
def check_vp_formats!(_verified, _vp_formats); end
|
|
60
|
+
|
|
61
|
+
protected
|
|
62
|
+
|
|
63
|
+
# Raises PresentationInvalid unless `alg` is allowed by `values` (an
|
|
64
|
+
# Array from `vp_formats.params_for(format_id)`, or nil when the
|
|
65
|
+
# parameter is absent). A nil `alg` (the restriction does not apply to
|
|
66
|
+
# this Presentation) is always allowed.
|
|
67
|
+
def alg_allowed!(values, alg, param_name)
|
|
68
|
+
return if values.nil? || alg.nil? || values.include?(alg)
|
|
69
|
+
|
|
70
|
+
raise OpenID4VP::PresentationInvalid, "#{param_name} does not allow #{alg}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../formats"
|
|
4
|
+
|
|
5
|
+
module OpenID4VP
|
|
6
|
+
module Formats
|
|
7
|
+
# Everything a `Base` handler needs to verify or build a Presentation,
|
|
8
|
+
# threaded through by the (not-yet-implemented) protocol layer.
|
|
9
|
+
# Immutable.
|
|
10
|
+
class Context
|
|
11
|
+
attr_reader :nonce, :audience, :client_id, :response_uri, :origin, :encryption_jwk, :transaction_data, :now,
|
|
12
|
+
:leeway, :trust, :vp_formats, :require_holder_binding, :dc_api
|
|
13
|
+
|
|
14
|
+
# nonce/audience: the Authorization Request's `nonce`, and the
|
|
15
|
+
# expected `aud` of a holder-bound Presentation (the Client
|
|
16
|
+
# Identifier, or `origin:<origin>` for DC API requests -- see
|
|
17
|
+
# OpenID4VP Appendix B.3.6 / A.4).
|
|
18
|
+
# trust: a Hash with Symbol keys, e.g. `:sd_jwt_issuer_keys` (callable
|
|
19
|
+
# `(iss, header) -> JWK | [JWK]`), `:jwt_vc_issuer_keys`,
|
|
20
|
+
# `:jwt_vc_holder_keys`, `:mdoc_trust_anchors` (Array of
|
|
21
|
+
# certificates), `:ldp_proof_verifier` (callable
|
|
22
|
+
# `(vp_hash, context) -> bool`). Consulted via `#trust_hook`.
|
|
23
|
+
# `:authority_hooks` (OpenID4VP §6.1.1, consulted by
|
|
24
|
+
# `Verifier::ResponseValidator`, not via `#trust_hook`) is instead a
|
|
25
|
+
# Hash of `{trusted_authorities "type" String => callable(ta_query,
|
|
26
|
+
# credential) -> bool}`, for authority types `DCQL::Matcher
|
|
27
|
+
# .authority_matches?` has no built-in check for (only `"aki"` is
|
|
28
|
+
# built in).
|
|
29
|
+
# vp_formats: an `OpenID4VP::Metadata::VpFormats`, or nil to skip
|
|
30
|
+
# `Base#check_vp_formats!` algorithm restriction checks.
|
|
31
|
+
def initialize(nonce:, audience:, client_id: nil, response_uri: nil, origin: nil, encryption_jwk: nil,
|
|
32
|
+
transaction_data: nil, now: Time.now, leeway: 60, trust: {}, vp_formats: nil,
|
|
33
|
+
require_holder_binding: true, dc_api: false)
|
|
34
|
+
@nonce = nonce
|
|
35
|
+
@audience = audience
|
|
36
|
+
@client_id = client_id
|
|
37
|
+
@response_uri = response_uri
|
|
38
|
+
@origin = origin
|
|
39
|
+
@encryption_jwk = encryption_jwk
|
|
40
|
+
@transaction_data = transaction_data
|
|
41
|
+
@now = now
|
|
42
|
+
@leeway = leeway
|
|
43
|
+
@trust = trust.dup.freeze
|
|
44
|
+
@vp_formats = vp_formats
|
|
45
|
+
@require_holder_binding = require_holder_binding
|
|
46
|
+
@dc_api = dc_api
|
|
47
|
+
freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The configured trust hook for `name` (a Symbol), or nil.
|
|
51
|
+
def trust_hook(name)
|
|
52
|
+
trust[name]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,274 @@
|
|
|
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 "../jose/jwk"
|
|
12
|
+
|
|
13
|
+
module OpenID4VP
|
|
14
|
+
module Formats
|
|
15
|
+
# OpenID4VP Appendix B.1.3.1: the `jwt_vc_json` (W3C Verifiable
|
|
16
|
+
# Credential secured as a JWT, no JSON-LD) Credential Format handler.
|
|
17
|
+
class JwtVcJson < Base
|
|
18
|
+
FORMAT_ID = "jwt_vc_json"
|
|
19
|
+
VP_CONTEXT = ["https://www.w3.org/2018/credentials/v1"].freeze
|
|
20
|
+
|
|
21
|
+
def self.format_id
|
|
22
|
+
FORMAT_ID
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Issues a `jwt_vc_json` Verifiable Credential JWT with payload
|
|
26
|
+
# `{iss, sub, nbf, exp, jti, vc: {"@context", "type", "credentialSubject"}}`.
|
|
27
|
+
def self.issue_vc(claims:, issuer:, subject:, types:, key:, alg: nil, exp: nil, nbf: nil, jti: nil)
|
|
28
|
+
payload = {
|
|
29
|
+
"iss" => issuer,
|
|
30
|
+
"sub" => subject,
|
|
31
|
+
"nbf" => nbf || Time.now.to_i,
|
|
32
|
+
"jti" => jti || "urn:uuid:#{::SecureRandom.uuid}",
|
|
33
|
+
"vc" => {
|
|
34
|
+
"@context" => VP_CONTEXT,
|
|
35
|
+
"type" => types,
|
|
36
|
+
"credentialSubject" => claims
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
payload["exp"] = exp unless exp.nil?
|
|
40
|
+
OpenID4VP::JOSE::JWS.sign(payload, key: key, alg: alg, header: { "typ" => "JWT" })
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Parses (WITHOUT verifying the signature) an issued VC JWT into a
|
|
44
|
+
# wallet-side `OpenID4VP::Credential`, e.g. right after issuance.
|
|
45
|
+
def self.credential_from(vc_jwt, holder_key: nil)
|
|
46
|
+
peeked = OpenID4VP::JOSE::JWS.peek(vc_jwt)
|
|
47
|
+
claims, metadata = claims_and_metadata(peeked.payload)
|
|
48
|
+
|
|
49
|
+
OpenID4VP::Credential.new(
|
|
50
|
+
format: FORMAT_ID,
|
|
51
|
+
claims: claims,
|
|
52
|
+
raw: vc_jwt,
|
|
53
|
+
holder_key: holder_key,
|
|
54
|
+
x5chain: x5chain_from_header(peeked.header),
|
|
55
|
+
metadata: metadata
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Merges the VC's own claims (`vc`) with the enclosing JWT's top-level
|
|
60
|
+
# `iss`/`sub`/`nbf`/`exp`/`jti`, so both `["credentialSubject", "x"]`
|
|
61
|
+
# and `["iss"]` claims paths resolve. Returns `[claims, metadata]`.
|
|
62
|
+
def self.claims_and_metadata(payload)
|
|
63
|
+
raise OpenID4VP::PresentationInvalid, "jwt_vc_json: payload is not a JSON object" unless payload.is_a?(::Hash)
|
|
64
|
+
|
|
65
|
+
vc = payload["vc"]
|
|
66
|
+
raise OpenID4VP::PresentationInvalid, "jwt_vc_json: payload missing vc" unless vc.is_a?(::Hash)
|
|
67
|
+
|
|
68
|
+
claims = vc.merge(
|
|
69
|
+
"iss" => payload["iss"], "sub" => payload["sub"], "nbf" => payload["nbf"],
|
|
70
|
+
"exp" => payload["exp"], "jti" => payload["jti"]
|
|
71
|
+
).compact
|
|
72
|
+
metadata = { "type" => vc["type"], "issuer" => payload["iss"], "holder" => payload["sub"] }
|
|
73
|
+
[claims, metadata]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.x5chain_from_header(header)
|
|
77
|
+
x5c = header["x5c"]
|
|
78
|
+
return [] unless x5c.is_a?(::Array)
|
|
79
|
+
|
|
80
|
+
OpenID4VP::X509.certs_from_x5c(x5c)
|
|
81
|
+
end
|
|
82
|
+
private_class_method :x5chain_from_header
|
|
83
|
+
|
|
84
|
+
# Verifies `presentation` (a compact JWT String) against `context`.
|
|
85
|
+
# Accepts either a Verifiable Presentation JWT (payload has `vp`) or,
|
|
86
|
+
# when `!context.require_holder_binding`, a bare Verifiable Credential
|
|
87
|
+
# JWT (payload has `vc` but no `vp`) -- OpenID4VP Appendix B.1.
|
|
88
|
+
def verify_presentation(presentation, context)
|
|
89
|
+
unless presentation.is_a?(::String)
|
|
90
|
+
raise OpenID4VP::PresentationInvalid, "jwt_vc_json: presentation must be a string"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
peeked = OpenID4VP::JOSE::JWS.peek(presentation)
|
|
94
|
+
payload = peeked.payload
|
|
95
|
+
raise OpenID4VP::PresentationInvalid, "jwt_vc_json: payload is not a JSON object" unless payload.is_a?(::Hash)
|
|
96
|
+
|
|
97
|
+
if payload.key?("vp")
|
|
98
|
+
verify_vp(presentation, peeked.header, payload, context)
|
|
99
|
+
elsif payload.key?("vc")
|
|
100
|
+
verify_bare_vc(presentation, context)
|
|
101
|
+
else
|
|
102
|
+
raise OpenID4VP::PresentationInvalid, "jwt_vc_json: payload has neither vp nor vc"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# OpenID4VP Appendix B.1.3.1.3: `alg_values` restricts the `alg` of
|
|
107
|
+
# both the Verifiable Presentation JWT and the Verifiable Credential
|
|
108
|
+
# JWT it carries.
|
|
109
|
+
def check_vp_formats!(verified, vp_formats)
|
|
110
|
+
return if vp_formats.nil?
|
|
111
|
+
|
|
112
|
+
params = vp_formats.params_for(FORMAT_ID)
|
|
113
|
+
return if params.nil?
|
|
114
|
+
|
|
115
|
+
alg_allowed!(params["alg_values"], verified.details[:vp_alg], "alg_values")
|
|
116
|
+
alg_allowed!(params["alg_values"], verified.details[:vc_alg], "alg_values")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Builds a Verifiable Presentation JWT for `credential`. When the
|
|
120
|
+
# credential has no holder key: returns `credential.raw` unchanged if
|
|
121
|
+
# `!context.require_holder_binding` (a Credential without Holder
|
|
122
|
+
# Binding, Appendix B.1), else raises HolderBindingInvalid.
|
|
123
|
+
def build_presentation(credential, _claims_queries, context)
|
|
124
|
+
holder_key = credential.holder_key
|
|
125
|
+
if holder_key.nil?
|
|
126
|
+
raise OpenID4VP::HolderBindingInvalid, "credential has no holder key" if context.require_holder_binding
|
|
127
|
+
|
|
128
|
+
return credential.raw
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
iat = context.now.to_i
|
|
132
|
+
holder_id = credential.metadata["holder"] || "did:jwk:#{holder_key.thumbprint_b64}"
|
|
133
|
+
payload = {
|
|
134
|
+
"iss" => holder_id,
|
|
135
|
+
"jti" => "urn:uuid:#{::SecureRandom.uuid}",
|
|
136
|
+
"aud" => context.audience,
|
|
137
|
+
"nonce" => context.nonce,
|
|
138
|
+
"iat" => iat,
|
|
139
|
+
"nbf" => iat,
|
|
140
|
+
"exp" => iat + 300,
|
|
141
|
+
"vp" => {
|
|
142
|
+
"@context" => VP_CONTEXT,
|
|
143
|
+
"type" => ["VerifiablePresentation"],
|
|
144
|
+
"verifiableCredential" => [credential.raw]
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
OpenID4VP::JOSE::JWS.sign(payload, key: holder_key, header: { "typ" => "JWT" })
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def verify_vp(presentation, header, unverified_payload, context)
|
|
153
|
+
resolver = context.trust_hook(:jwt_vc_holder_keys)
|
|
154
|
+
holder_key = resolver&.call(unverified_payload["iss"], header)
|
|
155
|
+
raise OpenID4VP::HolderBindingInvalid, "jwt_vc_json: cannot resolve holder key" if holder_key.nil?
|
|
156
|
+
|
|
157
|
+
verified = OpenID4VP::JOSE::JWS.verify(presentation, key: holder_key)
|
|
158
|
+
payload = verified.payload
|
|
159
|
+
|
|
160
|
+
raise OpenID4VP::HolderBindingInvalid, "jwt_vc_json: nonce mismatch" unless payload["nonce"] == context.nonce
|
|
161
|
+
raise OpenID4VP::HolderBindingInvalid, "jwt_vc_json: aud mismatch" unless payload["aud"] == context.audience
|
|
162
|
+
|
|
163
|
+
check_time_bounds!(payload, context, "presentation")
|
|
164
|
+
|
|
165
|
+
vc_jwt = single_verifiable_credential(payload["vp"])
|
|
166
|
+
vc_result = verify_vc_jwt(vc_jwt, context)
|
|
167
|
+
check_subject_binding!(payload["iss"], holder_key, vc_result[:payload])
|
|
168
|
+
|
|
169
|
+
VerifiedPresentation.new(
|
|
170
|
+
format: FORMAT_ID,
|
|
171
|
+
claims: vc_result[:claims],
|
|
172
|
+
holder_bound: true,
|
|
173
|
+
issuer: vc_result[:issuer],
|
|
174
|
+
raw: presentation,
|
|
175
|
+
metadata: vc_result[:metadata],
|
|
176
|
+
details: { vp_alg: verified.header["alg"], vc_alg: vc_result[:alg] }
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def verify_bare_vc(presentation, context)
|
|
181
|
+
raise OpenID4VP::HolderBindingInvalid, "jwt_vc_json: holder binding required" if context.require_holder_binding
|
|
182
|
+
|
|
183
|
+
vc_result = verify_vc_jwt(presentation, context)
|
|
184
|
+
|
|
185
|
+
VerifiedPresentation.new(
|
|
186
|
+
format: FORMAT_ID,
|
|
187
|
+
claims: vc_result[:claims],
|
|
188
|
+
holder_bound: false,
|
|
189
|
+
issuer: vc_result[:issuer],
|
|
190
|
+
raw: presentation,
|
|
191
|
+
metadata: vc_result[:metadata],
|
|
192
|
+
details: { vp_alg: nil, vc_alg: vc_result[:alg] }
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def single_verifiable_credential(vp_object)
|
|
197
|
+
vcs = vp_object.is_a?(::Hash) ? vp_object["verifiableCredential"] : nil
|
|
198
|
+
unless vcs.is_a?(::Array) && vcs.size == 1
|
|
199
|
+
raise OpenID4VP::PresentationInvalid, "jwt_vc_json: multiple credentials in one presentation not supported"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
vcs.first
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def verify_vc_jwt(vc_jwt, context)
|
|
206
|
+
unless vc_jwt.is_a?(::String)
|
|
207
|
+
raise OpenID4VP::PresentationInvalid, "jwt_vc_json: verifiableCredential entry must be a string"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
peeked = OpenID4VP::JOSE::JWS.peek(vc_jwt)
|
|
211
|
+
vc_payload = peeked.payload
|
|
212
|
+
unless vc_payload.is_a?(::Hash)
|
|
213
|
+
raise OpenID4VP::PresentationInvalid, "jwt_vc_json: vc payload is not a JSON object"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
resolver = context.trust_hook(:jwt_vc_issuer_keys)
|
|
217
|
+
raise OpenID4VP::ConfigurationError, "jwt_vc_issuer_keys trust hook required" if resolver.nil?
|
|
218
|
+
|
|
219
|
+
issuer_key = resolver.call(vc_payload["iss"], peeked.header)
|
|
220
|
+
raise OpenID4VP::PresentationInvalid, "jwt_vc_json: unknown issuer" if issuer_key.nil?
|
|
221
|
+
|
|
222
|
+
verified_vc = OpenID4VP::JOSE::JWS.verify(vc_jwt, key: issuer_key)
|
|
223
|
+
check_time_bounds!(verified_vc.payload, context, "credential")
|
|
224
|
+
|
|
225
|
+
claims, metadata = self.class.claims_and_metadata(verified_vc.payload)
|
|
226
|
+
{ claims: claims, metadata: metadata, issuer: verified_vc.payload["iss"], alg: verified_vc.header["alg"],
|
|
227
|
+
payload: verified_vc.payload }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# OpenID4VP Appendix B.1.3.1.5: the VP JWT's `iss` (the holder id the
|
|
231
|
+
# presentation was signed under) must be the enclosed VC's own
|
|
232
|
+
# subject -- `iss == vc_payload["sub"]` or `iss ==
|
|
233
|
+
# vc["credentialSubject"]["id"]` -- so a party holding someone else's
|
|
234
|
+
# VC cannot present it as their own. Only when the VC declares
|
|
235
|
+
# neither does a `cnf.jwk` in the VC payload matching the VP signing
|
|
236
|
+
# key's thumbprint satisfy the binding instead.
|
|
237
|
+
def check_subject_binding!(vp_iss, vp_signing_key, vc_payload)
|
|
238
|
+
sub = vc_payload["sub"]
|
|
239
|
+
vc = vc_payload["vc"]
|
|
240
|
+
subject_id = vc.is_a?(::Hash) ? vc.dig("credentialSubject", "id") : nil
|
|
241
|
+
|
|
242
|
+
if sub || subject_id
|
|
243
|
+
return if vp_iss == sub || vp_iss == subject_id
|
|
244
|
+
elsif cnf_jwk_matches?(vc_payload, vp_signing_key)
|
|
245
|
+
return
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
raise OpenID4VP::HolderBindingInvalid, "jwt_vc_json: VP issuer does not match credential subject"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def cnf_jwk_matches?(vc_payload, vp_signing_key)
|
|
252
|
+
cnf_jwk_hash = vc_payload.dig("cnf", "jwk")
|
|
253
|
+
return false unless cnf_jwk_hash.is_a?(::Hash)
|
|
254
|
+
|
|
255
|
+
OpenID4VP::JOSE::JWK.new(cnf_jwk_hash).thumbprint == vp_signing_key.thumbprint
|
|
256
|
+
rescue OpenID4VP::InvalidRequest
|
|
257
|
+
false
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def check_time_bounds!(payload, context, kind)
|
|
261
|
+
now = context.now.to_i
|
|
262
|
+
exp = payload["exp"]
|
|
263
|
+
raise OpenID4VP::PresentationInvalid, "jwt_vc_json: #{kind} expired" if exp && now > exp + context.leeway
|
|
264
|
+
|
|
265
|
+
nbf = payload["nbf"]
|
|
266
|
+
return unless nbf && now < nbf - context.leeway
|
|
267
|
+
|
|
268
|
+
raise OpenID4VP::PresentationInvalid, "jwt_vc_json: #{kind} not yet valid"
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
Registry.default_handlers << JwtVcJson unless Registry.default_handlers.include?(JwtVcJson)
|
|
273
|
+
end
|
|
274
|
+
end
|