ap2_mandate_verifier 0.1.0.pre.3

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.
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AP2MandateVerifier
4
+ # In-process immutable checked facts. No JSON/remote reconstruction API.
5
+ # Private constructors are API discipline, not a hostile Ruby-code sandbox.
6
+ class ResearchEvidence
7
+ CONTRACT_VERSION = "ap2.research-evidence.2"
8
+ IMPLEMENTATION_REVISION = "ap2.research-verifier.2"
9
+ SCOPE = %w[strict_inputs host_trust_profile_input_checks issuer_root_signature
10
+ original_root_graph_closure agent_key_confirmation agent_terminal_signature
11
+ closed_original_checkout_binding merchant_checkout_signature
12
+ fixed_subset_schema_constraints host_challenge_order_binding validity_intersection].freeze
13
+
14
+ def initialize(facts)
15
+ @data = self.class.__send__(:copy_frozen, facts.merge(
16
+ "contract_version" => CONTRACT_VERSION, "implementation_revision" => IMPLEMENTATION_REVISION,
17
+ "revision_kind" => "static_identifier_not_runtime_attestation", "verified_scope" => SCOPE))
18
+ freeze
19
+ end
20
+ def self.copy_frozen(value)
21
+ case value
22
+ when Hash
23
+ value.to_h { |key, child| [copy_frozen(key), copy_frozen(child)] }.freeze
24
+ when Array
25
+ value.map { |child| copy_frozen(child) }.freeze
26
+ when String
27
+ InputValidator.snapshot!(value)
28
+ when Integer, TrueClass, FalseClass, NilClass
29
+ value
30
+ else
31
+ raise ArgumentError, "unsupported checked fact"
32
+ end
33
+ end
34
+ private_class_method :new, :allocate, :copy_frozen
35
+ def [](field) = @data.fetch(field)
36
+ def to_h = @data
37
+ def dup = self
38
+ def clone(freeze: true) = self
39
+ end
40
+
41
+ # Only this factory runs the full supported pipeline and mints research output.
42
+ # Old Result never accepts Evidence, even for a successful research result.
43
+ class ResearchResult
44
+ def self.verify(**inputs)
45
+ facts = HostBinding.verified_binding!(**inputs)
46
+ new(ResearchEvidence.__send__(:new, facts))
47
+ rescue InputValidator::Unavailable, RootSignature::Unavailable => error
48
+ failure(:unavailable, error.code)
49
+ rescue InputValidator::Invalid, RootSignature::Rejected => error
50
+ failure(:rejected, error.code)
51
+ rescue LoadError
52
+ Result.new(status: :unavailable, code: :signature_dependency_unavailable)
53
+ rescue StandardError
54
+ Result.new(status: :unavailable, code: :research_verification_unavailable)
55
+ end
56
+ def self.failure(status, code)
57
+ unless Result::CODES.fetch(status).include?(code)
58
+ status, code = :unavailable, :research_verification_unavailable
59
+ end
60
+ Result.new(status: status, code: code)
61
+ end
62
+ private_class_method :failure
63
+ attr_reader :evidence
64
+ def initialize(evidence)
65
+ raise ArgumentError, "checked research evidence required" unless evidence.instance_of?(ResearchEvidence) && evidence.frozen?
66
+ @evidence = evidence
67
+ freeze
68
+ end
69
+ private_class_method :new, :allocate
70
+ def status = :verified_research
71
+ def code = :scoped_research_verified
72
+ def success? = true
73
+ def rejected? = false
74
+ def unverified? = false
75
+ def unavailable? = false
76
+ def dup = self
77
+ def clone(freeze: true) = self
78
+ end
79
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AP2MandateVerifier
4
+ # Legacy non-success type. The opt-in research entry uses a separate type.
5
+ class Result
6
+ CODES = {
7
+ rejected: %i[invalid_input_type input_too_large invalid_encoding
8
+ invalid_json duplicate_key input_too_deep invalid_root_jws
9
+ unsupported_root_header unsupported_root_algorithm
10
+ unsupported_root_type invalid_root_payload invalid_root_signature
11
+ invalid_root_presentation unsupported_disclosure_profile invalid_disclosure
12
+ missing_disclosure duplicate_disclosure duplicate_commitment extra_disclosure
13
+ disclosure_limit_exceeded unsupported_purchase_profile agent_key_mismatch
14
+ root_binding_mismatch audience_mismatch nonce_mismatch invalid_purchase_time
15
+ delegation_claim_mismatch checkout_binding_mismatch checkout_identity_mismatch
16
+ invalid_scoped_schema unsupported_scoped_profile unsupported_constraint
17
+ constraint_mismatch amount_mismatch currency_mismatch constraint_limit_exceeded
18
+ invalid_trust_context trust_context_mismatch unsupported_trust_profile inactive_trust_snapshot
19
+ trust_snapshot_time_mismatch invalid_trust_key trust_key_not_found ambiguous_trust_key
20
+ inactive_trust_key trust_key_time_mismatch
21
+ invalid_host_context host_binding_mismatch challenge_state_mismatch
22
+ challenge_reference_mismatch challenge_time_mismatch invalid_host_validity].freeze,
23
+ unverified: %i[verification_not_implemented root_signature_verified_only root_disclosures_verified_only
24
+ purchase_binding_verified_only scoped_constraints_verified_only host_binding_verified_only].freeze,
25
+ unavailable: %i[json_dependency_unavailable input_check_unavailable
26
+ signature_dependency_unavailable issuer_key_unavailable
27
+ issuer_key_unsupported root_signature_check_unavailable
28
+ disclosure_dependency_unavailable root_disclosure_check_unavailable
29
+ purchase_context_unavailable purchase_dependency_unavailable purchase_check_unavailable
30
+ constraint_context_unavailable constraint_dependency_unavailable constraint_check_unavailable trust_check_unavailable host_binding_check_unavailable research_verification_unavailable].freeze
31
+ }.freeze
32
+
33
+ attr_reader :status, :code
34
+
35
+ def initialize(status:, code:)
36
+ unless CODES.fetch(status, []).include?(code)
37
+ raise ArgumentError, "unsupported non-success result"
38
+ end
39
+ @status = status
40
+ @code = code
41
+ freeze
42
+ end
43
+
44
+ def success? = false
45
+ def evidence = nil
46
+ def rejected? = status == :rejected
47
+ def unverified? = status == :unverified
48
+ def unavailable? = status == :unavailable
49
+ end
50
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AP2MandateVerifier
4
+ # Pinned AP2 b4587ac1 / DC04 exact-three-array-commitments profile only.
5
+ # Whole original Root graph, including all disclosed subtrees, is inspected.
6
+ module RootDisclosures
7
+ MAX_DISCLOSURES = 16
8
+ MAX_SEGMENT_BYTES = 8_192
9
+ PATHS = [
10
+ ["delegate_payload", 0],
11
+ ["delegate_payload", 0, "disclosed", "constraints", 1, "allowed", 0],
12
+ ["delegate_payload", 0, "disclosed", "constraints", 0, "items", 0, "acceptable_items", 0]
13
+ ].map { |path| path.map { |part| part.freeze }.freeze }.freeze
14
+
15
+ class Rejected < RootSignature::Rejected; end
16
+
17
+ def self.check!(source, issuer_pem)
18
+ verified_root!(source, issuer_pem)
19
+ nil
20
+ end
21
+
22
+ # Internal handoff only: signature and entire closure are verified first.
23
+ # AP2 semantics, constraints and Host authorization remain unvalidated.
24
+ def self.verified_root!(source, issuer_pem)
25
+ wire = InputValidator.snapshot!(source)
26
+ raise Rejected, :invalid_root_presentation unless wire.ascii_only? && wire.end_with?("~")
27
+ parts = wire.split("~", -1)
28
+ raise Rejected, :invalid_root_presentation unless parts.size.between?(3, MAX_DISCLOSURES + 2) &&
29
+ parts.last == "" && !parts.first.empty? && parts[1...-1].none?(&:empty?)
30
+ # No disclosure decode, digest graph or trusted claims before signature.
31
+ payload = RootSignature.verified_payload!(parts.first, issuer_pem)
32
+ raise Rejected, :unsupported_disclosure_profile unless
33
+ (payload.keys - %w[delegate_payload _sd_alg]).empty? &&
34
+ (!payload.key?("_sd_alg") || payload["_sd_alg"] == "sha-256") &&
35
+ payload["delegate_payload"].instance_of?(Array) && payload["delegate_payload"].size == 1
36
+ require "digest"
37
+ mapping = {}
38
+ parts[1...-1].each do |part|
39
+ raise Rejected, :disclosure_limit_exceeded if part.bytesize > MAX_SEGMENT_BYTES
40
+ decoded = InputValidator.parse!(decode!(part))
41
+ raise Rejected, :unsupported_disclosure_profile unless decoded.instance_of?(Array) &&
42
+ decoded.size == 2 && decoded[0].instance_of?(String) &&
43
+ decoded[0].bytesize.between?(8, 128) && decoded[1].instance_of?(Hash)
44
+ digest = Base64.urlsafe_encode64(Digest::SHA256.digest(part), padding: false)
45
+ raise Rejected, :duplicate_disclosure if mapping.key?(digest)
46
+ mapping[digest] = decoded[1]
47
+ end
48
+ used = {}
49
+ observed = []
50
+ budget = [0]
51
+ resolved = walk!(payload, [], mapping, used, observed, budget, 1)
52
+ raise Rejected, :unsupported_disclosure_profile unless observed.sort_by(&:to_s) == PATHS.sort_by(&:to_s)
53
+ raise Rejected, :extra_disclosure unless used.size == mapping.size
54
+ resolved
55
+ end
56
+
57
+ def self.walk!(value, path, mapping, used, observed, budget, depth)
58
+ budget[0] += 1
59
+ raise Rejected, :disclosure_limit_exceeded if budget[0] > 2_048 ||
60
+ (depth > 24 && (value.instance_of?(Hash) || value.instance_of?(Array)))
61
+ case value
62
+ when Hash
63
+ raise Rejected, :unsupported_disclosure_profile if value.key?("_sd") || value.key?("...") ||
64
+ (path != [] && value.key?("_sd_alg"))
65
+ raise Rejected, :disclosure_limit_exceeded if value.size > 32
66
+ value.to_h { |key, child| [key, walk!(child, path + [key], mapping, used, observed, budget, depth + 1)] }
67
+ when Array
68
+ raise Rejected, :disclosure_limit_exceeded if value.size > 16
69
+ value.each_with_index.map do |item, index|
70
+ here = path + [index]
71
+ if item.instance_of?(Hash) && item.key?("...")
72
+ raise Rejected, :unsupported_disclosure_profile unless item.keys == ["..."] && PATHS.include?(here)
73
+ digest = item["..."]
74
+ raise Rejected, :invalid_disclosure unless digest.instance_of?(String) &&
75
+ digest.bytesize == 43 && decode!(digest).bytesize == 32
76
+ raise Rejected, :duplicate_commitment if used.key?(digest)
77
+ raise Rejected, :missing_disclosure unless mapping.key?(digest)
78
+ used[digest] = true
79
+ observed << here
80
+ walk!(mapping.fetch(digest), here + ["disclosed"], mapping, used, observed, budget, depth + 1)
81
+ else
82
+ walk!(item, here, mapping, used, observed, budget, depth + 1)
83
+ end
84
+ end
85
+ else
86
+ value
87
+ end
88
+ end
89
+
90
+ def self.decode!(part)
91
+ raise Rejected, :invalid_disclosure unless part.match?(/\A[A-Za-z0-9_-]+\z/)
92
+ raw = Base64.urlsafe_decode64(part + "=" * (-part.bytesize % 4))
93
+ raise Rejected, :invalid_disclosure unless Base64.urlsafe_encode64(raw, padding: false) == part
94
+ raw
95
+ rescue ArgumentError
96
+ raise Rejected, :invalid_disclosure
97
+ end
98
+ private_class_method :walk!, :decode!
99
+ end
100
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AP2MandateVerifier
4
+ # Crypto-only extraction of isolated_root_closure.rb's original JWS decode.
5
+ # No legacy research parser, test autorun, SD resolver or Host code is loaded.
6
+ module RootSignature
7
+ MAX_WIRE_BYTES = 32_768
8
+ MAX_KEY_BYTES = 1_024
9
+ JWT_VERSION = "3.3.0"
10
+
11
+ class Rejected < StandardError
12
+ attr_reader :code
13
+ def initialize(code)
14
+ @code = code
15
+ super(code.to_s)
16
+ end
17
+ end
18
+ class Unavailable < Rejected; end
19
+
20
+ def self.check!(source, issuer_pem)
21
+ verified_payload!(source, issuer_pem)
22
+ nil
23
+ end
24
+
25
+ # Internal stage handoff: always authenticates this original wire first.
26
+ # Returned claims remain unvalidated for AP2 authority/schema/constraints.
27
+ def self.verified_payload!(source, issuer_pem, expected_typ: "example+sd-jwt", max_bytes: MAX_WIRE_BYTES)
28
+ wire = InputValidator.snapshot!(source, max_bytes: max_bytes)
29
+ raise Rejected, :invalid_root_jws unless wire.ascii_only?
30
+ parts = wire.split(".", -1)
31
+ raise Rejected, :invalid_root_jws unless parts.length == 3
32
+ dependencies!
33
+ header = InputValidator.parse!(decode!(parts[0], 2_048))
34
+ payload = InputValidator.parse!(decode!(parts[1], 24_576))
35
+ signature = decode!(parts[2], 64)
36
+ raise Rejected, :invalid_root_jws unless signature.bytesize == 64
37
+ raise Rejected, :unsupported_root_header unless header.instance_of?(Hash) &&
38
+ (header.keys - %w[alg typ kid]).empty? &&
39
+ (!header.key?("kid") || (header["kid"].instance_of?(String) &&
40
+ header["kid"].bytesize.between?(1, 128)))
41
+ raise Rejected, :unsupported_root_algorithm unless header["alg"] == "ES256"
42
+ raise Rejected, :unsupported_root_type unless header["typ"] == expected_typ
43
+ raise Rejected, :invalid_root_payload unless payload.instance_of?(Hash)
44
+ key = public_key!(issuer_pem)
45
+
46
+ begin
47
+ # Preserve original signed bytes. Nested mandate claims/time, SD
48
+ # completeness and Host constraints belong to later checks, not this
49
+ # crypto-only primitive. Disabling top-level JWT claims grants nothing.
50
+ verified, protected_header = JWT.decode(wire, key, true,
51
+ algorithms: ["ES256"], verify_expiration: false,
52
+ verify_not_before: false, verify_iat: false, verify_iss: false,
53
+ verify_aud: false, verify_jti: false, verify_sub: false,
54
+ required_claims: [], jwks: nil, x5c: nil)
55
+ rescue JWT::DecodeError
56
+ raise Rejected, :invalid_root_signature
57
+ end
58
+ raise Unavailable, :root_signature_check_unavailable unless verified == payload && protected_header == header
59
+ payload
60
+ end
61
+
62
+ # Canonical coordinates from independently supplied public PEM, never JWK import.
63
+ def self.public_jwk!(pem)
64
+ dependencies!
65
+ point = public_key!(pem).public_key.to_octet_string(:uncompressed)
66
+ {"kty" => "EC", "crv" => "P-256",
67
+ "x" => Base64.urlsafe_encode64(point.byteslice(1, 32), padding: false),
68
+ "y" => Base64.urlsafe_encode64(point.byteslice(33, 32), padding: false)}
69
+ end
70
+
71
+ def self.dependencies!
72
+ raise Unavailable, :signature_dependency_unavailable unless defined?(Gem)
73
+ gem "jwt", "= #{JWT_VERSION}"
74
+ require "jwt"
75
+ require "base64"
76
+ require "openssl"
77
+ raise Unavailable, :signature_dependency_unavailable unless
78
+ Gem.loaded_specs["jwt"]&.version&.to_s == JWT_VERSION
79
+ rescue LoadError
80
+ raise Unavailable, :signature_dependency_unavailable
81
+ end
82
+
83
+ def self.decode!(part, limit)
84
+ raise Rejected, :invalid_root_jws unless part.match?(/\A[A-Za-z0-9_-]+\z/)
85
+ raw = Base64.urlsafe_decode64(part + "=" * (-part.bytesize % 4))
86
+ raise Rejected, :invalid_root_jws unless raw.bytesize <= limit &&
87
+ Base64.urlsafe_encode64(raw, padding: false) == part
88
+ raw.freeze
89
+ rescue ArgumentError
90
+ raise Rejected, :invalid_root_jws
91
+ end
92
+
93
+ def self.public_key!(source)
94
+ begin
95
+ pem = InputValidator.snapshot!(source, max_bytes: MAX_KEY_BYTES)
96
+ rescue InputValidator::Invalid
97
+ raise Unavailable, :issuer_key_unavailable
98
+ end
99
+ # Accept canonical SPKI public PEM only: no encrypted/private key
100
+ # parsing, filesystem paths, URLs, JWK fetches or token-selected keys.
101
+ raise Unavailable, :issuer_key_unsupported unless pem.ascii_only? &&
102
+ pem.start_with?("-----BEGIN PUBLIC KEY-----\n")
103
+ begin
104
+ key = OpenSSL::PKey.read(pem)
105
+ raise Unavailable, :issuer_key_unsupported unless
106
+ key.instance_of?(OpenSSL::PKey::EC) && !key.private? &&
107
+ key.group.curve_name == "prime256v1" && key.public_to_pem == pem
108
+ rescue OpenSSL::PKey::PKeyError, ArgumentError
109
+ raise Unavailable, :issuer_key_unsupported
110
+ end
111
+ key
112
+ end
113
+ private_class_method :dependencies!, :decode!, :public_key!
114
+ end
115
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AP2MandateVerifier
4
+ # Hand-checked closed subset of pinned b4587ac1 AP2/UCP schemas, not a
5
+ # general JSON Schema evaluator. One Merchant/SKU, no tax/discount/links.
6
+ module ScopedConstraints
7
+ MAX_AMOUNT = 9_007_199_254_740_991
8
+ MAX_QUANTITY = 1_000_000
9
+ CURRENCIES = %w[USD EUR JPY].freeze
10
+ CORE_INSTANCE_OF = Object.instance_method(:instance_of?)
11
+ class Rejected < RootSignature::Rejected; end
12
+
13
+ def self.check!(**inputs)
14
+ verified_constraints!(**inputs)
15
+ nil
16
+ end
17
+
18
+ # Internal claims handoff after the complete existing limited profile.
19
+ def self.verified_constraints!(expected_currency:, expected_total_minor:, **binding_inputs)
20
+ begin
21
+ currency = InputValidator.snapshot!(expected_currency, max_bytes: 3)
22
+ rescue InputValidator::Invalid
23
+ raise RootSignature::Unavailable, :constraint_context_unavailable
24
+ end
25
+ raise RootSignature::Unavailable, :constraint_context_unavailable unless
26
+ CURRENCIES.include?(currency) && CORE_INSTANCE_OF.bind_call(expected_total_minor, Integer) &&
27
+ expected_total_minor.between?(0, MAX_AMOUNT)
28
+ details = PurchaseBinding.verified_details!(**binding_inputs)
29
+ open, checkout = details.values_at(:open, :checkout)
30
+ constraints = open["constraints"]
31
+ raise Rejected, :unsupported_constraint unless constraints.instance_of?(Array) && constraints.size == 2
32
+ required, allowed = constraints
33
+ raise Rejected, :invalid_scoped_schema unless required.instance_of?(Hash) && allowed.instance_of?(Hash)
34
+ raise Rejected, :unsupported_constraint unless required["type"] == "checkout.line_items" &&
35
+ allowed["type"] == "checkout.allowed_merchants"
36
+ exact!(required, %w[items type])
37
+ exact!(allowed, %w[allowed type])
38
+ requirement = one!(required["items"])
39
+ exact!(requirement, %w[acceptable_items id quantity])
40
+ text!(requirement["id"]) # S01: signature VALID does not make id=123 valid.
41
+ quantity!(requirement["quantity"])
42
+ sku = one!(requirement["acceptable_items"])
43
+ exact!(sku, %w[id title])
44
+ text!(sku["id"])
45
+ text!(sku["title"], 512)
46
+ merchant = one!(allowed["allowed"])
47
+ exact!(merchant, %w[id name])
48
+ text!(merchant["id"])
49
+ text!(merchant["name"], 512)
50
+
51
+ text!(checkout["id"])
52
+ text!(checkout["merchant"]["id"])
53
+ text!(checkout["merchant"]["name"], 512)
54
+ raise Rejected, :constraint_mismatch unless checkout["merchant"] == merchant
55
+ text!(checkout["currency"], 3)
56
+ raise Rejected, :unsupported_scoped_profile unless CURRENCIES.include?(checkout["currency"])
57
+ raise Rejected, :currency_mismatch unless checkout["currency"] == currency
58
+ raise Rejected, :invalid_scoped_schema unless checkout["links"].instance_of?(Array)
59
+ raise Rejected, :unsupported_scoped_profile unless checkout["links"].empty?
60
+ line = one!(checkout["line_items"])
61
+ exact!(line, %w[id item quantity totals])
62
+ text!(line["id"])
63
+ quantity!(line["quantity"])
64
+ product = line["item"]
65
+ exact!(product, %w[id price title])
66
+ text!(product["id"])
67
+ text!(product["title"], 512)
68
+ amount!(product["price"])
69
+ raise Rejected, :constraint_mismatch unless product["id"] == sku["id"] &&
70
+ product["title"] == sku["title"] && line["quantity"] == requirement["quantity"]
71
+ amount = product["price"] * line["quantity"]
72
+ raise Rejected, :constraint_limit_exceeded if amount > MAX_AMOUNT
73
+ raise Rejected, :amount_mismatch unless amount == expected_total_minor
74
+ totals!(line["totals"], amount)
75
+ totals!(checkout["totals"], amount)
76
+ details
77
+ end
78
+
79
+ def self.exact!(value, fields)
80
+ raise Rejected, :invalid_scoped_schema unless value.instance_of?(Hash) && (fields - value.keys).empty?
81
+ raise Rejected, :unsupported_scoped_profile unless (value.keys - fields).empty?
82
+ end
83
+ def self.one!(value)
84
+ raise Rejected, :invalid_scoped_schema unless value.instance_of?(Array)
85
+ raise Rejected, :unsupported_scoped_profile unless value.size == 1
86
+ value.first
87
+ end
88
+ def self.text!(value, limit = 256)
89
+ raise Rejected, :invalid_scoped_schema unless value.instance_of?(String) && !value.empty?
90
+ raise Rejected, :constraint_limit_exceeded if value.bytesize > limit
91
+ end
92
+ def self.quantity!(value)
93
+ raise Rejected, :invalid_scoped_schema unless value.instance_of?(Integer) && value > 0
94
+ raise Rejected, :constraint_limit_exceeded if value > MAX_QUANTITY
95
+ end
96
+ def self.amount!(value)
97
+ raise Rejected, :invalid_scoped_schema unless value.instance_of?(Integer) && value >= 0
98
+ raise Rejected, :constraint_limit_exceeded if value > MAX_AMOUNT
99
+ end
100
+ def self.totals!(values, expected)
101
+ raise Rejected, :invalid_scoped_schema unless values.instance_of?(Array)
102
+ raise Rejected, :unsupported_scoped_profile unless values.size == 2
103
+ values.each do |entry|
104
+ exact!(entry, %w[amount type])
105
+ text!(entry["type"])
106
+ amount!(entry["amount"])
107
+ end
108
+ raise Rejected, :unsupported_scoped_profile unless values.map { |entry| entry["type"] }.sort == %w[subtotal total]
109
+ raise Rejected, :amount_mismatch unless values.all? { |entry| entry["amount"] == expected }
110
+ end
111
+ private_class_method :exact!, :one!, :text!, :quantity!, :amount!, :totals!
112
+ private_constant :CORE_INSTANCE_OF
113
+ end
114
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+ require "digest"
3
+
4
+ module AP2MandateVerifier
5
+ # Authenticated Host assertions, never token-selected registration. No I/O.
6
+ module TrustContext
7
+ PROFILE_BODY = '{"profile_ref":"ap2.checkout.single_merchant_sku","version":"1","ap2_source":"b4587ac1d055888a73b4b21750973cffba961793","subset_source":"027ed4734af47c8289dbcb837373bb6086463ec4","algorithm":"ES256","key_format":"canonical-public-SPKI-P256-SHA256","root_type":"example+sd-jwt","terminal_type":"kb+sd-jwt","checkout_type":"JWT","disclosure_path_semantics":"root-walker-with-synthetic-disclosed-edges","disclosure_positions":["delegate_payload[0]","delegate_payload[0].disclosed.constraints[0].items[0].acceptable_items[0]","delegate_payload[0].disclosed.constraints[1].allowed[0]"],"constraints":["checkout.line_items","checkout.allowed_merchants"]}'.freeze
8
+ PROFILE_REF = "ap2.checkout.single_merchant_sku"
9
+ PROFILE_VERSION = "1"
10
+ PROFILE_HASH = Digest::SHA256.hexdigest(PROFILE_BODY).freeze
11
+ ROLES = {"Issuer" => :trusted_issuer_public_key_pem,
12
+ "Agent" => :expected_agent_public_key_pem,
13
+ "Merchant" => :trusted_merchant_public_key_pem}.freeze
14
+ RECORD_FIELDS = %w[role identity tenant_id purpose key_ref profile_ref profile_version public_key_pem key_fingerprint algorithm active not_before not_after].freeze
15
+ SELECTION_FIELDS = %w[trust_snapshot_id policy_version trust_content_hash profile_ref profile_version profile_definition_hash tenant_id Issuer Agent Merchant].freeze
16
+ INPUTS = %i[root_presentation terminal_jws host_checkout_jws expected_aud expected_nonce verified_at expected_checkout_id expected_merchant_id expected_currency expected_total_minor].freeze
17
+
18
+ def self.reject!(code = :invalid_trust_context) = raise(RootSignature::Rejected, code)
19
+ def self.shape!(value, fields)
20
+ reject! unless value.instance_of?(Hash) && value.keys.sort == fields.sort
21
+ end
22
+ def self.id!(value)
23
+ reject! unless value.instance_of?(String) && value.bytesize.between?(1, 256)
24
+ end
25
+ def self.digest!(value)
26
+ reject! unless value.instance_of?(String) && /\A[0-9a-f]{64}\z/.match?(value)
27
+ end
28
+ def self.time!(value)
29
+ reject! unless value.instance_of?(Integer) && value.between?(0, 4_102_444_800)
30
+ end
31
+ def self.window!(value)
32
+ reject! unless value["active"] == true || value["active"] == false
33
+ time!(value["not_before"])
34
+ time!(value["not_after"])
35
+ reject! unless value["not_before"] < value["not_after"]
36
+ end
37
+ def self.live!(value, now, inactive, expired)
38
+ reject!(inactive) unless value["active"]
39
+ reject!(expired) unless value["not_before"] <= now && now < value["not_after"]
40
+ end
41
+ def self.body!(envelope, fields, hash_name, limit)
42
+ shape!(envelope, fields + [hash_name, "body"])
43
+ digest!(envelope[hash_name])
44
+ wire = InputValidator.snapshot!(envelope["body"], max_bytes: limit)
45
+ reject!(:trust_context_mismatch) unless Digest::SHA256.hexdigest(wire) == envelope[hash_name]
46
+ body = InputValidator.parse!(wire)
47
+ fields.each do |field|
48
+ id!(envelope[field])
49
+ reject!(:trust_context_mismatch) unless body.instance_of?(Hash) && body[field] == envelope[field]
50
+ end
51
+ [body, wire]
52
+ end
53
+ def self.keys!(**inputs) = verified_context!(**inputs).fetch(:keys)
54
+
55
+ # Internal handoff of validated Host references and selected-key windows.
56
+ def self.verified_context!(trust_json:, selection_json:, verified_at:, expected_merchant_id:)
57
+ time!(verified_at)
58
+ trust = InputValidator.parse!(trust_json)
59
+ selection = InputValidator.parse!(InputValidator.snapshot!(selection_json, max_bytes: 16_384))
60
+ shape!(trust, %w[snapshot profile])
61
+ shape!(selection, SELECTION_FIELDS)
62
+ %w[trust_snapshot_id policy_version profile_ref profile_version tenant_id].each { |f| id!(selection[f]) }
63
+ %w[trust_content_hash profile_definition_hash].each { |f| digest!(selection[f]) }
64
+ ROLES.each_key do |role|
65
+ shape!(selection[role], %w[identity purpose key_ref])
66
+ selection[role].each_value { |value| id!(value) }
67
+ end
68
+ reject!(:trust_context_mismatch) unless selection["Merchant"]["identity"] == expected_merchant_id
69
+ snapshot, = body!(trust["snapshot"], %w[id policy_version], "content_hash", 65_536)
70
+ shape!(snapshot, %w[id policy_version active not_before not_after records])
71
+ window!(snapshot)
72
+ reject!(:trust_context_mismatch) unless trust["snapshot"].values_at("id", "policy_version", "content_hash") ==
73
+ selection.values_at("trust_snapshot_id", "policy_version", "trust_content_hash")
74
+ live!(snapshot, verified_at, :inactive_trust_snapshot, :trust_snapshot_time_mismatch)
75
+ _profile, wire = body!(trust["profile"], %w[profile_ref version], "definition_hash", 8_192)
76
+ reject!(:unsupported_trust_profile) unless wire == PROFILE_BODY &&
77
+ trust["profile"].values_at("profile_ref", "version", "definition_hash") == [PROFILE_REF, PROFILE_VERSION, PROFILE_HASH] &&
78
+ selection.values_at("profile_ref", "profile_version", "profile_definition_hash") == [PROFILE_REF, PROFILE_VERSION, PROFILE_HASH]
79
+ records = snapshot["records"]
80
+ reject! unless records.instance_of?(Array) && records.length.between?(1, 16)
81
+ records.each do |record|
82
+ shape!(record, RECORD_FIELDS)
83
+ %w[identity tenant_id purpose key_ref profile_ref profile_version].each { |f| id!(record[f]) }
84
+ reject! unless ROLES.key?(record["role"])
85
+ window!(record)
86
+ reject!(:unsupported_trust_profile) unless record.values_at("profile_ref", "profile_version", "algorithm") == [PROFILE_REF, PROFILE_VERSION, "ES256"]
87
+ digest!(record["key_fingerprint"])
88
+ begin
89
+ RootSignature.public_jwk!(record["public_key_pem"])
90
+ rescue RootSignature::Unavailable => error
91
+ raise if error.code == :signature_dependency_unavailable
92
+ reject!(:invalid_trust_key)
93
+ end
94
+ reject!(:invalid_trust_key) unless Digest::SHA256.hexdigest(record["public_key_pem"]) == record["key_fingerprint"]
95
+ end
96
+ selected = {}
97
+ keys = ROLES.to_h do |role, keyword|
98
+ wanted = selection[role]
99
+ matches = records.select do |record|
100
+ record["role"] == role && record["tenant_id"] == selection["tenant_id"] &&
101
+ wanted.all? { |field, value| record[field] == value }
102
+ end
103
+ reject!(:trust_key_not_found) if matches.empty?
104
+ reject!(:ambiguous_trust_key) unless matches.length == 1
105
+ record = matches.first
106
+ live!(record, verified_at, :inactive_trust_key, :trust_key_time_mismatch)
107
+ selected[role] = record
108
+ [keyword, record["public_key_pem"].freeze]
109
+ end.freeze
110
+ windows = [snapshot] + selected.values
111
+ references = selection.slice("trust_snapshot_id", "policy_version", "trust_content_hash",
112
+ "profile_definition_hash", "profile_ref", "profile_version", "tenant_id").transform_values(&:freeze).freeze
113
+ selected_keys = selected.values.map do |record|
114
+ record.reject { |field, _| field == "public_key_pem" }.freeze
115
+ end.freeze
116
+ {keys: keys, references: references, selected_keys: selected_keys,
117
+ agent_fingerprint: selected.fetch("Agent").fetch("key_fingerprint").freeze,
118
+ valid_from: windows.map { |value| value["not_before"] }.max,
119
+ valid_until: windows.map { |value| value["not_after"] }.min}.freeze
120
+ end
121
+ private_class_method :reject!, :shape!, :id!, :digest!, :time!, :window!, :live!, :body!
122
+ end
123
+ end