数据截至 (上游 commit 9ed9049a14d9)
UCP — HTTP 消息签名与身份
本章讲 UCP 怎么保证“消息确实来自它声称的那一方、且没被篡改”。亮点是:用同一份 profile 既协商能力又发现公钥,于是连签名验证都不需要事先交换密钥。
1. 它要解决的小问题
两个素未谋面的系统在公网上谈钱,要防四件事:冒充、篡改、重放、方法/端点混淆。UCP 选用 RFC 9421 HTTP 消息签名统一应对所有基于 HTTP 的传输。来源:docs/specification/signatures.md:30-43。
2. 共享基座
所有 HTTP 传输共用一套密码学原语:
| 关切 | 选择 |
|---|---|
| 签名格式 | RFC 9421(HTTP Message Signatures) |
| body 摘要 | RFC 9530 Content-Digest,对原始字节算 sha-256 |
| 算法 | ES256(必须支持)、ES384(可选) |
| 密钥格式 | JWK(RFC 7517),EC 公钥 |
| 密钥发现 | /.well-known/ucp 里的 signing_keys[] |
| 重放保护 | 业务层 idempotency-key(不在签名层) |
来源:docs/specification/signatures.md:45-71、80-117。 算法从 JWK 的 crv 推导,alg 不进 Signature-Input 参数。docs/specification/signatures.md:124-125。
3. 核心机制:密钥从 profile 发现(permissionless 的关键)
验签时怎么拿到对方公钥?答案是:从对方 profile 的 signing_keys[] 里取——这跟能力协商用的是同一份文档。
收到带签名的请求
│
▼
① 从 UCP-Agent 头取签名方 profile URL(business → /.well-known/ucp)
│
▼
② fetch profile(HTTPS、禁 3xx 重定向)或读缓存
│
▼
③ 从 Signature-Input 取 keyid,匹配 signing_keys[] 里的 kid
│
▼
④ 用该公钥验签;body 在场则先核 Content-Digest
来源:docs/specification/signatures.md:604-743、docs/specification/overview/index.md:2187-2213。正因为公钥可从 profile 直接发现,HTTP 消息签名是唯一支持免许可接入的认证方式(其余 API key / OAuth / mTLS 都要事先交换凭证)。docs/specification/overview/index.md:2155-2170。
验签骨架(示意,非源码)
规范用伪代码描述了 verify_rest_request(docs/specification/signatures.md:677-743),核心步骤:
# 示意 ,非源码:REST 请求验签的骨架
def verify_rest_request(req):
sig_in = parse_signature_input(req.headers["Signature-Input"])
profile = fetch_profile(profile_url_from(req.headers["UCP-Agent"])) # HTTPS, 不跟重定向
pubkey = find_key_by_kid(profile.signing_keys, sig_in.keyid)
if not pubkey:
return error("key_not_found")
if "content-digest" in sig_in.components: # body 在场才核摘要
if req.headers["Content-Digest"] != "sha-256=:"+b64(sha256(req.body_bytes))+":":
return error("digest_mismatch")
base = build_signature_base(sig_in.components, req.method, req.path, req.query, req.headers)
return ok() if ecdsa_verify(base, parse_sig(req.headers["Signature"]), pubkey) else error("signature_invalid")
重点看:签名覆盖的不只是 body,还包括 @method @authority @path 等——所以能挡“把签名 payload 换个方法/换个 host 重放”的攻击。请求签 @method,响应签 @status。docs/specification/signatures.md:424-602。
4. 重放保护放在业务层(关注点分离)
UCP 故意不在签名层做重放保护(不强制 created 时间戳),而是用业务层的幂等键:
| 层 | 负责 |
|---|---|
| 签名 | 认证(是谁)、完整性(是什么) |
| 幂等 | 安全重试、重放保护 |
机制:状态改变操作带 idempotency-key,它进签名组件(攻击者改不了它而不破坏签名);重复键 + 相同 payload → 返回缓存响应;重复键 + 不同 payload → 409 Conflict。来源:docs/specification/signatures.md:798-859。
“相同 payload”的判定标准也很讲究:比较原始 body 字节的 SHA-256——正好就是 RFC 9530 那个 Content-Digest。所以平台修改了 payload 就 MUST 换新幂等键(改支付工具、改地址、换 line item 都算改)。docs/specification/signatures.md:837-848。
5. 何时必须签 / 密钥轮换
- Webhook MUST 签。 商家 → 平台的推送(如订单事件)必须签——否则收方无法验证服务端主动推送的真实性。
docs/specification/signatures.md:867-868、docs/specification/overview/index.md:2171-2172。 - 请求 SHOULD 签(用 HTTP 签名时);支付授权/结算完成响应 RECOMMENDED 签;购物车/目录查询/错误响应 OPTIONAL。
docs/specification/signatures.md:861-879。 - 密钥轮换:先在
signing_keys[]加新键 → 用新键签 → 宽限期(至少 7 天)继续接受旧键 → 移除旧键;建议每 90 天轮换。密钥泄露则立即移除并拒绝其所有签名。docs/specification/signatures.md:221-240。
6. 巧妙之处 / 坑
- 巧:摘要对原始字节,无需 JSON 规范化。
Content-Digest直接 hash 原始 body 字节,把 body 绑进签名,省掉 JSON canonicalization。docs/specification/signatures.md:413-417。 - 坑:中间件不得重序列化。 代理/网关若重新序列化 JSON,字节变了 → 签名失效。
docs/specification/signatures.md:419-423。 - 坑:签名编码必须 raw
r||s,不是 DER。 P-256 是 64 字节、P-384 是 96 字节定长拼接;OpenSSL/Java/.NET 默认 DER,需显式转换——这是个常见集成坑。docs/specification/signatures.md:494-503。 - 巧:耐用产物才规范化。 普通消息用原始字节摘要即可;只有需要长期留存的产物(AP2 mandate)才走 canonicalization。
docs/specification/signatures.md:415-417。
7. 代码地图
| 主题 | 文件 | 符号 / 锚点 |
|---|---|---|
| 签名架构 / 共享基座 | docs/specification/signatures.md | “Architecture”/“Shared Foundation” §40-160 |