Bindu Agent 认证调用实战:OAuth2 Bearer Token 与 DID 签名双通道全解析
Bindu Agent 认证调用实战:OAuth2 Bearer Token 与 DID 签名双通道全解析
导读
本文是 Bindu(AI Agent 的身份、通信与支付层)中"带认证调用 Agent"的完整实战指南。当 AUTH__ENABLED=true 时,每一次对 Bindu Agent 的调用都必须同时完成两件事:用 Hydra 签发的短期 Bearer Token 证明"你有权调用",并用你 DID 的私钥对请求体签名证明"你确实是你"。本文以 docs/AUTH.md 为骨架,结合仓库中间件与签名工具的真实实现,讲解四大请求头、四道校验闸门、从零注册身份的完整流程、每次请求的四步签名链路、错误排查对照表与跨语言规范签名基准(canonical fixture)。读完你将能独立手写任意语言下的安全调用客户端,也能在遇到 401/403 时一眼定位到具体失败闸门。
一、认证模型:两个证明缺一不可
当 AUTH__ENABLED=true 时,Bindu 采用"混合认证"(hybrid OAuth2 + DID)模型,请求必须同时携带两类凭证:
- 证明你有权调用(Authorization)——来自 Hydra(OAuth 服务器)的短期 Bearer Token;
- 证明你真的是你(Authentication)——用你的 DID 私钥对请求体签名,并附加签名头。
任何一个缺失,Agent 都会以 JSON-RPC -32009 拒绝请求;两者都正确,请求才会进入业务处理器。 这就是全部模型,剩下的是把它放到线上(wire)的具体格式。
这一模型在源码中的体现是 bindu/server/middleware/auth/hydra.py 的注释所描述的职责划分:
"Enhanced with hybrid OAuth2 + DID authentication for cryptographic identity verification, ensuring both authorization (who they are) and payload integrity (has the request been tampered with)."
即:Bearer Token 回答"你是谁/是否有权",DID 签名回答"请求是否真实、是否被篡改"。两者对应文档 docs/AUTHENTICATION.md(Token 半边)与 docs/DID.md(签名半边)。
二、四大请求头一览
每个发往开启认证的 Bindu Agent 的请求,必须携带以下四个头:
Authorization: Bearer <access_token> ← 来自 Hydra,约 1 小时过期
X-DID: did:bindu:<author>:<name>:<id> ← 你的身份
X-DID-Timestamp: <unix-seconds> ← 需在服务器时钟 ±300 秒内
X-DID-Signature: <base58 Ed25519 sig> ← 对 {body, did, timestamp} 签名
Agent 会依次通过四道闸门(gate)校验,第一处失败即中断。对运行中 AUTH__ENABLED=true 的 Agent 观察到的响应如下:
| 闸门 | 校验内容 | 失败时的表现 |
|---|---|---|
| 1 | Bearer Token 在 Hydra 中有效且处于激活态 | HTTP 401 + JSON-RPC -32009 "Authentication is required..." |
| 2 | X-DID 与 Token 的 client_id 一致 |
HTTP 403 + {"error":"Invalid DID signature","details":{"reason":"did_mismatch"}} |
| 3 | 该 DID 的公钥已注册到 Hydra client metadata | HTTP 403 + details.reason = public_key_unavailable |
| 4 | 时间戳在 300 秒窗口内 且 签名验证通过 | HTTP 403 + details.reason = invalid_signature(中间件将时钟偏差与签名错误合并为同一原因) |
四道闸门全部通过,你的 handler 才会执行。
源码印证:闸门实现与安全加固
在仓库中,这四道闸门由 bindu/server/middleware/auth/hydra.py 的 __call__ 与 _verify_did_signature_asgi 实现:
- 闸门 1:
_extract_token先从Authorization头提取 Bearer Token(无头则直接 401),随后_validate_token调用HydraClient.introspect_token向 Hydra admin 做 token introspection,校验active、sub、exp三个字段; - 闸门 2:
signature_data["did"] != client_did时返回did_mismatch; - 闸门 3:
hydra_client.get_public_key_from_client(client_did)查不到公钥时返回public_key_unavailable——值得注意的是,该模块注释明确记录了此前"失败即放行"(fail-open)的安全漏洞修复,见 bugs/core/2026-04-18-did-signature-fail-open.md:一旦client_id是did:前缀,DID 签名就是强制性的,不存在"不签名也能过"的回退; - 闸门 4:
verify_signature(见 bindu/utils/did/signature.py)在后台线程(asyncio.to_thread)执行,先做 300 秒重放窗口检查(MAX_SIGNATURE_AGE_SECONDS = 300),再重建签名载荷并验签。
此外中间件还内置了两项值得关注的安全机制:
- Token introspection 缓存:默认缓存 5 分钟(
CACHE_TTL_SECONDS = 300),但携带admin、agent:execute、payment:capture、key:rotate等敏感 scope 的 Token 永不缓存,每次请求都重新向 Hydra 确认,确保撤销立即生效(对应问题记录 bugs/core/2026-04-26-hydra-token-cache-revocation-lag.md); - DID 准入控制(allowlist):可通过
auth_config.allowed_dids配置白名单,仅允许列表内的 DID 访问 Agent,防止任意注册的 DID 消耗 Agent 资源。
三、快速上手指南:用内置调用方,别手搓
多数场景下不必手写完整链路。仓库中已有三个内置调用方完成整条链:
| 调用方 | 你需要提供 | 它为你做了什么 |
|---|---|---|
Inbox(POST /api/compose) |
Persona + OpenRouter key(通过 UI) | 派生你的个人 Agent、在 Hydra 注册、为每条出站消息签名。见 inbox/README.md |
Gateway(POST /plan) |
BINDU_GATEWAY_DID_SEED + Hydra URL |
每次 peer 调用使用同一身份。见 docs/GATEWAY.md |
| Postman 集合 | Seed + DID + secret(环境变量) | 预请求脚本为每次调用签名。见 docs/postman-did-signing.js |
如果你正在对一个开启认证的 Agent 做冒烟测试,最快的验证方式是走 Inbox:
# 假设 poet_agent 运行在 5776 端口且已开启 AUTH:
curl -s -X POST http://127.0.0.1:3787/api/ecosystem \
-H 'content-type: application/json' \
-d '{"id":"poet_agent","url":"http://127.0.0.1:5776"}'
curl -s -X POST http://127.0.0.1:3787/api/compose \
-H 'content-type: application/json' \
-d '{"agentId":"poet_agent","text":"write a 4-line poem"}'
# → {"ok":true,"status":200,"contextId":"...","taskId":"...","response":{...}}
如果返回 ok:true, status:200,说明上文四道闸门全部通过,你可以到此为止。其余内容面向需要在全新语言里从零编写调用方的人。
四、手写调用方(一):一次性初始化
你需要三样持久化产物:seed(你的秘密)、DID(你的公开身份名)、以及 Hydra 中把两者绑定在一起的 OAuth client 注册。
4.1 生成 seed 并派生 DID
uv run python -c "
import hashlib, os, base64, base58
from nacl.signing import SigningKey
AUTHOR = 'you_at_example_com' # 你的邮箱,@ → _at_,. → _
NAME = 'my_agent' # 简短标签,不能含冒号
seed = os.urandom(32)
pk = bytes(SigningKey(seed).verify_key)
sha = hashlib.sha256(pk).hexdigest()
agent_id = f'{sha[0:8]}-{sha[8:12]}-{sha[12:16]}-{sha[16:20]}-{sha[20:32]}'
did = f'did:bindu:{AUTHOR}:{NAME}:{agent_id}'
print('SEED_B64 =', base64.b64encode(seed).decode())
print('DID =', did)
print('PUBLIC_KEY_B58 =', base58.b58encode(pk).decode())
"
把三个输出都保存好。seed 就是你的私钥——丢失它会永久弃用该 DID,泄露它则任何人都能冒充你。
从源码看,这个派生逻辑正是 bindu/extensions/did/did_agent_extension.py 所采用的方案:DID 末尾的 agent_id 是 sha256(公钥) 前 16 字节的 UUID 化切片,公钥一变 DID 就变,因此无法在不改变 DID 的前提下换钥——这是 DID 绑定公钥带来的内建属性。
4.2 在 Hydra 注册 OAuth client
DID 就是 client_id:
CLIENT_SECRET=$(openssl rand -base64 32 | tr -d '=' | tr '+/' '-_')
curl -X POST https://hydra-admin.getbindu.com/admin/clients \
-H 'Content-Type: application/json' \
-d '{
"client_id": "'"$DID"'",
"client_secret": "'"$CLIENT_SECRET"'",
"grant_types": ["client_credentials"],
"response_types":["token"],
"scope": "openid offline agent:read agent:write",
"token_endpoint_auth_method": "client_secret_post",
"metadata": {
"did": "'"$DID"'",
"public_key": "'"$PUBLIC_KEY_B58"'",
"key_type": "Ed25519",
"verification_method": "Ed25519VerificationKey2020",
"hybrid_auth": true
}
}'
关键字段是 metadata.public_key——Agent 在闸门 3 正是通过它找到你的公钥。metadata.hybrid_auth: true 则向 Bindu 表明该 client 除了 Bearer Token 外还要求 DID 签名。保存好 client_secret,接下来铸造 Token 要用。
各字段说明:
client_id:在 Bindu 中永远是 DID 字符串(详见 docs/DID.md);client_secret:换取 Token 的密码,用openssl rand -base64 32 | tr -d '=' | tr '+/' '-_'生成 32 字节随机值,按数据库密码级别保管;grant_types: ["client_credentials"]:机器对机器认证,无登录表单、无重定向;scope:Token 携带的权限,agent:read只读、agent:write写权限,按需申请;token_endpoint_auth_method: client_secret_post:secret 通过 POST 表单体传递(与仓库客户端代码兼容)。
五、手写调用方(二):每次请求的四步
四步中第一步约每小时执行一次(Token 有缓存),其余三步每次调用都要执行。
5.1 铸造 Bearer Token
curl -s -X POST https://hydra.getbindu.com/oauth2/token \
-d grant_type=client_credentials \
-d "client_id=$DID" \
-d "client_secret=$CLIENT_SECRET" \
-d "scope=agent:read agent:write"
响应:
{ "access_token": "ory_at_...", "expires_in": 3599, "scope": "agent:read agent:write", "token_type": "bearer" }
Token 在内存中缓存,在 expires_in 到期前约 60 秒刷新。
5.2 构建 JSON-RPC 请求体
只序列化一次,并保留原始字节。你签名的字节必须等于你发送的字节。
body_bytes = json.dumps({
"jsonrpc": "2.0",
"id": "<uuid>",
"method": "message/send",
"params": {
"message": {
"role": "user", "kind": "message",
"parts": [{"kind": "text", "text": "your prompt"}],
"messageId": "<uuid>", "contextId": "<uuid>", "taskId": "<uuid>",
},
# 必填。缺失时 Agent 的请求校验器会在认证中间件看到请求之前
# 就以 400 拒绝。
"configuration": {"acceptedOutputModes": ["application/json"]},
}
}).encode("utf-8")
这一"只签一次、字节必须一致"的约束在 bindu/utils/did/signature.py 中有强制保障:create_signature_payload 只接受 str 或 bytes,传入 dict 会直接抛 TypeError。历史教训是:曾经允许 dict 并隐式做 json.dumps(body, sort_keys=True),导致"签名方签了一份字节、服务器验证另一份字节"的隐蔽 crypto_mismatch。现在你必须显式 json.dumps(data).encode("utf-8"),让签名与线上字节天然一致。
5.3 签名
签名载荷是第二个 JSON 对象,把请求体作为字符串包进去:
ts = int(time.time())
signing_str = json.dumps(
{"body": body_bytes.decode("utf-8"), "did": did, "timestamp": ts},
sort_keys=True, # ← 必填
)
# 使用 Python 默认分隔符:", " 和 ": " —— 注意空格。
sig_b58 = base58.b58encode(SigningKey(seed).sign(signing_str.encode("utf-8")).signature).decode()
⚠️ 跨语言第一大坑。 JavaScript 的
JSON.stringify在:和,后不加空格,Python 的json.dumps默认加空格。上面签名载荷用的是 Python 默认格式。如果你签的形态与服务器重建的形态不一致,签名必然验证失败,你会看到 HTTP 403 +details.reason: invalid_signature。请用下文的 canonical fixture 在任意语言中验证你的实现。
网关侧的参考实现是 gateway/src/bindu/identity/local.ts,其中包含与 Python json.dumps(payload, sort_keys=True) 逐字节对齐的 pythonSortedJson 辅助函数;Postman 侧则见 docs/postman-did-signing.js 的预请求脚本——它用 pm.variables.replaceIn() 取变量替换后的原始请求体字符串直接签名,并在注释中强调"LOAD-BEARING INVARIANT:签名的是 Postman 实际发出的精确字节,任何重序列化都会导致 crypto_mismatch"。
5.4 携带四个头发送
requests.post(
f"{agent_url}/",
data=body_bytes, # ← 必须是你签名的精确字节
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
"X-DID": did,
"X-DID-Timestamp": str(ts),
"X-DID-Signature": sig_b58,
},
)
如果 Agent 的中间件通过全部四道闸门,你的 handler 就会执行并返回正常的 task 响应。
六、Canonical Fixture:跨语言一致性基准
用它验证你的签名与编码实现是否与 Bindu 所有调用方一致:
| 输入 | 值 |
|---|---|
| Seed | 32 个零字节 |
| DID | did:bindu:test |
| Body | {"test": "value"} |
| Timestamp | 1000 |
签名载荷(注意 : 和 , 后的空格):
{"body": "{\"test\": \"value\"}", "did": "did:bindu:test", "timestamp": 1000}
期望的 base58 签名:
3SfU4VPTHLbzZzCn17ZqU6y2tnzHQbdo2nnXQr6XZXk34XgyzwSKRrCYEWRmmGXrV39mdkyhTsy5oasfTpNuqyM2
你的代码输出一致 → 可以上线;不一致 → 要么缺了空格、要么键没排序、要么 base58 字母表用错(Bindu 使用 Bitcoin 字母表——nacl-base58 同款)。仓库测试 tests/unit/extensions/did 与 tests/unit/utils/did 覆盖了签名工具与中间件的正反用例,可作为实现对照。
七、问题排查:错误对照表
| 响应 | 最可能的原因 | 修复 |
|---|---|---|
HTTP 401,JSON-RPC -32009 Authentication is required |
没有 Authorization 头,或 Token 无效/过期 |
重新铸造 Token,以 Authorization: Bearer … 附加 |
HTTP 403,details.reason = did_mismatch |
X-DID 与 Token 的 client_id 不匹配 |
用与 X-DID 相同的 DID 铸造 Token |
HTTP 403,details.reason = public_key_unavailable |
Hydra client 缺少 metadata.public_key,或注册到了别的 Hydra 实例 |
GET /admin/clients/<did> 检查 |
HTTP 403,details.reason = invalid_signature |
下列之一:时钟偏差 > 300s、重放旧时间戳、签名与发送间 body 字节漂移、sort_keys/空白不一致、用错 seed 签名 | 每次请求重新签名;对将要发送的精确字节签名;对照 canonical fixture 验证 |
HTTP 400,-32700 JSON 解析错误(如缺 params.configuration 字段) |
认证运行之前请求体形态就错了——JSON-RPC 校验器先拒绝 | 这是 body 问题不是认证问题;补上 params.configuration,并先在未认证的 peer 上确认 |
/oauth2/token 返回 invalid_client |
client_secret 错误,或 client 未注册到该 Hydra |
GET /admin/clients/<did> 确认 |
/oauth2/token 返回 invalid_scope |
请求了 client 未注册的 scope | 用该 scope 重新注册,或去掉该 scope |
中间件把"签名没通过"的四种子原因折叠成同一个 invalid_signature。要进一步定位:先换全新时间戳重新签名——这一步能排除时钟偏差与重放;若仍失败,则是 body 字节或密钥不匹配的问题。从 bindu/utils/did/signature.py 的实现看,验签内部的三种拒绝路径各有独立日志原因:timestamp_out_of_window(时间戳超窗)、malformed_input(base58 解码失败)、crypto_mismatch(签名数学校验失败),排查时可对照服务端日志精确区分。
调试捷径:直接 introspection 自己的 Token,确认 client_id 符合预期:
curl -s -X POST https://hydra-admin.getbindu.com/admin/oauth2/introspect \
-d "token=$ACCESS_TOKEN" \
| python3 -m json.tool
重点看 active: true、client_id == 你的 DID、exp > now。任何一个不对,就是你的 bug。
附加:常见细节陷阱
- Token 属于别的 Hydra:dev 机器的
HYDRA__ADMIN_URL指向本地 Hydra 而 Token 来自生产时,introspection 返回的client_id会与你预期不同。Agent 启动日志会打印它实际使用的 admin URL,务必让 Agent 与 client 指向同一个 Hydra; - 隐形字符:从聊天工具/Markdown 表格复制 DID 时
-可能被转成 en dash–,用diff <(xxd <<< "$a") <(xxd <<< "$b")检查字节差异; - 过期缓存头:Postman 重发时可能残留旧
Authorization头,用 Postman Console(⌥⌘C)查看实际出站头。
八、找回凭证:DID 与 client secret
- Agent 的 DID 发布在其 agent card 中:
curl http://localhost:3773/.well-known/agent.jsonagent.did(或类似字段)即 DID,也就是 Hydra 的client_id; - client secret 由
bindufy保存在本地.bindu/oauth_credentials.json。把它当.ssh/id_rsa对待——只读、仅本人、绝不提交; - 彻底丢失 secret 时,通过 admin API
PUT /admin/clients/<client_id>替换为新的(密钥轮换的完整流程见 docs/DID.md 的 seed 安全章节,包含chmod 600、secret manager 存储、轮换与疑似泄露后的处置清单)。
九、参考与延伸
- docs/AUTHENTICATION.md — Bearer Token 一侧的完整教学式讲解(含 Hydra 双 URL 架构、introspection 语义、环境变量配置)
- docs/DID.md — 签名一侧的完整教学式讲解(含 DID 文档结构、验签闸门图、失败模式)
- gateway/src/bindu/identity/local.ts — 参考 TypeScript 实现(含
pythonSortedJson) - inbox/server/index.ts — 同一流程的纯 Node 实现(§outbound A2A auth 与 §DID signature)
- docs/postman-did-signing.js — Postman 预请求脚本(含 Ed25519 PKCS8 前缀与 base58 编码的完整实现)
- bindu/server/middleware/auth/hydra.py — 服务端四闸门与 Token 缓存实现
- bindu/utils/did/signature.py — 签名/验签核心工具(重放窗口、base58、排序 JSON 契约)
一句话总结:开启认证后,Bindu 用两个独立保证保护每一个请求——Bearer Token 证明"该请求被允许"(经 Hydra introspection),DID 签名证明"该请求真实且未被篡改"(经 Ed25519 验签)。丢掉任何一个,请求即被拒绝;两者齐备,你就拥有了不依赖任何单一中心权威即可端到端验证身份与访问控制的调用体系。