跳到主要内容

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-JWTkb+sd-jwt+kb上一跳 cnf 指定的钥匙是(给下一跳)继续往下委托
终端闭 mandatekb+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_hashissuer_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.pyMandateClient.create present verify
根 SD-JWTcode/sdk/python/ap2/sdk/sdjwt/sd_jwt.pycreate verify
委托跳code/sdk/python/ap2/sdk/sdjwt/kb_sd_jwt.pycreate verify _resolve_delegate_payload
顺链验证code/sdk/python/ap2/sdk/sdjwt/chain.pyverify_chain X5cOrKidPublicKeyProvider
哈希/绑定/解析code/sdk/python/ap2/sdk/sdjwt/common.pycompute_binding verify_binding ParsedToken
选择性披露元数据code/sdk/python/ap2/sdk/disclosure_metadata.pyDisclosureMetadata sd_claims_to_disclose
取闭 mandate 叶子code/sdk/python/ap2/sdk/mandate.pyMandateClient.get_closed_mandate_jwt