From 727d0cdb559ddf56eea2f5ddba23bb05c15e2e18 Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Tue, 28 Jul 2026 15:58:04 -0400 Subject: [PATCH] feat(sdk): add versioned delegate-SD-JWT golden vectors + consumer test Add a keyed, versioned golden-vector set for the AP2 delegation chain layer (the SD-JWT ~~ wire), addressing issue #303. Every vector is minted by the real reference SDK (ap2.sdk) rather than hand-forged, so the committed serializations are authoritative. Vectors (delegate_sd_jwt_vectors/delegate_sd_jwt_vectors.json): - root-single-sd-jwt: a lone SD-JWT (ends with ~, zero ~~ joins) - single-hop-payment-chain: one hop via MandateClient.present (one ~~) - multi-hop-payment-chain: Bank -> SA -> CP -> Merchant (two ~~ joins) - single-hop-checkout-chain: the checkout mandate family (one ~~) delegate_sd_jwt_vector_tests.py consumes the frozen set and asserts the on-wire invariants (single ends ~, N-segment chain has N-1 ~~ joins) and that each vector verifies against the SDK with the expected resolved payloads. generate_vectors.py regenerates the set from pinned P-256 keys; salts and ES256 signatures are randomized by design, so the committed JSON is the frozen artifact the test verifies. --- .cspell.json | 3 +- .cspell/custom-words.txt | 1 + .../ap2/tests/delegate_sd_jwt_vector_tests.py | 163 +++++++ .../delegate_sd_jwt_vectors.json | 287 +++++++++++ .../generate_vectors.py | 454 ++++++++++++++++++ 5 files changed, 907 insertions(+), 1 deletion(-) create mode 100644 code/sdk/python/ap2/tests/delegate_sd_jwt_vector_tests.py create mode 100644 code/sdk/python/ap2/tests/delegate_sd_jwt_vectors/delegate_sd_jwt_vectors.json create mode 100644 code/sdk/python/ap2/tests/delegate_sd_jwt_vectors/generate_vectors.py diff --git a/.cspell.json b/.cspell.json index 3e21d466..2ab19292 100644 --- a/.cspell.json +++ b/.cspell.json @@ -9,7 +9,8 @@ ".gemini/**", ".vscode/**", ".cspell.json", - "**/go.sum" + "**/go.sum", + "**/delegate_sd_jwt_vectors/*.json" ], "dictionaryDefinitions": [ { diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index ce73c361..e11ba577 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -149,6 +149,7 @@ ropeproject RPCURL Rulebook screenreaders +sdjwt setlocal sharedpref Shopcider diff --git a/code/sdk/python/ap2/tests/delegate_sd_jwt_vector_tests.py b/code/sdk/python/ap2/tests/delegate_sd_jwt_vector_tests.py new file mode 100644 index 00000000..c589633b --- /dev/null +++ b/code/sdk/python/ap2/tests/delegate_sd_jwt_vector_tests.py @@ -0,0 +1,163 @@ +"""Consumer/verifier for the versioned delegate-SD-JWT golden vectors. + +Loads the frozen golden set +(``delegate_sd_jwt_vectors/delegate_sd_jwt_vectors.json``) and, for every +vector, asserts: + +1. the on-wire shape invariants (RFC 9901 + the dSD-JWT ``~~`` chain wire): + a single SD-JWT ends with exactly one ``~`` and has zero ``~~`` joins; an + N-segment chain has exactly N-1 ``~~`` joins and still ends with ``~``; +2. that the vector VERIFIES against the real AP2 reference SDK + (``ap2.sdk.MandateClient``) using the embedded root public key; and +3. that the resolved payloads carry the expected, byte-exact claim values. + +These vectors are frozen artifacts (see ``generate_vectors.py`` for the +reproducibility model); this test reads them, it never regenerates them. +""" + +from __future__ import annotations + +import json +import pathlib + +from typing import Any + +import pytest + +from ap2.sdk.generated.open_checkout_mandate import OpenCheckoutMandate +from ap2.sdk.generated.open_payment_mandate import OpenPaymentMandate +from ap2.sdk.mandate import MandateClient +from jwcrypto.jwk import JWK + + +_VECTORS_PATH = ( + pathlib.Path(__file__).parent + / 'delegate_sd_jwt_vectors' + / 'delegate_sd_jwt_vectors.json' +) + +# Root open-mandate model keyed by its ``vct``, for typed single-token verify. +_VCT_TO_MODEL = { + 'mandate.payment.open.1': OpenPaymentMandate, + 'mandate.checkout.open.1': OpenCheckoutMandate, +} + + +def _load() -> dict[str, Any]: + return json.loads(_VECTORS_PATH.read_text()) + + +_DATA = _load() +_VECTORS = _DATA['vectors'] +_IDS = [v['id'] for v in _VECTORS] + + +def test_vector_file_version_metadata(): + """The frozen set carries its keyed version metadata (issue #303).""" + version = _DATA['version'] + assert version['vector_set'] == 'ap2-delegate-sd-jwt/v1' + assert ( + version['delegate_sd_jwt_draft'] == 'draft-gco-oauth-delegate-sd-jwt-00' + ) + assert version['ap2_reference_commit'] == ( + 'e1ea56db72a6385bce3e5c1112b3a56ce60acb43' + ) + assert _VECTORS, 'vector set must be non-empty' + + +@pytest.mark.parametrize('vector', _VECTORS, ids=_IDS) +def test_wire_shape_invariants(vector: dict[str, Any]): + """RFC 9901 + dSD-JWT ``~~`` wire invariants hold for each vector.""" + token = vector['compact_serialization'] + exp = vector['expected'] + + # A single SD-JWT ends with exactly one '~'; a chain's final hop does too. + assert token.endswith('~'), 'compact serialization must end with ~' + assert exp['ends_with_tilde'] is True + + # An N-segment chain is joined by exactly N-1 '~~'. + segments = token.split('~~') + assert len(segments) == exp['hop_count'] + assert token.count('~~') == exp['double_tilde_joins'] + assert exp['double_tilde_joins'] == exp['hop_count'] - 1 + + # A single SD-JWT (one segment) must carry no chain join. + if exp['hop_count'] == 1: + assert '~~' not in token + + # Every segment is a well-formed SD-JWT: a 3-part JWT followed by zero or + # more disclosures. The cnf disclosure guarantees the delegation path never + # emits a zero-disclosure segment for the delegating (open) hops. + for i, seg in enumerate(segments): + is_final = i == len(segments) - 1 + # Re-attach the '~' that ~~-joining strips from non-final segments. + canonical = seg if (is_final or seg.endswith('~')) else seg + '~' + head = canonical.split('~', 1)[0] + assert len(head.split('.')) == 3, f'segment {i} is not a compact JWT' + + +@pytest.mark.parametrize('vector', _VECTORS, ids=_IDS) +def test_vector_verifies_against_sdk(vector: dict[str, Any]): + """Each frozen vector verifies against the real AP2 SDK verifier.""" + token = vector['compact_serialization'] + root_pub = JWK.from_json(json.dumps(vector['root_public_jwk'])) + ver = vector['verification'] + client = MandateClient() + + if vector['expected']['hop_count'] == 1: + # Single token: typed verify via the public MandateClient API. + first_vct = vector['expected']['payload_assertions'][0] + assert first_vct['path'] == [0, 'vct'] + model = _VCT_TO_MODEL[first_vct['equals']] + mandate = client.verify( + token=token, + key_or_provider=root_pub, + payload_type=model, + expected_aud=ver['expected_aud'], + expected_nonce=ver['expected_nonce'], + ) + payloads = [mandate.mandate_payload.model_dump(by_alias=True)] + else: + # Chain: root hop keyed by the embedded root public key; aud/nonce + # enforced on the terminal hop. + payloads = client.verify( + token=token, + key_or_provider=lambda _t: root_pub, + expected_aud=ver['expected_aud'], + expected_nonce=ver['expected_nonce'], + ) + + assert len(payloads) == vector['expected']['hop_count'] + _check_assertions(payloads, vector['expected']['payload_assertions']) + + +def _check_assertions( + payloads: list[dict[str, Any]], + assertions: list[dict[str, Any]], +) -> None: + """Walk each ``[index, key, ...]`` path and enforce its expectation.""" + for a in assertions: + path = a['path'] + node: Any = payloads[path[0]] + missing = False + for key in path[1:]: + if isinstance(node, dict) and key in node: + node = node[key] + else: + missing = True + break + if a.get('absent'): + assert missing or node is None, f'{path} should be absent' + elif a.get('present'): + assert not missing and node is not None, f'{path} should be present' + else: + assert not missing, f'{path} missing; cannot equal {a["equals"]!r}' + assert node == a['equals'], f'{path}: {node!r} != {a["equals"]!r}' + + +def test_multi_hop_chain_is_the_bank_sa_cp_merchant_flow(): + """The multi-hop vector is a genuine 3-party delegation chain.""" + vector = next(v for v in _VECTORS if v['id'] == 'multi-hop-payment-chain') + assert vector['expected']['hop_count'] == 3 + assert vector['expected']['double_tilde_joins'] == 2 + assert vector['delegation_kids'] == ['user-root', 'agent-sa', 'provider-cp'] diff --git a/code/sdk/python/ap2/tests/delegate_sd_jwt_vectors/delegate_sd_jwt_vectors.json b/code/sdk/python/ap2/tests/delegate_sd_jwt_vectors/delegate_sd_jwt_vectors.json new file mode 100644 index 00000000..916f2d0c --- /dev/null +++ b/code/sdk/python/ap2/tests/delegate_sd_jwt_vectors/delegate_sd_jwt_vectors.json @@ -0,0 +1,287 @@ +{ + "version": { + "vector_set": "ap2-delegate-sd-jwt/v1", + "delegate_sd_jwt_draft": "draft-gco-oauth-delegate-sd-jwt-00", + "ap2_reference_commit": "e1ea56db72a6385bce3e5c1112b3a56ce60acb43", + "frozen_standards": { + "sd_jwt": "RFC 9901", + "jcs": "RFC 8785", + "jws": "RFC 7515" + } + }, + "keys": { + "note": "Pinned P-256 signing keys. Private \"d\" is included so the vectors are fully reproducible; verification only needs the public coordinates.", + "pinned": { + "user-root": { + "crv": "P-256", + "d": "OteyNUjAnN-JavrYb12_Wo_X4Mnuh6EX9m12PuNsFf0", + "kty": "EC", + "x": "toGTbmS_yILsQsNraZfhLjTy1yoGHd-NmHJ1FrV5OWs", + "y": "PiXfjVFtax7OIBhrFwgFV4esW8PAwQBnZhS_h-ZfwCk", + "kid": "user-root" + }, + "agent-sa": { + "crv": "P-256", + "d": "iZi7Sme-wIEM2LjSiPGBrYcISGHyylc9yE7ZRPBiCI8", + "kty": "EC", + "x": "bYSLLVmu229lugkS6vzsT7rVGMJLlGA7r5qaO41dZxo", + "y": "GTWguqlzoLwGyvfdqVXLakN3ZYQo63ICAxu0oYRyckU", + "kid": "agent-sa" + }, + "provider-cp": { + "crv": "P-256", + "d": "ZSpgdC8OZ8kaEXcx9cYoUNEBK9zsJ1tglT6ZlTnruug", + "kty": "EC", + "x": "MGtE_mNvXCJB2ZuZKVkmgr2yMdX3FbTRIUVO4cYapXU", + "y": "iksDJe7QVCFs-01YfrRe2kduda8AQzvPvXtBTE4x27U", + "kid": "provider-cp" + }, + "merchant-key": { + "crv": "P-256", + "d": "7hIxjcsFCw9wlbamhiH6nF2np8P60P3IgSptnvRS3ec", + "kty": "EC", + "x": "kfremiWl_Rn5FZuyCJxDn18WhLcBdbPS-79QyN5dZ6s", + "y": "2LG18c1C30Mx7orscJ8VjOSq9t5AX4Cu0BhtEyZEPnw", + "kid": "merchant-key" + }, + "checkout-user": { + "crv": "P-256", + "d": "-2SnOhB3fPW7_swxgpBqyH-RkRQYMbsAvkw7yKQzRho", + "kty": "EC", + "x": "WuFQr15dAj6o8N35JFh5L9_6BvilsmrPd6ugwrsJbds", + "y": "2yJAKCmRNaQZQM5Xczh3CN66Oul6qVkRmrqS221AWu4", + "kid": "checkout-user" + }, + "checkout-agent": { + "crv": "P-256", + "d": "sE_r97_araaDITX8wt1I_tk0Oa8j4kYTozBKy0Jd2qY", + "kty": "EC", + "x": "Vxo2xJC2JDhNRCP0QGyDWXfyzWxw3_kb2zJTRmuxkL0", + "y": "X0CYROGy3VZFa2ALfUjiX9u_MFSEn0Xd4vczHZQM_9E", + "kid": "checkout-agent" + } + } + }, + "reproducibility": { + "deterministic": [ + "keys", + "claims", + "aud", + "nonce", + "wire_shape" + ], + "nondeterministic": [ + "disclosure salts (RFC 9901 4.1 requires fresh randomness)", + "ES256 signatures (randomized ECDSA k; not RFC 6979)" + ], + "model": "The committed JSON is the frozen golden artifact. The consumer test verifies these exact bytes against the SDK; regeneration yields semantically identical, byte-different vectors." + }, + "vectors": [ + { + "id": "root-single-sd-jwt", + "description": "A single issuer-signed root SD-JWT with no delegation hop. RFC 9901: ends with exactly one ~ and carries zero ~~ joins.", + "root_public_jwk": { + "crv": "P-256", + "kid": "user-root", + "kty": "EC", + "x": "toGTbmS_yILsQsNraZfhLjTy1yoGHd-NmHJ1FrV5OWs", + "y": "PiXfjVFtax7OIBhrFwgFV4esW8PAwQBnZhS_h-ZfwCk" + }, + "signer_kid": "user-root", + "holder_binding_kid": "agent-sa", + "verification": { + "expected_aud": null, + "expected_nonce": null + }, + "expected": { + "hop_count": 1, + "double_tilde_joins": 0, + "ends_with_tilde": true, + "payload_assertions": [ + { + "path": [ + 0, + "vct" + ], + "equals": "mandate.payment.open.1" + }, + { + "path": [ + 0, + "payment_amount", + "amount" + ], + "equals": 2500 + }, + { + "path": [ + 0, + "payee", + "name" + ], + "equals": "Acme Store" + } + ] + }, + "compact_serialization": "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0IiwgImtpZCI6ICJ1c2VyLXJvb3QifQ.eyJkZWxlZ2F0ZV9wYXlsb2FkIjogW3siLi4uIjogIjFuUE9pNlRhRk9PYTRfUk9PcHA0ZkdNSVNWOG1aeG9OMjhJdjNRbmdmVk0ifV0sICJfc2RfYWxnIjogInNoYS0yNTYifQ.LlsU97YChdk5GssMe6XN3d3XGeosw3AnL0v_inArAcIwpP7nNtf3n9LCt0Qx2rcho6hgtGpXEzzDPEBG5j5H7A~WyIyZUZiOHEtU3RTZnc4NWF4N0pNTGFnIiwgInBheWVlIiwgeyJpZCI6ICJtLTEiLCAibmFtZSI6ICJBY21lIFN0b3JlIn1d~WyJLYWRod3ZKNDZOakdmODNzZXRCZEZ3IiwgInBheW1lbnRfYW1vdW50IiwgeyJhbW91bnQiOiAyNTAwLCAiY3VycmVuY3kiOiAiVVNEIn1d~WyJaZjNVUVBJMzdPMl9UQk40M3VCbDlBIiwgeyJfc2QiOiBbIkNsVm9yYmNzWk55LXpqQzVmcUpMYVZzV2xBaTJGekFJQ2d2d0xGT21OejQiLCAicnBYVzhVeVJteTFMV2xaRjk2d1Rxb0JVamcyMTZtc0liOFhzTjktbjJvbyJdLCAidmN0IjogIm1hbmRhdGUucGF5bWVudC5vcGVuLjEiLCAiY29uc3RyYWludHMiOiBbXSwgImNuZiI6IHsiandrIjogeyJjcnYiOiAiUC0yNTYiLCAia2lkIjogImFnZW50LXNhIiwgImt0eSI6ICJFQyIsICJ4IjogImJZU0xMVm11MjI5bHVna1M2dnpzVDdyVkdNSkxsR0E3cjVxYU80MWRaeG8iLCAieSI6ICJHVFdndXFsem9Md0d5dmZkcVZYTGFrTjNaWVFvNjNJQ0F4dTBvWVJ5Y2tVIn19fV0~" + }, + { + "id": "single-hop-payment-chain", + "description": "One delegation hop appended with MandateClient.present(): open payment mandate -> closed payment mandate. Exactly one ~~ join; the open hop discloses only payment_amount (payee redacted).", + "root_public_jwk": { + "crv": "P-256", + "kid": "user-root", + "kty": "EC", + "x": "toGTbmS_yILsQsNraZfhLjTy1yoGHd-NmHJ1FrV5OWs", + "y": "PiXfjVFtax7OIBhrFwgFV4esW8PAwQBnZhS_h-ZfwCk" + }, + "signer_kid": "user-root", + "holder_binding_kid": "agent-sa", + "verification": { + "expected_aud": "merchant", + "expected_nonce": "merchant-nonce-single" + }, + "expected": { + "hop_count": 2, + "double_tilde_joins": 1, + "ends_with_tilde": true, + "payload_assertions": [ + { + "path": [ + 0, + "vct" + ], + "equals": "mandate.payment.open.1" + }, + { + "path": [ + 0, + "payment_amount", + "amount" + ], + "equals": 2500 + }, + { + "path": [ + 0, + "payee" + ], + "absent": true + }, + { + "path": [ + 1, + "transaction_id" + ], + "equals": "tx_single_hop" + } + ] + }, + "compact_serialization": "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0IiwgImtpZCI6ICJ1c2VyLXJvb3QifQ.eyJkZWxlZ2F0ZV9wYXlsb2FkIjogW3siLi4uIjogIm5UUWN2dzBFTklGS3JhdUFUT2ROQTUxdktLVEtjRndnc21UWG5EbDZQNncifV0sICJfc2RfYWxnIjogInNoYS0yNTYifQ.cHaOnaymkftP9OlOGEDlxC56PCYIu7CrVxSiRxQnh24R3fjYiQKBntC3_1cxMyBR2VR8IIsXNtfTo96yAcP7Dg~WyJCN09hQ1QtSUpXa3dEdlpUMHVQOVNnIiwgInBheW1lbnRfYW1vdW50IiwgeyJhbW91bnQiOiAyNTAwLCAiY3VycmVuY3kiOiAiVVNEIn1d~WyJNclNuTVJzY3JZcDl0REVXRVc1OThnIiwgeyJfc2QiOiBbIktHTFlKU3AtWHZPUVNUUXVBWTFzaXNqejNNYTAtTm5kTXY4aHpMT1duTkEiLCAiT0I5a0ZadURVWGx2c09HMTViSGdmOV9WUVpOdVJZR2pQWTk4aG12LXVqbyJdLCAidmN0IjogIm1hbmRhdGUucGF5bWVudC5vcGVuLjEiLCAiY29uc3RyYWludHMiOiBbXSwgImNuZiI6IHsiandrIjogeyJjcnYiOiAiUC0yNTYiLCAia2lkIjogImFnZW50LXNhIiwgImt0eSI6ICJFQyIsICJ4IjogImJZU0xMVm11MjI5bHVna1M2dnpzVDdyVkdNSkxsR0E3cjVxYU80MWRaeG8iLCAieSI6ICJHVFdndXFsem9Md0d5dmZkcVZYTGFrTjNaWVFvNjNJQ0F4dTBvWVJ5Y2tVIn19fV0~~eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImtiK3NkLWp3dCIsICJraWQiOiAiYWdlbnQtc2EifQ.eyJkZWxlZ2F0ZV9wYXlsb2FkIjogW3siLi4uIjogIjR6X3dqWWIxcVFMLWdBYXhsamJia1oxbThHZWhOQlp0TDZVa2RRNVdaQ0kifV0sICJpYXQiOiAxNzg1MjY4NjAyLCAiYXVkIjogIm1lcmNoYW50IiwgIm5vbmNlIjogIm1lcmNoYW50LW5vbmNlLXNpbmdsZSIsICJzZF9oYXNoIjogIkp5dVBvRnNtQkpGS244UXVyTTRWMEVTNVZRbDZUNkVfcmVzdnhLR3NSM3MiLCAiX3NkX2FsZyI6ICJzaGEtMjU2In0.40aQWQKjx6I_OL1qLDwS2j1HVDCUGclAW9nyWA5NyOSde9sWXchMb4t5RR8RHAk6vAzzXR4dv03ZCZFrnaj6Gw~WyJla01UOHhUYWd1OUN3MS1MSTM0SXpRIiwgeyJ2Y3QiOiAibWFuZGF0ZS5wYXltZW50LjEiLCAidHJhbnNhY3Rpb25faWQiOiAidHhfc2luZ2xlX2hvcCIsICJwYXllZSI6IHsiaWQiOiAibS0xIiwgIm5hbWUiOiAiQWNtZSBTdG9yZSJ9LCAicGF5bWVudF9hbW91bnQiOiB7ImFtb3VudCI6IDI1MDAsICJjdXJyZW5jeSI6ICJVU0QifSwgInBheW1lbnRfaW5zdHJ1bWVudCI6IHsiaWQiOiAicGktMSIsICJ0eXBlIjogImNyZWRpdCJ9fV0~" + }, + { + "id": "multi-hop-payment-chain", + "description": "Canonical Bank -> Shopping Agent -> Credentials Provider -> Merchant delegation chain. Three segments joined on the ~~ wire (two joins), three resolved payloads. Each hop binds to the preceding hop via cnf + sd_hash; aud/nonce enforced on the final CP -> Merchant hop.", + "root_public_jwk": { + "crv": "P-256", + "kid": "user-root", + "kty": "EC", + "x": "toGTbmS_yILsQsNraZfhLjTy1yoGHd-NmHJ1FrV5OWs", + "y": "PiXfjVFtax7OIBhrFwgFV4esW8PAwQBnZhS_h-ZfwCk" + }, + "signer_kid": "user-root", + "delegation_kids": [ + "user-root", + "agent-sa", + "provider-cp" + ], + "verification": { + "expected_aud": "merchant", + "expected_nonce": "merchant-nonce-456" + }, + "expected": { + "hop_count": 3, + "double_tilde_joins": 2, + "ends_with_tilde": true, + "payload_assertions": [ + { + "path": [ + 0, + "vct" + ], + "equals": "mandate.payment.open.1" + }, + { + "path": [ + 1, + "cnf" + ], + "present": true + }, + { + "path": [ + 2, + "transaction_id" + ], + "equals": "tx_multi_hop" + }, + { + "path": [ + 2, + "payment_amount", + "amount" + ], + "equals": 2500 + } + ] + }, + "compact_serialization": "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0IiwgImtpZCI6ICJ1c2VyLXJvb3QifQ.eyJkZWxlZ2F0ZV9wYXlsb2FkIjogW3siLi4uIjogInE0WE1yWFpoVzhhMHVrQ0JSZUxBWGRQZjF3TWZxRjU1T2JHVGpDMlJKeDQifV0sICJfc2RfYWxnIjogInNoYS0yNTYifQ.PGWZmDDTBRtROr2VGEOD_hk-ZZQ8Cb5_4XepwlRb-4Ty6_ZaXaP2r4zVQZDNS-eqxFM85o664WzrlEw-g-ZlWA~WyJSUGZTVlhrUVc1QWZNOG02aHJiX1hBIiwgInBheWVlIiwgeyJpZCI6ICJtLTEiLCAibmFtZSI6ICJBY21lIFN0b3JlIn1d~WyJJaHJZdGVhaU5fU0h5NFUxR2N5WUdRIiwgInBheW1lbnRfYW1vdW50IiwgeyJhbW91bnQiOiAyNTAwLCAiY3VycmVuY3kiOiAiVVNEIn1d~WyJlaXd2WS1EYU9HVDRvTXVrVHhQYVVnIiwgeyJfc2QiOiBbIkMyV0ZaR1hRdDJ2X01jR1ZsczUwRnVxQXZJcGs0UE5KbUFVb3dEOGdNekEiLCAiVGVaSTB6WVZ5OXJUak1iWmNtMXMwR1RhdDE0bUhRdWUzV0pjWDFGb3k2ayJdLCAidmN0IjogIm1hbmRhdGUucGF5bWVudC5vcGVuLjEiLCAiY29uc3RyYWludHMiOiBbXSwgImNuZiI6IHsiandrIjogeyJjcnYiOiAiUC0yNTYiLCAia2lkIjogImFnZW50LXNhIiwgImt0eSI6ICJFQyIsICJ4IjogImJZU0xMVm11MjI5bHVna1M2dnpzVDdyVkdNSkxsR0E3cjVxYU80MWRaeG8iLCAieSI6ICJHVFdndXFsem9Md0d5dmZkcVZYTGFrTjNaWVFvNjNJQ0F4dTBvWVJ5Y2tVIn19fV0~~eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImtiK3NkLWp3dCtrYiIsICJraWQiOiAiYWdlbnQtc2EifQ.eyJkZWxlZ2F0ZV9wYXlsb2FkIjogW3siLi4uIjogIjA5Wkg3ZWt6LU82ZkY1VVQwVktxbTc5NzB1SjRUMmNLa0ZjS1ZXNENPVFEifV0sICJpYXQiOiAxNzg1MjY4NjAyLCAiYXVkIjogImNwLWFnZW50IiwgIm5vbmNlIjogImNwLW5vbmNlLTEyMyIsICJzZF9oYXNoIjogInhiLUYyUVdqdklEbW0talBsZVRPSFg1enFkQmRqTEotSjV2aWQ1UUxZRVEiLCAiX3NkX2FsZyI6ICJzaGEtMjU2In0.S23I-59CJTNgyjUTTdObaGAW8_q6DnVIV2fvTnVB0dTL7q17fhOnxVg4f89vqKed3YtzCoLAoo_8y1vTCvHxeQ~WyJsUnh3bkdqY1lDWXV6NF8xRThyRXpBIiwgeyJ2Y3QiOiAibWFuZGF0ZS5wYXltZW50Lm9wZW4uMSIsICJjb25zdHJhaW50cyI6IFtdLCAiY25mIjogeyJqd2siOiB7ImNydiI6ICJQLTI1NiIsICJraWQiOiAicHJvdmlkZXItY3AiLCAia3R5IjogIkVDIiwgIngiOiAiTUd0RV9tTnZYQ0pCMlp1WktWa21ncjJ5TWRYM0ZiVFJJVVZPNGNZYXBYVSIsICJ5IjogImlrc0RKZTdRVkNGcy0wMVlmclJlMmtkdWRhOEFRenZQdlh0QlRFNHgyN1UifX19XQ~~eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImtiK3NkLWp3dCIsICJraWQiOiAicHJvdmlkZXItY3AifQ.eyJkZWxlZ2F0ZV9wYXlsb2FkIjogW3siLi4uIjogIk5fWHhtQkpBbnE4cW1VZXdDaTZ6OGZNSjN3Rlp2UUhzb3M0cGdTZmw5c1EifV0sICJpYXQiOiAxNzg1MjY4NjAyLCAiYXVkIjogIm1lcmNoYW50IiwgIm5vbmNlIjogIm1lcmNoYW50LW5vbmNlLTQ1NiIsICJzZF9oYXNoIjogImtBNEViWVR0dXJjaGFQaWhpOGU1d0VpSTFtc2x0SVBTQk5ySTBUOW5DTUUiLCAiX3NkX2FsZyI6ICJzaGEtMjU2In0.29KAuHkDRvQnh2v_6BZTs9EQP6g7-eY8XiThXWjff0_3Mul3OdRGGLnG3l4KmnbN_JlvaCxHU5M1PV5N9qPQRw~WyJNckg5NHA3OUluVkthNzNfRDVFMHF3IiwgeyJ2Y3QiOiAibWFuZGF0ZS5wYXltZW50LjEiLCAidHJhbnNhY3Rpb25faWQiOiAidHhfbXVsdGlfaG9wIiwgInBheWVlIjogeyJpZCI6ICJtLTEiLCAibmFtZSI6ICJBY21lIFN0b3JlIn0sICJwYXltZW50X2Ftb3VudCI6IHsiYW1vdW50IjogMjUwMCwgImN1cnJlbmN5IjogIlVTRCJ9LCAicGF5bWVudF9pbnN0cnVtZW50IjogeyJpZCI6ICJwaS0xIiwgInR5cGUiOiAiY3JlZGl0In19XQ~" + }, + { + "id": "single-hop-checkout-chain", + "description": "One delegation hop for the checkout mandate family: open checkout mandate (constrained to an allowed merchant) -> closed checkout mandate. Exactly one ~~ join.", + "root_public_jwk": { + "crv": "P-256", + "kid": "checkout-user", + "kty": "EC", + "x": "WuFQr15dAj6o8N35JFh5L9_6BvilsmrPd6ugwrsJbds", + "y": "2yJAKCmRNaQZQM5Xczh3CN66Oul6qVkRmrqS221AWu4" + }, + "signer_kid": "checkout-user", + "holder_binding_kid": "checkout-agent", + "verification": { + "expected_aud": "merchant", + "expected_nonce": "merchant-nonce-checkout" + }, + "expected": { + "hop_count": 2, + "double_tilde_joins": 1, + "ends_with_tilde": true, + "payload_assertions": [ + { + "path": [ + 0, + "vct" + ], + "equals": "mandate.checkout.open.1" + }, + { + "path": [ + 1, + "vct" + ], + "equals": "mandate.checkout.1" + }, + { + "path": [ + 1, + "checkout_hash" + ], + "equals": "Zm9vYmFyX2NoZWNrb3V0X2hhc2hfcGxhY2Vob2xkZXI" + } + ] + }, + "compact_serialization": "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0IiwgImtpZCI6ICJjaGVja291dC11c2VyIn0.eyJkZWxlZ2F0ZV9wYXlsb2FkIjogW3siLi4uIjogImJBaHhuZEdPQVJHYnBRWWswOEthZ09hNFNJY0xpejZyVFhsWXd1aXlxTTgifV0sICJfc2RfYWxnIjogInNoYS0yNTYifQ.KRgYGUPhV6HuN2QcqRragVe8nslvr1998BxEE2fBI7bFEeY1cnKe7rrbcIqD2L463sahwU9oakpbWJ2uK4-tXA~WyJnRUt5NnVkdEhmSXA3LVhXWjhvWWdnIiwgeyJpZCI6ICJtLTEiLCAibmFtZSI6ICJBY21lIFN0b3JlIn1d~WyJCZFZZbl9qbmVHbWs3Ti03TTA5MUZnIiwgeyJ2Y3QiOiAibWFuZGF0ZS5jaGVja291dC5vcGVuLjEiLCAiY29uc3RyYWludHMiOiBbeyJ0eXBlIjogImNoZWNrb3V0LmFsbG93ZWRfbWVyY2hhbnRzIiwgImFsbG93ZWQiOiBbeyIuLi4iOiAidk5oQ2N3bnczV1RNNk5Lc1pPYy1pU0R5QUFOeDFfTllaclpRUllQQWVpMCJ9XX1dLCAiY25mIjogeyJqd2siOiB7ImNydiI6ICJQLTI1NiIsICJraWQiOiAiY2hlY2tvdXQtYWdlbnQiLCAia3R5IjogIkVDIiwgIngiOiAiVnhvMnhKQzJKRGhOUkNQMFFHeURXWGZ5eld4dzNfa2IyekpUUm11eGtMMCIsICJ5IjogIlgwQ1lST0d5M1ZaRmEyQUxmVWppWDl1X01GU0VuMFhkNHZjekhaUU1fOUUifX19XQ~~eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImtiK3NkLWp3dCIsICJraWQiOiAiY2hlY2tvdXQtYWdlbnQifQ.eyJkZWxlZ2F0ZV9wYXlsb2FkIjogW3siLi4uIjogInhnazRWTXk1RWVrZS1CZWxMSkdSSklKMDZvekdqSGQzN3VuUU9zeHBDM0UifV0sICJpYXQiOiAxNzg1MjY4NjAyLCAiYXVkIjogIm1lcmNoYW50IiwgIm5vbmNlIjogIm1lcmNoYW50LW5vbmNlLWNoZWNrb3V0IiwgInNkX2hhc2giOiAiVjJTSkoybi1UU25YcFhXSU5WVmFxZzRYcjV2YUlnUTNza3dCMHRXMVpvTSIsICJfc2RfYWxnIjogInNoYS0yNTYifQ.ZGeFqXp6yaNZeYXQlgRS6kRla2a3PejnFiIdYOgPammIFnckbRmpN_RsMbarNJkbPUHVUSJcvnqBz-cj8vmcXA~WyJKYmhCem5GUXRDbUxBZ1pfb0dfbDFRIiwgeyJfc2QiOiBbIlN3LU5CY2d1UlJYalhrY2FkZF9iUTVjM0VzVjl3aW9Ta2hva0k2dTQ1aTAiXSwgInZjdCI6ICJtYW5kYXRlLmNoZWNrb3V0LjEiLCAiY2hlY2tvdXRfaGFzaCI6ICJabTl2WW1GeVgyTm9aV05yYjNWMFgyaGhjMmhmY0d4aFkyVm9iMnhrWlhJIn1d~WyJrY01IR3MyUWVHd05IV1hGdzZFeXFRIiwgImNoZWNrb3V0X2p3dCIsICJoZHIuZXlKcFpDSTZJbU5vYTE5MFpYTjBJbjAuc2lnIl0~" + } + ] +} diff --git a/code/sdk/python/ap2/tests/delegate_sd_jwt_vectors/generate_vectors.py b/code/sdk/python/ap2/tests/delegate_sd_jwt_vectors/generate_vectors.py new file mode 100644 index 00000000..2ce82cd4 --- /dev/null +++ b/code/sdk/python/ap2/tests/delegate_sd_jwt_vectors/generate_vectors.py @@ -0,0 +1,454 @@ +"""Generator for versioned delegate-SD-JWT (dSD-JWT) golden vectors. + +This mints a keyed, versioned golden-vector set for the **delegation chain +layer** of AP2 mandates — the layer that travels on the SD-JWT ``~~`` wire +(picks up issue #303 / Discussion #262). Every vector is produced by the +*real* AP2 reference SDK (``ap2.sdk``), never by hand-forged strings, so the +committed serializations are authoritative. + +What is covered: + +- ``root-single-sd-jwt`` — a single, un-delegated root SD-JWT. Demonstrates the + RFC 9901 rule that a lone SD-JWT ends with exactly one ``~`` and carries zero + ``~~`` chain joins. +- ``single-hop-payment-chain`` — one delegation hop appended with the public + ``MandateClient.present(...)`` API (open payment mandate -> closed payment + mandate). Exactly one ``~~`` join, two resolved payloads. +- ``multi-hop-payment-chain`` — the canonical Bank -> SA -> CP -> Merchant + three-segment chain (two ``~~`` joins, three resolved payloads), minted hop + by hop with the SDK's ``sd_jwt.create`` + ``kb_sd_jwt.create`` primitives and + joined on the ``~~`` wire exactly as the reference does. +- ``single-hop-checkout-chain`` — the same one-hop delegation for the *checkout* + mandate family (open checkout mandate -> closed checkout mandate), covering + the second mandate type called out in #303. + +REPRODUCIBILITY +--------------- +Keys are PINNED (see ``PINNED_KEYS`` below) and embedded in every vector, so +signature verification against the committed ``root_public_jwk`` is fully +deterministic and reproducible on any machine. + +The compact serializations are NOT byte-reproducible, by design, for two +independent reasons that are inherent to the format: + +1. RFC 9901 §4.1 requires each disclosure ``salt`` to be freshly random. +2. The signature alg is ES256 (ECDSA/P-256), which is randomized (a fresh + nonce ``k`` per RFC 6279 — jwcrypto/cryptography do not use deterministic + ECDSA). + +So re-running this generator yields *semantically identical* vectors (same +keys, same claims, same wire shape, verifying to the same payloads) with +different salt/signature bytes. The committed JSON file is therefore the frozen +golden artifact; the consumer test verifies THOSE exact committed bytes against +the SDK rather than regenerating them. That is the correct model for a +randomized-signature credential format. + +Run: ``uv run python -m ap2.tests.delegate_sd_jwt_vectors.generate_vectors`` +(writes ``delegate_sd_jwt_vectors.json`` next to this file). The consumer test +(``delegate_sd_jwt_vector_tests.py``) does NOT run this; it reads the frozen +JSON only. +""" + +from __future__ import annotations + +import json +import pathlib + +from typing import Any + +from ap2.sdk.disclosure_metadata import DisclosureMetadata +from ap2.sdk.generated.checkout_mandate import CheckoutMandate +from ap2.sdk.generated.open_checkout_mandate import ( + AllowedMerchants, + OpenCheckoutMandate, +) +from ap2.sdk.generated.open_payment_mandate import OpenPaymentMandate +from ap2.sdk.generated.payment_mandate import PaymentMandate +from ap2.sdk.generated.types.amount import Amount +from ap2.sdk.generated.types.merchant import Merchant +from ap2.sdk.generated.types.payment_instrument import PaymentInstrument +from ap2.sdk.mandate import MandateClient +from ap2.sdk.sdjwt import kb_sd_jwt, parse_token, sd_jwt +from jwcrypto.jwk import JWK + + +# ── Version metadata (mirrors the keyed inventory in issue #303) ────────── + +VERSION = { + 'vector_set': 'ap2-delegate-sd-jwt/v1', + 'delegate_sd_jwt_draft': 'draft-gco-oauth-delegate-sd-jwt-00', + 'ap2_reference_commit': 'e1ea56db72a6385bce3e5c1112b3a56ce60acb43', + 'frozen_standards': { + 'sd_jwt': 'RFC 9901', + 'jcs': 'RFC 8785', + 'jws': 'RFC 7515', + }, +} + + +# ── Pinned keys (generated once; embedded for deterministic verification) ─ + +PINNED_KEYS: dict[str, dict[str, str]] = { + 'user-root': { + 'crv': 'P-256', + 'd': 'OteyNUjAnN-JavrYb12_Wo_X4Mnuh6EX9m12PuNsFf0', + 'kty': 'EC', + 'x': 'toGTbmS_yILsQsNraZfhLjTy1yoGHd-NmHJ1FrV5OWs', + 'y': 'PiXfjVFtax7OIBhrFwgFV4esW8PAwQBnZhS_h-ZfwCk', + 'kid': 'user-root', + }, + 'agent-sa': { + 'crv': 'P-256', + 'd': 'iZi7Sme-wIEM2LjSiPGBrYcISGHyylc9yE7ZRPBiCI8', + 'kty': 'EC', + 'x': 'bYSLLVmu229lugkS6vzsT7rVGMJLlGA7r5qaO41dZxo', + 'y': 'GTWguqlzoLwGyvfdqVXLakN3ZYQo63ICAxu0oYRyckU', + 'kid': 'agent-sa', + }, + 'provider-cp': { + 'crv': 'P-256', + 'd': 'ZSpgdC8OZ8kaEXcx9cYoUNEBK9zsJ1tglT6ZlTnruug', + 'kty': 'EC', + 'x': 'MGtE_mNvXCJB2ZuZKVkmgr2yMdX3FbTRIUVO4cYapXU', + 'y': 'iksDJe7QVCFs-01YfrRe2kduda8AQzvPvXtBTE4x27U', + 'kid': 'provider-cp', + }, + 'merchant-key': { + 'crv': 'P-256', + 'd': '7hIxjcsFCw9wlbamhiH6nF2np8P60P3IgSptnvRS3ec', + 'kty': 'EC', + 'x': 'kfremiWl_Rn5FZuyCJxDn18WhLcBdbPS-79QyN5dZ6s', + 'y': '2LG18c1C30Mx7orscJ8VjOSq9t5AX4Cu0BhtEyZEPnw', + 'kid': 'merchant-key', + }, + 'checkout-user': { + 'crv': 'P-256', + 'd': '-2SnOhB3fPW7_swxgpBqyH-RkRQYMbsAvkw7yKQzRho', + 'kty': 'EC', + 'x': 'WuFQr15dAj6o8N35JFh5L9_6BvilsmrPd6ugwrsJbds', + 'y': '2yJAKCmRNaQZQM5Xczh3CN66Oul6qVkRmrqS221AWu4', + 'kid': 'checkout-user', + }, + 'checkout-agent': { + 'crv': 'P-256', + 'd': 'sE_r97_araaDITX8wt1I_tk0Oa8j4kYTozBKy0Jd2qY', + 'kty': 'EC', + 'x': 'Vxo2xJC2JDhNRCP0QGyDWXfyzWxw3_kb2zJTRmuxkL0', + 'y': 'X0CYROGy3VZFa2ALfUjiX9u_MFSEn0Xd4vczHZQM_9E', + 'kid': 'checkout-agent', + }, +} + +OUT_PATH = pathlib.Path(__file__).with_name('delegate_sd_jwt_vectors.json') + + +def _priv(kid: str) -> JWK: + return JWK.from_json(json.dumps(PINNED_KEYS[kid])) + + +def _pub_dict(kid: str) -> dict[str, Any]: + return json.loads(_priv(kid).export_public()) + + +def _make_cnf(kid: str) -> dict[str, Any]: + """Public-only ``cnf`` binding for the delegate that holds ``kid``.""" + return {'jwk': _pub_dict(kid)} + + +# ── Wire-shape assertions (defensive; the consumer test re-checks these) ── + + +def _assert_single(token: str) -> None: + assert '~~' not in token, 'single SD-JWT must carry no ~~ chain join' + assert token.endswith('~'), 'single SD-JWT must end with ~' + + +def _assert_chain(token: str, expected_joins: int) -> None: + assert token.count('~~') == expected_joins, ( + f'chain must have {expected_joins} ~~ joins, got {token.count("~~")}' + ) + assert token.endswith('~'), 'final hop of a chain must end with ~' + + +# ── Vector builders ────────────────────────────────────────────────────── + + +def build_root_single() -> dict[str, Any]: + """A single, un-delegated root SD-JWT (open payment mandate).""" + open_payload = OpenPaymentMandate( + constraints=[], + cnf=_make_cnf('agent-sa'), + payment_amount=Amount(amount=2500, currency='USD'), + payee=Merchant(id='m-1', name='Acme Store'), + ) + token = MandateClient().create( + payloads=[open_payload], + issuer_key=_priv('user-root'), + sd=DisclosureMetadata(sd_keys=['payee', 'payment_amount']), + ) + _assert_single(token) + return { + 'id': 'root-single-sd-jwt', + 'description': ( + 'A single issuer-signed root SD-JWT with no delegation hop. ' + 'RFC 9901: ends with exactly one ~ and carries zero ~~ joins.' + ), + 'root_public_jwk': _pub_dict('user-root'), + 'signer_kid': 'user-root', + 'holder_binding_kid': 'agent-sa', + 'verification': {'expected_aud': None, 'expected_nonce': None}, + 'expected': { + 'hop_count': 1, + 'double_tilde_joins': 0, + 'ends_with_tilde': True, + 'payload_assertions': [ + {'path': [0, 'vct'], 'equals': 'mandate.payment.open.1'}, + {'path': [0, 'payment_amount', 'amount'], 'equals': 2500}, + {'path': [0, 'payee', 'name'], 'equals': 'Acme Store'}, + ], + }, + 'compact_serialization': token, + } + + +def build_single_hop_payment() -> dict[str, Any]: + """One delegation hop via the public ``MandateClient.present`` API.""" + client = MandateClient() + open_payload = OpenPaymentMandate( + constraints=[], + cnf=_make_cnf('agent-sa'), + payment_amount=Amount(amount=2500, currency='USD'), + payee=Merchant(id='m-1', name='Acme Store'), + ) + open_tok = client.create( + payloads=[open_payload], + issuer_key=_priv('user-root'), + sd=DisclosureMetadata(sd_keys=['payee', 'payment_amount']), + ) + closed = PaymentMandate( + transaction_id='tx_single_hop', + payee=Merchant(id='m-1', name='Acme Store'), + payment_amount=Amount(amount=2500, currency='USD'), + payment_instrument=PaymentInstrument(id='pi-1', type='credit'), + ) + aud, nonce = 'merchant', 'merchant-nonce-single' + token = client.present( + holder_key=_priv('agent-sa'), + mandate_token=open_tok, + payloads=[closed], + claims_to_disclose={'payment_amount': True}, + aud=aud, + nonce=nonce, + ) + _assert_chain(token, expected_joins=1) + return { + 'id': 'single-hop-payment-chain', + 'description': ( + 'One delegation hop appended with MandateClient.present(): ' + 'open payment mandate -> closed payment mandate. Exactly one ~~ ' + 'join; the open hop discloses only payment_amount (payee redacted).' + ), + 'root_public_jwk': _pub_dict('user-root'), + 'signer_kid': 'user-root', + 'holder_binding_kid': 'agent-sa', + 'verification': {'expected_aud': aud, 'expected_nonce': nonce}, + 'expected': { + 'hop_count': 2, + 'double_tilde_joins': 1, + 'ends_with_tilde': True, + 'payload_assertions': [ + {'path': [0, 'vct'], 'equals': 'mandate.payment.open.1'}, + {'path': [0, 'payment_amount', 'amount'], 'equals': 2500}, + {'path': [0, 'payee'], 'absent': True}, + {'path': [1, 'transaction_id'], 'equals': 'tx_single_hop'}, + ], + }, + 'compact_serialization': token, + } + + +def build_multi_hop_payment() -> dict[str, Any]: + """Canonical Bank -> SA -> CP -> Merchant three-segment chain. + + Minted hop-by-hop with the SDK primitives (``sd_jwt.create`` for the root, + ``kb_sd_jwt.create`` for each KB-SD-JWT delegation) and joined on the ``~~`` + wire exactly as the reference verifier expects. ``present()`` appends a + single hop onto one preceding segment, so multi-hop chains are composed + from the segment primitives — this mirrors the SDK's own + ``test_three_step_bank_sa_cp_merchant_flow``. + """ + bank_segment = sd_jwt.create( + payload=OpenPaymentMandate( + constraints=[], + cnf=_make_cnf('agent-sa'), + payment_amount=Amount(amount=2500, currency='USD'), + payee=Merchant(id='m-1', name='Acme Store'), + ), + issuer_key=_priv('user-root'), + sd=DisclosureMetadata(sd_keys=['payee', 'payment_amount']), + ).sd_jwt_issuance + _assert_single(bank_segment) + + sa_segment = kb_sd_jwt.create( + prev_token=parse_token(bank_segment), + holder_key=_priv('agent-sa'), + payload=OpenPaymentMandate( + constraints=[], cnf=_make_cnf('provider-cp') + ), + aud='cp-agent', + nonce='cp-nonce-123', + ).sd_jwt_issuance + + closed = PaymentMandate( + transaction_id='tx_multi_hop', + payee=Merchant(id='m-1', name='Acme Store'), + payment_amount=Amount(amount=2500, currency='USD'), + payment_instrument=PaymentInstrument(id='pi-1', type='credit'), + ) + aud, nonce = 'merchant', 'merchant-nonce-456' + cp_segment = kb_sd_jwt.create( + prev_token=parse_token(sa_segment), + holder_key=_priv('provider-cp'), + payload=closed, + aud=aud, + nonce=nonce, + ).sd_jwt_issuance + + sa_stripped = sa_segment[:-1] if sa_segment.endswith('~') else sa_segment + token = f'{bank_segment[:-1]}~~{sa_stripped}~~{cp_segment}' + _assert_chain(token, expected_joins=2) + return { + 'id': 'multi-hop-payment-chain', + 'description': ( + 'Canonical Bank -> Shopping Agent -> Credentials Provider -> ' + 'Merchant delegation chain. Three segments joined on the ~~ wire ' + '(two joins), three resolved payloads. Each hop binds to the ' + 'preceding hop via cnf + sd_hash; aud/nonce enforced on the final ' + 'CP -> Merchant hop.' + ), + 'root_public_jwk': _pub_dict('user-root'), + 'signer_kid': 'user-root', + 'delegation_kids': ['user-root', 'agent-sa', 'provider-cp'], + 'verification': {'expected_aud': aud, 'expected_nonce': nonce}, + 'expected': { + 'hop_count': 3, + 'double_tilde_joins': 2, + 'ends_with_tilde': True, + 'payload_assertions': [ + {'path': [0, 'vct'], 'equals': 'mandate.payment.open.1'}, + {'path': [1, 'cnf'], 'present': True}, + {'path': [2, 'transaction_id'], 'equals': 'tx_multi_hop'}, + {'path': [2, 'payment_amount', 'amount'], 'equals': 2500}, + ], + }, + 'compact_serialization': token, + } + + +def build_single_hop_checkout() -> dict[str, Any]: + """One delegation hop for the checkout mandate family. + + open checkout mandate -> closed checkout mandate, exercising the second + mandate type called out in issue #303. + """ + client = MandateClient() + open_payload = OpenCheckoutMandate( + constraints=[ + AllowedMerchants(allowed=[Merchant(id='m-1', name='Acme Store')]) + ], + cnf=_make_cnf('checkout-agent'), + ) + open_tok = client.create( + payloads=[open_payload], + issuer_key=_priv('checkout-user'), + ) + closed = CheckoutMandate( + checkout_jwt='hdr.eyJpZCI6ImNoa190ZXN0In0.sig', + checkout_hash='Zm9vYmFyX2NoZWNrb3V0X2hhc2hfcGxhY2Vob2xkZXI', + ) + aud, nonce = 'merchant', 'merchant-nonce-checkout' + token = client.present( + holder_key=_priv('checkout-agent'), + mandate_token=open_tok, + payloads=[closed], + aud=aud, + nonce=nonce, + ) + _assert_chain(token, expected_joins=1) + return { + 'id': 'single-hop-checkout-chain', + 'description': ( + 'One delegation hop for the checkout mandate family: open checkout ' + 'mandate (constrained to an allowed merchant) -> closed checkout ' + 'mandate. Exactly one ~~ join.' + ), + 'root_public_jwk': _pub_dict('checkout-user'), + 'signer_kid': 'checkout-user', + 'holder_binding_kid': 'checkout-agent', + 'verification': {'expected_aud': aud, 'expected_nonce': nonce}, + 'expected': { + 'hop_count': 2, + 'double_tilde_joins': 1, + 'ends_with_tilde': True, + 'payload_assertions': [ + {'path': [0, 'vct'], 'equals': 'mandate.checkout.open.1'}, + {'path': [1, 'vct'], 'equals': 'mandate.checkout.1'}, + { + 'path': [1, 'checkout_hash'], + 'equals': 'Zm9vYmFyX2NoZWNrb3V0X2hhc2hfcGxhY2Vob2xkZXI', + }, + ], + }, + 'compact_serialization': token, + } + + +def build_all() -> dict[str, Any]: + """Assemble the full versioned vector set (metadata + keys + vectors).""" + return { + 'version': VERSION, + 'keys': { + 'note': ( + 'Pinned P-256 signing keys. Private "d" is included so the ' + 'vectors are fully reproducible; verification only needs the ' + 'public coordinates.' + ), + 'pinned': PINNED_KEYS, + }, + 'reproducibility': { + 'deterministic': ['keys', 'claims', 'aud', 'nonce', 'wire_shape'], + 'nondeterministic': [ + 'disclosure salts (RFC 9901 4.1 requires fresh randomness)', + 'ES256 signatures (randomized ECDSA k; not RFC 6979)', + ], + 'model': ( + 'The committed JSON is the frozen golden artifact. The ' + 'consumer test verifies these exact bytes against the SDK; ' + 'regeneration yields semantically identical, byte-different ' + 'vectors.' + ), + }, + 'vectors': [ + build_root_single(), + build_single_hop_payment(), + build_multi_hop_payment(), + build_single_hop_checkout(), + ], + } + + +def main() -> None: + """Regenerate the frozen vector JSON next to this module.""" + data = build_all() + OUT_PATH.write_text(json.dumps(data, indent=2, sort_keys=False) + '\n') + print(f'Wrote {len(data["vectors"])} vectors to {OUT_PATH}') + for v in data['vectors']: + tok = v['compact_serialization'] + print( + f' - {v["id"]}: joins={tok.count("~~")} ' + f'ends_tilde={tok.endswith("~")} len={len(tok)}' + ) + + +if __name__ == '__main__': + main()