KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

08 · 认证、签名卡与信任边界 — keel 龙骨

## 现场:一张看起来完全正常的卡,指向了别人的地址

现场:一张看起来完全正常的卡,指向了别人的地址

安全团队做红队演练时做了一件事:在内部网络里起了一个服务,在 /.well-known/agent-card.json 上发布了一张和变更分析 Agent 一模一样的卡,只把 url 改成了攻击者控制的地址。

演练结果:SRE 的客户端照常发现、照常委派,任务内容和参数全部送到了攻击者的服务上。整个过程没有任何报错——因为卡面本来就是「拿来就用」的,客户端没有任何依据判断这张卡是不是真的。

这个演练暴露的是 A2A 发现机制的天然弱点:去中心化发现的前提,是卡必须可验证。 没有签名,任何人都能冒充任何人。

直觉模型:三件事,缺一件都不叫认证

问题 由什么回答
你是谁 凭据(token / 证书)
你能干什么 scope
你是在跟谁说话 audience(受众绑定)

第三件最容易被漏。少了受众绑定,一张给 A 服务签发的票据可以拿到 B 服务上用——这就是 confused deputy。MCP 课程第 07 章讲过同一个问题,A2A 里它同样存在,而且更危险:跨组织调用时,两边的凭据体系往往不同。

精确定义

凭据走 HTTP 头,不进协议消息

规范的做法是:认证要求在卡面的 securitySchemes / securityRequirements 里声明,凭据则通过 HTTP 头传递(通常是 Authorization: Bearer)。

这个分工有个重要后果:凭据不会跟着任务历史被持久化、也不会被转发给第三方。 如果你把 token 塞进 Message.metadata,它会被写进 history,然后在每次 GetTask 时回显给所有人。

v1.0 在安全方案上有几处变化(来源:a2a-protocol.org What's New in v1.0,检索于 2026-10-05):

方案 状态
OAuth 2.0 授权码流 支持,新增 pkceRequired 字段
OAuth 2.0 Device Code(RFC 8628) 新增,适合 CLI 与无头环境
mutual TLS 支持
Implicit 流 / Password 流 移除(OAuth BCP 已废弃)

扩展卡:同一张卡,两种可见度

capabilities.extendedAgentCard 声明存在扩展卡,调用 GetExtendedAgentCard 需要额外 scope。这让「公开能做什么」和「授权后还能做什么」分成两层——公开卡只写能对外说的技能。

签名卡:JWS 加 JSON 规范化

签名流程的关键一步是规范化:同一个 JSON 对象有不同的键顺序和空白,必须先变成同一串字节才能签名。v1.0 用 RFC 8785(JCS),并且规范化时排除 signatures 字段本身——签名字段不能参与自己的签名输入。

全链路图:信任在两处独立建立

这张图要澄清一个常见误判:「认证通过」和「卡是真的」是两件事,走的是两条不同的链路。 前者保护的是你的请求,后者保护的是你要连的地址。

flowchart TD
    subgraph CLIENT["A2A Client —— 调用方进程"]
        A1["① 取卡 GET /.well-known/agent-card.json"]
        A2{"② verify_card<br/>signatures 存在且验签通过?"}
        A3["③ 从卡读 securitySchemes<br/>换取凭据"]
        A4["④ 发请求<br/>Authorization: Bearer ... 走 HTTP 头"]
    end
    subgraph SERVER["A2A Server —— 远端 Agent(不透明)"]
        B1["⑤ _require_version"]
        B2{"⑥ _authenticate<br/>scope ∩ 租户 ∩ 受众"}
        B3["⑦ dispatch 到具体 _op_*"]
        B4["⑧ 按 owner 过滤任务可见性"]
    end
    A1 --> A2
    A2 -->|"无 signatures"| E0["本地判定失败<br/>不发任何请求"]
    A2 -->|"url 被改 验签不过"| E1["本地判定失败<br/>冒充者被挡在连接之前"]
    A2 -->|"通过"| A3
    A3 --> A4
    A4 --> B1
    B1 --> B2
    B2 -->|"任一不匹配"| E2["-32000 AUTHENTICATION_FAILED"]
    B2 -->|"通过"| B3
    B3 --> B4
    B4 --> OK["⑨ 只返回 owner 匹配的任务"]
    E0 -.-> WARN["跳过验证 = 把防护开关交给攻击者"]
    B4 -.->|"owner 不匹配"| E3["-32001 TASK_NOT_FOUND<br/>与不存在同形"]

图上两个要点:

一次完整运行

python courses/foundation/a2a-protocol-engineering/course/project/examples/07_auth_card.py

实跑输出:

A. 凭据检查:
  不带 Authorization             -> -32000 AUTHENTICATION_FAILED
  缺 a2a:message:send scope     -> -32000 AUTHENTICATION_FAILED
  租户不是 acme                    -> -32000 AUTHENTICATION_FAILED
  受众是别的 Agent                  -> -32000 AUTHENTICATION_FAILED
  凭据齐全                         -> ok
B. 扩展卡(多一层 scope 才看得到完整技能):
  带 a2a:card:extended          -> ok
     公开技能 2 个 -> 扩展后 3 个
  不带该 scope                    -> -32000 AUTHENTICATION_FAILED
C. Agent Card 签名(JWS + 规范化,规范化排除 signatures 本身):
  签名后的卡能通过校验: True
  url 被改后还能通过吗: False
  没有 signatures 字段: False
  换一把密钥校验: False

逐段读:

  1. A 是四个独立的失败原因被压成了同一个错误码。 这是本项目教学实现的一个刻意简化,也是它的局限:生产环境应当区分「没带凭据」「scope 不够」「租户不对」「受众不对」,因为它们对应的处置完全不同(重试 / 申请权限 / 换租户 / 换票据)。
  2. B 是扩展卡:公开 2 个技能,授权后 3 个。清单本身就是需要分层的信息,这点常被忽略。
  3. C 是签名验证的四条:签名有效、改 url 后失效、无签名一律失败、换密钥失败。第三条最关键——没有 signatures 就判定失败,而不是「没签名就跳过验证」。后者等于把防护开关交给了攻击者。

失败注入

注入 A:跳过验证

最常见的错误写法:

if "signatures" in card:
    verify(card)      # 没签名就不校验

攻击者只需要不发 signatures 字段就绕过了整道防线。正确的做法是本实现的写法:无签名即失败。

注入 B:把凭据塞进消息

把 token 放进 Message.metadata 里传过去,然后调用 GetTask 并带 historyLength=10——你会看到凭据原样出现在历史里,而任何能读这个任务的人都能拿到它。

判断标准:协议消息里不应该出现凭据。 需要认证信息时用 AUTH_REQUIRED 状态让客户端重新走认证流程,而不是把密钥塞进正文。

不透明性带来的一条硬约束

A2A 的不透明性(第 01 章)在安全上有一条直接推论:任务的可见性必须按调用者隔离。

规范明确 GetTask 与 ListTasks 只应返回调用者可见的任务(来源:规范 v1.0.1 对 GetTask 的澄清,检索于 2026-10-05)。本项目的实现是给每个任务记 owner,GetTask 时校验。

这条约束在跨组织场景下尤其重要:「任务不存在」和「任务存在但你看不见」对外都表现为 TASK_NOT_FOUND——不能因为要友好就区分开,那会变成枚举别人任务的探针。

生产边界

教学实现 生产替换
HMAC bearer 替身 OAuth 2.0 授权码加 PKCE、Device Code、mTLS
单一错误码 区分认证失败、授权不足、租户不符、受众不符
共享密钥签名 非对称签名(JWS)+ 公钥分发与轮转 + JCS(RFC 8785)
内存里的 owner 字典 任务表上的 owner 列 + 行级权限
无密钥轮转 kid 与 JWKS,支持多把公钥并存

练习与验收

练习(有可观察结果):把「认证失败」拆成四种不同的 reason(MISSING_CREDENTIALS / INSUFFICIENT_SCOPE / TENANT_MISMATCH / AUDIENCE_MISMATCH),然后跑一遍示例 A 的四种情况,断言四种不同的 reason。

验收标准:四种情况必须给出四种不同 reason,且错误码仍在同一族内。如果你的实现把它们合并成一句「认证失败」,调用方就无法决定下一步该重试、申请权限还是换凭据——排障时间会成倍增加。

本章检查点

现在能解释什么

你现在能解释 A2A 的信任由三层构成:凭据证明你是谁、scope 加租户加受众限定你能对谁干什么、签名卡证明对面就是它本人。你也知道「没有 signatures 就跳过验证」是最危险的写法,以及任务可见性必须按调用者隔离。下一章把这些收口到工程验证上:你怎么证明自己的实现在各种客户端上都兼容,以及出了问题怎么查。

进入 keel 阅读