签名票据的防护拦在哪一层:微软 AI Agent 入门课第 18 课
把 ai-agents-for-beginners 第 18 课那套签名票据接进自己的 agent,前半程通常很顺:抄 sign_receipt、抄 verify_receipt,跑通了,改一个字段验证就失败,看着挺踏实。麻烦出在后半程——你换一条攻击路径再试,票据全部验证通过,动作照样执行了;或者反过来,字段肉眼看没问题,验证却始终返回 False。
这两种情况都不是 bug,而是你把不同层的检查混成了一层。这一课的代码其实把拦截点分得很清楚,只是分界线写在注释和 README 的边角里。下面按排查顺序走一遍。
现象:三种”防住了”是三件不同的事
接入之后你可能遇到的现象大致三类:
一是自己写的签发端产出的票据,字段结构和课程示例一模一样,公钥也是对的,verify_receipt 就是返回 False;
二是单条票据验证通过,但把它们串成序列后,某一条报的问题不在签名上;
三是所有检查全绿,可执行的动作本身是错的——比如金额被改过、或者压根没人批准过。
三类现象对应三个完全不同的层,处置方式没有交集。
先拿仓库自带的 fixture 建基线
18-securing-ai-agents/code_samples/sample_receipts/ 下有三个预生成的 JSON,就是给你做基线用的,README.md 里的表格逐条写明了它们分别代表什么:01_valid_receipt.json 是一条 lookup_flights 工具调用的有效票据,02_tampered_receipt.json 是同一条票据在签名之后被改了一个字段,03_chain_three_receipts.json 是 search、hold、book 三条票据用 previous_receipt_hash 串起来的链。
这三个文件的数据形态值得先看清楚,因为它决定了你的判定动作有没有意义。生成脚本 generate_fixtures.py 顶部写死了两个常量:一个 Ed25519 私钥常量 FIXED_SK_HEX,注释逐字写着 Fixed key for byte-reproducible fixtures. NEVER reuse in production.;一个固定时间戳常量 FIXED_TS。脚本 docstring 说明这样做是为了输出 byte-reproducible。换句话说,这三个文件是可以当 golden 文件用的——你的实现如果对同一份 payload 算出不同的签名字节,那就是你的实现和课程的约定不一致,而不是随机性造成的。
02_tampered_receipt.json 的构造方式尤其要看代码而不是看描述。脚本里是 copy.deepcopy(valid) 之后把 policy_id 从 contoso-travel-policy-v3 改成 contoso-travel-policy-PERMISSIVE,signature 对象原封不动。所以这个文件和 01 的差异只有一个字段,其余包括 sig、public_key 全部相同——这是最干净的负样本,用来确认你的验证函数确实覆盖到了每个字段的字节。
顺带记一条:第 18 课的 README.md 在”检测篡改”那节说 notebook 会修改 tool_args_hash 字段,而 code_samples/18-signed-receipts.ipynb 第 2 节实际改的是 policy_id,02_tampered_receipt.json 改的也是 policy_id。两处对不上,以代码为准。
verify_receipt 的 False 有三个出口
排查现象一,关键是别把 False 当成一个结论。看 18-signed-receipts.ipynb 里 verify_receipt 的结构,它有三条不同的返回路径:
def verify_receipt(receipt: dict) -> bool:
sig_obj = receipt.get("signature")
if not sig_obj or sig_obj.get("alg") != "EdDSA":
return False
# Reconstruct the payload that was actually signed (everything except signature).
payload = {k: v for k, v in receipt.items() if k != "signature"}
canonical = canonicalize(payload)
try:
verify_key = signing.VerifyKey(b64url_decode(sig_obj["public_key"]))
verify_key.verify(canonical, b64url_decode(sig_obj["sig"]))
return True
except BadSignatureError:
return False
except Exception as exc:
print(f"Verification error: {exc}")
return False
判定动作很直接:看控制台有没有 Verification error: 这一行。有,说明走的是最后那个宽泛的 except,问题在输入本身(base64 解码、公钥长度这类),不是签名对不上;没有,且 alg 确实是 EdDSA,那才是 BadSignatureError,即签名与字节不匹配。
字节不匹配最常见的原因,课程在同一个单元格里给了负控。notebook 在验证通过之后紧接着构造了一条 prehashed_receipt:先对 canonicalize(payload) 做一次 SHA-256,再拿摘要去签名,然后再验证一次。单元格下方的说明文字写明你应当看到 Receipt is valid: True 和 Pre-hashed receipt valid: False,并说这条负控是把签名范围的规则变成可执行的检查,而不是留在文字里。
这里要补一句限定。那条负控的注释写的是 Regression control for the signature scope in draft revision 02,指向一份 IETF Internet-Draft;而第 18 课 README.md 的延伸阅读那节自己写明,这一课用的扁平票据结构与该草案的 {payload, signature} 信封并不相同,不作为符合该草案的实现呈现,草案另有一套公开的一致性测试向量供对齐其 wire format 的实现使用。换句话说,你在这里学到的字段布局是教学用形态,不是可以拿去对外声称兼容某标准的格式;草案本身还在 Internet-Draft 阶段,修订之间会变。
这条对应的处置是:签名对象签的是 canonicalize(payload) 的字节本身,不做额外预哈希。sign_receipt 的 docstring 也写了 The 'signature' and 'public_key' fields are NOT part of the canonical signed bytes.。所以两个高频写错的点是——签之前多做了一次哈希,或者把 signature 对象也算进了 canonical 字节。另外注意 canonical 用的是 jcs 包的 canonicalize(RFC 8785),换成 json.dumps 得到的字节顺序与空白处理都可能不同。
链层和签名层要分开看
现象二在 verify_chain 里有现成的分层输出。这个函数对每条票据返回一个 dict,里面 signature_valid、chain_link_valid、sequence_valid 三个键各自独立,overall_valid 才是三者相与。docstring 写明三条检查分别是:签名必须验过、除创世那条外 previous_receipt_hash 必须等于前一条的哈希、sequence 必须等于它在链里的零基下标。
notebook 第 3 节末尾演示的破坏方式是只改中间那条的 tool_args_hash,随后的说明写明结果是:第 0 条仍然通过;第 1 条签名检查失败;第 2 条是链接检查失败,因为它的 previous_receipt_hash 是对改动前的第 1 条算的。也就是说,一次篡改会在两个不同的键上留下痕迹,而这两个键指向的处置完全不同。
这里有个实现上容易踩的坑:链哈希和内容哈希用的不是同一个函数。receipt_hash 的 docstring 写明它计算的是**完整票据(含 signature)**的哈希,而 sha256_canonical 用于 tool_args 和 tool_result 这类内容摘要。如果你在拼链时对不含 signature 的 payload 取哈希,得到的表现就是签名层全绿、链接层从第二条开始全红——这是一个很好认的指纹。
真正拦不住的那层
现象三才是这一课花了最大篇幅讲的部分。README 有一节专门列出票据不能证明什么:动作是否正确、policy_id 指的那条策略是否真的被求值过、密钥背后是不是某个具体的人或组织、输入本身是否可信。原话的意思是票据记录的是”声称了什么”,不是”执行了什么”。
顺着代码看会更具体。第 18 课主 notebook 的 verify_receipt 用的是票据内嵌的 public_key 去验签——这意味着任何人拿自己的密钥重签一整份 payload,也能让这个函数返回 True。它证明的是”这份内容自签名以来没被改过”,不是”这是你们家 agent 签的”。
同目录下的 human-authorization-receipts.ipynb 补的正是这一层。它引入了两个固定的密钥注册表 APPROVER_KEYS 与 AGENT_KEYS,注释逐字写着验证方 NEVER trusts a key carried inside a receipt,verify_envelope 通过签名对象里的 key_id 去注册表查公钥,查不到就直接以 stale authority 为由拒绝。它的 verify_chain 在信封验过之后还要再过四道:审批与执行两份票据必须绑定同一个 action_digest(不一致时报 digest substitution)、agent 那份的 parent_approval_ref 必须等于审批票据的 receipt_hash、审批的 policy_version 必须等于 CURRENT_POLICY、expires_at 必须晚于执行时刻;最后用一个 _consumed 集合做一次性消费,重放会被以 approval already consumed (replay refused) 拒绝。
notebook 的说明里把这条规则写得很明确:签名有效的审批本身不等于授权,只有两份票据在执行时刻仍然绑定同一份规范化动作,授权才成立。同一份说明也标注了 human.approval.v1 是这一课定义的教学用组合,不是 draft-farley-acta-signed-receipts 里定义的票据类型。
处置后怎么验证
sample_receipts/README.md 给了一段不跑 notebook 叙事、直接验三个 fixture 的片段,注释里标了每一行的预期:
valid = json.loads(Path("01_valid_receipt.json").read_text())
print(f"Valid receipt: {verify_receipt(valid)}") # True
tampered = json.loads(Path("02_tampered_receipt.json").read_text())
print(f"Tampered receipt: {verify_receipt(tampered)}") # False
它前面写明这段假定你已经完成了 notebook 第 1、2 节的导入与辅助函数。三个 fixture 加上前面那条 prehashed 负控,四个点位就能把签名层的行为钉住;链层再用 03_chain_three_receipts.json 过一遍 verify_chain,逐条看三个布尔键而不是只看 overall_valid。
重新生成 fixture 的命令 README 写的是 python3 generate_fixtures.py。Windows 上默认没有 python3 这个可执行名,通常要换成 python generate_fixtures.py,或者用 Python 官方安装包附带的启动器 py generate_fixtures.py;Linux 与 macOS 直接照抄即可。这一条属于通用环境差异,不是课程官方内容。依赖看 code_samples/requirements.txt,里面只有 pynacl(Ed25519,libsodium 绑定)、jcs(RFC 8785 规范化 JSON)和运行 notebook 用的 ipykernel,都写了下限版本约束。
以上片段均为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
什么情况说明不是这个原因
几条反向判据,避免你在错的层里挖:
- 控制台打出了
Verification error:——不是篡改,是输入本身坏了,回去检查 base64 串和公钥长度,别去动签名范围。 signature_valid全是True、只有chain_link_valid从某条起变False——签名层没问题,去看链哈希是不是对含signature的完整票据算的。sequence_valid单独为False——那只是序号与数组下标对不上,和密码学无关,verify_chain的 docstring 把它列为独立的第三项检查。- 自己算的哈希和 fixture 里的对不上,未必是实现错了。fixture 的
03里第一条lookup_flights的参数只有origin与destination,而 notebook 第 3 节同一条多了一个日期字段;01的结果是两条航班,notebook 第 1 节是三条。加上 fixture 用固定时间戳、notebook 用当前时间,摘要本来就不会一样。要比就拿 fixture 文件自己比。 - 三层检查全绿、业务结果仍然是错的——按 README 那节的说法,这不在票据的能力范围内,该找的是输入校验、策略执行和身份基础设施,继续在签名层排查是白费力气。
最后提醒一句:generate_fixtures.py 里那个写死的私钥是给可复现 fixture 用的,脚本注释自己写了不要在生产里复用;真接进项目时,签名密钥的存放位置属于第 18 课”生产检查清单”那一节讨论的范围,和这里的排查是两件事。
本文依据 github.com/microsoft/ai-agents-for-beginners 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码,
因此不涉及运行结果、耗时与 Agent 实际表现的任何描述。
该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。
文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。