Skip to content

默认显示最新 1.1;也可查看历史 1.0。

ANP Profile 5:私聊端到端加密

  • 文档编号:ANP-P5
  • 标题:私聊端到端加密
  • 状态:已发布
  • 版本:1.1
  • 语言:中文
  • 适用范围:本 Profile 适用于基于 agent_did 的私聊端到端加密 Overlay。
  • 依赖关系:
    • anp.core.binding.v1
    • anp.identity.discovery.v1
    • anp.direct.base.v1
    • did:wba 身份与证明 Profile(外部依赖)

1. 目标与范围

本 Profile 定义 ANP 私聊端到端加密的完整可实现方案,规定:

  1. 如何把 did:wba 的身份与服务发现能力接入私聊 E2EE;
  2. 如何发布、发现和验证 Prekey Bundle;
  3. 如何执行基于 X3DH-like 的初始异步建链;
  4. 如何使用 Double Ratchet-like 保护后续消息;
  5. 如何定义 AAD、重放防护、乱序处理、会话重建与错误模型;
  6. 如何在不引入设备概念的前提下,把私聊 E2EE 绑定到 Agent。

本 Profile 定义:

  • 设备、多端登录或内部副本同步;
  • 历史消息拉取;
  • 已读状态;
  • Presence;
  • 群组端到端加密;
  • Agent 内部密钥复制机制;
  • did:wba 方法本身的解析、更新和撤销机制。

2. 规范性关键字与术语

2.1 规范性关键字

本文中的 MUSTMUST NOTREQUIREDSHALLSHALL NOTSHOULDSHOULD NOTRECOMMENDEDNOT RECOMMENDEDMAYOPTIONAL 按照其大写形式解释为规范性要求。

2.2 术语

  • Agent DID:对外互通的 Agent 标识。在本 Profile 中,它既是外层 direct.send 的业务主体标识,也是 AAD、Bundle 校验和会话绑定的身份锚点。
  • Assertion Keydid:wba DID 文档中被 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 身份签名。

原因是:

  1. X3DH-like 的认证本身来自长期静态协商密钥与接收方 SPK 的组合;
  2. 接收方 SPK 已由 Assertion Key 绑定;
  3. 若再要求对整个 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+json
  • application/anp-direct-cipher+json

发送方 MUST NOT 在 v1 MTI 路径中使用其它 wire object type。
接收方若收到其它 content_typeMUST 拒绝,除非该能力已通过扩展协商显式启用。

3.7 anp.direct.e2ee.v1direct.send 的消息标识约束

对于 meta.profile = "anp.direct.e2ee.v1"direct.send

  • meta.message_id MUST 存在;
  • meta.operation_id MUST 存在;
  • meta.operation_id MUSTmeta.message_id 完全相等。

3.8 anp.direct.e2ee.v1direct.sendauth 约束

对于 meta.profile = "anp.direct.e2ee.v1"direct.sendparams.auth MUST 缺省。

若部署显式协商启用了 Direct Init Accountability Extension,则 MAY 引入额外证明对象;未协商该扩展时,接收方收到 params.auth MUST 拒绝。


3.9 与 P3 原发者绑定要求的关系

对于 P3 第 9.7 节中“auth.origin_proof.contentDigest 或等价的原发者证明摘要”要求,本 Profile 通过以下对象共同满足等价绑定:

  • AD_init
  • AD_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 满足:

  1. 至少有一个可用于身份认证的Authentication Key和断言签名的 Assertion Key;
  2. 至少有一个 keyAgreement 验证方法;
  3. 至少有一个 ANPMessageService 或等价服务入口;
  4. ANPMessageService MUST 可被调用方解析和访问,并提供本 Profile 所需的密钥材料方法。

4.2 密钥角色分离

本 Profile MUST 采用密钥角色分离:

  1. authentication / assertionMethod 所列密钥用于:
    • 签署 Prekey Bundle;
    • 签署需要强身份归属的控制对象(若某扩展启用);
  2. keyAgreement 所列密钥用于:
    • 长期静态协商;
    • 初始共享密钥推导;
  3. 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 文档中的不同密钥角色与对外公开材料放进同一视图,便于后续阅读建链流程。

mermaid
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]

图 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.proof MUST 复用 P1 附录 B 定义的共享 Object Proof Profile

本要求至少适用于:

  • prekey_bundle 的被保护文档(本文中亦称 Signed Bundle Object
  • AD_init
  • AD_msg
  • Application Plaintext

5.3 推荐可选套件

实现 MAY 额外支持:

  • ANP-DIRECT-E2EE-X3DH-25519-AES256GCM-SHA256-V1
  • ANP-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-1
  • type:实现自选,但必须明确表示 X25519
  • publicKeyMultibase 或等价公钥表示

字段使用说明:

  • 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 推荐定义如下:

json
{
  "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"
  }
}

字段使用说明:

  • bundle_id:该静态 Bundle 版本的稳定标识。发送方会把它带入 direct_initAD_init,接收方也可用它做审计、缓存命中和重放范围区分。
  • owner_did:Bundle 所属 Agent DID。发送方依据它解析 DID 文档、验证 proof 并确认该 Bundle 的身份归属。
  • suite:该 Bundle 适用的密码套件名称。发送方应先判断本地是否支持,再决定是否使用该 Bundle 建链。
  • static_key_agreement_id:指向 owner_did DID 文档中长期静态 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 MUSTowner_did
  • 被保护文档 MUST 是移除 proof 后的整个 prekey_bundle 对象
  • 本文将该被保护文档简称为 Signed Bundle Object
  • proof.verificationMethod MUST 指向 owner_did DID 文档中被 assertionMethod 授权的验证方法
  • proofPurpose MUST 固定为 assertionMethod

6.2.2 bundle_id 的不可重定义语义

在本地接受窗口内,同一 bundle_id MUST 唯一映射到且仅映射到以下字段组合:

  • owner_did
  • suite
  • static_key_agreement_id
  • signed_prekey.key_id
  • signed_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_bundleMUST 满足:

  • proof MUST 存在
  • proof.verificationMethod 所属 DID MUST 等于 owner_did
  • prekey_bundle.proof 不满足 P1 附录 B,或其 issuer DID 与 owner_did 不一致,接收方 MUSTanp.direct.e2ee.bundle_invalid 拒绝该 Bundle

6.3 Bundle 字段要求

prekey_bundle MUST 包含:

  • bundle_id
  • owner_did
  • suite
  • static_key_agreement_id
  • signed_prekey
  • proof

prekey_bundle MUST NOT 直接包含:

  • one_time_prekey

6.4 Bundle proof 的对象特定字段约束

除 P1 附录 B 对“移除 proof 后的整个对象”这一统一保护范围要求外,prekey_bundleMUST 至少包含并因此整体受 proof 保护以下安全关键字段:

  • bundle_id
  • owner_did
  • suite
  • static_key_agreement_id
  • signed_prekey

proof 绑定的是静态 Bundle;运行时按次发放的 OPK MUST NOT 作为静态 Bundle proof 的覆盖内容。

6.5 Bundle 校验要求

发送方在使用 Bundle 前 MUST

  1. 解析 owner_did 的 DID 文档;
  2. 验证 proof.verificationMethod 引用的公钥是否属于 owner_did DID 文档中被 assertionMethod 授权的验证方法;
  3. 按 P1 附录 B 定义的共享 Object Proof Profile 验证 prekey_bundle.proof(其被保护文档即 Signed Bundle Object);
  4. 校验 static_key_agreement_id 是否存在于 DID 文档 keyAgreement
  5. 校验 suite 是否为本地支持的套件;
  6. 校验 signed_prekey.expires_at 未过期;
  7. direct.e2ee.get_prekey_bundle 的成功响应附带 one_time_prekey,发送方 MUST 校验其字段格式合法,并记录其 key_id 以供后续建链、消费与审计。
  8. 发送方后续构造 direct_init 时,recipient_signed_prekey_id MUST 等于所选 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 时,其对象格式推荐如下:

json
{
  "key_id": "opk-001",
  "public_key_b64u": "BASE64URL_X25519_OPK"
}

字段使用说明:

  • 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.v1
  • meta.security_profile = transport-protected
  • meta.target.kind = "service"
  • meta.target.did MUST 等于发布方自己公开的 ANPMessageService.serviceDid
  • meta.sender_did MUST 存在
  • meta.operation_id MUST 存在
  • 未协商扩展时,params.auth MUST 缺省
  • body.prekey_bundle MUST 存在
  • body.prekey_bundle.owner_did MUST 等于 meta.sender_did
  • body.one_time_prekeys MAY 存在;若存在,MUST 为非空数组,且每个元素 MUST 至少包含 key_idpublic_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_bundlebody.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 = true
  • owner_did
  • bundle_id
  • published_at

成功响应 MAY 包含:

  • published_opk_count

7.2 direct.e2ee.get_prekey_bundle

此方法用于通过目标 Agent 公开的 ANPMessageService 获取一个可用静态 Bundle,并按需附带一个可用 OPK。

请求要求

  • meta.profile = anp.direct.e2ee.v1
  • meta.security_profile = transport-protected
  • meta.target.kind = "service"
  • meta.target.did MUST 等于目标 Agent 公开的 ANPMessageService.serviceDid
  • meta.sender_did MUST 存在
  • meta.operation_id MUST 存在
  • 未协商扩展时,params.auth MUST 缺省

body MUST 包含:

  • target_did

body MAY 包含:

  • preferred_suite
  • require_opk

认证约束:

  • 该方法属于 service-scoped 控制面方法;
  • v1 最小互通要求至少是 hop / service 级认证;
  • v1 标准路径下,未协商扩展时,params.auth MUST 缺省;
  • 匿名获取 Bundle 或 OPK 不属于 v1 MTI;若某部署开放匿名访问,属于额外部署策略。

字段使用说明:

  • target_did:表示希望获取谁的建链材料;服务端应返回与该 DID 绑定的 Bundle,而不是其它主体的缓存结果。
  • preferred_suite:在服务端同时维护多个套件时,用于表达调用方希望优先拿到哪一个套件的材料。
  • require_opk:表示“没有可用 OPK 就不要退化建链”;若为 true,调用方期望拿到可直接用于 DH4 的一次性预密钥。

成功响应

成功响应 MUST 至少包含:

  • target_did
  • prekey_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_prekey MUST 来自 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:发送方长期静态 X25519 keyAgreement 公钥 / 私钥对。在线协议中它通常由 sender_static_key_agreement_id 指明。
  • EK_A:发送方一次性临时 X25519 公钥 / 私钥对。它为每次新的 init 重新生成,并通过 sender_ephemeral_pub_b64u 暴露其公钥部分。

接收方 B

  • KA_B:接收方长期静态 X25519 keyAgreement 公钥 / 私钥对。发送方通过 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

  1. 解析并验证目标 recipient_did
  2. 获取并验证目标 prekey_bundle;若响应附带 one_time_prekey,则一并记录该 OPK;
  3. 获取并验证发送方自己的长期静态 keyAgreement 密钥;
  4. 生成新的临时 X25519 密钥对 EK_A

8.4 DH 计算

direct.e2ee.get_prekey_bundle 的成功响应中 没有 OPK,则发送方 MUST 计算:

text
DH1 = DH(KA_A, SPK_B)
DH2 = DH(EK_A, KA_B)
DH3 = DH(EK_A, SPK_B)
IKM = DH1 || DH2 || DH3

direct.e2ee.get_prekey_bundle 的成功响应中 OPK,则发送方 MUST 额外计算:

text
DH4 = DH(EK_A, OPK_B)
IKM = DH1 || DH2 || DH3 || DH4

8.5 初始共享秘密派生

发送方和接收方 MUST 使用:

text
PRK = HKDF-Extract(salt = 0x00...00(32 bytes), IKM)
SK  = HKDF-Expand(PRK, info = "ANP Direct E2EE v1 Initial Secret", L = 32)

然后从 SK 派生:

text
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)

其中:

  • RK0:初始 Root Key
  • CK0:首条 init 密文消耗前的初始链起点
  • SID:16 字节会话标识,编码为 Base64URL 后作为 session_id

8.5.1 首条消息密钥与 nonce

SK 派生出 RK0CK0 后,发送方和接收方 MUST 立即对 CK0 执行一次 kdf_ck

text
CK1, MK0, NONCE0 = kdf_ck(CK0)

其中:

  • MK0 仅用于加密或解密 direct_init.ciphertext_b64u
  • NONCE0 仅用于该首条 init 密文的 AEAD nonce
  • CK1 是 init 成功后后续链状态的唯一起点

v1 统一规定:init 消息消耗链上的第 0 条消息。换言之,CK0 只用于派生 MK0NONCE0CK1;init 成功后双方 MUSTCK1 继续,而 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+json
  • sender_did
  • recipient_did
  • suite
  • recipient_bundle_id
  • sender_static_key_agreement_id
  • recipient_signed_prekey_id
  • recipient_one_time_prekey_id(若存在)
  • session_id
  • message_id
  • profile = anp.direct.e2ee.v1
  • security_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 使用由 CK0kdf_ck 派生得到的 MK0 / NONCE0 对首个 Application Plaintext 进行加密,并将其写入 ciphertext_b64u


下面的时序图把获取 Bundle、选择可选 OPK、发送 direct_init、接收方 bootstrap 与首条回复连成一条完整链路。这样读者在阅读后面的公式时,可以同时看到消息与状态是怎样一起推进的。

mermaid
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: 会话进入 established

图 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 采用如下结构:

json
{
  "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"
}

9.2 字段要求

direct_init MUST 包含:

  • session_id
  • suite
  • sender_static_key_agreement_id
  • recipient_bundle_id
  • recipient_signed_prekey_id
  • sender_ephemeral_pub_b64u
  • ciphertext_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.v1
  • meta.security_profile = direct-e2ee
  • meta.content_type = application/anp-direct-init+json
  • meta.operation_id MUSTmeta.message_id 完全相等
  • 未协商扩展时,params.auth MUST 缺省

补充说明:这里的外层 meta.content_type 描述的是 body 采用哪一种 E2EE 承载对象格式;首条应用明文的真实业务类型位于被 ciphertext_b64u 加密的内层 Application Plaintext 中。

9.4 唯一建链与状态初始化算法(规范性)

本节统一定义 init 密文生成、init 后本地状态建立、响应方首条回复以及发起方收到首条回复前后的状态推进规则。实现 MUST NOT 在其它章节另外定义与本节冲突的 bootstrap 语义。

9.4.1 输入与前置条件

发送方 A 在发起 direct_initMUST 已完成:

  1. 解析并验证目标 recipient_did
  2. 获取并验证目标 prekey_bundle
  3. 若响应附带 one_time_prekey,则记录该 OPK
  4. 获取并验证发送方自己的长期静态 keyAgreement 密钥
  5. 生成新的临时 X25519 密钥对 EK_A

发送方和接收方按第 8.4、8.5 节推导出:

  • SK
  • RK0
  • CK0
  • SID

并令:

  • 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 编码后的字节串:

json
{
  "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>"
}

其中:

  • 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 先执行:

text
CK1, MK0, NONCE0 = kdf_ck(CK0)

然后:

  • 发送方 MUST 使用 MK0NONCE0AD_init 加密 init 内层明文;
  • 生成的 AEAD ciphertext(含 tag)写入 ciphertext_b64u
  • ciphertext_b64u MUST NOT 拼接 nonce。

9.4.5 init 消耗链上的第 0 条消息

v1 统一规定:init 密文是“初始 A -> B 链”的第 0 条消息。

因此,发送方 A 在成功发出 direct_init 后,本地状态 MUST 设为:

  • RK = RK0
  • DHs = EK_A
  • DHr = null
  • CKs = CK1
  • CKr = null
  • Ns = 1
  • Nr = 0
  • PN = 0
  • MKSKIPPED = empty
  • status = "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

  1. recipient_one_time_prekey_id 存在,则消费对应 OPK
  2. 建立以下本地状态:
  • RK = RK0
  • DHr = EK_A
  • CKr = CK1
  • Nr = 1
  • DHs = GenerateRatchetKeyPair()
  • (RK, CKs) = kdf_rk(RK, DH(DHs, DHr))
  • Ns = 0
  • PN = 0
  • MKSKIPPED = empty
  • status = "established"

9.4.8 接收方首条回复

接收方 B 的首条回复 MUST 使用 application/anp-direct-cipher+json

该条消息的 ratchet_header MUST 满足:

  • dh_pub_b64u = DHs.public
  • pn = "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"

若不满足,发起方 MUSTanp.direct.e2ee.bad_init_messageanp.direct.e2ee.invalid_security_binding 拒绝,且 MUST NOT 推进本地状态。

在通过上述校验后,发起方 MUST 按以下顺序处理:

  1. DHr_new = ratchet_header.dh_pub_b64u
  2. 计算:
    • (RK, CKr) = kdf_rk(RK, DH(DHs, DHr_new))
  3. DHr = DHr_new
  4. PN = Ns
  5. Ns = 0
  6. Nr = 0
  7. 生成新的本地发送 DH 密钥对 DHs_new
  8. 计算:
    • (RK, CKs) = kdf_rk(RK, DH(DHs_new, DHr))
  9. DHs = DHs_new
  10. 执行:
    • CKr_next, MK_reply0, NONCE_reply0 = kdf_ck(CKr)
  11. 使用 MK_reply0NONCE_reply0 和第 10.5 节定义的 AD_msg 解密该首条回复
  12. 若解密成功,令:
    • CKr = CKr_next
    • Nr = 1
    • status = "established"

若步骤 11 解密失败,实现 MUST 丢弃本次 tentative 状态推进,并保持收到该消息前的会话状态不变。

本节定义完成后,后续消息进入第 10 章的 steady-state Double Ratchet 处理。

仅靠文字描述,pending-confirmationestablished 的边界很容易被实现者理解错。下图把 v1 的 bootstrap 状态压缩成一张状态图,突出发起方在收到首条有效回复前不能发送独立后续密文这一约束。

mermaid
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 重建

图 P5-3:会话 bootstrap 状态(非规范性)。

实现方在处理发起方本地缓冲、重发和错误恢复时,应以这张状态图为准,而不是把 direct_init 发出后立即视为一个完全可用的 steady-state 会话。

9.5 Init 消息不强制签名

v1 默认 不要求 对整个 direct_init 对象再做一层长期 DID 签名。

若某部署需要更强可审计性,可以额外启用 Direct Init Accountability Extension;该扩展 MAYparams.auth 中引入 origin_proof 或等价证明对象。未协商该扩展时,params.auth MUST 缺省。

该扩展不是 v1 MTI 的组成部分。


10. 后续消息:Double Ratchet-like

10.1 初始化状态

init bootstrap、首条 init 密文、接收方首条回复以及发起方收到首条回复前后的状态初始化,统一由第 9.4 节定义。本节只列出 steady-state 会话至少需要保存的字段。

每个会话 MUST 至少保存:

  • session_id
  • suite
  • peer_did
  • RK:Root Key
  • DHs:本地发送 DH 私钥 / 公钥对
  • DHr:对端最近一次 DH 公钥
  • CKs:发送链 Chain Key
  • CKr:接收链 Chain Key
  • Ns:发送链消息号
  • Nr:接收链消息号
  • PN:前一发送链长度
  • MKSKIPPED:跳过消息密钥缓存
  • status:本地会话状态,推荐值:pending-confirmationestablished

各状态字段的作用如下:

  • 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

json
{
  "dh_pub_b64u": "BASE64URL_DH_PUB",
  "pn": "12",
  "n": "3"
}

字段要求:

  • dh_pub_b64uMUST
  • pnMUST
  • nMUST

字段使用说明:

  • dh_pub_b64u:当前发送方 ratchet 公钥,接收方据此判断是否需要推进 DH ratchet。
  • pn:上一发送链的消息总数,用于告诉接收方在 ratchet 切换前最多可能还有多少旧链消息需要处理。
  • n:当前发送链中的消息序号;接收方结合 dh_pub_b64un 确定应派生或查找哪一个消息密钥。

10.2.1 KDF 规则(规范性)

为保证跨实现互通,本 Profile MUST 固定使用以下派生规则:

kdf_rk(RK, dh_out) -> (RK', CK)

text
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]

kdf_ck(CK) -> (CK', MK, NONCE)

text
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]

规则说明:

  • 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:

  1. 先按 ratchet_header.pn 处理旧接收链上可能仍需保留的 skipped message keys
  2. DHr_new = ratchet_header.dh_pub_b64u
  3. 计算:
    • (RK, CKr) = kdf_rk(RK, DH(DHs, DHr_new))
  4. DHr = DHr_new
  5. PN = Ns
  6. Ns = 0
  7. Nr = 0
  8. 生成新的本地发送 DH 密钥对 DHs_new
  9. 计算:
    • (RK, CKs) = kdf_rk(RK, DH(DHs_new, DHr))
  10. DHs = DHs_new

10.2.3 消息密钥派生与解密处理(规范性)

本节定义 steady-state 下的最小互通处理算法。

10.2.3.1 RatchetEncrypt()

发送方发送一条后续消息时 MUST

  1. 执行 CKs_next, MK, NONCE = kdf_ck(CKs)
  2. 构造 ratchet_header = { dh_pub_b64u = DHs.public, pn = decimal(PN), n = decimal(Ns) }
  3. 按第 10.5 节构造 AD_msg
  4. 使用 MKNONCEAD_msg 加密第 10.4 节的 Application Plaintext
  5. 发送后令:
    • CKs = CKs_next
    • Ns = Ns + 1

10.2.3.2 TrySkippedMessageKeys()

接收方在处理一条后续消息前 MUST 先检查 MKSKIPPED[(dh_pub_b64u, n)]。若命中,则:

  1. 取出对应 MKNONCE
  2. 按第 10.5 节构造 AD_msg
  3. 尝试解密
  4. 无论成功与否,MUST 删除该条目
  5. 若解密成功,则完成该消息处理;若失败,则 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

否则,接收方 MUSTNr < until_n 循环中重复:

  1. 执行 CKr_next, MK, NONCE = kdf_ck(CKr)
  2. 以键 (DHr, Nr) 或等价键把 (MK, NONCE) 写入 MKSKIPPED
  3. CKr = CKr_next
  4. Nr = Nr + 1

10.2.3.4 RatchetDecrypt()

接收方处理一条后续消息时 MUST

  1. 先执行 TrySkippedMessageKeys()
  2. ratchet_header.dh_pub_b64u 与当前 DHr 不一致,则先执行第 10.2.2 节的 steady-state DH ratchet
  3. ratchet_header.n < Nr 且未命中 MKSKIPPED,则 MUST 返回 anp.direct.e2ee.decrypt_failed
  4. 执行 SkipMessageKeys(ratchet_header.n)
  5. 执行 CKr_next, MK, NONCE = kdf_ck(CKr)
  6. 按第 10.5 节构造 AD_msg
  7. 使用 MKNONCEAD_msg 解密
  8. 若解密成功,令:
    • CKr = CKr_next
    • Nr = 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 的核心推进路径集中展示出来。

mermaid
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]

图 P5-4:Double Ratchet 状态推进(非规范性)。

阅读后面的 RatchetEncrypt()RatchetDecrypt() 和 skipped message key 处理逻辑时,可以把它们理解为这张状态推进图在发送、接收和乱序恢复上的具体展开。

10.3 application/anp-direct-cipher+json 对象

推荐结构如下:

json
{
  "session_id": "BASE64URL_16_BYTES",
  "ratchet_header": {
    "dh_pub_b64u": "BASE64URL_DH_PUB",
    "pn": "12",
    "n": "3"
  },
  "ciphertext_b64u": "BASE64URL_CIPHERTEXT"
}

字段要求:

  • session_idMUST
  • ratchet_headerMUST
  • ciphertext_b64uMUST
  • suiteMAY

字段使用说明:

  • session_id:指出这条密文属于哪一个已建立的 Direct Session,接收方据此加载对应的 ratchet 状态。
  • suite:若存在,则其值 MUSTsession_id 绑定的会话套件完全一致;若缺省,接收方 MUST 使用本地会话状态中已绑定的套件。
  • ratchet_header:承载后续消息公开可见但必须受认证绑定的 ratchet 坐标。
  • ciphertext_b64u:对内层 Application Plaintext 加密后的 AEAD 密文字节串,使用无填充 base64url 表示;v1 中其值 只包含密文本体,不包含 nonce

10.4 内层 Application Plaintext

发送方在加密前 MUST 把 Direct Base 的应用负载归一化为:

json
{
  "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": "..."
}

字段使用说明:

  • 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_type = "application/json" 时,payload
    MUST 直接承载 JSON 对象。本 Profile 不定义该对象内部字段的业务含义。

并满足:

  • application_content_type MUST 存在;
  • text / payload / payload_b64u MUST 恰好出现一个。

发送方在加密前 MUST 将整个 Application Plaintext 对象使用 UTF-8 + RFC 8785 JCS 序列化为字节串;接收方解密后 MUST 按相同规则解释该对象。

普通 JSON application plaintext 示例:

json
{
  "application_content_type": "application/json",
  "conversation_id": "conv-001",
  "payload": {
    "type": "example",
    "data": {
      "hello": "world"
    }
  }
}

缺省可选字段 MUST 直接省略;conversation_idreply_to_message_id 缺省时 MUST NOT 使用 null 或空字符串占位。annotations 若缺省 MUST 省略;若存在,则 MAY{}。除字段语义另有规定外,发送方 MUST NOT 使用空对象、空数组或其它占位值代替“字段不存在”。

10.5 消息 AAD

每条后续消息的 AEAD AAD,记为 AD_msgMUST 是以下 JSON 对象经 UTF-8 + RFC 8785 JCS 编码后的字节串:

json
{
  "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": { ... }
}

这些字段的作用如下:

  • 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_header MUST 与 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 MUSTmessage_id 完全相等,外层业务幂等键和消息标识绑定为同一值。接收方 MUST 先完成外层幂等检查,再推进任何 ratchet 状态。

接收方 MUST 至少基于以下维度做防重放:

  • session_id
  • message_id
  • ratchet_header.dh_pub_b64u
  • ratchet_header.n
  • 密文摘要(可选)

11.2 跳号支持

实现 MUST 支持一定范围内的跳号消息,并为此维护 MKSKIPPED

11.3 MAX_SKIP

实现 MUST 定义 MAX_SKIP

v1 推荐值:

  • MAX_SKIP = 1000

实现 MAY 采用更小或更大的值,但必须通过能力协商对外暴露。

11.4 Skipped Key 删除

实现 SHOULDMKSKIPPED 设定:

  • 每会话最大条目数;
  • 删除策略;
  • 确定性触发条件。

推荐优先采用基于“消息接收数 / ratchet 步数”的确定性删除,而不是单纯基于墙钟时间。

11.5 初始消息重放与幂等

接收方 MUST 同时维护两类状态:

  1. 外层幂等记录,键至少为:

    • (sender_did, recipient_did, method, operation_id)
  2. init replay 记录,键至少为:

    • (recipient_bundle_id, sender_did, sender_ephemeral_pub_b64u, session_id)

处理规则如下:

  • 若重复请求命中相同 operation_id,且其 init replay 键也相同,则 MUST 视为同一次 init 的幂等重试,并返回原结果或等价结果;
  • 若 init replay 键相同,但 operation_idmessage_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_idapplication/anp-direct-cipher+json 时,MUST 返回 anp.direct.e2ee.session_not_found

12.3 控制对象(非 MTI 扩展)

application/anp-direct-control+json 不属于 v1 MTI,也不是 anp.direct.e2ee.v1direct.send 的标准 wire object。

若实现需要显式会话控制,MAY 通过扩展协商定义该对象。未协商该扩展时,发送方 MUST NOT 使用它,接收方 MUST 拒绝它。

推荐控制类型:

  • reset
  • close
  • error

这些控制对象 SHOULD 由已建立的会话密钥保护;若会话已失效,则优先使用新的 init 重新建链。


13. 错误模型

本 Profile 为私聊 E2EE 错误固定分配 5000-5012 码段。服务端返回本节错误时,error.data.anp_code MUST 存在。

codeanp_code含义
4000anp.direct.e2ee.bundle_not_found未找到可用 Bundle
4001anp.direct.e2ee.bundle_invalidBundle 无效或 proof 校验失败
4002anp.direct.e2ee.bundle_expiredBundle 或 SPK 已过期
4003anp.direct.e2ee.opk_unavailable请求要求 OPK,但当前无可用 OPK
4004anp.direct.e2ee.missing_key_agreement缺少可用 keyAgreement 材料
4005anp.direct.e2ee.session_not_found未找到会话状态
4006anp.direct.e2ee.session_conflict会话状态冲突或重复建链冲突
4007anp.direct.e2ee.bad_init_messageinit 消息结构或绑定校验失败
4008anp.direct.e2ee.replay_detected检测到重放或与幂等不兼容的重复 init
4009anp.direct.e2ee.decrypt_failed解密失败
4010anp.direct.e2ee.max_skip_exceeded超出 MAX_SKIP 上限
4011anp.direct.e2ee.reset_required本地策略要求重建会话
4012anp.direct.e2ee.invalid_security_binding外层绑定、AAD 绑定或安全模式绑定不一致

错误响应 SHOULDerror.data 中尽量提供:

  • target_did
  • bundle_id
  • opk_id
  • session_id
  • required_security_profile
  • retryable

14. 安全要求与限制

14.1 身份错绑防护

实现 MUST 在初始消息的 AAD 中绑定:

  • sender_did
  • recipient_did
  • 相关 key id
  • bundle_id
  • session_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 支持:

  1. 解析 DID 文档中的 authenticationassertionMethodkeyAgreementANPMessageService
  2. 发布与获取 prekey_bundle;若实现宣称支持 OPK,还 MUST 支持 OPK 池补充与按次发放;
  3. direct.e2ee.publish_prekey_bundle / direct.e2ee.get_prekey_bundle 明确采用 target.kind = "service" 的 service-scoped 目标模型;
  4. 验证 prekey_bundle.proof,并支持 P1 附录 B 定义的共享 Object Proof Profile
  5. 使用 MTI 套件执行 X3DH-like 初始建链;
  6. anp.direct.e2ee.v1 下,direct.send 的 v1 MTI 路径仅允许 application/anp-direct-init+jsonapplication/anp-direct-cipher+json
  7. anp.direct.e2ee.v1 下,direct.sendmeta.operation_id MUSTmeta.message_id 完全相等;
  8. anp.direct.e2ee.v1 下,未协商扩展时 direct.send.params.auth MUST 缺省;
  9. 通过 direct.send 承载 application/anp-direct-init+json
  10. 通过 direct.send 承载 application/anp-direct-cipher+json
  11. 固定使用本 Profile 定义的 kdf_rkkdf_ck 与派生 nonce 规则;
  12. init 消息 MUST 消耗链上的第 0 条消息,init 成功后双方 MUSTCK1 继续;
  13. direct_init.ciphertext_b64u MUST 承载一个完整的 Application Plaintext
  14. 发起方在收到对端第一条有效回复前 MUST NOT 发送独立的 application/anp-direct-cipher+json
  15. 维护 Double Ratchet-like 状态;
  16. 实现 RatchetEncrypt()RatchetDecrypt()SkipMessageKeys()TrySkippedMessageKeys() 的等价处理逻辑;
  17. 实现 MAX_SKIPMKSKIPPED
  18. direct.e2ee.publish_prekey_bundle / direct.e2ee.get_prekey_bundle 实现幂等处理;若 get_prekey_bundle 已分配 OPK,则同一幂等键重试 MUST 返回同一个 OPK;
  19. 防止初始消息重放导致的密钥灾难性复用;
  20. 不把设备或内部副本概念暴露到线协议中。

16. 示例

16.1 发布 Bundle

json
{
  "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"
        }
      ]
    }
  }
}

16.2 初始建链消息

json
{
  "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"
    }
  }
}

16.3 后续加密消息

json
{
  "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"
    }
  }
}

附录 A(信息性):与原始 X3DH 的差异

本 Profile 与原始 Signal X3DH 的关键差异如下:

  1. 长期 DH 身份由 DID 文档 keyAgreement 承担,而不是由签名身份键直接承担;
  2. 静态 Bundle 绑定复用 did:wba proof,而不是原始 XEdDSA 专用格式;OPK 不进入静态 Bundle proof,而由服务端按次发放;
  3. 本 Profile 以 Agent 为协议主体,不引入多设备线协议语义;
  4. v1 默认不强制对 init 消息整体长期签名,以保留更接近 Signal 风格的安全属性;
  5. v1 明确保留向 PQXDH-like 升级的路径。

附录 B(信息性):后续建议

  1. 建议尽快增加 ANP-DIRECT-E2EE-PQXDH-HYBRID-V1 作为 v2 候选;
  2. 建议为 Bundle 发布增加透明目录或审计日志;
  3. 建议把 did:wba proof 的规范化规则在身份 Profile 中写死,以避免实现差异;
  4. 建议为可审计部署定义 Direct Init Accountability Extension,但不要把它做成 MTI。