跳到主要内容

DID 工程更新:解析、密钥轮换与 Agent 授权

Robert Mao
IdentityAgent IdentityVerifiabilityBlockchainSecurity

在《ArcBlock 链即将升级:无感迁移与零协议费 Web 跨链桥计划》里,我们预告了本轮链升级的范围。本文展开其中的 DID 部分,具体解释这次升级带来的身份、密钥与凭证改进。升级预告说明整体切换安排,这篇 Engineering Blog 说明这些能力的实现与接入方式。

一个请求携带 principalDid,验证器能知道多少?它可以检查标识符格式,却还不知道请求者是否持有对应密钥、这个 Agent 的权限来自谁,或者签发给它的凭证是否已经失效。服务迁了机器、控制密钥轮换之后,还要回答另外两个问题:去哪里访问,认哪一把公钥。

这是我们从支持 DID 以来的一次大幅 refresh。它把解析标识与服务、验证密钥连续性、检查授权来源和读取凭证状态,整理成了可以组合的验证步骤。 其中既有对原来 did:abt: 设计的延伸,也采用了 W3C DID Document、KERI 预轮换、SD-JWT 和位图状态列表的思路。

2019 年,我们把 DID 引入 ArcBlock 架构,随后在钱包、DID Connect 与应用登录中持续使用它。我们持续加大在 DID 上的工程投入,这次把密钥轮换、凭证隐私和 Agent 委托一起向前推进。ARC 的集成则按先让系统以简化身份跑稳、再收紧验证的次序展开,现在补上的是这些标识背后的证据。本文以已合入的 SDK 与链协议源码为依据,逐项说明数据怎样组织、验证器检查什么,以及应用接入时还需要提供什么。代码片段展示当前接口与编码方式,调用中的密钥、可信状态和请求参数由接入方提供;具体 SDK 发布版本与生产入口的启用状态,需要在接入时核对。

解析:从自描述地址,到能被发现的服务

服务换了机器,使用者仍可以沿原来的域名找到它;接入方还可以从同一身份发现签名验证、加密通信和文件服务的入口。这把“认谁”和“去哪里访问”分开,迁移时更新服务位置,身份关系继续保留。

ABT DID Protocol 定义的 did:abt: 26 字节二进制布局继续保留:类型字段描述角色、密钥类型和哈希类型,后面是公钥哈希与校验和。多 method 支持把 abt、afs、aos、spaces 的表示显式化,did:name 则负责可读别名。解析器区分加密标识与名称别名,名称经解析后再进入公钥验证。

名称本身也有输入边界。normalizeName 使用 Unicode NFKC 与小写规范化,拒绝控制字符、零宽字符、首尾空白,以及实现列出的部分混用文字和同形字。这样可以在名称进入注册和解析流程前消除一部分歧义。完整的防钓鱼体验还需要客户端展示和命名策略配合。

DID 放进 DNS 时,会遇到另一个明确的编码冲突:Base58 地址区分大小写,DNS 标签不区分。RFC 4343 说明了 DNS 的大小写处理规则。新的 toDnsDidLabel 将原始 DID 字节编码成小写标签,fromDnsDidLabel 反向解码时,对 26 字节 ArcBlock DID 验证内置校验和,对 Ethereum 地址恢复校验和地址表示:

原始标识默认 DNS 标签长度
26 字节 ArcBlock DIDk + 固定宽度 base3642 字符
20 字节 Ethereum 地址e + 固定宽度 base3632 字符

这保留了我们原来的三层寻址关系:

text
custom.example
  CNAME → <dns-safe-label>.did.abtnet.io
           → 当前运行端点

应用可以用下面的接口完成 DID 与网关域名之间的转换。这里的 did 是已校验的标识,返回值是名称,不会创建 DNS 记录。

ts
import { toDnsDidLabel, fromDnsDidLabel, toDidDomainHost } from '@arcblock/did';

const label = toDnsDidLabel(did);
const decodedDid = fromDnsDidLabel(label);
const host = toDidDomainHost(did, 'did.abtnet.io');

DIDDomainEdgeRouter 在三层关系上增加所有权检查。域名 NFT 绑定证明包含域名、DID 和资产信息;调用方提供可信资产状态快照后,验证器核对 owner、资产是否已消费,以及 data.domain 或 moniker 是否匹配。签名端点记录还绑定端点内容与更新时间,防止未经授权的覆盖。链上状态获取、NFT 转让后的刷新与 DNS 写入仍由接入服务负责。

从“找到机器”到“知道怎样验证机器”,靠的是 synthesizeDidDocument。它根据提供的公钥、轮换事件与服务配置生成文档,将签名验证关系放进 authentication、assertionMethod、capabilityInvocation 和 capabilityDelegation;提供 X25519 公钥时,再声明 keyAgreement。个人空间、AFS、Noise 端点进入 service。

这些字段分别对应 W3C DID Core 的验证关系与服务模型。例如,登录认证和签发声明有不同用途,接入方应按用途选择验证关系,而不是看到文档里有一把公钥就接受所有操作。

域名解析与域名控制证明也要分开。DIF Well Known DID Configuration 用 /.well-known/did-configuration.json 中的 Domain Linkage Credential 证明 origin 与 DID 的关系。我们本轮的 NFT 绑定证明与 DID Document 合成有自己的接口;与外部系统接入时,需要分别核对寻址、控制证明和文档格式。

密钥轮换:预先承诺下一把钥匙,再验证交接

密钥轮换让正常换设备、替换旧密钥有一条可检查的交接路径。预轮换再多保护一步:提前约定下一把钥匙之后,即使当前热钥泄露,攻击者也不能随意指定自己的钥匙接管身份。

公钥哈希生成的身份,很容易遇到一个难题:当前公钥换了,验证器怎样确认新公钥还代表原来的身份?只让当前热钥签一条“换成这把新钥匙”的声明,热钥失陷时,攻击者也能签。

KERI 的 pre-rotation 提供了一个有用思路:提前承诺下一把密钥,轮换时要求新密钥满足这份承诺。我们采用这个思路构造自己的轮换事件与链协议检查;当前事件格式是 ArcBlock 的格式。

createPreRotationCommitment 先把下一把公钥规范化为小写 hex,再计算下面的承诺。下面用 LP 简记长度前缀编码:字符串先转 UTF-8,每个字段写成 u32be(字节数) || 字节内容,最后追加 u32be(index);长度与末尾 index 用 32 位大端无符号值表示;公式中的密钥类型与时间戳字段先转十进制字符串。这样无需依赖分隔符猜测字段边界:

text
C_next = SHA3-256(
  LP("arcblock:did-pre-rotation:v1", keyType, normalizedPublicKey; index=0)
)

轮换事件包含 did、递增的 seq、previousPublicKey、newPublicKey、newKeyType、可选的下一跳承诺和 rotatedAt。旧钥与新钥对同一个域分离摘要分别签名:

text
digest = SHA3-256(LP(
  "arcblock:did-key-rotation:v1",
  DID address, previousPublicKey, newPublicKey,
  newKeyType, nextKeyCommitment, rotatedAt;
  index=seq
))

previousKeySignature = Sign(oldKey, digest)
newKeySignature      = Sign(newKey, digest)

验证器既要确认旧钥授权,也要确认新钥的持有证明。verifyKeyRotationChain 从初始公钥与 DID 的绑定开始,逐跳检查 seq = i + 1、前后公钥连续性、时间不倒退、预承诺和双签名。当前最多接受 64 个事件。下面是验证调用的关键输入:

ts
const result = verifyKeyRotationChain({
  did,
  events,
  genesisPreRotationCommitment: trustedInitialCommitment,
  expectedCurrentPublicKey: presentedPublicKey,
});

if (!result.valid) throw new Error(result.reason);

trustedInitialCommitment 的来源尤其关键。验证者只有在拿到了可信的初始承诺时,才能验证第一跳是否遵守了“早已约定的下一把钥匙”。没有传入初始承诺时,第一跳仍检查初始公钥与 DID 的绑定及双签名,但少了这项先验约束。离线事件链能证明拿到的历史内部连续,最新事件的发现与防旧状态回退仍需要可信更新来源。

链上接入复用了 account_migrate。账户数据里存在有效 nextKeyCommitment 时,迁移会在费用和状态修改前检查目标公钥是否满足承诺,不匹配返回 INVALID_PRE_ROTATION_KEY。成功迁移后,旧承诺被消费并记录,下一跳承诺可以向前推进;实现还拒绝承诺当前钥、已退休钥和已消费过的历史钥,避免后续迁移被锁进死路。

这个接口目前支持 Ed25519、secp256k1 与 Ethereum 同步签名类型的轮换事件,不能直接把异步 Passkey 断言塞进同一调用。预承诺适合保护“热钥已泄露、下一把独立密钥仍安全”的场景;旧钥已经丢失之后如何恢复,需要另外的恢复策略。

选择性披露:摘要承诺与本次出示分别验证

一家服务只需要确认用户已成年,就可以让用户出示可信发证方签过的 over18=true,保留姓名、证件号码和其他资料。会员等级、培训资格也可以按同样方式使用:每次只交付完成这件事所需的已签字段,减少服务方收集和保存的个人资料。

普通 JWT 的签名覆盖整个 payload。持有人出示 token,接收方也就看到了里面的全部字段。RFC 9901 定义的 SD-JWT,将部分字段替换成摘要,出示时再按需提供披露材料。

本轮的三个接口是 signSdJwt、presentSdJwt 和 verifySdJwt。以虚构的资格凭证为例,发证方签发 over18=true 与姓名,但持有人只披露前一项。字段披露材料和 token 的组合关系是:

text
disclosure = base64url(JSON([salt, claimName, claimValue]))
digest     = hex(SHA3-256(disclosure))

issuer JWT: { _sd: [digest, ...], _sd_alg: "sha3-256", cnf: { did: holderDid }, ... }
presentation: issuerJwt ~ selectedDisclosure ~ kbJwt

当前实现为选择性字段生成随机 salt,也支持加入诱饵摘要,减少通过摘要数量推测字段数量的信息。验证方重新计算每份 disclosure 的摘要,确认它出现在签发者签过的 _sd 中,并拒绝重复披露、覆盖已有字段与保留字段的注入。

kbJwt 是持有人针对本次出示签的 Key-Binding JWT。它包含出示内容的 sd_hash,并可绑定接收方 aud 与 challenge nonce。接入方应提供预期的 audience、随机 challenge,以及可信的持有人公钥:

ts
const checked = await verifySdJwt(presentation, issuerPublicKey, {
  issuer: trustedIssuerDid,
  requireKeyBinding: true,
  holderPk: holderPublicKey,
  expectedAudience: verifierOrigin,
  expectedNonce: challenge,
  isStatusRevoked: (index) => !trustedStatusList.isActive(index),
});

// 成功时只读取已经验证的字段,缺失的必需字段按业务规则拒绝
if (!checked.valid || checked.disclosedClaims?.over18 !== true) {
  throw new Error('Adult credential required');
}

issuerPublicKey 应来自验证方认可的签发者,并与固定的 trustedIssuerDid 一起使用;持有人公钥由验证器与签过名的 cnf.did 绑定。应用要确认“谁有资格签发这类资格”,再接受其凭证。这样的验证同时检查签发者、披露内容与本次出示的持有人。challenge 需要由接收方生成,并由应用限制使用次数;单靠 token 中有一个 nonce,不能知道同一份证明是否已经被使用过。

这里的年龄资格要由发证方先签出 over18,当前接口不对隐藏的生日生成范围证明。实现使用的 SHA3-256/hex 摘要与 cnf.did 绑定,也需要在对接 RFC 9901 实现时核对。字段级选择性披露减少交出去的资料,但同一签发凭证中的稳定标识仍可能使多次出示关联起来。

状态列表:把有效性检查变成一次位查询

凭证发出去之后,还需要能撤销或暂停。位图状态列表让签发方更新一批凭证的状态,其他仍有效的凭证继续使用;验证方则可以批量取得状态,在缓存窗口内本地检查,减少每次出示都回查签发者的请求。

签名正确,只说明内容没有被篡改。资格后来撤销,原来的签名仍然正确。W3C Bitstring Status List v1.0 提供了批量发布凭证状态的方式,让验证方可以下载并缓存一份列表,再检查某张凭证对应的位置。

BitstringStatusList 默认容纳 131,072 条记录。单比特模式每条记录表示有效或吊销,原始大小为 16 KiB;双比特模式用 0=ACTIVE、1=REVOKED、2=SUSPENDED,原始大小为 32 KiB。签发凭证时,给它分配一个列表索引,并把“哪份列表、哪个位置”一起签进去。本实现可以用 statusListCredential 和 statusListIndex 记录这两个值。验证者先取得可信列表,再查这个位置。

ts
import { BitstringStatusList } from '@arcblock/did-ext';

const revoked = new BitstringStatusList(); // 默认单比特模式
revoked.revoke(13);
revoked.getRawBytes()[1] === 0b00000100; // true

const list = new BitstringStatusList({ bitSize: 2 });
list.suspend(1042);
list.isActive(1042); // false
list.reinstate(1042);
list.isActive(1042); // true
const encodedList = list.encode(); // gzip + multibase base64url

以单比特模式的第 13 号凭证为例,索引从 0 开始。byteIndex = 13 >> 3 = 1,落在第二个字节;bitOffset = 7 - (13 & 7) = 2,将这个字节与 1 << 2 做按位或,就把相应状态改成了吊销:

text
凭证索引: 8  9 10 11 12 13 14 15
吊销状态: 0  0  0  0  0  1  0  0
第二字节: 00000100

查询时,验证者读取这个字节,再移位、取最低一位即可,时间复杂度为 O(1)。吊销一张凭证只需改变它对应的位,更新并重新签署列表,不必重新签发其他仍有效的凭证。双比特模式沿用同样的寻址思路,每个字节容纳四项状态,增加了临时暂停的表示空间。

位图还有一个适合凭证系统的特性:绝大多数凭证仍有效时,列表里有很长的连续零位,gzip 可以有效压缩这种重复数据。验证者下载一份列表,能在缓存有效期内检查这一批凭证,无需为每一次出示向签发者报出具体凭证。节省请求和减少逐凭证查询暴露,是同一个设计带来的两个收益。

这个例子展示的是本地状态操作。跨服务使用时,需要先通过签名列表验证接口确认发布者与有效期,再信任里面的位。SDK 同时提供状态摘要计算工具,供上层锚定或比对;列表发布、刷新和链上锚定由接入系统完成。解码路径限制压缩输入,并把解压后的原始位图限制在 2 MiB,避免把不可信压缩内容无限展开。

批量列表减少了“每验证一张凭证,就向签发者查询一次”的使用记录,但新鲜度仍取决于刷新策略。缓存五分钟,就需要接受这段窗口内可能不知道新发生的撤销。gzip 体积随状态分布变化,16 KiB 是默认单比特列表的原始大小。当前签名封装和双比特多状态表示采用本实现的约定,第三方凭证系统需验证格式兼容性。

Agent 委托:验签之后,还要检查权限有没有扩大

用户可以给 Agent 一份短时授权,让它代办特定操作。任务继续转交时,接受请求的服务仍能检查“它替谁办事、权限经过了谁、这次操作是否被允许”。把授权交给执行者之后,边界也能跟着传下去。

链上 delegate 适合需要链状态约束的委托。对于短时 Agent 调用,本轮统一验证接口可以接收离线 DelegationToken,沿签名链追溯 Human → Agent A → Agent B 的授权关系。它采用逐级缩小权限的思路,与 UCAN 的能力衰减模型相近;当前 token 的字段和编码使用 ArcBlock 自有约定。

委托 payload 包含 iss、sub、pk、ops、可选 deny、时间字段,以及可选的父 delegation。验证器会检查签发公钥能否推导 iss,父 token 的 sub 是否等于子 token 的 iss,子 token 有效期是否超出父 token,以及子操作集合是否被父授权允许。禁止规则也不能在子授权里被丢掉。默认最大委托深度是 5。

这给接受请求的服务两类证据:当前执行者的身份,以及它所依赖的上游授权。资源端仍要把 ops 映射到实际访问规则。例如“允许读”与“只允许读某个目录”是不同约束,目录判断必须落到资源访问路径上。验证结果应通过真实的允许与越界请求来检查。

verifyArcPrincipal 把直接会话、委托、轮换证明和状态检查组合在同一入口。常用接入形态如下,envelope 是调用方传来的身份与证明,列表和请求绑定值来自验证方:

ts
const principal = await verifyArcPrincipal(envelope, {
  mode: 'strict',
  requiredScope: 'fg:afs:read',
  expectedAudience: serviceAudience,
  expectedNonce: challenge,
  isRevokedIndex: (index) => !trustedStatusList.isActive(index),
});

if (!principal.valid) throw new Error(principal.reason);

三档模式的区别值得直接写在迁移配置里:

模式无密码学证明时的处理
permissive可接受特定旧 Agent 标识或校验和有效的 DID,并返回未验证警告;要求 scope 的操作仍需证明
hybrid要求校验和有效 DID;无证明且未要求 scope 时可接受并警告
strict要求密码学证明;按接入选项检查 scope、请求绑定与状态

返回的 verificationLevel 区分 legacy_string、checksum_only、cryptographic 与 delegated_cryptographic。这样可以先观察仍依赖旧标识的调用,再逐个入口收紧。吊销检查只有在接入回调和相应索引时才有数据可查;受委托调用若要绑定当前请求,还需要提供由执行者签署的会话证明,并由服务端检查 audience、challenge 与使用策略。

Passkey、子地址与加密通信的接点

这批基础工具也服务于更直接的使用体验:通过设备认证器参与签名,按用途组织账户地址,以及与身份已验证的对端建立加密会话。下面三组接口分别处理签名流程、地址分区和通信,接入时可以按需要组合。

Passkey JWT 将浏览器认证器调用拆成两个阶段。先生成 headerB64.bodyB64,用 getChallenge 得到本实现的 SHA3 签名前摘要;浏览器取得 WebAuthn assertion 后,再由 assemble 将 authenticatorData、clientDataJSON 和 signature 组装进 token 第三段。验证侧检查 challenge 与 JWT 内容的绑定,并验证断言签名。当前 PasskeySigner 还强制检查 clientData.type = webauthn.get、用户在场标志 UP,并默认要求用户验证标志 UV。通用链验签路径没有统一的站点 origin/RP ID,不检查 origin、rpIdHash 和签名计数器;网站将这类 JWT 用于登录时,还要按本站策略额外核对预期 origin、RP ID 和已登记凭证的归属。

认证器实际签署的是 WebAuthn 规定的 authenticatorData || SHA-256(clientDataJSON)。JWT 摘要进入 challenge,challenge 再进入 clientDataJSON,这两层哈希承担不同职责。此前的钱包文章讨论了密钥保护、授权显示与恢复,这里补的是 SDK 如何衔接签名过程。

子地址也分了两层。公开身份分区使用固定域分离键和长度前缀编码:

text
seed = HMAC-SHA256(
  key = "arcblock:subaccount:v1",
  message = LP(scope, parentIdentity; index)
)
address = fromPublicKeyHash(seed, type)

这里的 key 是公开的域分离常量,输入也可以是公开身份,计算结果用于确定性地址分区。它不会生成只有硬件知道的秘密。fromChainDid(chainDid, rootSk, type, index) 则以软件根秘密派生独立链钱包,涉及真实的私钥材料;两种派生不能混用安全假设。跨链账户关系需要披露时,还有带 challenge 和过期时间的关联证明接口。

加密通信侧,@arcblock/did-noise 提供 DID 身份绑定的 Noise 会话与传输工具,并包含 Ed25519/X25519 转换接口。Noise Protocol Framework 定义握手模式和密钥协商机制;身份被允许访问什么资源,仍由握手后的授权策略决定。DID Document 中的签名关系、密钥协商关系与服务端点因此各有位置,不必混进同一把“万能公钥”的假设里。

用一条完整请求验证这套组合

一次实际接入,可以沿着同一条请求检查:DNS-safe 标签能否还原原 DID;DID Document 提供的公钥与用途是否匹配;轮换日志能否回到可信初始身份;Agent 的子授权是否收窄;资格字段是否足够且未多交资料;状态列表是否可信、足够新;最后,资源服务是否允许正确请求并拒绝越界请求。

这也是本轮实现的工程价值。解析、轮换、披露、委托与状态分别留下了可检查的输入和结果,应用可以逐项接入,也能在失败时指出具体拒绝原因。DID 要陪用户跨过换机器、换钥匙与换 Agent 的变化,靠的就是这些交接步骤中的验证。

参考与相关链接

这些规范提供对应问题的数据模型与设计依据;本文已在各段注明 ArcBlock 自有编码和接入约定,第三方互操作仍需按具体格式验证。