From bd65e2eb93e67ee60965e3ac9afef8f3ac5a90ef Mon Sep 17 00:00:00 2001 From: Silent Partner <179998047+Silentpartnercoding@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:15:09 -0700 Subject: [PATCH 1/4] fix(sdjwt): require terminal aud and nonce claims --- code/sdk/python/ap2/sdk/README.md | 6 +- code/sdk/python/ap2/sdk/sdjwt/common.py | 6 ++ code/sdk/python/ap2/tests/kb_sd_jwt_tests.py | 66 +++++++++++++++++++- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/code/sdk/python/ap2/sdk/README.md b/code/sdk/python/ap2/sdk/README.md index 8dbc9280..00a83683 100644 --- a/code/sdk/python/ap2/sdk/README.md +++ b/code/sdk/python/ap2/sdk/README.md @@ -133,7 +133,7 @@ Selective-disclosure annotations on the models: A dSD-JWT chain has arbitrary depth. Hops are joined by `~~`: -``` +```text ~~~~~~…~~~~ ``` @@ -146,7 +146,7 @@ A dSD-JWT chain has arbitrary depth. Hops are joined by `~~`: - **Closed mandate (leaf)** (`typ=kb+sd-jwt`) — final KB-SD-JWT with a `PaymentMandate` or `CheckoutMandate` payload and no outgoing `cnf`. Binds to the preceding hop via `sd_hash` or `issuer_jwt_hash`, and - carries `iat` plus (optionally) `aud`/`nonce`. + carries `iat`, `aud`, and `nonce`. A KB-SD-JWT *is* a KB-JWT (draft §5.1.4), so the binding/transaction claims live in its payload — AP2 does not emit the dSD-JWT+KB variant @@ -157,7 +157,7 @@ further delegation possible), `kb+sd-jwt` otherwise (closed, terminal). ## Trust chain -``` +```text Root issuer │ signs ▼ diff --git a/code/sdk/python/ap2/sdk/sdjwt/common.py b/code/sdk/python/ap2/sdk/sdjwt/common.py index cdea78ae..04fe9ef5 100644 --- a/code/sdk/python/ap2/sdk/sdjwt/common.py +++ b/code/sdk/python/ap2/sdk/sdjwt/common.py @@ -299,6 +299,12 @@ def verify_expected_claims( """Validate common KB-SD-JWT claims after signature verification.""" if 'iat' not in payload: raise ValueError(f"{token_label} missing required 'iat' claim") + for claim in ('aud', 'nonce'): + value = payload.get(claim) + if not isinstance(value, str) or not value: + raise ValueError( + f"{token_label} missing required non-empty '{claim}' claim" + ) if expected_aud is not None and payload.get('aud') != expected_aud: raise ValueError( f"{token_label} aud mismatch: expected '{expected_aud}'," diff --git a/code/sdk/python/ap2/tests/kb_sd_jwt_tests.py b/code/sdk/python/ap2/tests/kb_sd_jwt_tests.py index 7f0dc80f..71f97d8c 100644 --- a/code/sdk/python/ap2/tests/kb_sd_jwt_tests.py +++ b/code/sdk/python/ap2/tests/kb_sd_jwt_tests.py @@ -8,11 +8,14 @@ import base64 import json +import time import pytest +from ap2.sdk.disclosure_metadata import DisclosureMetadata from ap2.sdk.generated.open_payment_mandate import OpenPaymentMandate from ap2.sdk.sdjwt import ( + common, compute_issuer_jwt_hash, compute_sd_hash, kb_sd_jwt, @@ -62,11 +65,38 @@ def _root_open(issuer_key, holder_jwk) -> str: ).sd_jwt_issuance +def _terminal_with_claims( + prev_token, + holder_key, + *, + typ='kb+sd-jwt', + **extra_claims, +) -> str: + """Sign an otherwise-valid terminal hop with selected binding claims.""" + payload = sample_payment_mandate() + claims = common.selectively_disclosable_claims( + common.delegate_claims_from_model(payload), + DisclosureMetadata.from_model(payload), + { + 'iat': int(time.time()), + 'sd_hash': compute_sd_hash(parse_token(prev_token)), + **extra_claims, + }, + ) + return common.issue_sd_jwt( + claims=claims, + issuer_key=holder_key, + header_params=common.header_parameters(holder_key, typ), + add_decoy_claims=False, + serialization_format='compact', + ).sd_jwt_issuance + + # ── Happy paths ────────────────────────────────────────────────────────── def test_create_sets_typ_and_binding(issuer_key): - """Header typ=kb+sd-jwt; payload has iat + sd_hash (aud/nonce optional).""" + """Header typ=kb+sd-jwt; payload has required transaction bindings.""" holder = JWK.generate(kty='EC', crv='P-256') prev = _root_open(issuer_key, holder) @@ -248,3 +278,37 @@ def test_verify_rejects_nonce_mismatch(issuer_key): expected_aud='a', expected_nonce='wrong', ) + + +@pytest.mark.parametrize('typ', kb_sd_jwt.TYP_TERMINAL) +def test_verify_rejects_missing_aud_without_expected_value(issuer_key, typ): + holder = JWK.generate(kty='EC', crv='P-256') + prev = _root_open(issuer_key, holder) + token = _terminal_with_claims(prev, holder, typ=typ, nonce='n') + + with pytest.raises(ValueError, match="missing required non-empty 'aud'"): + _verify(token, prev, issuer_key) + + +@pytest.mark.parametrize('typ', kb_sd_jwt.TYP_TERMINAL) +def test_verify_rejects_missing_nonce_without_expected_value(issuer_key, typ): + holder = JWK.generate(kty='EC', crv='P-256') + prev = _root_open(issuer_key, holder) + token = _terminal_with_claims(prev, holder, typ=typ, aud='a') + + with pytest.raises(ValueError, match="missing required non-empty 'nonce'"): + _verify(token, prev, issuer_key) + + +@pytest.mark.parametrize('claim', ['aud', 'nonce']) +def test_verify_rejects_empty_required_claim(issuer_key, claim): + holder = JWK.generate(kty='EC', crv='P-256') + prev = _root_open(issuer_key, holder) + claims = {'aud': 'a', 'nonce': 'n'} + claims[claim] = '' + token = _terminal_with_claims(prev, holder, **claims) + + with pytest.raises( + ValueError, match=rf"missing required non-empty '{claim}'" + ): + _verify(token, prev, issuer_key) From f3e070707851e55bf92753c1b75365c735633f2c Mon Sep 17 00:00:00 2001 From: Silent Partner <179998047+Silentpartnercoding@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:28:42 -0700 Subject: [PATCH 2/4] chore: register existing SDK spellcheck terms --- .cspell/custom-words.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index ce73c361..8f9ffd46 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -185,3 +185,8 @@ XVCJ Yapily Zalopay Zalora +autonumber +pyca +sdjwt +SDJWT +SECP From eaab4c544e47b4d1f3d2084aae616421e1c7a7ad Mon Sep 17 00:00:00 2001 From: Silent Partner <179998047+Silentpartnercoding@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:28:08 -0700 Subject: [PATCH 3/4] fix(sdjwt): verify intermediate transaction bindings --- code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py b/code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py index 35c709b3..3d8e38ce 100644 --- a/code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py +++ b/code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py @@ -125,13 +125,12 @@ def verify( # them into inline dicts so the cnf check below works correctly. _resolve_delegate_payload(payload, token) common.verify_binding(payload, prev_token) - if typ in TYP_TERMINAL: - common.verify_expected_claims( - payload, - expected_aud=expected_aud, - expected_nonce=expected_nonce, - token_label='KB-SD-JWT', - ) + common.verify_expected_claims( + payload, + expected_aud=expected_aud, + expected_nonce=expected_nonce, + token_label='KB-SD-JWT', + ) has_cnf = _delegate_payload_has_cnf(payload) if typ in TYP_TERMINAL and has_cnf: raise ValueError("Terminal KB-SD-JWT MUST NOT carry a 'cnf' claim") From 8c8384c6212ea5e2ad15fe93f1624947b56f5033 Mon Sep 17 00:00:00 2001 From: Silent Partner <179998047+Silentpartnercoding@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:30:47 -0700 Subject: [PATCH 4/4] chore: use registered spelling in SD-JWT comment --- code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py b/code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py index 3d8e38ce..1260bc9b 100644 --- a/code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py +++ b/code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py @@ -121,7 +121,7 @@ def verify( payload = sd_jwt_verify(token.canonical, prev_key) # Resolve SD-JWT digests in delegate_payload against token disclosures. # CMWallet places mandate commitment digests directly in delegate_payload - # rather than via a standard top-level _sd array; this step normalises + # rather than via a standard top-level _sd array; this step normalizes # them into inline dicts so the cnf check below works correctly. _resolve_delegate_payload(payload, token) common.verify_binding(payload, prev_token)