diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index ce73c361..3d8b0eaa 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -182,6 +182,27 @@ xerrors xmocksignature xrandomnonce XVCJ +AlgoVoi +algovoi +amavashev +Anders +andysalvo +canonicalizer +cyberphone +Erdtman +gowebpki +javac +webpki +Iaap +JCS +jcs +merch +Mlrw +Papi +Rundgren +serde +seritalien +Solé Yapily Zalopay Zalora diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..b867da0b --- /dev/null +++ b/biome.json @@ -0,0 +1,5 @@ +{ + "files": { + "includes": ["**", "!code/web-client"] + } +} diff --git a/code/sdk/schemas/ap2/conformance/open_mandate_hash/README.md b/code/sdk/schemas/ap2/conformance/open_mandate_hash/README.md new file mode 100644 index 00000000..d52075eb --- /dev/null +++ b/code/sdk/schemas/ap2/conformance/open_mandate_hash/README.md @@ -0,0 +1,107 @@ +# AP2 `open_mandate_hash` Conformance Vectors — v0 + +Conformance vectors for the `open_mandate_hash` derivation rule for +`open_checkout_mandate.json`. Any AP2 implementation that derives +`open_mandate_hash` MUST reproduce these vectors byte-for-byte. + +## Derivation rule + +```text +open_mandate_hash = SHA-256(JCS_RFC8785(unsigned_open_checkout_mandate_body)) +``` + +**Lowercase hex output.** + +The hash input is the mandate **claims object**, not the JWS compact form. +Re-encoding the JWS envelope MUST NOT change `open_mandate_hash`. + +## Vectors + +`vectors-v0.json` — 7 vectors anchored to +`code/sdk/schemas/ap2/open_checkout_mandate.json` at schema commit +`e3d9cafa7311d90612c7f908ae9b8821ddc8735a`. + +Each vector is structured as: + +```json +{ + "vector_id": "ap2-omh-v0-", + "mandate_body": { ... }, + "expected_jcs_bytes_b64": "", + "expected_open_mandate_hash": "sha256:", + "expectation": "reference | same_hash_as: | different_hash_from:" +} +``` + +| Vector | Pair invariant | Tests | +| --- | --- | --- | +| `ap2-omh-v0-baseline-001` | reference | Canonical baseline | +| `ap2-omh-v0-object-key-order-002` | `same_hash_as:ap2-omh-v0-baseline-001` | JCS sorts object members | +| `ap2-omh-v0-array-order-003` | `different_hash_from:ap2-omh-v0-baseline-001` | Arrays are order-significant | +| `ap2-omh-v0-optional-fields-004` | `different_hash_from:ap2-omh-v0-baseline-001` | Presence ≠ absence | +| `ap2-omh-v0-currency-minor-unit-005` | canonical form | Integer minor units only | +| `ap2-omh-v0-unicode-nfc-006a` | `different_hash_from:ap2-omh-v0-unicode-nfd-006b` | No Unicode normalization | +| `ap2-omh-v0-unicode-nfd-006b` | `different_hash_from:ap2-omh-v0-unicode-nfc-006a` | No Unicode normalization | + +The array-order and Unicode pairs catch the divergences most commonly seen +in practice: implementations that sort arrays or NFC-normalize strings will +fail the corresponding pair invariant immediately. + +## Reproduce locally + +Three runner scripts are included. Each reads `vectors-v0.json` and verifies +all 7 vectors and 4 pair invariants independently: + +| Runner | Language | Library | +| --- | --- | --- | +| `runner_python.py` | Python | `rfc8785@0.1.4` (Trail of Bits) | +| `runner_node.js` | JavaScript | `canonicalize@3.0.0` (Erdtman + Rundgren) | +| `runner_go.go` | Go | `gowebpki/jcs v1.0.1` | + +```bash +# Python +pip install rfc8785==0.1.4 +python runner_python.py vectors-v0.json + +# Node.js +npm install canonicalize@3.0.0 +node runner_node.js vectors-v0.json + +# Go +go run runner_go.go vectors-v0.json + +``` + +## Cross-implementation validation + +All 6 independent implementations produce byte-identical results for all 7 vectors +and all 4 pair invariants. Five different authors, four author sets, independently +attested. + +| Implementation | Language | Library | Vectors | Pair invariants | +| --- | --- | --- | --- | --- | +| `rfc8785@0.1.4` | Python | Trail of Bits | 7/7 ✓ | 4/4 ✓ | +| `canonicalize@3.0.0` | JavaScript | Erdtman + Rundgren (RFC 8785 author) | 7/7 ✓ | 4/4 ✓ | +| `gowebpki/jcs v1.0.1` | Go | @amavashev (AP2 maintainer) | 7/7 ✓ | 4/4 ✓ | +| `cyberphone/json-canonicalization` | Java | Rundgren (RFC 8785 reference impl) | 7/7 ✓ | 4/4 ✓ | +| `serde_jcs 0.2.0` | Rust | @seritalien / Vauban | 7/7 ✓ | 4/4 ✓ | +| `rfc8785` (corrected) | Python | Crest Systems (@andysalvo) | 7/7 ✓ | 4/4 ✓ | + +Full validation history: [AP2 issue #265](https://github.com/google-agentic-commerce/AP2/issues/265) + +## Known implementation hazards + +- **`json.dumps()` non-ASCII escaping** — Python's `json.dumps()` escapes + non-ASCII characters as `\uXXXX`. RFC 8785 requires literal UTF-8 bytes + for printable codepoints above U+007F. Use the `rfc8785` library instead. + This will cause failures on vectors 006a and 006b. + +- **Array sorting** — Do not sort arrays before hashing. JCS preserves + array element order. Vector 003 catches this. + +- **Unicode normalization** — Do not NFC-normalize strings before hashing. + RFC 8785 makes no Unicode normalization. Vectors 006a/006b catch this. + +- **Float/decimal prices** — `item.price` and `amount.amount` are integers + in the currency minor unit. `10.50` MUST be encoded as `1050`. Vector + 005 catches this. diff --git a/code/sdk/schemas/ap2/conformance/open_mandate_hash/runner_go.go b/code/sdk/schemas/ap2/conformance/open_mandate_hash/runner_go.go new file mode 100644 index 00000000..fb5105d7 --- /dev/null +++ b/code/sdk/schemas/ap2/conformance/open_mandate_hash/runner_go.go @@ -0,0 +1,130 @@ +// runner_go.go — gowebpki/jcs v1.0.1 runner for AP2 open_mandate_hash v0. +// +// Reads ap2-omh-v0.json, recomputes JCS + SHA-256 for each vector, and verifies +// recomputed hashes match expected_open_mandate_hash and pair expectations. +// +// Usage: +// +// go mod init jcs_runner && go get github.com/gowebpki/jcs@v1.0.1 +// go run runner_go.go ap2-omh-v0.json +package main + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "strings" + + "github.com/gowebpki/jcs" +) + +type vector struct { + VectorID string `json:"vector_id"` + MandateBody map[string]interface{} `json:"mandate_body"` + ExpectedJcsBytesB64 string `json:"expected_jcs_bytes_b64"` + ExpectedOpenMandateHash string `json:"expected_open_mandate_hash"` + Expectation string `json:"expectation"` +} + +type artefact struct { + Vectors []vector `json:"vectors"` +} + +func hashVector(body map[string]interface{}) (string, string, error) { + raw, err := json.Marshal(body) + if err != nil { + return "", "", err + } + jcsBytes, err := jcs.Transform(raw) + if err != nil { + return "", "", err + } + sum := sha256.Sum256(jcsBytes) + return base64.StdEncoding.EncodeToString(jcsBytes), + hex.EncodeToString(sum[:]), nil +} + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: go run runner_go.go ap2-omh-v0.json") + os.Exit(2) + } + raw, err := ioutil.ReadFile(os.Args[1]) + if err != nil { + fmt.Fprintf(os.Stderr, "read: %v\n", err) + os.Exit(2) + } + var data artefact + if err := json.Unmarshal(raw, &data); err != nil { + fmt.Fprintf(os.Stderr, "parse: %v\n", err) + os.Exit(2) + } + + computed := map[string]string{} + pass, fail := 0, 0 + for _, v := range data.Vectors { + b64, sha, err := hashVector(v.MandateBody) + if err != nil { + fmt.Printf(" FAIL %s jcs error: %v\n", v.VectorID, err) + fail++ + continue + } + computed[v.VectorID] = sha + expectedSha := strings.TrimPrefix(v.ExpectedOpenMandateHash, "sha256:") + bytesOk := b64 == v.ExpectedJcsBytesB64 + shaOk := sha == expectedSha + ok := bytesOk && shaOk + mark := "OK " + if !ok { + mark = "FAIL" + } + fmt.Printf(" %s %-34s sha256:%s\n", mark, v.VectorID, sha) + if !ok { + if !bytesOk { + fmt.Println(" bytes mismatch") + } + if !shaOk { + fmt.Printf(" expected sha256:%s\n", expectedSha) + } + fail++ + } else { + pass++ + } + } + + fmt.Println("\n--- pair invariants ---") + pairFail := 0 + for _, v := range data.Vectors { + exp := v.Expectation + if strings.HasPrefix(exp, "same_hash_as:") { + other := strings.TrimPrefix(exp, "same_hash_as:") + ok := computed[v.VectorID] == computed[other] + mark := "OK " + if !ok { + mark = "FAIL" + pairFail++ + } + fmt.Printf(" %s %s == %s\n", mark, v.VectorID, other) + } else if strings.HasPrefix(exp, "different_hash_from:") { + other := strings.TrimPrefix(exp, "different_hash_from:") + ok := computed[v.VectorID] != computed[other] + mark := "OK " + if !ok { + mark = "FAIL" + pairFail++ + } + fmt.Printf(" %s %s != %s\n", mark, v.VectorID, other) + } + } + + fmt.Printf("\n%d/%d vectors match (gowebpki/jcs v1.0.1)\n", pass, pass+fail) + fmt.Printf("%d pair-invariant failures\n", pairFail) + if fail == 0 && pairFail == 0 { + os.Exit(0) + } + os.Exit(1) +} diff --git a/code/sdk/schemas/ap2/conformance/open_mandate_hash/runner_node.js b/code/sdk/schemas/ap2/conformance/open_mandate_hash/runner_node.js new file mode 100644 index 00000000..b41a0359 --- /dev/null +++ b/code/sdk/schemas/ap2/conformance/open_mandate_hash/runner_node.js @@ -0,0 +1,75 @@ +#!/usr/bin/env node +/** + * runner_node.js — canonicalize@3.0.0 (Erdtman) runner for AP2 open_mandate_hash v0. + * + * Reads ap2-omh-v0.json, recomputes JCS + SHA-256 for each vector, and verifies + * recomputed hashes match `expected_open_mandate_hash` and pair expectations. + * + * Usage: + * npm install canonicalize@3.0.0 + * node --input-type=module runner_node.js ap2-omh-v0.json + * + * Or save next to a package.json with `{ "type": "module" }`. + */ +import fs from 'fs'; +import crypto from 'crypto'; +import canonicalize from 'canonicalize'; + +function hashVector(body) { + const jcs = canonicalize(body); + const jcsBytes = Buffer.from(jcs, 'utf8'); + return { + bytes_b64: jcsBytes.toString('base64'), + sha256: crypto.createHash('sha256').update(jcsBytes).digest('hex'), + }; +} + +function main() { + if (process.argv.length < 3) { + console.error('usage: node runner_node.js ap2-omh-v0.json'); + process.exit(2); + } + const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); + const vectors = data.vectors; + const computed = {}; + let pass = 0, fail = 0; + + for (const v of vectors) { + const { bytes_b64, sha256 } = hashVector(v.mandate_body); + computed[v.vector_id] = sha256; + const expectedSha = v.expected_open_mandate_hash.replace(/^sha256:/, ''); + const bytesOk = bytes_b64 === v.expected_jcs_bytes_b64; + const shaOk = sha256 === expectedSha; + const ok = bytesOk && shaOk; + const mark = ok ? 'OK ' : 'FAIL'; + console.log(` ${mark} ${v.vector_id.padEnd(34)} sha256:${sha256}`); + if (!ok) { + if (!bytesOk) console.log(` bytes mismatch`); + if (!shaOk) console.log(` expected sha256:${expectedSha}`); + } + if (ok) pass++; else fail++; + } + + console.log('\n--- pair invariants ---'); + let pairFail = 0; + for (const v of vectors) { + const exp = v.expectation || ''; + if (exp.startsWith('same_hash_as:')) { + const other = exp.split(':')[1]; + const ok = computed[v.vector_id] === computed[other]; + console.log(` ${ok ? 'OK ' : 'FAIL'} ${v.vector_id} == ${other}`); + if (!ok) pairFail++; + } else if (exp.startsWith('different_hash_from:')) { + const other = exp.split(':')[1]; + const ok = computed[v.vector_id] !== computed[other]; + console.log(` ${ok ? 'OK ' : 'FAIL'} ${v.vector_id} != ${other}`); + if (!ok) pairFail++; + } + } + + console.log(`\n${pass}/${pass + fail} vectors match (canonicalize@3.0.0)`); + console.log(`${pairFail} pair-invariant failures`); + process.exit(fail === 0 && pairFail === 0 ? 0 : 1); +} + +main(); diff --git a/code/sdk/schemas/ap2/conformance/open_mandate_hash/runner_python.py b/code/sdk/schemas/ap2/conformance/open_mandate_hash/runner_python.py new file mode 100644 index 00000000..4cbe9e1c --- /dev/null +++ b/code/sdk/schemas/ap2/conformance/open_mandate_hash/runner_python.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python +""" +runner_python.py — reference JCS runner for AP2 open_mandate_hash v0 vectors. + +Reads ap2-omh-v0.json, recomputes JCS(RFC 8785)(mandate_body) + SHA-256 for +each vector, and verifies: + + 1. recomputed SHA-256 == expected_open_mandate_hash + 2. base64(JCS bytes) == expected_jcs_bytes_b64 + 3. pair expectations (same_hash_as / different_hash_from) + +Usage: + pip install rfc8785==0.1.4 + python runner_python.py ap2-omh-v0.json +""" +from __future__ import annotations + +import base64 +import hashlib +import json +import sys +from pathlib import Path + +import rfc8785 + + +def hash_vector(body: dict) -> tuple[str, str]: + """Return (jcs_bytes_b64, sha256_hex) for the canonical form of body.""" + jcs = rfc8785.dumps(body) + return base64.b64encode(jcs).decode("ascii"), hashlib.sha256(jcs).hexdigest() + + +def main() -> int: + if len(sys.argv) < 2: + print("usage: runner_python.py ap2-omh-v0.json", file=sys.stderr) + return 2 + + data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + vectors = data["vectors"] + by_id = {v["vector_id"]: v for v in vectors} + + pass_, fail = 0, 0 + computed: dict[str, str] = {} + + for v in vectors: + bytes_b64, sha = hash_vector(v["mandate_body"]) + computed[v["vector_id"]] = sha + expected_sha = v["expected_open_mandate_hash"].removeprefix("sha256:") + bytes_ok = bytes_b64 == v["expected_jcs_bytes_b64"] + sha_ok = sha == expected_sha + ok = bytes_ok and sha_ok + mark = "OK " if ok else "FAIL" + print(f" {mark} {v['vector_id']:<34} sha256:{sha}") + if not ok: + if not bytes_ok: + print(f" bytes mismatch") + if not sha_ok: + print(f" expected sha256:{expected_sha}") + pass_ += int(ok) + fail += int(not ok) + + # Pair-invariant verification + print("\n--- pair invariants ---") + pair_fail = 0 + for v in vectors: + exp = v.get("expectation", "") + if exp.startswith("same_hash_as:"): + other = exp.split(":", 1)[1] + ok = computed[v["vector_id"]] == computed[other] + mark = "OK " if ok else "FAIL" + print(f" {mark} {v['vector_id']} == {other}") + if not ok: + pair_fail += 1 + elif exp.startswith("different_hash_from:"): + other = exp.split(":", 1)[1] + ok = computed[v["vector_id"]] != computed[other] + mark = "OK " if ok else "FAIL" + print(f" {mark} {v['vector_id']} != {other}") + if not ok: + pair_fail += 1 + + print(f"\n{pass_}/{pass_ + fail} vectors match (rfc8785@0.1.4)") + print(f"{pair_fail} pair-invariant failures") + return 0 if (fail == 0 and pair_fail == 0) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/code/sdk/schemas/ap2/conformance/open_mandate_hash/vectors-v0.json b/code/sdk/schemas/ap2/conformance/open_mandate_hash/vectors-v0.json new file mode 100644 index 00000000..e7907493 --- /dev/null +++ b/code/sdk/schemas/ap2/conformance/open_mandate_hash/vectors-v0.json @@ -0,0 +1,394 @@ +{ + "schema_version": "1.0", + "artefact_id": "ap2-open-mandate-hash-conformance-v0", + "published_at": "2026-05-19T13:30:00Z", + "canonicalizer": "rfc8785@0.1.4", + "hash": "SHA-256, lowercase hex, prefixed sha256:", + "anchored_to": { + "schema": "google-agentic-commerce/AP2 code/sdk/schemas/ap2/open_checkout_mandate.json", + "vct": "mandate.checkout.open.1", + "spec_section": "AP2 specification, Open Checkout Mandate" + }, + "derivation": "open_mandate_hash = SHA-256(JCS_RFC8785(unsigned_open_checkout_mandate_body)). Hash input is the mandate claims object, NOT the JWS compact form. Re-encoding the JWS envelope MUST NOT change open_mandate_hash.", + "context": { + "thread": "https://github.com/google-agentic-commerce/AP2/discussions/262", + "purpose": "Move open_mandate_hash interop from independent-convergence to spec-anchored. Two implementers agreeing is not an anchor; a deterministic vector set is. Seeded by AlgoVoi; AP2 community to own the canonical set long-term.", + "verification_recipe": [ + "1. For each vector, take mandate_body verbatim.", + "2. Canonicalize with RFC 8785 (rfc8785@0.1.4 or any conformant impl).", + "3. base64-encode the JCS bytes; it MUST equal expected_jcs_bytes_b64.", + "4. SHA-256 the JCS bytes, lowercase hex; MUST equal expected_open_mandate_hash.", + "5. Honour the pair expectations: same_hash_as / different_hash_from MUST hold." + ], + "pair_semantics": { + "object_key_order": "MUST be hash-identical (JCS sorts object keys)", + "array_order": "MUST differ (JCS preserves array order; do not sort arrays)", + "optional_fields": "MUST differ (presence != absence; not collapsed to default)", + "currency_minor_unit": "canonical integer-minor-unit form; no decimal/float", + "unicode_normalization": "MUST differ (RFC 8785 does no Unicode normalization)" + } + }, + "vectors": [ + { + "vector_id": "ap2-omh-v0-baseline-001", + "description": "Canonical reference Open Checkout Mandate, all required fields present.", + "pair_group": "baseline", + "expectation": "reference", + "mandate_body": { + "vct": "mandate.checkout.open.1", + "constraints": [ + { + "type": "checkout.allowed_merchants", + "allowed": [ + { + "id": "merch_acme", + "name": "Acme Tools", + "website": "https://acme.example" + } + ] + }, + { + "type": "checkout.line_items", + "items": [ + { + "id": "req_drill", + "acceptable_items": [ + { + "id": "SKU-DRILL-18V", + "title": "18V Cordless Drill", + "price": 8999 + }, + { + "id": "SKU-DRILL-12V", + "title": "12V Cordless Drill", + "price": 5999 + } + ], + "quantity": 1 + } + ] + } + ], + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + } + } + }, + "expected_jcs_bytes_b64": "eyJjbmYiOnsiandrIjp7ImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMTFxWUFZS3hDcmZWU183VHlXUUhPZzdoY3ZQYXBpTWxyd0lhYVBjSFVSbyJ9fSwiY29uc3RyYWludHMiOlt7ImFsbG93ZWQiOlt7ImlkIjoibWVyY2hfYWNtZSIsIm5hbWUiOiJBY21lIFRvb2xzIiwid2Vic2l0ZSI6Imh0dHBzOi8vYWNtZS5leGFtcGxlIn1dLCJ0eXBlIjoiY2hlY2tvdXQuYWxsb3dlZF9tZXJjaGFudHMifSx7Iml0ZW1zIjpbeyJhY2NlcHRhYmxlX2l0ZW1zIjpbeyJpZCI6IlNLVS1EUklMTC0xOFYiLCJwcmljZSI6ODk5OSwidGl0bGUiOiIxOFYgQ29yZGxlc3MgRHJpbGwifSx7ImlkIjoiU0tVLURSSUxMLTEyViIsInByaWNlIjo1OTk5LCJ0aXRsZSI6IjEyViBDb3JkbGVzcyBEcmlsbCJ9XSwiaWQiOiJyZXFfZHJpbGwiLCJxdWFudGl0eSI6MX1dLCJ0eXBlIjoiY2hlY2tvdXQubGluZV9pdGVtcyJ9XSwidmN0IjoibWFuZGF0ZS5jaGVja291dC5vcGVuLjEifQ==", + "expected_open_mandate_hash": "sha256:4a407c36ab0619a9086756abb3f5c6cae8f80dff721dd3746cc0fa9256616683", + "notes": "The anchor. All other vectors are deltas against this body." + }, + { + "vector_id": "ap2-omh-v0-object-key-order-002", + "description": "Identical mandate with every object's keys in different source order.", + "pair_group": "object_key_order", + "expectation": "same_hash_as:ap2-omh-v0-baseline-001", + "mandate_body": { + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + } + }, + "constraints": [ + { + "allowed": [ + { + "website": "https://acme.example", + "name": "Acme Tools", + "id": "merch_acme" + } + ], + "type": "checkout.allowed_merchants" + }, + { + "items": [ + { + "quantity": 1, + "acceptable_items": [ + { + "price": 8999, + "title": "18V Cordless Drill", + "id": "SKU-DRILL-18V" + }, + { + "price": 5999, + "title": "12V Cordless Drill", + "id": "SKU-DRILL-12V" + } + ], + "id": "req_drill" + } + ], + "type": "checkout.line_items" + } + ], + "vct": "mandate.checkout.open.1" + }, + "expected_jcs_bytes_b64": "eyJjbmYiOnsiandrIjp7ImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMTFxWUFZS3hDcmZWU183VHlXUUhPZzdoY3ZQYXBpTWxyd0lhYVBjSFVSbyJ9fSwiY29uc3RyYWludHMiOlt7ImFsbG93ZWQiOlt7ImlkIjoibWVyY2hfYWNtZSIsIm5hbWUiOiJBY21lIFRvb2xzIiwid2Vic2l0ZSI6Imh0dHBzOi8vYWNtZS5leGFtcGxlIn1dLCJ0eXBlIjoiY2hlY2tvdXQuYWxsb3dlZF9tZXJjaGFudHMifSx7Iml0ZW1zIjpbeyJhY2NlcHRhYmxlX2l0ZW1zIjpbeyJpZCI6IlNLVS1EUklMTC0xOFYiLCJwcmljZSI6ODk5OSwidGl0bGUiOiIxOFYgQ29yZGxlc3MgRHJpbGwifSx7ImlkIjoiU0tVLURSSUxMLTEyViIsInByaWNlIjo1OTk5LCJ0aXRsZSI6IjEyViBDb3JkbGVzcyBEcmlsbCJ9XSwiaWQiOiJyZXFfZHJpbGwiLCJxdWFudGl0eSI6MX1dLCJ0eXBlIjoiY2hlY2tvdXQubGluZV9pdGVtcyJ9XSwidmN0IjoibWFuZGF0ZS5jaGVja291dC5vcGVuLjEifQ==", + "expected_open_mandate_hash": "sha256:4a407c36ab0619a9086756abb3f5c6cae8f80dff721dd3746cc0fa9256616683", + "notes": "JCS (RFC 8785 sec 3.2.3) sorts object members by code unit. Object key order in the source MUST NOT affect open_mandate_hash. This vector MUST produce the byte-identical JCS output and hash as baseline-001." + }, + { + "vector_id": "ap2-omh-v0-array-order-003", + "description": "Baseline with acceptable_items[] order swapped.", + "pair_group": "array_order", + "expectation": "different_hash_from:ap2-omh-v0-baseline-001", + "mandate_body": { + "vct": "mandate.checkout.open.1", + "constraints": [ + { + "type": "checkout.allowed_merchants", + "allowed": [ + { + "id": "merch_acme", + "name": "Acme Tools", + "website": "https://acme.example" + } + ] + }, + { + "type": "checkout.line_items", + "items": [ + { + "id": "req_drill", + "acceptable_items": [ + { + "id": "SKU-DRILL-12V", + "title": "12V Cordless Drill", + "price": 5999 + }, + { + "id": "SKU-DRILL-18V", + "title": "18V Cordless Drill", + "price": 8999 + } + ], + "quantity": 1 + } + ] + } + ], + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + } + } + }, + "expected_jcs_bytes_b64": "eyJjbmYiOnsiandrIjp7ImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMTFxWUFZS3hDcmZWU183VHlXUUhPZzdoY3ZQYXBpTWxyd0lhYVBjSFVSbyJ9fSwiY29uc3RyYWludHMiOlt7ImFsbG93ZWQiOlt7ImlkIjoibWVyY2hfYWNtZSIsIm5hbWUiOiJBY21lIFRvb2xzIiwid2Vic2l0ZSI6Imh0dHBzOi8vYWNtZS5leGFtcGxlIn1dLCJ0eXBlIjoiY2hlY2tvdXQuYWxsb3dlZF9tZXJjaGFudHMifSx7Iml0ZW1zIjpbeyJhY2NlcHRhYmxlX2l0ZW1zIjpbeyJpZCI6IlNLVS1EUklMTC0xMlYiLCJwcmljZSI6NTk5OSwidGl0bGUiOiIxMlYgQ29yZGxlc3MgRHJpbGwifSx7ImlkIjoiU0tVLURSSUxMLTE4ViIsInByaWNlIjo4OTk5LCJ0aXRsZSI6IjE4ViBDb3JkbGVzcyBEcmlsbCJ9XSwiaWQiOiJyZXFfZHJpbGwiLCJxdWFudGl0eSI6MX1dLCJ0eXBlIjoiY2hlY2tvdXQubGluZV9pdGVtcyJ9XSwidmN0IjoibWFuZGF0ZS5jaGVja291dC5vcGVuLjEifQ==", + "expected_open_mandate_hash": "sha256:754fc15813c3ca16bf23f09d4ccedb74d727f5d9226947e8a63c1518133c9ed9", + "notes": "JSON arrays are order-significant; JCS preserves array element order. An implementer that sorts arrays for 'canonicalization' WILL diverge. This vector MUST produce a DIFFERENT hash from baseline-001. If yours matches baseline-001, your canonicalizer is incorrectly reordering arrays." + }, + { + "vector_id": "ap2-omh-v0-optional-fields-004", + "description": "Baseline plus optional iat and exp (Unix epoch integers).", + "pair_group": "optional_fields", + "expectation": "different_hash_from:ap2-omh-v0-baseline-001", + "mandate_body": { + "vct": "mandate.checkout.open.1", + "constraints": [ + { + "type": "checkout.allowed_merchants", + "allowed": [ + { + "id": "merch_acme", + "name": "Acme Tools", + "website": "https://acme.example" + } + ] + }, + { + "type": "checkout.line_items", + "items": [ + { + "id": "req_drill", + "acceptable_items": [ + { + "id": "SKU-DRILL-18V", + "title": "18V Cordless Drill", + "price": 8999 + }, + { + "id": "SKU-DRILL-12V", + "title": "12V Cordless Drill", + "price": 5999 + } + ], + "quantity": 1 + } + ] + } + ], + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + } + }, + "iat": 1747400000, + "exp": 1747403600 + }, + "expected_jcs_bytes_b64": "eyJjbmYiOnsiandrIjp7ImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMTFxWUFZS3hDcmZWU183VHlXUUhPZzdoY3ZQYXBpTWxyd0lhYVBjSFVSbyJ9fSwiY29uc3RyYWludHMiOlt7ImFsbG93ZWQiOlt7ImlkIjoibWVyY2hfYWNtZSIsIm5hbWUiOiJBY21lIFRvb2xzIiwid2Vic2l0ZSI6Imh0dHBzOi8vYWNtZS5leGFtcGxlIn1dLCJ0eXBlIjoiY2hlY2tvdXQuYWxsb3dlZF9tZXJjaGFudHMifSx7Iml0ZW1zIjpbeyJhY2NlcHRhYmxlX2l0ZW1zIjpbeyJpZCI6IlNLVS1EUklMTC0xOFYiLCJwcmljZSI6ODk5OSwidGl0bGUiOiIxOFYgQ29yZGxlc3MgRHJpbGwifSx7ImlkIjoiU0tVLURSSUxMLTEyViIsInByaWNlIjo1OTk5LCJ0aXRsZSI6IjEyViBDb3JkbGVzcyBEcmlsbCJ9XSwiaWQiOiJyZXFfZHJpbGwiLCJxdWFudGl0eSI6MX1dLCJ0eXBlIjoiY2hlY2tvdXQubGluZV9pdGVtcyJ9XSwiZXhwIjoxNzQ3NDAzNjAwLCJpYXQiOjE3NDc0MDAwMDAsInZjdCI6Im1hbmRhdGUuY2hlY2tvdXQub3Blbi4xIn0=", + "expected_open_mandate_hash": "sha256:6368fcf2c7d7aa63453ba310043b6a284f845cf48eef31eb1660aec36bce8052", + "notes": "Optional fields present vs absent are different mandates with different hashes; absence is not normalized to a default. iat/exp MUST be integers (Unix epoch), never strings or floats." + }, + { + "vector_id": "ap2-omh-v0-currency-minor-unit-005", + "description": "Price expressed as ISO-4217 integer minor units (1050 == 10.50 in a cent currency).", + "pair_group": "currency_minor_unit", + "expectation": "canonical_form", + "mandate_body": { + "vct": "mandate.checkout.open.1", + "constraints": [ + { + "type": "checkout.allowed_merchants", + "allowed": [ + { + "id": "merch_acme", + "name": "Acme Tools", + "website": "https://acme.example" + } + ] + }, + { + "type": "checkout.line_items", + "items": [ + { + "id": "req_drill", + "acceptable_items": [ + { + "id": "SKU-WIDGET", + "title": "Widget", + "price": 1050 + } + ], + "quantity": 1 + } + ] + } + ], + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + } + } + }, + "expected_jcs_bytes_b64": "eyJjbmYiOnsiandrIjp7ImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMTFxWUFZS3hDcmZWU183VHlXUUhPZzdoY3ZQYXBpTWxyd0lhYVBjSFVSbyJ9fSwiY29uc3RyYWludHMiOlt7ImFsbG93ZWQiOlt7ImlkIjoibWVyY2hfYWNtZSIsIm5hbWUiOiJBY21lIFRvb2xzIiwid2Vic2l0ZSI6Imh0dHBzOi8vYWNtZS5leGFtcGxlIn1dLCJ0eXBlIjoiY2hlY2tvdXQuYWxsb3dlZF9tZXJjaGFudHMifSx7Iml0ZW1zIjpbeyJhY2NlcHRhYmxlX2l0ZW1zIjpbeyJpZCI6IlNLVS1XSURHRVQiLCJwcmljZSI6MTA1MCwidGl0bGUiOiJXaWRnZXQifV0sImlkIjoicmVxX2RyaWxsIiwicXVhbnRpdHkiOjF9XSwidHlwZSI6ImNoZWNrb3V0LmxpbmVfaXRlbXMifV0sInZjdCI6Im1hbmRhdGUuY2hlY2tvdXQub3Blbi4xIn0=", + "expected_open_mandate_hash": "sha256:cd294f91a53366dbf8d50e5850605c2fec30d6eb3378df7e1192944f4284f9dc", + "notes": "AP2 item.price and amount.amount are integers in the currency minor unit (amount.json / item.json). 10.50 MUST be encoded 1050, never 10.50, \"10.50\", or 10.5. A float/decimal encoding is non-conformant and will not match this vector's hash." + }, + { + "vector_id": "ap2-omh-v0-unicode-nfc-006a", + "description": "merchant.name in Unicode NFC (composed) form.", + "pair_group": "unicode_normalization", + "expectation": "different_hash_from:ap2-omh-v0-unicode-nfd-006b", + "mandate_body": { + "vct": "mandate.checkout.open.1", + "constraints": [ + { + "type": "checkout.allowed_merchants", + "allowed": [ + { + "id": "merch_acme", + "name": "Café Solé", + "website": "https://acme.example" + } + ] + }, + { + "type": "checkout.line_items", + "items": [ + { + "id": "req_drill", + "acceptable_items": [ + { + "id": "SKU-DRILL-18V", + "title": "18V Cordless Drill", + "price": 8999 + }, + { + "id": "SKU-DRILL-12V", + "title": "12V Cordless Drill", + "price": 5999 + } + ], + "quantity": 1 + } + ] + } + ], + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + } + } + }, + "expected_jcs_bytes_b64": "eyJjbmYiOnsiandrIjp7ImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMTFxWUFZS3hDcmZWU183VHlXUUhPZzdoY3ZQYXBpTWxyd0lhYVBjSFVSbyJ9fSwiY29uc3RyYWludHMiOlt7ImFsbG93ZWQiOlt7ImlkIjoibWVyY2hfYWNtZSIsIm5hbWUiOiJDYWbDqSBTb2zDqSIsIndlYnNpdGUiOiJodHRwczovL2FjbWUuZXhhbXBsZSJ9XSwidHlwZSI6ImNoZWNrb3V0LmFsbG93ZWRfbWVyY2hhbnRzIn0seyJpdGVtcyI6W3siYWNjZXB0YWJsZV9pdGVtcyI6W3siaWQiOiJTS1UtRFJJTEwtMThWIiwicHJpY2UiOjg5OTksInRpdGxlIjoiMThWIENvcmRsZXNzIERyaWxsIn0seyJpZCI6IlNLVS1EUklMTC0xMlYiLCJwcmljZSI6NTk5OSwidGl0bGUiOiIxMlYgQ29yZGxlc3MgRHJpbGwifV0sImlkIjoicmVxX2RyaWxsIiwicXVhbnRpdHkiOjF9XSwidHlwZSI6ImNoZWNrb3V0LmxpbmVfaXRlbXMifV0sInZjdCI6Im1hbmRhdGUuY2hlY2tvdXQub3Blbi4xIn0=", + "expected_open_mandate_hash": "sha256:dd0330b1d02b1f83ade5821d64f9f5abf75d1af00575e8fa39d68a8bdeadfb31", + "notes": "RFC 8785 performs NO Unicode normalization. The serialized string is the exact code points supplied. NFC and NFD forms of the same glyph produce DIFFERENT open_mandate_hash. Implementers MUST NOT normalize merchant strings before hashing; the signed bytes are authoritative." + }, + { + "vector_id": "ap2-omh-v0-unicode-nfd-006b", + "description": "Same mandate, merchant.name in Unicode NFD (decomposed) form.", + "pair_group": "unicode_normalization", + "expectation": "different_hash_from:ap2-omh-v0-unicode-nfc-006a", + "mandate_body": { + "vct": "mandate.checkout.open.1", + "constraints": [ + { + "type": "checkout.allowed_merchants", + "allowed": [ + { + "id": "merch_acme", + "name": "Café Solé", + "website": "https://acme.example" + } + ] + }, + { + "type": "checkout.line_items", + "items": [ + { + "id": "req_drill", + "acceptable_items": [ + { + "id": "SKU-DRILL-18V", + "title": "18V Cordless Drill", + "price": 8999 + }, + { + "id": "SKU-DRILL-12V", + "title": "12V Cordless Drill", + "price": 5999 + } + ], + "quantity": 1 + } + ] + } + ], + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + } + } + }, + "expected_jcs_bytes_b64": "eyJjbmYiOnsiandrIjp7ImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMTFxWUFZS3hDcmZWU183VHlXUUhPZzdoY3ZQYXBpTWxyd0lhYVBjSFVSbyJ9fSwiY29uc3RyYWludHMiOlt7ImFsbG93ZWQiOlt7ImlkIjoibWVyY2hfYWNtZSIsIm5hbWUiOiJDYWZlzIEgU29sZcyBIiwid2Vic2l0ZSI6Imh0dHBzOi8vYWNtZS5leGFtcGxlIn1dLCJ0eXBlIjoiY2hlY2tvdXQuYWxsb3dlZF9tZXJjaGFudHMifSx7Iml0ZW1zIjpbeyJhY2NlcHRhYmxlX2l0ZW1zIjpbeyJpZCI6IlNLVS1EUklMTC0xOFYiLCJwcmljZSI6ODk5OSwidGl0bGUiOiIxOFYgQ29yZGxlc3MgRHJpbGwifSx7ImlkIjoiU0tVLURSSUxMLTEyViIsInByaWNlIjo1OTk5LCJ0aXRsZSI6IjEyViBDb3JkbGVzcyBEcmlsbCJ9XSwiaWQiOiJyZXFfZHJpbGwiLCJxdWFudGl0eSI6MX1dLCJ0eXBlIjoiY2hlY2tvdXQubGluZV9pdGVtcyJ9XSwidmN0IjoibWFuZGF0ZS5jaGVja291dC5vcGVuLjEifQ==", + "expected_open_mandate_hash": "sha256:f8ae25428c8f1eb7ffaf49da13366c5e76db95de76f237e01f98b6ec8e4247e9", + "notes": "Pair of 006a. If 006a and 006b produce the same hash, your pipeline is Unicode-normalizing and is non-conformant with RFC 8785." + } + ] +}