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..a00d1e4b --- /dev/null +++ b/.github/workflows/mandate-corpus.yml @@ -0,0 +1,44 @@ +# Конформити-корпус мандатов 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: + +permissions: + contents: read + +jobs: + vectors: + name: signed mandate fixtures + runs-on: ubuntu-latest + steps: + - 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 + # 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 + 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..27bc6371 --- /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 . # 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 +.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