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,284 @@
1
+ # Fixed-profile research prerelease candidate — 0.1.0.pre.3
2
+
3
+ The Owner accepted a fixed-profile research-only prerelease policy. GitHub pre2
4
+ is published; this pre3 documentation/package revision is unpublished and requires
5
+ separate distribution approval. RubyGems publication is not performed or confirmed. This
6
+ is not a stable release, full Phase2-A PASS, production or Phase2B/E10 GO. Evidence is not Host ALLOW, payment authorization or proof of consumption.
7
+
8
+ ## Supported proposition and environment
9
+
10
+ The sole registered profile is `ap2.checkout.single_merchant_sku` version1,
11
+ with the exact `TrustContext::PROFILE_BODY`/hash. Official AP2 source is fixed at
12
+ b4587ac1d055888a73b4b21750973cffba961793; its subset-source reference027ed4734...
13
+ is historical profile provenance, not runtime code attestation. Root typ is
14
+ `example+sd-jwt`, terminal `kb+sd-jwt`, Merchant `JWT`, all ES256/P256.
15
+ Root Open1 -> Agent terminal inline Closed1, exactly3 supported array commitment
16
+ positions, one Merchant/SKU/cart line, ordered line_items/allowed_merchants
17
+ constraints, USD/EUR/JPY and empty links only. Other profiles/forms are rejected,
18
+ not accepted through a generic AP2/Schema fallback. This synthetic registered
19
+ profile does not establish real Issuer enrollment or general interoperability.
20
+
21
+ The supported baseline is MRI Ruby4.0.9 and JSON2.19.9/JWT3.3.0/base640.3.0/
22
+ OpenSSL gem4.0.2/Digest3.2.1, all fixed in the gemspec. These versions must be
23
+ available through normal RubyGems/Bundler resolution; source-loadpath bypass
24
+ or dependency replacement is not a compatibility contract. MBA409-20261023-BUILD02
25
+ used official --disable-dtrace and reused the existing task-local OpenSSL3.5.9
26
+ library. Loaded provider observations confirmed OpenSSL gem4.0.2/libcrypto3.5.9,
27
+ Digest3.2.1/resolv0.7.2/zlib3.2.3/Bundler4.0.20;885 source regressions passed
28
+ as MBA409-20261023-VALIDATION01. PACKAGE01 records the original validation snapshot's
29
+ package/install checks; PACKAGE02 is the later evidence-reference correction.
30
+ Linux4.0.9 was not yet observed at that packaging snapshot; the later pre2 run
31
+ 39939021098 is identified below, separately from this pre3 candidate.
32
+ The candidate contains no OpenSSL/JSON native binary and performs no dependency
33
+ download/build itself. Missing fixed/native providers must stop an offline
34
+ validation rather than trigger extra download/build. Actual native paths/hashes
35
+ are environment observations, not proved by gem versions. Bundler4.0.20 is the
36
+ resolver baseline, not a runtime dependency. RubyGems' version requirement does
37
+ not itself enforce the MRI engine or OS. DTrace/USDT probes are disabled.
38
+
39
+ Verifier-source observations exist for MBA Darwin arm64 and Ubuntu24.04 x86_64
40
+ at base main8d9845ff4b5618b9e57eda86d3a1e447d4124b47. The old pre.2 artifact was subsequently built/installed on Linux in
41
+ run39869876461 (885 source checks,52 installed checks,13 payload/10 loaded).
42
+ Stage19 records MBA7inputs x3 observations. See the commit-pinned Stage21
43
+ supplement below. Those Ruby4.0.1 observations do not validate this Ruby4.0.9
44
+ candidate. The later pre2 Linux observation is separately identified below;
45
+ this pre3 package has no Linux observation.
46
+ Windows, Intel Mac, other Rubies/engines and all OS versions are unverified.
47
+ No additional Actions run is authorized.
48
+
49
+ ## Full entry and required inputs
50
+
51
+ `AP2MandateVerifier.verify_research(trust_json:, selection_json:, host_json:,
52
+ root_presentation:, terminal_jws:, host_checkout_jws:, verified_at:)`
53
+
54
+ All byte inputs are exact Strings; no normalization/coercion. The first three
55
+ are strict JSON Strings from authenticated independent Host state, not a token
56
+ registration claim. Use the exact supported shapes implemented by
57
+ TrustContext and HostBinding; missing/null/extra/duplicate fields fail closed.
58
+ `verified_at` is an external integer Unix second, not the token's own clock.
59
+
60
+ - trust_json has snapshot{id,policy_version,content_hash,body} and
61
+ profile{profile_ref,version,definition_hash,body}. Snapshot body has exactly
62
+ id/policy_version/active/not_before/not_after/records. Each record contains
63
+ role/identity/tenant_id/purpose/key_ref/profile_ref/profile_version/
64
+ public_key_pem/key_fingerprint/algorithm/active/not_before/not_after.
65
+ Body hashes are lowercase SHA256 hex of exact frozen body bytes, outside body.
66
+ - selection_json has trust_snapshot_id/policy_version/trust_content_hash/
67
+ profile_ref/profile_version/profile_definition_hash/tenant_id plus
68
+ Issuer,Agent,Merchant selectors, each exactly identity/purpose/key_ref.
69
+ - host_json has binding/challenge/expected_challenge_issuer_id/clock_source_id.
70
+ Binding B contains tenant_id/buyer_account_id/order_id/merchant_id/audience/
71
+ profile_ref/order_version/profile_version/approved_agent_key_fingerprint/
72
+ root_presentation_digest/checkout_jwt_digest/currency/expected_total.
73
+ Challenge contains challenge_id/nonce/issuer_id/B/trust_snapshot_id/
74
+ policy_version/trust_content_hash/profile_definition_hash/not_before/not_after/state.
75
+ Only state=issued is accepted, B must match current Host B in every field,
76
+ references must match selected trust, and signed bytes/keys/amounts must bind.
77
+
78
+ Keys are canonical public SPKI PEM P256, fingerprinted with SHA256 of exact PEM.
79
+ Exactly one role/identity/tenant/purpose/key_ref/profile match is selected.
80
+ Unknown, ambiguous, inactive, expired, wrong-purpose or unknown profile is rejected.
81
+ Host is responsible for authenticating these inputs and supplying current state;
82
+ self-consistent hashes do not authenticate provenance or prevent rollback.
83
+
84
+ ## Result and Evidence
85
+
86
+ Full supported conjunction -> frozen ResearchResult:
87
+ status=:verified_research, code=:scoped_research_verified, success?=true,
88
+ deeply immutable in-process ResearchEvidence. Failures -> non-success Result,
89
+ evidence=nil; invalid/unsupported inputs are rejected, missing dependencies or
90
+ unexpected internal failure are unavailable. Process failure may produce no
91
+ result and must never be interpreted as success. Ruby API keyword/arity misuse
92
+ can raise before some low-level wrappers execute; arbitrary caller misuse is
93
+ not guaranteed to be wrapped in a Result.
94
+
95
+ The old `verify(presentation_json:)` checks JSON syntax only; it still returns
96
+ unverified/verification_not_implemented for valid JSON. All older verification
97
+ entries retain success?=false/evidence=nil even when their limited stage passes:
98
+ verify_root_signature, verify_root_disclosures, verify_purchase_binding,
99
+ verify_scoped_constraints, verify_trusted_scoped_constraints, verify_host_binding.
100
+ They do not replace the full entry. Internal verified_*! handoffs are not a
101
+ supported caller-supplied checked-facts API. Result accepts no Evidence injection.
102
+
103
+ Evidence contract ap2.research-evidence.2 / implementation revision
104
+ ap2.research-verifier.2 identifies checked profile/version/hash, evaluation
105
+ clock/time, snapshot, selected keys/fingerprints/periods, complete checked B and
106
+ challenge projection, original Root/Checkout/terminal digests, scope and shortest
107
+ validity interval. Revision is a static semantic identifier, not Git HEAD/source
108
+ measurement/attestation. Raw nonce field is input-only; verified_nonce_sha256 is
109
+ computed from the exact signature-verified terminal nonce after Host equality.
110
+ This does not detect secret text repeated by a caller in another allowed field.
111
+
112
+ Evidence.to_h is a detached deep-frozen Hash; strings/arrays/nesting/result are
113
+ immutable. No JSON/remote/Marshal reconstruction or authenticity of a transported
114
+ Hash is provided. Trusted Ruby process/interpreter is a precondition; private
115
+ constructors do not sandbox malicious Ruby/reflection in that same process.
116
+
117
+ ## Parsing, schema and time boundaries
118
+
119
+ Header/payload parse before signature enforces strict bounded syntax only.
120
+ JSON2.19.9 duplicate rejection remains on, including escaped duplicate keys;
121
+ BOM/comments/trailing commas/nonfinite numbers are rejected. JOSE is fixed alg/
122
+ typ with optional ignored kid1..128B, never a trust enrollment/key URL. JWK URLs,
123
+ jku/x5c/crit etc are unsupported. Canonical unpadded base64url is checked.
124
+ Root signature precedes full original commitment walk. All3 supported positions
125
+ and every supplied disclosure must be consumed exactly once; unknown/duplicate/
126
+ extra/unresolved and unsupported object_sd forms stop. Decoy origin is not inferred.
127
+ Root wire includes original disclosure order and trailing~. Signed sd_hash and
128
+ checkout_hash match original bytes; Host hex digests are not confused with them.
129
+
130
+ A hand-checked closed subset covers supported Open/Closed/Merchant/Checkout/
131
+ line-item/item/total required fields/types/const/positive quantities/price.
132
+ Only one line/allowed Merchant/SKU is accepted, quantities1..1000000, price and
133
+ amounts0..9007199254740991; multiplication must fit and equal Host total.
134
+ Exactly one subtotal/total each, consistent at line and Checkout level.
135
+ Schema-optional buyer/messages/website/image_url/parent_id/display_text and
136
+ Checkout expiry/continue URL are not supported; nonempty links are rejected.
137
+ No required field inside a claimed supported shape may be skipped by labelling
138
+ it unsupported. This is not a general Schema engine; static coverage and prior
139
+ fixed Schema examples do not prove all official formats.
140
+
141
+ Open/Closed iat/exp: integer0..4102444800, same values, exp>iat, TTL<=7200,
142
+ iat<=t<exp. Terminal iat in max(t-300,Open.iat)..t and <Open.exp, skew0.
143
+ All selected key/snapshot/challenge periods must contain t. valid_from=max(all
144
+ starts), valid_until=min(all exclusive ends including terminal.iat+301).
145
+ Age300 is allowed,301 refused. Sole profile has no extra period; Merchant
146
+ exp/nbf is not invented. clock_source_id is a Host assertion, not real-clock proof.
147
+
148
+ ## Resource contract and Host execution
149
+
150
+ Raw Root/trust <=131072B each; terminal <=32768B; Checkout/selection <=16384B
151
+ each; host <=32768B. Six original Strings total at most360448B, NOT an RSS quota.
152
+ JSON depth24; compact Root <=32768B; decoded header/payload/signature <=2048/
153
+ 24576/64B; disclosures<=16, encoded segment<=8192B. Root graph visits<=2048,
154
+ array<=16/object<=32, container depth24; this node/member budget is not a general
155
+ Host JSON limit. Snapshot/profile bodies<=65536/8192B, records1..16, PEM<=1024B.
156
+ IDs/versions/Host nonce mostly<=256B; title/name<=512B. Integer digit count has no
157
+ separate quota beyond byte caps. These are finite input limits, not time bounds.
158
+
159
+ There is no library wall-clock deadline, total memory quota, concurrency limiter
160
+ or actual-clock acquisition. CI10s probe/300s batches/15min job are harness limits.
161
+ Host must enforce its process/time/concurrency policy; no Thread Timeout/native
162
+ interrupt guarantee is asserted. Product CPU/RSS/parallel limits remain unproved.
163
+ No custom runtime/supervisor is supplied.
164
+
165
+ Host ordering: freeze Root/Checkout -> issue immutable challenge for current B/
166
+ trust/profile -> Agent signs terminal -> verify -> Evidence -> latest-state/time
167
+ recheck -> atomic nonce consumption + order CAS. Buyer/tenant/order version are
168
+ Host assertions, not Issuer-authenticated Buyer identity. Host must prevent nonce
169
+ rebinding/reuse, handle revocation/concurrency and reconcile unknown commits as
170
+ PENDING. Enrollment/rollback/DB/CAS/payment/Receipt/transport authority are outside
171
+ this Gem; missing Host guarantees mean no production ALLOW.
172
+
173
+ ## Evidence preservation and release boundary
174
+
175
+ 885 means existing regression cases,30 independent MBA contract cases,12 boundary
176
+ probes,8 MBA Schema judgments;42 Linux comparisons replay the30+12 same inputs.
177
+ Old O4's13cases64judgments are older evidence, not added/relabelled for this package.
178
+ Old manifests intentionally do not certify the changed gemspec/version. New
179
+ candidate artifact/source/runtime/test reports must be separate. Historical
180
+ O1/O3 total10MB, O4 sent-header-inclusive1MiB and Linux128MiB all-traffic compliance
181
+ remain unproved; successful logic tests do not repair them.
182
+
183
+ The package whitelist is lib/**/*.rb, README.md, LICENSE and this document.
184
+ No fixtures/research JSON/test code/private keys/vendor/cache/raw logs/native
185
+ extensions are included. Repository research data/harnesses are now under
186
+ research/; historical evidence bytes and logical pins were preserved.
187
+ Local candidate installation can reuse existing fixed dependency homes/default
188
+ OpenSSL: it proves installed candidate usability, not fresh offline installation
189
+ of all dependencies or future download availability. Stable/public release,
190
+ realIssuer/production/Phase2B/Stage16 GO require separate decisions.
191
+
192
+ ## Current candidate versus historical evidence
193
+
194
+ This unpublished README/package revision uses candidate version0.1.0.pre.3 pending
195
+ an external same-version/ownership check; no claim of uploadability is made.
196
+ The historical MBA409 VALIDATION01/PACKAGE01/PACKAGE02 records below identify pre2,
197
+ not new pre3 execution. Product lib and dependency requirements remain unchanged.
198
+ The later pre2 Linux run39939021098 passed885/52 and13/10 at commit
199
+ f6d4f83d396b75505256dba5769a54a8f065eeea; it is not a pre3 Linux package run.
200
+ Any new pre3 package/install report is separate and does not replace those records.
201
+ JSON2.19.9 must override the vulnerable default JSON2.18.0 supplied in Ruby4.0.9.
202
+ Measured additional providers are resolv0.7.2 and zlib3.2.3; actual identities,
203
+ linked libcrypto, source/archive/installed hashes and test results belong to the
204
+ separate new local validation report. Installed version assertions are not
205
+ full native-library/runtime security attestation. Historical sources/results/pins
206
+ remain unchanged and are not relabelled as this runtime.
207
+
208
+ Historical validation/limitations supplement:
209
+ https://github.com/cuichangquan/ap2-mandate-verifier/blob/c8d81eab49e37a1236a355d42cf667aab552392d/docs/STAGE21_VALIDATION_SUPPLEMENT.md
210
+ E06 remains PARTIAL/OPEN; trusted-harness assumptions, genuine Host trust/clock/
211
+ revocation, worst-case resource/concurrency guarantees and communication-accounting
212
+ gaps remain unproved. Evidence is not Host ALLOW; no generic AP2 claim is made.
213
+
214
+ ## Evidence identifiers for this runtime update
215
+
216
+ Current namespace: `MBA409-20261023`, separate from all MBA408 and historical
217
+ Ruby4.0.1 records below. The original completed VALIDATION01/PACKAGE01 pair
218
+ has these immutable identities (local retained files, not public downloads):
219
+
220
+ | Evidence | SHA256 / matching source |
221
+ | --- | --- |
222
+ | REPORT_MBA409_VALIDATION01.json | a329a990fca351380674639181ea487a7f2cc61f52af013f8b474727dd4bf8a4 |
223
+ | CANDIDATE_SOURCE_MANIFEST.json (417 files) | d5288db5c24827fc9a7b40e3c768f75afd7bda18177a124ef500f69b9037e6e6 |
224
+ | Matching original tested source commit | 7822fc2b11b6cb2df4c5b5495d747c0d730083bc |
225
+ | Matching original tested source tree | dd3e65e85502e47188bfc64b7fd974e97e8aba78 |
226
+ | Original PACKAGE01 Gem (32768B) | 13a5042fb0b16010ee16ede106ed4503874208da03fa09e6ba39ffc15b0137b6 |
227
+
228
+ The report binds provider identities, original input/expectation hashes and
229
+ package SHA/size to that complete manifest. The commit/tree was assigned after
230
+ local validation and all417 blobs matched; original local-only report text is
231
+ preserved. Product lib remains byte-identical to base main
232
+ d6778f54cb4f00bfbcbc99feadbb1b1ae1ea0c70. These immutable identities establish
233
+ the original observation, not automatic validation of subsequent edits.
234
+
235
+ - ACQUIRE01: official Ruby4.0.9 archive size/SHA matched; HTTP/2 sent-header
236
+ count is unproved. Original STOP record is immutable; a separate continuation
237
+ decision allowed reuse without redownload. Total HTTP/128MiB compliance is
238
+ unproved, independently of product test results.
239
+ - BUILD01: capacity scan raced configure's temporary conftest.dSYM deletion;
240
+ STOP with owned group reclaimed. No install success is attributed to it.
241
+ - BUILD02: one new build/install completed using the bounded, exact-transient
242
+ du recheck; unrelated errors or repeated uncertainty still STOP.
243
+ - VALIDATION01: new Ruby4.0.9/provider observation and885 source regressions.
244
+ - PACKAGE01: original validation snapshot's package build/fresh offline install,
245
+ source/archive/installed13 and ordinary loaded10/installed52 verification;
246
+ only the completed matching local report establishes those results.
247
+
248
+ - PACKAGE02: this documentation-only identity correction is a distinct package;
249
+ source/archive/installed13, loaded10 and installed52 are checked again once.
250
+ Its final source commit/tree, report/manifest hashes and new artifact SHA/size
251
+ are recorded together in the PR174 public evidence summary. The immutable
252
+ VALIDATION01/PACKAGE01 pair above is not replaced;885 is not rerun.
253
+
254
+ Public non-secret extraction and later PACKAGE02 identity mapping:
255
+ https://github.com/cuichangquan/ap2-mandate-verifier/pull/174
256
+ No local path is a public download. Missing, failed or mismatched report/source
257
+ pairs are not success. Hashes identify bytes, not runtime/Host attestation.
258
+
259
+ ### Historical Ruby4.0.8 records (unchanged observation identities)
260
+
261
+ The namespace `MBA408-20261023` identifies this Ruby4.0.8 work only. It must
262
+ not be resolved against `research/packaging_candidate/results/R1`, `R2` or `R3`,
263
+ which retain their original Ruby4.0.1/OpenSSL4.0.0 observations unchanged.
264
+
265
+ | Unique evidence ID | Observation and report binding |
266
+ | --- | --- |
267
+ | MBA408-20261023-BUILD01 | Ruby build stopped at DTrace access denial; preserved failure, not PASS. |
268
+ | MBA408-20261023-BUILD02 | Capacity monitor exception; original completion remains unknown. Later scoped process absence does not establish success. |
269
+ | MBA408-20261023-BUILD03 | Official disable-dtrace Ruby build/install completed in145.4s; OpenSSL gem4.0.2 linked libcrypto3.5.9. |
270
+ | MBA408-20261023-VALIDATION01 | Original885 source regressions and52 installed checks; original new-runtime package31232B, SHA2563252c9ce71f4ff91870d52004283b2b2fad09e38c71fa93447dfe232d2b7d716. |
271
+ | MBA408-20261023-PACKAGE02 | Documentation-only evidence-reference correction; its final source/tree SHA, new package SHA/size and repeated13-file/52-installed results are recorded together in the PR173 evidence summary. It is a separate artifact and does not rerun885 or replace VALIDATION01. |
272
+
273
+ VALIDATION01's retained local report (`REPORT_R3.json`, not a public download)
274
+ has SHA256a4923bb132e709568251b8bd15d574893ca88ea36d0274a03d0326436c27dc81.
275
+ Its complete source snapshot was subsequently recorded, byte-identically, as
276
+ commit96351b8d4a0ae5d9566599ccb92b6fa1da5d7986,
277
+ treecd72fe7b9353ffc0270a068e14c2bc34c5b64e2a. Product verifier sources are
278
+ unchanged from base49f4de5e88c6611dfe04b8e595acc7764247a359; historical
279
+ profile-source pins are provenance and do not identify this runtime run.
280
+ Public non-secret report summary and PACKAGE02 source/artifact identities:
281
+ https://github.com/cuichangquan/ap2-mandate-verifier/pull/173
282
+ The summary must explicitly identify the completed observation; a missing,
283
+ failed or mismatched report is not success. No local path is a public evidence
284
+ URL. Raw local environment logs are not published by this package.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AP2MandateVerifier
4
+ # Checks authenticated Host assertions; never issues/consumes a challenge.
5
+ module HostBinding
6
+ B_IDS = %w[tenant_id buyer_account_id order_id merchant_id audience profile_ref order_version profile_version].freeze
7
+ B_HASHES = %w[approved_agent_key_fingerprint root_presentation_digest checkout_jwt_digest].freeze
8
+ B_FIELDS = (B_IDS + B_HASHES + %w[currency expected_total]).freeze
9
+ REFERENCES = %w[trust_snapshot_id policy_version trust_content_hash profile_definition_hash].freeze
10
+ CHALLENGE_FIELDS = (%w[challenge_id nonce issuer_id B not_before not_after state] + REFERENCES).freeze
11
+
12
+ def self.reject!(code = :invalid_host_context) = raise(RootSignature::Rejected, code)
13
+ def self.shape!(value, fields)
14
+ reject! unless value.instance_of?(Hash) && value.keys.sort == fields.sort
15
+ end
16
+ def self.id!(value)
17
+ reject! unless value.instance_of?(String) && value.bytesize.between?(1, 256)
18
+ end
19
+ def self.hash!(value)
20
+ reject! unless value.instance_of?(String) && /\A[0-9a-f]{64}\z/.match?(value)
21
+ end
22
+ def self.time!(value)
23
+ reject! unless value.instance_of?(Integer) && value.between?(0, PurchaseBinding::MAX_EPOCH)
24
+ end
25
+ def self.binding!(value)
26
+ shape!(value, B_FIELDS)
27
+ B_IDS.each { |field| id!(value[field]) }
28
+ B_HASHES.each { |field| hash!(value[field]) }
29
+ reject! unless ScopedConstraints::CURRENCIES.include?(value["currency"]) &&
30
+ value["expected_total"].instance_of?(Integer) && value["expected_total"].between?(0, ScopedConstraints::MAX_AMOUNT)
31
+ end
32
+ def self.check!(**inputs)
33
+ verified_binding!(**inputs)
34
+ nil
35
+ end
36
+
37
+ # Internal handoff only after the entire supported conjunction passes.
38
+ def self.verified_binding!(trust_json:, selection_json:, host_json:, root_presentation:, terminal_jws:,
39
+ host_checkout_jws:, verified_at:)
40
+ # Capture every caller-owned original before key processing. JSON parse
41
+ # yields detached values; neither the Host nor signed bytes are rewritten.
42
+ root = InputValidator.snapshot!(root_presentation)
43
+ terminal = InputValidator.snapshot!(terminal_jws, max_bytes: 32_768)
44
+ checkout = InputValidator.snapshot!(host_checkout_jws, max_bytes: 16_384)
45
+ trust = InputValidator.snapshot!(trust_json)
46
+ selection = InputValidator.snapshot!(selection_json, max_bytes: 16_384)
47
+ host = InputValidator.parse!(InputValidator.snapshot!(host_json, max_bytes: 32_768))
48
+ time!(verified_at)
49
+ shape!(host, %w[binding challenge expected_challenge_issuer_id clock_source_id])
50
+ id!(host["expected_challenge_issuer_id"])
51
+ id!(host["clock_source_id"])
52
+ binding = host["binding"]
53
+ challenge = host["challenge"]
54
+ binding!(binding)
55
+ shape!(challenge, CHALLENGE_FIELDS)
56
+ binding!(challenge["B"])
57
+ %w[challenge_id nonce issuer_id trust_snapshot_id policy_version].each { |f| id!(challenge[f]) }
58
+ %w[trust_content_hash profile_definition_hash].each { |f| hash!(challenge[f]) }
59
+ time!(challenge["not_before"])
60
+ time!(challenge["not_after"])
61
+ reject! unless challenge["not_before"] < challenge["not_after"]
62
+ reject!(:challenge_state_mismatch) unless challenge["state"] == "issued"
63
+ reject!(:host_binding_mismatch) unless challenge["B"] == binding &&
64
+ challenge["issuer_id"] == host["expected_challenge_issuer_id"]
65
+ reject!(:challenge_time_mismatch) unless challenge["not_before"] <= verified_at && verified_at < challenge["not_after"]
66
+ context = TrustContext.verified_context!(trust_json: trust, selection_json: selection,
67
+ verified_at: verified_at, expected_merchant_id: binding["merchant_id"])
68
+ references = context.fetch(:references)
69
+ reject!(:challenge_reference_mismatch) unless REFERENCES.all? { |f| challenge[f] == references[f] }
70
+ reject!(:host_binding_mismatch) unless %w[tenant_id profile_ref profile_version].all? { |f| binding[f] == references[f] } &&
71
+ binding["approved_agent_key_fingerprint"] == context.fetch(:agent_fingerprint) &&
72
+ binding["root_presentation_digest"] == Digest::SHA256.hexdigest(root) &&
73
+ binding["checkout_jwt_digest"] == Digest::SHA256.hexdigest(checkout)
74
+ # This is B_verified, not just a copy of authenticated Host context:
75
+ # external key -> Root cnf; signed terminal nonce/aud/sd_hash; Closed
76
+ # original Checkout hash/bytes; Merchant signature/IDs; signed constraints.
77
+ details = ScopedConstraints.verified_constraints!(root_presentation: root, terminal_jws: terminal,
78
+ host_checkout_jws: checkout, **context.fetch(:keys), expected_aud: binding["audience"],
79
+ expected_nonce: challenge["nonce"], verified_at: verified_at,
80
+ expected_checkout_id: binding["order_id"], expected_merchant_id: binding["merchant_id"],
81
+ expected_currency: binding["currency"], expected_total_minor: binding["expected_total"])
82
+ open, closed, proof = details.values_at(:open, :closed, :terminal)
83
+ starts = [context.fetch(:valid_from), challenge["not_before"], open["iat"], closed["iat"], proof["iat"]]
84
+ ends = [context.fetch(:valid_until), challenge["not_after"], open["exp"], closed["exp"], proof["iat"] + 301]
85
+ # The sole registered profile has no separate validity interval. Merchant
86
+ # JWT has no iat/exp/nbf: inventing one would change the pinned profile.
87
+ reject!(:invalid_host_validity) unless starts.max <= verified_at && verified_at < ends.min
88
+ # Explicit public projection: the checked signed nonce remains input-only.
89
+ verified_challenge = challenge.slice("challenge_id", "issuer_id", "B", "trust_snapshot_id",
90
+ "policy_version", "trust_content_hash", "profile_definition_hash", "not_before", "not_after", "state")
91
+ .merge("verified_nonce_sha256" => Digest::SHA256.hexdigest(proof.fetch("nonce")))
92
+ {"profile_ref" => references.fetch("profile_ref"), "profile_version" => references.fetch("profile_version"),
93
+ "profile_definition_hash" => references.fetch("profile_definition_hash"),
94
+ "verified_at" => verified_at, "clock_source_id" => host.fetch("clock_source_id"),
95
+ "valid_from" => starts.max, "valid_until" => ends.min,
96
+ "trust_snapshot" => {"id" => references.fetch("trust_snapshot_id"), "policy_version" => references.fetch("policy_version"),
97
+ "content_hash" => references.fetch("trust_content_hash")},
98
+ "selected_keys" => context.fetch(:selected_keys), "checkout_binding" => binding,
99
+ "verified_challenge" => verified_challenge, "root_presentation_digest" => Digest::SHA256.hexdigest(root),
100
+ "checkout_jwt_digest" => Digest::SHA256.hexdigest(checkout), "terminal_digest" => Digest::SHA256.hexdigest(terminal)}
101
+ end
102
+ private_class_method :reject!, :shape!, :id!, :hash!, :time!, :binding!
103
+ end
104
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AP2MandateVerifier
4
+ # JSON syntax boundary only. Parsed data is never promoted to trusted data.
5
+ module InputValidator
6
+ MAX_BYTES = 131_072
7
+ MAX_DEPTH = 24 # Root container counts as depth one, matching JSON.parse.
8
+ JSON_VERSION = "2.19.9"
9
+ CORE_INSTANCE_OF = Object.instance_method(:instance_of?)
10
+ CORE_BYTESIZE = String.instance_method(:bytesize)
11
+ CORE_BYTESLICE = String.instance_method(:byteslice)
12
+
13
+ class Invalid < StandardError
14
+ attr_reader :code
15
+ def initialize(code)
16
+ @code = code
17
+ super(code.to_s)
18
+ end
19
+ end
20
+
21
+ class Unavailable < StandardError
22
+ attr_reader :code
23
+ def initialize(code)
24
+ @code = code
25
+ super(code.to_s)
26
+ end
27
+ end
28
+
29
+ def self.check!(source)
30
+ parse!(source)
31
+ nil
32
+ rescue Unavailable => error
33
+ error.code
34
+ end
35
+
36
+ # Returns untrusted JSON values only, for subsequent signature checks.
37
+ def self.parse!(source)
38
+ wire = snapshot!(source)
39
+ reject_extensions!(wire)
40
+ require "json"
41
+ raise Unavailable, :json_dependency_unavailable unless JSON::VERSION == JSON_VERSION
42
+ begin
43
+ value = JSON.parse(wire, create_additions: false, symbolize_names: false,
44
+ max_nesting: MAX_DEPTH, allow_nan: false,
45
+ allow_duplicate_key: false, allow_trailing_comma: false)
46
+ rescue JSON::NestingError
47
+ raise Invalid, :input_too_deep
48
+ rescue JSON::ParserError => error
49
+ raise Invalid, :duplicate_key if error.message.start_with?("duplicate key ")
50
+ raise Invalid, :invalid_json
51
+ end
52
+ check_numbers!(value)
53
+ value
54
+ end
55
+
56
+ def self.snapshot!(source, max_bytes: MAX_BYTES)
57
+ # Even exact Strings can have singleton overrides. Never dispatch to
58
+ # caller methods during type/size checks or original-byte copying.
59
+ raise Invalid, :invalid_input_type unless CORE_INSTANCE_OF.bind_call(source, String)
60
+ raise Invalid, :input_too_large if CORE_BYTESIZE.bind_call(source) > max_bytes
61
+ # Bounded, detached snapshot; never dup an unbounded mutable source.
62
+ wire = CORE_BYTESLICE.bind_call(source, 0, max_bytes + 1)
63
+ raise Invalid, :input_too_large if wire.bytesize > max_bytes
64
+ wire.force_encoding(Encoding::UTF_8)
65
+ raise Invalid, :invalid_encoding unless wire.valid_encoding?
66
+ wire.freeze
67
+ end
68
+
69
+ # JSON 2.18 accepts comments and a BOM. Reject both without interpreting
70
+ # string contents; ordinary '/' and Unicode inside strings remain valid.
71
+ def self.reject_extensions!(wire)
72
+ quoted = false
73
+ escaped = false
74
+ wire.each_byte do |byte|
75
+ if quoted
76
+ if escaped
77
+ escaped = false
78
+ elsif byte == 92
79
+ escaped = true
80
+ elsif byte == 34
81
+ quoted = false
82
+ end
83
+ elsif byte == 34
84
+ quoted = true
85
+ elsif byte == 47 || byte >= 128
86
+ raise Invalid, :invalid_json
87
+ end
88
+ end
89
+ end
90
+
91
+ def self.check_numbers!(value)
92
+ case value
93
+ when Hash
94
+ value.each_value { |child| check_numbers!(child) }
95
+ when Array
96
+ value.each { |child| check_numbers!(child) }
97
+ when Float
98
+ raise Invalid, :invalid_json unless value.finite?
99
+ end
100
+ end
101
+ private_class_method :check_numbers!, :reject_extensions!
102
+ private_constant :CORE_INSTANCE_OF, :CORE_BYTESIZE, :CORE_BYTESLICE
103
+ end
104
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AP2MandateVerifier
4
+ # Fixed DC04 Root -> one inline Closed terminal -> Host Merchant JWT only.
5
+ # No dynamic trust lookup, extra hops, constraint evaluation or Evidence.
6
+ module PurchaseBinding
7
+ CORE_INSTANCE_OF = Object.instance_method(:instance_of?)
8
+ MAX_EPOCH = 4_102_444_800
9
+ class Rejected < RootSignature::Rejected; end
10
+
11
+ def self.check!(root_presentation:, terminal_jws:, host_checkout_jws:,
12
+ **context)
13
+ verified_purchase!(root_presentation: root_presentation, terminal_jws: terminal_jws,
14
+ host_checkout_jws: host_checkout_jws, **context)
15
+ nil
16
+ end
17
+
18
+ # Internal handoff after every signature, closure and binding check.
19
+ def self.verified_purchase!(**inputs)
20
+ verified_details!(**inputs).values_at(:open, :checkout)
21
+ end
22
+
23
+ # Internal handoff only after all existing signature and binding checks.
24
+ def self.verified_details!(root_presentation:, terminal_jws:, host_checkout_jws:,
25
+ trusted_issuer_public_key_pem:, expected_agent_public_key_pem:,
26
+ trusted_merchant_public_key_pem:, expected_aud:, expected_nonce:,
27
+ verified_at:, expected_checkout_id:, expected_merchant_id:)
28
+ # Snapshot every caller-owned byte string before starting cryptography.
29
+ root_wire = InputValidator.snapshot!(root_presentation)
30
+ terminal_wire = InputValidator.snapshot!(terminal_jws, max_bytes: 32_768)
31
+ checkout_wire = context!(host_checkout_jws, 16_384)
32
+ issuer_pem = context!(trusted_issuer_public_key_pem, 1_024)
33
+ agent_pem = context!(expected_agent_public_key_pem, 1_024)
34
+ merchant_pem = context!(trusted_merchant_public_key_pem, 1_024)
35
+ aud = context!(expected_aud, 512)
36
+ nonce = context!(expected_nonce, 512)
37
+ checkout_id = context!(expected_checkout_id, 256)
38
+ merchant_id = context!(expected_merchant_id, 256)
39
+ raise RootSignature::Unavailable, :purchase_context_unavailable unless integer?(verified_at) &&
40
+ verified_at.between?(0, MAX_EPOCH)
41
+
42
+ root = RootDisclosures.verified_root!(root_wire, issuer_pem)
43
+ open = root.fetch("delegate_payload").first
44
+ exact!(open, %w[cnf constraints exp iat vct])
45
+ raise Rejected, :unsupported_purchase_profile unless open["vct"] == "mandate.checkout.open.1"
46
+ times!(open, verified_at)
47
+ exact!(open["cnf"], %w[jwk])
48
+ exact!(open["cnf"]["jwk"], %w[kty crv x y])
49
+ raise Rejected, :agent_key_mismatch unless open["cnf"]["jwk"] == RootSignature.public_jwk!(agent_pem)
50
+
51
+ terminal = RootSignature.verified_payload!(terminal_wire, agent_pem, expected_typ: "kb+sd-jwt")
52
+ exact!(terminal, %w[aud delegate_payload iat nonce sd_hash])
53
+ raise Rejected, :root_binding_mismatch unless terminal["sd_hash"] == hash(root_wire)
54
+ raise Rejected, :audience_mismatch unless terminal["aud"] == aud
55
+ raise Rejected, :nonce_mismatch unless terminal["nonce"] == nonce
56
+ raise Rejected, :invalid_purchase_time unless integer?(terminal["iat"]) &&
57
+ terminal["iat"].between?([verified_at - 300, open["iat"]].max, verified_at) &&
58
+ terminal["iat"] < open["exp"]
59
+ payloads = terminal["delegate_payload"]
60
+ raise Rejected, :unsupported_purchase_profile unless payloads.instance_of?(Array) && payloads.size == 1
61
+ closed = payloads.first
62
+ exact!(closed, %w[checkout_hash checkout_jwt exp iat vct])
63
+ raise Rejected, :unsupported_purchase_profile unless closed["vct"] == "mandate.checkout.1"
64
+ times!(closed, verified_at)
65
+ # R06 fixed shared claims: no shortening or extending Issuer iat/exp.
66
+ raise Rejected, :delegation_claim_mismatch unless closed["iat"] == open["iat"] && closed["exp"] == open["exp"]
67
+ raise Rejected, :checkout_binding_mismatch unless closed["checkout_jwt"] == checkout_wire &&
68
+ closed["checkout_hash"] == hash(checkout_wire)
69
+
70
+ checkout = RootSignature.verified_payload!(checkout_wire, merchant_pem, expected_typ: "JWT", max_bytes: 16_384)
71
+ exact!(checkout, %w[currency id line_items links merchant status totals])
72
+ exact!(checkout["merchant"], %w[id name])
73
+ raise Rejected, :checkout_identity_mismatch unless checkout["id"] == checkout_id &&
74
+ checkout["merchant"]["id"] == merchant_id
75
+ raise Rejected, :unsupported_purchase_profile unless checkout["status"] == "ready_for_complete"
76
+ # Item/amount/currency/links and Root constraints are stage6, not certified.
77
+ {open: open, closed: closed, terminal: terminal, checkout: checkout}
78
+ end
79
+
80
+ def self.context!(source, limit)
81
+ value = InputValidator.snapshot!(source, max_bytes: limit)
82
+ raise RootSignature::Unavailable, :purchase_context_unavailable if value.empty?
83
+ value
84
+ rescue InputValidator::Invalid
85
+ raise RootSignature::Unavailable, :purchase_context_unavailable
86
+ end
87
+
88
+ def self.times!(claims, now)
89
+ iat, exp = claims.values_at("iat", "exp")
90
+ raise Rejected, :invalid_purchase_time unless integer?(iat) && integer?(exp) &&
91
+ iat.between?(0, MAX_EPOCH) && exp.between?(0, MAX_EPOCH) &&
92
+ iat <= now && now < exp && exp > iat && exp - iat <= 7_200
93
+ end
94
+
95
+ def self.exact!(value, keys)
96
+ raise Rejected, :unsupported_purchase_profile unless value.instance_of?(Hash) && value.keys.sort == keys.sort
97
+ end
98
+ def self.integer?(value) = CORE_INSTANCE_OF.bind_call(value, Integer)
99
+ def self.hash(wire) = Base64.urlsafe_encode64(Digest::SHA256.digest(wire), padding: false)
100
+ private_class_method :context!, :times!, :exact!, :integer?, :hash
101
+ private_constant :CORE_INSTANCE_OF
102
+ end
103
+ end