TellWorldAI 世界档案
问档案English

首页›实践›实践

实践

智能体操作留痕:用签名收据做可验证的审计

用 Ed25519 签名和哈希链,给智能体的每次工具调用留一张收据,拿到公钥的人就能离线验证。同时讲清收据证明什么、不证明什么。

这篇适合谁,读完能做什么

适合要让智能体处理订票、退款、转账这类有后果的操作,并且事后要能向审计方证明「智能体做了什么」的开发者。读完你应该能用 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]

  1. 签名:收据用智能体网关的 Ed25519 私钥签名。拿到对应公钥的人可以离线验证。依赖方必须事先固定(pin)这把公钥,或者从收据之外的可信注册表获取。任何一个被签名的字段被改动,签名都会失效。
  2. 规范化编码:签名前,收据先按 JSON 规范化方案(JCS,RFC 8785)序列化。这样,两个实现对同一份收据会得到逐字节相同的输出。如果不做规范化,不同的 JSON 序列化器对同一内容会算出不同的签名。[2]
  3. 哈希链: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 许可证发布。

来源

  1. 1
    Securing AI Agents with Cryptographic ReceiptsMicrosoft ai-agents-for-beginners (MIT)
  2. 2
  3. 3

引用本页: 智能体操作留痕:用签名收据做可验证的审计. TellWorld, https://www.tellworld.net/zh/practice/practice-securing-ai-agents. 更新于 2026-11-14

相关动态