Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .cspell/custom-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,10 @@ XVCJ
Yapily
Zalopay
Zalora
sdjwt
SDJWT
SECP
keypair
normalises
pyca
replayable
30 changes: 24 additions & 6 deletions code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,31 @@ def verify(
# them into inline dicts so the cnf check below works correctly.
_resolve_delegate_payload(payload, token)
common.verify_binding(payload, prev_token)
# 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',
)
if typ in TYP_TERMINAL:
common.verify_expected_claims(
payload,
expected_aud=expected_aud,
expected_nonce=expected_nonce,
token_label='KB-SD-JWT',
)
# 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")
Expand Down
4 changes: 4 additions & 0 deletions code/sdk/python/ap2/tests/chain_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions code/sdk/python/ap2/tests/checkout_mandate_chain_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 31 additions & 1 deletion code/sdk/python/ap2/tests/kb_sd_jwt_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 ───────────────────────────────────────────────────────
Expand Down
6 changes: 6 additions & 0 deletions code/sdk/python/ap2/tests/payment_mandate_chain_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Expand Down
Loading