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,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cose"
|
|
4
|
+
require_relative "../mdoc"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "cbor_values"
|
|
7
|
+
require_relative "cose_key"
|
|
8
|
+
require_relative "device_authentication"
|
|
9
|
+
require_relative "sign1_decoder"
|
|
10
|
+
|
|
11
|
+
module OpenID4VP
|
|
12
|
+
module Mdoc
|
|
13
|
+
# DeviceSigned = {"nameSpaces" => DeviceNameSpacesBytes, "deviceAuth" =>
|
|
14
|
+
# {"deviceSignature" => COSE_Sign1}} (ISO 18013-5 §9.1.3). DeviceMac
|
|
15
|
+
# authentication is not supported.
|
|
16
|
+
class DeviceSigned
|
|
17
|
+
attr_reader :name_spaces_bytes, :name_spaces, :device_auth
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def parse(map)
|
|
21
|
+
raise OpenID4VP::InvalidResponse, "mdoc: invalid DeviceSigned" unless map.is_a?(::Hash)
|
|
22
|
+
|
|
23
|
+
tagged = map.fetch("nameSpaces") { raise OpenID4VP::InvalidResponse, "mdoc: missing DeviceSigned.nameSpaces" }
|
|
24
|
+
unless tagged.is_a?(CBOR::Tagged) && tagged.tag == 24
|
|
25
|
+
raise OpenID4VP::InvalidResponse, "mdoc: expected DeviceNameSpacesBytes (tag 24)"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
device_name_spaces = decode(tagged.value)
|
|
29
|
+
raise OpenID4VP::InvalidResponse, "mdoc: invalid DeviceNameSpaces" unless device_name_spaces.is_a?(::Hash)
|
|
30
|
+
|
|
31
|
+
name_spaces = device_name_spaces.transform_values do |elements|
|
|
32
|
+
raise OpenID4VP::InvalidResponse, "mdoc: invalid DeviceNameSpaces" unless elements.is_a?(::Hash)
|
|
33
|
+
|
|
34
|
+
elements.transform_values { |v| CborValues.decode(v) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
new(name_spaces_bytes: tagged, name_spaces: name_spaces, device_auth: parse_device_auth(map))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# device_name_spaces: {ns => {element_identifier => value}}, values
|
|
41
|
+
# may be a Date/Time (encoded as tag 1004/0) or any other
|
|
42
|
+
# CBOR-encodable value. `device_key` is the device's private
|
|
43
|
+
# OpenID4VP::JOSE::JWK; the deviceSignature is a detached signature
|
|
44
|
+
# over DeviceAuthenticationBytes.
|
|
45
|
+
def build(device_name_spaces:, device_key:, session_transcript:, doc_type:, alg: -7, kid: nil)
|
|
46
|
+
CoseKey.check_alg_matches_key!(alg, device_key)
|
|
47
|
+
|
|
48
|
+
tagged = CBOR::Tagged.new(24, CBOR.encode(encode_name_spaces(device_name_spaces)))
|
|
49
|
+
device_authentication_bytes = DeviceAuthentication.bytes(
|
|
50
|
+
session_transcript: session_transcript, doc_type: doc_type, name_spaces_bytes: tagged
|
|
51
|
+
)
|
|
52
|
+
protected_headers = { 1 => alg }
|
|
53
|
+
protected_headers[4] = kid if kid
|
|
54
|
+
signing_sign1 = ::COSE::Sign1.new(
|
|
55
|
+
protected_headers: protected_headers, unprotected_headers: {}, payload: device_authentication_bytes
|
|
56
|
+
)
|
|
57
|
+
signing_sign1.sign(CoseKey.from_jwk(device_key))
|
|
58
|
+
signature = ::COSE::Sign1.new(
|
|
59
|
+
protected_headers: protected_headers, unprotected_headers: {},
|
|
60
|
+
payload: nil, signature: signing_sign1.signature
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
new(
|
|
64
|
+
name_spaces_bytes: tagged, name_spaces: device_name_spaces,
|
|
65
|
+
device_auth: { "deviceSignature" => signature }
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def parse_device_auth(map)
|
|
72
|
+
device_auth = map.fetch("deviceAuth") { raise OpenID4VP::InvalidResponse, "mdoc: missing deviceAuth" }
|
|
73
|
+
raise OpenID4VP::InvalidResponse, "mdoc: invalid deviceAuth" unless device_auth.is_a?(::Hash)
|
|
74
|
+
raise OpenID4VP::PresentationInvalid, "DeviceMac not supported" if device_auth.key?("deviceMac")
|
|
75
|
+
|
|
76
|
+
raw_signature = device_auth.fetch("deviceSignature") do
|
|
77
|
+
raise OpenID4VP::InvalidResponse, "mdoc: missing deviceAuth.deviceSignature"
|
|
78
|
+
end
|
|
79
|
+
{ "deviceSignature" => decode_sign1(raw_signature) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def decode_sign1(raw)
|
|
83
|
+
Sign1Decoder.decode(raw, "deviceSignature")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def decode(bytes)
|
|
87
|
+
CBOR.decode(bytes)
|
|
88
|
+
rescue CBOR::MalformedFormatError, EOFError, TypeError
|
|
89
|
+
raise OpenID4VP::InvalidResponse, "mdoc: malformed DeviceNameSpaces"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def encode_name_spaces(name_spaces)
|
|
93
|
+
name_spaces.transform_values { |elements| elements.transform_values { |v| CborValues.encode(v) } }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def initialize(name_spaces_bytes:, name_spaces:, device_auth:)
|
|
98
|
+
@name_spaces_bytes = name_spaces_bytes
|
|
99
|
+
@name_spaces = name_spaces
|
|
100
|
+
@device_auth = device_auth
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def to_map
|
|
104
|
+
{
|
|
105
|
+
"nameSpaces" => @name_spaces_bytes,
|
|
106
|
+
"deviceAuth" => { "deviceSignature" => @device_auth.fetch("deviceSignature").to_array }
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../mdoc"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
require_relative "issuer_signed"
|
|
6
|
+
require_relative "device_signed"
|
|
7
|
+
|
|
8
|
+
module OpenID4VP
|
|
9
|
+
module Mdoc
|
|
10
|
+
# Document = {"docType" => tstr, "issuerSigned" => IssuerSigned,
|
|
11
|
+
# "deviceSigned" => DeviceSigned, ? "errors" => ...} (ISO 18013-5 §8.3.2.1.2.2).
|
|
12
|
+
class Document
|
|
13
|
+
attr_reader :doc_type, :issuer_signed, :device_signed, :errors
|
|
14
|
+
|
|
15
|
+
def self.parse(map)
|
|
16
|
+
raise OpenID4VP::InvalidResponse, "mdoc: invalid Document" unless map.is_a?(::Hash)
|
|
17
|
+
|
|
18
|
+
doc_type = map.fetch("docType") { raise OpenID4VP::InvalidResponse, "mdoc: missing docType" }
|
|
19
|
+
issuer_signed_map = map.fetch("issuerSigned") { raise OpenID4VP::InvalidResponse, "mdoc: missing issuerSigned" }
|
|
20
|
+
device_signed_map = map.fetch("deviceSigned") { raise OpenID4VP::InvalidResponse, "mdoc: missing deviceSigned" }
|
|
21
|
+
|
|
22
|
+
new(
|
|
23
|
+
doc_type: doc_type,
|
|
24
|
+
issuer_signed: IssuerSigned.parse(issuer_signed_map),
|
|
25
|
+
device_signed: DeviceSigned.parse(device_signed_map),
|
|
26
|
+
errors: map["errors"]
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(doc_type:, issuer_signed:, device_signed:, errors: nil)
|
|
31
|
+
@doc_type = doc_type
|
|
32
|
+
@issuer_signed = issuer_signed
|
|
33
|
+
@device_signed = device_signed
|
|
34
|
+
@errors = errors
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_map
|
|
38
|
+
map = { "docType" => doc_type, "issuerSigned" => issuer_signed.to_map, "deviceSigned" => device_signed.to_map }
|
|
39
|
+
map["errors"] = errors if errors
|
|
40
|
+
map
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../mdoc"
|
|
4
|
+
require_relative "../util"
|
|
5
|
+
|
|
6
|
+
module OpenID4VP
|
|
7
|
+
module Mdoc
|
|
8
|
+
# OpenID4VPHandover and OpenID4VPDCAPIHandover (OpenID4VP §B.2.6): the
|
|
9
|
+
# CBOR structures that bind an ISO mdoc DeviceResponse to an OpenID4VP
|
|
10
|
+
# request, for redirect-based and Digital Credentials API invocation
|
|
11
|
+
# respectively.
|
|
12
|
+
module Handover
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# The OpenID4VPHandoverInfo array, CBOR-encoded (OpenID4VP §B.2.6.1).
|
|
16
|
+
# `client_id` may be a String or an object responding to `#to_s` (e.g.
|
|
17
|
+
# OpenID4VP::ClientId). `jwk_thumbprint`, when present, must be the raw
|
|
18
|
+
# (binary-encoded) 32-byte SHA-256 JWK thumbprint; nil encodes as CBOR
|
|
19
|
+
# null.
|
|
20
|
+
def info_bytes_openid4vp(client_id:, nonce:, response_uri:, jwk_thumbprint: nil)
|
|
21
|
+
CBOR.encode([client_id.to_s, nonce, jwk_thumbprint&.b, response_uri])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The OpenID4VPHandover array: ["OpenID4VPHandover", sha256(info_bytes)].
|
|
25
|
+
def openid4vp(client_id:, nonce:, response_uri:, jwk_thumbprint: nil)
|
|
26
|
+
info = info_bytes_openid4vp(
|
|
27
|
+
client_id: client_id, nonce: nonce, response_uri: response_uri, jwk_thumbprint: jwk_thumbprint
|
|
28
|
+
)
|
|
29
|
+
["OpenID4VPHandover", Util.sha256(info)]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The OpenID4VPDCAPIHandoverInfo array, CBOR-encoded (OpenID4VP
|
|
33
|
+
# §B.2.6.2). `origin` MUST NOT be prefixed with `origin:`.
|
|
34
|
+
def info_bytes_dc_api(origin:, nonce:, jwk_thumbprint: nil)
|
|
35
|
+
CBOR.encode([origin, nonce, jwk_thumbprint&.b])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The OpenID4VPDCAPIHandover array: ["OpenID4VPDCAPIHandover", sha256(info_bytes)].
|
|
39
|
+
def dc_api(origin:, nonce:, jwk_thumbprint: nil)
|
|
40
|
+
info = info_bytes_dc_api(origin: origin, nonce: nonce, jwk_thumbprint: jwk_thumbprint)
|
|
41
|
+
["OpenID4VPDCAPIHandover", Util.sha256(info)]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# CBOR encoding of a handover array (as returned by `.openid4vp` or
|
|
45
|
+
# `.dc_api`).
|
|
46
|
+
def encode(handover_array)
|
|
47
|
+
CBOR.encode(handover_array)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cose"
|
|
4
|
+
require_relative "../mdoc"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "../x509"
|
|
7
|
+
require_relative "cbor_values"
|
|
8
|
+
require_relative "sign1_decoder"
|
|
9
|
+
|
|
10
|
+
module OpenID4VP
|
|
11
|
+
module Mdoc
|
|
12
|
+
# A single ISO 18013-5 §9.1.2.4 IssuerSignedItem: one disclosed data
|
|
13
|
+
# element from an mdoc namespace. The original IssuerSignedItemBytes
|
|
14
|
+
# (`#6.24(bstr .cbor IssuerSignedItem)`) are preserved verbatim in
|
|
15
|
+
# `#bytes` so that `#digest` recomputes exactly the bytes the issuer
|
|
16
|
+
# digested into the MSO's valueDigests, regardless of Ruby Hash
|
|
17
|
+
# re-encoding order.
|
|
18
|
+
class IssuerSignedItem
|
|
19
|
+
attr_reader :digest_id, :random, :element_identifier, :element_value, :bytes
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# tagged: a CBOR::Tagged(24, bstr), as decoded from an IssuerSigned
|
|
23
|
+
# nameSpaces entry.
|
|
24
|
+
def from_tagged(tagged)
|
|
25
|
+
bytes = unwrap_tag24(tagged)
|
|
26
|
+
map = decode_item(bytes)
|
|
27
|
+
|
|
28
|
+
new(
|
|
29
|
+
digest_id: fetch!(map, "digestID"),
|
|
30
|
+
random: fetch!(map, "random"),
|
|
31
|
+
element_identifier: fetch!(map, "elementIdentifier"),
|
|
32
|
+
element_value: CborValues.decode(fetch!(map, "elementValue")),
|
|
33
|
+
bytes: bytes
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Builds a fresh item (used by Builder), encoding `element_value`
|
|
38
|
+
# immediately so `#bytes`/`#digest` are available. `element_value`
|
|
39
|
+
# may be a Date (encoded as full-date, tag 1004), a Time (encoded as
|
|
40
|
+
# tdate, tag 0), or any other CBOR-encodable value (kept as-is).
|
|
41
|
+
def build(digest_id:, random:, element_identifier:, element_value:)
|
|
42
|
+
map = {
|
|
43
|
+
"digestID" => digest_id, "random" => random,
|
|
44
|
+
"elementIdentifier" => element_identifier, "elementValue" => CborValues.encode(element_value)
|
|
45
|
+
}
|
|
46
|
+
new(
|
|
47
|
+
digest_id: digest_id, random: random, element_identifier: element_identifier,
|
|
48
|
+
element_value: element_value, bytes: CBOR.encode(map)
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def unwrap_tag24(tagged)
|
|
55
|
+
unless tagged.is_a?(CBOR::Tagged) && tagged.tag == 24
|
|
56
|
+
raise OpenID4VP::InvalidResponse, "mdoc: expected IssuerSignedItemBytes (tag 24)"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
tagged.value
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def decode_item(bytes)
|
|
63
|
+
map = CBOR.decode(bytes)
|
|
64
|
+
raise OpenID4VP::InvalidResponse, "mdoc: invalid IssuerSignedItem" unless map.is_a?(::Hash)
|
|
65
|
+
|
|
66
|
+
map
|
|
67
|
+
rescue CBOR::MalformedFormatError, EOFError, TypeError
|
|
68
|
+
raise OpenID4VP::InvalidResponse, "mdoc: malformed IssuerSignedItem"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def fetch!(map, key)
|
|
72
|
+
map.fetch(key) { raise OpenID4VP::InvalidResponse, "mdoc: missing IssuerSignedItem.#{key}" }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def initialize(digest_id:, random:, element_identifier:, element_value:, bytes:)
|
|
77
|
+
@digest_id = digest_id
|
|
78
|
+
@random = random
|
|
79
|
+
@element_identifier = element_identifier
|
|
80
|
+
@element_value = element_value
|
|
81
|
+
@bytes = bytes.b
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# SHA-256/384/512 digest of the tag-24 IssuerSignedItemBytes, INCLUDING
|
|
85
|
+
# the tag, over the exact original bytes.
|
|
86
|
+
def digest(alg)
|
|
87
|
+
OpenSSL::Digest.digest(alg, CBOR.encode(CBOR::Tagged.new(24, @bytes)))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def to_tagged
|
|
91
|
+
CBOR::Tagged.new(24, @bytes)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# IssuerSigned = {"nameSpaces" => {ns => [IssuerSignedItemBytes]}, "issuerAuth" => COSE_Sign1}
|
|
96
|
+
# (ISO 18013-5 §9.1.2.4).
|
|
97
|
+
class IssuerSigned
|
|
98
|
+
# COSE header label for the x5chain header (RFC 9360 §2).
|
|
99
|
+
LABEL_X5CHAIN = 33
|
|
100
|
+
|
|
101
|
+
attr_reader :name_spaces, :issuer_auth
|
|
102
|
+
|
|
103
|
+
class << self
|
|
104
|
+
def parse(map)
|
|
105
|
+
raise OpenID4VP::InvalidResponse, "mdoc: invalid IssuerSigned" unless map.is_a?(::Hash)
|
|
106
|
+
|
|
107
|
+
raw_name_spaces = map.fetch("nameSpaces") { raise OpenID4VP::InvalidResponse, "mdoc: missing nameSpaces" }
|
|
108
|
+
raise OpenID4VP::InvalidResponse, "mdoc: invalid nameSpaces" unless raw_name_spaces.is_a?(::Hash)
|
|
109
|
+
|
|
110
|
+
name_spaces = raw_name_spaces.transform_values do |items|
|
|
111
|
+
Array(items).map { |t| IssuerSignedItem.from_tagged(t) }
|
|
112
|
+
end
|
|
113
|
+
raw_issuer_auth = map.fetch("issuerAuth") { raise OpenID4VP::InvalidResponse, "mdoc: missing issuerAuth" }
|
|
114
|
+
issuer_auth = decode_sign1(raw_issuer_auth)
|
|
115
|
+
|
|
116
|
+
new(name_spaces: name_spaces, issuer_auth: issuer_auth)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# `raw` is the already-decoded issuerAuth value: either a plain
|
|
122
|
+
# 4-element Array (untagged COSE_Sign1, the common mdoc encoding) or
|
|
123
|
+
# a CBOR::Tagged(18, array).
|
|
124
|
+
def decode_sign1(raw)
|
|
125
|
+
Sign1Decoder.decode(raw, "issuerAuth")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def initialize(name_spaces:, issuer_auth:)
|
|
130
|
+
@name_spaces = name_spaces
|
|
131
|
+
@issuer_auth = issuer_auth
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def mso
|
|
135
|
+
@mso ||= MSO.parse(@issuer_auth.payload)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The certificate chain from header label 33 (protected header takes
|
|
139
|
+
# precedence over unprotected), as OpenSSL::X509::Certificate objects.
|
|
140
|
+
# A single byte string is one certificate; an array of byte strings is
|
|
141
|
+
# several, leaf first. Returns [] when there is no x5chain header.
|
|
142
|
+
def x5chain
|
|
143
|
+
raw = @issuer_auth.protected_headers[LABEL_X5CHAIN] || @issuer_auth.unprotected_headers[LABEL_X5CHAIN]
|
|
144
|
+
return [] if raw.nil?
|
|
145
|
+
|
|
146
|
+
(raw.is_a?(::Array) ? raw : [raw]).map { |der| OpenID4VP::X509.parse(der) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def to_map
|
|
150
|
+
{
|
|
151
|
+
"nameSpaces" => @name_spaces.transform_values { |items| items.map(&:to_tagged) },
|
|
152
|
+
"issuerAuth" => @issuer_auth.to_array
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "../mdoc"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "cose_key"
|
|
7
|
+
|
|
8
|
+
module OpenID4VP
|
|
9
|
+
module Mdoc
|
|
10
|
+
# The ISO 18013-5 §9.1.2.4 MobileSecurityObject (MSO): the IssuerAuth
|
|
11
|
+
# payload, binding each namespace's disclosed-element digests and the
|
|
12
|
+
# mdoc's device public key to a docType and validity window.
|
|
13
|
+
class MSO
|
|
14
|
+
DIGEST_ALGORITHMS = %w[SHA-256 SHA-384 SHA-512].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :version, :digest_algorithm, :value_digests, :device_key, :doc_type, :validity_info
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# bytes_or_tagged: MobileSecurityObjectBytes (`#6.24(bstr .cbor MSO)`),
|
|
20
|
+
# either raw CBOR bytes (typically `IssuerAuth#payload`) or an
|
|
21
|
+
# already-decoded CBOR::Tagged(24, bstr).
|
|
22
|
+
def parse(bytes_or_tagged)
|
|
23
|
+
tagged = bytes_or_tagged.is_a?(CBOR::Tagged) ? bytes_or_tagged : decode(bytes_or_tagged)
|
|
24
|
+
unless tagged.is_a?(CBOR::Tagged) && tagged.tag == 24
|
|
25
|
+
raise OpenID4VP::InvalidResponse, "mdoc: expected MobileSecurityObjectBytes (tag 24)"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
map = decode(tagged.value)
|
|
29
|
+
raise OpenID4VP::InvalidResponse, "mdoc: invalid MobileSecurityObject" unless map.is_a?(::Hash)
|
|
30
|
+
|
|
31
|
+
from_map(map)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Builds MobileSecurityObjectBytes (tag-24 CBOR bytes), suitable as
|
|
35
|
+
# an IssuerAuth payload. `device_key` is a public
|
|
36
|
+
# OpenID4VP::JOSE::JWK.
|
|
37
|
+
def build(
|
|
38
|
+
doc_type:, digest_algorithm:, value_digests:, device_key:, valid_from:, valid_until:, signed: ::Time.now
|
|
39
|
+
)
|
|
40
|
+
unless DIGEST_ALGORITHMS.include?(digest_algorithm)
|
|
41
|
+
raise OpenID4VP::InvalidRequest, "mdoc: unsupported digestAlgorithm #{digest_algorithm.inspect}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
map = {
|
|
45
|
+
"version" => "1.0",
|
|
46
|
+
"digestAlgorithm" => digest_algorithm,
|
|
47
|
+
"valueDigests" => value_digests,
|
|
48
|
+
"deviceKeyInfo" => { "deviceKey" => CoseKey.from_jwk(device_key.to_public).map },
|
|
49
|
+
"docType" => doc_type,
|
|
50
|
+
"validityInfo" => {
|
|
51
|
+
"signed" => tdate(signed), "validFrom" => tdate(valid_from), "validUntil" => tdate(valid_until)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
CBOR.encode(CBOR::Tagged.new(24, CBOR.encode(map)))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def from_map(map)
|
|
60
|
+
validity = fetch!(map, "validityInfo")
|
|
61
|
+
raise OpenID4VP::InvalidResponse, "mdoc: invalid validityInfo" unless validity.is_a?(::Hash)
|
|
62
|
+
|
|
63
|
+
device_key_info = fetch!(map, "deviceKeyInfo")
|
|
64
|
+
raise OpenID4VP::InvalidResponse, "mdoc: invalid deviceKeyInfo" unless device_key_info.is_a?(::Hash)
|
|
65
|
+
|
|
66
|
+
new(
|
|
67
|
+
version: fetch!(map, "version"),
|
|
68
|
+
digest_algorithm: validate_digest_algorithm!(fetch!(map, "digestAlgorithm")),
|
|
69
|
+
value_digests: fetch!(map, "valueDigests"),
|
|
70
|
+
device_key: parse_device_key(fetch!(device_key_info, "deviceKey")),
|
|
71
|
+
doc_type: fetch!(map, "docType"),
|
|
72
|
+
validity_info: {
|
|
73
|
+
"signed" => parse_tdate(fetch!(validity, "signed")),
|
|
74
|
+
"validFrom" => parse_tdate(fetch!(validity, "validFrom")),
|
|
75
|
+
"validUntil" => parse_tdate(fetch!(validity, "validUntil"))
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def fetch!(map, key)
|
|
81
|
+
map.fetch(key) { raise OpenID4VP::InvalidResponse, "mdoc: missing MobileSecurityObject.#{key}" }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# map: a deviceKeyInfo.deviceKey COSE_Key map (Integer label =>
|
|
85
|
+
# value), as decoded from CBOR. Raises OpenID4VP::InvalidResponse for
|
|
86
|
+
# anything COSE::Key::EC2/OpenSSL rejects, rather than leaking a bare
|
|
87
|
+
# COSE::Error/RuntimeError/ArgumentError/OpenSSL error.
|
|
88
|
+
def parse_device_key(map)
|
|
89
|
+
CoseKey.to_jwk(::COSE::Key::EC2.from_map(map))
|
|
90
|
+
rescue ::COSE::Error, ::RuntimeError, ::ArgumentError, ::TypeError, ::OpenSSL::OpenSSLError => e
|
|
91
|
+
raise OpenID4VP::InvalidResponse, "mdoc: invalid deviceKey: #{e.message}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# ISO 18013-5 §9.1.2.4: `digestAlgorithm` must be one of
|
|
95
|
+
# DIGEST_ALGORITHMS -- an unsupported value would otherwise only
|
|
96
|
+
# surface later, as a bare RuntimeError out of OpenSSL::Digest, the
|
|
97
|
+
# first time a digest is computed with it (`IssuerSignedItem#digest`).
|
|
98
|
+
def validate_digest_algorithm!(digest_algorithm)
|
|
99
|
+
return digest_algorithm if DIGEST_ALGORITHMS.include?(digest_algorithm)
|
|
100
|
+
|
|
101
|
+
raise OpenID4VP::PresentationInvalid, "mdoc: unsupported digestAlgorithm #{digest_algorithm.inspect}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def decode(bytes)
|
|
105
|
+
CBOR.decode(bytes)
|
|
106
|
+
rescue CBOR::MalformedFormatError, EOFError, TypeError
|
|
107
|
+
raise OpenID4VP::InvalidResponse, "mdoc: malformed MobileSecurityObject"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def tdate(time)
|
|
111
|
+
CBOR::Tagged.new(0, time.utc.strftime("%Y-%m-%dT%H:%M:%SZ"))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse_tdate(tagged)
|
|
115
|
+
unless tagged.is_a?(CBOR::Tagged) && tagged.tag.zero?
|
|
116
|
+
raise OpenID4VP::InvalidResponse, "mdoc: expected tdate (tag 0)"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
::Time.parse(tagged.value).utc
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def initialize(version:, digest_algorithm:, value_digests:, device_key:, doc_type:, validity_info:)
|
|
124
|
+
@version = version
|
|
125
|
+
@digest_algorithm = digest_algorithm
|
|
126
|
+
@value_digests = value_digests
|
|
127
|
+
@device_key = device_key
|
|
128
|
+
@doc_type = doc_type
|
|
129
|
+
@validity_info = validity_info
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../mdoc"
|
|
4
|
+
require_relative "handover"
|
|
5
|
+
|
|
6
|
+
module OpenID4VP
|
|
7
|
+
module Mdoc
|
|
8
|
+
# The ISO/IEC 18013-5 §9.1.5.1 SessionTranscript CBOR structure, as
|
|
9
|
+
# adapted by OpenID4VP §B.2.6 for OpenID4VP-invoked mdoc presentations:
|
|
10
|
+
# DeviceEngagementBytes and EReaderKeyBytes are always null, and Handover
|
|
11
|
+
# is an OpenID4VPHandover or OpenID4VPDCAPIHandover array (see
|
|
12
|
+
# OpenID4VP::Mdoc::Handover).
|
|
13
|
+
module SessionTranscript
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# [nil, nil, handover], where `handover` is a Handover array as
|
|
17
|
+
# returned by `Handover.openid4vp` or `Handover.dc_api`.
|
|
18
|
+
def build(handover)
|
|
19
|
+
[nil, nil, handover]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# CBOR encoding of `build(handover)`.
|
|
23
|
+
def encode(handover)
|
|
24
|
+
CBOR.encode(build(handover))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The SessionTranscript array for a redirect-based (OpenID4VPHandover)
|
|
28
|
+
# presentation. `encryption_jwk`, when the response is encrypted (e.g.
|
|
29
|
+
# response_mode `direct_post.jwt`), is the Verifier's public
|
|
30
|
+
# OpenID4VP::JOSE::JWK used to encrypt the response.
|
|
31
|
+
def for_request(client_id:, nonce:, response_uri:, encryption_jwk: nil)
|
|
32
|
+
handover = Handover.openid4vp(
|
|
33
|
+
client_id: client_id, nonce: nonce, response_uri: response_uri,
|
|
34
|
+
jwk_thumbprint: encryption_jwk&.thumbprint
|
|
35
|
+
)
|
|
36
|
+
build(handover)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The SessionTranscript array for a Digital Credentials API
|
|
40
|
+
# (OpenID4VPDCAPIHandover) presentation. `encryption_jwk`, when the
|
|
41
|
+
# response is encrypted (response_mode `dc_api.jwt`), is the
|
|
42
|
+
# Verifier's public OpenID4VP::JOSE::JWK used to encrypt the response.
|
|
43
|
+
def for_dc_api(origin:, nonce:, encryption_jwk: nil)
|
|
44
|
+
handover = Handover.dc_api(origin: origin, nonce: nonce, jwk_thumbprint: encryption_jwk&.thumbprint)
|
|
45
|
+
build(handover)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cose"
|
|
4
|
+
require_relative "../mdoc"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
|
|
7
|
+
module OpenID4VP
|
|
8
|
+
module Mdoc
|
|
9
|
+
# Decodes an already CBOR-decoded COSE_Sign1 value -- either a bare
|
|
10
|
+
# 4-element Array or a CBOR::Tagged(18, array), as found inside a parsed
|
|
11
|
+
# IssuerSigned.issuerAuth / DeviceSigned.deviceAuth.deviceSignature --
|
|
12
|
+
# into a ::COSE::Sign1. Validates the array's shape first (element
|
|
13
|
+
# count/types) so a malformed or adversarial structure (wrong element
|
|
14
|
+
# count, a non-map unprotected header, a non-string signature, a
|
|
15
|
+
# protected header bstr whose content decodes to deeply nested CBOR)
|
|
16
|
+
# raises OpenID4VP::InvalidResponse instead of a bare
|
|
17
|
+
# NoMethodError/CBOR::StackError/... out of cose-ruby, which only
|
|
18
|
+
# validates COSE_Sign1 shape loosely (it never checks that the
|
|
19
|
+
# unprotected header is actually a Hash, for example).
|
|
20
|
+
module Sign1Decoder
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# raw: the already-decoded issuerAuth/deviceSignature value. label: a
|
|
24
|
+
# short description used in the error message (e.g. "issuerAuth",
|
|
25
|
+
# "deviceSignature").
|
|
26
|
+
def decode(raw, label)
|
|
27
|
+
array = validate!(unwrap(raw), label)
|
|
28
|
+
sign1 = ::COSE::Sign1.from_array(array)
|
|
29
|
+
raise OpenID4VP::InvalidResponse, "mdoc: malformed #{label}" unless sign1.protected_headers.is_a?(::Hash)
|
|
30
|
+
|
|
31
|
+
sign1
|
|
32
|
+
rescue CBOR::UnpackError, CBOR::TypeError, EOFError, ::TypeError, ::ArgumentError, ::NoMethodError,
|
|
33
|
+
::COSE::Error => e
|
|
34
|
+
raise OpenID4VP::InvalidResponse, "mdoc: malformed #{label}: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def unwrap(raw)
|
|
38
|
+
raw.is_a?(CBOR::Tagged) ? raw.value : raw
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# array: the (possibly tag-18-unwrapped) COSE_Sign1 candidate. Requires
|
|
42
|
+
# exactly 4 elements: protected (a binary String, possibly empty),
|
|
43
|
+
# unprotected (a Hash), payload (a binary String or nil, for a
|
|
44
|
+
# detached-content COSE_Sign1 like mdoc DeviceSignature) and signature
|
|
45
|
+
# (a binary String). Returns `array` unchanged once validated.
|
|
46
|
+
def validate!(array, label)
|
|
47
|
+
raise OpenID4VP::InvalidResponse, "mdoc: malformed #{label}" unless array.is_a?(::Array) && array.size == 4
|
|
48
|
+
|
|
49
|
+
protected_bstr, unprotected, payload, signature = array
|
|
50
|
+
valid_shape =
|
|
51
|
+
protected_bstr.is_a?(::String) && unprotected.is_a?(::Hash) &&
|
|
52
|
+
(payload.nil? || payload.is_a?(::String)) && signature.is_a?(::String)
|
|
53
|
+
|
|
54
|
+
raise OpenID4VP::InvalidResponse, "mdoc: malformed #{label}" unless valid_shape
|
|
55
|
+
|
|
56
|
+
array
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|