跳到主要内容

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-6980-117。算法从 JWK 的 crv 推导alg 不进 Signature-Input 参数。docs/specification/signatures.md:96-98

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:338-408docs/specification/overview.md:1066-1086。正因为公钥可从 profile 直接发现,HTTP 消息签名是唯一支持免许可接入的认证方式(其余 API key / OAuth / mTLS 都要事先交换凭证)。docs/specification/overview.md:1034-1049

验签骨架(示意,非源码)

规范用伪代码描述了 verify_rest_requestdocs/specification/signatures.md:374-408),核心步骤:

# 示意,非源码: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,响应签 @statusdocs/specification/signatures.md:188-336

4. 重放保护放在业务层(关注点分离)

UCP 故意在签名层做重放保护(不强制 created 时间戳),而是用业务层的幂等键:

负责
签名认证(是谁)、完整性(是什么)
幂等安全重试、重放保护

机制:状态改变操作带 idempotency-key,它进签名组件(攻击者改不了它而不破坏签名);重复键 + 相同 payload → 返回缓存响应;重复键 + 不同 payload → 409 Conflict。来源:docs/specification/signatures.md:448-503

“相同 payload”的判定标准也很讲究:比较原始 body 字节的 SHA-256——正好就是 RFC 9530 那个 Content-Digest。所以平台修改了 payload 就 MUST 换新幂等键(改支付工具、改地址、换 line item 都算改)。docs/specification/signatures.md:487-498

5. 何时必须签 / 密钥轮换

  • Webhook MUST 签。 商家 → 平台的推送(如订单事件)必须签——否则收方无法验证服务端主动推送的真实性。docs/specification/signatures.md:511-512docs/specification/overview.md:1051-1052
  • 请求 SHOULD 签(用 HTTP 签名时);支付授权/结算完成响应 RECOMMENDED 签;购物车/目录查询/错误响应 OPTIONALdocs/specification/signatures.md:505-523
  • 密钥轮换:先在 signing_keys[] 加新键 → 用新键签 → 宽限期(至少 7 天)继续接受旧键 → 移除旧键;建议每 90 天轮换。密钥泄露则立即移除并拒绝其所有签名。docs/specification/signatures.md:143-160

6. 巧妙之处 / 坑

  • 巧:摘要对原始字节,无需 JSON 规范化。 Content-Digest 直接 hash 原始 body 字节,把 body 绑进签名,省掉 JSON canonicalization。docs/specification/signatures.md:177-181
  • 坑:中间件不得重序列化。 代理/网关若重新序列化 JSON,字节变了 → 签名失效。docs/specification/signatures.md:183-187
  • 坑:签名编码必须 raw r||s,不是 DER。 P-256 是 64 字节、P-384 是 96 字节定长拼接;OpenSSL/Java/.NET 默认 DER,需显式转换——这是个常见集成坑。docs/specification/signatures.md:250-255
  • 巧:耐用产物才规范化。 普通消息用原始字节摘要即可;只有需要长期留存的产物(AP2 mandate)才走 canonicalization。docs/specification/signatures.md:179-181

7. 代码地图

主题文件符号 / 锚点
签名架构 / 共享基座docs/specification/signatures.md“Architecture”/“Shared Foundation” §40-160
签名组件(请求/响应)docs/specification/signatures.md“REST Request/Response Signing” §188-336
验签流程docs/specification/signatures.mdverify_rest_requestverify_rest_response
密钥发现docs/specification/overview.md“Key Discovery” §1066-1086
幂等/重放docs/specification/signatures.md“Replay Protection” §448-503
认证机制对比docs/specification/overview.md“Authentication Mechanisms” §1034-1064
签名错误码docs/specification/signatures.md“Error Handling” §553-609