数据截至 (上游 commit e1ea56db72a6)
AP2 — SD-JWT 委托链(SDK 的心脏)
这一章讲 SDK 怎么把上一章的「开 → 闭」委托,落成一条真实的密码学链。读完你能看懂
verify_chain为什么能「只信任一个根钥匙,就把整条任意深度的委托链验下来」。
1. 它要解决的小问题
上一章说:用户签开 mandate(带约束 + cnf),agent 在上面追加签闭 mandate。怎么把这个「在别人签的东西上面再签一层、还能被验」做成密码学?
AP2 用的是 SD-JWT 家族:
- SD-JWT(RFC 9901):一种可以选择性披露字段的 JWT——签发时把某些字段换成哈希,持有者出示时自己决定揭开哪些。
- KB-SD-JWT(Key Binding):在 SD-JWT 后面附一段「持有者用自己钥匙签的证明」,证明「我就是
cnf里指定的那个持有者」,并绑定aud/nonce/对前一跳的哈希。 - dSD-JWT(delegate,草案 draft-gco-oauth-delegate-sd-jwt-00):把上面两者串成任意深度的委托链。
一句话直觉:SD-JWT 管「露多少」,Key Binding 管「我是谁、我同意这笔」,链管「一层层往下转授权」。
2. 链长什么样(线格式)
一条 dSD-JWT 链用 ~~ 把各跳拼起来(code/sdk/python/ap2/sdk/README.md:134-137):
<根 SD-JWT>~<披露…>~~<KB-SD-JWT+KB_1>~<披露…>~~…~~<闭 KB-SD-JWT>~<披露…>~
三种段落各司其职:
| 段落 | typ | 谁签 | 含 cnf? | 作用 |
|---|---|---|---|---|
| 根 SD-JWT | (无特定 typ) | 信任根(用户/银行) | 是 | 链的起点,验证方唯一信任的钥匙在这验 |
| 中间跳 KB-SD-JWT | kb+sd-jwt+kb | 上一跳 cnf 指定的钥匙 | 是(给下一跳) | 继续往下委托 |
| 终端闭 mandate | kb+sd-jwt | 最后一个委托方 | 否 | 绑死交易,带 aud/nonce |
typ 的选择规则非常直接:payload 里有 cnf 就是中间跳(kb+sd-jwt+kb),没有就是终端(kb+sd-jwt)(code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py:79-80)。
3. 三个原语,三个动作
SDK 把链拆成三个原语模块,门面 MandateClient 调度它们:
MandateClient.create() ─▶ sd_jwt.create() # 签根:第一跳
MandateClient.present() ─▶ kb_sd_jwt.create() # 追加一跳:中间或终端
MandateClient.verify() ─▶ chain.verify_chain() # 顺链全验
3.1 签根:sd_jwt.create
思路: 把 Pydantic payload 包进一个 delegate_payload 数组里再签。为什么要包一层?这样根和后续每一跳的解析逻辑完全一致(code/sdk/python/ap2/sdk/sdjwt/sd_jwt.py:31-34)。根不带 sd_hash/aud/nonce——那些是 KB-SD-JWT 才有的东西。
3.2 追加一跳:kb_sd_jwt.create
这是委托的关键动作。每一跳要做三件事(code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py:35-91):
# 示意,非源码:一跳 KB-SD-JWT 的核心逻辑
binding_claim, binding_value = compute_binding(prev_token, hash_mode) # 对前一跳算哈希
extra = {
"iat": now, "aud": aud, "nonce": nonce, # 谁、给谁、防重放
binding_claim: binding_value, # sd_hash 或 issuer_jwt_hash
}
has_cnf = "cnf" in delegate_claims # payload 带 cnf → 还能往下委托
typ = "kb+sd-jwt" if not has_cnf else "kb+sd-jwt+kb"
# 用 holder_key(=上一跳 cnf 指定的钥匙)签名
重点看 compute_binding——它把这一跳「钉」在前一跳上。两种模式(code/sdk/python/ap2/sdk/sdjwt/common.py:185-195):
sd_hash(默认):对前一跳的 JWT + 披露 一起算哈希。锁死前一跳露了哪些字段,下游不能再删披露。issuer_jwt_hash:只对前一跳的 issuer JWT 算哈希,不含披露。于是下游可以继续删披露做隐私最小化,链照样完整。
这是一个很漂亮的设计点:同一条链上,每一跳可以自己决定「我允不允许下游继续脱敏」,通过选
sd_hash(锁死)还是issuer_jwt_hash(放行)(README.md:59-72)。
3.3 顺链全验:chain.verify_chain
这是整个 SDK 最该懂的函数。 它怎么做到「只信一个根钥匙就验完任意深度的链」?靠顺着 cnf 走(code/sdk/python/ap2/sdk/sdjwt/chain.py:121-180):
验证方信任的钥匙(只有这 一把)
│
▼ 验根
┌─────────────┐ cnf.jwk = K1
│ 根 SD-JWT │───────────────┐
└─────────────┘ ▼ 用 K1 验下一跳
┌─────────────┐ cnf.jwk = K2
│ KB-SD-JWT #1 │───────────┐
└─────────────┘ ▼ 用 K2 验下一跳
┌─────────────┐
│ 闭 KB-SD-JWT │ 无 cnf → 终端
└─────────────┘
额外查 aud / nonce
核心循环只有几行(code/sdk/python/ap2/sdk/sdjwt/chain.py:148-178):
# 示意,非源码:verify_chain 的骨架
current_key = public_key_provider(root_token) # 唯一信任的根钥匙
root_payload = sd_jwt.verify(root_token, current_key)
root_token = root_token.with_verified_payload(...) # 把验过的 payload 存回 token
for i, token in enumerate(tokens[1:], start=1):
is_last = (i == len(tokens) - 1)
payload = kb_sd_jwt.verify(
token, prev_token=tokens[i-1], # 用「上一跳的 cnf.jwk」验本跳
expected_aud=aud if is_last else None, # aud/nonce 只在终端查
expected_nonce=nonce if is_last else None,
)
关键点:第 i 跳的验证钥匙,来自第 i-1 跳 payload 里的 cnf.jwk(code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py:118,prev_token.cnf_jwk())。而 cnf.jwk 只有在前一跳验签并解开披露之后才拿得到,所以代码用 with_verified_payload 把验过的状态存回 ParsedToken 再往下走(chain.py:155-157 那段注释专门解释了这个时序)。
4. 关键细节与坑
4.1 终端跳必须无 cnf,中间跳必须有
kb_sd_jwt.verify 末尾有一对硬校验(code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py:135-139):
# code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py:136-139
if typ in TYP_TERMINAL and has_cnf:
raise ValueError("Terminal KB-SD-JWT MUST NOT carry a 'cnf' claim")
if typ in TYP_INTERMEDIATE and not has_cnf:
raise ValueError(f"Intermediate {typ} requires a 'cnf' claim")
为什么重要: 终端如果还带 cnf,等于「这张闭 mandate 还能继续转授权」,那就破坏了「闭=终点」的语义。这条校验把它堵死。
4.2 绑定哈希必须恰好一个
verify_binding 强制 sd_hash 和 issuer_jwt_hash 有且仅有一个(code/sdk/python/ap2/sdk/sdjwt/common.py:198-207):两个都有或都没有都报错。这防止「既声称锁死披露、又声称放行」的歧义攻击。
4.3 aud/nonce 只在终端跳查
中间跳传 expected_aud=None(chain.py:167)——它们是「转授权」不是「最终成交」,只有终端那跳代表「向这个验证方成交」,才需要核对受众和防重放 nonce。MandateClient.verify 还有一道前置:链里带 KB-JWT 却没给 aud+nonce 直接拒(code/sdk/python/ap2/sdk/mandate.py:227-234)。
4.4 选择性披露的「揭/不揭」在 present 里控
present(..., claims_to_disclose=):None=全揭,{}=全不揭,dict=按名揭(README.md:92-93)。注意一个细节:当调用方过滤披露时,代码会对「过滤后实际发出的那份 token」重算 sd_hash,而不是对原始全披露 token(code/sdk/python/ap2/sdk/mandate.py:346-356 那段注释专门讲了这个坑)——否则下游重算哈希会对不上。
4.5 兼容 CMWallet 的非标准格式
kb_sd_jwt._resolve_delegate_payload 处理一种现实里的格式:有些钱包(注释点名 CMWallet)把 mandate 承诺直接以 _sd 风格的哈希串塞进 delegate_payload,而不是标准的顶层 _sd 数组。SDK 会把它们规范化成内联 dict 再验 cnf(code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py:122-126,181-205)。这是「写给真实生态、不只对着 RFC」的务实痕迹。
5. 这套实现对草案的有意偏离
SDK README 明说了一处与 draft-gco-oauth-delegate-sd-jwt-00 的有意不同(README.md:73-80):
不发 dSD-JWT+KB 形态。 AP2 总是用一个
typ=kb+sd-jwt的 KB-SD-JWT 收尾,binding/transaction 声明就放在它自己的 payload 里(因为 KB-SD-JWT 本身就是 KB-JWT)。那种「外层再挂一个独立尾随 plain KB-JWT」的形态既不发也不收。
知道这点对读源码很重要:你不会在终端跳后面找到第二个独立的 KB-JWT。
6. 代码地图
| 主题 | 文件 | 符号名 |
|---|---|---|
| 门面三动作 | code/sdk/python/ap2/sdk/mandate.py | MandateClient.create present verify |
| 根 SD-JWT | code/sdk/python/ap2/sdk/sdjwt/sd_jwt.py | create verify |
| 委托跳 | code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.py | create verify _resolve_delegate_payload |
| 顺链验证 | code/sdk/python/ap2/sdk/sdjwt/chain.py | verify_chain X5cOrKidPublicKeyProvider |
| 哈希/绑定/解析 | code/sdk/python/ap2/sdk/sdjwt/common.py | compute_binding verify_binding ParsedToken |
| 选择性披露元数据 | code/sdk/python/ap2/sdk/disclosure_metadata.py | DisclosureMetadata sd_claims_to_disclose |
| 取闭 mandate 叶子 | code/sdk/python/ap2/sdk/mandate.py | MandateClient.get_closed_mandate_jwt |