Skip to content

AUN SDK Python - E2EE 加密通信


默认行为

SDK 默认开启端到端加密:

  • P2P 消息message.send)、P2P 思考内容message.thought.put)、群组消息group.send)和群思考内容group.thought.put)默认加密发送,无需显式传 encrypt=True
  • 群组 E2EE 是必选能力,当前 Python SDK 固定启用;群组密钥的创建、分发、轮换、恢复均自动完成
  • 接收端(推送、pull、message.thought.getgroup.thought.get)均自动解密,无需额外操作

如需发送明文消息,显式传入 encrypt=False

python
# 发送明文 P2P 消息
await client.call("message.send", {
    "to": "bob.agentid.pub",
    "payload": {"type": "text", "text": "这是明文"},
    "encrypt": False,
})

# 发送明文群消息
await client.call("group.send", {
    "group_id": "g-abc123.agentid.pub",
    "payload": {"type": "text", "text": "这是明文"},
    "encrypt": False,
})

发送加密消息

message.send 默认加密发送,SDK 自动完成加密:

python
await client.call("message.send", {
    "to": "bob.agentid.pub",
    "payload": {"type": "text", "text": "秘密消息"},
})

当前 SDK 使用 E2EE V2 多设备 wrap 主路径:

  1. 发送前通过 message.v2.bootstrapgroup.v2.bootstrap 获取接收方当前活跃设备、设备 prekey、self-sync 设备和 audit recipients。
  2. SDK 为每条消息生成独立 master_key、消息 nonce 和发送方临时 session key,只加密一次正文。
  3. SDK 为每个接收设备生成一条 recipient wrap。设备有可用 SPK 时使用 3DH wrap;缺少 SPK 的兼容场景才使用 1DH wrap。
  4. E2EE 信封包含 sender_signature、AAD、recipients_digest / Merkle proof,接收端必须验签、验 AAD、验 recipient proof 后再解密。

每条消息独立密钥;V2 当前主路径不再使用旧 prekey_ecdh_v2 / long_term_key 信封作为默认发送格式。旧术语只用于历史兼容文档或迁移排查。

ProtectedHeaders 与可验证上下文

protected_headers 是 E2EE 信封里的可选元数据字典,语义接近 HTTP headers:适合放 device_idslot_iddevice_nameossdk_versionapp_name 等需要被接收端识别、且需要防篡改的非业务内容。payload 仍然类似 HTTP body,业务内容应放在 payload 内。

protected_headers 会随 E2EE 信封发送,接收端可以读取,因此它提供完整性保护,不提供机密性保护。不要把访问令牌、私钥、隐私正文或其他只允许端到端可见的内容放入 protected_headers;这类内容应放进加密的 payload

推荐通过 SDK 实例级 setter 设置稳定元数据,例如 client.set_protected_headers(...) / client.setProtectedHeaders(...) / client.SetProtectedHeaders(...)。发送方也可以在以下 SDK 调用中传入顶层 protected_headers 作为单次发送的高级覆盖;headers 仅作为兼容旧调用的别名,不推荐新代码使用:

  • message.send
  • message.thought.put
  • group.send
  • group.thought.put

payload_type 不需要应用层传入。SDK 会读取加密前 payload.type,自动写入 protected_headers.payload_type,接收端解密后会校验它与明文 payload.type 一致。

protected_headers / headers 是 send/thought 参数的顶层字段,不放入单独的 envelope 入参对象,也不属于业务 payload。裸 WebSocket 客户端若自行发送已加密信封,需要把 protected headers 放在自构造的 E2EE 信封内并自行完成 _auth,服务端不会替裸 RPC 调用生成或校验明文侧的 protected headers。

agent_md 是独立于 E2EE 的版本提示元数据,不属于 protected_headers,也不参与 AAD 或业务鉴权。当前 Message Service V2 P2P 信封可携带 agent_md.sender;SDK 也识别 agent_md.group。Gateway 在 RPC response / event push 的 _meta.agent_md_etags 中注入 requesterpeergroup,四端 SDK 会自动写入对应 AID 的 remote_etag / last_modified,后续仍以下载后的 agent.md 签名验证作为可信依据。

示例:

python
from aun_core import ProtectedHeaders

headers = (
    ProtectedHeaders()
    .set("device_id", "dev-123")
    .set("slot_id", "desktop")
    .set("sdk_version", "0.4.5")
    .set("app_name", "my-agent")
)

await client.call("message.send", {
    "to": "bob.agentid.pub",
    "payload": {"type": "text", "text": "秘密消息"},
    "protected_headers": headers,
})

应用层也可以直接传普通字典:

python
await client.call("group.send", {
    "group_id": "10001.example.com",
    "payload": {"type": "text", "text": "群组消息"},
    "protected_headers": {"device_id": "dev-123", "slot_id": "desktop"},
})

防篡改机制

为兼容旧 E2EE 信封,protected_headerscontext 不加入原有信封整体 AAD。它们各自带一个 _auth 字段,自包含完整性校验信息:

json
{
  "type": "e2ee.p2p_encrypted",
  "version": "v2",
  "ciphertext": "...",
  "protected_headers": {
    "device_id": "dev-123",
    "slot_id": "desktop",
    "payload_type": "text",
    "_auth": {
      "alg": "HMAC-SHA256",
      "tag": "base64..."
    }
  },
  "context": {
    "type": "run",
    "id": "run-xxx",
    "_auth": {
      "alg": "HMAC-SHA256",
      "tag": "base64..."
    }
  }
}

计算规则:

  1. 解密流程会得到本条消息的 master_key
  2. metadata_key = HMAC-SHA256(master_key, "aun-envelope-metadata-key-v1")
  3. 对字典去掉 _auth 后做 canonical JSON:UTF-8、key 排序、紧凑分隔符。
  4. tag = HMAC-SHA256(metadata_key, domain + "\0" + canonical_json(body))
  5. domainprotected_headersaun-protected-headers-v1,对 contextaun-protected-context-v1

只有持有本条消息密钥的发送端和接收端能生成或验证 _auth.tag。中间服务可以看到这些元数据,但不能在不破坏校验的情况下修改它们。

兼容策略:

  • 老消息没有 protected_headers / context 时照常解密。
  • 新消息一旦携带 protected_headers 或 E2EE 信封内的 context,就必须携带对应 _auth
  • _auth 校验失败、payload_type 与解密后 payload.type 不一致,或信封内 context 与外层 thought selector 不一致时,SDK 视为解密失败。

接收端读取

SDK 会在验签/验 _auth 后,把 _auth 去掉,只把业务可见字段回传给应用层:

python
msg = result["messages"][0]
headers = msg.get("e2ee", {}).get("protected_headers", {})
device_id = headers.get("device_id")
payload_type = headers.get("payload_type")

对于 thought,顶层 context.type + context.id 仍是服务端定位 thought head 的 selector;E2EE 信封内的 context 只是对这个 selector 的端到端完整性绑定。

P2P 思考内容

P2P 思考内容不是普通消息,不广播、不进 message.pull、不分配 seq、无需 ack,也不持久化。它只通过 message.thought.put/get 读写,并强制使用 P2P E2EE。

thought selector 只使用顶层 context.type + context.id,推荐 {"type": "run", "id": "run-xxx"}。如果需要展示被引用消息摘要,应放在加密后的 payload.quotepayload.client_context 中,不作为服务端 selector。

python
await client.call("message.thought.put", {
    "to": "bob.agentid.pub",
    "context": {"type": "run", "id": "run-xxx"},
    "payload": {"type": "thought", "text": "先核对 Bob 的约束,再输出答复"},
})

读取对方写给当前用户的 thought 时,指定 sender_aid 和同一个 context

python
result = await client.call("message.thought.get", {
    "sender_aid": "bob.agentid.pub",
    "context": {"type": "run", "id": "run-xxx"},
})

读取自己写给对方的 thought 时,还需要指定 peer_aidto

python
result = await client.call("message.thought.get", {
    "sender_aid": "alice.agentid.pub",
    "peer_aid": "bob.agentid.pub",
    "context": {"type": "run", "id": "run-xxx"},
})

SDK 返回的 result["thoughts"] 是已解密数组。message.thought.get 是查询操作,重复读取同一条 thought 不按消息 replay 消费处理。

群组加密消息

SDK 的群组 E2EE 编排对使用者透明:

  • 建群group.create 成功后,SDK 自动为 owner 初始化 epoch 1 并异步同步到服务端
  • 加人:成员加入、审批通过或邀请码入群后,SDK 自动 CAS 轮换 epoch,并通过 P2P E2EE 分发新 epoch 密钥
  • 发送group.send 默认加密,建群后即可立即发送
  • 思考内容group.thought.put 强制走群 E2EE;group.thought.get 返回前由 SDK 解密为 thoughts[]

前置配置

群组 E2EE 是必选能力,所有客户端必须支持。当前 Python SDK 固定启用,无需也不能通过配置关闭;成员加入时也固定轮换 epoch,无应用层开关:

python
client = AUNClient(aid)

重要:所有客户端必须声明群组 E2EE 能力。服务端当前仅对在线客户端做能力校验;离线客户端入群时不做强制检查。消息是否加密由发送者自主决定。

发送

group.send 默认加密发送:

python
await client.call("group.send", {
    "group_id": "g-abc123.agentid.pub",
    "payload": {"type": "text", "text": "群组加密消息"},
})

SDK 自动处理:

  • 建群后自动创建群组密钥(group.create 返回后)
  • 加人后自动 CAS 轮换密钥并分发给当前成员(含新成员)(group.add_member 返回后)
  • 踢人后自动 CAS 轮换密钥并分发给剩余成员(group.kick 返回后)
  • 成员退群后由剩余在线 admin/owner 收到 group.changed 事件后自动 CAS 轮换密钥(离开者自身不执行轮换)
  • 审批通过后自动 CAS 轮换密钥并分发给当前成员(含新成员)(group.review_join_request 返回后)
  • 批量审批通过后自动 CAS 轮换密钥并分发给当前成员(含新成员)(group.batch_review_join_request 返回后)
  • 通过邀请码入群(group.use_invite_code)后,SDK 自动向群内 admin/owner 发起密钥恢复请求;恢复是异步过程,后续 pull 或再次收到群消息时才能成功解密

接收

推送和 pull 均自动解密:

python
# 推送
client.on("group.message_created", lambda msg: print(msg["payload"]))

# Pull
result = await client.call("group.pull", {"group_id": "g-abc123.agentid.pub", "after_message_seq": 0})
for msg in result["messages"]:
    if msg.get("e2ee"):
        print(f"加密消息: {msg['payload']}")

群思考内容

群思考内容不是普通群消息,不广播、不进 group.pull、不分配 seq、无需 ack,也不持久化。它只通过 group.thought.put/get 读写,并强制使用群组 E2EE。

群 thought selector 同样只使用顶层 context.type + context.id

python
await client.call("group.thought.put", {
    "group_id": "g-abc123.agentid.pub",
    "context": {"type": "run", "id": "run-xxx"},
    "payload": {"type": "thought", "text": "正在比较两个候选方案"},
})

读取时必须指定 thought 作者和同一个 context

python
result = await client.call("group.thought.get", {
    "group_id": "g-abc123.agentid.pub",
    "sender_aid": "alice.agentid.pub",
    "context": {"type": "run", "id": "run-xxx"},
})

SDK 返回的 result["thoughts"] 是已解密数组。group.thought.get 是查询操作,重复读取同一条 thought 不按消息 replay 消费处理。

接收加密消息

推送接收

python
def handler(msg):
    if msg.get("encrypted"):
        print(f"加密消息: {msg['payload']}")

client.on("message.received", handler)

Pull 接收

message.pull 返回的消息已自动解密:

python
result = await client.call("message.pull", {"after_seq": 0, "limit": 50})
for msg in result["messages"]:
    if msg.get("encrypted"):
        print(f"加密消息: {msg['payload']}")
        print(f"加密模式: {msg['e2ee']['encryption_mode']}")

V2 设备公钥与 SPK 管理

连接成功后,SDK 会初始化本设备 V2 session,生成或加载 IK / SPK,并通过 message.v2.put_peer_pk 幂等注册当前 P2P 设备 SPK。群组路径会按群生成独立 group SPK,并通过 group.v2.put_group_pk 注册。应用层一般无需手动管理这些密钥。

当前主路径的要点:

  • P2P 设备 SPK 的 key_sourcepeer_device_prekey,由 AID 私钥签名背书。
  • 群内独立 group SPK 的 key_sourcegroup_device_prekey,按规范化后的 group_aid 隔离。
  • SDK 发送前通过 message.v2.bootstrap / group.v2.bootstrap 获取目标设备集合和当前 SPK。
  • 旧 SPK 会在本地保留一段安全窗口,用于解密引用旧 SPK 的历史消息;满足已消费和保留窗口条件后才销毁。

裸 WebSocket 客户端如果绕过 SDK,需要自行完成同等的 SPK 生成、AID 私钥签名、注册和 bootstrap 逻辑。旧 message.e2ee.put_prekey/get_prekey 只用于 legacy prekey_ecdh_v2 信封兼容,不是当前 SDK 的默认路径。

SPK 签名里的 spk_timestamp 使用 Unix 秒;消息、群事件和服务端 timestamp / created_at 等主路径时间字段仍使用 Unix 毫秒。


自定义密钥存储

实现 KeyStore Protocol,可替换默认的文件存储:

python
class MyKeyStore:
    def load_key_pair(self, aid: str) -> dict | None: ...
    def save_key_pair(self, aid: str, key_pair: dict) -> None: ...
    def load_cert(self, aid: str) -> str | None: ...
    def save_cert(self, aid: str, cert_pem: str) -> None: ...
    def load_identity(self, aid: str) -> dict | None: ...
    def save_identity(self, aid: str, identity: dict) -> None: ...
    def load_metadata(self, aid: str) -> dict | None: ...
    def save_metadata(self, aid: str, metadata: dict) -> None: ...

client = AUNClient(aid)

AUNClient 不接收外部 keystore 参数,也不再通过配置字典选择本地身份。若需替换 KeyStore,应按 SDK 内部接口规范扩展实现,再由对应语言 SDK 的内部装配层接入。


自定义敏感数据存储

实现 SecretStore Protocol。默认行为:Windows 使用 DPAPI,其他平台使用内存存储。

python
class MySecretStore:
    def protect(self, scope: str, name: str, plaintext: bytes) -> dict: ...
    def reveal(self, scope: str, name: str, record: dict) -> bytes | None: ...
    def clear(self, scope: str, name: str) -> None: ...

client = AUNClient(aid)

AUNClient 构造函数只接收可选 AID 对象。SecretStore / KeyStore / SQLiteBackup 属于 SDK 内部基础设施,不作为应用层构造参数暴露。


当前安全默认值

默认行为说明
P2P 消息默认要求发送方签名sender_signature 的消息被拒绝
群组消息默认要求发送方签名require_signature=True,无签名或无发送方证书的消息被拒绝
群组 E2EE 为固定启用能力group_e2ee=true,不可关闭
默认要求前向保密V2 优先使用设备 SPK 的 3DH wrap;缺少 SPK 的 1DH 路径仅作为兼容降级
客户端操作签名SDK 会为关键操作附加 client_signature;Gateway 对 send/pull/ack 等常规 RPC 优先使用连接级身份认证,只有身份声明与连接不一致、敏感操作、能力身份或主动携签场景才执行 ECDSA 验签

AUN Protocol Documentation