这篇适合谁,读完能做什么
适合要让智能体处理订票、退款、转账这类有后果的操作,并且事后要能向审计方证明「智能体做了什么」的开发者。读完你应该能用 Python 为一次工具调用生成签名收据,用公钥离线验证它,并用哈希链把多步操作串起来。同时要分清,收据证明了什么,又没有证明什么。[1]
智能体的审计日志为什么不够
设想一家旅行公司的智能体替用户查航班、订座位,每天处理大量订单。审计人员来了,先问「给我看看你的智能体做了什么」,接着问「你怎么知道这些日志没有被改过」。[1]
常见的做法都有缺口:[1]
- 应用日志由智能体自己写,任何能访问文件系统的人都能改。
- 云端日志服务有一定的防篡改能力,但前提是审计方信任平台运营方。
- 数据库事务日志适合记录数据库的变更,对任意的工具调用就不够用了。
对内部使用,这种信任也许可以接受。但对金融、医疗,或者受欧盟《人工智能法案》约束的场景,单靠对运营方的信任是不够的。[1]
什么是密码学收据
收据(receipt)是一个 JSON 对象,记录智能体做了什么,并附上数字签名。下面是一张最简的收据,包含调用的工具、参数和结果的哈希、策略编号、时间戳、序号、前一张收据的哈希,以及签名:[1]
{
"type": "agent.tool_call.v1",
"agent_id": "contoso-travel-bot",
"tool_name": "lookup_flights",
"tool_args_hash": "sha256:a3f9c1...",
"result_hash": "sha256:7b2e1d...",
"policy_id": "contoso-travel-policy-v3",
"timestamp": "2026-04-25T14:30:00Z",
"sequence": 47,
"previous_receipt_hash": "sha256:9d4e6a...",
"signature": {
"alg": "EdDSA",
"sig": "c5af83...",
"public_key": "8f3b2c..."
}
}收据的可信度来自三个部分:[1]
- 签名:收据用智能体网关的 Ed25519 私钥签名。拿到对应公钥的人可以离线验证。依赖方必须事先固定(pin)这把公钥,或者从收据之外的可信注册表获取。任何一个被签名的字段被改动,签名都会失效。
- 规范化编码:签名前,收据先按 JSON 规范化方案(JCS,RFC 8785)序列化。这样,两个实现对同一份收据会得到逐字节相同的输出。如果不做规范化,不同的 JSON 序列化器对同一内容会算出不同的签名。[2]
- 哈希链:
previous_receipt_hash把每张收据和前一张连起来。删除或调换其中一张,呈现出来的序列就会断开。
三者合起来给出三种保证:签名证明这段内容是某把密钥签的;完整性保证签名之后内容没有变过;顺序保证在已验证的序列内,这张收据确实接在那张的后面。要发现链尾被截断,或者签名方自己重写了历史,还需要固定的当前链头、独立见证的检查点,或经过认证的封闭集合承诺。[1]
用 Python 签一张收据
示例代码分三段看。第一段导入依赖,生成或加载签名密钥。示例里的注释提醒,生产环境应把密钥存进密钥管理服务。[1]
import json
import hashlib
import base64
from nacl import signing
from jcs import canonicalize # RFC 8785 canonical JSON
def b64url_nopad(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def sha256_canonical(obj) -> str:
"""SHA-256 of a Python object's JCS-canonical JSON form."""
return f"sha256:{hashlib.sha256(canonicalize(obj)).hexdigest()}"
# Generate or load a signing key (in production, store in a key vault)
signing_key = signing.SigningKey.generate()
verify_key = signing_key.verify_key
TRUSTED_PUBLIC_KEYS = {b64url_nopad(bytes(verify_key))} # pinned out of band by the verifier第二段构造收据的载荷,此时还没有签名。收据里保存的是参数和结果的哈希,不是原始内容。[1]
# Build the receipt payload (no signature yet)
tool_args = {"origin": "SYD", "destination": "LAX"}
tool_result = [{"flight": "QF11", "price": 1850, "stops": 0}]
payload = {
"type": "agent.tool_call.v1",
"agent_id": "contoso-travel-bot",
"tool_name": "lookup_flights",
"tool_args_hash": sha256_canonical(tool_args),
"result_hash": sha256_canonical(tool_result),
"policy_id": "contoso-travel-policy-v3",
"timestamp": "2026-04-25T14:30:00Z",
"sequence": 0,
"previous_receipt_hash": None,
}第三段对载荷做规范化,然后用 Ed25519 对 JCS 字节直接签名,最后把签名对象附到收据上。Ed25519 在内部自行做哈希。[1][3]
# Canonicalize and sign the JCS bytes directly. PureEdDSA hashes internally.
canonical_bytes = canonicalize(payload)
signature_bytes = signing_key.sign(canonical_bytes).signature
# Attach a structured signature object.
receipt = {
**payload,
"signature": {
"alg": "EdDSA",
"sig": b64url_nopad(signature_bytes),
"public_key": b64url_nopad(bytes(verify_key)),
},
}离线验证,发现篡改
验证是签名的逆过程。验证函数接收收据和签发方的公钥,签名有效就返回 True,否则返回 False,输入格式不对时也返回 False。验证过程不需要联网,也不需要验证服务,只要手上有签发方的公钥。[1]
代码同样分三段。第一段是严格的 base64url 解码,它只接受一种无填充的写法。[1]
import base64
import hashlib
from nacl import signing
from nacl.exceptions import CryptoError
from jcs import canonicalize
def b64url_decode(s: str) -> bytes:
# Strict: Python's decoder skips characters outside the alphabet and also
# accepts padding, the standard +/ alphabet and non-zero spare bits.
# Re-encoding and comparing accepts only the one unpadded base64url form.
padding = "=" * ((4 - len(s) % 4) % 4)
data = base64.urlsafe_b64decode(s + padding)
if base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") != s:
raise ValueError("not canonical unpadded base64url")
return data第二段是 verify_receipt 的开头。它先检查输入,遇到异常时返回 False,而不是抛出异常。[1]
def verify_receipt(receipt: dict, trusted_public_key: str) -> bool:
# trusted_public_key is the issuer's key, obtained from somewhere other
# than this receipt. Never use receipt["signature"]["public_key"] here:
# see "What Receipts Prove (and What They Do Not)" below.
# Fails closed: anything malformed returns False instead of raising.
if not isinstance(receipt, dict) or not isinstance(trusted_public_key, str):
return False
sig_obj = receipt.get("signature")
if not isinstance(sig_obj, dict) or sig_obj.get("alg") != "EdDSA":
return False
sig_b64 = sig_obj.get("sig")
if not isinstance(sig_b64, str):
return False第三段重建真正被签名的载荷,也就是除 signature 之外的所有字段,规范化后再验证签名。[1]
try:
# Reconstruct the payload that was actually signed (everything except signature).
payload = {k: v for k, v in receipt.items() if k != "signature"}
canonical_bytes = canonicalize(payload)
verify_key = signing.VerifyKey(b64url_decode(trusted_public_key))
verify_key.verify(canonical_bytes, b64url_decode(sig_b64))
return True
except (CryptoError, TypeError, ValueError, KeyError, OverflowError):
return False
except Exception:
# An undecodable key or signature, or a payload that cannot be
# canonicalized: fail closed rather than raise.
return False关键在于公钥从哪里来。验证者绝不能使用收据里自带的 public_key,必须从收据之外获取签发方的公钥。原因是:攻击者可以修改收据,用自己生成的密钥重新签名,再把这把密钥嵌进收据。这样的收据和它自带的密钥是自洽的,能通过验证。只有拿签发方真实的公钥来验,才能识破。[1]
可以这样自己演示篡改。先生成一张有效收据并确认能通过验证;然后把 tool_args_hash 改动一个字节,重新验证,应该失败;最后是更狡猾的一种:攻击者用自己生成的密钥重新签名,并把这把密钥嵌进去。它能通过自带密钥的验证,换成签发方的真实公钥,就会失败。[1]
用哈希链串起多步操作
单张签名只保护一次动作,哈希链才能保护一个序列。每张收据都记录前一张的哈希。设想有三张收据,攻击者想悄悄删掉中间的第 2 张,他需要:[1]
- 修改第 3 张的
previous_receipt_hash,但这会让第 3 张的签名失效; - 或者为改动后的第 3 张伪造新签名,而这需要智能体的私钥。
如果私钥放在硬件密钥库中,并且验证者用独立获取的公钥检查每一张收据,这两条路都会被发现。只把公钥放进每张收据里是不够的,攻击者可以用自己的密钥重签,再把那把密钥嵌进去替换掉。[1]
收据证明什么,不证明什么
这一部分是全文最重要的,收据的作用有明确的边界。收据能证明三件事:某把密钥签了某个载荷(归属);载荷签名之后没有被改过(完整性);在已呈现并验证的序列里,这张收据接在那张的后面(顺序)。[1]
收据不能证明:[1]
- 动作是正确的。收据对错误答案和正确答案都能签得同样干净。
- 策略被评估过,或者策略本来会允许这个动作。
- 密钥背后是哪个人授权的。
- 输入是真实可靠的。如果智能体收到被操纵的提示词并照做,收据会如实记下这个动作。
- 动作真的执行了,或产生了真实世界的效果。
- 记录是完整的。收据缺失,可能是被拦截、遗漏、丢失,也可能根本没有埋点。
- 时间是可信的。签名里的时间戳只说明签名方填入了这个值,并不说明它是对的。
「有收据」不等于「已受治理」。收据是地基,输入校验、策略执行和身份基础设施都要建在它之上。[1]
高风险操作:让审批人签名
收据只说明「这把密钥签了这段内容」,并不说明「某个人授权了它」。对退款、删除、转账这类高风险操作,可以另外让审批人签名,把审批签名绑定到具体的动作上。把审批密钥和一个真实的人对应起来,以及确认这个人确实在场,仍然是另外的职责。[1]
上线前的检查清单
准备把收据用在真实环境时,可以逐条核对:[1]
- 把签名密钥移出开发者的笔记本,放进 Azure Key Vault、AWS KMS 或硬件安全模块(HSM)。
- 通过 JWK Set 或注册表发布验证用的公钥,由依赖方事先固定接受的签发方身份、密钥集合和轮换策略。
- 定期把最新的链头哈希写进透明日志,比如 Sigstore Rekor、RFC 3161 时间戳机构,或一套独立的内部系统。这样外部可以确认「这条链在某个时间点就已经存在」。
- 用不可修改的存储保存收据,比如设置了不可变策略的 Azure Storage,或开启了 Object Lock 的 AWS S3。
- 提前规划保留期。每张收据约 500 字节,按每天一万次调用估算,一年约 1.8 GB。
- 在运行手册里写明收据不覆盖什么。输入校验、策略执行、限流和身份基础设施,仍然需要另外配套。
限制和常见问题
- 收据的加密原语 Ed25519 已被广泛部署,但它不具备抗量子能力。[1]
- 本文只讲签名、验证和审计的思路,不涉及身份管理和策略引擎的实现。
下一步
想了解签名算法本身,可以读 RFC 8032[3]。想了解规范化的细节,可以读 RFC 8785[2]。
本文改编自 Microsoft 开源课程《AI Agents for Beginners》第 18 课[1],原文以 MIT 许可证发布。
来源
- 1Securing AI Agents with Cryptographic ReceiptsMicrosoft ai-agents-for-beginners (MIT)
- 2
- 3
引用本页: 智能体操作留痕:用签名收据做可验证的审计. TellWorld, https://www.tellworld.net/zh/practice/practice-securing-ai-agents. 更新于 2026-11-14