默认显示最新 1.1;也可查看历史 1.0。
ANP Profile 5:私聊端到端加密(精修稿)
- 文档编号:ANP-P5
- 标题:私聊端到端加密
- 状态:Draft
- 版本:0.3.2(精修稿)
- 语言:中文
- 适用范围:本 Profile 适用于基于
agent_did的私聊端到端加密 Overlay。 - 依赖关系:
anp.core.binding.v1anp.identity.discovery.v1anp.direct.base.v1did:wba身份与证明 Profile(外部依赖)
1. 目标与范围
本 Profile 定义 ANP 私聊端到端加密的完整可实现方案,规定:
- 如何把
did:wba的身份与服务发现能力接入私聊 E2EE; - 如何发布、发现和验证 Prekey Bundle;
- 如何执行基于
X3DH-like的初始异步建链; - 如何使用
Double Ratchet-like保护后续消息; - 如何定义 AAD、重放防护、乱序处理、会话重建与错误模型;
- 如何在不引入设备概念的前提下,把私聊 E2EE 绑定到 Agent。
本 Profile 不定义:
- 设备、多端登录或内部副本同步;
- 历史消息拉取;
- 已读状态;
- Presence;
- 群组端到端加密;
- Agent 内部密钥复制机制;
did:wba方法本身的解析、更新和撤销机制。
2. 规范性关键字与术语
2.1 规范性关键字
本文中的 MUST、MUST NOT、REQUIRED、SHALL、SHALL NOT、SHOULD、SHOULD NOT、RECOMMENDED、NOT RECOMMENDED、MAY、OPTIONAL 按照其大写形式解释为规范性要求。
2.2 术语
- Agent DID:对外互通的 Agent 标识。在本 Profile 中,它既是外层
direct.send的业务主体标识,也是 AAD、Bundle 校验和会话绑定的身份锚点。 - Assertion Key:
did:wbaDID 文档中被assertionMethod授权、并可选同时出现在authentication中的签名密钥。它用于签署 Bundle 或绑定证明,不参与 DH 计算。 - Static Key Agreement Key:DID 文档
keyAgreement中声明的长期 X25519 公钥。它是长期 DH 身份材料,在线协议中通常通过static_key_agreement_id被引用。 - Signed Prekey (SPK):由接收方生成、并由 Assertion Key 进行签名绑定的中期 X25519 公钥。它在初始建链时与发送方的静态/临时密钥共同参与 DH 计算,并通过
signed_prekey.key_id被引用。 - One-Time Prekey (OPK):接收方一次性使用的 X25519 公钥。它用于为初始建链额外提供一次性前向安全增益;若被成功使用,必须消费且不可重用。
- Prekey Bundle:供发起方获取的建链公开材料集合。它描述长期静态协商密钥和当前 SPK 等静态信息,但不直接内嵌 OPK。
- Direct Session:两个 Agent 之间的一个 E2EE 会话,由
session_id标识。后续的 Double Ratchet 状态、乱序处理和重放检测都围绕该会话维护。 - Application Plaintext:被加密前的私聊应用层明文对象。它是 Direct Base 应用负载的归一化内层表示,用于进入 AEAD 加密。
- Ratchet Header:后续消息所需的最小公开头字段。接收方依赖它定位接收链、判断是否需要推进 DH ratchet,并处理乱序消息。
- Replay Cache:用于识别重复初始消息或重复后续消息的状态集合。它是本地状态,不是标准线协议字段。
3. 设计总则
3.1 Agent 是协议终点
本 Profile 的外部互通终点是 agent_did。本 Profile 不识别设备、终端、副本、worker 或执行器。
3.2 安全 Overlay 与业务语义分离
本 Profile 不重新定义私聊业务语义;它复用 anp.direct.base.v1 的:
direct.send方法;sender_did/target.did语义;message_id/operation_id语义;- 私聊成功语义(目标 Agent 入口服务已接受);
- 应用层内容类型语义。
3.3 v1 基线选择
本 Profile 的 v1 强制互通基线 采用:
- 初始建链:
X3DH-like(面向 did:wba 适配) - 长期静态协商密钥:
X25519 - KDF:
HKDF-SHA-256 - 消息 AEAD:
ChaCha20-Poly1305 - 后续消息:
Double Ratchet-like
3.4 未来升级路径
本 Profile 保留向 PQXDH-like 升级的空间。v1 不强制后量子套件,但应通过 suite 注册表为 v2 保留兼容路径。
3.5 默认不强制对 init 消息做长期签名
v1 默认 不强制 对 direct_init 对象整体再做一次长期 DID 身份签名。
原因是:
- X3DH-like 的认证本身来自长期静态协商密钥与接收方 SPK 的组合;
- 接收方 SPK 已由 Assertion Key 绑定;
- 若再要求对整个 init 消息做长期签名,会显著偏向“可归责控制消息”模型,削弱 Signal 风格的可否认性。
若部署需要更强的可审计性,可额外启用 Direct Init Accountability Extension;该扩展不是本 Profile 的默认必选项。
3.6 anp.direct.e2ee.v1 的 MTI wire object 集合
当 method = "direct.send" 且 meta.profile = "anp.direct.e2ee.v1" 时,v1 MTI 路径中的 meta.content_type 仅允许以下两个值:
application/anp-direct-init+jsonapplication/anp-direct-cipher+json
发送方 MUST NOT 在 v1 MTI 路径中使用其它 wire object type。
接收方若收到其它 content_type,MUST 拒绝,除非该能力已通过扩展协商显式启用。
3.7 anp.direct.e2ee.v1 下 direct.send 的消息标识约束
对于 meta.profile = "anp.direct.e2ee.v1" 的 direct.send:
meta.message_idMUST 存在;meta.operation_idMUST 存在;meta.operation_idMUST 与meta.message_id完全相等。
3.8 anp.direct.e2ee.v1 下 direct.send 的 auth 约束
对于 meta.profile = "anp.direct.e2ee.v1" 的 direct.send,params.auth MUST 缺省。
若部署显式协商启用了 Direct Init Accountability Extension,则 MAY 引入额外证明对象;未协商该扩展时,接收方收到 params.auth MUST 拒绝。
3.9 与 P3 原发者绑定要求的关系
对于 P3 第 9.7 节中“auth.origin_proof.contentDigest 或等价的原发者证明摘要”要求,本 Profile 通过以下对象共同满足等价绑定:
AD_initAD_msg- AEAD 保护下的
Application Plaintext
未协商 Direct Init Accountability Extension 时,v1 MTI 不要求 额外携带 P3 形式的 params.auth.origin_proof。
4. did:wba 集成规则
4.1 DID 文档最小要求
支持本 Profile 的 Agent DID 文档 MUST 满足:
- 至少有一个可用于身份认证的Authentication Key和断言签名的 Assertion Key;
- 至少有一个
keyAgreement验证方法; - 至少有一个
ANPMessageService或等价服务入口; - 该
ANPMessageServiceMUST 可被调用方解析和访问,并提供本 Profile 所需的密钥材料方法。
4.2 密钥角色分离
本 Profile MUST 采用密钥角色分离:
authentication/assertionMethod所列密钥用于:- 签署 Prekey Bundle;
- 签署需要强身份归属的控制对象(若某扩展启用);
keyAgreement所列密钥用于:- 长期静态协商;
- 初始共享密钥推导;
- Assertion Key 与 Static Key Agreement Key MUST NOT 混用。
补充说明:在线协议里,前者通常通过 proof.verificationMethod 被引用,后者通常通过 Bundle 中的 static_key_agreement_id 或 init 中的 sender_static_key_agreement_id 被引用;实现方不应让同一把密钥同时承担这两类语义。
4.3 did:wba 指纹绑定
参考 did:wba 规范的约定。
4.4 ANPMessageService 的密钥材料能力
ANPMessageService MUST 承担本 Profile 所需的以下密钥材料能力:
- 发布 Prekey Bundle;
- 补充或轮换 OPK 池;
- 查询 Prekey Bundle;
- 按次发放一个可用 OPK(若存在且策略允许);
- 标记 OPK 已分配、已消费或不可再用;
- 返回 Bundle 撤销或失效状态(若实现支持)。
P5 的理解门槛首先不在算法,而在于“谁负责签、谁负责做 DH、谁负责发布 Bundle / OPK”。下图把 DID 文档中的不同密钥角色与对外公开材料放进同一视图,便于后续阅读建链流程。
flowchart TB
DID[Agent DID 文档]
DID --> AK[Assertion Key<br/>assertionMethod]
DID --> KA[Static Key Agreement Key<br/>keyAgreement / X25519]
AK --> PBP[prekey_bundle.proof]
KA --> PB[prekey_bundle.static_key_agreement_id]
SPK[Signed Prekey<br/>X25519] --> PB
PBP --> PB
OPK[One-Time Prekey 池] --> SVC[ANPMessageService]
PB --> SVC
SVC --> GET[direct.e2ee.get_prekey_bundle]
GET --> RET[返回 prekey_bundle + 可选 OPK]2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
图 P5-1:密钥角色与公开材料关系(非规范性)。
本图强调的是职责分离:Assertion Key 负责身份绑定,长期 keyAgreement 与 SPK / OPK 负责 DH 计算;实现方不应让同一把密钥同时承担这两类语义。
5. 强制互通套件(MTI)
5.1 MTI 套件名称
v1 的 MTI 套件定义为:
ANP-DIRECT-E2EE-X3DH-25519-CHACHA20POLY1305-SHA256-V1
5.2 MTI 套件参数
该套件参数固定如下:
- 静态协商曲线:
X25519 - 临时协商曲线:
X25519 - HKDF:
HKDF-SHA-256 - AEAD:
ChaCha20-Poly1305 - Bundle object proof profile:
prekey_bundle.proofMUST 复用 P1 附录 B 定义的共享Object Proof Profile
本要求至少适用于:
prekey_bundle的被保护文档(本文中亦称Signed Bundle Object)AD_initAD_msgApplication Plaintext
5.3 推荐可选套件
实现 MAY 额外支持:
ANP-DIRECT-E2EE-X3DH-25519-AES256GCM-SHA256-V1ANP-DIRECT-E2EE-PQXDH-HYBRID-V1(预留)
但任何 v1 互通实现 MUST 支持 MTI 套件。
6. 公开材料与 Bundle 结构
6.1 长期静态协商密钥
每个 Agent MUST 在其 DID 文档 keyAgreement 中声明至少一个长期静态 X25519 公钥。
推荐字段:
id:例如did:wba:example.com:agent:alice:e1_xxx#ka-1type:实现自选,但必须明确表示 X25519publicKeyMultibase或等价公钥表示
字段使用说明:
id:用于在 Bundle、Init 消息和 AAD 中精确指明“本次用了哪一个长期 DH 键”。type:用于声明该验证方法的算法与编码方式,便于对端正确解码并校验它确实是 X25519。publicKeyMultibase或等价表示:承载实际的公开密钥字节,是对端执行 DH 的输入来源。
6.2 prekey_bundle 结构
prekey_bundle 表示由接收方长期发布、并由 did:wba 身份证明绑定的静态建链材料。为与 Signal 风格的 OPK 按次发放模型保持一致,prekey_bundle MUST NOT 直接内嵌 one_time_prekey;OPK 由服务端在查询时按次返回。
prekey_bundle 推荐定义如下:
{
"bundle_id": "bundle-20260329-001",
"owner_did": "did:wba:example.com:agent:alice:e1_xxx",
"suite": "ANP-DIRECT-E2EE-X3DH-25519-CHACHA20POLY1305-SHA256-V1",
"static_key_agreement_id": "did:wba:example.com:agent:alice:e1_xxx#ka-1",
"signed_prekey": {
"key_id": "spk-001",
"public_key_b64u": "BASE64URL_X25519_SPK",
"expires_at": "2026-04-05T00:00:00Z"
},
"proof": {
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"verificationMethod": "did:wba:example.com:agent:alice:e1_xxx#assert-1",
"proofPurpose": "assertionMethod",
"created": "2026-03-29T00:00:00Z",
"proofValue": "zBASE58MULTIBASE_PROOF"
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
字段使用说明:
bundle_id:该静态 Bundle 版本的稳定标识。发送方会把它带入direct_init和AD_init,接收方也可用它做审计、缓存命中和重放范围区分。owner_did:Bundle 所属 Agent DID。发送方依据它解析 DID 文档、验证proof并确认该 Bundle 的身份归属。suite:该 Bundle 适用的密码套件名称。发送方应先判断本地是否支持,再决定是否使用该 Bundle 建链。static_key_agreement_id:指向owner_didDID 文档中长期静态 X25519 密钥的 DID URL。它告诉对端“本 Bundle 对应哪把长期 DH 键”。signed_prekey.key_id:当前 SPK 的稳定标识。发送方在direct_init中引用它,接收方据此定位正确的 SPK 私钥。signed_prekey.public_key_b64u:SPK 的公开密钥字节,使用无填充 base64url 表示,是初始 DH 的输入之一。signed_prekey.expires_at:SPK 的失效时间。发送方在使用 Bundle 前必须校验它尚未过期。proof.type:证明对象的类型;v1 MTI 固定为DataIntegrityProof。proof.cryptosuite:具体的 proof cryptosuite 名称;v1 MTI 固定为eddsa-jcs-2022。proof.verificationMethod:用于签署 Bundle 的验证方法 DID URL。发送方应检查它是否属于owner_did文档允许的assertionMethod关系。proof.proofPurpose:声明签名所使用的授权关系,用于与 DID 文档中的授权关系对应。proof.created:该证明生成时间,用于审计和策略检查。proof.proofValue:对 Bundle 规范化表示签出的证明值。
6.2.1 prekey_bundle.proof 与共享 Object Proof Profile
prekey_bundle.proof MUST 复用 P1 附录 B 定义的共享 Object Proof Profile。
对 prekey_bundle 而言:
- issuer DID MUST 为
owner_did - 被保护文档 MUST 是移除
proof后的整个prekey_bundle对象 - 本文将该被保护文档简称为
Signed Bundle Object proof.verificationMethodMUST 指向owner_didDID 文档中被assertionMethod授权的验证方法proofPurposeMUST 固定为assertionMethod
6.2.2 bundle_id 的不可重定义语义
在本地接受窗口内,同一 bundle_id MUST 唯一映射到且仅映射到以下字段组合:
owner_didsuitestatic_key_agreement_idsigned_prekey.key_idsigned_prekey.public_key_b64u
服务端 MAY 在短暂 grace period 内同时接受多个不同 bundle_id,但 MUST NOT 重定义同一 bundle_id。
当某 bundle_id 仍在接受窗口内时,接收方 MUST 保留其对应的 SPK 私钥、Bundle 元数据和必要查找索引,直到该接受窗口结束。
6.2.3 Bundle proof 的对象特定约束
除 P1 附录 B 的共享规则外,v1 MTI 中,prekey_bundle 还 MUST 满足:
proofMUST 存在proof.verificationMethod所属 DID MUST 等于owner_did- 若
prekey_bundle.proof不满足 P1 附录 B,或其 issuer DID 与owner_did不一致,接收方 MUST 以anp.direct.e2ee.bundle_invalid拒绝该 Bundle
6.3 Bundle 字段要求
prekey_bundle MUST 包含:
bundle_idowner_didsuitestatic_key_agreement_idsigned_prekeyproof
prekey_bundle MUST NOT 直接包含:
one_time_prekey
6.4 Bundle proof 的对象特定字段约束
除 P1 附录 B 对“移除 proof 后的整个对象”这一统一保护范围要求外,prekey_bundle 仍 MUST 至少包含并因此整体受 proof 保护以下安全关键字段:
bundle_idowner_didsuitestatic_key_agreement_idsigned_prekey
proof 绑定的是静态 Bundle;运行时按次发放的 OPK MUST NOT 作为静态 Bundle proof 的覆盖内容。
6.5 Bundle 校验要求
发送方在使用 Bundle 前 MUST:
- 解析
owner_did的 DID 文档; - 验证
proof.verificationMethod引用的公钥是否属于owner_didDID 文档中被assertionMethod授权的验证方法; - 按 P1 附录 B 定义的共享 Object Proof Profile 验证
prekey_bundle.proof(其被保护文档即Signed Bundle Object); - 校验
static_key_agreement_id是否存在于 DID 文档keyAgreement; - 校验
suite是否为本地支持的套件; - 校验
signed_prekey.expires_at未过期; - 若
direct.e2ee.get_prekey_bundle的成功响应附带one_time_prekey,发送方 MUST 校验其字段格式合法,并记录其key_id以供后续建链、消费与审计。 - 发送方后续构造
direct_init时,recipient_signed_prekey_idMUST 等于所选prekey_bundle.signed_prekey.key_id。
6.6 OPK 策略
one_time_prekey 在 v1 中 SHOULD 使用,但 MAY 缺省。
为与 Signal 风格的一次性预密钥发放方式保持一致,接收方 SHOULD 预先向其 ANPMessageService 上传一批可供发放的 OPK,由服务端在 direct.e2ee.get_prekey_bundle 的成功响应中按次返回其中一个可用 OPK。
当成功响应附带 OPK 时,其对象格式推荐如下:
{
"key_id": "opk-001",
"public_key_b64u": "BASE64URL_X25519_OPK"
}2
3
4
字段使用说明:
key_id:该 OPK 记录的稳定标识。发送方在direct_init中通过recipient_one_time_prekey_id引用它,接收方用它定位并消费相应私钥。public_key_b64u:该 OPK 的公开密钥字节,使用无填充 base64url 表示,是可选DH4的输入。
规则如下:
- 服务端 SHOULD 在返回 OPK 时立即将其标记为已分配;
- 同一 OPK MUST NOT 被并发分配给多个发送方;
- 若某 OPK 已完成一次成功的初始建链,服务端 MUST 将其标记为已消费,接收方 MUST 删除对应 OPK 私钥;
- 若分配后建链未完成,是否允许回收由部署策略决定;但任何被确认用于成功建链的 OPK MUST NOT 再次发放;
- 若
direct.e2ee.get_prekey_bundle的成功响应未附带 OPK,发起方仍可继续建链,但初始共享秘密的前向安全性将更多依赖 SPK 生命周期。
7. Key Service 方法
7.1 direct.e2ee.publish_prekey_bundle
此方法用于把新的静态 Prekey Bundle 发布到发送方自己公开的 ANPMessageService,并可选补充一批 OPK。
请求要求
meta.profile = anp.direct.e2ee.v1meta.security_profile = transport-protectedmeta.target.kind = "service"meta.target.didMUST 等于发布方自己公开的ANPMessageService.serviceDidmeta.sender_didMUST 存在meta.operation_idMUST 存在- 未协商扩展时,
params.authMUST 缺省 body.prekey_bundleMUST 存在body.prekey_bundle.owner_didMUST 等于meta.sender_didbody.one_time_prekeysMAY 存在;若存在,MUST 为非空数组,且每个元素 MUST 至少包含key_id与public_key_b64u
认证约束:
- 该方法属于 service-scoped 控制面方法;
- v1 标准路径下,调用方 MUST 运行在已认证的本域会话或等价的 hop / service 认证上下文中;
- v1 不要求 为该方法额外定义新的业务层
origin_proof; - 服务端在接受该请求前,除验证 hop / service 级认证外,还 MUST 验证当前已认证 caller 有权代表
meta.sender_did发布body.prekey_bundle.owner_did对应的材料。
幂等要求:
- 服务端 MUST 以
(meta.sender_did, meta.target.did, method, meta.operation_id)作为幂等键; - 同一幂等键的重复请求,若
body.prekey_bundle与body.one_time_prekeys语义等价,则 MUST 返回原结果或等价结果; - 若同一幂等键对应的请求体语义冲突,则 MUST 返回
anp.idempotency_conflict或等价错误。
字段使用说明:
meta.security_profile = transport-protected表示这里是密钥控制面调用,而不是已经建立会话后的 E2EE 密文传输。body.prekey_bundle用于发布或替换当前可供他人获取的静态建链材料。body.one_time_prekeys用于一次性补充 OPK 池;它是可选批量上传项,不会进入静态 Bundle proof。
成功响应
成功响应 MUST 至少包含:
published = trueowner_didbundle_idpublished_at
成功响应 MAY 包含:
published_opk_count
7.2 direct.e2ee.get_prekey_bundle
此方法用于通过目标 Agent 公开的 ANPMessageService 获取一个可用静态 Bundle,并按需附带一个可用 OPK。
请求要求
meta.profile = anp.direct.e2ee.v1meta.security_profile = transport-protectedmeta.target.kind = "service"meta.target.didMUST 等于目标 Agent 公开的ANPMessageService.serviceDidmeta.sender_didMUST 存在meta.operation_idMUST 存在- 未协商扩展时,
params.authMUST 缺省
body MUST 包含:
target_did
body MAY 包含:
preferred_suiterequire_opk
认证约束:
- 该方法属于 service-scoped 控制面方法;
- v1 最小互通要求至少是 hop / service 级认证;
- v1 标准路径下,未协商扩展时,
params.authMUST 缺省; - 匿名获取 Bundle 或 OPK 不属于 v1 MTI;若某部署开放匿名访问,属于额外部署策略。
字段使用说明:
target_did:表示希望获取谁的建链材料;服务端应返回与该 DID 绑定的 Bundle,而不是其它主体的缓存结果。preferred_suite:在服务端同时维护多个套件时,用于表达调用方希望优先拿到哪一个套件的材料。require_opk:表示“没有可用 OPK 就不要退化建链”;若为true,调用方期望拿到可直接用于DH4的一次性预密钥。
成功响应
成功响应 MUST 至少包含:
target_didprekey_bundle
成功响应 MAY 包含:
one_time_prekey
成功响应字段的使用方式如下:
target_did:回显本次返回材料实际归属的目标 DID,便于调用方核对缓存与请求目标是否一致。prekey_bundle:返回目标主体当前可用的静态建链材料;发送方在真正建链前仍需按第 6.5 节完成验证。one_time_prekey:若存在,表示服务端已为本次查询分配了一个可用 OPK;发送方应将其视为与随后的 init 尝试绑定的材料,而不是长期缓存的公共信息。
若 require_opk = true 且服务端当前无可发放 OPK,则服务端 SHOULD 返回显式错误,而不是伪造或复用旧 OPK。
幂等与 OPK 分配要求:
- 服务端 MUST 以
(meta.sender_did, meta.target.did, method, meta.operation_id)作为幂等键; - 若某次成功响应已为该幂等键分配
one_time_prekey,则同一幂等键的重试 MUST 返回同一个prekey_bundle与同一个one_time_prekey; - 服务端 MUST 以原子方式完成“幂等记录写入 + OPK 分配/保留状态写入”;
- 服务端 MUST NOT 因同一幂等键的重试而重新分配另一个 OPK。
7.3 服务端 Bundle 发放规则
ANPMessageService 在返回 Bundle 时:
- SHOULD 优先返回仍在有效期内的最新静态
prekey_bundle; - 若存在可用 OPK 且策略允许,SHOULD 额外附带一个
one_time_prekey; - 返回的
one_time_prekeyMUST 来自owner_did当前可用的 OPK 池,且 MUST NOT 被静态 Bundle proof 冒充为其签名组成部分; - 返回 OPK 后 SHOULD 将其标记为已分配;
- 当本次返回附带 OPK 时,服务端 MUST 以原子方式完成“本次响应的幂等记录写入 + OPK 分配/保留状态写入”;
- MUST NOT 将同一 OPK 并发分配给多个发送方;
- 目标接收方成功处理 init 后,服务端 MUST 将对应 OPK 标记为已消费,接收方 MUST 删除对应 OPK 私钥;
- 若目标服务拒绝或处理失败,是否允许 OPK 回收由部署策略决定;但任何被确认用于成功建链的 OPK MUST NOT 再次发放;
- caller identity、限流与防滥用策略 MUST 基于 hop / service 级认证实施。
8. 初始建链:X3DH-like(did:wba 适配版)
8.1 设计说明
本 Profile 的初始建链 不是 原样照搬 Signal X3DH,而是一个 X3DH-like 适配版:
- X3DH 原始设计中的长期身份 DH 密钥,在本 Profile 中由 DID 文档
keyAgreement的长期静态 X25519 密钥承担; - DID 的 Assertion Key 不参与 DH,而只负责签署 Bundle;
- 因为签名密钥与 DH 密钥已分离,所以原始 X3DH 为 XEdDSA 同钥用途而引入的某些域分离细节不再需要原样照搬。
8.2 参与密钥
发送方 A
KA_A:发送方长期静态 X25519keyAgreement公钥 / 私钥对。在线协议中它通常由sender_static_key_agreement_id指明。EK_A:发送方一次性临时 X25519 公钥 / 私钥对。它为每次新的 init 重新生成,并通过sender_ephemeral_pub_b64u暴露其公钥部分。
接收方 B
KA_B:接收方长期静态 X25519keyAgreement公钥 / 私钥对。发送方通过recipient_bundle_id选定的 Bundle 版本以及其中的static_key_agreement_id间接确定它。SPK_B:接收方带签名的中期 X25519 预密钥。在线协议中它由recipient_signed_prekey_id指明,并由 Bundle proof 绑定到 DID 身份。OPK_B:接收方一次性 X25519 预密钥(可选)。若服务端返回它,则在线协议中由recipient_one_time_prekey_id指明,并在成功建链后消费。
8.3 建链前置条件
本节中的 recipient_did 是对外层 direct.send.params.meta.target.did 的简写,不新增独立线协议字段。
发送方在发起建链前 MUST:
- 解析并验证目标
recipient_did; - 获取并验证目标
prekey_bundle;若响应附带one_time_prekey,则一并记录该 OPK; - 获取并验证发送方自己的长期静态
keyAgreement密钥; - 生成新的临时 X25519 密钥对
EK_A。
8.4 DH 计算
若 direct.e2ee.get_prekey_bundle 的成功响应中 没有 OPK,则发送方 MUST 计算:
DH1 = DH(KA_A, SPK_B)
DH2 = DH(EK_A, KA_B)
DH3 = DH(EK_A, SPK_B)
IKM = DH1 || DH2 || DH32
3
4
若 direct.e2ee.get_prekey_bundle 的成功响应中 有 OPK,则发送方 MUST 额外计算:
DH4 = DH(EK_A, OPK_B)
IKM = DH1 || DH2 || DH3 || DH42
8.5 初始共享秘密派生
发送方和接收方 MUST 使用:
PRK = HKDF-Extract(salt = 0x00...00(32 bytes), IKM)
SK = HKDF-Expand(PRK, info = "ANP Direct E2EE v1 Initial Secret", L = 32)2
然后从 SK 派生:
RK0 = HKDF-Expand(SK, info = "ANP Direct E2EE v1 Root Key", L = 32)
CK0 = HKDF-Expand(SK, info = "ANP Direct E2EE v1 Chain Key", L = 32)
SID = HKDF-Expand(SK, info = "ANP Direct E2EE v1 Session ID", L = 16)2
3
其中:
RK0:初始 Root KeyCK0:首条 init 密文消耗前的初始链起点SID:16 字节会话标识,编码为 Base64URL 后作为session_id
8.5.1 首条消息密钥与 nonce
SK 派生出 RK0 与 CK0 后,发送方和接收方 MUST 立即对 CK0 执行一次 kdf_ck:
CK1, MK0, NONCE0 = kdf_ck(CK0)其中:
MK0仅用于加密或解密direct_init.ciphertext_b64uNONCE0仅用于该首条 init 密文的 AEAD nonceCK1是 init 成功后后续链状态的唯一起点
v1 统一规定:init 消息消耗链上的第 0 条消息。换言之,CK0 只用于派生 MK0、NONCE0 与 CK1;init 成功后双方 MUST 从 CK1 继续,而 MUST NOT 再把 CK0 作为后续发送链或接收链的起点。
此外,ciphertext_b64u 只包含 AEAD ciphertext(含认证标签)本体,不额外拼接或前置 nonce;nonce 一律按本 Profile 的 KDF 规则派生。
8.6 初始 Associated Data
建链时的初始 AEAD AAD,记为 AD_init。其规范性 JSON 结构与唯一字节序列由第 9.4.2 节定义,并 MUST 使用 UTF-8 + RFC 8785 JCS 编码。
AD_init MUST 至少绑定以下字段:
content_type = application/anp-direct-init+jsonsender_didrecipient_didsuiterecipient_bundle_idsender_static_key_agreement_idrecipient_signed_prekey_idrecipient_one_time_prekey_id(若存在)session_idmessage_idprofile = anp.direct.e2ee.v1security_profile = direct-e2ee
这些字段的作用如下:
content_type:绑定当前 wire object type,防止同一密文被当作其它对象类型解释。sender_did/recipient_did:绑定建链双方的业务身份,降低身份错绑风险。suite:绑定所用密码套件,防止同一密文被跨套件误解释。recipient_bundle_id:绑定接收方所使用的具体 Bundle 版本,防止不同 Bundle 间的材料混用。sender_static_key_agreement_id:绑定发送方实际参与 DH 的长期静态密钥。recipient_signed_prekey_id:绑定接收方此次使用的 SPK 版本。recipient_one_time_prekey_id:若存在,绑定此次建链所消费的 OPK 记录。session_id:绑定本次推导出的会话标识,防止同一密文被挪用到其它会话。message_id:这里指外层direct.send.meta.message_id,用于把 init 密文与具体业务消息实例绑定。profile/security_profile:绑定协议解释上下文,防止跨 Profile 或跨安全模式替换。
8.7 初始应用消息
v1 MTI 中,direct_init.ciphertext_b64u MUST 承载一个完整的内层 Application Plaintext 对象。
也就是说,v1 MTI 不定义“空 init”对象;发送方在成功推导 SK 后,MUST 使用由 CK0 经 kdf_ck 派生得到的 MK0 / NONCE0 对首个 Application Plaintext 进行加密,并将其写入 ciphertext_b64u。
下面的时序图把获取 Bundle、选择可选 OPK、发送 direct_init、接收方 bootstrap 与首条回复连成一条完整链路。这样读者在阅读后面的公式时,可以同时看到消息与状态是怎样一起推进的。
sequenceDiagram
participant A as 发起方 A
participant BS as 接收方 B 的 ANPMessageService
participant B as 接收方 B
A->>BS: direct.e2ee.get_prekey_bundle
BS-->>A: prekey_bundle + optional OPK
A->>A: 验证 Bundle / 生成 EK_A / 计算 DH1~DH4 / 派生 SK
A->>BS: direct.send (direct_init)
BS-->>B: 交付 direct_init
B->>B: 重算共享秘密 / 校验 session_id / 解密 init
B->>BS: direct.send (首条 direct-cipher 回复)
BS-->>A: 首条回复
A->>A: 会话进入 established2
3
4
5
6
7
8
9
10
11
12
13
14
图 P5-2:X3DH-like 初始建链时序(非规范性)。
此处的“建立成功”不是 Bundle 获取成功,而是双方都已经依据同一套派生结果完成 bootstrap,并且发起方收到了同一 session_id 下的首条有效回复。
9. application/anp-direct-init+json 对象
9.1 顶层结构
当 meta.content_type = application/anp-direct-init+json 时,body MUST 采用如下结构:
{
"session_id": "BASE64URL_16_BYTES",
"suite": "ANP-DIRECT-E2EE-X3DH-25519-CHACHA20POLY1305-SHA256-V1",
"sender_static_key_agreement_id": "did:wba:example.com:agent:alice:e1_xxx#ka-1",
"recipient_bundle_id": "bundle-20260329-001",
"recipient_signed_prekey_id": "spk-001",
"recipient_one_time_prekey_id": "opk-001",
"sender_ephemeral_pub_b64u": "BASE64URL_X25519_EK_A",
"ciphertext_b64u": "BASE64URL_INIT_CIPHERTEXT"
}2
3
4
5
6
7
8
9
10
9.2 字段要求
direct_init MUST 包含:
session_idsuitesender_static_key_agreement_idrecipient_bundle_idrecipient_signed_prekey_idsender_ephemeral_pub_b64uciphertext_b64u
direct_init MAY 包含:
recipient_one_time_prekey_id
字段使用说明:
session_id:本次新建会话的标识。它由初始共享秘密派生并在后续application/anp-direct-cipher+json消息中复用,用于索引 ratchet 状态。suite:声明本次 init 使用的密码套件,接收方应先据此选择正确的 KDF、AEAD 和报文解释规则。sender_static_key_agreement_id:指向发送方 DID 文档中实际参与 DH 的长期静态keyAgreement键。recipient_bundle_id:表示本次 init 使用了接收方哪一个静态 Bundle 版本;接收方可据此定位对应的 SPK 生命周期、本地接受窗口与重放范围。recipient_signed_prekey_id:指向接收方本次使用的 SPK 记录,接收方需据此找到正确的 SPK 私钥。发送方构造direct_init时,其值 MUST 等于所选prekey_bundle.signed_prekey.key_id。recipient_one_time_prekey_id:若存在,表示本次 init 还消费了一个 OPK;接收方应据此定位并在成功建链后消费对应私钥。sender_ephemeral_pub_b64u:发送方为本次 init 新生成的临时 X25519 公钥,接收方会把它与本地长期/预密钥一起参与 DH 计算。ciphertext_b64u:使用 init 派生的首个消息密钥加密得到的 AEAD 密文字节;v1 中 nonce 不单独传输,而是按本 Profile 的kdf_ck规则派生,因此ciphertext_b64u只包含密文本体,不拼接 nonce。
9.3 外层方法要求
direct_init MUST 通过 direct.send 承载,并满足:
meta.profile = anp.direct.e2ee.v1meta.security_profile = direct-e2eemeta.content_type = application/anp-direct-init+jsonmeta.operation_idMUST 与meta.message_id完全相等- 未协商扩展时,
params.authMUST 缺省
补充说明:这里的外层 meta.content_type 描述的是 body 采用哪一种 E2EE 承载对象格式;首条应用明文的真实业务类型位于被 ciphertext_b64u 加密的内层 Application Plaintext 中。
9.4 唯一建链与状态初始化算法(规范性)
本节统一定义 init 密文生成、init 后本地状态建立、响应方首条回复以及发起方收到首条回复前后的状态推进规则。实现 MUST NOT 在其它章节另外定义与本节冲突的 bootstrap 语义。
9.4.1 输入与前置条件
发送方 A 在发起 direct_init 前 MUST 已完成:
- 解析并验证目标
recipient_did - 获取并验证目标
prekey_bundle - 若响应附带
one_time_prekey,则记录该 OPK - 获取并验证发送方自己的长期静态
keyAgreement密钥 - 生成新的临时 X25519 密钥对
EK_A
发送方和接收方按第 8.4、8.5 节推导出:
SKRK0CK0SID
并令:
session_id = base64url(SID)
接收方在处理 direct_init 时,MUST 验证收到的 body.session_id 与本地推导出的 session_id 完全一致。
9.4.2 AD_init 的唯一字节序列
AD_init MUST 是以下 JSON 对象经 UTF-8 + RFC 8785 JCS 编码后的字节串:
{
"content_type": "application/anp-direct-init+json",
"message_id": "<outer meta.message_id>",
"profile": "anp.direct.e2ee.v1",
"security_profile": "direct-e2ee",
"sender_did": "<outer meta.sender_did>",
"recipient_did": "<outer meta.target.did>",
"suite": "<body.suite>",
"recipient_bundle_id": "<body.recipient_bundle_id>",
"sender_static_key_agreement_id": "<body.sender_static_key_agreement_id>",
"recipient_signed_prekey_id": "<body.recipient_signed_prekey_id>",
"recipient_one_time_prekey_id": "<body.recipient_one_time_prekey_id>",
"session_id": "<body.session_id>"
}2
3
4
5
6
7
8
9
10
11
12
13
14
其中:
recipient_one_time_prekey_id缺省时 MUST 直接省略;- 所有缺省可选字段 MUST 直接省略;
- MUST NOT 使用
null、空字符串或其它占位值代替省略字段。
9.4.3 init 内层明文字节
init 中被加密的 Application Plaintext MUST 先构造成第 10.4 节定义的内层明文对象,再使用 UTF-8 + RFC 8785 JCS 编码为字节串。
9.4.4 init 密文生成
发送方和接收方 MUST 先执行:
CK1, MK0, NONCE0 = kdf_ck(CK0)然后:
- 发送方 MUST 使用
MK0、NONCE0和AD_init加密 init 内层明文; - 生成的 AEAD ciphertext(含 tag)写入
ciphertext_b64u; ciphertext_b64uMUST NOT 拼接 nonce。
9.4.5 init 消耗链上的第 0 条消息
v1 统一规定:init 密文是“初始 A -> B 链”的第 0 条消息。
因此,发送方 A 在成功发出 direct_init 后,本地状态 MUST 设为:
RK = RK0DHs = EK_ADHr = nullCKs = CK1CKr = nullNs = 1Nr = 0PN = 0MKSKIPPED = emptystatus = "pending-confirmation"
其中:
Ns = 1表示第 0 条消息已被 init 消耗;status是本地状态字段,不是线协议字段。
9.4.6 发起方首个回复前的发送限制
当本地会话 status = "pending-confirmation" 时,发起方 MUST NOT 发送独立的 application/anp-direct-cipher+json。
允许对同一 direct_init 做幂等重试;但额外应用消息 MUST 在本地缓冲,直到收到并成功解密对端在同一 session_id 下的首条有效回复。
9.4.7 接收方成功处理 init 后的本地状态
接收方 B 在成功解密 direct_init 后,MUST:
- 若
recipient_one_time_prekey_id存在,则消费对应 OPK - 建立以下本地状态:
RK = RK0DHr = EK_ACKr = CK1Nr = 1DHs = GenerateRatchetKeyPair()(RK, CKs) = kdf_rk(RK, DH(DHs, DHr))Ns = 0PN = 0MKSKIPPED = emptystatus = "established"
9.4.8 接收方首条回复
接收方 B 的首条回复 MUST 使用 application/anp-direct-cipher+json。
该条消息的 ratchet_header MUST 满足:
dh_pub_b64u = DHs.publicpn = "0"n = "0"
其消息密钥与 nonce MUST 通过一次 kdf_ck(CKs) 派生。发送完成后,接收方 MUST 以派生得到的 CKs' 更新本地 CKs,并令 Ns = 1。
9.4.9 发起方收到首条回复
发起方 A 在收到同一 session_id 下的首条有效回复时,若当前会话 status = "pending-confirmation",则收到的 ratchet_header MUST 先满足:
pn = "0"n = "0"
若不满足,发起方 MUST 以 anp.direct.e2ee.bad_init_message 或 anp.direct.e2ee.invalid_security_binding 拒绝,且 MUST NOT 推进本地状态。
在通过上述校验后,发起方 MUST 按以下顺序处理:
- 设
DHr_new = ratchet_header.dh_pub_b64u - 计算:
(RK, CKr) = kdf_rk(RK, DH(DHs, DHr_new))
- 令
DHr = DHr_new - 令
PN = Ns - 令
Ns = 0 - 令
Nr = 0 - 生成新的本地发送 DH 密钥对
DHs_new - 计算:
(RK, CKs) = kdf_rk(RK, DH(DHs_new, DHr))
- 令
DHs = DHs_new - 执行:
CKr_next, MK_reply0, NONCE_reply0 = kdf_ck(CKr)
- 使用
MK_reply0、NONCE_reply0和第 10.5 节定义的AD_msg解密该首条回复 - 若解密成功,令:
CKr = CKr_nextNr = 1status = "established"
若步骤 11 解密失败,实现 MUST 丢弃本次 tentative 状态推进,并保持收到该消息前的会话状态不变。
本节定义完成后,后续消息进入第 10 章的 steady-state Double Ratchet 处理。
仅靠文字描述,pending-confirmation 与 established 的边界很容易被实现者理解错。下图把 v1 的 bootstrap 状态压缩成一张状态图,突出发起方在收到首条有效回复前不能发送独立后续密文这一约束。
stateDiagram-v2
[*] --> NoSession
NoSession --> Pending: 发送 direct_init
state "Pending Confirmation / 禁止独立 direct-cipher" as Pending
Pending --> Pending: 同一 init 幂等重试
Pending --> Established: 收到首条有效回复
Pending --> NoSession: 超时 / 放弃 / 重建
Established --> NoSession: reset / 新 init 重建2
3
4
5
6
7
8
9
10
11
图 P5-3:会话 bootstrap 状态(非规范性)。
实现方在处理发起方本地缓冲、重发和错误恢复时,应以这张状态图为准,而不是把 direct_init 发出后立即视为一个完全可用的 steady-state 会话。
9.5 Init 消息不强制签名
v1 默认 不要求 对整个 direct_init 对象再做一层长期 DID 签名。
若某部署需要更强可审计性,可以额外启用 Direct Init Accountability Extension;该扩展 MAY 在 params.auth 中引入 origin_proof 或等价证明对象。未协商该扩展时,params.auth MUST 缺省。
该扩展不是 v1 MTI 的组成部分。
10. 后续消息:Double Ratchet-like
10.1 初始化状态
init bootstrap、首条 init 密文、接收方首条回复以及发起方收到首条回复前后的状态初始化,统一由第 9.4 节定义。本节只列出 steady-state 会话至少需要保存的字段。
每个会话 MUST 至少保存:
session_idsuitepeer_didRK:Root KeyDHs:本地发送 DH 私钥 / 公钥对DHr:对端最近一次 DH 公钥CKs:发送链 Chain KeyCKr:接收链 Chain KeyNs:发送链消息号Nr:接收链消息号PN:前一发送链长度MKSKIPPED:跳过消息密钥缓存status:本地会话状态,推荐值:pending-confirmation、established
各状态字段的作用如下:
session_id:索引该会话对应的 ratchet 状态。suite:指明该会话应使用的密码套件,便于后续消息按同一规则处理。peer_did:记录对端 Agent DID,供 AAD 绑定和会话归属检查使用。RK:Root Key,用于每次 DH ratchet 推进后继续派生新的链密钥。DHs:本地当前发送 ratchet 的 DH 密钥对;当本端切换 ratchet 时会更新。DHr:对端最近一次被接受的 ratchet 公钥;用于判断何时需要推进接收侧 DH ratchet。CKs/CKr:分别表示发送链和接收链的 Chain Key,用于逐条派生消息密钥。Ns/Nr:分别表示当前发送链和接收链中的消息计数器。PN:上一条发送链的长度,用于通过ratchet_header.pn帮助对端处理 ratchet 切换前的跳号消息。MKSKIPPED:用于缓存乱序消息对应的已派生消息密钥,以便在合理窗口内恢复解密。status:本地会话阶段;发起方在 init 发出后进入pending-confirmation,收到并解密对端首条有效回复后进入established。
10.2 Ratchet Header
当 meta.content_type = application/anp-direct-cipher+json 时,body MUST 包含 ratchet_header:
{
"dh_pub_b64u": "BASE64URL_DH_PUB",
"pn": "12",
"n": "3"
}2
3
4
5
字段要求:
dh_pub_b64u:MUSTpn:MUSTn:MUST
字段使用说明:
dh_pub_b64u:当前发送方 ratchet 公钥,接收方据此判断是否需要推进 DH ratchet。pn:上一发送链的消息总数,用于告诉接收方在 ratchet 切换前最多可能还有多少旧链消息需要处理。n:当前发送链中的消息序号;接收方结合dh_pub_b64u和n确定应派生或查找哪一个消息密钥。
10.2.1 KDF 规则(规范性)
为保证跨实现互通,本 Profile MUST 固定使用以下派生规则:
kdf_rk(RK, dh_out) -> (RK', CK)
PRK = HKDF-Extract(salt = RK, IKM = dh_out)
OUT = HKDF-Expand(PRK, info = "ANP Direct E2EE v1 KDF_RK", L = 64)
RK' = OUT[0:32]
CK = OUT[32:64]2
3
4
kdf_ck(CK) -> (CK', MK, NONCE)
PRK = HKDF-Extract(salt = 0x00...00(32 bytes), IKM = CK)
OUT = HKDF-Expand(PRK, info = "ANP Direct E2EE v1 KDF_CK", L = 76)
CK' = OUT[0:32]
MK = OUT[32:64]
NONCE = OUT[64:76]2
3
4
5
规则说明:
MK作为 AEAD 密钥;NONCE固定为 12 字节,直接作为 ChaCha20-Poly1305 nonce;- v1 中 不单独传输 nonce,也 不把 nonce 拼接进
ciphertext_b64u。
10.2.2 DH Ratchet 推进(规范性)
本节只定义已建立会话的 steady-state DH Ratchet 推进。init 引导、首条 init 密文、接收方首条回复以及发起方首条回复前后的状态初始化,统一由第 9.4 节定义。
若当前会话仍处于 pending-confirmation,或者当前 DHr 尚未建立,则实现 MUST 按第 9.4.9 节处理,而 MUST NOT 直接套用本节。
当接收方收到的 ratchet_header.dh_pub_b64u 与当前记录的 DHr 不一致时,MUST 执行一次 steady-state DH ratchet:
- 先按
ratchet_header.pn处理旧接收链上可能仍需保留的 skipped message keys - 设
DHr_new = ratchet_header.dh_pub_b64u - 计算:
(RK, CKr) = kdf_rk(RK, DH(DHs, DHr_new))
- 令
DHr = DHr_new - 令
PN = Ns - 令
Ns = 0 - 令
Nr = 0 - 生成新的本地发送 DH 密钥对
DHs_new - 计算:
(RK, CKs) = kdf_rk(RK, DH(DHs_new, DHr))
- 令
DHs = DHs_new
10.2.3 消息密钥派生与解密处理(规范性)
本节定义 steady-state 下的最小互通处理算法。
10.2.3.1 RatchetEncrypt()
发送方发送一条后续消息时 MUST:
- 执行
CKs_next, MK, NONCE = kdf_ck(CKs) - 构造
ratchet_header = { dh_pub_b64u = DHs.public, pn = decimal(PN), n = decimal(Ns) } - 按第 10.5 节构造
AD_msg - 使用
MK、NONCE和AD_msg加密第 10.4 节的Application Plaintext - 发送后令:
CKs = CKs_nextNs = Ns + 1
10.2.3.2 TrySkippedMessageKeys()
接收方在处理一条后续消息前 MUST 先检查 MKSKIPPED[(dh_pub_b64u, n)]。若命中,则:
- 取出对应
MK与NONCE - 按第 10.5 节构造
AD_msg - 尝试解密
- 无论成功与否,MUST 删除该条目
- 若解密成功,则完成该消息处理;若失败,则 MUST 返回
anp.direct.e2ee.decrypt_failed
10.2.3.3 SkipMessageKeys(until_n)
当 until_n < Nr 时,MUST NOT 生成新 skipped keys。
当 until_n - Nr > MAX_SKIP 时,MUST 返回 anp.direct.e2ee.max_skip_exceeded。
否则,接收方 MUST 在 Nr < until_n 循环中重复:
- 执行
CKr_next, MK, NONCE = kdf_ck(CKr) - 以键
(DHr, Nr)或等价键把(MK, NONCE)写入MKSKIPPED - 令
CKr = CKr_next - 令
Nr = Nr + 1
10.2.3.4 RatchetDecrypt()
接收方处理一条后续消息时 MUST:
- 先执行
TrySkippedMessageKeys() - 若
ratchet_header.dh_pub_b64u与当前DHr不一致,则先执行第 10.2.2 节的 steady-state DH ratchet - 若
ratchet_header.n < Nr且未命中MKSKIPPED,则 MUST 返回anp.direct.e2ee.decrypt_failed - 执行
SkipMessageKeys(ratchet_header.n) - 执行
CKr_next, MK, NONCE = kdf_ck(CKr) - 按第 10.5 节构造
AD_msg - 使用
MK、NONCE和AD_msg解密 - 若解密成功,令:
CKr = CKr_nextNr = Nr + 1
实现 SHOULD 以 tentative 状态或等价回滚机制执行步骤 5-8;若解密失败,MUST NOT 消耗 CKr 或增加 Nr,并 MUST 返回 anp.direct.e2ee.decrypt_failed。
进入 steady-state 之后,P5 的难点转移为:什么时候只是链上推进,什么时候需要执行一次新的 DH ratchet。下图把 RK / DHs / DHr / CKs / CKr / Ns / Nr / PN 的核心推进路径集中展示出来。
flowchart TD
Start[当前会话状态<br/>RK / DHs / DHr / CKs / CKr / Ns / Nr / PN]
Start --> Send[发送后续消息]
Send --> K1[kdf_ck(CKs)]
K1 --> MK1[得到 MK / NONCE / CKs']
MK1 --> SMsg[加密 Application Plaintext]
SMsg --> SUpd[更新 CKs = CKs' ; Ns++]
Start --> RecvSame[接收消息<br/>dh_pub 与当前 DHr 相同]
RecvSame --> K2[kdf_ck(CKr)]
K2 --> MK2[得到 MK / NONCE / CKr']
MK2 --> RMsg[解密并验证 AD_msg]
RMsg --> RUpd[更新 CKr = CKr' ; Nr++]
Start --> RecvNew[接收消息<br/>dh_pub 与当前 DHr 不同]
RecvNew --> RK1[kdf_rk(RK, DH(DHs, DHr_new))]
RK1 --> NewCKr[得到新的 CKr]
NewCKr --> Rotate[生成新的 DHs]
Rotate --> RK2[kdf_rk(RK, DH(DHs_new, DHr_new))]
RK2 --> NewCKs[得到新的 CKs]
NewCKs --> ResetCtr[PN = Ns ; Ns = 0 ; Nr = 0]2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
图 P5-4:Double Ratchet 状态推进(非规范性)。
阅读后面的 RatchetEncrypt()、RatchetDecrypt() 和 skipped message key 处理逻辑时,可以把它们理解为这张状态推进图在发送、接收和乱序恢复上的具体展开。
10.3 application/anp-direct-cipher+json 对象
推荐结构如下:
{
"session_id": "BASE64URL_16_BYTES",
"ratchet_header": {
"dh_pub_b64u": "BASE64URL_DH_PUB",
"pn": "12",
"n": "3"
},
"ciphertext_b64u": "BASE64URL_CIPHERTEXT"
}2
3
4
5
6
7
8
9
字段要求:
session_id:MUSTratchet_header:MUSTciphertext_b64u:MUSTsuite:MAY
字段使用说明:
session_id:指出这条密文属于哪一个已建立的 Direct Session,接收方据此加载对应的 ratchet 状态。suite:若存在,则其值 MUST 与session_id绑定的会话套件完全一致;若缺省,接收方 MUST 使用本地会话状态中已绑定的套件。ratchet_header:承载后续消息公开可见但必须受认证绑定的 ratchet 坐标。ciphertext_b64u:对内层Application Plaintext加密后的 AEAD 密文字节串,使用无填充 base64url 表示;v1 中其值 只包含密文本体,不包含 nonce。
10.4 内层 Application Plaintext
发送方在加密前 MUST 把 Direct Base 的应用负载归一化为:
{
"application_content_type": "text/plain | application/json | application/anp-attachment-manifest+json | ...",
"conversation_id": "conv-001",
"reply_to_message_id": "msg-0001",
"annotations": {},
"text": "...",
"payload": {},
"payload_b64u": "..."
}2
3
4
5
6
7
8
9
字段使用说明:
application_content_type:内层原始应用负载类型。它相当于 Direct Base 中的meta.content_type在密文内部的对应字段。conversation_id:可选的应用会话上下文标识;它用于会话归并,而不是 E2EE 会话或 ratchet 状态标识。reply_to_message_id:可选的回复引用,表示这条内层应用消息正在回复哪一条业务消息。annotations:加密保护下的扩展应用元数据;它与 P3 的annotations字段保持同名同义,不应用于外层路由或幂等判定。text/payload/payload_b64u:分别对应明文文本、结构化 JSON 对象和二进制扩展负载三种互斥承载方式,语义与 Direct Base 保持一致,只是位置转移到了密文内部。
并满足:
application_content_typeMUST 存在;text/payload/payload_b64uMUST 恰好出现一个。
发送方在加密前 MUST 将整个 Application Plaintext 对象使用 UTF-8 + RFC 8785 JCS 序列化为字节串;接收方解密后 MUST 按相同规则解释该对象。
缺省可选字段 MUST 直接省略;conversation_id、reply_to_message_id 缺省时 MUST NOT 使用 null 或空字符串占位。annotations 若缺省 MUST 省略;若存在,则 MAY 为 {}。除字段语义另有规定外,发送方 MUST NOT 使用空对象、空数组或其它占位值代替“字段不存在”。
10.5 消息 AAD
每条后续消息的 AEAD AAD,记为 AD_msg,MUST 是以下 JSON 对象经 UTF-8 + RFC 8785 JCS 编码后的字节串:
{
"content_type": "application/anp-direct-cipher+json",
"message_id": "<outer meta.message_id>",
"profile": "anp.direct.e2ee.v1",
"security_profile": "direct-e2ee",
"sender_did": "<outer meta.sender_did>",
"recipient_did": "<outer meta.target.did>",
"session_id": "<body.session_id>",
"ratchet_header": { ... }
}2
3
4
5
6
7
8
9
10
这些字段的作用如下:
content_type:绑定当前 wire object type,防止同一密文被解释成其它对象。message_id:这里指外层direct.send.meta.message_id,用于把密文和应用层消息标识绑定。profile/security_profile:防止密文被跨 Profile 或跨安全模式复用。sender_did/recipient_did:绑定后续密文所属的业务发送方与接收方。session_id:绑定到具体的 Direct Session,防止密文被搬运到其它会话状态中解释。ratchet_header:把公开头和密文作为一个整体认证,防止头密文字段被拆换或拼接。
说明:
ratchet_headerMUST 与 wire object 中实际发送的ratchet_header完全一致;application_content_type继续只存在于内层Application Plaintext中,由 AEAD 本身保护完整性;- v1 不要求 把
attachment_manifest_digest作为额外 AAD 字段;若部署自行增加该绑定,属于扩展能力而非 MTI。
10.6 Header Encryption
v1 中,Header Encryption 不是 MTI 必选项。
实现 MAY 支持 Header Encryption 变体;但若支持,必须在能力协商中显式通告,并定义头部 AEAD 与 nonce 规则。
11. 乱序处理、跳号与 Replay 防护
11.1 状态型防重放
对于 anp.direct.e2ee.v1 下的 direct.send,由于 operation_id MUST 与 message_id 完全相等,外层业务幂等键和消息标识绑定为同一值。接收方 MUST 先完成外层幂等检查,再推进任何 ratchet 状态。
接收方 MUST 至少基于以下维度做防重放:
session_idmessage_idratchet_header.dh_pub_b64uratchet_header.n- 密文摘要(可选)
11.2 跳号支持
实现 MUST 支持一定范围内的跳号消息,并为此维护 MKSKIPPED。
11.3 MAX_SKIP
实现 MUST 定义 MAX_SKIP。
v1 推荐值:
MAX_SKIP = 1000
实现 MAY 采用更小或更大的值,但必须通过能力协商对外暴露。
11.4 Skipped Key 删除
实现 SHOULD 为 MKSKIPPED 设定:
- 每会话最大条目数;
- 删除策略;
- 确定性触发条件。
推荐优先采用基于“消息接收数 / ratchet 步数”的确定性删除,而不是单纯基于墙钟时间。
11.5 初始消息重放与幂等
接收方 MUST 同时维护两类状态:
外层幂等记录,键至少为:
(sender_did, recipient_did, method, operation_id)
init replay 记录,键至少为:
(recipient_bundle_id, sender_did, sender_ephemeral_pub_b64u, session_id)
处理规则如下:
- 若重复请求命中相同
operation_id,且其 init replay 键也相同,则 MUST 视为同一次 init 的幂等重试,并返回原结果或等价结果; - 若 init replay 键相同,但
operation_id或message_id不同,则 MUST 拒绝,并返回anp.direct.e2ee.replay_detected; - 对后续
application/anp-direct-cipher+json,ratchet 状态 MUST NOT 因重复投递而再次推进。
12. 会话重建与控制消息
12.1 重建原则
当出现以下情况时,会话 SHOULD 重建:
- 连续解密失败达到本地阈值;
- 收到明确的重建指令;
- 对端 suite 改变;
- 本地策略要求更新长期材料。
12.2 v1 推荐重建方式
v1 推荐直接通过新的 application/anp-direct-init+json 重新建链,而不是设计复杂的单独 rekey 协议。
12.2.1 会话并存与默认出站会话
同一 (local_agent_did, peer_did, suite) 之间,实现 MAY 并存多个 session_id。
当应用层未显式指定 session_id 时,默认出站会话 SHOULD 选择最近一个 status = "established" 的会话。若不存在任何 established 会话,则发送方 SHOULD 发起新的 direct_init,而 MUST NOT 把消息发送到 pending-confirmation 会话的独立 application/anp-direct-cipher+json 上。
接收方收到未知 session_id 的 application/anp-direct-cipher+json 时,MUST 返回 anp.direct.e2ee.session_not_found。
12.3 控制对象(非 MTI 扩展)
application/anp-direct-control+json 不属于 v1 MTI,也不是 anp.direct.e2ee.v1 下 direct.send 的标准 wire object。
若实现需要显式会话控制,MAY 通过扩展协商定义该对象。未协商该扩展时,发送方 MUST NOT 使用它,接收方 MUST 拒绝它。
推荐控制类型:
resetcloseerror
这些控制对象 SHOULD 由已建立的会话密钥保护;若会话已失效,则优先使用新的 init 重新建链。
13. 错误模型
本 Profile 为私聊 E2EE 错误固定分配 5000-5012 码段。服务端返回本节错误时,error.data.anp_code MUST 存在。
code | anp_code | 含义 |
|---|---|---|
| 4000 | anp.direct.e2ee.bundle_not_found | 未找到可用 Bundle |
| 4001 | anp.direct.e2ee.bundle_invalid | Bundle 无效或 proof 校验失败 |
| 4002 | anp.direct.e2ee.bundle_expired | Bundle 或 SPK 已过期 |
| 4003 | anp.direct.e2ee.opk_unavailable | 请求要求 OPK,但当前无可用 OPK |
| 4004 | anp.direct.e2ee.missing_key_agreement | 缺少可用 keyAgreement 材料 |
| 4005 | anp.direct.e2ee.session_not_found | 未找到会话状态 |
| 4006 | anp.direct.e2ee.session_conflict | 会话状态冲突或重复建链冲突 |
| 4007 | anp.direct.e2ee.bad_init_message | init 消息结构或绑定校验失败 |
| 4008 | anp.direct.e2ee.replay_detected | 检测到重放或与幂等不兼容的重复 init |
| 4009 | anp.direct.e2ee.decrypt_failed | 解密失败 |
| 4010 | anp.direct.e2ee.max_skip_exceeded | 超出 MAX_SKIP 上限 |
| 4011 | anp.direct.e2ee.reset_required | 本地策略要求重建会话 |
| 4012 | anp.direct.e2ee.invalid_security_binding | 外层绑定、AAD 绑定或安全模式绑定不一致 |
错误响应 SHOULD 在 error.data 中尽量提供:
target_didbundle_idopk_idsession_idrequired_security_profileretryable
14. 安全要求与限制
14.1 身份错绑防护
实现 MUST 在初始消息的 AAD 中绑定:
sender_didrecipient_did- 相关 key id
bundle_idsession_id
以降低 identity misbinding / unknown key share 风险。
14.2 Bundle 签名不可省略
即使 DH 计算本身提供某些认证属性,接收方的 Signed Prekey 仍 MUST 由 DID 文档中被 assertionMethod 授权的验证方法绑定。省略这一步会让恶意服务器有机会提供伪造 prekey 并削弱前向安全。
one_time_prekey 不要求进入静态 Bundle proof;OPK 的一次性语义由服务端按次发放与消费状态管理保证。恶意服务端最多应导致拒绝发放、建链失败或退化为无 OPK 路径,而 MUST NOT 取代对 signed_prekey 绑定的验证。
14.3 服务器信任边界
恶意 Key Service 或中继服务依然可以:
- 拒绝转发;
- 拒绝发放 OPK;
- 诱导建链失败。
因此,长期部署 SHOULD 叠加透明目录、Bundle 可审计日志或等价机制。
14.4 X3DH-like v1 的边界
使用 X3DH-like 作为 v1 是可行的,但必须明确:
- 它不是后量子方案;
- 它要求长期静态
keyAgreement密钥与 Assertion Key 分离; - 它默认不提供“所有控制对象都长期签名”的强归责语义;
- 若需要更强的抗未来量子攻击能力,应在后续版本切换到 PQXDH-like 套件。
15. 最小互通要求
一个符合本 Profile 的实现至少 MUST 支持:
- 解析 DID 文档中的
authentication、assertionMethod、keyAgreement、ANPMessageService; - 发布与获取
prekey_bundle;若实现宣称支持 OPK,还 MUST 支持 OPK 池补充与按次发放; - 对
direct.e2ee.publish_prekey_bundle/direct.e2ee.get_prekey_bundle明确采用target.kind = "service"的 service-scoped 目标模型; - 验证
prekey_bundle.proof,并支持 P1 附录 B 定义的共享Object Proof Profile; - 使用 MTI 套件执行 X3DH-like 初始建链;
- 在
anp.direct.e2ee.v1下,direct.send的 v1 MTI 路径仅允许application/anp-direct-init+json与application/anp-direct-cipher+json; - 在
anp.direct.e2ee.v1下,direct.send的meta.operation_idMUST 与meta.message_id完全相等; - 在
anp.direct.e2ee.v1下,未协商扩展时direct.send.params.authMUST 缺省; - 通过
direct.send承载application/anp-direct-init+json; - 通过
direct.send承载application/anp-direct-cipher+json; - 固定使用本 Profile 定义的
kdf_rk、kdf_ck与派生 nonce 规则; - init 消息 MUST 消耗链上的第 0 条消息,init 成功后双方 MUST 从
CK1继续; direct_init.ciphertext_b64uMUST 承载一个完整的Application Plaintext;- 发起方在收到对端第一条有效回复前 MUST NOT 发送独立的
application/anp-direct-cipher+json; - 维护 Double Ratchet-like 状态;
- 实现
RatchetEncrypt()、RatchetDecrypt()、SkipMessageKeys()、TrySkippedMessageKeys()的等价处理逻辑; - 实现
MAX_SKIP与MKSKIPPED; - 对
direct.e2ee.publish_prekey_bundle/direct.e2ee.get_prekey_bundle实现幂等处理;若get_prekey_bundle已分配 OPK,则同一幂等键重试 MUST 返回同一个 OPK; - 防止初始消息重放导致的密钥灾难性复用;
- 不把设备或内部副本概念暴露到线协议中。
16. 示例
16.1 发布 Bundle
{
"jsonrpc": "2.0",
"id": "req-50001",
"method": "direct.e2ee.publish_prekey_bundle",
"params": {
"meta": {
"anp_version": "1.0",
"profile": "anp.direct.e2ee.v1",
"security_profile": "transport-protected",
"sender_did": "did:wba:example.com:agent:alice:e1_xxx",
"target": {
"kind": "service",
"did": "did:wba:example.com"
},
"operation_id": "op-50001",
"created_at": "2026-03-29T12:00:00Z"
},
"body": {
"prekey_bundle": {
"bundle_id": "bundle-20260329-001",
"owner_did": "did:wba:example.com:agent:alice:e1_xxx",
"suite": "ANP-DIRECT-E2EE-X3DH-25519-CHACHA20POLY1305-SHA256-V1",
"static_key_agreement_id": "did:wba:example.com:agent:alice:e1_xxx#ka-1",
"signed_prekey": {
"key_id": "spk-001",
"public_key_b64u": "BASE64URL_X25519_SPK",
"expires_at": "2026-04-05T00:00:00Z"
},
"proof": {
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"verificationMethod": "did:wba:example.com:agent:alice:e1_xxx#assert-1",
"proofPurpose": "assertionMethod",
"created": "2026-03-29T00:00:00Z",
"proofValue": "zBASE58MULTIBASE_PROOF"
}
},
"one_time_prekeys": [
{
"key_id": "opk-001",
"public_key_b64u": "BASE64URL_X25519_OPK_001"
},
{
"key_id": "opk-002",
"public_key_b64u": "BASE64URL_X25519_OPK_002"
}
]
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
16.2 初始建链消息
{
"jsonrpc": "2.0",
"id": "req-50002",
"method": "direct.send",
"params": {
"meta": {
"anp_version": "1.0",
"profile": "anp.direct.e2ee.v1",
"security_profile": "direct-e2ee",
"sender_did": "did:wba:example.com:agent:alice:e1_xxx",
"target": {
"kind": "agent",
"did": "did:wba:example.org:agent:bob:e1_yyy"
},
"operation_id": "msg-50002",
"message_id": "msg-50002",
"created_at": "2026-03-29T12:01:00Z",
"content_type": "application/anp-direct-init+json"
},
"body": {
"session_id": "BASE64URL_16_BYTES",
"suite": "ANP-DIRECT-E2EE-X3DH-25519-CHACHA20POLY1305-SHA256-V1",
"sender_static_key_agreement_id": "did:wba:example.com:agent:alice:e1_xxx#ka-1",
"recipient_bundle_id": "bundle-bob-20260329-001",
"recipient_signed_prekey_id": "spk-bob-001",
"recipient_one_time_prekey_id": "opk-bob-007",
"sender_ephemeral_pub_b64u": "BASE64URL_EK_A",
"ciphertext_b64u": "BASE64URL_INIT_CIPHERTEXT"
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
16.3 后续加密消息
{
"jsonrpc": "2.0",
"id": "req-50003",
"method": "direct.send",
"params": {
"meta": {
"anp_version": "1.0",
"profile": "anp.direct.e2ee.v1",
"security_profile": "direct-e2ee",
"sender_did": "did:wba:example.com:agent:alice:e1_xxx",
"target": {
"kind": "agent",
"did": "did:wba:example.org:agent:bob:e1_yyy"
},
"operation_id": "msg-50003",
"message_id": "msg-50003",
"created_at": "2026-03-29T12:02:00Z",
"content_type": "application/anp-direct-cipher+json"
},
"body": {
"session_id": "BASE64URL_16_BYTES",
"ratchet_header": {
"dh_pub_b64u": "BASE64URL_DH_PUB",
"pn": "4",
"n": "1"
},
"ciphertext_b64u": "BASE64URL_CIPHERTEXT"
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
附录 A(信息性):与原始 X3DH 的差异
本 Profile 与原始 Signal X3DH 的关键差异如下:
- 长期 DH 身份由 DID 文档
keyAgreement承担,而不是由签名身份键直接承担; - 静态 Bundle 绑定复用
did:wbaproof,而不是原始 XEdDSA 专用格式;OPK 不进入静态 Bundle proof,而由服务端按次发放; - 本 Profile 以 Agent 为协议主体,不引入多设备线协议语义;
- v1 默认不强制对 init 消息整体长期签名,以保留更接近 Signal 风格的安全属性;
- v1 明确保留向 PQXDH-like 升级的路径。
附录 B(信息性):后续建议
- 建议尽快增加
ANP-DIRECT-E2EE-PQXDH-HYBRID-V1作为 v2 候选; - 建议为 Bundle 发布增加透明目录或审计日志;
- 建议把 did:wba proof 的规范化规则在身份 Profile 中写死,以避免实现差异;
- 建议为可审计部署定义
Direct Init Accountability Extension,但不要把它做成 MTI。