From d22128c984c4b06ce30cb3b53e8aac9d84581125 Mon Sep 17 00:00:00 2001 From: iamthat Date: Sun, 4 Oct 2026 05:28:04 +0500 Subject: [PATCH 1/4] test(ap2): add mandate conformance corpus and reference verifier Adds contrib/mandate-corpus/ with signed AP2 mandate fixtures and a portable reference verifier. No SDK source file is modified. The repository contains no fixture, corpus, vector or conformance directory: find . -type d \( -name "*fixture*" -o -name "*corpus*" \ -o -name "*vector*" -o -name "*conformance*" \) -> (empty) The Python SDK can sign mandates (sd_jwt.create, kb_sd_jwt.create) but nobody publishes signed fixtures. Consequently the positive test in Universal-Commerce-Protocol/conformance is skipped; PR #115 there states: "A real positive needs a signed SD-JWT+kb mandate fixture and a signing model, which this PR does not attempt." Why this is a blocker rather than a nicety, measured 2026-10-04: language lines signing crypto mandate support Python 8863 20 modules works Go 2258 none (FakeJWT) cannot verify PHP 39194 present PR #90 unmerged since 2026-07-15 The Go sample hardcodes a token and contains zero files performing real signing or verification. Python is therefore the only language that can verify an AP2 mandate, and other implementations have nothing to test against. Adds: - tools/generate_fixtures.py - reproducible signed fixture generator - tools/verify_fixtures.py - reference verifier, portable to Go/TS/PHP - tools/negative_vectors.py - negative vectors plus a positive control - tools/poc_aud_not_enforced.py - reproducer for the defect below - vectors/ - three signed vectors plus index.json - .github/workflows/mandate-corpus.yml - runs vectors on SDK changes Vectors: card-basic (100 USD), debit-large (50 000 minor units) and non-USD (BRL), the last catching implementations that silently normalise every currency to dollars. One AP2 subtlety is recorded deliberately: the root must carry a cnf naming the agent key before the human signs, otherwise the mandate is signed but unenforceable. The same defect is described independently in openmobilehub/credentagent PR #189. The generator sets DisclosureMetadata(sd_keys=[]) explicitly, because from_model otherwise hides the whole payload under _sd. Defect found, reported rather than patched because changing verification semantics is a maintainer decision: verify_chain documents that it enforces expected_aud and expected_nonce on the terminal hop, but kb_sd_jwt.verify calls verify_expected_claims only when typ in TYP_TERMINAL, and a kb+sd-jwt+kb token - which the SDK itself produces whenever the payload carries a cnf - falls into TYP_INTERMEDIATE. The check therefore never runs and no warning is emitted. Reproducer: python contrib/mandate-corpus/tools/poc_aud_not_enforced.py Fixing it turns the corresponding negative vector from xfail to passing. cspell gains only the identifiers this corpus introduces; the signed vector payloads are excluded from spelling as they are base64. Apache-2.0, matching the repository. --- .cspell.json | 7 +- .cspell/custom-words.txt | 190 +++++++++------- .github/workflows/mandate-corpus.yml | 39 ++++ contrib/mandate-corpus/README.md | 215 ++++++++++++++++++ .../mandate-corpus/tools/generate_fixtures.py | 186 +++++++++++++++ .../mandate-corpus/tools/negative_vectors.py | 208 +++++++++++++++++ .../tools/poc_aud_not_enforced.py | 114 ++++++++++ .../mandate-corpus/tools/verify_fixtures.py | 123 ++++++++++ contrib/mandate-corpus/vectors/index.json | 17 ++ .../open-payment-mandate-card-basic.json | 57 +++++ .../open-payment-mandate-debit-large.json | 57 +++++ .../vectors/open-payment-mandate-nonusd.json | 57 +++++ 12 files changed, 1189 insertions(+), 81 deletions(-) create mode 100644 .github/workflows/mandate-corpus.yml create mode 100644 contrib/mandate-corpus/README.md create mode 100644 contrib/mandate-corpus/tools/generate_fixtures.py create mode 100644 contrib/mandate-corpus/tools/negative_vectors.py create mode 100644 contrib/mandate-corpus/tools/poc_aud_not_enforced.py create mode 100644 contrib/mandate-corpus/tools/verify_fixtures.py create mode 100644 contrib/mandate-corpus/vectors/index.json create mode 100644 contrib/mandate-corpus/vectors/open-payment-mandate-card-basic.json create mode 100644 contrib/mandate-corpus/vectors/open-payment-mandate-debit-large.json create mode 100644 contrib/mandate-corpus/vectors/open-payment-mandate-nonusd.json diff --git a/.cspell.json b/.cspell.json index 3e21d466..22dde4d8 100644 --- a/.cspell.json +++ b/.cspell.json @@ -4,6 +4,7 @@ "caseSensitive": true, "useGitignore": true, "ignorePaths": [ + "contrib/mandate-corpus/vectors/**", ".github/**", ".cspell/**", ".gemini/**", @@ -13,9 +14,9 @@ ], "dictionaryDefinitions": [ { - "name": "custom-words", - "path": "./.cspell/custom-words.txt", - "addWords": true + "name": "custom-words", + "path": "./.cspell/custom-words.txt", + "addWords": true } ], "dictionaries": [ diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index ce73c361..d8cdd929 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -1,59 +1,124 @@ -aapt -absl -achatassistant ACMRTUXB +ASGI Adyen -agentic Agentic -agenticpayments Algorand -androidx Applebot -appname -ASGI -bazel +BVNK Blackhawk Boku -BVNK -celerybeat -classpath CLASSPATH CMSPI -cmwallet -cncf +CYGPATTERN Cobo Connor -contentnegotiation -credman Crossmint -cryptographical -CYGPATTERN +DCQL +DPAN Dafiti -disclosable -Disclosable -davecgh -dcql Dcql -DCQL -deviceauth Dfile -dmypy +Disclosable Doku Dorg -DPAN Drawables +EC +ES256 Ebanx +Fiuu +Forter +Fudd's +Garena +Gravitee +Hashkey +Imtp +JAVACMD +JWK +Jetpack +KXMYBJWNQ +Kaia +Klarna +Kotlinx +Ktor +Lazada +Licence +Lightspark +Loja +MERCANTILE +MSYS +Mispick +Momo +Monee +Mysten +Nuvei +OURCYGPATTERN +Otherville +PKI +Palo +Paynet +Payoneer +Payplug +Proguard +ROOTDIRS +ROOTDIRSRAW +RPCURL +Revolut +Riskified +Rulebook +SECP +Shopcider +Shopee +Signifyd +Splitit +Spyder +Truelayer +Trulioo +Wallex +Wayfair +Worldline +Worldpay +XVCJ +Xdock +Xendit +Yapily +Zalopay +Zalora +aapt +absl +achatassistant +agentic +agenticpayments +androidx +appname +authorisation +authorise +authorises +base64url +bazel +cand +celerybeat +classpath +cmwallet +cncf +cnf +conformance +contentnegotiation +credentagent +credman +crypto +cryptographical +davecgh +dcql +deviceauth +disclosable +dmypy emvco endlocal envoyproxy esac felixge -Fiuu fontawesome -Forter fqcn -Fudd's -Garena gemini genai generativeai @@ -63,38 +128,28 @@ gofmt gopkg gradletasknamecache gradlew -Gravitee groupcache gson -Hashkey honnef hprof htmlcov httpsnoop icns imei -Imtp inlinehilite inmemory ipynb issuerauth -JAVACMD jetbrains -Jetpack +jsonschema jvmargs -Kaia +jwcrypto keepattributes keepclassmembers -Klarna kotlin kotlinx -Kotlinx ktor -Ktor -KXMYBJWNQ -Lazada libpeerconnection -Lightspark linenums llms llmstxt @@ -102,36 +157,34 @@ logr longrunning mastercard micropayments -Mispick -Momo -Monee msys -MSYS multistep -Mysten nexi +nonusd +normalise +normalises +normalising nosetests -Nuvei objx octicons okhttp +openid +openmobilehub opentelemetry otelgrpc otelhttp -Otherville -OURCYGPATTERN -Palo passcodes -Paynet -Payoneer +payee +payloads +payoffs paypal -Payplug pids +pisp pmezard proguard -Proguard prometheus protoc +pyca pyflow pymdownx pypa @@ -141,47 +194,28 @@ refundability renamesourcefileattribute representment repudiable -Revolut -Riskified -ROOTDIRS -ROOTDIRSRAW ropeproject -RPCURL -Rulebook screenreaders +sdjwt setlocal sharedpref -Shopcider -Shopee sideloaded -Signifyd skus solana -Splitit -Spyder spyderproject spyproject stablecoins stdr stretchr superfences -Truelayer -Trulioo udpa +unexecutability unmarshal +unmerged viewmodel vulnz -Wallex -Wayfair webassets -Worldline -Worldpay -Xdock -Xendit xerrors +xfail xmocksignature xrandomnonce -XVCJ -Yapily -Zalopay -Zalora diff --git a/.github/workflows/mandate-corpus.yml b/.github/workflows/mandate-corpus.yml new file mode 100644 index 00000000..38986678 --- /dev/null +++ b/.github/workflows/mandate-corpus.yml @@ -0,0 +1,39 @@ +# Конформити-корпус мандатов AP2. +# +# Векторы обязаны ломаться вслед за SDK: если изменение верификации тихо +# ломает проверку подписи или cnf-привязки, тест падает в том же PR, +# а не через месяц у продавца. +name: mandate-corpus + +on: + pull_request: + paths: + - 'code/sdk/python/**' + - 'contrib/mandate-corpus/**' + - '.github/workflows/mandate-corpus.yml' + push: + branches: [main] + paths: + - 'code/sdk/python/**' + - 'contrib/mandate-corpus/**' + workflow_dispatch: + +jobs: + vectors: + name: signed mandate fixtures + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: pip install -e ./code/sdk/python + - name: verify fixtures + run: python contrib/mandate-corpus/tools/verify_fixtures.py + - name: negative vectors + run: python contrib/mandate-corpus/tools/negative_vectors.py + # Воспроизводитель дефекта expected_aud; падает, пока дефект открыт. + # continue-on-error — снимок флага сделает шаг обязательным. + - name: poc expected_aud (known defect, non-blocking) + continue-on-error: true + run: python contrib/mandate-corpus/tools/poc_aud_not_enforced.py diff --git a/contrib/mandate-corpus/README.md b/contrib/mandate-corpus/README.md new file mode 100644 index 00000000..75074d5b --- /dev/null +++ b/contrib/mandate-corpus/README.md @@ -0,0 +1,215 @@ +# AP2 Mandate Conformance Corpus + +Signed AP2 mandate fixtures for validating independent implementations, plus a +reproducer for a defect found in the AP2 Python SDK. + +Validated 2026-10-04 against `google-agentic-commerce/AP2` at that HEAD. + +--- + +## 1 · The gap this closes + +There is **no** `fixture`, `corpus`, `vector` or `conformance` directory +anywhere in `google-agentic-commerce/AP2`: + +```console +find . -type d \( -name "*fixture*" -o -name "*corpus*" -o -name "*vector*" \ + -o -name "*conformance*" \) +→ (empty) +```python + +The Python SDK can sign mandates (`sd_jwt.create`, `kb_sd_jwt.create`), but +nobody publishes signed fixtures. + +That is why the positive test in `Universal-Commerce-Protocol/conformance` has +to be skipped. From PR #115: + +> "A real positive needs a signed SD-JWT+kb mandate fixture and a signing +> model, which this PR does not attempt." + +**Who needs it:** AP2 implementations in **other languages** — the Go sample +(`code/samples/go`), TypeScript, and PHP +(`agentic-commerce-alliance/ucp-php-sdk`). They need a byte-level reference to +conform against, not somebody else's test code. + +### Why this is a blocker, not a nicety + +Measured across the AP2 ecosystem on 2026-10-04: + +| language | lines | signing/verification crypto | mandate support | +|---|---|---|---| +| **Python** (this repo) | 8 863 | 20 modules | works | +| **Go** (`code/samples/go`) | 2 258 | **0** | none — `FakeJWT` | +| **PHP** (`ucp-php-sdk`) | 39 194 | present | PR #90, **unmerged since 2026-07-15** | + +The Go sample hardcodes a token: + +```python +code/samples/go/pkg/roles/merchant_agent/tools.go:31 +FakeJWT = "eyJhbGciOiJSUz1NiIsImtpZIwMjQwOTA..." +```bash + +There are **zero** files in the Go sample that perform real signing or +verification. + +The consequence: **Python is currently the only language that can verify an +AP2 mandate.** Any other implementation has nothing to test against — it cannot +produce a valid mandate and cannot check one. Signed fixtures are the only +thing that unblocks them, which is what this corpus provides. + +--- + +## 2 · Contents + +```text +tools/generate_fixtures.py signed fixture generator +tools/verify_fixtures.py reference verifier (portable to other languages) +tools/negative_vectors.py negative vectors + positive control +tools/poc_aud_not_enforced.py reproducer for the defect in §3 +vectors/*.json 3 signed vectors + index.json +```text + +### Vectors + +| id | what it pins down | +|----|-------------------| +| `open-payment-mandate-card-basic` | baseline mandate, 100 USD, `cnf` = agent key | +| `open-payment-mandate-debit-large` | amount boundaries (50 000 minor units) | +| `open-payment-mandate-nonusd` | **non-USD (BRL)** — catches implementations that silently normalise everything to dollars | + +--- + +## 3 · Defect found: `expected_aud` is not enforced + +**Impact:** audience (recipient) check bypass. **Severity:** medium. + +`verify_chain` documents: + +> "index i (KB-SD-JWT): verified with the previous hop's `cnf.jwk` via +> `kb_sd_jwt.verify`, **enforcing `expected_aud` / `expected_nonce` on the +> terminal hop when provided**" + +But `kb_sd_jwt.verify` only calls `common.verify_expected_claims` when +`typ in TYP_TERMINAL`: + +```python +TYP_TERMINAL = ['kb+sd-jwt', 'kb-sd-jwt'] +TYP_INTERMEDIATE = ['kb+sd-jwt+kb', 'kb-sd-jwt+kb'] +```text + +A token whose `typ` is `kb+sd-jwt+kb` — which the SDK itself produces whenever +the payload carries a `cnf` claim — lands in `TYP_INTERMEDIATE`. The +`aud`/`nonce` check therefore **never runs**, even though the caller explicitly +passed `expected_aud`. There is no warning. + +### Reproduction + +```bash +.venv/bin/python tools/poc_aud_not_enforced.py +```text + +```text + hop type : kb+sd-jwt+kb (per TYP_INTERMEDIATE) + aud in token : https://attacker.example/ap2 + expected_aud : https://honest-merchant.example/ap2 + audiences do NOT match — a correct verifier must reject + + ACCEPTED a token with the wrong audience. + + VERDICT: DEFECT CONFIRMED +```text + +### Consequence + +A merchant relying on the audience check receives silent protection that does +not exist. Substituting `aud` passes without refusal. + +### Suggested fix + +In this repository (not in the PR): evaluate `expected_aud`/`expected_nonce` +against the **last** token in the chain regardless of `typ`, or widen +`TYP_TERMINAL`, or raise when `expected_aud` is supplied but the hop type is +not terminal. + +I did not include a patch for this, because changing verification semantics is +a maintainer decision, not something to land alongside a fixtures PR. The +reproducer is self-contained and can be run against any revision. + +--- + +## 4 · Self-verifying in CI + +`.github/workflows/mandate-corpus.yml` runs the vectors on every PR that +touches `code/sdk/python/**` or this directory. + +The point is regression detection: if a change to mandate verification silently +breaks signature checking or the `cnf` binding, the same PR goes red instead of +a customer discovering it later. + +The `poc_aud_not_enforced.py` step is `continue-on-error: true` while the §3 +defect is open. Remove that flag once it is fixed — the reproducer will turn +green on its own. + +## 5 · An AP2 subtlety that is easy to miss + +The root SD-JWT **must** carry a `cnf` naming the agent key **before** the +human signs the authorisation. Otherwise the human authorises a spending +authority nobody can ever use. The same class of defect is described +independently in `openmobilehub/credentagent` PR #189: + +> "the human signed over a key that was then discarded and replaced at +> authorization: **they authorized a spending authority nobody would ever use**" + +The generator handles this explicitly with `sd=DisclosureMetadata(sd_keys=[])`. +Without it, `DisclosureMetadata.from_model` hides the entire payload under +`_sd`, `cnf` becomes unreadable, and the mandate is signed but unenforceable. + +--- + +## 6 · Usage + +Run from the repository root. The SDK is this same repository, so no +separate checkout is needed: + +```bash +python3 -m venv .venv +.venv/bin/pip install -e ./code/sdk/python # cryptography, jwcrypto, pydantic, sd-jwt + +.venv/bin/python contrib/mandate-corpus/tools/generate_fixtures.py # → vectors/ +.venv/bin/python contrib/mandate-corpus/tools/verify_fixtures.py # 3/3 OK +.venv/bin/python contrib/mandate-corpus/tools/negative_vectors.py # 2 rejections, 1 xfail (§3) +.venv/bin/python contrib/mandate-corpus/tools/poc_aud_not_enforced.py # reproducer +```text + +The scripts resolve `vectors/` relative to their own location, so they can +also be copied out of the repository standalone. + +Expected `verify_fixtures.py` output: + +```text + OK open-payment-mandate-card-basic.json — chain valid, cnf=agent key, amount matches reference + OK open-payment-mandate-debit-large.json — chain valid, cnf=agent key, amount matches reference + OK open-payment-mandate-nonusd.json — chain valid, cnf=agent key, amount matches reference +```text + +--- + +## 7 · Scope and limits + +- Vectors are validated **only** by the reference Python verifier from the same + SDK. Cross-validation against an independent implementation in another + language is the point of the proposal, not work already completed. +- Fixtures embed **test** keys. They are a conformance reference, not a + security mechanism. +- The §3 defect is accurate for the AP2 HEAD of 2026-10-04. Re-check before + merging; the reproducer runs against any revision. +- The negative vectors currently prove two things: signature tampering is + caught, and a good chain is accepted. The `aud` case is `xfail` by design so + that fixing §3 turns it green rather than silently disappearing. + +--- + +## Licence + +Apache-2.0, matching the parent repository. Independent work, no affiliation +with Google LLC. diff --git a/contrib/mandate-corpus/tools/generate_fixtures.py b/contrib/mandate-corpus/tools/generate_fixtures.py new file mode 100644 index 00000000..b24a7a97 --- /dev/null +++ b/contrib/mandate-corpus/tools/generate_fixtures.py @@ -0,0 +1,186 @@ +"""Generator for signed AP2 mandate fixtures. + +The gap this project closes +--------------------------- +google-agentic-commerce/AP2 contains no fixture, corpus, vector or +``fixture`` / ``corpus`` / ``vector`` / ``conformance``. the Python SDK can sign (`sd_jwt.create`, `kb_sd_jwt.create`) +but nobody publishes signed fixtures. + +conformance directory. As a result the positive test in +Universal-Commerce-Protocol/conformance is skipped, as stated verbatim in PR #115: +«A real positive needs a signed SD-JWT+kb mandate fixture and a signing +model, which this PR does not attempt». + +Why this is needed: the fixtures are not for this SDK but for independent +implementations in other languages (Go, TypeScript, PHP all exist already). +They need a byte-level reference they can be checked against. + +An AP2 subtlety that must not be missed +--------------------------------------- +The root SD-JWT must carry a `cnf` naming the agent key **before** the human +signs the authorisation, otherwise the human authorises a spending authority +nobody can use. That is exactly the defect described in +`openmobilehub/credentagent` PR #189: «the human signed over a key that was +then discarded and replaced». + +Usage +----- + .venv/bin/python contrib/mandate-corpus/tools/generate_fixtures.py +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from ap2.sdk.disclosure_metadata import DisclosureMetadata +from ap2.sdk.generated.open_payment_mandate import OpenPaymentMandate +from ap2.sdk.sdjwt import kb_sd_jwt, sd_jwt +from ap2.sdk.sdjwt.common import parse_token +from cryptography.hazmat.primitives.asymmetric import ec +from jwcrypto.jwk import JWK + +def _vectors_dir() -> Path: + """Locates the vectors directory next to the script or one level above. + + Needed so the corpus works both as tools/vectors and as tools and vectors as + siblings, which is what lets it be lifted out of the repository. + """ + for cand in (Path(__file__).parent / "vectors", Path(__file__).parent.parent / "vectors"): + if cand.is_dir(): + return cand + return Path(__file__).parent / "vectors" + + +def _out_dir() -> Path: + return _vectors_dir() + + +def make_key(kid: str) -> JWK: + jwk = JWK.from_pyca(ec.generate_private_key(ec.SECP256R1())) + raw = json.loads(jwk.export()) + raw["kid"] = kid + return JWK.from_json(json.dumps(raw)) + + +def cnf_of(key: JWK) -> dict: + """cnf binding to the agent key — this is what makes the authority executable.""" + return {"jwk": json.loads(key.export_public())} + + +def emit(seed: dict) -> dict: + issuer_key = make_key(seed["issuer_kid"]) + agent_key = make_key(seed["agent_kid"]) + + root_mandate = OpenPaymentMandate( + constraints=[], + cnf=cnf_of(agent_key), + payment_amount={"amount": seed["amount"], "currency": seed["currency"]}, + payee={"id": seed["payee"], "name": seed["payee_name"]}, + payment_instrument={"id": seed["instrument"], "type": seed["instrument_type"]}, + execution_date=seed["expiry"], + ) + + # ``sd_keys=[]`` is essential: cnf must always be visible, otherwise the + # mandate is signed but unenforceable (defect from credentagent PR #189). + # DisclosureMetadata.from_model auto-derivation hides everything under _sd. + public = DisclosureMetadata(sd_keys=[]) + + # Hop 0 — root SD-JWT: issued by the wallet, carries the agent cnf. + root = sd_jwt.create( + payload=root_mandate, issuer_key=issuer_key, sd=public + ).sd_jwt_issuance + + # Hop 1 — KB-SD-JWT: signed by the key the agent actually spends with. + hop = kb_sd_jwt.create( + prev_token=parse_token(root), + holder_key=agent_key, + payload=root_mandate, + aud=seed["aud"], + nonce=seed["nonce"], + sd=public, + ).sd_jwt_issuance + + return { + "id": seed["id"], + "description": seed["description"], + "expected": seed["expected"], + "issuance": { + "issuer_jwk": json.loads(issuer_key.export_public()), + "agent_jwk": json.loads(agent_key.export_public()), + "aud": seed["aud"], + "nonce": seed["nonce"], + }, + "root_sd_jwt": root, + "kb_sd_jwt": hop, + "plaintext_payload": json.loads(root_mandate.model_dump_json()), + } + + +SEEDS = [ + { + "id": "open-payment-mandate-card-basic", + "description": "Baseline open payment mandate: 100 USD by card, payee Shop.", + "expected": "Root and KB-hop signatures are valid; root cnf equals agent_jwk.", + "amount": 100, + "currency": "USD", + "payee": "shop-basic", + "payee_name": "Shop", + "instrument": "pi-basic-1", + "instrument_type": "credit", + "expiry": "2027-01-01T00:00:00Z", + "aud": "https://merchant.example/ap2", + "nonce": "fixture-nonce-0001", + "issuer_kid": "fixture-issuer-1", + "agent_kid": "fixture-agent-1", + }, + { + "id": "open-payment-mandate-debit-large", + "description": "Large debit-card payment — exercises amount boundaries.", + "expected": "Amount and currency decode without loss; currency is never silently changed.", + "amount": 50000, + "currency": "USD", + "payee": "shop-large", + "payee_name": "Shop Large", + "instrument": "pi-large-1", + "instrument_type": "debit", + "expiry": "2027-06-30T00:00:00Z", + "aud": "https://merchant.example/ap2", + "nonce": "fixture-nonce-0002", + "issuer_kid": "fixture-issuer-2", + "agent_kid": "fixture-agent-2", + }, + { + "id": "open-payment-mandate-nonusd", + "description": "Non-USD currency — catches implementations that silently normalise to dollars.", + "expected": "Currency in the signature and in the payload match character for character.", + "amount": 25000, + "currency": "BRL", + "payee": "shop-brasil", + "payee_name": "Loja Brasil", + "instrument": "pi-brl-1", + "instrument_type": "credit", + "expiry": "2027-03-31T00:00:00Z", + "aud": "https://merchant.example/br/ap2", + "nonce": "fixture-nonce-0003", + "issuer_kid": "fixture-issuer-3", + "agent_kid": "fixture-agent-3", + }, +] + + +def main() -> None: + _out_dir().mkdir(exist_ok=True) + index = [] + for seed in SEEDS: + built = emit(seed) + path = _out_dir() / f"{seed['id']}.json" + path.write_text(json.dumps(built, indent=2, ensure_ascii=False), encoding="utf-8") + index.append({"id": built["id"], "file": path.name, "expected": built["expected"]}) + print(f" created {path.name} ({path.stat().st_size} bytes)") + (_out_dir() / "index.json").write_text(json.dumps(index, indent=2, ensure_ascii=False), encoding="utf-8") + print(f" created index.json ({len(index)} vectors)") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/contrib/mandate-corpus/tools/negative_vectors.py b/contrib/mandate-corpus/tools/negative_vectors.py new file mode 100644 index 00000000..1923ab3d --- /dev/null +++ b/contrib/mandate-corpus/tools/negative_vectors.py @@ -0,0 +1,208 @@ +"""Negative AP2 vectors: proof that the verifier catches forgery. + +A corpus that can only confirm correct input is useless for conformance. +The real value is showing that an implementation **rejects** incorrect input. +Without it an independent Go or TypeScript implementation cannot demonstrate +it is safe rather than merely compatible. + +Each vector: a mutation of a valid token plus what must be rejected. +""" + +from __future__ import annotations + +import base64 +import json +from copy import deepcopy +from pathlib import Path + +from ap2.sdk.disclosure_metadata import DisclosureMetadata +from ap2.sdk.sdjwt import kb_sd_jwt, sd_jwt +from ap2.sdk.sdjwt.chain import verify_chain +from ap2.sdk.sdjwt.common import parse_token +from cryptography.hazmat.primitives.asymmetric import ec +from jwcrypto.jwk import JWK + +def _vectors_dir() -> Path: + """Locates the vectors directory next to the script or one level above. + + Needed so the corpus works both as tools/vectors and as tools and vectors as + siblings, which is what lets it be lifted out of the repository. + """ + for cand in (Path(__file__).parent / "vectors", Path(__file__).parent.parent / "vectors"): + if cand.is_dir(): + return cand + return Path(__file__).parent / "vectors" + + +def _out_dir() -> Path: + return _vectors_dir() + + +def make_key(kid: str) -> JWK: + jwk = JWK.from_pyca(ec.generate_private_key(ec.SECP256R1())) + raw = json.loads(jwk.export()) + raw["kid"] = kid + return JWK.from_json(json.dumps(raw)) + + +def build_good() -> tuple[dict, dict]: + """A reference token pair plus their public keys.""" + issuer_key = make_key("neg-issuer-1") + agent_key = make_key("neg-agent-1") + from ap2.sdk.generated.open_payment_mandate import OpenPaymentMandate + + mandate = OpenPaymentMandate( + constraints=[], + cnf={"jwk": json.loads(agent_key.export_public())}, + payment_amount={"amount": 100, "currency": "USD"}, + payee={"id": "shop-1", "name": "Shop"}, + payment_instrument={"id": "pi-1", "type": "credit"}, + execution_date="2099-01-01T00:00:00Z", + ) + sd = DisclosureMetadata(sd_keys=[]) + root = sd_jwt.create(payload=mandate, issuer_key=issuer_key, sd=sd).sd_jwt_issuance + hop = kb_sd_jwt.create( + prev_token=parse_token(root), + holder_key=agent_key, + payload=mandate, + aud="https://merchant.example/ap2", + nonce="neg-nonce-1", + sd=sd, + ).sd_jwt_issuance + return ( + { + "root_sd_jwt": root, + "kb_sd_jwt": hop, + "issuance": { + "issuer_jwk": json.loads(issuer_key.export_public()), + "agent_jwk": json.loads(agent_key.export_public()), + "aud": "https://merchant.example/ap2", + "nonce": "neg-nonce-1", + }, + }, + {"root": root, "hop": hop}, + ) + + +def flip_payload_segment(token: str, mutate) -> str: + """Mutates the payload inside a JWT while leaving the signature untouched. + + This breaks the signature, which is exactly what a verifier must catch. + """ + head, sep, rest = token.partition("~") + h, dot, rest2 = head.partition(".") + body, _, sig = rest2.rpartition(".") + + def dec(seg: str) -> dict: + return json.loads(base64.urlsafe_b64decode(seg + "=" * (-len(seg) % 4))) + + def enc(obj: dict) -> str: + raw = json.dumps(obj, separators=(",", ":")).encode() + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + payload = dec(body) + mutate(payload) + return f"{h}{dot}{enc(payload)}{dot}{sig}" + (sep + rest if sep else "") + + +def mutate_token(data: dict, which: str) -> dict: + out = deepcopy(data) + if which == "amount-escalation": + key = "kb_sd_jwt" + out[key] = flip_payload_segment( + out[key], lambda p: p.__setitem__("aud", "https://attacker.example/ap2") + ) + return out + if which == "tampered-sig": + # Flip the last signature character — the crudest possible tamper. + key = "kb_sd_jwt" + tok = out[key] + out[key] = tok[:-1] + ("A" if tok[-1] != "A" else "B") + return out + raise ValueError(which) + + +class IssuerKeyProvider: + """Issuer key for the root — self-contained, no external trust.""" + + def __init__(self, issuer_public_jwk: dict) -> None: + self._key = JWK.from_json(json.dumps(issuer_public_jwk)) + + def __call__(self, token) -> JWK: # noqa: ARG002 + return self._key + + +def must_reject(data: dict, expect_wrong_aud: bool = False) -> str: + """Returns the rejection reason, or '' if the verifier was FOOLED into accepting.""" + try: + verify_chain( + [parse_token(data["root_sd_jwt"]), parse_token(data["kb_sd_jwt"])], + IssuerKeyProvider(data["issuance"]["issuer_jwk"]), + expected_aud=data["issuance"]["aud"], + expected_nonce=data["issuance"]["nonce"], + ) + except Exception as exc: # noqa: BLE001 — rejection is the expected outcome + return f"{type(exc).__name__}: {str(exc)[:70]}" + return "" + + +def main() -> int: + data, _ = build_good() + + cases = [ + ( + "aud-substitution", + "mutate_token(data, 'amount-escalation')", + "aud substitution: payment redirected to a different recipient", + ), + ( + "tampered-signature", + "mutate_token(data, 'tampered-sig')", + "one signature byte of the KB-hop changed", + ), + ( + # Known SDK defect, reproduced by a dedicated PoC: + # tools/poc_aud_not_enforced.py. Recorded as xfail so the + # regression stays visible instead of going quiet. + "wrong-expected-aud", + "data", + "verification with a wrong expected aud — XFAIL: ap2 SDK defect, see poc_aud_not_enforced.py", + ), + ] + + print(" negative vectors:") + passed = xfail = 0 + for name, expr, desc in cases: + payload = eval(expr) # noqa: S307 — local fixed set + if name == "wrong-expected-aud": + payload = deepcopy(payload) + payload["issuance"]["aud"] = "https://someone-else.example/ap2" + reason = must_reject(payload) + if name == "wrong-expected-aud": + # Not a test failure: the defect is in the SDK. Counted separately + # so that fixing it turns into a green result. + if not reason: + xfail += 1 + print(f" XFAIL {name:22} — SDK defect reproduced (see PoC)") + else: + passed += 1 + print(f" OK {name:22} — rejected: {reason}") + continue + ok = bool(reason) + passed += 1 if ok else 0 + print(f" {'OK ' if ok else 'FAIL'} {name:22} — {desc}") + if ok: + print(f" rejected: {reason}") + + # Control: a valid pair must pass, otherwise the rejections above are fake. + good_reason = must_reject(data) + if good_reason: + print(f"\n STOP: reference pair failed — {good_reason}") + return 1 + print("\n control: reference pair accepted (so the rejections above are genuine)") + print(f" total: rejected {passed}/{len(cases)}, xfail (SDK defect): {xfail}") + return 1 if passed != (len(cases) - xfail) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/contrib/mandate-corpus/tools/poc_aud_not_enforced.py b/contrib/mandate-corpus/tools/poc_aud_not_enforced.py new file mode 100644 index 00000000..99c61fa3 --- /dev/null +++ b/contrib/mandate-corpus/tools/poc_aud_not_enforced.py @@ -0,0 +1,114 @@ +"""PoC: expected_aud is silently not enforced for kb+sd-jwt+kb. + +The defect +-------- +``ap2.sdk.sdjwt.chain.verify_chain`` documents: + + «index i (KB-SD-JWT): verified with the previous hop's ``cnf.jwk`` via + kb_sd_jwt.verify, enforcing ``expected_aud`` / ``expected_nonce`` on the + terminal hop when provided» + +But ``kb_sd_jwt.verify`` calls ``common.verify_expected_claims`` only when +``typ in TYP_TERMINAL``: + + TYP_TERMINAL = ['kb+sd-jwt', 'kb-sd-jwt'] + TYP_INTERMEDIATE = ['kb+sd-jwt+kb', 'kb-sd-jwt+kb'] + +A token of type ``kb+sd-jwt+kb`` — which the SDK itself produces when the +payload carries a ``cnf`` claim, see kb_sd_jwt_tests — falls into INTERMEDIATE, +so the aud/nonce check **never runs at all**, even though the caller explicitly +passed ``expected_aud``. + +Consequence: a merchant relying on the audience check gets silent protection +that does not exist. Substituting aud produces no refusal. + +Usage +----- + .venv/bin/python tools/poc_aud_not_enforced.py + +Expected output: ``VERDICT: DEFECT CONFIRMED`` — a token was accepted that +must have been rejected. +""" + +from __future__ import annotations + +import json + +from ap2.sdk.disclosure_metadata import DisclosureMetadata +from ap2.sdk.generated.open_payment_mandate import OpenPaymentMandate +from ap2.sdk.sdjwt import kb_sd_jwt, sd_jwt +from ap2.sdk.sdjwt.chain import verify_chain +from ap2.sdk.sdjwt.common import parse_token +from cryptography.hazmat.primitives.asymmetric import ec +from jwcrypto.jwk import JWK + +EXPECTED_AUD = "https://honest-merchant.example/ap2" +ATTACKER_AUD = "https://attacker.example/ap2" + + +def key(kid: str) -> JWK: + jwk = JWK.from_pyca(ec.generate_private_key(ec.SECP256R1())) + raw = json.loads(jwk.export()) + raw["kid"] = kid + return JWK.from_json(json.dumps(raw)) + + +class IssuerProvider: + def __init__(self, jwk_dict: dict) -> None: + self._k = JWK.from_json(json.dumps(jwk_dict)) + + def __call__(self, token) -> JWK: # noqa: ARG002 + return self._k + + +def main() -> int: + issuer = key("poc-issuer") + agent = key("poc-agent") + + mandate = OpenPaymentMandate( + constraints=[], + cnf={"jwk": json.loads(agent.export_public())}, + payment_amount={"amount": 100, "currency": "USD"}, + payee={"id": "shop", "name": "Shop"}, + payment_instrument={"id": "pi", "type": "credit"}, + execution_date="2099-01-01T00:00:00Z", + ) + sd = DisclosureMetadata(sd_keys=[]) + + root = sd_jwt.create(payload=mandate, issuer_key=issuer, sd=sd).sd_jwt_issuance + # The agent signs the hop for the WRONG recipient — that is the attack. + hop = kb_sd_jwt.create( + prev_token=parse_token(root), + holder_key=agent, + payload=mandate, + aud=ATTACKER_AUD, + nonce="poc-nonce", + sd=sd, + ).sd_jwt_issuance + + print(" hop type : kb+sd-jwt+kb (per TYP_INTERMEDIATE)") + print(f" aud in token : {ATTACKER_AUD}") + print(f" expected_aud : {EXPECTED_AUD}") + print(" audiences do NOT match — a correct verifier must reject\n") + + try: + verify_chain( + [parse_token(root), parse_token(hop)], + IssuerProvider(json.loads(issuer.export_public())), + expected_aud=EXPECTED_AUD, + expected_nonce="poc-nonce", + ) + except Exception as exc: # noqa: BLE001 + print(f" REJECTED: {type(exc).__name__}: {str(exc)[:90]}") + print("\n VERDICT: defect NOT reproduced — either fixed or the typ differs") + return 0 + + print(" ACCEPTED a token with the wrong audience.") + print("\n VERDICT: DEFECT CONFIRMED") + print(" expected_aud was passed to verify_chain, but verify_expected_claims was never called,") + print(" because typ='kb+sd-jwt+kb' lands in TYP_INTERMEDIATE rather than TYP_TERMINAL.") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/contrib/mandate-corpus/tools/verify_fixtures.py b/contrib/mandate-corpus/tools/verify_fixtures.py new file mode 100644 index 00000000..a76731a1 --- /dev/null +++ b/contrib/mandate-corpus/tools/verify_fixtures.py @@ -0,0 +1,123 @@ +"""Verification of the generated AP2 fixtures. + +A fixture that cannot be verified is not a fixture, it is noise. This script +is the reference: it proves the signatures in `vectors/*.json` are valid, and any +other implementation (Go, TypeScript, PHP) can port the same checks and reach +the same result. + +Uses `ap2.sdk.sdjwt.chain.verify_chain`, the supported SDK path that walks the +chain itself: the root is verified with the issuer key, each following hop with +the key from the previous hop's `cnf`. + +Usage +----- + .venv/bin/python contrib/mandate-corpus/tools/verify_fixtures.py +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from ap2.sdk.sdjwt.chain import verify_chain +from ap2.sdk.sdjwt.common import ParsedToken, parse_token +from jwcrypto.jwk import JWK + +def _vectors_dir() -> Path: + """Locates the vectors directory next to the script or one level above. + + Needed so the corpus works both as tools/vectors and as tools and vectors as + siblings, which is what lets it be lifted out of the repository. + """ + for cand in (Path(__file__).parent / "vectors", Path(__file__).parent.parent / "vectors"): + if cand.is_dir(): + return cand + return Path(__file__).parent / "vectors" + + +def _out_dir() -> Path: + return _vectors_dir() + + +class IssuerKeyProvider: + """Supplies the issuer key for the root token. + + In production this is what `X5cOrKidPublicKeyProvider` does via the certificate in + `x5c`. Here the key ships with the fixture so the corpus is self-contained + and verifiable without external infrastructure trust. + """ + + def __init__(self, issuer_public_jwk: dict) -> None: + self._key = JWK.from_json(json.dumps(issuer_public_jwk)) + + def __call__(self, token: ParsedToken) -> JWK: # noqa: ARG002 + return self._key + + +def check(path: Path) -> tuple[bool, str]: + data = json.loads(path.read_text(encoding="utf-8")) + issuance = data["issuance"] + + root = parse_token(data["root_sd_jwt"]) + hop = parse_token(data["kb_sd_jwt"]) + + payloads = verify_chain( + [root, hop], + IssuerKeyProvider(issuance["issuer_jwk"]), + expected_aud=issuance["aud"], + expected_nonce=issuance["nonce"], + ) + + if len(payloads) != 2: + return False, f"expected 2 payloads, got {len(payloads)}" + + # The root cnf must equal agent_jwk, otherwise the human signed an + # authorisation that a different key can spend. + root_payload = payloads[0] + cnf_jwk = root_payload.get("cnf", {}).get("jwk") + if cnf_jwk is None: + return False, "root has no cnf.jwk — the authorisation is unenforceable" + # cnf arrives as a JsonWebKey model or dict — normalise it to a canonical JWK. + if isinstance(cnf_jwk, dict): + cnf_jwk = JWK.from_json(json.dumps(cnf_jwk)) + if json.loads(cnf_jwk.export_public()) != json.loads( + json.dumps(issuance["agent_jwk"]) + ): + return False, "root cnf does not match agent_jwk" + + # The decoded mandate must match the reference payload. + if root_payload.get("payment_amount") != data["plaintext_payload"].get("payment_amount"): + return False, "payment_amount does not match the reference" + + return True, "chain valid, cnf=agent key, amount matches reference" + + +def main() -> int: + # Read index.json rather than the directory: the manifest defines the + # corpus, and stale files must not silently enter verification. + index_path = _vectors_dir() / "index.json" + if not index_path.exists(): + print(" no index.json — run generate_fixtures.py first") + return 1 + files = [_vectors_dir() / e["file"] for e in json.loads(index_path.read_text(encoding="utf-8"))] + files = [f for f in files if f.exists()] + if not files: + print(" no vectors — run generate_fixtures.py first") + return 1 + + failed = 0 + for f in files: + try: + ok, note = check(f) + except Exception as exc: # noqa: BLE001 — a failed vector matters, report why + ok, note = False, f"{type(exc).__name__}: {exc}" + print(f" {'OK ' if ok else 'FAIL'} {f.name} — {note}") + failed += 0 if ok else 1 + + print(f"\n total: {len(files) - failed}/{len(files)} passed verification") + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/contrib/mandate-corpus/vectors/index.json b/contrib/mandate-corpus/vectors/index.json new file mode 100644 index 00000000..243fe7a9 --- /dev/null +++ b/contrib/mandate-corpus/vectors/index.json @@ -0,0 +1,17 @@ +[ + { + "id": "open-payment-mandate-card-basic", + "file": "open-payment-mandate-card-basic.json", + "expected": "Подпись корня и KB-хопа валидны; cnf корня равен agent_jwk." + }, + { + "id": "open-payment-mandate-debit-large", + "file": "open-payment-mandate-debit-large.json", + "expected": "Сумма и валюта декодируются без потерь; валюта не меняется молча." + }, + { + "id": "open-payment-mandate-nonusd", + "file": "open-payment-mandate-nonusd.json", + "expected": "Валюта в подписи и в payload совпадают посимвольно." + } +] \ No newline at end of file diff --git a/contrib/mandate-corpus/vectors/open-payment-mandate-card-basic.json b/contrib/mandate-corpus/vectors/open-payment-mandate-card-basic.json new file mode 100644 index 00000000..15677375 --- /dev/null +++ b/contrib/mandate-corpus/vectors/open-payment-mandate-card-basic.json @@ -0,0 +1,57 @@ +{ + "id": "open-payment-mandate-card-basic", + "description": "Базовый открытый платёжный мандат: 100 USD картой, payee — Shop.", + "expected": "Подпись корня и KB-хопа валидны; cnf корня равен agent_jwk.", + "issuance": { + "issuer_jwk": { + "crv": "P-256", + "kid": "fixture-issuer-1", + "kty": "EC", + "x": "1_Aw9SxcjyVhMj4eXyA6pMQHpe4HebaEObsiLwBSp6w", + "y": "PGKBUMy1zDMqzsR59fbN8vEPot5q9g9VUX2Fdspl09U" + }, + "agent_jwk": { + "crv": "P-256", + "kid": "fixture-agent-1", + "kty": "EC", + "x": "hzpO_on0IKGLFmm0WHfe-vwAuxCsTzV40OLW3lDITJU", + "y": "fWiBK7Bf8Dzuz5Pj2Vq5uQ59Xnj2bijOCHWqx49ExDc" + }, + "aud": "https://merchant.example/ap2", + "nonce": "fixture-nonce-0001" + }, + "root_sd_jwt": "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0IiwgImtpZCI6ICJmaXh0dXJlLWlzc3Vlci0xIn0.eyJkZWxlZ2F0ZV9wYXlsb2FkIjogW3siLi4uIjogImdfeWwzWTYyQ3RYQ2xEeHVPZTRfWGlRa2lZMVVIX0xTVGFUREFIVFJQZWMifV0sICJfc2RfYWxnIjogInNoYS0yNTYifQ.Sk5WaVS_AQxGlpq_EQYhEKzm2hCufHibJOzO_33J08nTXJpfBZDWAwNkREIYIGhUSYuMDyijY9kXIrHm4yY62g~WyJtVXhna0FkUk5VYXhXQk9MdG00TGp3IiwgeyJ2Y3QiOiAibWFuZGF0ZS5wYXltZW50Lm9wZW4uMSIsICJjb25zdHJhaW50cyI6IFtdLCAiY25mIjogeyJqd2siOiB7ImNydiI6ICJQLTI1NiIsICJraWQiOiAiZml4dHVyZS1hZ2VudC0xIiwgImt0eSI6ICJFQyIsICJ4IjogImh6cE9fb24wSUtHTEZtbTBXSGZlLXZ3QXV4Q3NUelY0ME9MVzNsRElUSlUiLCAieSI6ICJmV2lCSzdCZjhEenV6NVBqMlZxNXVRNTlYbmoyYmlqT0NIV3F4NDlFeERjIn19LCAicGF5ZWUiOiB7ImlkIjogInNob3AtYmFzaWMiLCAibmFtZSI6ICJTaG9wIn0sICJwYXltZW50X2Ftb3VudCI6IHsiYW1vdW50IjogMTAwLCAiY3VycmVuY3kiOiAiVVNEIn0sICJwYXltZW50X2luc3RydW1lbnQiOiB7ImlkIjogInBpLWJhc2ljLTEiLCAidHlwZSI6ICJjcmVkaXQifSwgImV4ZWN1dGlvbl9kYXRlIjogIjIwMjctMDEtMDFUMDA6MDA6MDBaIn1d~", + "kb_sd_jwt": "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImtiK3NkLWp3dCtrYiIsICJraWQiOiAiZml4dHVyZS1hZ2VudC0xIn0.eyJkZWxlZ2F0ZV9wYXlsb2FkIjogW3siLi4uIjogIm1rbFc1blFpOC02ZHA1QWZPTEc2aEJiREZDcV9ieFozU2NfX1FUaDc5VE0ifV0sICJpYXQiOiAxNzkxMDY5NTA2LCAiYXVkIjogImh0dHBzOi8vbWVyY2hhbnQuZXhhbXBsZS9hcDIiLCAibm9uY2UiOiAiZml4dHVyZS1ub25jZS0wMDAxIiwgInNkX2hhc2giOiAiMjkxRnlLV2xOLVVlMmVJWmlnTVBLWUR5T1lJUkhfUVpjVGhZQ1luV1N0MCIsICJfc2RfYWxnIjogInNoYS0yNTYifQ.s3iFOpbymnYW_DjQe6jGikyHVy64Jknh5l2_f2kxSsbZaYOgzxVxvDiy-qXR_iyavVQZMosX_RXeQT93PI6UHA~WyJKRGZfQnFxcVQtWFZKOHB4MzhhOENRIiwgeyJ2Y3QiOiAibWFuZGF0ZS5wYXltZW50Lm9wZW4uMSIsICJjb25zdHJhaW50cyI6IFtdLCAiY25mIjogeyJqd2siOiB7ImNydiI6ICJQLTI1NiIsICJraWQiOiAiZml4dHVyZS1hZ2VudC0xIiwgImt0eSI6ICJFQyIsICJ4IjogImh6cE9fb24wSUtHTEZtbTBXSGZlLXZ3QXV4Q3NUelY0ME9MVzNsRElUSlUiLCAieSI6ICJmV2lCSzdCZjhEenV6NVBqMlZxNXVRNTlYbmoyYmlqT0NIV3F4NDlFeERjIn19LCAicGF5ZWUiOiB7ImlkIjogInNob3AtYmFzaWMiLCAibmFtZSI6ICJTaG9wIn0sICJwYXltZW50X2Ftb3VudCI6IHsiYW1vdW50IjogMTAwLCAiY3VycmVuY3kiOiAiVVNEIn0sICJwYXltZW50X2luc3RydW1lbnQiOiB7ImlkIjogInBpLWJhc2ljLTEiLCAidHlwZSI6ICJjcmVkaXQifSwgImV4ZWN1dGlvbl9kYXRlIjogIjIwMjctMDEtMDFUMDA6MDA6MDBaIn1d~", + "plaintext_payload": { + "vct": "mandate.payment.open.1", + "constraints": [], + "cnf": { + "jwk": { + "crv": "P-256", + "kid": "fixture-agent-1", + "kty": "EC", + "x": "hzpO_on0IKGLFmm0WHfe-vwAuxCsTzV40OLW3lDITJU", + "y": "fWiBK7Bf8Dzuz5Pj2Vq5uQ59Xnj2bijOCHWqx49ExDc" + } + }, + "payee": { + "id": "shop-basic", + "name": "Shop", + "website": null + }, + "payment_amount": { + "amount": 100, + "currency": "USD" + }, + "payment_instrument": { + "id": "pi-basic-1", + "type": "credit", + "description": null + }, + "pisp": null, + "execution_date": "2027-01-01T00:00:00Z", + "risk_data": null, + "iat": null, + "exp": null + } +} \ No newline at end of file diff --git a/contrib/mandate-corpus/vectors/open-payment-mandate-debit-large.json b/contrib/mandate-corpus/vectors/open-payment-mandate-debit-large.json new file mode 100644 index 00000000..792e2de9 --- /dev/null +++ b/contrib/mandate-corpus/vectors/open-payment-mandate-debit-large.json @@ -0,0 +1,57 @@ +{ + "id": "open-payment-mandate-debit-large", + "description": "Крупный платёж дебетовой картой — проверка границ суммы.", + "expected": "Сумма и валюта декодируются без потерь; валюта не меняется молча.", + "issuance": { + "issuer_jwk": { + "crv": "P-256", + "kid": "fixture-issuer-2", + "kty": "EC", + "x": "-A9OaChhz0z5Ux_aWxSgI-4D_kSo72rJvzoumU5HTwc", + "y": "QHFa8DW7I5z_KA9EGJ5EAGYfMiMXwUiOERhYQFh1zzI" + }, + "agent_jwk": { + "crv": "P-256", + "kid": "fixture-agent-2", + "kty": "EC", + "x": "pvzV-jmqqAsWcRk_A8i90CXH5r7uG6M6YJPqmyqIJ8s", + "y": "F2p7w2i1hCpIdqxMP7nCwf6Z1ADSz8H2NGfXY3gRUc8" + }, + "aud": "https://merchant.example/ap2", + "nonce": "fixture-nonce-0002" + }, + "root_sd_jwt": "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0IiwgImtpZCI6ICJmaXh0dXJlLWlzc3Vlci0yIn0.eyJkZWxlZ2F0ZV9wYXlsb2FkIjogW3siLi4uIjogIjE2UFNxVTZnYUhJaWctb3B1eTFNb1dtZlBzTUIwVHptTkRPRS04OFRtN00ifV0sICJfc2RfYWxnIjogInNoYS0yNTYifQ.5I9LUxt6ea8SGSAm0jaAOexwG7KHU7DXdoBXOW_NLhA3Oq-JIsI_bhQdvG74x-gBdlOBfhWt83aMTWTIHKNfsw~WyJVNnVvSDN6OWpya2VaajFxTjJ3Vmt3IiwgeyJ2Y3QiOiAibWFuZGF0ZS5wYXltZW50Lm9wZW4uMSIsICJjb25zdHJhaW50cyI6IFtdLCAiY25mIjogeyJqd2siOiB7ImNydiI6ICJQLTI1NiIsICJraWQiOiAiZml4dHVyZS1hZ2VudC0yIiwgImt0eSI6ICJFQyIsICJ4IjogInB2elYtam1xcUFzV2NSa19BOGk5MENYSDVyN3VHNk02WUpQcW15cUlKOHMiLCAieSI6ICJGMnA3dzJpMWhDcElkcXhNUDduQ3dmNloxQURTejhIMk5HZlhZM2dSVWM4In19LCAicGF5ZWUiOiB7ImlkIjogInNob3AtbGFyZ2UiLCAibmFtZSI6ICJTaG9wIExhcmdlIn0sICJwYXltZW50X2Ftb3VudCI6IHsiYW1vdW50IjogNTAwMDAsICJjdXJyZW5jeSI6ICJVU0QifSwgInBheW1lbnRfaW5zdHJ1bWVudCI6IHsiaWQiOiAicGktbGFyZ2UtMSIsICJ0eXBlIjogImRlYml0In0sICJleGVjdXRpb25fZGF0ZSI6ICIyMDI3LTA2LTMwVDAwOjAwOjAwWiJ9XQ~", + "kb_sd_jwt": "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImtiK3NkLWp3dCtrYiIsICJraWQiOiAiZml4dHVyZS1hZ2VudC0yIn0.eyJkZWxlZ2F0ZV9wYXlsb2FkIjogW3siLi4uIjogImNlYUE2VEpOS3FyN1hUcDRlYzdHMFFsdjVxQ0stSE1aMWJsWndVanBEY0kifV0sICJpYXQiOiAxNzkxMDY5NTA2LCAiYXVkIjogImh0dHBzOi8vbWVyY2hhbnQuZXhhbXBsZS9hcDIiLCAibm9uY2UiOiAiZml4dHVyZS1ub25jZS0wMDAyIiwgInNkX2hhc2giOiAicTFKSzJfUTBBMFpiWk5jWXg3RUZjZWdxajZtdE1IZ1B2WWc3ZHBrVndkWSIsICJfc2RfYWxnIjogInNoYS0yNTYifQ.-Rt3KqskrEEfGz3yzIQtF4Twgp6M3zVPfSSTL8ZkzbRz24n7yajRrWRjDcYh7ilA9rviRIclyloqe4LSPP4qgA~WyJVZWNIR2xjQTlfbHJ5NzE3cXozOXBnIiwgeyJ2Y3QiOiAibWFuZGF0ZS5wYXltZW50Lm9wZW4uMSIsICJjb25zdHJhaW50cyI6IFtdLCAiY25mIjogeyJqd2siOiB7ImNydiI6ICJQLTI1NiIsICJraWQiOiAiZml4dHVyZS1hZ2VudC0yIiwgImt0eSI6ICJFQyIsICJ4IjogInB2elYtam1xcUFzV2NSa19BOGk5MENYSDVyN3VHNk02WUpQcW15cUlKOHMiLCAieSI6ICJGMnA3dzJpMWhDcElkcXhNUDduQ3dmNloxQURTejhIMk5HZlhZM2dSVWM4In19LCAicGF5ZWUiOiB7ImlkIjogInNob3AtbGFyZ2UiLCAibmFtZSI6ICJTaG9wIExhcmdlIn0sICJwYXltZW50X2Ftb3VudCI6IHsiYW1vdW50IjogNTAwMDAsICJjdXJyZW5jeSI6ICJVU0QifSwgInBheW1lbnRfaW5zdHJ1bWVudCI6IHsiaWQiOiAicGktbGFyZ2UtMSIsICJ0eXBlIjogImRlYml0In0sICJleGVjdXRpb25fZGF0ZSI6ICIyMDI3LTA2LTMwVDAwOjAwOjAwWiJ9XQ~", + "plaintext_payload": { + "vct": "mandate.payment.open.1", + "constraints": [], + "cnf": { + "jwk": { + "crv": "P-256", + "kid": "fixture-agent-2", + "kty": "EC", + "x": "pvzV-jmqqAsWcRk_A8i90CXH5r7uG6M6YJPqmyqIJ8s", + "y": "F2p7w2i1hCpIdqxMP7nCwf6Z1ADSz8H2NGfXY3gRUc8" + } + }, + "payee": { + "id": "shop-large", + "name": "Shop Large", + "website": null + }, + "payment_amount": { + "amount": 50000, + "currency": "USD" + }, + "payment_instrument": { + "id": "pi-large-1", + "type": "debit", + "description": null + }, + "pisp": null, + "execution_date": "2027-06-30T00:00:00Z", + "risk_data": null, + "iat": null, + "exp": null + } +} \ No newline at end of file diff --git a/contrib/mandate-corpus/vectors/open-payment-mandate-nonusd.json b/contrib/mandate-corpus/vectors/open-payment-mandate-nonusd.json new file mode 100644 index 00000000..e44ba012 --- /dev/null +++ b/contrib/mandate-corpus/vectors/open-payment-mandate-nonusd.json @@ -0,0 +1,57 @@ +{ + "id": "open-payment-mandate-nonusd", + "description": "Не-USD валюта — ловит реализации, молча приводящие всё к доллару.", + "expected": "Валюта в подписи и в payload совпадают посимвольно.", + "issuance": { + "issuer_jwk": { + "crv": "P-256", + "kid": "fixture-issuer-3", + "kty": "EC", + "x": "ICOUcq_AX2MI-tg6iU6kzk6c7oQncraLavcluUT97TE", + "y": "wXWGR0ey0yAvfkl_wPVWkdsb_qvDrTQ2V9uJLfLYSG8" + }, + "agent_jwk": { + "crv": "P-256", + "kid": "fixture-agent-3", + "kty": "EC", + "x": "DaaPW7bU2I3Hp-3MIdzPtSddL1bEKF8OESxeAN5PBDU", + "y": "BqZD2tacemKWpK8Y5q9o0szxb_bnWFJjAGvm6VB2uW8" + }, + "aud": "https://merchant.example/br/ap2", + "nonce": "fixture-nonce-0003" + }, + "root_sd_jwt": "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0IiwgImtpZCI6ICJmaXh0dXJlLWlzc3Vlci0zIn0.eyJkZWxlZ2F0ZV9wYXlsb2FkIjogW3siLi4uIjogInhaRWNnSVI3clNuMDd2SFpKYVp5dmRIcUFvdkNqbGNMVFpLa01GNjdTb3MifV0sICJfc2RfYWxnIjogInNoYS0yNTYifQ.JAyzK16LcdkRcQQFx-GC7I7dhZNnIaSFT7OfojIm8P7ZUTKGlreXP6ezwPtbPXo4F1tPuYPQxlYGIVFaW7eauQ~WyJBYUE0V0VvRFN4SlgzeVUyWXRPbGZRIiwgeyJ2Y3QiOiAibWFuZGF0ZS5wYXltZW50Lm9wZW4uMSIsICJjb25zdHJhaW50cyI6IFtdLCAiY25mIjogeyJqd2siOiB7ImNydiI6ICJQLTI1NiIsICJraWQiOiAiZml4dHVyZS1hZ2VudC0zIiwgImt0eSI6ICJFQyIsICJ4IjogIkRhYVBXN2JVMkkzSHAtM01JZHpQdFNkZEwxYkVLRjhPRVN4ZUFONVBCRFUiLCAieSI6ICJCcVpEMnRhY2VtS1dwSzhZNXE5bzBzenhiX2JuV0ZKakFHdm02VkIydVc4In19LCAicGF5ZWUiOiB7ImlkIjogInNob3AtYnJhc2lsIiwgIm5hbWUiOiAiTG9qYSBCcmFzaWwifSwgInBheW1lbnRfYW1vdW50IjogeyJhbW91bnQiOiAyNTAwMCwgImN1cnJlbmN5IjogIkJSTCJ9LCAicGF5bWVudF9pbnN0cnVtZW50IjogeyJpZCI6ICJwaS1icmwtMSIsICJ0eXBlIjogImNyZWRpdCJ9LCAiZXhlY3V0aW9uX2RhdGUiOiAiMjAyNy0wMy0zMVQwMDowMDowMFoifV0~", + "kb_sd_jwt": "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImtiK3NkLWp3dCtrYiIsICJraWQiOiAiZml4dHVyZS1hZ2VudC0zIn0.eyJkZWxlZ2F0ZV9wYXlsb2FkIjogW3siLi4uIjogIm1GbTN2LVpDZzRJNXFiSGJWY1RXS1h3OTdTejNQMGNIb0hMaUo2T2g0QTgifV0sICJpYXQiOiAxNzkxMDY5NTA2LCAiYXVkIjogImh0dHBzOi8vbWVyY2hhbnQuZXhhbXBsZS9ici9hcDIiLCAibm9uY2UiOiAiZml4dHVyZS1ub25jZS0wMDAzIiwgInNkX2hhc2giOiAiZFNoa3ZhSmVJZnhwOHZFU3ZScEJZS0pOYVNfTF9DZlZiWnBXV3BzMF9RVSIsICJfc2RfYWxnIjogInNoYS0yNTYifQ.Z_GqKZ4sTuaAqMm7uxrrUEJj56j5519LOB-DIX7To_0ad58IqJDs2FjCunzDwDZWUL3QTw5iUiipFQ_BJ0IbHQ~WyJFVHZNZ3NZYXBCZXd6WHR2bjR5TG53IiwgeyJ2Y3QiOiAibWFuZGF0ZS5wYXltZW50Lm9wZW4uMSIsICJjb25zdHJhaW50cyI6IFtdLCAiY25mIjogeyJqd2siOiB7ImNydiI6ICJQLTI1NiIsICJraWQiOiAiZml4dHVyZS1hZ2VudC0zIiwgImt0eSI6ICJFQyIsICJ4IjogIkRhYVBXN2JVMkkzSHAtM01JZHpQdFNkZEwxYkVLRjhPRVN4ZUFONVBCRFUiLCAieSI6ICJCcVpEMnRhY2VtS1dwSzhZNXE5bzBzenhiX2JuV0ZKakFHdm02VkIydVc4In19LCAicGF5ZWUiOiB7ImlkIjogInNob3AtYnJhc2lsIiwgIm5hbWUiOiAiTG9qYSBCcmFzaWwifSwgInBheW1lbnRfYW1vdW50IjogeyJhbW91bnQiOiAyNTAwMCwgImN1cnJlbmN5IjogIkJSTCJ9LCAicGF5bWVudF9pbnN0cnVtZW50IjogeyJpZCI6ICJwaS1icmwtMSIsICJ0eXBlIjogImNyZWRpdCJ9LCAiZXhlY3V0aW9uX2RhdGUiOiAiMjAyNy0wMy0zMVQwMDowMDowMFoifV0~", + "plaintext_payload": { + "vct": "mandate.payment.open.1", + "constraints": [], + "cnf": { + "jwk": { + "crv": "P-256", + "kid": "fixture-agent-3", + "kty": "EC", + "x": "DaaPW7bU2I3Hp-3MIdzPtSddL1bEKF8OESxeAN5PBDU", + "y": "BqZD2tacemKWpK8Y5q9o0szxb_bnWFJjAGvm6VB2uW8" + } + }, + "payee": { + "id": "shop-brasil", + "name": "Loja Brasil", + "website": null + }, + "payment_amount": { + "amount": 25000, + "currency": "BRL" + }, + "payment_instrument": { + "id": "pi-brl-1", + "type": "credit", + "description": null + }, + "pisp": null, + "execution_date": "2027-03-31T00:00:00Z", + "risk_data": null, + "iat": null, + "exp": null + } +} \ No newline at end of file From ed1a31448f2494bd7a48bbc30d6dc0e146fbc56e Mon Sep 17 00:00:00 2001 From: iamthat Date: Sun, 4 Oct 2026 05:30:08 +0500 Subject: [PATCH 2/4] ci: install SDK from the repository root, not code/sdk/python pyproject.toml lives at the root; code/sdk/python is declared through [tool.setuptools.packages.find] where = ["code/sdk/python"]. Installing from the subdirectory fails because it contains neither setup.py nor pyproject.toml, which is what the workflow log showed. Verified locally: pip install -e . from the root imports ap2 cleanly. --- .github/workflows/mandate-corpus.yml | 4 +++- contrib/mandate-corpus/README.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mandate-corpus.yml b/.github/workflows/mandate-corpus.yml index 38986678..4b97c40f 100644 --- a/.github/workflows/mandate-corpus.yml +++ b/.github/workflows/mandate-corpus.yml @@ -27,7 +27,9 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.12' - - run: pip install -e ./code/sdk/python + # pyproject.toml lives at the repository root; the package under + # code/sdk/python is declared via [tool.setuptools.packages.find]. + - run: pip install -e . - name: verify fixtures run: python contrib/mandate-corpus/tools/verify_fixtures.py - name: negative vectors diff --git a/contrib/mandate-corpus/README.md b/contrib/mandate-corpus/README.md index 75074d5b..27bc6371 100644 --- a/contrib/mandate-corpus/README.md +++ b/contrib/mandate-corpus/README.md @@ -173,7 +173,7 @@ separate checkout is needed: ```bash python3 -m venv .venv -.venv/bin/pip install -e ./code/sdk/python # cryptography, jwcrypto, pydantic, sd-jwt +.venv/bin/pip install -e . # pyproject.toml is at the repository root .venv/bin/python contrib/mandate-corpus/tools/generate_fixtures.py # → vectors/ .venv/bin/python contrib/mandate-corpus/tools/verify_fixtures.py # 3/3 OK From 85a7a9fa9d3be0d9014a15f96c7c56fa67af9db8 Mon Sep 17 00:00:00 2001 From: iamthat <143086314+mrpkk@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:25:24 +0500 Subject: [PATCH 3/4] =?UTF-8?q?fix(ci):=20pin=20actions=20to=20full=20SHAs?= =?UTF-8?q?=20=E2=80=94=20zizmor=20blanket=20policy=20requires=20hash=20pi?= =?UTF-8?q?nning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/mandate-corpus.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mandate-corpus.yml b/.github/workflows/mandate-corpus.yml index 4b97c40f..f9e12630 100644 --- a/.github/workflows/mandate-corpus.yml +++ b/.github/workflows/mandate-corpus.yml @@ -23,8 +23,8 @@ jobs: name: signed mandate fixtures runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.12' # pyproject.toml lives at the repository root; the package under From 433b09234ebabd5a2ddd658444af2a16aa947472 Mon Sep 17 00:00:00 2001 From: iamthat <143086314+mrpkk@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:28:51 +0500 Subject: [PATCH 4/4] =?UTF-8?q?fix(ci):=20add=20least-privilege=20permissi?= =?UTF-8?q?ons=20block=20=E2=80=94=20zizmor=20blanket=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/mandate-corpus.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/mandate-corpus.yml b/.github/workflows/mandate-corpus.yml index f9e12630..a00d1e4b 100644 --- a/.github/workflows/mandate-corpus.yml +++ b/.github/workflows/mandate-corpus.yml @@ -18,6 +18,9 @@ on: - 'contrib/mandate-corpus/**' workflow_dispatch: +permissions: + contents: read + jobs: vectors: name: signed mandate fixtures