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/>与不存在同形"]
图上两个要点:
- ② 发生在本地,不需要服务端配合。 这正是红队演练的修复点:冒充的卡在连接建立之前就被挡掉。
- ⑥ 是三元组判定,缺任何一个都拒绝。而凭据始终在 HTTP 头里——如果塞进
Message.metadata,它会落进history并被每次GetTask回显(注入 B)。
一次完整运行
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
逐段读:
- A 是四个独立的失败原因被压成了同一个错误码。 这是本项目教学实现的一个刻意简化,也是它的局限:生产环境应当区分「没带凭据」「scope 不够」「租户不对」「受众不对」,因为它们对应的处置完全不同(重试 / 申请权限 / 换租户 / 换票据)。
- B 是扩展卡:公开 2 个技能,授权后 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,且错误码仍在同一族内。如果你的实现把它们合并成一句「认证失败」,调用方就无法决定下一步该重试、申请权限还是换凭据——排障时间会成倍增加。
本章检查点
- 现场那个红队演练,签名卡在哪个环节拦住了它?如果卡没签名,还有别的补救办法吗?
- 「没签名就不校验」为什么比「不签名」更危险?
- 凭据为什么不能放进协议消息?放进
metadata之后它会流到哪里?
现在能解释什么
你现在能解释 A2A 的信任由三层构成:凭据证明你是谁、scope 加租户加受众限定你能对谁干什么、签名卡证明对面就是它本人。你也知道「没有 signatures 就跳过验证」是最危险的写法,以及任务可见性必须按调用者隔离。下一章把这些收口到工程验证上:你怎么证明自己的实现在各种客户端上都兼容,以及出了问题怎么查。