From ee37922562ef14505666b5bfcbc8977435765fc5 Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Mon, 10 Aug 2026 18:06:47 +0700 Subject: [PATCH 1/2] fix(sdjwt): honor expected aud/nonce on every KB hop verify() only called verify_expected_claims under if typ in TYP_TERMINAL, so on an intermediate hop (typ kb+sd-jwt+kb) a caller that passes expected_aud or expected_nonce got no check at all. That is why test_verify_rejects_aud_mismatch and test_verify_rejects_nonce_mismatch fail on main. Move the check out of the terminal guard so it runs on every hop. Matches the verify() docstring (expected match is a general check) and create() (which already requires aud and nonce on every hop). Mandatory presence of expected_aud/expected_nonce when a terminal hop carries those claims is intentionally deferred to a follow-up so this change can land as a clean green baseline for #325. Signed-off-by: SashaMIT --- .cspell/custom-words.txt | 2 ++ code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py | 17 ++++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index ce73c361..cd6dd6fa 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -185,3 +185,5 @@ XVCJ Yapily Zalopay Zalora +sdjwt +SDJWT 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..6773acdf 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,16 @@ 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', - ) + # aud/nonce checks apply to every hop when the caller binds them, not just + # the terminal one: an intermediate hop presented to the wrong recipient + # must fail too. (Mandatory presence of expected_aud/expected_nonce on + # terminal hops that carry those claims is a separate follow-up.) + 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 aeb621d76d50b16e69804857ff5f2a9e72cd773c Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Mon, 10 Aug 2026 18:07:06 +0700 Subject: [PATCH 2/2] fix(sdjwt): require expected aud/nonce when a terminal KB hop carries them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #313. A terminal hop that carries aud/nonce but is verified without expected_aud/expected_nonce previously passed silently. Per RFC 9901 §7.3 a verifier MUST confirm a key-binding token's aud identifies itself; the AP2 profile always issues terminal hops with aud/nonce and exp is optional, so an unbound verifier turned every captured presentation into a replayable bearer credential. This is a deliberate behaviour change for existing callers that verified terminal presentations without binding those claims. Compatibility note: call sites must now pass expected_aud/expected_nonce when verifying a terminal hop that carries them. Signed-off-by: SashaMIT --- .cspell/custom-words.txt | 5 +++ code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py | 15 +++++++++ code/sdk/python/ap2/tests/chain_tests.py | 4 +++ .../ap2/tests/checkout_mandate_chain_tests.py | 2 ++ code/sdk/python/ap2/tests/kb_sd_jwt_tests.py | 32 ++++++++++++++++++- .../ap2/tests/payment_mandate_chain_tests.py | 6 ++++ 6 files changed, 63 insertions(+), 1 deletion(-) diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index cd6dd6fa..d31d2488 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -187,3 +187,8 @@ Zalopay Zalora sdjwt SDJWT +SECP +keypair +normalises +pyca +replayable 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 6773acdf..e65dadb8 100644 --- a/code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py +++ b/code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py @@ -135,6 +135,21 @@ def verify( expected_nonce=expected_nonce, token_label='KB-SD-JWT', ) + if typ in TYP_TERMINAL: + # Per RFC 9901 §7.3 a verifier MUST confirm a key-binding token's aud + # identifies itself, and the AP2 profile always issues terminal hops + # with aud/nonce. Accepting such a token without binding it turns + # every captured presentation into a replayable bearer credential. + if 'aud' in payload and expected_aud is None: + raise ValueError( + "Terminal KB-SD-JWT carries 'aud'; the verifier must pass " + 'expected_aud' + ) + if 'nonce' in payload and expected_nonce is None: + raise ValueError( + "Terminal KB-SD-JWT carries 'nonce'; the verifier must pass " + 'expected_nonce' + ) 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") diff --git a/code/sdk/python/ap2/tests/chain_tests.py b/code/sdk/python/ap2/tests/chain_tests.py index 3f3ba2fd..cf039e15 100644 --- a/code/sdk/python/ap2/tests/chain_tests.py +++ b/code/sdk/python/ap2/tests/chain_tests.py @@ -222,6 +222,8 @@ def test_delegation_chain_cnf_binding( payloads = holder.verify( token=tok_chain, key_or_provider=lambda _token: user_public_key, + expected_aud='merchant', + expected_nonce='merchant-nonce', ) assert len(payloads) == 2 assert payloads[0]['vct'] == 'mandate.payment.open.1' @@ -374,6 +376,8 @@ def test_delegation_chain_selective_disclosure( payloads = client.verify( token=presentation_token, key_or_provider=lambda _token: user_public_key, + expected_aud='merchant', + expected_nonce='merchant-nonce', ) assert len(payloads) == 2 diff --git a/code/sdk/python/ap2/tests/checkout_mandate_chain_tests.py b/code/sdk/python/ap2/tests/checkout_mandate_chain_tests.py index 6e77ec25..87a98a5a 100644 --- a/code/sdk/python/ap2/tests/checkout_mandate_chain_tests.py +++ b/code/sdk/python/ap2/tests/checkout_mandate_chain_tests.py @@ -110,6 +110,8 @@ def test_full_checkout_end_to_end( payloads = holder.verify( token=tok_chain, key_or_provider=lambda _token: user_public_key, + expected_aud='merchant', + expected_nonce='merchant-nonce', ) chain = CheckoutMandateChain.parse(payloads) violations = chain.verify(checkout_jwt=checkout_jwt) 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..b02e79c9 100644 --- a/code/sdk/python/ap2/tests/kb_sd_jwt_tests.py +++ b/code/sdk/python/ap2/tests/kb_sd_jwt_tests.py @@ -137,6 +137,36 @@ def test_verify_accepts_valid_hop(issuer_key): ) +def test_verify_rejects_terminal_aud_left_unbound(issuer_key): + """A terminal hop carrying aud MUST NOT verify without expected_aud.""" + holder = JWK.generate(kty='EC', crv='P-256') + prev = _root_open(issuer_key, holder) + result = _create( + prev_token=prev, + holder_key=holder, + payload=sample_payment_mandate(), + aud='a', + nonce='n', + ) + with pytest.raises(ValueError, match="carries 'aud'"): + _verify(result.sd_jwt_issuance, prev, issuer_key, expected_nonce='n') + + +def test_verify_rejects_terminal_nonce_left_unbound(issuer_key): + """A terminal hop carrying nonce MUST NOT verify without expected_nonce.""" + holder = JWK.generate(kty='EC', crv='P-256') + prev = _root_open(issuer_key, holder) + result = _create( + prev_token=prev, + holder_key=holder, + payload=sample_payment_mandate(), + aud='a', + nonce='n', + ) + with pytest.raises(ValueError, match="carries 'nonce'"): + _verify(result.sd_jwt_issuance, prev, issuer_key, expected_aud='a') + + def test_verify_accepts_issuer_jwt_hash_mode(issuer_key): holder = JWK.generate(kty='EC', crv='P-256') prev = _root_open(issuer_key, holder) @@ -148,7 +178,7 @@ def test_verify_accepts_issuer_jwt_hash_mode(issuer_key): nonce='n', hash_mode='issuer_jwt_hash', ) - _verify(result.sd_jwt_issuance, prev, issuer_key) + _verify(result.sd_jwt_issuance, prev, issuer_key, expected_aud='a', expected_nonce='n') # ── Negative cases ─────────────────────────────────────────────────────── diff --git a/code/sdk/python/ap2/tests/payment_mandate_chain_tests.py b/code/sdk/python/ap2/tests/payment_mandate_chain_tests.py index e1362b00..16343b29 100644 --- a/code/sdk/python/ap2/tests/payment_mandate_chain_tests.py +++ b/code/sdk/python/ap2/tests/payment_mandate_chain_tests.py @@ -47,6 +47,8 @@ def test_payment_chain_constraint_violation( payloads = holder.verify( token=tok_chain, key_or_provider=lambda _token: user_public_key, + expected_aud='merchant', + expected_nonce='merchant-nonce', ) chain = PaymentMandateChain.parse(payloads) violations = chain.verify() @@ -80,6 +82,8 @@ def test_payment_chain_transaction_id_mismatch( payloads = holder.verify( token=tok_chain, key_or_provider=lambda _token: user_public_key, + expected_aud='merchant', + expected_nonce='merchant-nonce', ) chain = PaymentMandateChain.parse(payloads) @@ -115,6 +119,8 @@ def test_full_payment_end_to_end( payloads = holder.verify( token=tok_chain, key_or_provider=lambda _token: user_public_key, + expected_aud='merchant', + expected_nonce='merchant-nonce', ) chain = PaymentMandateChain.parse(payloads) violations = chain.verify()