高级 📋 6 个步骤 第 574 / 575 篇

用 Aroha Protocol 给每一次委托签一张只能收窄的授权状:签发、衰减与离线验链实操

Agent 把活交给下一个 Agent 再交给第三方工具之后,没人能证明它被允许做什么。Aroha 用签名授权状解决:人签额度与有效期,往下派活只能收窄,任何一环都能离线验链回到人这个根。本教程六步接入。

2026-10-28· 10 分钟上手· 约 3309 字· 🔏 Aroha Protocol / 📜 签名授权

智能体把活交给下一个智能体、再交给第三方工具之后,链路中间没有人能回答四个问题:现在到底是谁在动、他被允许做什么、能不能随时叫停、以及事后拿什么证明发生过什么。API 密钥每跳各管一段,人的额度、用途与有效期在传递中就丢了;框架内的护栏出不了自己那份代码。Aroha Protocol 的做法是给每一次委托签一张授权状(mandate):人签一张带额度与有效期的意图授权状给编排者,编排者往下派活时只能收窄不能放宽,任何一环都能离线验链回到人这个根,撤销会被推送给验证方,每一次放行或拒绝都留一张签名回执。本教程六步把它接进你现有的栈:起环境、认识四个包、签发、衰减、验链、再挂到 MCP 上。

🔏 本教程适合:已经有多智能体或长工具链路、并且需要向别人证明「这个 Agent 当时确实被允许做这件事」的团队。你需要 Node.js 22 及以上、npm 10 及以上;建议先在本地跑通,再考虑接入真实链路。仓库为 Apache-2.0 协议(个别 npm 子包页面标注为 MIT,商用前请以仓库 LICENSE 与 NOTICE 为准)。

Step 1:起环境,并先搞清楚它不是什么

1 脚手架会给你一个已经会验签的 Agent

脚手架创建项目、装依赖、写 .env,然后起一个本地 Agent。零配置模式会自己挑完所有默认值,适合先看形态;需要指定能力时用交互式初始化。起完之后有两件事值得马上做:跑一次环境自检,以及起一个本地开发注册表与看板,后面的验链与撤销都能在上面看到。

npx create-aroha-agent my-agent
cd my-agent && npm install && npm run dev
# 输出形如:did:aroha:my-agent  http://localhost:3001/aroha/v1  Ready

# 或用 CLI:
aroha init my-agent            # 交互式
aroha init my-agent --yes      # 零配置,自动写 .env
aroha convert --from mcp --dir .   # 把已有 MCP 服务包一层
aroha doctor [endpoint]        # 环境连通性自检
aroha dev                      # 本地注册表 + 看板

在动手改代码之前,先把边界说清楚,否则很容易把它用错地方:它不是 Agent 框架或编排器,不替你计划和调度(那是 LangGraph、CrewAI、各家 Agent SDK 的事);它不是传输协议,而是搭在 MCP 与 A2A 之上;它不是身份提供方,但可以把 IdP 发出来的身份当作授权链的根;它也不是支付网络,消费授权状只是映射到那些协议已有的信封上。它只回答前面那四个问题。

别指望它替你管流程:Aroha 限定 Agent 可以做什么,不负责决定它做什么。把它当成套在现有框架外面的一层权限与凭据,而不是又一个编排器;计划、重试与状态机仍然是你原来那套。

Step 2:四个包,对应四个问题

2 核心四个,适配器按需

核心层由四个包组成,各自的职责对应一个要回答的问题:core 负责身份与签名信封、回执与会话档案;credentials 负责授权状的签发、衰减、验链与撤销,是真正要开始用的那个;delegation 负责多跳委托的深度限制与回执拼接;policy 则在模型看到请求之前做确定性限额检查。适配器那一层是给已有栈用的,包括把任意 MCP 服务包成 Aroha Agent 的 mcp-bridge、挡在 MCP 服务前面的 mcp-guard、以及 A2A 与 LangChain 的桥。

核心                                    适配器(按你已有的栈选)
@aroha-sdk/core        身份/信封/回执      @aroha-sdk/mcp-server
@aroha-sdk/credentials 签发/衰减/验链 ★    @aroha-sdk/mcp-guard
@aroha-sdk/delegation  多跳委托            @aroha-sdk/mcp-bridge
@aroha-sdk/policy      确定性限额          @aroha-sdk/a2a-bridge
                                          @aroha-sdk/langchain-bridge

这里的身份是内容寻址的:did:aroha 后面的标识直接由公钥本身推导出来,因此验证方不需要去查任何注册表就能核对签名。这一点决定了后面整条链可以离线验证——在跨厂商、跨公司的链路上,能不依赖中心服务就完成校验,是它和「每跳各发一把钥匙」最本质的区别。

💡 如果你是给已有代码做加固,通常只需要 credentials 与 mcp-bridge 两个包:前者签发与验链,后者把验链挡在工具前面。core 与 policy 是做深了之后再引入的。

Step 3:签一张意图授权状

3 额度、用途与有效期,三样一起签

人先给编排者签一张意图授权状,内容是:允许的动作、消费上限、有效期,并且用密码学绑定到接收方的身份标识上。示例里给助手的是 500 美元、商户类别码 4511(航空)、有效期一个小时。密钥对与标识都由 SDK 生成,私钥留在签发方自己手里。

import { generateKeyPair, generateDid } from "@aroha-sdk/core";
import { issueIntentMandate, attenuateToPayment, verifyMandateChain }
  from "@aroha-sdk/credentials";

const human = await generateKeyPair();
const assistant = await generateKeyPair();
const flightAgent = await generateKeyPair();

const humanDID = generateDid(human.publicKey);
const assistantDID = generateDid(assistant.publicKey);
const flightAgentDID = generateDid(flightAgent.publicKey);

const intent = await issueIntentMandate(
  humanDID, assistantDID,
  { spendLimitUsd: 500, merchantCategory: "4511" },  // 4511 = 航空
  human.privateKey,
  3_600_000,                                          // TTL:一小时
);

有效期这一项最容易被写成一个巨大的数字,然后被忘掉。它应当贴着真实任务的长度来定:一个小时内要订完机票,就给一小时;给一周意味着这张授权状在整个星期里都是有效的攻击面。额度同理,按这次任务的真实上限给,而不是给一个「反正不会用完」的整数。

私钥就是授权本身:谁能拿到人的私钥,谁就能以人的名义签发授权状。请把人的密钥放在受保护的存储或硬件里,不要和 Agent 的运行时密钥放在同一台机器上——那样做等于把「越权」和「被发现越权」的能力交给了同一个进程。

Step 4:往下派活时只能收窄

4 放宽一丁点,验证就会失败

编排者把活派给下一个 Agent 时,签的是一张更窄的子授权状,并且把回到人那一层的完整签名链一起带过去。示例里助手把 500 美元收窄成 300 美元交给订票 Agent;子令牌内嵌了这条链,接收方不需要再回头去问任何人。

// 助手把 500 收窄成 300 交给订票 Agent,用途类别不变
const payment = await attenuateToPayment(
  intent, flightAgentDID,
  { spendLimitUsd: 300, merchantCategory: "4511" },
  assistant.privateKey,
);

「只能收窄」这四个字是整套设计里最硬的那条规则,它靠的是验链而不是约定:任何一跳如果放宽了额度、改了用途、换了签发人、或者过期,链条验证就会失败。也就是说,中间某个 Agent 想给自己多要点权限,它做得到签名,但下游不会接受——它只能回头向上游要一张新的、由人签发的授权状。

💡 分层收窄本身就是一种预算管理:人给 500,编排者给订票 300,订票再给实际支付 389.99 那一笔。每一层留下来的差额就是这一层可以自主决定的余量,也是事后追责时能看清「谁放开了多少」的依据。

Step 5:验链,然后拒答

5 两行代码,四个失败原因

接收方在动手之前验链,只需要两行:给出期望的根(你承认其权威的那个人)与期望的被授权人(你自己)。返回里 valid 表示签名与结构没问题,chainVerified 表示整条链一路验回到了人的根;两者都为真才继续,否则直接拒绝并给出原因。

const result = await verifyMandateChain(payment.token, undefined, {
  expectedRoot: humanDID,          // 权威必须来自这个人
  expectedGrantee: flightAgentDID, // 被授权人必须是我自己
});
// result.valid === true, result.chainVerified === true
// result.mandate?.constraints.spendLimitUsd === 300

const { valid, chainVerified, mandate, reason } = await verifyMandateChain(
  envelope.body.credentialToken, undefined,
  { expectedRoot: process.env.HUMAN_ROOT_DID, expectedGrantee: MY_DID },
);
if (!valid || !chainVerified) {
  res.status(403).json({ error: reason });
}

会失败的四种情况值得记住,因为它们正是你想挡住的事:伪造的授权状、被改过内容的、放宽了范围的、过期的,以及由错误一方签发的。验证完之后,额度与用途类别都能从 mandate.constraints 里读出来,用来做真正的业务判断——例如确认这一笔的商户类别确实在允许范围内,而不只是「有授权状」。

// 在业务侧再落一道:授权状给了多少,实际就只能用多少
const limit = mandate?.constraints?.spendLimitUsd ?? 0;
if (amount > limit) return refuse('超出授权额度');
if (mandate?.constraints?.merchantCategory !== mccOf(merchant)) {
  return refuse('商户类别不在授权范围内');
}

验链不是授权:它只证明「这一跳被允许到什么程度」,不证明这件事本身该做。金额、商户、收件人这些业务判断仍然要在你自己的代码里做;把验链当成全部检查,是这类系统最常见的误用。

Step 6:挂到 MCP 上,以及上线红线

6 授权状走 _meta,不走 HTTP 头

如果你已经有一个 MCP 服务,mcp-bridge 的 wrapMcp 能把它包成一个 Aroha Agent:传入工具清单与每个工具的真实实现,调用方的公钥解析器用来核对每一次请求的签名、有效期与 nonce 重放。工具清单里每一个名字都必须有对应实现,否则启动就会抛错。包好之后它会带自己的标识与本地端点。

import { wrapMcp, registryPublicKeyResolver } from "@aroha-sdk/mcp-bridge";

const agent = await wrapMcp(server.listTools(), {
  name: "my-tools",
  price: 0.01,
  resolvePublicKey: registryPublicKeyResolver(),  // 核对调用方是谁
  handlers: {
    search: async (args) => ({ content: [{ type: "text", text: await search(args) }] }),
  },
});
await agent.start();
// did:aroha:my-tools -> http://localhost:3001

调用方这一侧的约定要记牢:授权状放在请求参数的 _meta 里,键名是 aroha/mandate,既不是 HTTP 头,也不是工具参数;它由 presentMandate 生成,里面带一个持有者证明,证明里签的是规范化的 JSON(RFC 8785)形式,包含用途、受众、工具名、参数哈希、授权状 id、持有者、随机数与有效期。桥会解析持有者、校验证明、拒绝重复使用的随机数,再以持有者为期望被授权人验链。没有有效证明的令牌会被直接拒绝——否则它就是一张谁看到都能重放的 bearer 凭据。

// 调用侧:生成 _meta,随调用一起发出
const _meta = await presentMandate({
  token, holderDid: MY_DID, holderPrivateKey: MY_KEY,
  audience: AGENT_DID, tool: "search", args,
});
await client.callTool({ name: "search", arguments: args, _meta });

最后几条上线红线:不加 requireMandate 时,MCP 端点不认证任何人,此时启动会拒绝在非回环地址上提供服务,除非显式打开允许未认证的开关(那就得自己在前面加认证);Host 与 Origin 头会按回环名称与允许名单核对。想全开严格模式时用 governed,它会要求每一次调用都带授权状、按密钥事件日志验证人的根、对每一跳查撤销、并钉住根,缺少撤销检查、密钥事件源、根钉或持久化兑付存储时启动会被拒绝,内存版兑付存储明确不被接受。人可以随时撤销,撤销状态由注册表见证者推送给验证方;放行与拒绝都会留签名回执。

桌面运行时尚未发布:仓库说明里提到 Aroha 运行时(桌面应用)在另一个私有仓库开发、目前没有发布;saga、结算、商务与声誉这几个扩展也在仓库外维护,只以 npm 包形式发布。做方案时请按「当前能用的是 SDK 与适配器」来规划,不要把未发布的组件排进工期。

常见问题速查

现象原因与处理
脚手架起不来要求 Node.js 22 及以上、npm 10 及以上
想知道环境通不通aroha doctor,可带 endpoint 检查连通性
已有 MCP 服务想加验链aroha convert --from mcp --dir . 或直接用 wrapMcp
chainVerified 为 false链上某一跳放宽了范围、被改过、过期或签发方不对
想只允许来自某个人的权威verifyMandateChain 传 expectedRoot,并确认是同一个人
担心授权状被别人拿去重放走 _meta 的持有者证明,随机数重复使用会被拒绝
把令牌放进 HTTP 头不管用约定是 _meta 里的 aroha/mandate,没有 HTTP 头约定
启动报缺少 handler工具清单里每个名字都要有实现,否则 start 抛错
想在公网提供服务被拒未开 requireMandate 时不认证任何人,仅允许回环地址
governed 模式启动失败缺撤销检查、密钥事件源、根钉或持久化兑付存储会被拒;内存存储不接受
业务上被超额调用验链之外还要自己比对 mandate.constraints 里的额度与类别
想看撤销与回执人可随时撤销,状态由注册表见证者推送;放行与拒绝都有签名回执
授权状有效期该给多久贴着真实任务长度给,别给一个「反正用不完」的大数字
← 返回教程中心