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,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "verified_request"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "../x509"
|
|
7
|
+
require_relative "../x509/chain_validator"
|
|
8
|
+
require_relative "../jose/jwk"
|
|
9
|
+
|
|
10
|
+
module OpenID4VP
|
|
11
|
+
module RequestVerification
|
|
12
|
+
# `x509_san_dns` Client Identifier Prefix (OpenID4VP §5.9.3): the
|
|
13
|
+
# original Client Identifier is a DNS name that MUST match a dNSName
|
|
14
|
+
# Subject Alternative Name entry in the leaf certificate carried in the
|
|
15
|
+
# request's `x5c` JOSE header.
|
|
16
|
+
class X509SanDns < Base
|
|
17
|
+
def verify!(client_id, request, parsed, dc_api: false)
|
|
18
|
+
raise OpenID4VP::InvalidRequest, "signed request required" if parsed.nil?
|
|
19
|
+
|
|
20
|
+
certs = parsed.x5c_certs
|
|
21
|
+
raise OpenID4VP::InvalidRequest, "x5c required" if certs.empty?
|
|
22
|
+
|
|
23
|
+
leaf = OpenID4VP::X509::ChainValidator.new(
|
|
24
|
+
trust_anchors: config.x509_trust_anchors, time: config.current_time
|
|
25
|
+
).validate!(certs)
|
|
26
|
+
|
|
27
|
+
unless OpenID4VP::X509.dns_sans(leaf).include?(client_id.original)
|
|
28
|
+
raise OpenID4VP::VerificationError, "SAN mismatch"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
signing_key = OpenID4VP::JOSE::JWK.from_openssl(leaf.public_key)
|
|
32
|
+
verify_signature!(parsed, signing_key)
|
|
33
|
+
check_return_uri_host!(client_id, request, dc_api)
|
|
34
|
+
|
|
35
|
+
VerifiedRequest.new(
|
|
36
|
+
request: request, client_id: client_id, verifier_metadata: request.client_metadata,
|
|
37
|
+
signed: true, signing_key: signing_key, parsed: parsed, attestation: nil
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def check_return_uri_host!(client_id, request, dc_api)
|
|
44
|
+
return if dc_api || config.trusted_client_ids.include?(client_id.to_s)
|
|
45
|
+
|
|
46
|
+
return_uri = request.return_uri
|
|
47
|
+
return if return_uri.nil?
|
|
48
|
+
|
|
49
|
+
host = begin
|
|
50
|
+
::URI.parse(return_uri).host
|
|
51
|
+
rescue ::URI::InvalidURIError
|
|
52
|
+
raise OpenID4VP::VerificationError, "malformed return URI"
|
|
53
|
+
end
|
|
54
|
+
return if host&.casecmp?(client_id.original)
|
|
55
|
+
|
|
56
|
+
raise OpenID4VP::VerificationError, "return URI host mismatch"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenID4VP
|
|
4
|
+
# Wallet-side request verification (OpenID4VP §5.9): one strategy per
|
|
5
|
+
# Client Identifier Prefix (`OpenID4VP::RequestVerification::Base`
|
|
6
|
+
# subclasses), dispatched by `OpenID4VP::RequestVerification::Resolver`
|
|
7
|
+
# according to `OpenID4VP::RequestVerification::Config`.
|
|
8
|
+
module RequestVerification
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "jose/jwe"
|
|
5
|
+
|
|
6
|
+
module OpenID4VP
|
|
7
|
+
# OpenID4VP §8.3: selects the Verifier's encryption key, `alg` and `enc`
|
|
8
|
+
# for an encrypted Authorization Response from the Verifier's
|
|
9
|
+
# `client_metadata` (a `Metadata::VerifierMetadata`), optionally narrowed
|
|
10
|
+
# by what the Wallet itself supports, and encrypts response payloads to it.
|
|
11
|
+
class ResponseEncryption
|
|
12
|
+
def initialize(verifier_metadata:, wallet_supported_algs: nil, wallet_supported_encs: nil)
|
|
13
|
+
@verifier_metadata = verifier_metadata
|
|
14
|
+
@wallet_supported_algs = wallet_supported_algs
|
|
15
|
+
@wallet_supported_encs = wallet_supported_encs
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The JWK to encrypt to. Raises OpenID4VP::InvalidRequest when the
|
|
19
|
+
# Verifier's metadata has no usable encryption key.
|
|
20
|
+
def key
|
|
21
|
+
return @key if @key
|
|
22
|
+
|
|
23
|
+
@key = @verifier_metadata.encryption_key(supported_algs: @wallet_supported_algs)
|
|
24
|
+
raise OpenID4VP::InvalidRequest, "no usable encryption key" if @key.nil?
|
|
25
|
+
|
|
26
|
+
@key
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def alg
|
|
30
|
+
key.alg
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The JWE `enc` value: the first entry common to the Verifier's
|
|
34
|
+
# `encrypted_response_enc_values_supported` and the Wallet's supported
|
|
35
|
+
# `enc` values (all are considered supported when the latter is nil).
|
|
36
|
+
# Raises OpenID4VP::InvalidRequest when there is no common value.
|
|
37
|
+
def enc
|
|
38
|
+
return @enc if @enc
|
|
39
|
+
|
|
40
|
+
verifier_encs = @verifier_metadata.encrypted_response_enc_values_supported
|
|
41
|
+
common = @wallet_supported_encs ? (verifier_encs & @wallet_supported_encs) : verifier_encs
|
|
42
|
+
raise OpenID4VP::InvalidRequest, "no common content encryption algorithm" if common.empty?
|
|
43
|
+
|
|
44
|
+
@enc = common.first
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def encrypt(payload_hash)
|
|
48
|
+
OpenID4VP::JOSE::JWE.encrypt(payload_hash, recipient: key, alg: alg, enc: enc)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenID4VP
|
|
4
|
+
# OpenID4VP §8.2: the JSON body a Response Endpoint returns to the Wallet
|
|
5
|
+
# (HTTP 200, application/json, Cache-Control: no-store) after processing a
|
|
6
|
+
# direct_post[.jwt] Authorization Response.
|
|
7
|
+
class ResponseEndpointResult
|
|
8
|
+
HEADERS = { "content-type" => "application/json", "cache-control" => "no-store" }.freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :redirect_uri
|
|
11
|
+
|
|
12
|
+
def initialize(redirect_uri: nil)
|
|
13
|
+
@redirect_uri = redirect_uri
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def status
|
|
18
|
+
200
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def headers
|
|
22
|
+
HEADERS
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
@redirect_uri.nil? ? {} : { "redirect_uri" => @redirect_uri }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_json(*)
|
|
30
|
+
::JSON.generate(to_h, *)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenID4VP
|
|
4
|
+
module ResponseMode
|
|
5
|
+
FRAGMENT = "fragment"
|
|
6
|
+
QUERY = "query"
|
|
7
|
+
DIRECT_POST = "direct_post"
|
|
8
|
+
DIRECT_POST_JWT = "direct_post.jwt"
|
|
9
|
+
DC_API = "dc_api"
|
|
10
|
+
DC_API_JWT = "dc_api.jwt"
|
|
11
|
+
|
|
12
|
+
ALL = [FRAGMENT, QUERY, DIRECT_POST, DIRECT_POST_JWT, DC_API, DC_API_JWT].freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def direct_post?(mode)
|
|
17
|
+
return false if mode.nil?
|
|
18
|
+
|
|
19
|
+
[DIRECT_POST, DIRECT_POST_JWT].include?(mode)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def dc_api?(mode)
|
|
23
|
+
return false if mode.nil?
|
|
24
|
+
|
|
25
|
+
[DC_API, DC_API_JWT].include?(mode)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def encrypted?(mode)
|
|
29
|
+
return false if mode.nil?
|
|
30
|
+
|
|
31
|
+
mode.end_with?(".jwt")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def redirect?(mode)
|
|
35
|
+
return false if mode.nil?
|
|
36
|
+
|
|
37
|
+
[FRAGMENT, QUERY].include?(mode)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def default
|
|
41
|
+
FRAGMENT
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../sd_jwt"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
require_relative "../util/base64url"
|
|
6
|
+
|
|
7
|
+
module OpenID4VP
|
|
8
|
+
module SdJwt
|
|
9
|
+
# SD-JWT `_sd_alg` digest algorithms (draft-ietf-oauth-selective-disclosure-jwt-22
|
|
10
|
+
# §5.1.1.2): sha-256 (the default), sha-384, sha-512.
|
|
11
|
+
module Digest
|
|
12
|
+
ALGS = {
|
|
13
|
+
"sha-256" => "SHA256",
|
|
14
|
+
"sha-384" => "SHA384",
|
|
15
|
+
"sha-512" => "SHA512"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# base64url(hash(str)), hashing the ASCII bytes of `str` verbatim (no
|
|
21
|
+
# base64url decoding first) with the OpenSSL digest named by `alg`.
|
|
22
|
+
def hash(alg, str)
|
|
23
|
+
digest_name = ALGS.fetch(alg) do
|
|
24
|
+
raise OpenID4VP::PresentationInvalid, "sd-jwt: unsupported _sd_alg #{alg.inspect}"
|
|
25
|
+
end
|
|
26
|
+
Util::Base64url.encode(::OpenSSL::Digest.digest(digest_name, str))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../sd_jwt"
|
|
4
|
+
require_relative "digest"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "../util/base64url"
|
|
7
|
+
|
|
8
|
+
module OpenID4VP
|
|
9
|
+
module SdJwt
|
|
10
|
+
# A single SD-JWT disclosure (draft-ietf-oauth-selective-disclosure-jwt-22
|
|
11
|
+
# §5.2): base64url(JSON array). `[salt, name, value]` discloses an object
|
|
12
|
+
# property (`name` is a String); `[salt, value]` discloses an array
|
|
13
|
+
# element (`name` is nil).
|
|
14
|
+
class Disclosure
|
|
15
|
+
attr_reader :salt, :name, :value, :raw
|
|
16
|
+
|
|
17
|
+
def initialize(salt:, value:, name: nil, raw: nil)
|
|
18
|
+
@salt = salt
|
|
19
|
+
@name = name
|
|
20
|
+
@value = value
|
|
21
|
+
@raw = raw || self.class.encode(salt: salt, name: name, value: value)
|
|
22
|
+
freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# Parses a base64url-encoded disclosure, preserving `raw` verbatim.
|
|
27
|
+
# Raises PresentationInvalid on malformed input: invalid
|
|
28
|
+
# base64url/JSON, not a 2- or 3-element array, or a non-String
|
|
29
|
+
# salt/name.
|
|
30
|
+
def parse(raw)
|
|
31
|
+
array = decode_array(raw)
|
|
32
|
+
|
|
33
|
+
case array.size
|
|
34
|
+
when 3
|
|
35
|
+
salt, name, value = array
|
|
36
|
+
unless salt.is_a?(::String) && name.is_a?(::String)
|
|
37
|
+
raise OpenID4VP::PresentationInvalid, "sd-jwt: disclosure salt and name must be strings"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
new(salt: salt, name: name, value: value, raw: raw)
|
|
41
|
+
when 2
|
|
42
|
+
salt, value = array
|
|
43
|
+
raise OpenID4VP::PresentationInvalid, "sd-jwt: disclosure salt must be a string" unless salt.is_a?(::String)
|
|
44
|
+
|
|
45
|
+
new(salt: salt, value: value, raw: raw)
|
|
46
|
+
else
|
|
47
|
+
raise OpenID4VP::PresentationInvalid, "sd-jwt: disclosure must be a 2- or 3-element array"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Builds a disclosure for an object property (`name` given) or an
|
|
52
|
+
# array element (`name` nil), generating a fresh 128-bit salt unless
|
|
53
|
+
# one is given.
|
|
54
|
+
def build(value:, name: nil, salt: nil)
|
|
55
|
+
new(salt: salt || random_salt, name: name, value: value)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def encode(salt:, name:, value:)
|
|
59
|
+
array = name.nil? ? [salt, value] : [salt, name, value]
|
|
60
|
+
Util::Base64url.encode(::JSON.generate(array))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def random_salt
|
|
64
|
+
Util::Base64url.encode(::SecureRandom.random_bytes(16))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def decode_array(raw)
|
|
70
|
+
array =
|
|
71
|
+
begin
|
|
72
|
+
Util::Base64url.decode_json(raw)
|
|
73
|
+
rescue OpenID4VP::InvalidRequest
|
|
74
|
+
raise OpenID4VP::PresentationInvalid, "sd-jwt: invalid disclosure encoding"
|
|
75
|
+
end
|
|
76
|
+
raise OpenID4VP::PresentationInvalid, "sd-jwt: disclosure must be a JSON array" unless array.is_a?(::Array)
|
|
77
|
+
|
|
78
|
+
array
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Whether this disclosure discloses an array element (as opposed to an
|
|
83
|
+
# object property).
|
|
84
|
+
def array_element?
|
|
85
|
+
name.nil?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# base64url(hash(raw)) -- the digest referenced by `_sd` arrays or by
|
|
89
|
+
# `{"..." => digest}` array items.
|
|
90
|
+
def digest(alg = "sha-256")
|
|
91
|
+
Digest.hash(alg, raw)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def ==(other)
|
|
95
|
+
other.is_a?(Disclosure) && raw == other.raw
|
|
96
|
+
end
|
|
97
|
+
alias eql? ==
|
|
98
|
+
|
|
99
|
+
def hash
|
|
100
|
+
raw.hash
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../sd_jwt"
|
|
4
|
+
require_relative "disclosure"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "../util/base64url"
|
|
7
|
+
require_relative "../jose/jws"
|
|
8
|
+
|
|
9
|
+
module OpenID4VP
|
|
10
|
+
module SdJwt
|
|
11
|
+
# Issues an IETF SD-JWT VC (`dc+sd-jwt`): builds selectively disclosable
|
|
12
|
+
# claims (draft-ietf-oauth-selective-disclosure-jwt-22 §5.1) and signs
|
|
13
|
+
# the resulting Issuer-signed JWT.
|
|
14
|
+
module Issuer
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# `disclosable` is an Array of claims-path-like Arrays (of String
|
|
18
|
+
# property names / Integer array indices, or ClaimsPathPointer),
|
|
19
|
+
# each addressing a single object property or array element to make
|
|
20
|
+
# selectively disclosable -- only the path's final component becomes a
|
|
21
|
+
# disclosure; intermediate objects/arrays are navigated as plain
|
|
22
|
+
# structure. Returns `"<issuer-jwt>~<disclosure 1>~...~"`.
|
|
23
|
+
def issue(claims:, key:, vct:, iss:, iat:, exp:, disclosable: [], holder_jwk: nil, alg: nil, x5c: nil,
|
|
24
|
+
decoy: 0, typ: "dc+sd-jwt")
|
|
25
|
+
payload = deep_dup(claims)
|
|
26
|
+
payload["vct"] = vct
|
|
27
|
+
payload["iss"] = iss
|
|
28
|
+
payload["iat"] = iat
|
|
29
|
+
payload["exp"] = exp
|
|
30
|
+
payload["_sd_alg"] = "sha-256"
|
|
31
|
+
payload["cnf"] = { "jwk" => holder_jwk.to_public.to_h } if holder_jwk
|
|
32
|
+
|
|
33
|
+
# Deepest paths first, so a disclosure that itself contains a nested
|
|
34
|
+
# selectively-disclosable property (e.g. both ["address"] and
|
|
35
|
+
# ["address", "street_address"] listed) sees the still-plain parent
|
|
36
|
+
# object before it is turned into its own disclosure.
|
|
37
|
+
ordered = disclosable.sort_by { |path| -path_components(path).size }
|
|
38
|
+
disclosures = ordered.flat_map { |path| apply_disclosure!(payload, path_components(path)) }
|
|
39
|
+
|
|
40
|
+
add_decoys!(payload, decoy) if decoy.positive?
|
|
41
|
+
sort_sd_arrays!(payload)
|
|
42
|
+
|
|
43
|
+
header = { "typ" => typ }
|
|
44
|
+
header["x5c"] = x5c if x5c
|
|
45
|
+
|
|
46
|
+
issuer_jwt = OpenID4VP::JOSE::JWS.sign(payload, key: key, alg: alg, header: header)
|
|
47
|
+
"#{issuer_jwt}~#{disclosures.map { |d| "#{d.raw}~" }.join}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def path_components(path)
|
|
51
|
+
path.respond_to?(:components) ? path.components : path
|
|
52
|
+
end
|
|
53
|
+
private_class_method :path_components
|
|
54
|
+
|
|
55
|
+
def deep_dup(claims)
|
|
56
|
+
::JSON.parse(::JSON.generate(claims))
|
|
57
|
+
end
|
|
58
|
+
private_class_method :deep_dup
|
|
59
|
+
|
|
60
|
+
def apply_disclosure!(payload, components)
|
|
61
|
+
if !components.is_a?(::Array) || components.empty?
|
|
62
|
+
raise OpenID4VP::InvalidRequest, "sd-jwt: disclosable path must be a non-empty array"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
parent = components[0..-2].reduce(payload) { |node, component| navigate(node, component) }
|
|
66
|
+
build_disclosure!(parent, components.last)
|
|
67
|
+
end
|
|
68
|
+
private_class_method :apply_disclosure!
|
|
69
|
+
|
|
70
|
+
def navigate(node, component)
|
|
71
|
+
case component
|
|
72
|
+
when ::String
|
|
73
|
+
unless node.is_a?(::Hash) && node.key?(component)
|
|
74
|
+
raise OpenID4VP::InvalidRequest, "sd-jwt: no such claim #{component.inspect} to navigate"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
node[component]
|
|
78
|
+
when ::Integer
|
|
79
|
+
unless node.is_a?(::Array) && component < node.size
|
|
80
|
+
raise OpenID4VP::InvalidRequest, "sd-jwt: no such index #{component} to navigate"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
node[component]
|
|
84
|
+
else
|
|
85
|
+
raise OpenID4VP::InvalidRequest, "sd-jwt: unsupported claims path component #{component.inspect}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
private_class_method :navigate
|
|
89
|
+
|
|
90
|
+
def build_disclosure!(parent, final)
|
|
91
|
+
case final
|
|
92
|
+
when ::String
|
|
93
|
+
unless parent.is_a?(::Hash) && parent.key?(final)
|
|
94
|
+
raise OpenID4VP::InvalidRequest, "sd-jwt: no such claim #{final.inspect} to disclose"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
disclosure = Disclosure.build(name: final, value: parent.delete(final))
|
|
98
|
+
(parent["_sd"] ||= []) << disclosure.digest
|
|
99
|
+
disclosure
|
|
100
|
+
when ::Integer
|
|
101
|
+
unless parent.is_a?(::Array) && final < parent.size
|
|
102
|
+
raise OpenID4VP::InvalidRequest, "sd-jwt: no such index #{final} to disclose"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
disclosure = Disclosure.build(value: parent[final])
|
|
106
|
+
parent[final] = { "..." => disclosure.digest }
|
|
107
|
+
disclosure
|
|
108
|
+
else
|
|
109
|
+
raise OpenID4VP::InvalidRequest, "sd-jwt: unsupported claims path component #{final.inspect}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
private_class_method :build_disclosure!
|
|
113
|
+
|
|
114
|
+
def add_decoys!(payload, count)
|
|
115
|
+
payload["_sd"] ||= []
|
|
116
|
+
count.times { payload["_sd"] << Util::Base64url.encode(::SecureRandom.random_bytes(32)) }
|
|
117
|
+
end
|
|
118
|
+
private_class_method :add_decoys!
|
|
119
|
+
|
|
120
|
+
def sort_sd_arrays!(node)
|
|
121
|
+
case node
|
|
122
|
+
when ::Hash
|
|
123
|
+
node["_sd"] = node["_sd"].sort if node["_sd"].is_a?(::Array)
|
|
124
|
+
node.each_value { |value| sort_sd_arrays!(value) }
|
|
125
|
+
when ::Array
|
|
126
|
+
node.each { |value| sort_sd_arrays!(value) }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
private_class_method :sort_sd_arrays!
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../sd_jwt"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
require_relative "../util"
|
|
6
|
+
require_relative "../jose/jws"
|
|
7
|
+
require_relative "../jose/algorithms"
|
|
8
|
+
|
|
9
|
+
module OpenID4VP
|
|
10
|
+
module SdJwt
|
|
11
|
+
# The SD-JWT Key Binding JWT (draft-ietf-oauth-selective-disclosure-jwt-22
|
|
12
|
+
# §4.3): proves possession of the key in the Issuer-signed JWT's
|
|
13
|
+
# `cnf.jwk`, binding the presentation to a `nonce`/`aud` and to the exact
|
|
14
|
+
# disclosures presented (`sd_hash`).
|
|
15
|
+
module KbJwt
|
|
16
|
+
TYP = "kb+jwt"
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Signs a Key Binding JWT with `key` (the holder's private key). `iat`
|
|
21
|
+
# defaults to `Time.now.to_i`; `extra` claims (e.g.
|
|
22
|
+
# `transaction_data_hashes`) are merged in.
|
|
23
|
+
def build(aud:, nonce:, sd_hash:, key:, alg: nil, iat: nil, extra: {})
|
|
24
|
+
payload = { "iat" => iat || Time.now.to_i, "aud" => aud, "nonce" => nonce, "sd_hash" => sd_hash }.merge(extra)
|
|
25
|
+
OpenID4VP::JOSE::JWS.sign(payload, key: key, alg: alg, header: { "typ" => TYP })
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Verifies a Key Binding JWT against `key` (the holder's public key
|
|
29
|
+
# from the Issuer-signed JWT's `cnf.jwk`): `typ`, signature, `aud`,
|
|
30
|
+
# `nonce`, `sd_hash` and `iat` (within `leeway` seconds of `now`).
|
|
31
|
+
# Raises HolderBindingInvalid on any failure. Returns the payload Hash.
|
|
32
|
+
def verify(compact, key:, expected_aud:, expected_nonce:, expected_sd_hash:, now: Time.now, leeway: 60,
|
|
33
|
+
algorithms: OpenID4VP::JOSE::Algorithms::SIGNING)
|
|
34
|
+
raise OpenID4VP::HolderBindingInvalid, "kb-jwt: missing" if compact.nil? || compact.empty?
|
|
35
|
+
|
|
36
|
+
verified = verify_signature(compact, key, algorithms)
|
|
37
|
+
check_typ!(verified.header)
|
|
38
|
+
payload = verified.payload
|
|
39
|
+
raise OpenID4VP::HolderBindingInvalid, "kb-jwt: payload is not a JSON object" unless payload.is_a?(::Hash)
|
|
40
|
+
|
|
41
|
+
check_claims!(payload, expected_aud, expected_nonce, expected_sd_hash)
|
|
42
|
+
check_iat!(payload["iat"], now, leeway)
|
|
43
|
+
|
|
44
|
+
payload
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def verify_signature(compact, key, algorithms)
|
|
48
|
+
OpenID4VP::JOSE::JWS.verify(compact, key: key, algorithms: algorithms)
|
|
49
|
+
rescue OpenID4VP::InvalidRequest, OpenID4VP::VerificationError => e
|
|
50
|
+
raise OpenID4VP::HolderBindingInvalid, "kb-jwt: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
private_class_method :verify_signature
|
|
53
|
+
|
|
54
|
+
def check_typ!(header)
|
|
55
|
+
return if header["typ"] == TYP
|
|
56
|
+
|
|
57
|
+
raise OpenID4VP::HolderBindingInvalid, "kb-jwt: unexpected typ #{header['typ'].inspect}"
|
|
58
|
+
end
|
|
59
|
+
private_class_method :check_typ!
|
|
60
|
+
|
|
61
|
+
def check_claims!(payload, expected_aud, expected_nonce, expected_sd_hash)
|
|
62
|
+
raise OpenID4VP::HolderBindingInvalid, "kb-jwt: aud mismatch" unless payload["aud"] == expected_aud
|
|
63
|
+
raise OpenID4VP::HolderBindingInvalid, "kb-jwt: nonce mismatch" unless payload["nonce"] == expected_nonce
|
|
64
|
+
|
|
65
|
+
sd_hash = payload["sd_hash"]
|
|
66
|
+
return if sd_hash.is_a?(::String) && OpenID4VP::Util.secure_compare(sd_hash, expected_sd_hash)
|
|
67
|
+
|
|
68
|
+
raise OpenID4VP::HolderBindingInvalid, "kb-jwt: sd_hash mismatch"
|
|
69
|
+
end
|
|
70
|
+
private_class_method :check_claims!
|
|
71
|
+
|
|
72
|
+
def check_iat!(iat, now, leeway)
|
|
73
|
+
raise OpenID4VP::HolderBindingInvalid, "kb-jwt: missing iat" unless iat.is_a?(::Integer)
|
|
74
|
+
|
|
75
|
+
return if (iat - now.to_i).abs <= leeway
|
|
76
|
+
|
|
77
|
+
raise OpenID4VP::HolderBindingInvalid, "kb-jwt: iat outside of allowed leeway"
|
|
78
|
+
end
|
|
79
|
+
private_class_method :check_iat!
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../sd_jwt"
|
|
4
|
+
require_relative "disclosure"
|
|
5
|
+
require_relative "digest"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
|
|
8
|
+
module OpenID4VP
|
|
9
|
+
module SdJwt
|
|
10
|
+
# Splits an SD-JWT(+KB) compact serialization
|
|
11
|
+
# (`<Issuer-signed JWT>~<Disclosure 1>~...~<Disclosure N>~<KB-JWT>`, the
|
|
12
|
+
# KB-JWT being optional) into its parts, per
|
|
13
|
+
# draft-ietf-oauth-selective-disclosure-jwt-22 §4.
|
|
14
|
+
module Parser
|
|
15
|
+
# `raw` is the exact string parsed. `kb_jwt` is nil when the input has
|
|
16
|
+
# no Key Binding JWT (the segment after the last "~" is empty).
|
|
17
|
+
Parsed = Struct.new(:issuer_jwt, :disclosures, :kb_jwt, :raw, keyword_init: true) do
|
|
18
|
+
# The presented string up to and including the last disclosure's
|
|
19
|
+
# trailing "~", omitting any Key Binding JWT -- the exact string a
|
|
20
|
+
# Key Binding JWT's `sd_hash` is computed over.
|
|
21
|
+
def without_kb
|
|
22
|
+
"#{issuer_jwt}~#{disclosures.map { |d| "#{d.raw}~" }.join}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def sd_hash(alg = "sha-256")
|
|
26
|
+
Digest.hash(alg, without_kb)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# Raises PresentationInvalid unless `str` is a String containing at
|
|
33
|
+
# least one "~".
|
|
34
|
+
def parse(str)
|
|
35
|
+
unless str.is_a?(::String) && str.include?("~")
|
|
36
|
+
raise OpenID4VP::PresentationInvalid, "sd-jwt: malformed presentation (missing '~')"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
parts = str.split("~", -1)
|
|
40
|
+
issuer_jwt = parts.first
|
|
41
|
+
kb_jwt = parts.last.empty? ? nil : parts.last
|
|
42
|
+
disclosures = parts[1..-2].map { |part| Disclosure.parse(part) }
|
|
43
|
+
|
|
44
|
+
Parsed.new(issuer_jwt: issuer_jwt, disclosures: disclosures, kb_jwt: kb_jwt, raw: str)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|