diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index ce73c361..7b2d1d49 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -182,6 +182,37 @@ 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é +reencoded +JFZERTQSJ +Mwfj +Uvqm +Cual +Peufb +Tvei +Avzp +Swcv +Dzaz Yapily Zalopay Zalora diff --git a/.github/linters/.gitleaks.toml b/.github/linters/.gitleaks.toml new file mode 100644 index 00000000..d2bc16a1 --- /dev/null +++ b/.github/linters/.gitleaks.toml @@ -0,0 +1,15 @@ +# Gitleaks configuration for the AP2 super-linter run. +# +# Keeps all of gitleaks' default rules, and allowlists only the conformance +# test-vector JSON under code/sdk/schemas/ap2/conformance/. The `jws_compact` +# fields there are deterministic JWS fixtures signed with the published RFC 8037 +# Appendix A.1 Ed25519 test key (public half is embedded as the vectors' own +# cnf.jwk), so they are test fixtures, not secrets. +[extend] +useDefault = true + +[[allowlists]] +description = "AP2 conformance test vectors (RFC 8037 A.1 test key, not secrets)" +paths = [ + '''code/sdk/schemas/ap2/conformance/.*\.json''', +] diff --git a/.github/workflows/linter.yaml b/.github/workflows/linter.yaml index fea1b9c1..cfeddead 100644 --- a/.github/workflows/linter.yaml +++ b/.github/workflows/linter.yaml @@ -4,6 +4,11 @@ on: pull_request: branches: [main] +permissions: + contents: read + statuses: write + pull-requests: write + jobs: build: name: Lint Code Base @@ -11,20 +16,22 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: fetch-depth: 0 + persist-credentials: false - name: Lint Code Base - uses: super-linter/super-linter/slim@v8 + uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0 env: DEFAULT_BRANCH: main GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} LOG_LEVEL: WARN SHELLCHECK_OPTS: -e SC1091 -e 2086 VALIDATE_ALL_CODEBASE: false - FILTER_REGEX_EXCLUDE: "^(\\.github/|\\.vscode/|code/samples/).*|CODE_OF_CONDUCT.md|CHANGELOG.md" + FILTER_REGEX_EXCLUDE: "^(\\.github/|\\.vscode/|code/samples/|code/web-client/).*|CODE_OF_CONDUCT.md|CHANGELOG.md" VALIDATE_BIOME_FORMAT: false + VALIDATE_BIOME_LINT: false VALIDATE_PYTHON_BLACK: false VALIDATE_PYTHON_FLAKE8: false VALIDATE_PYTHON_ISORT: false 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..f9c7f72d --- /dev/null +++ b/code/sdk/schemas/ap2/conformance/open_mandate_hash/README.md @@ -0,0 +1,145 @@ +# 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` — 12 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": { ... }, + "jws_compact": "", + "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 | +| `ap2-omh-v0-jws-envelope-canonical-007a` | `same_hash_as:ap2-omh-v0-jws-envelope-reencoded-007b` | Hash input is the claims object, not the JWS | +| `ap2-omh-v0-jws-envelope-reencoded-007b` | `same_hash_as:ap2-omh-v0-baseline-001` | JWS re-encoding never changes the hash | +| `ap2-omh-v0-control-chars-008` | canonical form | JCS string escaping (sec 3.2.2.2) | +| `ap2-omh-v0-non-bmp-009` | canonical form | Non-BMP code points as literal UTF-8 | +| `ap2-omh-v0-integer-boundary-010` | canonical form | Integer exactness bound (2^53 - 1) | + +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. + +The 007 pair exercises the headline rule directly. Both vectors carry the +same claims object inside two byte-for-byte different JWS envelopes +(different protected headers, canonical vs non-canonical payload encoding, +different signatures) and MUST produce the same `open_mandate_hash` as the +baseline. Runners base64url-decode the `jws_compact` payload, parse it, +re-canonicalize, and require the same hash as from `mandate_body`. The +envelope signatures are real Ed25519 signatures under the RFC 8037 +appendix A.1 test key (whose public half is the `cnf.jwk` used throughout +these vectors); runners do not verify signatures. + +## Reproduce locally + +Three runner scripts are included. Each reads `vectors-v0.json` and verifies +all 12 vectors and 5 pair invariants independently, including the JWS +payload re-derivation for the 007 envelope pair: + +| 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 the +original 7 vectors (001 through 006b) and their 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) + +Vectors 007a through 010 were added later (JWS envelope invariance and +edge coverage) and are verified by the three in-tree runners above; +independent attestations for the additions are welcome on issue #265. + +## Known implementation hazards + +- **Hashing the JWS instead of the claims** — Do not hash the JWS compact + string or the raw payload bytes. The hash input is the parsed claims + object after RFC 8785 canonicalization; any JWS re-encoding (new header, + new payload serialization, new signature) leaves `open_mandate_hash` + unchanged. The 007a/007b pair catches this. + +- **`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, including non-BMP code points + (no surrogate-pair escapes). Use the `rfc8785` library instead. + This will cause failures on vectors 006a, 006b and 009. + +- **Control-character escaping** — RFC 8785 sec 3.2.2.2 mandates the + two-character escapes (`\b \f \n \r \t \" \\`) and lowercase `\u00xx` + for the remaining characters below U+0020. Emitting a `\u000a`-style + escape for line feed, or uppercase hex like `\u001F`, diverges. + Vector 008 catches this. + +- **Integers beyond 2^53 - 1** — RFC 8785 number serialization follows + ES6: integer values are exact only through 2^53 - 1. AP2 integer claims + MUST stay within that bound. Vector 010 pins the boundary. + +- **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..4e316831 --- /dev/null +++ b/code/sdk/schemas/ap2/conformance/open_mandate_hash/runner_go.go @@ -0,0 +1,162 @@ +// runner_go.go — gowebpki/jcs v1.0.1 runner for AP2 open_mandate_hash v0. +// +// Reads vectors-v0.json, recomputes JCS + SHA-256 for each vector, and verifies +// recomputed hashes match expected_open_mandate_hash and pair expectations. +// For vectors carrying jws_compact, also verifies the decoded JWS payload +// re-canonicalizes to the same hash (envelope invariance; signatures are not +// verified). +// +// Usage: +// +// go mod init jcs_runner && go get github.com/gowebpki/jcs@v1.0.1 +// go run runner_go.go vectors-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"` + JwsCompact string `json:"jws_compact"` + 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 +} + +// jwsPayloadHash returns the sha256 hex of the canonicalized claims parsed +// from a JWS compact serialization's payload. Envelope invariance check only; +// signatures are not verified. +func jwsPayloadHash(jwsCompact string) (string, error) { + parts := strings.Split(jwsCompact, ".") + if len(parts) != 3 { + return "", fmt.Errorf("jws_compact must have 3 parts, got %d", len(parts)) + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", err + } + var claims map[string]interface{} + if err := json.Unmarshal(payload, &claims); err != nil { + return "", err + } + _, sha, err := hashVector(claims) + return sha, err +} + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: go run runner_go.go vectors-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 + jwsOk := true + if v.JwsCompact != "" { + payloadSha, jwsErr := jwsPayloadHash(v.JwsCompact) + jwsOk = jwsErr == nil && payloadSha == sha + } + ok := bytesOk && shaOk && jwsOk + 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) + } + if !jwsOk { + fmt.Println(" jws payload hash mismatch (envelope invariance broken)") + } + 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..c2776c31 --- /dev/null +++ b/code/sdk/schemas/ap2/conformance/open_mandate_hash/runner_node.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * runner_node.js — canonicalize@3.0.0 (Erdtman) runner for AP2 open_mandate_hash v0. + * + * Reads vectors-v0.json, recomputes JCS + SHA-256 for each vector, and verifies + * recomputed hashes match `expected_open_mandate_hash` and pair expectations. + * For vectors carrying `jws_compact`, also verifies the decoded JWS payload + * re-canonicalizes to the same hash (envelope invariance; signatures are not + * verified). + * + * Usage: + * npm install canonicalize@3.0.0 + * node --input-type=module runner_node.js vectors-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 jwsPayloadHash(jwsCompact) { + // sha256 hex of the canonicalized claims parsed from the JWS payload. + // Envelope invariance check only; signatures are not verified. + const payload = Buffer.from(jwsCompact.split('.')[1], 'base64url'); + const claims = JSON.parse(payload.toString('utf8')); + return hashVector(claims).sha256; +} + +function main() { + if (process.argv.length < 3) { + console.error('usage: node runner_node.js vectors-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 jwsOk = v.jws_compact ? jwsPayloadHash(v.jws_compact) === sha256 : true; + const ok = bytesOk && shaOk && jwsOk; + 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 (!jwsOk) console.log(` jws payload hash mismatch (envelope invariance broken)`); + } + 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..72613d19 --- /dev/null +++ b/code/sdk/schemas/ap2/conformance/open_mandate_hash/runner_python.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python +""" +runner_python.py — reference JCS runner for AP2 open_mandate_hash v0 vectors. + +Reads vectors-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) + 4. for vectors carrying jws_compact: the base64url-decoded JWS payload, + parsed and re-canonicalized, produces the same open_mandate_hash + (envelope invariance; signatures are not verified) + +Usage: + pip install rfc8785==0.1.4 + python runner_python.py vectors-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 jws_payload_hash(jws_compact: str) -> str: + """Return sha256 hex of the canonicalized claims parsed from a JWS payload.""" + payload_b64 = jws_compact.split(".")[1] + payload = base64.urlsafe_b64decode(payload_b64 + "=" * (-len(payload_b64) % 4)) + claims = json.loads(payload.decode("utf-8")) + return hashlib.sha256(rfc8785.dumps(claims)).hexdigest() + + +def main() -> int: + if len(sys.argv) < 2: + print("usage: runner_python.py vectors-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 + jws_ok = True + if "jws_compact" in v: + jws_ok = jws_payload_hash(v["jws_compact"]) == sha + ok = bytes_ok and sha_ok and jws_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}") + if not jws_ok: + print(f" jws payload hash mismatch (envelope invariance broken)") + 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..6c5d5497 --- /dev/null +++ b/code/sdk/schemas/ap2/conformance/open_mandate_hash/vectors-v0.json @@ -0,0 +1,657 @@ +{ + "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)", + "jws_envelope": "MUST be hash-identical (hash input is the parsed claims object; re-encoding the JWS envelope does not affect it)", + "control_characters": "canonical two-character and lowercase \\u00xx escapes per RFC 8785 sec 3.2.2.2", + "non_bmp": "code points above U+FFFF are literal UTF-8 bytes, never surrogate-pair escapes", + "integer_boundary": "integers are exact through 2^53 - 1; larger values MUST NOT be used" + } + }, + "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." + }, + { + "vector_id": "ap2-omh-v0-jws-envelope-canonical-007a", + "description": "Baseline claims carried in a JWS whose payload is the JCS-canonical encoding.", + "pair_group": "jws_envelope", + "expectation": "same_hash_as:ap2-omh-v0-jws-envelope-reencoded-007b", + "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" + } + } + }, + "jws_compact": "eyJhbGciOiJFZERTQSJ9.eyJjbmYiOnsiandrIjp7ImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMTFxWUFZS3hDcmZWU183VHlXUUhPZzdoY3ZQYXBpTWxyd0lhYVBjSFVSbyJ9fSwiY29uc3RyYWludHMiOlt7ImFsbG93ZWQiOlt7ImlkIjoibWVyY2hfYWNtZSIsIm5hbWUiOiJBY21lIFRvb2xzIiwid2Vic2l0ZSI6Imh0dHBzOi8vYWNtZS5leGFtcGxlIn1dLCJ0eXBlIjoiY2hlY2tvdXQuYWxsb3dlZF9tZXJjaGFudHMifSx7Iml0ZW1zIjpbeyJhY2NlcHRhYmxlX2l0ZW1zIjpbeyJpZCI6IlNLVS1EUklMTC0xOFYiLCJwcmljZSI6ODk5OSwidGl0bGUiOiIxOFYgQ29yZGxlc3MgRHJpbGwifSx7ImlkIjoiU0tVLURSSUxMLTEyViIsInByaWNlIjo1OTk5LCJ0aXRsZSI6IjEyViBDb3JkbGVzcyBEcmlsbCJ9XSwiaWQiOiJyZXFfZHJpbGwiLCJxdWFudGl0eSI6MX1dLCJ0eXBlIjoiY2hlY2tvdXQubGluZV9pdGVtcyJ9XSwidmN0IjoibWFuZGF0ZS5jaGVja291dC5vcGVuLjEifQ.kcamtvq9PmsCN4yRt51Id04GZFjESeXONieOUWL-W6s8_rFDf4nkIQudIgz0yMHc2MtAj45mjdkelHTmec26Ag", + "expected_jcs_bytes_b64": "eyJjbmYiOnsiandrIjp7ImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMTFxWUFZS3hDcmZWU183VHlXUUhPZzdoY3ZQYXBpTWxyd0lhYVBjSFVSbyJ9fSwiY29uc3RyYWludHMiOlt7ImFsbG93ZWQiOlt7ImlkIjoibWVyY2hfYWNtZSIsIm5hbWUiOiJBY21lIFRvb2xzIiwid2Vic2l0ZSI6Imh0dHBzOi8vYWNtZS5leGFtcGxlIn1dLCJ0eXBlIjoiY2hlY2tvdXQuYWxsb3dlZF9tZXJjaGFudHMifSx7Iml0ZW1zIjpbeyJhY2NlcHRhYmxlX2l0ZW1zIjpbeyJpZCI6IlNLVS1EUklMTC0xOFYiLCJwcmljZSI6ODk5OSwidGl0bGUiOiIxOFYgQ29yZGxlc3MgRHJpbGwifSx7ImlkIjoiU0tVLURSSUxMLTEyViIsInByaWNlIjo1OTk5LCJ0aXRsZSI6IjEyViBDb3JkbGVzcyBEcmlsbCJ9XSwiaWQiOiJyZXFfZHJpbGwiLCJxdWFudGl0eSI6MX1dLCJ0eXBlIjoiY2hlY2tvdXQubGluZV9pdGVtcyJ9XSwidmN0IjoibWFuZGF0ZS5jaGVja291dC5vcGVuLjEifQ==", + "expected_open_mandate_hash": "sha256:4a407c36ab0619a9086756abb3f5c6cae8f80dff721dd3746cc0fa9256616683", + "notes": "Exercises the headline rule directly: the hash input is the claims object, NOT the JWS compact form. The JWS payload here happens to be JCS-canonical. Runners MUST base64url-decode the payload, parse it, re-canonicalize, and obtain the same open_mandate_hash as from mandate_body. The signature is a real Ed25519 signature under the RFC 8037 appendix A.1 test key (the same public key as cnf.jwk); runners do not verify it." + }, + { + "vector_id": "ap2-omh-v0-jws-envelope-reencoded-007b", + "description": "Identical claims re-enveloped: different protected header (extra members, different order) and a non-canonical payload encoding (source key order, whitespace).", + "pair_group": "jws_envelope", + "expectation": "same_hash_as: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" + } + } + }, + "jws_compact": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6InJmYzgwMzctYTEtdGVzdC1rZXkifQ.eyJ2Y3QiOiAibWFuZGF0ZS5jaGVja291dC5vcGVuLjEiLCAiY29uc3RyYWludHMiOiBbeyJ0eXBlIjogImNoZWNrb3V0LmFsbG93ZWRfbWVyY2hhbnRzIiwgImFsbG93ZWQiOiBbeyJpZCI6ICJtZXJjaF9hY21lIiwgIm5hbWUiOiAiQWNtZSBUb29scyIsICJ3ZWJzaXRlIjogImh0dHBzOi8vYWNtZS5leGFtcGxlIn1dfSwgeyJ0eXBlIjogImNoZWNrb3V0LmxpbmVfaXRlbXMiLCAiaXRlbXMiOiBbeyJpZCI6ICJyZXFfZHJpbGwiLCAiYWNjZXB0YWJsZV9pdGVtcyI6IFt7ImlkIjogIlNLVS1EUklMTC0xOFYiLCAidGl0bGUiOiAiMThWIENvcmRsZXNzIERyaWxsIiwgInByaWNlIjogODk5OX0sIHsiaWQiOiAiU0tVLURSSUxMLTEyViIsICJ0aXRsZSI6ICIxMlYgQ29yZGxlc3MgRHJpbGwiLCAicHJpY2UiOiA1OTk5fV0sICJxdWFudGl0eSI6IDF9XX1dLCAiY25mIjogeyJqd2siOiB7Imt0eSI6ICJPS1AiLCAiY3J2IjogIkVkMjU1MTkiLCAieCI6ICIxMXFZQVlLeENyZlZTXzdUeVdRSE9nN2hjdlBhcGlNbHJ3SWFhUGNIVVJvIn19fQ.CCobMwfjCanVUvqmYWGuCualPeufbT-FFTveiYpEyl352dXnn-Gj2w4_IMC2AvzpDE95Wa3VhUSwcvDzazHpCA", + "expected_jcs_bytes_b64": "eyJjbmYiOnsiandrIjp7ImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMTFxWUFZS3hDcmZWU183VHlXUUhPZzdoY3ZQYXBpTWxyd0lhYVBjSFVSbyJ9fSwiY29uc3RyYWludHMiOlt7ImFsbG93ZWQiOlt7ImlkIjoibWVyY2hfYWNtZSIsIm5hbWUiOiJBY21lIFRvb2xzIiwid2Vic2l0ZSI6Imh0dHBzOi8vYWNtZS5leGFtcGxlIn1dLCJ0eXBlIjoiY2hlY2tvdXQuYWxsb3dlZF9tZXJjaGFudHMifSx7Iml0ZW1zIjpbeyJhY2NlcHRhYmxlX2l0ZW1zIjpbeyJpZCI6IlNLVS1EUklMTC0xOFYiLCJwcmljZSI6ODk5OSwidGl0bGUiOiIxOFYgQ29yZGxlc3MgRHJpbGwifSx7ImlkIjoiU0tVLURSSUxMLTEyViIsInByaWNlIjo1OTk5LCJ0aXRsZSI6IjEyViBDb3JkbGVzcyBEcmlsbCJ9XSwiaWQiOiJyZXFfZHJpbGwiLCJxdWFudGl0eSI6MX1dLCJ0eXBlIjoiY2hlY2tvdXQubGluZV9pdGVtcyJ9XSwidmN0IjoibWFuZGF0ZS5jaGVja291dC5vcGVuLjEifQ==", + "expected_open_mandate_hash": "sha256:4a407c36ab0619a9086756abb3f5c6cae8f80dff721dd3746cc0fa9256616683", + "notes": "Pair of 007a. Every byte of the envelope differs from 007a (header, payload encoding, signature), yet open_mandate_hash is unchanged because the parsed claims object is identical. Re-encoding the JWS envelope MUST NOT change open_mandate_hash. An implementation that hashes the JWS compact string, or the raw payload bytes, instead of the canonicalized claims object will fail this pair." + }, + { + "vector_id": "ap2-omh-v0-control-chars-008", + "description": "Item title containing every JCS escape class: quote, backslash, \\b \\f \\n \\r \\t, and unnamed control characters U+0000 and U+001F.", + "pair_group": "control_characters", + "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-CTRL", + "title": "Drill \"XR\"\\PRO\tkit\r\nspec\b\f\u0000\u001fend", + "price": 8999 + } + ], + "quantity": 1 + } + ] + } + ], + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + } + } + }, + "expected_jcs_bytes_b64": "eyJjbmYiOnsiandrIjp7ImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMTFxWUFZS3hDcmZWU183VHlXUUhPZzdoY3ZQYXBpTWxyd0lhYVBjSFVSbyJ9fSwiY29uc3RyYWludHMiOlt7ImFsbG93ZWQiOlt7ImlkIjoibWVyY2hfYWNtZSIsIm5hbWUiOiJBY21lIFRvb2xzIiwid2Vic2l0ZSI6Imh0dHBzOi8vYWNtZS5leGFtcGxlIn1dLCJ0eXBlIjoiY2hlY2tvdXQuYWxsb3dlZF9tZXJjaGFudHMifSx7Iml0ZW1zIjpbeyJhY2NlcHRhYmxlX2l0ZW1zIjpbeyJpZCI6IlNLVS1DVFJMIiwicHJpY2UiOjg5OTksInRpdGxlIjoiRHJpbGwgXCJYUlwiXFxQUk9cdGtpdFxyXG5zcGVjXGJcZlx1MDAwMFx1MDAxZmVuZCJ9XSwiaWQiOiJyZXFfZHJpbGwiLCJxdWFudGl0eSI6MX1dLCJ0eXBlIjoiY2hlY2tvdXQubGluZV9pdGVtcyJ9XSwidmN0IjoibWFuZGF0ZS5jaGVja291dC5vcGVuLjEifQ==", + "expected_open_mandate_hash": "sha256:91834c50763e8aa6260b990bb050d8de4660df5f2490dd7bef989ecf91b79be4", + "notes": "RFC 8785 sec 3.2.2.2: quote and backslash escape as \\\" and \\\\; backspace, form feed, line feed, carriage return and tab use their two-character escapes; all other control characters below U+0020 use lowercase \\u00xx (here \\u0000 and \\u001f). A serializer that emits \\u000A for line feed, uppercase hex, or a solidus escape will not match these bytes." + }, + { + "vector_id": "ap2-omh-v0-non-bmp-009", + "description": "Item titles containing non-BMP code points: U+1F527 (emoji) and U+1D541 U+1D554 U+1D564 (mathematical alphanumerics).", + "pair_group": "non_bmp", + "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-IMPACT", + "title": "Impact Driver 🔧", + "price": 12999 + }, + { + "id": "SKU-BITS", + "title": "𝕁𝕔𝕤 Bit Set", + "price": 2499 + } + ], + "quantity": 1 + } + ] + } + ], + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + } + } + }, + "expected_jcs_bytes_b64": "eyJjbmYiOnsiandrIjp7ImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMTFxWUFZS3hDcmZWU183VHlXUUhPZzdoY3ZQYXBpTWxyd0lhYVBjSFVSbyJ9fSwiY29uc3RyYWludHMiOlt7ImFsbG93ZWQiOlt7ImlkIjoibWVyY2hfYWNtZSIsIm5hbWUiOiJBY21lIFRvb2xzIiwid2Vic2l0ZSI6Imh0dHBzOi8vYWNtZS5leGFtcGxlIn1dLCJ0eXBlIjoiY2hlY2tvdXQuYWxsb3dlZF9tZXJjaGFudHMifSx7Iml0ZW1zIjpbeyJhY2NlcHRhYmxlX2l0ZW1zIjpbeyJpZCI6IlNLVS1JTVBBQ1QiLCJwcmljZSI6MTI5OTksInRpdGxlIjoiSW1wYWN0IERyaXZlciDwn5SnIn0seyJpZCI6IlNLVS1CSVRTIiwicHJpY2UiOjI0OTksInRpdGxlIjoi8J2VgfCdlZTwnZWkIEJpdCBTZXQifV0sImlkIjoicmVxX2RyaWxsIiwicXVhbnRpdHkiOjF9XSwidHlwZSI6ImNoZWNrb3V0LmxpbmVfaXRlbXMifV0sInZjdCI6Im1hbmRhdGUuY2hlY2tvdXQub3Blbi4xIn0=", + "expected_open_mandate_hash": "sha256:d0f73248b4668813ee61280a3bd101102148158b013eb7046088b2151cf0f156", + "notes": "Complements the 006 NFC/NFD pair. Code points above U+FFFF are serialized as literal UTF-8 (4 bytes each), never as \\uXXXX surrogate-pair escapes. An implementation whose serializer escapes non-ASCII output (for example Python json.dumps defaults) fails here even if it passed the Latin-1 vectors." + }, + { + "vector_id": "ap2-omh-v0-integer-boundary-010", + "description": "Baseline plus iat: 1 and exp: 9007199254740991 (2^53 - 1, the largest integer exact under RFC 8785 number serialization).", + "pair_group": "integer_boundary", + "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-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": 1, + "exp": 9007199254740991 + }, + "expected_jcs_bytes_b64": "eyJjbmYiOnsiandrIjp7ImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoiMTFxWUFZS3hDcmZWU183VHlXUUhPZzdoY3ZQYXBpTWxyd0lhYVBjSFVSbyJ9fSwiY29uc3RyYWludHMiOlt7ImFsbG93ZWQiOlt7ImlkIjoibWVyY2hfYWNtZSIsIm5hbWUiOiJBY21lIFRvb2xzIiwid2Vic2l0ZSI6Imh0dHBzOi8vYWNtZS5leGFtcGxlIn1dLCJ0eXBlIjoiY2hlY2tvdXQuYWxsb3dlZF9tZXJjaGFudHMifSx7Iml0ZW1zIjpbeyJhY2NlcHRhYmxlX2l0ZW1zIjpbeyJpZCI6IlNLVS1EUklMTC0xOFYiLCJwcmljZSI6ODk5OSwidGl0bGUiOiIxOFYgQ29yZGxlc3MgRHJpbGwifSx7ImlkIjoiU0tVLURSSUxMLTEyViIsInByaWNlIjo1OTk5LCJ0aXRsZSI6IjEyViBDb3JkbGVzcyBEcmlsbCJ9XSwiaWQiOiJyZXFfZHJpbGwiLCJxdWFudGl0eSI6MX1dLCJ0eXBlIjoiY2hlY2tvdXQubGluZV9pdGVtcyJ9XSwiZXhwIjo5MDA3MTk5MjU0NzQwOTkxLCJpYXQiOjEsInZjdCI6Im1hbmRhdGUuY2hlY2tvdXQub3Blbi4xIn0=", + "expected_open_mandate_hash": "sha256:043ff0e58003badbc052c427b9d0c1a5776aef7368dbc50bd1282c256e2e01a0", + "notes": "RFC 8785 serializes numbers with the ES6 algorithm: integers are exact only through 2^53 - 1 and print in plain decimal below 10^21. AP2 integer claims (iat, exp, price, amount) MUST stay within 2^53 - 1; larger values are not interoperable across JSON parsers and MUST NOT be used." + } + ] +}