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
5 changes: 5 additions & 0 deletions .cspell/custom-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,8 @@ XVCJ
Yapily
Zalopay
Zalora
autonumber
pyca
sdjwt
SDJWT
SECP
6 changes: 3 additions & 3 deletions code/sdk/python/ap2/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ Selective-disclosure annotations on the models:

A dSD-JWT chain has arbitrary depth. Hops are joined by `~~`:

```
```text
<root_SD-JWT>~<disc…>~~<KB-SD-JWT+KB_1>~<disc…>~~…~~<closed_KB-SD-JWT>~<disc…>~
```

Expand All @@ -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
Expand All @@ -157,7 +157,7 @@ further delegation possible), `kb+sd-jwt` otherwise (closed, terminal).

## Trust chain

```
```text
Root issuer
│ signs
Expand Down
6 changes: 6 additions & 0 deletions code/sdk/python/ap2/sdk/sdjwt/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}',"
Expand Down
15 changes: 7 additions & 8 deletions code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,17 +121,16 @@ 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)
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")
Expand Down
66 changes: 65 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 @@ -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,
Expand Down Expand Up @@ -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)

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