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,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
|