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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +300 -0
- data/docs/SUPPORT_CONTRACT.md +284 -0
- data/lib/ap2_mandate_verifier/host_binding.rb +104 -0
- data/lib/ap2_mandate_verifier/input_validator.rb +104 -0
- data/lib/ap2_mandate_verifier/purchase_binding.rb +103 -0
- data/lib/ap2_mandate_verifier/research_result.rb +79 -0
- data/lib/ap2_mandate_verifier/result.rb +50 -0
- data/lib/ap2_mandate_verifier/root_disclosures.rb +100 -0
- data/lib/ap2_mandate_verifier/root_signature.rb +115 -0
- data/lib/ap2_mandate_verifier/scoped_constraints.rb +114 -0
- data/lib/ap2_mandate_verifier/trust_context.rb +123 -0
- data/lib/ap2_mandate_verifier.rb +128 -0
- metadata +123 -0
|
@@ -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
|