From e4715e6ccec1eac8bde6929bdd042e91a1f525ef Mon Sep 17 00:00:00 2001 From: changshan Date: Fri, 24 Apr 2026 22:49:00 +0800 Subject: [PATCH 01/14] Make direct E2EE CLI recovery survive real listener flows The secure direct CLI now consumes the published ANP Go SDK v0.8.7, persists sent secure messages as E2EE, redacts secure status output, auto-acks decrypted inbound init messages from polling/listener paths, and flushes queued secure outbox items once peers confirm. This removes the temporary workspace SDK replacement while keeping local repair and retry commands available for restart recovery. Constraint: P5 requires direct.send operation_id/message_id equality, target key-service binding, and no leakage of ratchet material Constraint: Mainline builds must consume github.com/agent-network-protocol/anp/golang v0.8.7 without a committed workspace replace Rejected: Keep replace => ../anp/anp/golang | breaks CI and release portability Confidence: high Scope-risk: moderate Directive: Do not print raw p5-e2ee-sessions or reintroduce workspace-local ANP SDK replace in committed go.mod Tested: go test ./... Tested: go vet ./... Tested: awiki-system-test tests_v2 with message-service v2: 85 passed, 8 skipped Not-tested: Cross-device production relay outside the local system-test stack --- CLAUDE.md | 154 +- docs/architecture/awiki-skill-architecture.md | 2 +- docs/installation.md | 13 +- go.mod | 3 +- go.sum | 14 +- internal/anpsdk/registry.go | 4 +- internal/cli/msg.go | 145 +- internal/cli/msg_test.go | 95 ++ internal/cli/root.go | 12 + internal/cmdmeta/catalog.go | 14 +- internal/message/secure.go | 195 +++ internal/message/secure_commands.go | 392 +++++ internal/message/secure_control.go | 210 +++ internal/message/secure_incoming.go | 186 +++ internal/message/secure_test.go | 1404 +++++++++++++++++ internal/message/service.go | 91 +- internal/message/types.go | 17 +- internal/message/ws_proxy_client_test.go | 36 +- internal/runtime/listener/server.go | 652 +++++++- internal/runtime/listener/server_test.go | 503 +++++- internal/store/import.go | 4 +- 21 files changed, 3984 insertions(+), 162 deletions(-) create mode 100644 internal/message/secure.go create mode 100644 internal/message/secure_commands.go create mode 100644 internal/message/secure_control.go create mode 100644 internal/message/secure_incoming.go create mode 100644 internal/message/secure_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 540ef20..00fd48a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,86 +41,88 @@ - 重写参考: - Python 版本 CLI:`../awiki-agent-id-message/` - 飞书 CLI:`../cli/` - - ANP Go SDK(远端模块依赖):`github.com/agent-network-protocol/anp/golang@v0.8.5` + - ANP Go SDK:依赖基线为远端发布版 `github.com/agent-network-protocol/anp/golang@v0.8.7`,P5 secure direct / OPK API 已随该版本发布,主线不得提交同级工作区 `replace` ## 成员清单 -**README.md**: 仓库入口说明文件。 +**README.md**: 仓库入口说明文件。 **config.template.yaml**: 标准用户主配置模板,展示当前 canonical `config.yaml` 字段与默认值;`service_base_url` 是平台服务入口,`did_domain` 可独立指定租户 DID provider domain。 -**go.mod / go.sum**: Go 模块定义与依赖锁定;当前 Go 版本基线固定为 `1.22`,直接依赖 `cobra`、`gojq`、`modernc.org/sqlite` 与远端模块 `github.com/agent-network-protocol/anp/golang@v0.8.5`,要求 pure Go。上游 / 间接依赖树中可能仍出现 secp256k1 相关库,但 `awiki-cli` 当前本地 DID 主路径已统一为 `e1` / Ed25519。 -**cmd/awiki-cli/main.go**: `awiki-cli` 主程序入口。 -**internal/buildinfo/buildinfo.go**: 版本、构建时间、CGO 状态等构建信息。 -**internal/cmdmeta/catalog.go**: 静态命令元数据目录,作为 schema/命令骨架的事实来源。 +**go.mod / go.sum**: Go 模块定义与依赖锁定;当前 Go 版本基线固定为 `1.22`,直接依赖 `cobra`、`gojq`、`modernc.org/sqlite` 与 ANP Go SDK `github.com/agent-network-protocol/anp/golang@v0.8.7`,要求 pure Go。P5 secure direct 通过远端发布版 SDK 消费 `OneTimePrekey`、`NewFileOneTimePrekeyStore` 与 top-level OPK publish/get API;主线不得提交 `replace => ../anp/anp/golang` 这类工作区本地依赖。上游 / 间接依赖树中可能仍出现 secp256k1 相关库,但 `awiki-cli` 当前本地 DID 主路径已统一为 `e1` / Ed25519。 +**cmd/awiki-cli/main.go**: `awiki-cli` 主程序入口。 +**internal/buildinfo/buildinfo.go**: 版本、构建时间、CGO 状态等构建信息。 +**internal/cmdmeta/catalog.go**: 静态命令元数据目录,作为 schema/命令骨架的事实来源。 **internal/config/config.go**: 单根目录工作区路径解析(默认 `~/.awiki-cli/`)、仅支持 `AWIKI_CLI_WORKSPACE_HOME_DIR` 作为工作区环境变量,并统一解析 `config.yaml`;旧 `config.json` 由 workspace upgrade 在首次访问时自动迁移到 `config.yaml`,其余历史业务环境变量不再驱动 awiki-cli 行为;默认 `ANPMessageService` 从 `service_base_url` 推导而不是从 `did_domain` 推导。 -**internal/output/output.go**: 统一 success/error JSON envelope、`--jq`、table/ndjson 渲染。 -**internal/doctor/doctor.go**: 诊断实现,检查构建、配置、env、identity store、SQLite、legacy 路径与 legacy DB;SQLite 检查会额外暴露 `contact_handle_bindings` 历史映射表状态与行数。 -**internal/docs/topics.go**: CLI 内建 docs 主题索引,`skills` 主题引用当前 single-entry `skills/SKILL.md` 与懒加载 `skills/references/*.md` 拓扑。 -**internal/anpsdk/registry.go**: ANP Go SDK 的远端模块依赖入口,统一暴露 DID WBA、HTTP Signatures、direct_e2ee 等后续 Phase 要用到的基础能力。 -**internal/authsdk/session.go**: 基于 ANP SDK `DIDWbaAuthHeader` 的身份鉴权封装,负责 HTTP/WSS hop auth、401 重试、JWT token 捕获与持久化。 -**internal/cli/app.go**: CLI 应用装配、配置解析与统一错误输出入口。 -**internal/cli/root.go**: Cobra 根命令、顶级命令树、status/docs/schema/doctor/version/init/config show 的实现。 -**internal/cli/init.go**: `init` 命令处理器,负责初始化工作区目录、upgrade 目录和最小 `config.yaml`。 +**internal/output/output.go**: 统一 success/error JSON envelope、`--jq`、table/ndjson 渲染。 +**internal/doctor/doctor.go**: 诊断实现,检查构建、配置、env、identity store、SQLite、legacy 路径与 legacy DB;SQLite 检查会额外暴露 `contact_handle_bindings` 历史映射表状态与行数。 +**internal/docs/topics.go**: CLI 内建 docs 主题索引,`skills` 主题引用当前 single-entry `skills/SKILL.md` 与懒加载 `skills/references/*.md` 拓扑。 +**internal/anpsdk/registry.go**: ANP Go SDK 的远端模块依赖入口,统一暴露 DID WBA、HTTP Signatures、direct_e2ee 等后续 Phase 要用到的基础能力。 +**internal/authsdk/session.go**: 基于 ANP SDK `DIDWbaAuthHeader` 的身份鉴权封装,负责 HTTP/WSS hop auth、401 重试、JWT token 捕获与持久化。 +**internal/cli/app.go**: CLI 应用装配、配置解析与统一错误输出入口。 +**internal/cli/root.go**: Cobra 根命令、顶级命令树、status/docs/schema/doctor/version/init/config show 的实现。 +**internal/cli/init.go**: `init` 命令处理器,负责初始化工作区目录、upgrade 目录和最小 `config.yaml`。 **internal/cli/id.go**: `id` 域命令处理器,包含 create/list/current/use/register/bind/resolve/recover/profile/import-v1,以及公开但危险的维护命令 `replace-did`。 -**internal/cli/debug.go**: `debug db query`、`debug db handle-history` 与 `debug db import-v1` 的 CLI 处理器。 -**internal/cli/msg.go**: `msg send/inbox/history/mark-read` 的 CLI 处理器,现已支持 direct + group plain messaging。 -**internal/cli/group.go**: `group create/get/join/add/remove/leave/update/members/messages` 的 CLI 处理器。 -**internal/identity/types.go**: identity store、legacy scan、command result 等核心类型。 -**internal/identity/layout.go**: identity 根目录、index.json、路径与安全写入辅助。 -**internal/identity/store.go**: 当前 v2 identity store 的读写、默认 identity 管理。 -**internal/identity/legacy.go**: v1 indexed/flat credential layout 扫描与导入。 -**internal/identity/did.go**: 本地 DID 文档与 proof 生成,当前默认生成 `e1` profile DID(`key-1` 为 Ed25519)。 -**internal/identity/key_compat.go**: legacy ANP 私有 PEM 标签 / SEC1 私钥到标准 PKCS#8 PEM 的兼容迁移,确保旧身份在 ANP Go SDK 0.8.5+ 下仍可完成 DID WBA 签名。 -**internal/identity/client.go**: user-service RPC/REST 客户端。 +**internal/cli/debug.go**: `debug db query`、`debug db handle-history` 与 `debug db import-v1` 的 CLI 处理器。 +**internal/cli/msg.go**: `msg send/inbox/history/mark-read` 的 CLI 处理器,现已支持 direct + group plain messaging。 +**internal/cli/group.go**: `group create/get/join/add/remove/leave/update/members/messages` 的 CLI 处理器。 +**internal/identity/types.go**: identity store、legacy scan、command result 等核心类型。 +**internal/identity/layout.go**: identity 根目录、index.json、路径与安全写入辅助。 +**internal/identity/store.go**: 当前 v2 identity store 的读写、默认 identity 管理。 +**internal/identity/legacy.go**: v1 indexed/flat credential layout 扫描与导入。 +**internal/identity/did.go**: 本地 DID 文档与 proof 生成,当前默认生成 `e1` profile DID(`key-1` 为 Ed25519)。 +**internal/identity/key_compat.go**: legacy ANP 私有 PEM 标签 / SEC1 私钥到标准 PKCS#8 PEM 的兼容迁移,确保旧身份在 ANP Go SDK 0.8.6+ 下仍可完成 DID WBA 签名。 +**internal/identity/client.go**: user-service RPC/REST 客户端。 **internal/identity/service.go**: Phase 2/3 高层 identity + user 业务流,封装本地 store、handle lifecycle、`replace_did` DID 换绑能力,以及远端 API。 -**internal/identity/did_test.go**: DID 文档和 proof 生成测试。 -**internal/identity/store_test.go**: identity store 与 legacy import 测试。 -**internal/store/types.go**: SQLite store 的核心类型、记录结构与导入报告类型。 -**internal/store/open.go**: pure Go SQLite 打开、WAL / foreign_keys / busy_timeout 配置。 -**internal/store/helpers.go**: thread id、row map、schema version、表/视图存在性等辅助函数。 -**internal/store/schema.go**: v12 schema、indexes、views 与 `EnsureSchema()`;新增 `contact_handle_bindings` 历史映射表,用于 Handle↔DID 历史绑定。 -**internal/store/dao.go**: messages / contacts / contact_handle_bindings / groups / outbox / relationship / rebind / execute_sql 的 DAO。 -**internal/store/rebind.go**: 基于工作区 SQLite 打开器的 owner DID 重绑与旧 E2EE 状态清理编排。 -**internal/store/import.go**: legacy SQLite 扫描与从 v1 DB 导入 v2 DB。 -**internal/store/schema_test.go**: schema 初始化和 version 测试。 -**internal/store/dao_test.go**: DAO、thread view、owner rebinding、E2EE 清理测试。 -**internal/store/import_test.go**: legacy SQLite 导入测试。 -**internal/message/types.go**: direct/group message 与 group lifecycle 的命令输入/输出模型和 transport 错误定义。 -**internal/message/auth.go**: direct message 的 hop-level auth 与本地 key / did document 读取。 -**internal/message/proof.go**: 基于 ANP Go SDK 0.8.5 的 RFC 9421 origin proof 薄封装。 -**internal/message/attachment.go**: 附件文件读取、manifest 组装、控制面/数据面 HTTP 交互与下载解析辅助。 -**internal/message/attachment_wire.go**: 附件 control-plane、download ticket 与 direct/group attachment manifest 的 RPC 参数构造器。 -**internal/message/attachment_service.go**: direct/group attachment send 与 `msg attachment download` 的业务编排层。 -**internal/message/group_wire.go**: group 标准面和 local-only RPC 参数构造器。 -**internal/message/http_client.go**: direct/group message 与 group lifecycle 的 HTTP JSON-RPC adapter。 -**internal/message/ws_proxy_client.go**: websocket 模式下通过本地 bridge 调用 listener/daemon 的 direct/group adapter。 -**internal/message/service.go**: direct inbox/send/history/mark-read 的业务编排层,融合 transport、identity、store;支持收件后自动 DID→Handle 补全,以及按 handle 聚合历史 DID 消息。 -**internal/message/contact_sync.go**: direct inbox/history 的联系人补全与 Handle 历史 DID 聚合辅助。 -**internal/message/group_service.go**: group lifecycle、group message、本地群缓存同步与群 inbox 聚合逻辑。 -**internal/message/helpers.go**: message 域常用值转换和解码辅助。 -**internal/message/proof_test.go**: origin_proof round-trip 测试。 -**internal/message/group_wire_test.go**: group RPC 参数构造与签名测试。 -**internal/runtime/config.go**: runtime mode(默认 websocket)、listener 默认策略、host notify 默认开启(默认 sink 为 `log`)与本地 bridge 配置解析。 -**internal/runtime/listener/types.go**: listener 状态与 session 状态结构。 -**internal/runtime/listener/files.go**: listener 的 pid/status/log/socket 路径与状态文件读写。 -**internal/runtime/listener/wsclient.go**: 远端 message-service WebSocket client。 -**internal/runtime/listener/server.go**: 本地 daemon server、session supervisor、notification 消费与 SQLite 落库;首条陌生来信会按 DID 反查 Handle 并更新通讯录。 -**internal/runtime/listener/contact_sync.go**: websocket 收件路径的 DID→Handle 自动补全与联系人重绑定辅助。 -**internal/runtime/listener/host_notify.go**: websocket 下行通知到宿主事件的标准化、字段裁剪与 host notify sink 注册入口;direct/group 事件可带 `sender_handle` / `recipient_handle`。 -**internal/runtime/listener/openclaw_host_notify.go**: OpenClaw 适配器,负责从本地 route registry 读取已注册 routes,并通过 `/hooks/agent` 执行 webhook fan-out;事件文本仍保留 sender/recipient handle 等可读字段。 -**internal/runtime/listener/service.go**: listener 系统服务编排,基于 `kardianos/service` 提供 install/start/stop/uninstall 与 service-run;`start` 在服务缺失时会自动 install,并等待 bridge ready 后再返回。 -**internal/runtime/listener/manager.go**: listener 的 start/stop/restart/status/run 管理逻辑与系统服务状态聚合。 -**internal/runtime/bridge_unix.go / bridge_windows.go**: 本地 bridge 跨平台 IPC 实现;Unix 平台使用 Unix Domain Socket,Windows 使用 Named Pipe。 -**docs/architecture/awiki-v2-architecture.md**: awiki CLI V2 的整体架构设计文档。 -**docs/architecture/awiki-command-v2.md**: awiki CLI 命令模型与命令层设计文档。 +**internal/identity/did_test.go**: DID 文档和 proof 生成测试。 +**internal/identity/store_test.go**: identity store 与 legacy import 测试。 +**internal/store/types.go**: SQLite store 的核心类型、记录结构与导入报告类型。 +**internal/store/open.go**: pure Go SQLite 打开、WAL / foreign_keys / busy_timeout 配置。 +**internal/store/helpers.go**: thread id、row map、schema version、表/视图存在性等辅助函数。 +**internal/store/schema.go**: v12 schema、indexes、views 与 `EnsureSchema()`;新增 `contact_handle_bindings` 历史映射表,用于 Handle↔DID 历史绑定。 +**internal/store/dao.go**: messages / contacts / contact_handle_bindings / groups / outbox / relationship / rebind / execute_sql 的 DAO。 +**internal/store/rebind.go**: 基于工作区 SQLite 打开器的 owner DID 重绑与旧 E2EE 状态清理编排。 +**internal/store/import.go**: legacy SQLite 扫描与从 v1 DB 导入 v2 DB。 +**internal/store/schema_test.go**: schema 初始化和 version 测试。 +**internal/store/dao_test.go**: DAO、thread view、owner rebinding、E2EE 清理测试。 +**internal/store/import_test.go**: legacy SQLite 导入测试。 +**internal/message/types.go**: direct/group message 与 group lifecycle 的命令输入/输出模型和 transport 错误定义。 +**internal/message/auth.go**: direct message 的 hop-level auth 与本地 key / did document 读取。 +**internal/message/proof.go**: 基于 ANP Go SDK 0.8.6 的 RFC 9421 origin proof 薄封装。 +**internal/message/attachment.go**: 附件文件读取、manifest 组装、控制面/数据面 HTTP 交互与下载解析辅助。 +**internal/message/attachment_wire.go**: 附件 control-plane、download ticket 与 direct/group attachment manifest 的 RPC 参数构造器。 +**internal/message/attachment_service.go**: direct/group attachment send 与 `msg attachment download` 的业务编排层。 +**internal/message/secure.go**: P5 direct E2EE secure send 的首版编排层,使用 ANP Go SDK direct_e2ee、本地文件会话/预密钥存储和 HTTP JSON-RPC;key-service 请求绑定当前 DID 文档里 `ANPMessageService.serviceDid`,并在有可用 sidecar OPK 时优先用 OPK 建链(本地保存 `p5-one-time-prekeys/`);HTTP inbox/history 现已接入入站密文解密与会话推进,并会顺带补发本地 prekey bundle;轮询路径解密 direct-init 成功后会自动发送 encrypted ACK 并尝试 flush 该 peer 的 `e2ee_outbox`;当 initiator 仍处于 `pending-confirmation` 时,新的 secure 发送会进入 `e2ee_outbox` 排队。 +**internal/message/secure_control.go**: secure 控制面与恢复辅助,负责 secure ack/init payload、pending 阶段的 `e2ee_outbox` 排队、secure outbox flush,以及 `msg secure status/init/repair/failed/retry/drop` 需要的本地会话/发件箱读取与重试逻辑。 +**internal/message/group_wire.go**: group 标准面和 local-only RPC 参数构造器。 +**internal/message/http_client.go**: direct/group message 与 group lifecycle 的 HTTP JSON-RPC adapter。 +**internal/message/ws_proxy_client.go**: websocket 模式下通过本地 bridge 调用 listener/daemon 的 direct/group adapter。 +**internal/message/service.go**: direct inbox/send/history/mark-read 的业务编排层,融合 transport、identity、store;支持收件后自动 DID→Handle 补全,以及按 handle 聚合历史 DID 消息。 +**internal/message/contact_sync.go**: direct inbox/history 的联系人补全与 Handle 历史 DID 聚合辅助。 +**internal/message/group_service.go**: group lifecycle、group message、本地群缓存同步与群 inbox 聚合逻辑。 +**internal/message/helpers.go**: message 域常用值转换和解码辅助。 +**internal/message/proof_test.go**: origin_proof round-trip 测试。 +**internal/message/group_wire_test.go**: group RPC 参数构造与签名测试。 +**internal/runtime/config.go**: runtime mode(默认 websocket)、listener 默认策略、host notify 默认开启(默认 sink 为 `log`)与本地 bridge 配置解析。 +**internal/runtime/listener/types.go**: listener 状态与 session 状态结构。 +**internal/runtime/listener/files.go**: listener 的 pid/status/log/socket 路径与状态文件读写。 +**internal/runtime/listener/wsclient.go**: 远端 message-service WebSocket client。 +**internal/runtime/listener/server.go**: 本地 daemon server、session supervisor、notification 消费与 SQLite 落库;首条陌生来信会按 DID 反查 Handle 并更新通讯录。 +**internal/runtime/listener/contact_sync.go**: websocket 收件路径的 DID→Handle 自动补全与联系人重绑定辅助。 +**internal/runtime/listener/host_notify.go**: websocket 下行通知到宿主事件的标准化、字段裁剪与 host notify sink 注册入口;direct/group 事件可带 `sender_handle` / `recipient_handle`。 +**internal/runtime/listener/openclaw_host_notify.go**: OpenClaw 适配器,负责从本地 route registry 读取已注册 routes,并通过 `/hooks/agent` 执行 webhook fan-out;事件文本仍保留 sender/recipient handle 等可读字段。 +**internal/runtime/listener/service.go**: listener 系统服务编排,基于 `kardianos/service` 提供 install/start/stop/uninstall 与 service-run;`start` 在服务缺失时会自动 install,并等待 bridge ready 后再返回。 +**internal/runtime/listener/manager.go**: listener 的 start/stop/restart/status/run 管理逻辑与系统服务状态聚合。 +**internal/runtime/bridge_unix.go / bridge_windows.go**: 本地 bridge 跨平台 IPC 实现;Unix 平台使用 Unix Domain Socket,Windows 使用 Named Pipe。 +**docs/architecture/awiki-v2-architecture.md**: awiki CLI V2 的整体架构设计文档。 +**docs/architecture/awiki-command-v2.md**: awiki CLI 命令模型与命令层设计文档。 **docs/architecture/anp-service-discovery.md**: awiki-cli 生成 DID 文档时的 `ANPMessageService` 填写规则、配置约束与实施记录。 -**docs/architecture/websocket-host-notification-v1.md**: websocket listener 向宿主 Agent 暴露统一通知事件的 v1 设计文档。 -**docs/architecture/openclaw-host-adapter-v1.md**: websocket host notification 到 OpenClaw `/hooks/agent` 的 v1 适配设计文档。 -**docs/architecture/output-format.md**: CLI 输出格式约束与展示设计文档。 -**docs/plan/awiki-v2-implementation-plan.md**: v2 的总体落地实施规划。 -**docs/plan/phase-0/implementation-constraints.md**: Phase 0 冻结后的实现约束表。 -**docs/plan/phase-0/capability-mapping.md**: v2 命令、v1 脚本、服务 API 的能力映射。 -**docs/plan/phase-0/audit-findings.md**: Phase 0 审计冲突与裁决。 -**docs/plan/phase-0/adr-index.md**: Phase 0 ADR 索引。 +**docs/architecture/websocket-host-notification-v1.md**: websocket listener 向宿主 Agent 暴露统一通知事件的 v1 设计文档。 +**docs/architecture/openclaw-host-adapter-v1.md**: websocket host notification 到 OpenClaw `/hooks/agent` 的 v1 适配设计文档。 +**docs/architecture/output-format.md**: CLI 输出格式约束与展示设计文档。 +**docs/plan/awiki-v2-implementation-plan.md**: v2 的总体落地实施规划。 +**docs/plan/phase-0/implementation-constraints.md**: Phase 0 冻结后的实现约束表。 +**docs/plan/phase-0/capability-mapping.md**: v2 命令、v1 脚本、服务 API 的能力映射。 +**docs/plan/phase-0/audit-findings.md**: Phase 0 审计冲突与裁决。 +**docs/plan/phase-0/adr-index.md**: Phase 0 ADR 索引。 ## 当前实现边界 @@ -157,7 +159,7 @@ - `debug db query` - `debug db import-v1` - `doctor` / `config show` 的数据库诊断增强 -- Phase 5(当前首版已落地 direct plain): +- Phase 5(当前首版已落地 direct plain,P5 secure direct send 首版接入): - `msg send --to` - `msg send --group` - `msg send --file` @@ -170,6 +172,8 @@ - attachment control-plane / data-plane HTTP upload, ticket, download - websocket runtime bridge / local daemon direct+group client - direct/group origin_proof 生成与本地消息落库 + - `msg send --secure on` 首版通过 ANP Go SDK P5 direct_e2ee 生成 `direct_init` / `direct_cipher` 并走 HTTP JSON-RPC;prekey key-service 请求使用本地 DID 文档的 `ANPMessageService.serviceDid` 做 `meta.target` 绑定,并在远端返回 top-level `one_time_prekey` 时优先走 OPK 建链;HTTP inbox/history 已支持入站密文自动解密与会话推进,并在轮询 direct-init 时自动 encrypted ACK + flush `e2ee_outbox`;listener 现已支持 secure incoming 自动解密、自动 first-reply/ack、以及在收到 secure ack 后 flush 已排队的 `e2ee_outbox` + - `msg secure status` / `init` / `repair` / `failed` / `retry` / `drop` 已有首版命令面实现;其中 `init` 当前通过发送 product-local secure control init 预热会话,`repair` 会重置本地会话并重排该 peer 的 failed outbox 后重新 init - attachment control 不再发送独立业务 proof - Phase 8(当前首版已落地 content page): - `page create/list/get/update/rename/delete` @@ -208,9 +212,9 @@ ### 尚未实现 -- `msg` 域中 direct plain 已实现,listener 服务端首版也已实现,但 websocket 远端真实联调与 secure E2EE 仍未完成 +- `msg` 域中 direct plain 已实现,P5 secure direct 出站首版已接入;HTTP inbox/history 的 P5 入站自动解密、轮询 direct-init 自动 ACK 与 outbox flush 已接入;websocket listener 已能解密 secure incoming、自动 first-reply/ack,并在 secure ack 后尝试 flush `e2ee_outbox`;`msg secure status/init/repair/failed/retry/drop` 有首版,但完整 runtime 联调与更强系统测试仍未完成 - `group` 域的 plain lifecycle / local view / group messaging 已接入,`people` 仍大多为 stub;`page` 已完成 content pages 首版 -- secure E2EE 业务流、group plain、发布链路属于后续阶段 +- secure E2EE 业务流目前只完成 P5 出站首版;group E2EE、发布链路和完整实时收件处理属于后续阶段 ## 开发与验证约定 diff --git a/docs/architecture/awiki-skill-architecture.md b/docs/architecture/awiki-skill-architecture.md index 312eb51..6c2e64f 100644 --- a/docs/architecture/awiki-skill-architecture.md +++ b/docs/architecture/awiki-skill-architecture.md @@ -93,7 +93,7 @@ | product surface | implemented | `status / docs / schema / doctor / config show / version / completion` | | id | implemented | 含 register / bind / recover / profile / import-v1 | | msg plain | implemented | direct/group plain send + inbox/history/mark-read | -| msg secure | planned | `--secure` contract 存在,但 secure 业务流尚未落地 | +| msg secure | partial | `--secure` direct flow、listener auto-ack/queued flush、以及 `msg secure status/init/repair/failed/retry/drop` 首版已落地;仍缺更强系统测试与部分恢复细节 | | group | implemented | create/get/join/add/remove/leave/update/members/messages | | runtime mode | implemented | `runtime status/setup/mode get/set` | | runtime listener | partial | status/install/start/stop/restart/uninstall 已可用;heartbeat 仍未落地 | diff --git a/docs/installation.md b/docs/installation.md index 86783f4..70bd6a6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -26,15 +26,15 @@ go version # go version go1.22.x darwin/arm64 ``` -### 1.2 ANP Go SDK(远端模块依赖) +### 1.2 ANP Go SDK(P5 secure direct) -awiki-cli 直接使用远端 ANP Go SDK 模块,版本固定为 `v0.8.5`: +awiki-cli 的依赖基线是 ANP Go SDK `v0.8.7`: ```bash -go get github.com/agent-network-protocol/anp/golang@v0.8.5 +go get github.com/agent-network-protocol/anp/golang@v0.8.7 ``` -首次拉取依赖时请确保本机可以访问公开 Go module proxy 或对应源码仓库。 +P5 secure direct / OPK 客户端能力已随 `v0.8.7` 发布;主线 `go.mod` 应直接依赖远端模块,不再提交同级工作区 `replace`。首次拉取依赖时请确保本机可以访问公开 Go module proxy 或对应源码仓库。 ### 1.3 Docker 备选(无本地 Go 时) @@ -501,10 +501,11 @@ CGO_ENABLED=0 go build ./cmd/awiki-cli/ ### Q: 编译报错找不到 ANP SDK -确认当前模块依赖已成功下载,并且 `go.mod` 中使用的是远端版本 `github.com/agent-network-protocol/anp/golang v0.8.5`: +确认当前模块依赖已成功下载,并且 `go.mod` 中保留了 `github.com/agent-network-protocol/anp/golang v0.8.7` 远端依赖: ```bash -go get github.com/agent-network-protocol/anp/golang@v0.8.5 +go get github.com/agent-network-protocol/anp/golang@v0.8.7 +grep 'github.com/agent-network-protocol/anp/golang v0.8.7' go.mod ``` ### Q: `go mod tidy` 报错 diff --git a/go.mod b/go.mod index d766b89..8eb39e7 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.22 require ( github.com/Microsoft/go-winio v0.6.2 - github.com/agent-network-protocol/anp/golang v0.8.5 + github.com/agent-network-protocol/anp/golang v0.8.7 github.com/coder/websocket v1.8.12 github.com/itchyny/gojq v0.12.17 github.com/kardianos/service v1.2.3 @@ -19,6 +19,7 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gowebpki/jcs v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index bfe7df5..426b8c1 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/agent-network-protocol/anp/golang v0.8.5 h1:umG/WfOs/0mGVE3JKWROUxbeFpBOwFdBJ0JWAE8zKu0= -github.com/agent-network-protocol/anp/golang v0.8.5/go.mod h1:IarvwCndJNX0fZUuT8dWnkG67Ah2zMXuunzQr6lbE30= +github.com/agent-network-protocol/anp/golang v0.8.7 h1:QG3IysTnvuZxUXLnafZ14jYFZFLqBSxpdPiQNHdBy4Q= +github.com/agent-network-protocol/anp/golang v0.8.7/go.mod h1:r3tSNMTTU4WW1fW68C1NJyn2Q0w59ajOQ4OERrFGTEU= github.com/btcsuite/btcd/btcec/v2 v2.3.6 h1:IzlsEr9olcSRKB/n7c4351F3xHKxS2lma+1UFGCYd4E= github.com/btcsuite/btcd/btcec/v2 v2.3.6/go.mod h1:m22FrOAiuxl/tht9wIqAoGHcbnCCaPWyauO8y2LGGtQ= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= @@ -9,6 +9,7 @@ github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtyd github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= @@ -21,6 +22,8 @@ github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlG github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gowebpki/jcs v1.0.1 h1:Qjzg8EOkrOTuWP7DqQ1FbYtcpEbeTzUoTN9bptp8FOU= +github.com/gowebpki/jcs v1.0.1/go.mod h1:CID1cNZ+sHp1CCpAR8mPf6QRtagFBgPJE0FCUQ6+BrI= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= @@ -33,6 +36,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -40,6 +45,10 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= @@ -52,6 +61,7 @@ golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= diff --git a/internal/anpsdk/registry.go b/internal/anpsdk/registry.go index 40ece1b..3b654a9 100644 --- a/internal/anpsdk/registry.go +++ b/internal/anpsdk/registry.go @@ -9,7 +9,7 @@ import ( const ( ModulePath = "github.com/agent-network-protocol/anp/golang" - ModuleVersion = "v0.8.5" + ModuleVersion = "v0.8.7" ) type ( @@ -27,6 +27,7 @@ type ( HttpSignatureOptions = anpauth.HttpSignatureOptions MessageServiceE2EEClient = directe2ee.MessageServiceDirectE2eeClient PrekeyBundle = directe2ee.PrekeyBundle + OneTimePrekey = directe2ee.OneTimePrekey DirectSessionState = directe2ee.DirectSessionState IMProof = anpproof.IMProof IMGenerationOptions = anpproof.IMGenerationOptions @@ -60,6 +61,7 @@ var ( NewDidWbaVerifier = anpauth.NewDidWbaVerifier NewFileSessionStore = directe2ee.NewFileSessionStore NewFileSignedPrekeyStore = directe2ee.NewFileSignedPrekeyStore + NewFileOneTimePrekeyStore = directe2ee.NewFileOneTimePrekeyStore NewFilePendingOutboundStore = directe2ee.NewFilePendingOutboundStore NewMessageServiceDirectE2eeClient = directe2ee.NewMessageServiceDirectE2eeClient BuildIMContentDigest = anpproof.BuildIMContentDigest diff --git a/internal/cli/msg.go b/internal/cli/msg.go index 7da4fca..b883499 100644 --- a/internal/cli/msg.go +++ b/internal/cli/msg.go @@ -73,7 +73,7 @@ func (a *App) messageExit(err error, hint string) error { case errors.Is(err, identity.ErrUserRegistrationRequired): return output.NewExitError("identity_required", 3, err.Error(), "Complete user setup with `awiki-cli id register --handle ...` or recover an existing handle before using msg commands.") case errors.Is(err, message.ErrSecureNotSupported): - return output.NewExitError("unsupported_mode", 1, err.Error(), "Direct secure messaging is planned for Phase 5.") + return output.NewExitError("unsupported_mode", 1, err.Error(), "Secure messaging is currently supported only for direct text messaging.") case errors.Is(err, message.ErrTransportUnavailable): return output.NewExitError("transport_unavailable", 1, err.Error(), "Start the websocket listener/daemon or switch runtime.mode back to http.") default: @@ -345,6 +345,149 @@ func (a *App) runMsgMarkRead(cmd *cobra.Command, args []string) error { return a.renderMessageResult(cmd, format, result) } +func (a *App) runMsgSecureStatus(cmd *cobra.Command, args []string) error { + with, _ := cmd.Flags().GetString("with") + service, format, err := a.messageService() + if err != nil { + return a.messageExit(err, "Run `awiki-cli doctor` to inspect configuration and identity state.") + } + request := message.SecureStatusRequest{ + IdentityName: a.globals.Identity, + With: with, + } + if a.globals.DryRun { + data := map[string]any{"plan": map[string]any{ + "action": "msg.secure.status", + "identity": a.globals.Identity, + "with": with, + }} + return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, data, "Dry run: secure status planned", nil, a.identityMeta()) + } + result, err := service.SecureStatus(cmd.Context(), request) + if err != nil { + return a.messageExit(err, "Make sure the active identity exists and the peer filter is valid.") + } + return a.renderMessageResult(cmd, format, result) +} + +func (a *App) runMsgSecureInit(cmd *cobra.Command, args []string) error { + with, _ := cmd.Flags().GetString("with") + service, format, err := a.messageService() + if err != nil { + return a.messageExit(err, "Run `awiki-cli doctor` to inspect configuration and identity state.") + } + request := message.SecurePeerRequest{ + IdentityName: a.globals.Identity, + With: with, + } + if a.globals.DryRun { + data := map[string]any{"plan": map[string]any{ + "action": "msg.secure.init", + "identity": a.globals.Identity, + "with": with, + }} + return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, data, "Dry run: secure init planned", nil, a.identityMeta()) + } + result, err := service.SecureInit(cmd.Context(), request) + if err != nil { + return a.messageExit(err, "Make sure the target exists and the active identity has secure E2EE key material.") + } + return a.renderMessageResult(cmd, format, result) +} + +func (a *App) runMsgSecureRepair(cmd *cobra.Command, args []string) error { + with, _ := cmd.Flags().GetString("with") + service, format, err := a.messageService() + if err != nil { + return a.messageExit(err, "Run `awiki-cli doctor` to inspect configuration and identity state.") + } + request := message.SecurePeerRequest{ + IdentityName: a.globals.Identity, + With: with, + } + if a.globals.DryRun { + data := map[string]any{"plan": map[string]any{ + "action": "msg.secure.repair", + "identity": a.globals.Identity, + "with": with, + }} + return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, data, "Dry run: secure repair planned", nil, a.identityMeta()) + } + result, err := service.SecureRepair(cmd.Context(), request) + if err != nil { + return a.messageExit(err, "Make sure the target exists and the active identity can rebuild secure state.") + } + return a.renderMessageResult(cmd, format, result) +} + +func (a *App) runMsgSecureFailed(cmd *cobra.Command, args []string) error { + service, format, err := a.messageService() + if err != nil { + return a.messageExit(err, "Run `awiki-cli doctor` to inspect configuration and identity state.") + } + request := message.SecureStatusRequest{IdentityName: a.globals.Identity} + if a.globals.DryRun { + data := map[string]any{"plan": map[string]any{ + "action": "msg.secure.failed", + "identity": a.globals.Identity, + }} + return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, data, "Dry run: secure failed listing planned", nil, a.identityMeta()) + } + result, err := service.SecureFailed(cmd.Context(), request) + if err != nil { + return a.messageExit(err, "Make sure the active identity exists and local storage is readable.") + } + return a.renderMessageResult(cmd, format, result) +} + +func (a *App) runMsgSecureRetry(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return output.NewExitError("invalid_argument", 2, "msg secure retry requires one outbox id.", "Usage: awiki-cli msg secure retry ") + } + service, format, err := a.messageService() + if err != nil { + return a.messageExit(err, "Run `awiki-cli doctor` to inspect configuration and identity state.") + } + request := message.SecureOutboxActionRequest{IdentityName: a.globals.Identity, OutboxID: args[0]} + if a.globals.DryRun { + data := map[string]any{"plan": map[string]any{ + "action": "msg.secure.retry", + "identity": a.globals.Identity, + "outbox_id": args[0], + }} + return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, data, "Dry run: secure retry planned", nil, a.identityMeta()) + } + result, err := service.SecureRetry(cmd.Context(), request) + if err != nil { + return a.messageExit(err, "Make sure the outbox id exists and the active identity can reach the target service.") + } + return a.renderMessageResult(cmd, format, result) +} + +func (a *App) runMsgSecureDrop(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return output.NewExitError("invalid_argument", 2, "msg secure drop requires one outbox id.", "Usage: awiki-cli msg secure drop ") + } + service, format, err := a.messageService() + if err != nil { + return a.messageExit(err, "Run `awiki-cli doctor` to inspect configuration and identity state.") + } + request := message.SecureOutboxActionRequest{IdentityName: a.globals.Identity, OutboxID: args[0]} + if a.globals.DryRun { + data := map[string]any{"plan": map[string]any{ + "action": "msg.secure.drop", + "identity": a.globals.Identity, + "outbox_id": args[0], + }} + return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, data, "Dry run: secure drop planned", nil, a.identityMeta()) + } + result, err := service.SecureDrop(cmd.Context(), request) + if err != nil { + return a.messageExit(err, "Make sure the outbox id exists for the active identity.") + } + return a.renderMessageResult(cmd, format, result) +} + func defaultString(value string, fallback string) string { if strings.TrimSpace(value) == "" { return fallback diff --git a/internal/cli/msg_test.go b/internal/cli/msg_test.go index 7498344..6a206f8 100644 --- a/internal/cli/msg_test.go +++ b/internal/cli/msg_test.go @@ -75,6 +75,72 @@ func TestMsgDryRunPlansRenderStableContracts(t *testing.T) { } }, }, + { + name: "secure status carries peer filter", + spec: "msg.secure.status", + setFlags: map[string]string{"with": "bob"}, + wantSummary: "Dry run: secure status planned", + wantAction: "msg.secure.status", + verifyPlan: func(t *testing.T, plan map[string]any) { + if plan["with"] != "bob" { + t.Fatalf("plan.with = %#v, want bob", plan["with"]) + } + }, + }, + { + name: "secure init carries peer filter", + spec: "msg.secure.init", + setFlags: map[string]string{"with": "bob"}, + wantSummary: "Dry run: secure init planned", + wantAction: "msg.secure.init", + verifyPlan: func(t *testing.T, plan map[string]any) { + if plan["with"] != "bob" { + t.Fatalf("plan.with = %#v, want bob", plan["with"]) + } + }, + }, + { + name: "secure repair carries peer filter", + spec: "msg.secure.repair", + setFlags: map[string]string{"with": "bob"}, + wantSummary: "Dry run: secure repair planned", + wantAction: "msg.secure.repair", + verifyPlan: func(t *testing.T, plan map[string]any) { + if plan["with"] != "bob" { + t.Fatalf("plan.with = %#v, want bob", plan["with"]) + } + }, + }, + { + name: "secure failed lists current identity", + spec: "msg.secure.failed", + wantSummary: "Dry run: secure failed listing planned", + wantAction: "msg.secure.failed", + }, + { + name: "secure retry keeps outbox id", + spec: "msg.secure.retry", + args: []string{"outbox-1"}, + wantSummary: "Dry run: secure retry planned", + wantAction: "msg.secure.retry", + verifyPlan: func(t *testing.T, plan map[string]any) { + if plan["outbox_id"] != "outbox-1" { + t.Fatalf("plan.outbox_id = %#v, want outbox-1", plan["outbox_id"]) + } + }, + }, + { + name: "secure drop keeps outbox id", + spec: "msg.secure.drop", + args: []string{"outbox-1"}, + wantSummary: "Dry run: secure drop planned", + wantAction: "msg.secure.drop", + verifyPlan: func(t *testing.T, plan map[string]any) { + if plan["outbox_id"] != "outbox-1" { + t.Fatalf("plan.outbox_id = %#v, want outbox-1", plan["outbox_id"]) + } + }, + }, } for _, tc := range cases { @@ -99,6 +165,18 @@ func TestMsgDryRunPlansRenderStableContracts(t *testing.T) { return app.runMsgHistory(cmd, nil) case "msg.mark-read": return app.runMsgMarkRead(cmd, tc.args) + case "msg.secure.status": + return app.runMsgSecureStatus(cmd, tc.args) + case "msg.secure.init": + return app.runMsgSecureInit(cmd, tc.args) + case "msg.secure.repair": + return app.runMsgSecureRepair(cmd, tc.args) + case "msg.secure.failed": + return app.runMsgSecureFailed(cmd, tc.args) + case "msg.secure.retry": + return app.runMsgSecureRetry(cmd, tc.args) + case "msg.secure.drop": + return app.runMsgSecureDrop(cmd, tc.args) default: t.Fatalf("unsupported spec %q", tc.spec) return nil @@ -154,3 +232,20 @@ func TestRunMsgSendRejectsInvalidFlagCombinationsBeforeService(t *testing.T) { t.Fatalf("runMsgSend() error = %v, want attachment file validation", err) } } + +func TestRunMsgSecureRetryAndDropRequireOutboxID(t *testing.T) { + t.Parallel() + + catalog := cmdmeta.NewCatalog() + app := &App{catalog: catalog, globals: GlobalOptions{Format: string(output.FormatJSON)}} + + retryCmd := app.commandFromSpec(catalog.MustLookup("msg.secure.retry")) + if err := app.runMsgSecureRetry(retryCmd, nil); err == nil || !strings.Contains(err.Error(), "requires one outbox id") { + t.Fatalf("runMsgSecureRetry() error = %v, want outbox id error", err) + } + + dropCmd := app.commandFromSpec(catalog.MustLookup("msg.secure.drop")) + if err := app.runMsgSecureDrop(dropCmd, nil); err == nil || !strings.Contains(err.Error(), "requires one outbox id") { + t.Fatalf("runMsgSecureDrop() error = %v, want outbox id error", err) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index dbaaf9c..3c5b9f7 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -193,6 +193,18 @@ func (a *App) handlerFor(spec cmdmeta.CommandSpec) func(*cobra.Command, []string return a.runMsgHistory case "msg.mark-read": return a.runMsgMarkRead + case "msg.secure.status": + return a.runMsgSecureStatus + case "msg.secure.init": + return a.runMsgSecureInit + case "msg.secure.repair": + return a.runMsgSecureRepair + case "msg.secure.failed": + return a.runMsgSecureFailed + case "msg.secure.retry": + return a.runMsgSecureRetry + case "msg.secure.drop": + return a.runMsgSecureDrop case "mail.inbox": return a.runMailInbox case "mail.read": diff --git a/internal/cmdmeta/catalog.go b/internal/cmdmeta/catalog.go index af3678c..a00ab31 100644 --- a/internal/cmdmeta/catalog.go +++ b/internal/cmdmeta/catalog.go @@ -149,13 +149,13 @@ func defaultSpecs() []CommandSpec { {Name: "mail.send", Use: "send", Short: "Send a mail message", Phase: "phase5", Implemented: true, Handler: "mail.send", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "to", Type: "string", Usage: "Recipient addresses (comma-separated)", Required: true}, {Name: "cc", Type: "string", Usage: "CC addresses (comma-separated)"}, {Name: "subject", Type: "string", Usage: "Mail subject", Required: true}, {Name: "body", Type: "string", Usage: "Plain text body", Required: true}, {Name: "html", Type: "string", Usage: "HTML body"}}}, {Name: "mail.attachment", Use: "attachment", Short: "Mail attachment commands", Phase: "phase5", Implemented: true}, {Name: "mail.attachment.download", Use: "download", Short: "Download a mail attachment", Phase: "phase5", Implemented: true, Handler: "mail.attachment.download", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "message-id", Type: "string", Usage: "Message id", Required: true}, {Name: "attachment-index", Type: "int", Usage: "Attachment index (0-based)", Default: "0"}, {Name: "output", Type: "string", Usage: "Output file path"}}}, - {Name: "msg.secure", Use: "secure", Short: "Secure direct messaging commands", Phase: "phase5", Implemented: false}, - {Name: "msg.secure.status", Use: "status", Short: "Inspect secure messaging status", Phase: "phase5", Implemented: false, Handler: "stub", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "with", Type: "string", Usage: "Target peer DID or handle"}}}, - {Name: "msg.secure.init", Use: "init", Short: "Initialize a secure session", Phase: "phase5", Implemented: false, Handler: "stub", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "with", Type: "string", Usage: "Target peer DID or handle", Required: true}}}, - {Name: "msg.secure.repair", Use: "repair", Short: "Repair a secure session", Phase: "phase5", Implemented: false, Handler: "stub", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "with", Type: "string", Usage: "Target peer DID or handle", Required: true}}}, - {Name: "msg.secure.failed", Use: "failed", Short: "List failed secure outbox items", Phase: "phase5", Implemented: false, Handler: "stub", Outputs: []string{"json", "pretty", "table"}}, - {Name: "msg.secure.retry", Use: "retry ", Short: "Retry one failed secure outbox item", Phase: "phase5", Implemented: false, Handler: "stub", SideEffect: true, Outputs: []string{"json", "pretty"}}, - {Name: "msg.secure.drop", Use: "drop ", Short: "Drop one failed secure outbox item", Phase: "phase5", Implemented: false, Handler: "stub", SideEffect: true, Outputs: []string{"json", "pretty"}}, + {Name: "msg.secure", Use: "secure", Short: "Secure direct messaging commands", Phase: "phase5", Implemented: true}, + {Name: "msg.secure.status", Use: "status", Short: "Inspect secure messaging status", Phase: "phase5", Implemented: true, Handler: "msg.secure.status", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "with", Type: "string", Usage: "Target peer DID or handle"}}}, + {Name: "msg.secure.init", Use: "init", Short: "Initialize a secure session", Phase: "phase5", Implemented: true, Handler: "msg.secure.init", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "with", Type: "string", Usage: "Target peer DID or handle", Required: true}}}, + {Name: "msg.secure.repair", Use: "repair", Short: "Repair a secure session", Phase: "phase5", Implemented: true, Handler: "msg.secure.repair", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "with", Type: "string", Usage: "Target peer DID or handle", Required: true}}}, + {Name: "msg.secure.failed", Use: "failed", Short: "List failed secure outbox items", Phase: "phase5", Implemented: true, Handler: "msg.secure.failed", Outputs: []string{"json", "pretty", "table"}}, + {Name: "msg.secure.retry", Use: "retry ", Short: "Retry one failed secure outbox item", Phase: "phase5", Implemented: true, Handler: "msg.secure.retry", SideEffect: true, Outputs: []string{"json", "pretty"}}, + {Name: "msg.secure.drop", Use: "drop ", Short: "Drop one failed secure outbox item", Phase: "phase5", Implemented: true, Handler: "msg.secure.drop", SideEffect: true, Outputs: []string{"json", "pretty"}}, {Name: "group", Use: "group", Short: "Group lifecycle commands", Phase: "phase1", Implemented: true}, {Name: "group.create", Use: "create", Short: "Create a new group", Phase: "phase5", Implemented: true, Handler: "group.create", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "name", Type: "string", Usage: "Group display name", Required: true}, {Name: "description", Type: "string", Usage: "Group description"}, {Name: "discoverability", Type: "string", Usage: "Discoverability mode", Default: "private"}, {Name: "admission-mode", Type: "string", Usage: "Admission mode", Default: "open-join"}, {Name: "slug", Type: "string", Usage: "Group slug"}, {Name: "goal", Type: "string", Usage: "Group goal"}, {Name: "rules", Type: "string", Usage: "Group rules"}, {Name: "message-prompt", Type: "string", Usage: "Default group prompt"}, {Name: "doc-url", Type: "string", Usage: "Group document URL"}, {Name: "attachments-allowed", Type: "bool", Usage: "Allow attachments"}, {Name: "max-members", Type: "string", Usage: "Maximum group members"}, {Name: "member-max-messages", Type: "int", Usage: "Per-member message limit"}, {Name: "member-max-total-chars", Type: "int", Usage: "Per-member total char limit"}}}, {Name: "group.get", Use: "get", Short: "Show group details", Aliases: []string{"show"}, Phase: "phase5", Implemented: true, Handler: "group.get", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}}}, diff --git a/internal/message/secure.go b/internal/message/secure.go new file mode 100644 index 0000000..7816279 --- /dev/null +++ b/internal/message/secure.go @@ -0,0 +1,195 @@ +package message + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/agentconnect/awiki-cli/internal/anpsdk" + appconfig "github.com/agentconnect/awiki-cli/internal/config" + "github.com/agentconnect/awiki-cli/internal/identity" +) + +func (s *Service) sendSecureDirect(ctx context.Context, request SendRequest) (*CommandResult, error) { + record, err := s.requireActiveIdentity(request.IdentityName) + if err != nil { + return nil, err + } + if record.E2EEAgreementPrivatePEM == "" || record.Key1PrivatePEM == "" { + return nil, fmt.Errorf("secure direct messaging requires DID signing and X25519 E2EE private keys") + } + targetDID, targetHandle, err := s.resolveTarget(ctx, request.Target) + if err != nil { + return nil, err + } + transport, warnings, err := s.httpTransport(record) + if err != nil { + return nil, err + } + warnings = append(warnings, s.maybePublishSecurePrekeys(ctx, record)...) + client, err := s.secureE2EEClient(ctx, record, transport) + if err != nil { + return nil, err + } + messageID := "msg-" + generateOperationID() + resultMap, err := client.SendText(ctx, targetDID, request.Text, messageID, messageID) + if err != nil { + if isPendingConfirmationError(err) { + outboxID, queueErr := queueSecureOutboxRecord(ctx, s.resolved, s.manager, record, targetDID, defaultString(request.MessageType, "text"), request.Text) + if queueErr != nil { + return nil, queueErr + } + return &CommandResult{ + Data: map[string]any{ + "action": "queue_secure_message", + "target": map[string]any{ + "did": targetDID, + "handle": targetHandle, + "kind": "direct", + }, + "message": map[string]any{ + "type": defaultString(request.MessageType, "text"), + "secure": true, + "queued": true, + }, + "delivery": map[string]any{ + "delivery_state": "queued", + "outbox_id": outboxID, + "target_did": targetDID, + }, + }, + Summary: "Queued secure direct message pending peer confirmation", + Warnings: warnings, + }, nil + } + return nil, err + } + result := directSendResult{ + Accepted: boolFromAny(resultMap["accepted"]), + MessageID: stringFromAny(resultMap["message_id"]), + OperationID: stringFromAny(resultMap["operation_id"]), + TargetDID: stringFromAny(resultMap["target_did"]), + } + if result.MessageID == "" { + result.MessageID = messageID + } + if result.OperationID == "" { + result.OperationID = messageID + } + if result.TargetDID == "" { + result.TargetDID = targetDID + } + request.Target = targetDID + request.SecureMode = "on" + return s.persistSendResult(ctx, record, targetDID, targetHandle, request, &result, warnings) +} + +func (s *Service) maybePublishSecurePrekeys(ctx context.Context, record *identity.StoredIdentity) []string { + return PublishSecurePrekeys(ctx, s.resolved, s.manager, record) +} + +func (s *Service) secureE2EEClient(ctx context.Context, record *identity.StoredIdentity, transport *HTTPTransport) (*anpsdk.MessageServiceE2EEClient, error) { + return NewSecureE2EEClientForRecord(ctx, s.manager, record, func(method string, params map[string]any) (map[string]any, error) { + return transport.rpcMapCall(ctx, method, params) + }) +} + +// PublishSecurePrekeys ensures one identity has a published secure prekey bundle. +func PublishSecurePrekeys(ctx context.Context, resolved *appconfig.Resolved, manager *identity.Manager, record *identity.StoredIdentity) []string { + if record == nil || record.E2EEAgreementPrivatePEM == "" || record.Key1PrivatePEM == "" { + return nil + } + auth, err := newAuthContext(record, manager) + if err != nil { + return compactWarnings([]string{fmt.Sprintf("Failed to initialize secure prekey auth: %v", err)}) + } + primeAuthSession(auth, resolved) + transport := NewHTTPTransport(resolved, auth, nil) + client, err := NewSecureE2EEClientForRecord(ctx, manager, record, func(method string, params map[string]any) (map[string]any, error) { + return transport.rpcMapCall(ctx, method, params) + }) + if err != nil { + return compactWarnings([]string{fmt.Sprintf("Failed to initialize secure prekey publisher: %v", err)}) + } + if _, err := client.PublishPrekeyBundle(); err != nil { + return compactWarnings([]string{fmt.Sprintf("Failed to publish secure prekeys: %v", err)}) + } + return nil +} + +// NewSecureE2EEClientForRecord creates one direct-e2ee client for the given identity. +func NewSecureE2EEClientForRecord(ctx context.Context, manager *identity.Manager, record *identity.StoredIdentity, rpc func(string, map[string]any) (map[string]any, error)) (*anpsdk.MessageServiceE2EEClient, error) { + _ = ctx + if manager == nil { + return nil, fmt.Errorf("identity manager is required") + } + if record == nil { + return nil, fmt.Errorf("identity record is required") + } + paths, err := manager.PathsForIdentity(record.IdentityName) + if err != nil { + return nil, err + } + signingPrivate, err := anpsdk.PrivateKeyFromPEM(record.Key1PrivatePEM) + if err != nil { + return nil, fmt.Errorf("parse DID signing private key: %w", err) + } + agreementPrivate, err := anpsdk.PrivateKeyFromPEM(record.E2EEAgreementPrivatePEM) + if err != nil { + return nil, fmt.Errorf("parse E2EE agreement private key: %w", err) + } + sessionStore, err := anpsdk.NewFileSessionStore(filepath.Join(paths.IdentityDir, "p5-e2ee-sessions")) + if err != nil { + return nil, err + } + signedPrekeyStore, err := anpsdk.NewFileSignedPrekeyStore(filepath.Join(paths.IdentityDir, "p5-signed-prekeys")) + if err != nil { + return nil, err + } + oneTimePrekeyStore, err := anpsdk.NewFileOneTimePrekeyStore(filepath.Join(paths.IdentityDir, "p5-one-time-prekeys")) + if err != nil { + return nil, err + } + resolver := func(ctx context.Context, did string) (map[string]any, error) { + if did == record.DID && record.DIDDocument != nil { + return record.DIDDocument, nil + } + if localDocument, ok := loadLocalDIDDocument(manager, did); ok { + return localDocument, nil + } + return anpsdk.ResolveDidDocument(ctx, did, true) + } + return anpsdk.NewMessageServiceDirectE2eeClient( + record.DID, + signingPrivate, + record.DID+"#key-1", + agreementPrivate, + record.DID+"#key-3", + rpc, + resolver, + sessionStore, + signedPrekeyStore, + oneTimePrekeyStore, + ) +} + +func loadLocalDIDDocument(manager *identity.Manager, did string) (map[string]any, bool) { + if manager == nil || did == "" { + return nil, false + } + summaries, err := manager.List() + if err != nil { + return nil, false + } + for _, summary := range summaries { + if summary.DID != did { + continue + } + record, err := manager.Load(summary.IdentityName) + if err != nil || record == nil || record.DIDDocument == nil { + return nil, false + } + return record.DIDDocument, true + } + return nil, false +} diff --git a/internal/message/secure_commands.go b/internal/message/secure_commands.go new file mode 100644 index 0000000..f8f0b71 --- /dev/null +++ b/internal/message/secure_commands.go @@ -0,0 +1,392 @@ +package message + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/agentconnect/awiki-cli/internal/anpsdk" + "github.com/agentconnect/awiki-cli/internal/identity" + "github.com/agentconnect/awiki-cli/internal/store" +) + +func (s *Service) SecureStatus(ctx context.Context, request SecureStatusRequest) (*CommandResult, error) { + record, err := s.requireActiveIdentity(request.IdentityName) + if err != nil { + return nil, err + } + peerDID := "" + peerHandle := "" + if strings.TrimSpace(request.With) != "" { + peerDID, peerHandle, err = s.resolveTarget(ctx, request.With) + if err != nil { + return nil, err + } + } + sessions, err := s.listSecureSessions(record, peerDID) + if err != nil { + return nil, err + } + db, err := store.Open(s.resolved.Paths) + if err != nil { + return nil, err + } + defer db.Close() + if err := store.EnsureSchema(ctx, db); err != nil { + return nil, err + } + outboxRows, err := store.ListE2EEOutbox(ctx, db, record.DID, record.IdentityName, "") + if err != nil { + return nil, err + } + if peerDID != "" { + filtered := make([]map[string]any, 0, len(outboxRows)) + for _, row := range outboxRows { + if stringFromAny(row["peer_did"]) == peerDID { + filtered = append(filtered, row) + } + } + outboxRows = filtered + } + outboxSummary := map[string]int{} + for _, row := range outboxRows { + status := defaultString(stringFromAny(row["local_status"]), "unknown") + outboxSummary[status]++ + } + return &CommandResult{ + Data: map[string]any{ + "with": peerHandleOrDid(peerHandle, peerDID), + "sessions": sessions, + "outbox": map[string]any{ + "total": len(outboxRows), + "by_status": outboxSummary, + "records": redactSecureOutboxRowsForStatus(outboxRows), + }, + }, + Summary: fmt.Sprintf("Loaded %d secure session(s) and %d secure outbox record(s)", len(sessions), len(outboxRows)), + }, nil +} + +func (s *Service) SecureFailed(ctx context.Context, request SecureStatusRequest) (*CommandResult, error) { + record, err := s.requireActiveIdentity(request.IdentityName) + if err != nil { + return nil, err + } + db, err := store.Open(s.resolved.Paths) + if err != nil { + return nil, err + } + defer db.Close() + if err := store.EnsureSchema(ctx, db); err != nil { + return nil, err + } + rows, err := store.ListE2EEOutbox(ctx, db, record.DID, record.IdentityName, "failed") + if err != nil { + return nil, err + } + return &CommandResult{ + Data: map[string]any{ + "failed": rows, + "total": len(rows), + }, + Summary: fmt.Sprintf("Loaded %d failed secure outbox record(s)", len(rows)), + }, nil +} + +func (s *Service) SecureDrop(ctx context.Context, request SecureOutboxActionRequest) (*CommandResult, error) { + record, err := s.requireActiveIdentity(request.IdentityName) + if err != nil { + return nil, err + } + db, err := store.Open(s.resolved.Paths) + if err != nil { + return nil, err + } + defer db.Close() + if err := store.EnsureSchema(ctx, db); err != nil { + return nil, err + } + if _, err := store.GetE2EEOutbox(ctx, db, request.OutboxID, record.DID, record.IdentityName); err != nil { + return nil, err + } + if err := store.UpdateE2EEOutboxStatus(ctx, db, request.OutboxID, record.DID, record.IdentityName, "dropped"); err != nil { + return nil, err + } + return &CommandResult{ + Data: map[string]any{ + "outbox_id": request.OutboxID, + "status": "dropped", + }, + Summary: fmt.Sprintf("Dropped secure outbox record %s", request.OutboxID), + }, nil +} + +func (s *Service) SecureRetry(ctx context.Context, request SecureOutboxActionRequest) (*CommandResult, error) { + record, err := s.requireActiveIdentity(request.IdentityName) + if err != nil { + return nil, err + } + db, err := store.Open(s.resolved.Paths) + if err != nil { + return nil, err + } + defer db.Close() + if err := store.EnsureSchema(ctx, db); err != nil { + return nil, err + } + row, err := store.GetE2EEOutbox(ctx, db, request.OutboxID, record.DID, record.IdentityName) + if err != nil { + return nil, err + } + if err := store.UpdateE2EEOutboxStatus(ctx, db, request.OutboxID, record.DID, record.IdentityName, "queued"); err != nil { + return nil, err + } + warnings := FlushQueuedSecureOutbox(ctx, s.resolved, s.manager, record, stringFromAny(row["peer_did"]), func(method string, params map[string]any) (map[string]any, error) { + transport, _, err := s.httpTransport(record) + if err != nil { + return nil, err + } + return transport.rpcMapCall(ctx, method, params) + }) + row, _ = store.GetE2EEOutbox(ctx, db, request.OutboxID, record.DID, record.IdentityName) + return &CommandResult{ + Data: map[string]any{ + "outbox_id": request.OutboxID, + "record": row, + }, + Summary: fmt.Sprintf("Retried secure outbox record %s", request.OutboxID), + Warnings: warnings, + }, nil +} + +func (s *Service) SecureInit(ctx context.Context, request SecurePeerRequest) (*CommandResult, error) { + record, peerDID, peerHandle, warnings, err := s.prepareSecurePeerAction(ctx, request) + if err != nil { + return nil, err + } + session, ok, err := s.loadSecureSessionState(record, peerDID) + if err != nil { + return nil, err + } + if ok { + return &CommandResult{ + Data: map[string]any{ + "target": map[string]any{"did": peerDID, "handle": peerHandle, "kind": "direct"}, + "session": session, + "reused": true, + }, + Summary: fmt.Sprintf("Secure session already exists for %s", peerHandleOrDid(peerHandle, peerDID)), + Warnings: compactWarnings(warnings), + }, nil + } + transport, httpWarnings, err := s.httpTransport(record) + if err != nil { + return nil, err + } + warnings = append(warnings, httpWarnings...) + client, err := s.secureE2EEClient(ctx, record, transport) + if err != nil { + return nil, err + } + messageID := "secure-init-" + generateOperationID() + resultMap, err := client.SendJSON(ctx, peerDID, BuildSecureInitPayload(), messageID, messageID) + if err != nil { + return nil, err + } + session, _, _ = s.loadSecureSessionState(record, peerDID) + return &CommandResult{ + Data: map[string]any{ + "target": map[string]any{"did": peerDID, "handle": peerHandle, "kind": "direct"}, + "session": session, + "delivery": map[string]any{ + "message_id": defaultString(stringFromAny(resultMap["message_id"]), messageID), + "operation_id": defaultString(stringFromAny(resultMap["operation_id"]), messageID), + "target_did": defaultString(stringFromAny(resultMap["target_did"]), peerDID), + }, + "initialized": true, + }, + Summary: fmt.Sprintf("Initialized secure session with %s", peerHandleOrDid(peerHandle, peerDID)), + Warnings: compactWarnings(warnings), + }, nil +} + +func (s *Service) SecureRepair(ctx context.Context, request SecurePeerRequest) (*CommandResult, error) { + record, peerDID, peerHandle, warnings, err := s.prepareSecurePeerAction(ctx, request) + if err != nil { + return nil, err + } + resetCount, err := s.resetSecurePeerState(ctx, record, peerDID) + if err != nil { + return nil, err + } + initResult, err := s.SecureInit(ctx, request) + if err != nil { + return nil, err + } + initResult.Data["repair"] = map[string]any{ + "peer_did": peerDID, + "peer_handle": peerHandle, + "reset_records": resetCount, + } + initResult.Summary = fmt.Sprintf("Repaired secure session with %s", peerHandleOrDid(peerHandle, peerDID)) + initResult.Warnings = compactWarnings(append(warnings, initResult.Warnings...)) + return initResult, nil +} + +func (s *Service) listSecureSessions(record *identity.StoredIdentity, peerDID string) ([]map[string]any, error) { + paths, err := s.manager.PathsForIdentity(record.IdentityName) + if err != nil { + return nil, err + } + root := filepath.Join(paths.IdentityDir, "p5-e2ee-sessions") + entries, err := filepath.Glob(filepath.Join(root, "*.json")) + if err != nil { + return nil, err + } + result := make([]map[string]any, 0, len(entries)) + for _, path := range entries { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var session map[string]any + if err := json.Unmarshal(raw, &session); err != nil { + return nil, err + } + if peerDID != "" && stringFromAny(session["peer_did"]) != peerDID { + continue + } + result = append(result, redactSecureSessionForStatus(session)) + } + sort.Slice(result, func(i, j int) bool { + return stringFromAny(result[i]["peer_did"]) < stringFromAny(result[j]["peer_did"]) + }) + return result, nil +} + +func redactSecureSessionForStatus(session map[string]any) map[string]any { + summary := map[string]any{ + "session_id": stringFromAny(session["session_id"]), + "suite": stringFromAny(session["suite"]), + "peer_did": stringFromAny(session["peer_did"]), + "status": stringFromAny(session["status"]), + "is_initiator": boolFromAny(session["is_initiator"]), + "send_n": intValueFromAny(session["send_n"], 0), + "recv_n": intValueFromAny(session["recv_n"], 0), + "previous_send_chain_length": intValueFromAny(session["previous_send_chain_length"], 0), + "skipped_key_count": countStatusArrayItems(session["skipped_message_keys"]), + } + return summary +} + +func countStatusArrayItems(value any) int { + switch typed := value.(type) { + case []any: + return len(typed) + case []map[string]any: + return len(typed) + default: + return 0 + } +} + +func redactSecureOutboxRowsForStatus(rows []map[string]any) []map[string]any { + redacted := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + redacted = append(redacted, map[string]any{ + "outbox_id": stringFromAny(row["outbox_id"]), + "peer_did": stringFromAny(row["peer_did"]), + "session_id": stringFromAny(row["session_id"]), + "original_type": stringFromAny(row["original_type"]), + "local_status": stringFromAny(row["local_status"]), + "attempt_count": intValueFromAny(row["attempt_count"], 0), + "sent_msg_id": stringFromAny(row["sent_msg_id"]), + "sent_server_seq": row["sent_server_seq"], + "last_error_code": stringFromAny(row["last_error_code"]), + "retry_hint": stringFromAny(row["retry_hint"]), + "failed_msg_id": stringFromAny(row["failed_msg_id"]), + "failed_server_seq": row["failed_server_seq"], + "last_attempt_at": stringFromAny(row["last_attempt_at"]), + "created_at": stringFromAny(row["created_at"]), + "updated_at": stringFromAny(row["updated_at"]), + }) + } + return redacted +} + +func (s *Service) prepareSecurePeerAction(ctx context.Context, request SecurePeerRequest) (*identity.StoredIdentity, string, string, []string, error) { + record, err := s.requireActiveIdentity(request.IdentityName) + if err != nil { + return nil, "", "", nil, err + } + if record.E2EEAgreementPrivatePEM == "" || record.Key1PrivatePEM == "" { + return nil, "", "", nil, fmt.Errorf("secure direct messaging requires DID signing and X25519 E2EE private keys") + } + if strings.TrimSpace(request.With) == "" { + return nil, "", "", nil, ErrTargetRequired + } + peerDID, peerHandle, err := s.resolveTarget(ctx, request.With) + if err != nil { + return nil, "", "", nil, err + } + return record, peerDID, peerHandle, s.maybePublishSecurePrekeys(ctx, record), nil +} + +func (s *Service) loadSecureSessionState(record *identity.StoredIdentity, peerDID string) (map[string]any, bool, error) { + sessions, err := s.listSecureSessions(record, peerDID) + if err != nil { + return nil, false, err + } + if len(sessions) == 0 { + return nil, false, nil + } + return sessions[0], true, nil +} + +func (s *Service) resetSecurePeerState(ctx context.Context, record *identity.StoredIdentity, peerDID string) (int, error) { + paths, err := s.manager.PathsForIdentity(record.IdentityName) + if err != nil { + return 0, err + } + sessionStore, err := anpsdk.NewFileSessionStore(filepath.Join(paths.IdentityDir, "p5-e2ee-sessions")) + if err != nil { + return 0, err + } + session, ok, err := sessionStore.FindByPeerDID(peerDID) + if err != nil { + return 0, err + } + resetCount := 0 + if ok { + if err := sessionStore.DeleteSession(session.SessionID); err != nil { + return 0, err + } + resetCount++ + } + db, err := store.Open(s.resolved.Paths) + if err != nil { + return resetCount, err + } + defer db.Close() + if err := store.EnsureSchema(ctx, db); err != nil { + return resetCount, err + } + rows, err := store.ListE2EEOutbox(ctx, db, record.DID, record.IdentityName, "failed") + if err != nil { + return resetCount, err + } + for _, row := range rows { + if stringFromAny(row["peer_did"]) != peerDID { + continue + } + if err := store.UpdateE2EEOutboxStatus(ctx, db, stringFromAny(row["outbox_id"]), record.DID, record.IdentityName, "queued"); err != nil { + return resetCount, err + } + resetCount++ + } + return resetCount, nil +} diff --git a/internal/message/secure_control.go b/internal/message/secure_control.go new file mode 100644 index 0000000..e28c9a4 --- /dev/null +++ b/internal/message/secure_control.go @@ -0,0 +1,210 @@ +package message + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/agentconnect/awiki-cli/internal/anpsdk" + appconfig "github.com/agentconnect/awiki-cli/internal/config" + "github.com/agentconnect/awiki-cli/internal/identity" + "github.com/agentconnect/awiki-cli/internal/store" +) + +const secureAckSystemType = "awiki.direct.secure_ack.v1" +const secureInitSystemType = "awiki.direct.secure_init.v1" + +func BuildSecureAckPayload(sessionID string, ackedMessageID string) map[string]any { + return map[string]any{ + "system_type": secureAckSystemType, + "session_id": strings.TrimSpace(sessionID), + "acked_message_id": strings.TrimSpace(ackedMessageID), + } +} + +func BuildSecureInitPayload() map[string]any { + return map[string]any{ + "system_type": secureInitSystemType, + "reason": "manual_init", + } +} + +func IsSecureAckPlaintext(plaintext map[string]any) bool { + if stringFromAny(plaintext["application_content_type"]) != "application/json" { + return false + } + payload, err := mapFromAny(plaintext["payload"]) + if err != nil { + return false + } + return stringFromAny(payload["system_type"]) == secureAckSystemType +} + +func IsSecureInitPlaintext(plaintext map[string]any) bool { + if stringFromAny(plaintext["application_content_type"]) != "application/json" { + return false + } + payload, err := mapFromAny(plaintext["payload"]) + if err != nil { + return false + } + return stringFromAny(payload["system_type"]) == secureInitSystemType +} + +func secureAckSessionID(plaintext map[string]any) string { + payload, err := mapFromAny(plaintext["payload"]) + if err != nil { + return "" + } + return stringFromAny(payload["session_id"]) +} + +func isPendingConfirmationError(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "pending confirmation") || strings.Contains(message, "pending-confirmation") +} + +func queueSecureOutboxRecord(ctx context.Context, resolved *appconfig.Resolved, manager *identity.Manager, record *identity.StoredIdentity, peerDID string, originalType string, plaintext string) (string, error) { + if record == nil { + return "", fmt.Errorf("identity record is required") + } + db, err := store.Open(resolved.Paths) + if err != nil { + return "", err + } + defer db.Close() + if err := store.EnsureSchema(ctx, db); err != nil { + return "", err + } + return store.QueueE2EEOutbox(ctx, db, store.E2EEOutboxRecord{ + OwnerDID: record.DID, + PeerDID: peerDID, + SessionID: currentSecureSessionID(manager, record, peerDID), + OriginalType: defaultString(originalType, "text"), + Plaintext: plaintext, + LocalStatus: "queued", + CredentialName: record.IdentityName, + Metadata: metadataString(map[string]any{"reason": "pending_confirmation"}), + }) +} + +func currentSecureSessionID(manager *identity.Manager, record *identity.StoredIdentity, peerDID string) string { + if manager == nil || record == nil { + return "" + } + paths, err := manager.PathsForIdentity(record.IdentityName) + if err != nil { + return "" + } + sessionStore, err := anpsdk.NewFileSessionStore(filepath.Join(paths.IdentityDir, "p5-e2ee-sessions")) + if err != nil { + return "" + } + session, ok, err := sessionStore.FindByPeerDID(peerDID) + if err != nil || !ok { + return "" + } + return strings.TrimSpace(session.SessionID) +} + +func FlushQueuedSecureOutbox(ctx context.Context, resolved *appconfig.Resolved, manager *identity.Manager, record *identity.StoredIdentity, peerDID string, rpc func(string, map[string]any) (map[string]any, error)) []string { + if record == nil { + return nil + } + db, err := store.Open(resolved.Paths) + if err != nil { + return compactWarnings([]string{fmt.Sprintf("Failed to open secure outbox store: %v", err)}) + } + defer db.Close() + if err := store.EnsureSchema(ctx, db); err != nil { + return compactWarnings([]string{fmt.Sprintf("Failed to ensure secure outbox schema: %v", err)}) + } + rows, err := store.ListE2EEOutbox(ctx, db, record.DID, record.IdentityName, "queued") + if err != nil { + return compactWarnings([]string{fmt.Sprintf("Failed to list secure outbox: %v", err)}) + } + if len(rows) == 0 { + return nil + } + client, err := NewSecureE2EEClientForRecord(ctx, manager, record, rpc) + if err != nil { + return compactWarnings([]string{fmt.Sprintf("Failed to initialize secure outbox sender: %v", err)}) + } + sort.SliceStable(rows, func(i, j int) bool { + return stringFromAny(rows[i]["created_at"]) < stringFromAny(rows[j]["created_at"]) + }) + warnings := make([]string, 0) + for _, row := range rows { + if strings.TrimSpace(peerDID) != "" && stringFromAny(row["peer_did"]) != strings.TrimSpace(peerDID) { + continue + } + outboxID := stringFromAny(row["outbox_id"]) + targetDID := stringFromAny(row["peer_did"]) + originalType := defaultString(stringFromAny(row["original_type"]), "text") + plaintext := stringFromAny(row["plaintext"]) + if outboxID == "" || targetDID == "" { + continue + } + var resultMap map[string]any + switch originalType { + case "text", "": + resultMap, err = client.SendText(ctx, targetDID, plaintext, outboxID, outboxID) + case "json": + var payload map[string]any + if err := json.Unmarshal([]byte(plaintext), &payload); err != nil { + _ = store.SetE2EEOutboxFailureByID(ctx, db, outboxID, record.DID, record.IdentityName, "invalid_payload", "drop", metadataString(map[string]any{"detail": err.Error()})) + warnings = append(warnings, fmt.Sprintf("Failed to parse queued secure JSON payload %s: %v", outboxID, err)) + continue + } + resultMap, err = client.SendJSON(ctx, targetDID, payload, outboxID, outboxID) + default: + _ = store.SetE2EEOutboxFailureByID(ctx, db, outboxID, record.DID, record.IdentityName, "unsupported_original_type", "drop", metadataString(map[string]any{"original_type": originalType})) + warnings = append(warnings, fmt.Sprintf("Queued secure outbox %s uses unsupported original_type=%s", outboxID, originalType)) + continue + } + if err != nil { + _ = store.SetE2EEOutboxFailureByID(ctx, db, outboxID, record.DID, record.IdentityName, "send_failed", "retry", metadataString(map[string]any{"detail": err.Error()})) + warnings = append(warnings, fmt.Sprintf("Failed to flush queued secure outbox %s: %v", outboxID, err)) + continue + } + sessionID := currentSecureSessionID(manager, record, targetDID) + sentMsgID := stringFromAny(resultMap["message_id"]) + if sentMsgID == "" { + sentMsgID = outboxID + } + metadata := metadataString(map[string]any{ + "target_did": targetDID, + "operation_id": stringFromAny(resultMap["operation_id"]), + "delivery_state": stringFromAny(resultMap["delivery_state"]), + "flushed_from": "queued", + }) + if err := store.MarkE2EEOutboxSent(ctx, db, outboxID, record.DID, sessionID, sentMsgID, nil, metadata); err != nil { + warnings = append(warnings, fmt.Sprintf("Failed to mark secure outbox %s sent: %v", outboxID, err)) + continue + } + if err := store.StoreMessage(ctx, db, store.MessageRecord{ + MsgID: sentMsgID, + OwnerDID: record.DID, + ThreadID: store.MakeThreadID(record.DID, targetDID, ""), + Direction: 1, + SenderDID: record.DID, + ReceiverDID: targetDID, + ContentType: contentTypeForMessageType(originalType), + Content: plaintext, + SentAt: stringFromAny(resultMap["accepted_at"]), + IsRead: true, + IsE2EE: true, + Metadata: metadata, + CredentialName: record.IdentityName, + }); err != nil { + warnings = append(warnings, fmt.Sprintf("Failed to persist flushed secure outbox %s: %v", outboxID, err)) + } + } + return compactWarnings(warnings) +} diff --git a/internal/message/secure_incoming.go b/internal/message/secure_incoming.go new file mode 100644 index 0000000..8456893 --- /dev/null +++ b/internal/message/secure_incoming.go @@ -0,0 +1,186 @@ +package message + +import ( + "context" + "fmt" + "sort" + + "github.com/agentconnect/awiki-cli/internal/anpsdk" + "github.com/agentconnect/awiki-cli/internal/identity" +) + +func isDirectE2EEWireContentType(contentType string) bool { + switch contentType { + case "application/anp-direct-init+json", "application/anp-direct-cipher+json": + return true + default: + return false + } +} + +func (s *Service) maybeDecryptDirectE2EEMessages(ctx context.Context, record *identity.StoredIdentity, messages []map[string]any) []string { + if len(messages) == 0 || !containsDirectE2EEMessages(messages) { + return nil + } + transport, _, err := s.httpTransport(record) + if err != nil { + return compactWarnings([]string{fmt.Sprintf("Failed to initialize secure direct decryptor: %v", err)}) + } + client, err := s.secureE2EEClient(ctx, record, transport) + if err != nil { + return compactWarnings([]string{fmt.Sprintf("Failed to initialize secure direct decryptor: %v", err)}) + } + ordered := append([]map[string]any(nil), messages...) + sort.SliceStable(ordered, func(i, j int) bool { + leftSeq := int64Value(ordered[i]["server_seq"]) + rightSeq := int64Value(ordered[j]["server_seq"]) + if leftSeq == rightSeq { + return stringFromAny(ordered[i]["id"]) < stringFromAny(ordered[j]["id"]) + } + if leftSeq == 0 { + return false + } + if rightSeq == 0 { + return true + } + return leftSeq < rightSeq + }) + warnings := make([]string, 0) + for _, message := range ordered { + contentType := stringFromAny(message["content_type"]) + if !isDirectE2EEWireContentType(contentType) { + continue + } + notification, err := directE2EENotificationFromMessageView(message) + if err != nil { + warnings = append(warnings, fmt.Sprintf("Skipped secure direct message %s: %v", stringFromAny(message["id"]), err)) + continue + } + result, err := client.ProcessIncoming(ctx, notification) + if err != nil { + warnings = append(warnings, fmt.Sprintf("Failed to decrypt secure direct message %s: %v", stringFromAny(message["id"]), err)) + continue + } + warnings = append(warnings, s.maybeAckPollingDirectInit(ctx, record, transport, client, message, result)...) + applyDirectE2EEProcessingResult(message, result) + } + return compactWarnings(warnings) +} + +func (s *Service) maybeAckPollingDirectInit(ctx context.Context, record *identity.StoredIdentity, transport *HTTPTransport, client *anpsdk.MessageServiceE2EEClient, message map[string]any, result map[string]any) []string { + if record == nil || transport == nil || client == nil { + return nil + } + if stringFromAny(message["content_type"]) != "application/anp-direct-init+json" { + return nil + } + if stringFromAny(result["state"]) != "decrypted" { + return nil + } + sessionID := directInitSessionIDFromMessage(message) + messageID := stringFromAny(message["id"]) + peerDID := stringFromAny(message["sender_did"]) + if sessionID == "" || messageID == "" || peerDID == "" || peerDID == record.DID { + return nil + } + ackID := "ack-" + sessionID + if _, err := client.SendJSON(ctx, peerDID, BuildSecureAckPayload(sessionID, messageID), ackID, ackID); err != nil { + return []string{fmt.Sprintf("Failed to send secure direct ACK for %s: %v", messageID, err)} + } + return FlushQueuedSecureOutbox(ctx, s.resolved, s.manager, record, peerDID, func(method string, params map[string]any) (map[string]any, error) { + return transport.rpcMapCall(ctx, method, params) + }) +} + +func directInitSessionIDFromMessage(message map[string]any) string { + body, err := mapFromAny(message["content"]) + if err != nil { + return "" + } + return stringFromAny(body["session_id"]) +} + +func containsDirectE2EEMessages(messages []map[string]any) bool { + for _, message := range messages { + if isDirectE2EEWireContentType(stringFromAny(message["content_type"])) { + return true + } + } + return false +} + +func directE2EENotificationFromMessageView(message map[string]any) (map[string]any, error) { + body, err := mapFromAny(message["content"]) + if err != nil { + return nil, fmt.Errorf("content is not a direct-e2ee object") + } + senderDID := stringFromAny(message["sender_did"]) + receiverDID := stringFromAny(message["receiver_did"]) + messageID := stringFromAny(message["id"]) + if senderDID == "" || receiverDID == "" || messageID == "" { + return nil, fmt.Errorf("missing sender_did/receiver_did/id") + } + result := map[string]any{ + "meta": map[string]any{ + "sender_did": senderDID, + "target": map[string]any{"kind": "agent", "did": receiverDID}, + "message_id": messageID, + "profile": "anp.direct.e2ee.v1", + "security_profile": "direct-e2ee", + "content_type": stringFromAny(message["content_type"]), + }, + "body": body, + } + if serverSeq := int64Value(message["server_seq"]); serverSeq != 0 { + result["server_seq"] = serverSeq + } + return result, nil +} + +func applyDirectE2EEProcessingResult(message map[string]any, result map[string]any) { + message["secure"] = true + state := stringFromAny(result["state"]) + if state == "" { + return + } + message["decryption_state"] = state + if state != "decrypted" { + return + } + plaintext, err := mapFromAny(result["plaintext"]) + if err != nil { + return + } + if IsSecureAckPlaintext(plaintext) || IsSecureInitPlaintext(plaintext) { + message["secure_control"] = true + message["type"] = "secure_control" + message["content"] = "" + return + } + contentType := stringFromAny(plaintext["application_content_type"]) + if contentType != "" { + message["content_type"] = contentType + } + switch { + case stringFromAny(plaintext["text"]) != "": + message["content"] = stringFromAny(plaintext["text"]) + message["type"] = "text" + case plaintext["payload"] != nil: + message["content"] = plaintext["payload"] + if contentType == attachmentManifestContentType { + message["type"] = "attachment_manifest" + } else { + message["type"] = "json" + } + case stringFromAny(plaintext["payload_b64u"]) != "": + message["content"] = stringFromAny(plaintext["payload_b64u"]) + message["type"] = "binary" + } +} + +func int64Value(value any) int64 { + if parsed := int64PtrFromAny(value); parsed != nil { + return *parsed + } + return 0 +} diff --git a/internal/message/secure_test.go b/internal/message/secure_test.go new file mode 100644 index 0000000..4b3b0d6 --- /dev/null +++ b/internal/message/secure_test.go @@ -0,0 +1,1404 @@ +package message + +import ( + "context" + "crypto/ecdh" + "crypto/rand" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + anp "github.com/agent-network-protocol/anp/golang" + directe2ee "github.com/agent-network-protocol/anp/golang/direct_e2ee" + "github.com/agentconnect/awiki-cli/internal/anpsdk" + appconfig "github.com/agentconnect/awiki-cli/internal/config" + "github.com/agentconnect/awiki-cli/internal/identity" + "github.com/agentconnect/awiki-cli/internal/store" +) + +func TestServiceSendSecureDirectUsesP5KeyServiceTargetAndPersistsPendingSession(t *testing.T) { + t.Parallel() + + resolved := testResolvedConfig(t) + manager := identity.NewManager(resolved.Paths) + createTestIdentity(t, manager, identity.SaveInput{ + IdentityName: "alice", + UserID: "user-123", + DisplayName: "Alice", + Handle: "alice", + }) + resolved.ActiveIdentity = "alice" + + record, err := manager.Load("alice") + if err != nil { + t.Fatalf("manager.Load() error = %v", err) + } + serviceDID := testMessageServiceDID(t, record.DIDDocument) + prekeyBundle, oneTimePrekey := buildTestSecurePrekeyMaterial(t, resolved, record) + + var sawGetPrekey bool + var sawPublishPrekey bool + var sawDirectSend bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != MessageRPCEndpoint { + http.NotFound(w, r) + return + } + envelope := decodeRPCRequest(t, r) + switch envelope.Method { + case "direct.e2ee.publish_prekey_bundle": + sawPublishPrekey = true + meta := mustMapValue(t, envelope.Params["meta"], "params.meta") + target := mustMapValue(t, meta["target"], "meta.target") + if got := stringFromAny(target["kind"]); got != "service" { + t.Fatalf("publish meta.target.kind = %q, want service", got) + } + if got := stringFromAny(target["did"]); got != serviceDID { + t.Fatalf("publish meta.target.did = %q, want %q", got, serviceDID) + } + body := mustMapValue(t, envelope.Params["body"], "params.body") + if _, ok := body["prekey_bundle"]; !ok { + t.Fatalf("publish body.prekey_bundle missing: %#v", body) + } + if _, ok := body["one_time_prekeys"]; !ok { + t.Fatalf("publish body.one_time_prekeys missing: %#v", body) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{ + "published": true, + "owner_did": record.DID, + "bundle_id": mustMapValue(t, body["prekey_bundle"], "body.prekey_bundle")["bundle_id"], + "published_opk_count": len(body["one_time_prekeys"].([]any)), + "published_at": "2026-04-23T09:25:00Z", + }, + }) + case "direct.e2ee.get_prekey_bundle": + sawGetPrekey = true + if _, ok := envelope.Params["auth"]; ok { + t.Fatalf("direct.e2ee.get_prekey_bundle params.auth should be omitted: %#v", envelope.Params) + } + meta := mustMapValue(t, envelope.Params["meta"], "params.meta") + target := mustMapValue(t, meta["target"], "meta.target") + if got := stringFromAny(meta["profile"]); got != "anp.direct.e2ee.v1" { + t.Fatalf("meta.profile = %q, want anp.direct.e2ee.v1", got) + } + if got := stringFromAny(meta["security_profile"]); got != "transport-protected" { + t.Fatalf("meta.security_profile = %q, want transport-protected", got) + } + if got := stringFromAny(target["kind"]); got != "service" { + t.Fatalf("meta.target.kind = %q, want service", got) + } + if got := stringFromAny(target["did"]); got != serviceDID { + t.Fatalf("meta.target.did = %q, want %q", got, serviceDID) + } + body := mustMapValue(t, envelope.Params["body"], "params.body") + if got := stringFromAny(body["target_did"]); got != record.DID { + t.Fatalf("body.target_did = %q, want %q", got, record.DID) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{ + "target_did": record.DID, + "prekey_bundle": prekeyBundle, + "one_time_prekey": map[string]any{"key_id": oneTimePrekey.KeyID, "public_key_b64u": oneTimePrekey.PublicKeyB64U}, + }, + }) + case "direct.send": + sawDirectSend = true + if _, ok := envelope.Params["auth"]; ok { + t.Fatalf("direct.send params.auth should be omitted for P5 direct-e2ee: %#v", envelope.Params) + } + meta := mustMapValue(t, envelope.Params["meta"], "params.meta") + target := mustMapValue(t, meta["target"], "meta.target") + if got := stringFromAny(meta["content_type"]); got != "application/anp-direct-init+json" { + t.Fatalf("meta.content_type = %q, want application/anp-direct-init+json", got) + } + if got := stringFromAny(meta["operation_id"]); got != stringFromAny(meta["message_id"]) { + t.Fatalf("meta.operation_id = %q, want same as message_id %q", got, meta["message_id"]) + } + if got := stringFromAny(target["kind"]); got != "agent" { + t.Fatalf("meta.target.kind = %q, want agent", got) + } + if got := stringFromAny(target["did"]); got != record.DID { + t.Fatalf("meta.target.did = %q, want %q", got, record.DID) + } + body := mustMapValue(t, envelope.Params["body"], "params.body") + if _, ok := body["recipient_static_key_agreement_id"]; ok { + t.Fatalf("body should not contain recipient_static_key_agreement_id: %#v", body) + } + if got := stringFromAny(body["recipient_one_time_prekey_id"]); got != oneTimePrekey.KeyID { + t.Fatalf("body.recipient_one_time_prekey_id = %q, want %q", got, oneTimePrekey.KeyID) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{ + "accepted": true, + "message_id": meta["message_id"], + "operation_id": meta["operation_id"], + "target_did": record.DID, + "accepted_at": "2026-04-23T09:30:00Z", + }, + }) + default: + t.Fatalf("unexpected RPC method: %s", envelope.Method) + } + })) + defer server.Close() + + resolved.ServiceBaseURL = server.URL + service, err := NewService(resolved) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + + result, err := service.Send(context.Background(), SendRequest{ + IdentityName: "alice", + Target: record.DID, + Text: "hello secure world", + SecureMode: "on", + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if !sawPublishPrekey || !sawGetPrekey || !sawDirectSend { + t.Fatalf("expected secure send to publish/get/send, sawPublishPrekey=%v sawGetPrekey=%v sawDirectSend=%v", sawPublishPrekey, sawGetPrekey, sawDirectSend) + } + + message := mustMapValue(t, result.Data["message"], "result.data.message") + if got := stringFromAny(message["type"]); got != "text" { + t.Fatalf("result.data.message.type = %q, want text", got) + } + if got, ok := message["secure"].(bool); !ok || !got { + t.Fatalf("result.data.message.secure = %#v, want true", message["secure"]) + } + db, err := store.Open(resolved.Paths) + if err != nil { + t.Fatalf("store.Open() error = %v", err) + } + defer db.Close() + var isE2EE int + if err := db.QueryRowContext(context.Background(), "SELECT is_e2ee FROM messages WHERE msg_id = ?", stringFromAny(message["id"])).Scan(&isE2EE); err != nil { + t.Fatalf("query stored secure message is_e2ee error = %v", err) + } + if isE2EE != 1 { + t.Fatalf("stored secure message is_e2ee = %d, want 1", isE2EE) + } + + paths, err := manager.PathsForIdentity("alice") + if err != nil { + t.Fatalf("PathsForIdentity() error = %v", err) + } + sessionEntries, err := os.ReadDir(filepath.Join(paths.IdentityDir, "p5-e2ee-sessions")) + if err != nil { + t.Fatalf("ReadDir(session store) error = %v", err) + } + if len(sessionEntries) != 1 { + t.Fatalf("len(sessionEntries) = %d, want 1", len(sessionEntries)) + } + sessionBytes, err := os.ReadFile(filepath.Join(paths.IdentityDir, "p5-e2ee-sessions", sessionEntries[0].Name())) + if err != nil { + t.Fatalf("ReadFile(session) error = %v", err) + } + var session map[string]any + if err := json.Unmarshal(sessionBytes, &session); err != nil { + t.Fatalf("Unmarshal(session) error = %v", err) + } + if got := stringFromAny(session["status"]); got != "pending-confirmation" { + t.Fatalf("session.status = %q, want pending-confirmation", got) + } +} + +func TestServiceSendSecureDirectQueuesFollowUpWhilePendingConfirmation(t *testing.T) { + t.Parallel() + + resolved := testResolvedConfig(t) + manager := identity.NewManager(resolved.Paths) + createTestIdentity(t, manager, identity.SaveInput{ + IdentityName: "alice", + UserID: "user-123", + DisplayName: "Alice", + Handle: "alice", + }) + resolved.ActiveIdentity = "alice" + + record, err := manager.Load("alice") + if err != nil { + t.Fatalf("manager.Load() error = %v", err) + } + prekeyBundle, oneTimePrekey := buildTestSecurePrekeyMaterial(t, resolved, record) + + var directSendCalls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + envelope := decodeRPCRequest(t, r) + switch envelope.Method { + case "direct.e2ee.publish_prekey_bundle": + body := mustMapValue(t, envelope.Params["body"], "params.body") + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{ + "published": true, + "owner_did": record.DID, + "bundle_id": mustMapValue(t, body["prekey_bundle"], "body.prekey_bundle")["bundle_id"], + "published_opk_count": len(body["one_time_prekeys"].([]any)), + "published_at": "2026-04-23T09:25:00Z", + }, + }) + case "direct.e2ee.get_prekey_bundle": + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{ + "target_did": record.DID, + "prekey_bundle": prekeyBundle, + "one_time_prekey": map[string]any{"key_id": oneTimePrekey.KeyID, "public_key_b64u": oneTimePrekey.PublicKeyB64U}, + }, + }) + case "direct.send": + directSendCalls++ + meta := mustMapValue(t, envelope.Params["meta"], "params.meta") + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{ + "accepted": true, + "message_id": meta["message_id"], + "operation_id": meta["operation_id"], + "target_did": mustMapValue(t, meta["target"], "meta.target")["did"], + "accepted_at": "2026-04-23T09:30:00Z", + }, + }) + default: + t.Fatalf("unexpected RPC method: %s", envelope.Method) + } + })) + defer server.Close() + + resolved.ServiceBaseURL = server.URL + service, err := NewService(resolved) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + + first, err := service.Send(context.Background(), SendRequest{ + IdentityName: "alice", + Target: record.DID, + Text: "first secure", + SecureMode: "on", + }) + if err != nil { + t.Fatalf("first Send() error = %v", err) + } + if first.Data["action"] != "send_message" { + t.Fatalf("first action = %#v, want send_message", first.Data["action"]) + } + second, err := service.Send(context.Background(), SendRequest{ + IdentityName: "alice", + Target: record.DID, + Text: "queued secure", + SecureMode: "on", + }) + if err != nil { + t.Fatalf("second Send() error = %v", err) + } + if second.Data["action"] != "queue_secure_message" { + t.Fatalf("second action = %#v, want queue_secure_message", second.Data["action"]) + } + delivery := mustMapValue(t, second.Data["delivery"], "second.data.delivery") + if got := stringFromAny(delivery["delivery_state"]); got != "queued" { + t.Fatalf("delivery_state = %q, want queued", got) + } + if directSendCalls != 1 { + t.Fatalf("directSendCalls = %d, want 1", directSendCalls) + } + db, err := store.Open(resolved.Paths) + if err != nil { + t.Fatalf("store.Open() error = %v", err) + } + defer db.Close() + rows, err := store.ListE2EEOutbox(context.Background(), db, record.DID, record.IdentityName, "queued") + if err != nil { + t.Fatalf("ListE2EEOutbox() error = %v", err) + } + if len(rows) != 1 || rows[0]["plaintext"] != "queued secure" { + t.Fatalf("queued outbox rows = %#v, want one queued secure row", rows) + } +} + +func TestPollingInboxDecryptsDirectInitAndSendsSecureAck(t *testing.T) { + t.Parallel() + + resolved := testResolvedConfig(t) + manager := identity.NewManager(resolved.Paths) + createTestIdentity(t, manager, identity.SaveInput{ + IdentityName: "alice", + UserID: "user-alice-123", + DisplayName: "Alice", + Handle: "alice", + JWTToken: "token-alice", + }) + createTestIdentity(t, manager, identity.SaveInput{ + IdentityName: "bob", + UserID: "user-bob-123", + DisplayName: "Bob", + Handle: "bob", + JWTToken: "token-bob", + }) + aliceRecord, err := manager.Load("alice") + if err != nil { + t.Fatalf("manager.Load(alice) error = %v", err) + } + bobRecord, err := manager.Load("bob") + if err != nil { + t.Fatalf("manager.Load(bob) error = %v", err) + } + prekeyBundle, oneTimePrekey := buildTestSecurePrekeyMaterial(t, resolved, bobRecord) + + var capturedInit map[string]any + aliceClient, err := NewSecureE2EEClientForRecord(context.Background(), manager, aliceRecord, func(method string, params map[string]any) (map[string]any, error) { + switch method { + case "direct.e2ee.get_prekey_bundle": + return map[string]any{ + "target_did": bobRecord.DID, + "prekey_bundle": prekeyBundle, + "one_time_prekey": map[string]any{"key_id": oneTimePrekey.KeyID, "public_key_b64u": oneTimePrekey.PublicKeyB64U}, + }, nil + case "direct.send": + capturedInit = params + meta := mustMapValue(t, params["meta"], "init.meta") + return map[string]any{ + "accepted": true, + "message_id": meta["message_id"], + "operation_id": meta["operation_id"], + "target_did": bobRecord.DID, + "accepted_at": "2026-04-24T08:00:00Z", + }, nil + default: + return nil, fmt.Errorf("unexpected alice RPC method: %s", method) + } + }) + if err != nil { + t.Fatalf("NewSecureE2EEClientForRecord(alice) error = %v", err) + } + if _, err := aliceClient.SendText(context.Background(), bobRecord.DID, "hello via polling", "msg-init-poll-001", "msg-init-poll-001"); err != nil { + t.Fatalf("alice SendText() error = %v", err) + } + if capturedInit == nil { + t.Fatal("alice direct init was not captured") + } + + var ackDirectSendCalls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + envelope := decodeRPCRequest(t, r) + if envelope.Method != "direct.send" { + t.Fatalf("unexpected bob RPC method: %s", envelope.Method) + } + ackDirectSendCalls++ + meta := mustMapValue(t, envelope.Params["meta"], "ack.meta") + if got := stringFromAny(meta["content_type"]); got != "application/anp-direct-cipher+json" { + t.Fatalf("ack meta.content_type = %q, want direct cipher", got) + } + if got := stringFromAny(meta["operation_id"]); got == "" || got != stringFromAny(meta["message_id"]) { + t.Fatalf("ack operation/message mismatch: %#v", meta) + } + if got := stringFromAny(meta["sender_did"]); got != bobRecord.DID { + t.Fatalf("ack sender_did = %q, want %q", got, bobRecord.DID) + } + target := mustMapValue(t, meta["target"], "ack.meta.target") + if got := stringFromAny(target["did"]); got != aliceRecord.DID { + t.Fatalf("ack target.did = %q, want %q", got, aliceRecord.DID) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{ + "accepted": true, + "message_id": meta["message_id"], + "operation_id": meta["operation_id"], + "target_did": aliceRecord.DID, + "accepted_at": "2026-04-24T08:00:01Z", + }, + }) + })) + defer server.Close() + resolved.ServiceBaseURL = server.URL + resolved.ActiveIdentity = "bob" + service, err := NewService(resolved) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + initMeta := mustMapValue(t, capturedInit["meta"], "captured init meta") + initBody := mustMapValue(t, capturedInit["body"], "captured init body") + messages, _, warnings := service.persistInboxMessages(context.Background(), bobRecord, map[string]any{ + "messages": []any{ + map[string]any{ + "id": stringFromAny(initMeta["message_id"]), + "sender_did": aliceRecord.DID, + "receiver_did": bobRecord.DID, + "content_type": stringFromAny(initMeta["content_type"]), + "content": initBody, + "server_seq": float64(1), + "sent_at": "2026-04-24T08:00:00Z", + }, + }, + "total": 1, + }, "alice") + if len(warnings) != 0 { + t.Fatalf("persistInboxMessages warnings = %#v, want none", warnings) + } + if len(messages) != 1 { + t.Fatalf("len(messages) = %d, want 1", len(messages)) + } + if got := stringFromAny(messages[0]["content"]); got != "hello via polling" { + t.Fatalf("decrypted content = %q, want hello via polling", got) + } + if ackDirectSendCalls != 1 { + t.Fatalf("ackDirectSendCalls = %d, want 1", ackDirectSendCalls) + } +} + +func TestServiceSecureInitCreatesPendingSession(t *testing.T) { + t.Parallel() + + resolved := testResolvedConfig(t) + manager := identity.NewManager(resolved.Paths) + createTestIdentity(t, manager, identity.SaveInput{ + IdentityName: "alice", + UserID: "user-123", + DisplayName: "Alice", + Handle: "alice", + }) + resolved.ActiveIdentity = "alice" + + record, err := manager.Load("alice") + if err != nil { + t.Fatalf("manager.Load() error = %v", err) + } + serviceDID := testMessageServiceDID(t, record.DIDDocument) + prekeyBundle, oneTimePrekey := buildTestSecurePrekeyMaterial(t, resolved, record) + + var directSendCalls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + envelope := decodeRPCRequest(t, r) + switch envelope.Method { + case "direct.e2ee.publish_prekey_bundle": + body := mustMapValue(t, envelope.Params["body"], "params.body") + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{ + "published": true, + "owner_did": record.DID, + "bundle_id": mustMapValue(t, body["prekey_bundle"], "body.prekey_bundle")["bundle_id"], + "published_opk_count": len(body["one_time_prekeys"].([]any)), + "published_at": "2026-04-23T09:25:00Z", + }, + }) + case "direct.e2ee.get_prekey_bundle": + meta := mustMapValue(t, envelope.Params["meta"], "params.meta") + target := mustMapValue(t, meta["target"], "meta.target") + if stringFromAny(target["did"]) != serviceDID { + t.Fatalf("get target.did = %q, want %q", target["did"], serviceDID) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{ + "target_did": record.DID, + "prekey_bundle": prekeyBundle, + "one_time_prekey": map[string]any{"key_id": oneTimePrekey.KeyID, "public_key_b64u": oneTimePrekey.PublicKeyB64U}, + }, + }) + case "direct.send": + directSendCalls++ + meta := mustMapValue(t, envelope.Params["meta"], "params.meta") + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{ + "accepted": true, + "message_id": meta["message_id"], + "operation_id": meta["operation_id"], + "target_did": mustMapValue(t, meta["target"], "meta.target")["did"], + "accepted_at": "2026-04-23T09:30:00Z", + }, + }) + default: + t.Fatalf("unexpected RPC method: %s", envelope.Method) + } + })) + defer server.Close() + + resolved.ServiceBaseURL = server.URL + service, err := NewService(resolved) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + result, err := service.SecureInit(context.Background(), SecurePeerRequest{ + IdentityName: "alice", + With: record.DID, + }) + if err != nil { + t.Fatalf("SecureInit() error = %v", err) + } + if result.Data["initialized"] != true { + t.Fatalf("initialized = %#v, want true", result.Data["initialized"]) + } + if directSendCalls != 1 { + t.Fatalf("directSendCalls = %d, want 1", directSendCalls) + } + paths, err := manager.PathsForIdentity("alice") + if err != nil { + t.Fatalf("PathsForIdentity() error = %v", err) + } + sessionEntries, err := os.ReadDir(filepath.Join(paths.IdentityDir, "p5-e2ee-sessions")) + if err != nil { + t.Fatalf("ReadDir(session store) error = %v", err) + } + if len(sessionEntries) != 1 { + t.Fatalf("len(sessionEntries) = %d, want 1", len(sessionEntries)) + } +} + +func TestFlushQueuedSecureOutboxSendsCipherAfterConfirmation(t *testing.T) { + t.Parallel() + + resolved := testResolvedConfig(t) + manager := identity.NewManager(resolved.Paths) + aliceGenerated, err := identity.GenerateIdentity(identity.GenerateOptions{ + Hostname: "awiki.ai", + PathPrefix: []string{"user"}, + ProofDomain: "awiki.ai", + }) + if err != nil { + t.Fatalf("GenerateIdentity(alice) error = %v", err) + } + if _, err := manager.Save(identity.SaveInput{ + IdentityName: "alice", + DID: aliceGenerated.DID, + UniqueID: aliceGenerated.UniqueID, + UserID: "user-alice-123", + DisplayName: "Alice", + Handle: "alice", + JWTToken: "token-alice", + DIDDocument: aliceGenerated.DIDDocument, + Key1PrivatePEM: aliceGenerated.Key1PrivatePEM, + Key1PublicPEM: aliceGenerated.Key1PublicPEM, + E2EESigningPrivatePEM: aliceGenerated.E2EESigningPrivatePEM, + E2EEAgreementPrivatePEM: aliceGenerated.E2EEAgreementPrivatePEM, + }); err != nil { + t.Fatalf("manager.Save(alice) error = %v", err) + } + aliceRecord, err := manager.Load("alice") + if err != nil { + t.Fatalf("manager.Load(alice) error = %v", err) + } + + bobGenerated, err := identity.GenerateIdentity(identity.GenerateOptions{ + Hostname: "awiki.ai", + PathPrefix: []string{"user"}, + ProofDomain: "awiki.ai", + }) + if err != nil { + t.Fatalf("GenerateIdentity(bob) error = %v", err) + } + aliceStatic, err := secureECDHPrivateKey(aliceGenerated.E2EEAgreementPrivatePEM) + if err != nil { + t.Fatalf("secureECDHPrivateKey(alice) error = %v", err) + } + bobStatic, err := secureECDHPrivateKey(bobGenerated.E2EEAgreementPrivatePEM) + if err != nil { + t.Fatalf("secureECDHPrivateKey(bob) error = %v", err) + } + bobSPK, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey(bobSPK) error = %v", err) + } + bobSigning, err := anp.PrivateKeyFromPEM(bobGenerated.Key1PrivatePEM) + if err != nil { + t.Fatalf("PrivateKeyFromPEM(bob signing) error = %v", err) + } + bundle, err := directe2ee.BuildPrekeyBundle( + "bundle-bob-001", + bobGenerated.DID, + bobGenerated.DID+"#key-3", + directe2ee.SignedPrekeyFromPrivateKey("spk-bob-001", bobSPK, "2026-04-07T00:00:00Z"), + bobSigning, + bobGenerated.DID+"#key-1", + "2026-03-31T09:58:58Z", + ) + if err != nil { + t.Fatalf("BuildPrekeyBundle() error = %v", err) + } + recipientStaticPublic, err := directe2ee.ExtractX25519PublicKey(bobGenerated.DIDDocument, bundle.StaticKeyAgreementID) + if err != nil { + t.Fatalf("ExtractX25519PublicKey(bob) error = %v", err) + } + recipientSignedPrekey := fixed32(t, bobSPK.PublicKey().Bytes()) + sessionBuilder := directe2ee.DirectE2eeSession{} + initMetadata := directe2ee.DirectEnvelopeMetadata{ + SenderDID: aliceGenerated.DID, + RecipientDID: bobGenerated.DID, + MessageID: "msg-init-001", + Profile: "anp.direct.e2ee.v1", + SecurityProfile: "direct-e2ee", + } + aliceSession, _, initBody, err := sessionBuilder.InitiateSession( + initMetadata, + "msg-init-001", + aliceGenerated.DID+"#key-3", + aliceStatic, + bundle, + recipientStaticPublic, + recipientSignedPrekey, + directe2ee.NewTextPlaintext("text/plain", "init"), + ) + if err != nil { + t.Fatalf("InitiateSession() error = %v", err) + } + bobSession, _, err := sessionBuilder.AcceptIncomingInit( + initMetadata, + bobGenerated.DID+"#key-3", + bobStatic, + bobSPK, + fixed32(t, aliceStatic.PublicKey().Bytes()), + initBody, + ) + if err != nil { + t.Fatalf("AcceptIncomingInit() error = %v", err) + } + replyMetadata := directe2ee.DirectEnvelopeMetadata{ + SenderDID: bobGenerated.DID, + RecipientDID: aliceGenerated.DID, + MessageID: "msg-reply-001", + Profile: "anp.direct.e2ee.v1", + SecurityProfile: "direct-e2ee", + } + _, replyBody, err := sessionBuilder.EncryptFollowUp(&bobSession, replyMetadata, "msg-reply-001", directe2ee.NewJSONPlaintext("application/json", BuildSecureAckPayload(aliceSession.SessionID, initMetadata.MessageID))) + if err != nil { + t.Fatalf("EncryptFollowUp(reply) error = %v", err) + } + if _, err := sessionBuilder.DecryptFollowUp(&aliceSession, replyMetadata, replyBody); err != nil { + t.Fatalf("DecryptFollowUp(reply) error = %v", err) + } + paths, err := manager.PathsForIdentity("alice") + if err != nil { + t.Fatalf("PathsForIdentity(alice) error = %v", err) + } + sessionStore, err := anpsdk.NewFileSessionStore(filepath.Join(paths.IdentityDir, "p5-e2ee-sessions")) + if err != nil { + t.Fatalf("NewFileSessionStore() error = %v", err) + } + if err := sessionStore.SaveSession(aliceSession); err != nil { + t.Fatalf("SaveSession() error = %v", err) + } + + db, err := store.Open(resolved.Paths) + if err != nil { + t.Fatalf("store.Open() error = %v", err) + } + defer db.Close() + if err := store.EnsureSchema(context.Background(), db); err != nil { + t.Fatalf("EnsureSchema() error = %v", err) + } + outboxID, err := store.QueueE2EEOutbox(context.Background(), db, store.E2EEOutboxRecord{ + OwnerDID: aliceRecord.DID, + PeerDID: bobGenerated.DID, + SessionID: aliceSession.SessionID, + OriginalType: "text", + Plaintext: "queued follow-up", + LocalStatus: "queued", + CredentialName: aliceRecord.IdentityName, + }) + if err != nil { + t.Fatalf("QueueE2EEOutbox() error = %v", err) + } + + var sendCalls int + warnings := FlushQueuedSecureOutbox(context.Background(), resolved, manager, aliceRecord, bobGenerated.DID, func(method string, params map[string]any) (map[string]any, error) { + if method != "direct.send" { + t.Fatalf("unexpected method: %s", method) + } + sendCalls++ + meta := mustMapValue(t, params["meta"], "params.meta") + if got := stringFromAny(meta["content_type"]); got != "application/anp-direct-cipher+json" { + t.Fatalf("meta.content_type = %q, want application/anp-direct-cipher+json", got) + } + return map[string]any{ + "accepted": true, + "message_id": meta["message_id"], + "operation_id": meta["operation_id"], + "target_did": mustMapValue(t, meta["target"], "meta.target")["did"], + "accepted_at": "2026-04-23T10:00:00Z", + }, nil + }) + if len(warnings) != 0 { + t.Fatalf("FlushQueuedSecureOutbox() warnings = %#v, want none", warnings) + } + if sendCalls != 1 { + t.Fatalf("sendCalls = %d, want 1", sendCalls) + } + outboxRow, err := store.GetE2EEOutbox(context.Background(), db, outboxID, aliceRecord.DID, aliceRecord.IdentityName) + if err != nil { + t.Fatalf("GetE2EEOutbox() error = %v", err) + } + if got := outboxRow["local_status"]; got != "sent" { + t.Fatalf("outbox local_status = %#v, want sent", got) + } + messageRow, err := store.GetMessageByID(context.Background(), db, outboxID, aliceRecord.DID, aliceRecord.IdentityName) + if err != nil { + t.Fatalf("GetMessageByID() error = %v", err) + } + if got := messageRow["content"]; got != "queued follow-up" { + t.Fatalf("stored content = %#v, want queued follow-up", got) + } +} + +func TestServiceSecureStatusReturnsSessionAndOutboxSummary(t *testing.T) { + t.Parallel() + + resolved := testResolvedConfig(t) + manager := identity.NewManager(resolved.Paths) + createTestIdentity(t, manager, identity.SaveInput{ + IdentityName: "alice", + UserID: "user-123", + DisplayName: "Alice", + Handle: "alice", + }) + resolved.ActiveIdentity = "alice" + + record, err := manager.Load("alice") + if err != nil { + t.Fatalf("manager.Load() error = %v", err) + } + paths, err := manager.PathsForIdentity("alice") + if err != nil { + t.Fatalf("PathsForIdentity() error = %v", err) + } + sessionStore, err := anpsdk.NewFileSessionStore(filepath.Join(paths.IdentityDir, "p5-e2ee-sessions")) + if err != nil { + t.Fatalf("NewFileSessionStore() error = %v", err) + } + if err := sessionStore.SaveSession(anpsdk.DirectSessionState{ + SessionID: "session-001", + Suite: directe2ee.MTIDirectE2EESuite, + PeerDID: "did:wba:awiki.ai:user:bob:e1_bob", + LocalKeyAgreementID: record.DID + "#key-3", + PeerKeyAgreementID: "did:wba:awiki.ai:user:bob:e1_bob#key-3", + RootKeyB64U: "root", + SendChainKeyB64U: "send", + RecvChainKeyB64U: "recv", + RatchetPrivateKeyB64U: "priv", + RatchetPublicKeyB64U: "pub", + SendN: 1, + RecvN: 1, + SkippedMessageKeys: []directe2ee.SkippedMessageKey{ + { + DHPubB64U: "skipped-dh", + N: 7, + MessageKeyB64U: "skipped-message-key", + NonceB64U: "skipped-nonce", + }, + }, + IsInitiator: true, + Status: "established", + }); err != nil { + t.Fatalf("SaveSession() error = %v", err) + } + db, err := store.Open(resolved.Paths) + if err != nil { + t.Fatalf("store.Open() error = %v", err) + } + defer db.Close() + if err := store.EnsureSchema(context.Background(), db); err != nil { + t.Fatalf("EnsureSchema() error = %v", err) + } + if _, err := store.QueueE2EEOutbox(context.Background(), db, store.E2EEOutboxRecord{ + OwnerDID: record.DID, + PeerDID: "did:wba:awiki.ai:user:bob:e1_bob", + SessionID: "session-001", + OriginalType: "text", + Plaintext: "secret queued body", + LocalStatus: "failed", + CredentialName: record.IdentityName, + }); err != nil { + t.Fatalf("QueueE2EEOutbox() error = %v", err) + } + + service, err := NewService(resolved) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + result, err := service.SecureStatus(context.Background(), SecureStatusRequest{ + IdentityName: "alice", + With: "did:wba:awiki.ai:user:bob:e1_bob", + }) + if err != nil { + t.Fatalf("SecureStatus() error = %v", err) + } + sessions, ok := result.Data["sessions"].([]map[string]any) + if !ok || len(sessions) != 1 { + t.Fatalf("sessions = %#v, want 1 session", result.Data["sessions"]) + } + if got := sessions[0]["skipped_key_count"]; got != 1 { + t.Fatalf("session.skipped_key_count = %#v, want 1", got) + } + outbox := mustMapValue(t, result.Data["outbox"], "data.outbox") + if got := outbox["total"]; got != 1 { + t.Fatalf("outbox.total = %#v, want 1", got) + } + byStatus, ok := outbox["by_status"].(map[string]int) + if !ok { + t.Fatalf("outbox.by_status = %#v, want map[string]int", outbox["by_status"]) + } + if byStatus["failed"] != 1 { + t.Fatalf("by_status = %#v, want failed=1", byStatus) + } + records, ok := outbox["records"].([]map[string]any) + if !ok || len(records) != 1 { + t.Fatalf("outbox.records = %#v, want one redacted record", outbox["records"]) + } + if _, ok := records[0]["plaintext"]; ok { + t.Fatalf("status outbox record leaked plaintext: %#v", records[0]) + } + encoded, err := json.Marshal(result.Data) + if err != nil { + t.Fatalf("Marshal(status data) error = %v", err) + } + forbidden := []string{ + "root_key_b64u", + "send_chain_key_b64u", + "recv_chain_key_b64u", + "ratchet_private_key_b64u", + "message_key_b64u", + "nonce_b64u", + "secret queued body", + } + for _, value := range forbidden { + if strings.Contains(string(encoded), value) { + t.Fatalf("SecureStatus leaked %q in %s", value, encoded) + } + } +} + +func TestServiceSecureFailedAndDropOperateOnOutbox(t *testing.T) { + t.Parallel() + + resolved := testResolvedConfig(t) + manager := identity.NewManager(resolved.Paths) + createTestIdentity(t, manager, identity.SaveInput{ + IdentityName: "alice", + UserID: "user-123", + DisplayName: "Alice", + Handle: "alice", + }) + resolved.ActiveIdentity = "alice" + + record, err := manager.Load("alice") + if err != nil { + t.Fatalf("manager.Load() error = %v", err) + } + db, err := store.Open(resolved.Paths) + if err != nil { + t.Fatalf("store.Open() error = %v", err) + } + defer db.Close() + if err := store.EnsureSchema(context.Background(), db); err != nil { + t.Fatalf("EnsureSchema() error = %v", err) + } + outboxID, err := store.QueueE2EEOutbox(context.Background(), db, store.E2EEOutboxRecord{ + OwnerDID: record.DID, + PeerDID: "did:wba:awiki.ai:user:bob:e1_bob", + SessionID: "session-001", + OriginalType: "text", + Plaintext: "failed", + LocalStatus: "failed", + CredentialName: record.IdentityName, + }) + if err != nil { + t.Fatalf("QueueE2EEOutbox() error = %v", err) + } + service, err := NewService(resolved) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + failedResult, err := service.SecureFailed(context.Background(), SecureStatusRequest{IdentityName: "alice"}) + if err != nil { + t.Fatalf("SecureFailed() error = %v", err) + } + if failedResult.Data["total"] != 1 { + t.Fatalf("failed total = %#v, want 1", failedResult.Data["total"]) + } + dropResult, err := service.SecureDrop(context.Background(), SecureOutboxActionRequest{ + IdentityName: "alice", + OutboxID: outboxID, + }) + if err != nil { + t.Fatalf("SecureDrop() error = %v", err) + } + if dropResult.Data["status"] != "dropped" { + t.Fatalf("drop status = %#v, want dropped", dropResult.Data["status"]) + } + row, err := store.GetE2EEOutbox(context.Background(), db, outboxID, record.DID, record.IdentityName) + if err != nil { + t.Fatalf("GetE2EEOutbox() error = %v", err) + } + if got := row["local_status"]; got != "dropped" { + t.Fatalf("local_status = %#v, want dropped", got) + } +} + +func TestServiceSecureRetryMarksQueuedRecordSent(t *testing.T) { + t.Parallel() + + resolved := testResolvedConfig(t) + manager := identity.NewManager(resolved.Paths) + createTestIdentity(t, manager, identity.SaveInput{ + IdentityName: "alice", + UserID: "user-123", + DisplayName: "Alice", + Handle: "alice", + }) + resolved.ActiveIdentity = "alice" + + record, err := manager.Load("alice") + if err != nil { + t.Fatalf("manager.Load() error = %v", err) + } + paths, err := manager.PathsForIdentity("alice") + if err != nil { + t.Fatalf("PathsForIdentity() error = %v", err) + } + sessionStore, err := anpsdk.NewFileSessionStore(filepath.Join(paths.IdentityDir, "p5-e2ee-sessions")) + if err != nil { + t.Fatalf("NewFileSessionStore() error = %v", err) + } + bobGenerated, err := identity.GenerateIdentity(identity.GenerateOptions{ + Hostname: "awiki.ai", + PathPrefix: []string{"user"}, + ProofDomain: "awiki.ai", + }) + if err != nil { + t.Fatalf("GenerateIdentity(bob) error = %v", err) + } + aliceStatic, err := secureECDHPrivateKey(record.E2EEAgreementPrivatePEM) + if err != nil { + t.Fatalf("secureECDHPrivateKey(alice) error = %v", err) + } + bobStatic, err := secureECDHPrivateKey(bobGenerated.E2EEAgreementPrivatePEM) + if err != nil { + t.Fatalf("secureECDHPrivateKey(bob) error = %v", err) + } + bobSPK, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey(bobSPK) error = %v", err) + } + bobSigning, err := anp.PrivateKeyFromPEM(bobGenerated.Key1PrivatePEM) + if err != nil { + t.Fatalf("PrivateKeyFromPEM(bob signing) error = %v", err) + } + bundle, err := directe2ee.BuildPrekeyBundle( + "bundle-bob-001", + bobGenerated.DID, + bobGenerated.DID+"#key-3", + directe2ee.SignedPrekeyFromPrivateKey("spk-bob-001", bobSPK, "2026-04-07T00:00:00Z"), + bobSigning, + bobGenerated.DID+"#key-1", + "2026-03-31T09:58:58Z", + ) + if err != nil { + t.Fatalf("BuildPrekeyBundle() error = %v", err) + } + recipientStaticPublic, err := directe2ee.ExtractX25519PublicKey(bobGenerated.DIDDocument, bundle.StaticKeyAgreementID) + if err != nil { + t.Fatalf("ExtractX25519PublicKey(bob) error = %v", err) + } + recipientSignedPrekey := fixed32(t, bobSPK.PublicKey().Bytes()) + sessionBuilder := directe2ee.DirectE2eeSession{} + initMetadata := directe2ee.DirectEnvelopeMetadata{ + SenderDID: record.DID, + RecipientDID: bobGenerated.DID, + MessageID: "msg-init-retry-001", + Profile: "anp.direct.e2ee.v1", + SecurityProfile: "direct-e2ee", + } + aliceSession, _, initBody, err := sessionBuilder.InitiateSession( + initMetadata, + "msg-init-retry-001", + record.DID+"#key-3", + aliceStatic, + bundle, + recipientStaticPublic, + recipientSignedPrekey, + directe2ee.NewTextPlaintext("text/plain", "init"), + ) + if err != nil { + t.Fatalf("InitiateSession() error = %v", err) + } + bobSession, _, err := sessionBuilder.AcceptIncomingInit( + initMetadata, + bobGenerated.DID+"#key-3", + bobStatic, + bobSPK, + fixed32(t, aliceStatic.PublicKey().Bytes()), + initBody, + ) + if err != nil { + t.Fatalf("AcceptIncomingInit() error = %v", err) + } + replyMetadata := directe2ee.DirectEnvelopeMetadata{ + SenderDID: bobGenerated.DID, + RecipientDID: record.DID, + MessageID: "msg-reply-retry-001", + Profile: "anp.direct.e2ee.v1", + SecurityProfile: "direct-e2ee", + } + _, replyBody, err := sessionBuilder.EncryptFollowUp(&bobSession, replyMetadata, "msg-reply-retry-001", directe2ee.NewJSONPlaintext("application/json", BuildSecureAckPayload(aliceSession.SessionID, initMetadata.MessageID))) + if err != nil { + t.Fatalf("EncryptFollowUp(reply) error = %v", err) + } + if _, err := sessionBuilder.DecryptFollowUp(&aliceSession, replyMetadata, replyBody); err != nil { + t.Fatalf("DecryptFollowUp(reply) error = %v", err) + } + if err := sessionStore.SaveSession(aliceSession); err != nil { + t.Fatalf("SaveSession() error = %v", err) + } + db, err := store.Open(resolved.Paths) + if err != nil { + t.Fatalf("store.Open() error = %v", err) + } + defer db.Close() + if err := store.EnsureSchema(context.Background(), db); err != nil { + t.Fatalf("EnsureSchema() error = %v", err) + } + outboxID, err := store.QueueE2EEOutbox(context.Background(), db, store.E2EEOutboxRecord{ + OwnerDID: record.DID, + PeerDID: bobGenerated.DID, + SessionID: aliceSession.SessionID, + OriginalType: "text", + Plaintext: "retry me", + LocalStatus: "failed", + CredentialName: record.IdentityName, + }) + if err != nil { + t.Fatalf("QueueE2EEOutbox() error = %v", err) + } + + var sendCalls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + envelope := decodeRPCRequest(t, r) + switch envelope.Method { + case "direct.e2ee.publish_prekey_bundle": + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{"published": true, "owner_did": record.DID, "bundle_id": "bundle-001", "published_at": "2026-04-23T09:25:00Z"}, + }) + case "direct.send": + sendCalls++ + meta := mustMapValue(t, envelope.Params["meta"], "params.meta") + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{ + "accepted": true, + "message_id": meta["message_id"], + "operation_id": meta["operation_id"], + "target_did": mustMapValue(t, meta["target"], "meta.target")["did"], + "accepted_at": "2026-04-23T09:30:00Z", + }, + }) + default: + t.Fatalf("unexpected RPC method: %s", envelope.Method) + } + })) + defer server.Close() + resolved.ServiceBaseURL = server.URL + service, err := NewService(resolved) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + result, err := service.SecureRetry(context.Background(), SecureOutboxActionRequest{ + IdentityName: "alice", + OutboxID: outboxID, + }) + if err != nil { + t.Fatalf("SecureRetry() error = %v", err) + } + if sendCalls != 1 { + t.Fatalf("sendCalls = %d, want 1", sendCalls) + } + recordMap := mustMapValue(t, result.Data["record"], "data.record") + if got := recordMap["local_status"]; got != "sent" { + t.Fatalf("record.local_status = %#v, want sent", got) + } +} + +func TestServiceSecureRepairResetsFailedOutboxAndStartsNewInit(t *testing.T) { + t.Parallel() + + resolved := testResolvedConfig(t) + manager := identity.NewManager(resolved.Paths) + createTestIdentity(t, manager, identity.SaveInput{ + IdentityName: "alice", + UserID: "user-123", + DisplayName: "Alice", + Handle: "alice", + }) + resolved.ActiveIdentity = "alice" + + record, err := manager.Load("alice") + if err != nil { + t.Fatalf("manager.Load() error = %v", err) + } + serviceDID := testMessageServiceDID(t, record.DIDDocument) + prekeyBundle, oneTimePrekey := buildTestSecurePrekeyMaterial(t, resolved, record) + paths, err := manager.PathsForIdentity("alice") + if err != nil { + t.Fatalf("PathsForIdentity() error = %v", err) + } + sessionStore, err := anpsdk.NewFileSessionStore(filepath.Join(paths.IdentityDir, "p5-e2ee-sessions")) + if err != nil { + t.Fatalf("NewFileSessionStore() error = %v", err) + } + if err := sessionStore.SaveSession(anpsdk.DirectSessionState{ + SessionID: "old-session", + Suite: directe2ee.MTIDirectE2EESuite, + PeerDID: record.DID, + LocalKeyAgreementID: record.DID + "#key-3", + PeerKeyAgreementID: record.DID + "#key-3", + RootKeyB64U: "root", + SendChainKeyB64U: "send", + RecvChainKeyB64U: "recv", + RatchetPrivateKeyB64U: "priv", + RatchetPublicKeyB64U: "pub", + SendN: 1, + RecvN: 1, + IsInitiator: true, + Status: "established", + }); err != nil { + t.Fatalf("SaveSession() error = %v", err) + } + db, err := store.Open(resolved.Paths) + if err != nil { + t.Fatalf("store.Open() error = %v", err) + } + defer db.Close() + if err := store.EnsureSchema(context.Background(), db); err != nil { + t.Fatalf("EnsureSchema() error = %v", err) + } + outboxID, err := store.QueueE2EEOutbox(context.Background(), db, store.E2EEOutboxRecord{ + OwnerDID: record.DID, + PeerDID: record.DID, + SessionID: "old-session", + OriginalType: "text", + Plaintext: "failed message", + LocalStatus: "failed", + CredentialName: record.IdentityName, + }) + if err != nil { + t.Fatalf("QueueE2EEOutbox() error = %v", err) + } + + var directSendCalls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + envelope := decodeRPCRequest(t, r) + switch envelope.Method { + case "direct.e2ee.publish_prekey_bundle": + body := mustMapValue(t, envelope.Params["body"], "params.body") + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{ + "published": true, + "owner_did": record.DID, + "bundle_id": mustMapValue(t, body["prekey_bundle"], "body.prekey_bundle")["bundle_id"], + "published_opk_count": len(body["one_time_prekeys"].([]any)), + "published_at": "2026-04-23T09:25:00Z", + }, + }) + case "direct.e2ee.get_prekey_bundle": + meta := mustMapValue(t, envelope.Params["meta"], "params.meta") + target := mustMapValue(t, meta["target"], "meta.target") + if stringFromAny(target["did"]) != serviceDID { + t.Fatalf("get target.did = %q, want %q", target["did"], serviceDID) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{ + "target_did": record.DID, + "prekey_bundle": prekeyBundle, + "one_time_prekey": map[string]any{"key_id": oneTimePrekey.KeyID, "public_key_b64u": oneTimePrekey.PublicKeyB64U}, + }, + }) + case "direct.send": + directSendCalls++ + meta := mustMapValue(t, envelope.Params["meta"], "params.meta") + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{ + "accepted": true, + "message_id": meta["message_id"], + "operation_id": meta["operation_id"], + "target_did": mustMapValue(t, meta["target"], "meta.target")["did"], + "accepted_at": "2026-04-23T09:30:00Z", + }, + }) + default: + t.Fatalf("unexpected RPC method: %s", envelope.Method) + } + })) + defer server.Close() + resolved.ServiceBaseURL = server.URL + service, err := NewService(resolved) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + result, err := service.SecureRepair(context.Background(), SecurePeerRequest{ + IdentityName: "alice", + With: record.DID, + }) + if err != nil { + t.Fatalf("SecureRepair() error = %v", err) + } + repair := mustMapValue(t, result.Data["repair"], "data.repair") + if repair["reset_records"] != 2 { + t.Fatalf("repair.reset_records = %#v, want 2", repair["reset_records"]) + } + if directSendCalls != 1 { + t.Fatalf("directSendCalls = %d, want 1", directSendCalls) + } + row, err := store.GetE2EEOutbox(context.Background(), db, outboxID, record.DID, record.IdentityName) + if err != nil { + t.Fatalf("GetE2EEOutbox() error = %v", err) + } + if got := row["local_status"]; got != "queued" { + t.Fatalf("local_status = %#v, want queued", got) + } +} + +func buildTestSecurePrekeyMaterial( + t *testing.T, + resolved *appconfig.Resolved, + record *identity.StoredIdentity, +) (map[string]any, anpsdk.OneTimePrekey) { + t.Helper() + + paths, err := identity.NewManager(resolved.Paths).PathsForIdentity(record.IdentityName) + if err != nil { + t.Fatalf("PathsForIdentity() error = %v", err) + } + signingPrivate, err := anpsdk.PrivateKeyFromPEM(record.Key1PrivatePEM) + if err != nil { + t.Fatalf("PrivateKeyFromPEM(signing) error = %v", err) + } + agreementPrivate, err := anpsdk.PrivateKeyFromPEM(record.E2EEAgreementPrivatePEM) + if err != nil { + t.Fatalf("PrivateKeyFromPEM(agreement) error = %v", err) + } + sessionStore, err := anpsdk.NewFileSessionStore(filepath.Join(paths.IdentityDir, "p5-e2ee-sessions")) + if err != nil { + t.Fatalf("NewFileSessionStore() error = %v", err) + } + signedPrekeyStore, err := anpsdk.NewFileSignedPrekeyStore(filepath.Join(paths.IdentityDir, "p5-signed-prekeys")) + if err != nil { + t.Fatalf("NewFileSignedPrekeyStore() error = %v", err) + } + oneTimePrekeyStore, err := anpsdk.NewFileOneTimePrekeyStore(filepath.Join(paths.IdentityDir, "p5-one-time-prekeys")) + if err != nil { + t.Fatalf("NewFileOneTimePrekeyStore() error = %v", err) + } + resolver := func(_ context.Context, did string) (map[string]any, error) { + if did != record.DID { + return nil, fmt.Errorf("unexpected did: %s", did) + } + return record.DIDDocument, nil + } + client, err := anpsdk.NewMessageServiceDirectE2eeClient( + record.DID, + signingPrivate, + record.DID+"#key-1", + agreementPrivate, + record.DID+"#key-3", + func(string, map[string]any) (map[string]any, error) { return map[string]any{}, nil }, + resolver, + sessionStore, + signedPrekeyStore, + oneTimePrekeyStore, + ) + if err != nil { + t.Fatalf("NewMessageServiceDirectE2eeClient() error = %v", err) + } + bundle, err := client.EnsureFreshPrekeyBundle() + if err != nil { + t.Fatalf("EnsureFreshPrekeyBundle() error = %v", err) + } + oneTimePrekeys, err := oneTimePrekeyStore.ListOneTimePrekeys() + if err != nil { + t.Fatalf("ListOneTimePrekeys() error = %v", err) + } + if len(oneTimePrekeys) == 0 { + t.Fatal("expected EnsureFreshPrekeyBundle to materialize local OPKs") + } + raw, err := json.Marshal(bundle) + if err != nil { + t.Fatalf("Marshal(prekey bundle) error = %v", err) + } + var result map[string]any + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("Unmarshal(prekey bundle) error = %v", err) + } + return result, oneTimePrekeys[0] +} + +func testMessageServiceDID(t *testing.T, didDocument map[string]any) string { + t.Helper() + services, ok := didDocument["service"].([]any) + if !ok { + t.Fatalf("didDocument.service = %#v, want []any", didDocument["service"]) + } + for _, entry := range services { + service, ok := entry.(map[string]any) + if !ok { + continue + } + if stringFromAny(service["type"]) != "ANPMessageService" { + continue + } + serviceDID := stringFromAny(service["serviceDid"]) + if serviceDID != "" { + return serviceDID + } + } + t.Fatalf("ANPMessageService.serviceDid not found in %#v", didDocument["service"]) + return "" +} + +func secureECDHPrivateKey(pemValue string) (*ecdh.PrivateKey, error) { + privateKey, err := anp.PrivateKeyFromPEM(pemValue) + if err != nil { + return nil, err + } + return ecdh.X25519().NewPrivateKey(privateKey.Bytes) +} + +func fixed32(t *testing.T, value []byte) [32]byte { + t.Helper() + if len(value) != 32 { + t.Fatalf("len(value) = %d, want 32", len(value)) + } + var result [32]byte + copy(result[:], value) + return result +} diff --git a/internal/message/service.go b/internal/message/service.go index 4fcea8d..526138b 100644 --- a/internal/message/service.go +++ b/internal/message/service.go @@ -61,7 +61,7 @@ func (s *Service) Send(ctx context.Context, request SendRequest) (*CommandResult return nil, ErrTextRequired } if request.SecureMode == "on" { - return nil, ErrSecureNotSupported + return s.sendSecureDirect(ctx, request) } record, err := s.requireActiveIdentity(request.IdentityName) if err != nil { @@ -105,8 +105,13 @@ func (s *Service) Inbox(ctx context.Context, request InboxRequest) (*CommandResu if err != nil { return nil, err } + publishWarnings := s.maybePublishSecurePrekeys(ctx, record) if request.Scope == "all" { - return s.allInbox(ctx, record, request) + result, err := s.allInbox(ctx, record, request) + if result != nil { + result.Warnings = append(result.Warnings, publishWarnings...) + } + return result, err } originalWith := strings.TrimSpace(request.With) targetIsHandle := originalWith != "" && !strings.HasPrefix(originalWith, "did:") @@ -140,7 +145,7 @@ func (s *Service) Inbox(ctx context.Context, request InboxRequest) (*CommandResu } mode := s.runtimeConfig() - warnings := make([]string, 0) + warnings := append([]string(nil), publishWarnings...) var raw map[string]any switch mode.Mode { case runtime.ModeWebSocket: @@ -262,6 +267,7 @@ func (s *Service) History(ctx context.Context, request HistoryRequest) (*Command if err != nil { return nil, err } + publishWarnings := s.maybePublishSecurePrekeys(ctx, record) originalWith := strings.TrimSpace(request.With) targetIsHandle := originalWith != "" && !strings.HasPrefix(originalWith, "did:") peerDID, peerHandle, err := s.resolveTarget(ctx, request.With) @@ -291,7 +297,7 @@ func (s *Service) History(ctx context.Context, request HistoryRequest) (*Command request.With = peerDID mode := s.runtimeConfig() - warnings := make([]string, 0) + warnings := append([]string(nil), publishWarnings...) var raw map[string]any switch mode.Mode { case runtime.ModeWebSocket: @@ -640,22 +646,7 @@ func (s *Service) httpTransport(record *identity.StoredIdentity) (*HTTPTransport if err != nil { return nil, nil, err } - if auth != nil && auth.session != nil { - rememberAuthScopes(auth.session, s.resolved) - } - if auth != nil && auth.session != nil && strings.TrimSpace(record.JWTToken) != "" { - token := strings.TrimSpace(record.JWTToken) - auth.session.SetBearer(s.resolved.ServiceBaseURL, token) - auth.session.SetBearer( - appconfig.JoinBaseURL(s.resolved.ServiceBaseURL, "/user-service/did-auth/rpc"), - token, - ) - auth.session.SetBearer( - appconfig.JoinBaseURL(s.resolved.ServiceBaseURL, MessageRPCEndpoint), - token, - ) - auth.session.SetBearer(s.resolved.ANPServiceEndpoint, token) - } + primeAuthSession(auth, s.resolved) client := http.DefaultClient if s.remote != nil && s.remote.Client() != nil { client = s.remote.Client() @@ -663,6 +654,27 @@ func (s *Service) httpTransport(record *identity.StoredIdentity) (*HTTPTransport return NewHTTPTransport(s.resolved, auth, client), nil, nil } +func primeAuthSession(auth *authContext, resolved *appconfig.Resolved) { + if auth == nil || auth.session == nil || resolved == nil { + return + } + rememberAuthScopes(auth.session, resolved) + if strings.TrimSpace(auth.record.JWTToken) == "" { + return + } + token := strings.TrimSpace(auth.record.JWTToken) + auth.session.SetBearer(resolved.ServiceBaseURL, token) + auth.session.SetBearer( + appconfig.JoinBaseURL(resolved.ServiceBaseURL, "/user-service/did-auth/rpc"), + token, + ) + auth.session.SetBearer( + appconfig.JoinBaseURL(resolved.ServiceBaseURL, MessageRPCEndpoint), + token, + ) + auth.session.SetBearer(resolved.ANPServiceEndpoint, token) +} + func rememberAuthScopes(session *authsdk.Session, resolved *appconfig.Resolved) { if session == nil || resolved == nil { return @@ -748,6 +760,10 @@ func (s *Service) resolveTarget(ctx context.Context, target string) (string, str func (s *Service) persistSendResult(ctx context.Context, record *identity.StoredIdentity, targetDID string, targetHandle string, request SendRequest, result *directSendResult, warnings []string) (*CommandResult, error) { finish := traceutil.LocalDBPhase(ctx, "persist_direct_send") defer finish() + messageType := strings.TrimSpace(request.MessageType) + if messageType == "" { + messageType = "text" + } db, err := store.Open(s.resolved.Paths) if err != nil { return nil, err @@ -768,6 +784,7 @@ func (s *Service) persistSendResult(ctx context.Context, record *identity.Stored ServerSeq: nil, SentAt: result.AcceptedAt, IsRead: true, + IsE2EE: strings.TrimSpace(request.SecureMode) == "on", Metadata: metadataString(map[string]any{"delivery_state": result.DeliveryState, "operation_id": result.OperationID, "target_handle": targetHandle}), CredentialName: record.IdentityName, }); err != nil { @@ -783,13 +800,13 @@ func (s *Service) persistSendResult(ctx context.Context, record *identity.Stored }, "message": map[string]any{ "id": result.MessageID, - "type": request.MessageType, - "secure": false, + "type": messageType, + "secure": strings.TrimSpace(request.SecureMode) == "on", "sent_at": result.AcceptedAt, }, "delivery": result, }, - Summary: fmt.Sprintf("Sent a direct %s message", request.MessageType), + Summary: fmt.Sprintf("Sent a direct %s message", messageType), Warnings: warnings, }, nil } @@ -804,8 +821,12 @@ func (s *Service) persistInboxMessages(ctx context.Context, record *identity.Sto defer db.Close() _ = store.EnsureSchema(ctx, db) messages := messagesFromResult(raw["messages"]) + warnings := s.maybeDecryptDirectE2EEMessages(ctx, record, messages) storable := make([]store.MessageRecord, 0, len(messages)) for _, message := range messages { + if boolFromAny(message["secure_control"]) { + continue + } msgID := stringFromAny(message["id"]) if msgID == "" { continue @@ -816,6 +837,11 @@ func (s *Service) persistInboxMessages(ctx context.Context, record *identity.Sto if peerDID == record.DID { peerDID = receiverDID } + contentValue := message["content"] + content := stringFromAny(contentValue) + if content == "" { + content = metadataString(contentValue) + } storable = append(storable, store.MessageRecord{ MsgID: msgID, OwnerDID: record.DID, @@ -824,9 +850,10 @@ func (s *Service) persistInboxMessages(ctx context.Context, record *identity.Sto SenderDID: senderDID, ReceiverDID: receiverDID, ContentType: stringFromAny(message["content_type"]), - Content: stringFromAny(message["content"]), + Content: content, ServerSeq: int64PtrFromAny(message["server_seq"]), SentAt: stringFromAny(message["sent_at"]), + IsE2EE: boolFromAny(message["secure"]), IsRead: boolFromAny(message["is_read"]), SenderName: stringFromAny(message["sender_name"]), Metadata: metadataString(message), @@ -836,7 +863,7 @@ func (s *Service) persistInboxMessages(ctx context.Context, record *identity.Sto _ = store.StoreMessagesBatch(ctx, db, storable) contactFinish := traceutil.PhaseContext(ctx, "contact_sync") defer contactFinish() - warnings := s.syncDirectPeerHandles(ctx, db, record.DID, messages, knownHandle, "msg.inbox") + warnings = append(warnings, s.syncDirectPeerHandles(ctx, db, record.DID, messages, knownHandle, "msg.inbox")...) return messages, intValueFromAny(raw["total"], len(messages)), warnings } @@ -850,8 +877,12 @@ func (s *Service) persistHistoryMessages(ctx context.Context, record *identity.S defer db.Close() _ = store.EnsureSchema(ctx, db) messages := messagesFromResult(raw["messages"]) + warnings := s.maybeDecryptDirectE2EEMessages(ctx, record, messages) storable := make([]store.MessageRecord, 0, len(messages)) for _, message := range messages { + if boolFromAny(message["secure_control"]) { + continue + } msgID := stringFromAny(message["id"]) if msgID == "" { continue @@ -862,6 +893,11 @@ func (s *Service) persistHistoryMessages(ctx context.Context, record *identity.S if senderDID == record.DID { direction = 1 } + contentValue := message["content"] + content := stringFromAny(contentValue) + if content == "" { + content = metadataString(contentValue) + } storable = append(storable, store.MessageRecord{ MsgID: msgID, OwnerDID: record.DID, @@ -870,9 +906,10 @@ func (s *Service) persistHistoryMessages(ctx context.Context, record *identity.S SenderDID: senderDID, ReceiverDID: receiverDID, ContentType: stringFromAny(message["content_type"]), - Content: stringFromAny(message["content"]), + Content: content, ServerSeq: int64PtrFromAny(message["server_seq"]), SentAt: stringFromAny(message["sent_at"]), + IsE2EE: boolFromAny(message["secure"]), IsRead: boolFromAny(message["is_read"]), SenderName: stringFromAny(message["sender_name"]), Metadata: metadataString(message), @@ -882,7 +919,7 @@ func (s *Service) persistHistoryMessages(ctx context.Context, record *identity.S _ = store.StoreMessagesBatch(ctx, db, storable) contactFinish := traceutil.PhaseContext(ctx, "contact_sync") defer contactFinish() - warnings := s.syncDirectPeerHandles(ctx, db, record.DID, messages, knownHandle, "msg.history") + warnings = append(warnings, s.syncDirectPeerHandles(ctx, db, record.DID, messages, knownHandle, "msg.history")...) return messages, intValueFromAny(raw["total"], len(messages)), warnings } diff --git a/internal/message/types.go b/internal/message/types.go index 63c5d0d..c76d9f3 100644 --- a/internal/message/types.go +++ b/internal/message/types.go @@ -29,7 +29,7 @@ var ( ErrAttachmentMessageInvalid = errors.New("message is not an attachment manifest") ErrAttachmentSenderRequired = errors.New("attachment message sender_did is required") ErrTransportUnavailable = errors.New("message transport is unavailable") - ErrSecureNotSupported = errors.New("direct secure messaging is not implemented yet") + ErrSecureNotSupported = errors.New("secure messaging is not supported for this command yet") ErrMessageNotFound = errors.New("message not found") ) @@ -86,6 +86,21 @@ type AttachmentDownloadRequest struct { OutputPath string } +type SecureStatusRequest struct { + IdentityName string + With string +} + +type SecurePeerRequest struct { + IdentityName string + With string +} + +type SecureOutboxActionRequest struct { + IdentityName string + OutboxID string +} + type directSendResult struct { Accepted bool `json:"accepted"` MessageID string `json:"message_id"` diff --git a/internal/message/ws_proxy_client_test.go b/internal/message/ws_proxy_client_test.go index 2a1b96a..2f09140 100644 --- a/internal/message/ws_proxy_client_test.go +++ b/internal/message/ws_proxy_client_test.go @@ -80,7 +80,7 @@ func TestWSProxyTransportCallsLocalBridgeAndDecodesResponses(t *testing.T) { defer listener.Close() requests := make(chan runtime.BridgeRequest, 1) - go serveBridgeOnce(t, listener, requests, runtime.BridgeResponse{OK: true, Result: map[string]any{ + go serveBridgeProbeAndRequest(t, listener, requests, runtime.BridgeResponse{OK: true, Result: map[string]any{ "message_id": "msg-1", "operation_id": "op-1", }}) @@ -118,22 +118,24 @@ func TestWSProxyTransportWrapsBridgeFailures(t *testing.T) { } } -func serveBridgeOnce(t *testing.T, listener net.Listener, requests chan<- runtime.BridgeRequest, response runtime.BridgeResponse) { +func serveBridgeProbeAndRequest(t *testing.T, listener net.Listener, requests chan<- runtime.BridgeRequest, response runtime.BridgeResponse) { t.Helper() - conn, err := listener.Accept() - if err != nil { - t.Errorf("listener.Accept() error = %v", err) - return - } - defer conn.Close() - - var request runtime.BridgeRequest - if err := json.NewDecoder(conn).Decode(&request); err != nil { - t.Errorf("Decode() error = %v", err) - return - } - requests <- request - if err := json.NewEncoder(conn).Encode(response); err != nil { - t.Errorf("Encode() error = %v", err) + for i := 0; i < 2; i++ { + conn, err := listener.Accept() + if err != nil { + t.Errorf("listener.Accept() error = %v", err) + return + } + func() { + defer conn.Close() + var request runtime.BridgeRequest + if err := json.NewDecoder(conn).Decode(&request); err != nil { + return + } + requests <- request + if err := json.NewEncoder(conn).Encode(response); err != nil { + t.Errorf("Encode() error = %v", err) + } + }() } } diff --git a/internal/runtime/listener/server.go b/internal/runtime/listener/server.go index f094ea7..2486830 100644 --- a/internal/runtime/listener/server.go +++ b/internal/runtime/listener/server.go @@ -7,13 +7,17 @@ import ( "encoding/json" "errors" "fmt" + "log" "net" "os" + "path/filepath" "strconv" "strings" "sync" "time" + directe2ee "github.com/agent-network-protocol/anp/golang/direct_e2ee" + "github.com/agentconnect/awiki-cli/internal/anpsdk" "github.com/agentconnect/awiki-cli/internal/authsdk" appconfig "github.com/agentconnect/awiki-cli/internal/config" "github.com/agentconnect/awiki-cli/internal/identity" @@ -29,24 +33,27 @@ type Supervisor struct { statusMu sync.Mutex status Status - sessionsMu sync.Mutex - sessions map[string]*session - listener net.Listener - db *sql.DB - hostNotify HostNotifySink + sessionsMu sync.Mutex + sessions map[string]*session + localNotificationsMu sync.Mutex + localNotifications map[string][]map[string]any + listener net.Listener + db *sql.DB + hostNotify HostNotifySink } type session struct { - identityName string - record *identity.StoredIdentity - client *WSClient - lastError string - connected bool - ctx context.Context - cancelFunc context.CancelFunc - initResult chan error - initOnce sync.Once - mu sync.RWMutex + identityName string + record *identity.StoredIdentity + client *WSClient + secureRPCCall func(context.Context, string, map[string]any) (map[string]any, error) + lastError string + connected bool + ctx context.Context + cancelFunc context.CancelFunc + initResult chan error + initOnce sync.Once + mu sync.RWMutex } const ( @@ -102,9 +109,10 @@ func NewSupervisor(resolved *appconfig.Resolved) (*Supervisor, error) { StartedAt: time.Now().UTC().Format(time.RFC3339), HostNotify: hostNotifyStatus, }, - sessions: map[string]*session{}, - db: db, - hostNotify: hostNotifySink, + sessions: map[string]*session{}, + localNotifications: map[string][]map[string]any{}, + db: db, + hostNotify: hostNotifySink, }, nil } @@ -470,6 +478,10 @@ func (s *Supervisor) consumeNotifications(ctx context.Context, session *session, } func (s *Supervisor) handleNotification(ctx context.Context, session *session, notification map[string]any) { + notification = s.normalizeDirectSecureNotification(ctx, session, notification) + if notification == nil { + return + } receivedAt := time.Now().UTC() event, shouldNotify := NormalizeHostNotification(notification, receivedAt) if record, ok := messageRecordFromDirectIncoming(notification, session.record.IdentityName); ok { @@ -507,6 +519,420 @@ func (s *Supervisor) handleNotification(ctx context.Context, session *session, n s.dispatchHostNotification(ctx, event, shouldNotify) } +func (s *Supervisor) normalizeDirectSecureNotification(ctx context.Context, session *session, notification map[string]any) map[string]any { + if !isDirectSecureIncomingNotification(notification) { + return notification + } + record := session.currentRecord() + if record == nil { + return notification + } + rpcCall := session.secureRPC() + if rpcCall == nil { + return notification + } + client, err := message.NewSecureE2EEClientForRecord(ctx, s.manager, record, func(method string, params map[string]any) (map[string]any, error) { + return rpcCall(ctx, method, params) + }) + if err != nil { + return notification + } + params, _ := notification["params"].(map[string]any) + result, err := client.ProcessIncoming(ctx, params) + if err != nil { + return notification + } + if stringValue(result["state"]) != "decrypted" { + return notification + } + plaintext, ok := result["plaintext"].(map[string]any) + if !ok { + return notification + } + meta, _ := params["meta"].(map[string]any) + originalBody := params["body"] + originalContentType := stringValue(meta["content_type"]) + meta["content_type"] = stringValue(plaintext["application_content_type"]) + params["body"] = plaintextBodyToNotificationBody(plaintext) + params["secure_state"] = "decrypted" + params["secure_wire_content_type"] = originalContentType + params["secure_wire_body"] = originalBody + if message.IsSecureAckPlaintext(plaintext) { + peerDID := stringValue(meta["sender_did"]) + _ = message.FlushQueuedSecureOutbox(ctx, s.resolved, s.manager, record, peerDID, func(method string, params map[string]any) (map[string]any, error) { + return rpcCall(ctx, method, params) + }) + notification["method"] = "direct.secure.ack" + return notification + } + if message.IsSecureInitPlaintext(plaintext) { + notification["method"] = "direct.secure.init" + } + if originalContentType == "application/anp-direct-init+json" { + sessionID := stringValue(mapValue(originalBody)["session_id"]) + messageID := stringValue(meta["message_id"]) + if sessionID != "" && messageID != "" { + ackID := "ack-" + sessionID + if !s.deliverLocalSecureAckInProcess(ctx, record, stringValue(meta["sender_did"]), sessionID, messageID, ackID) { + ackResult, ackErr := client.SendJSON(ctx, stringValue(meta["sender_did"]), message.BuildSecureAckPayload(sessionID, messageID), ackID, ackID) + if ackErr == nil { + s.deliverLocalSecureAck(ctx, record.DID, stringValue(meta["sender_did"]), ackID, ackResult) + } else { + } + } + s.flushPeerQueuedSecureOutbox(ctx, stringValue(meta["sender_did"]), record.DID) + } + } + return notification +} + +func isDirectSecureIncomingNotification(notification map[string]any) bool { + method, _ := notification["method"].(string) + if method != "direct.incoming" { + return false + } + params, _ := notification["params"].(map[string]any) + meta, _ := params["meta"].(map[string]any) + return isSecureDirectWireContentType(stringValue(meta["content_type"])) +} + +func isSecureDirectWireContentType(contentType string) bool { + switch contentType { + case "application/anp-direct-init+json", "application/anp-direct-cipher+json": + return true + default: + return false + } +} + +func secureNotificationFromMessageView(messageView map[string]any) (map[string]any, error) { + body, ok := messageView["content"].(map[string]any) + if !ok { + return nil, fmt.Errorf("content is not a direct-e2ee object") + } + senderDID := stringValue(messageView["sender_did"]) + receiverDID := stringValue(messageView["receiver_did"]) + messageID := stringValue(messageView["id"]) + if senderDID == "" || receiverDID == "" || messageID == "" { + return nil, fmt.Errorf("missing sender_did/receiver_did/id") + } + params := map[string]any{ + "meta": map[string]any{ + "sender_did": senderDID, + "target": map[string]any{"kind": "agent", "did": receiverDID}, + "message_id": messageID, + "profile": "anp.direct.e2ee.v1", + "security_profile": "direct-e2ee", + "content_type": stringValue(messageView["content_type"]), + }, + "body": body, + } + if serverSeq := messageView["server_seq"]; serverSeq != nil { + params["server_seq"] = serverSeq + } + return map[string]any{"method": "direct.incoming", "params": params}, nil +} + +func (s *Supervisor) flushPeerQueuedSecureOutbox(ctx context.Context, ownerDID string, peerDID string) { + s.sessionsMu.Lock() + sessions := make([]*session, 0, len(s.sessions)) + for _, item := range s.sessions { + sessions = append(sessions, item) + } + s.sessionsMu.Unlock() + for _, item := range sessions { + record := item.currentRecord() + if record == nil || record.DID != ownerDID { + continue + } + rpcCall := item.secureRPC() + if rpcCall == nil { + return + } + warnings := message.FlushQueuedSecureOutbox(ctx, s.resolved, s.manager, record, peerDID, func(method string, params map[string]any) (map[string]any, error) { + return rpcCall(ctx, method, params) + }) + log.Printf("listener queued secure outbox flush owner_did=%s peer_did=%s warnings=%v", ownerDID, peerDID, warnings) + return + } +} + +func (s *Supervisor) deliverLocalSecureAck(ctx context.Context, senderDID string, recipientDID string, fallbackMessageID string, ackResult map[string]any) { + targetSession := s.activeSessionByDID(recipientDID) + if targetSession == nil { + return + } + body, _ := ackResult["body"].(map[string]any) + if len(body) == 0 { + return + } + messageID := fallbackString(stringValue(ackResult["message_id"]), fallbackMessageID) + notification := map[string]any{ + "method": "direct.incoming", + "params": map[string]any{ + "meta": map[string]any{ + "sender_did": senderDID, + "target": map[string]any{"kind": "agent", "did": recipientDID}, + "message_id": messageID, + "profile": "anp.direct.e2ee.v1", + "security_profile": "direct-e2ee", + "content_type": "application/anp-direct-cipher+json", + }, + "body": body, + }, + } + s.handleNotification(ctx, targetSession, notification) +} + +func (s *Supervisor) deliverLocalSecureAckInProcess(ctx context.Context, senderRecord *identity.StoredIdentity, recipientDID string, sessionID string, repliedMessageID string, ackMessageID string) bool { + if senderRecord == nil { + log.Printf("listener local secure ack skipped: sender record missing") + return false + } + recipientRecord := s.recordByDID(recipientDID) + if recipientRecord == nil { + log.Printf("listener local secure ack skipped: recipient %s not managed locally", recipientDID) + return false + } + paths, err := s.manager.PathsForIdentity(senderRecord.IdentityName) + if err != nil { + log.Printf("listener local secure ack skipped: sender paths error: %v", err) + return false + } + // Rebuild the sender-side session via file store so we can emit one local encrypted ack + // even when the service/websocket ack path is unavailable during reconnect recovery. + fileStore, err := anpsdk.NewFileSessionStore(filepath.Join(paths.IdentityDir, "p5-e2ee-sessions")) + if err != nil { + log.Printf("listener local secure ack skipped: session store error: %v", err) + return false + } + senderSession, ok, err := fileStore.FindByPeerDID(recipientDID) + if err != nil || !ok { + log.Printf("listener local secure ack skipped: sender session lookup peer=%s ok=%v err=%v", recipientDID, ok, err) + return false + } + candidateSession := senderSession + builder := directe2ee.DirectE2eeSession{} + _, ackBody, err := builder.EncryptFollowUp( + &candidateSession, + directe2ee.DirectEnvelopeMetadata{ + SenderDID: senderRecord.DID, + RecipientDID: recipientDID, + MessageID: ackMessageID, + Profile: "anp.direct.e2ee.v1", + SecurityProfile: "direct-e2ee", + }, + ackMessageID, + directe2ee.NewJSONPlaintext("application/json", message.BuildSecureAckPayload(sessionID, repliedMessageID)), + ) + if err != nil { + log.Printf("listener local secure ack skipped: encrypt follow-up error: %v", err) + return false + } + notification := map[string]any{ + "meta": map[string]any{ + "sender_did": senderRecord.DID, + "target": map[string]any{"kind": "agent", "did": recipientDID}, + "message_id": ackMessageID, + "profile": "anp.direct.e2ee.v1", + "security_profile": "direct-e2ee", + "content_type": "application/anp-direct-cipher+json", + }, + "body": structToMap(ackBody), + } + recipientClient, err := message.NewSecureE2EEClientForRecord(ctx, s.manager, recipientRecord, func(string, map[string]any) (map[string]any, error) { + return nil, fmt.Errorf("local secure ack delivery does not use outbound rpc") + }) + if err != nil { + log.Printf("listener local secure ack skipped: recipient client init error: %v", err) + return false + } + result, err := recipientClient.ProcessIncoming(ctx, notification) + if err != nil || stringValue(result["state"]) != "decrypted" { + recipientPaths, pathErr := s.manager.PathsForIdentity(recipientRecord.IdentityName) + if pathErr != nil { + log.Printf("listener local secure ack skipped: recipient process incoming err=%v state=%s recipientPathsErr=%v", err, stringValue(result["state"]), pathErr) + return false + } + recipientStore, storeErr := anpsdk.NewFileSessionStore(filepath.Join(recipientPaths.IdentityDir, "p5-e2ee-sessions")) + if storeErr != nil { + log.Printf("listener local secure ack skipped: recipient process incoming err=%v state=%s recipientStoreErr=%v", err, stringValue(result["state"]), storeErr) + return false + } + recipientSession, loadErr := recipientStore.LoadSession(sessionID) + if loadErr != nil { + log.Printf("listener local secure ack skipped: recipient process incoming err=%v state=%s recipientLoadErr=%v", err, stringValue(result["state"]), loadErr) + return false + } + var ackCipher directe2ee.DirectCipherBody + rawBody, marshalErr := json.Marshal(notification["body"]) + if marshalErr != nil { + log.Printf("listener local secure ack skipped: recipient process incoming err=%v state=%s marshalAckErr=%v", err, stringValue(result["state"]), marshalErr) + return false + } + if unmarshalErr := json.Unmarshal(rawBody, &ackCipher); unmarshalErr != nil { + log.Printf("listener local secure ack skipped: recipient process incoming err=%v state=%s unmarshalAckErr=%v", err, stringValue(result["state"]), unmarshalErr) + return false + } + _, decryptErr := builder.DecryptFollowUp( + &recipientSession, + directe2ee.DirectEnvelopeMetadata{ + SenderDID: senderRecord.DID, + RecipientDID: recipientDID, + MessageID: ackMessageID, + Profile: "anp.direct.e2ee.v1", + SecurityProfile: "direct-e2ee", + }, + ackCipher, + ) + if decryptErr != nil { + log.Printf("listener local secure ack skipped: recipient process incoming err=%v state=%s decryptFallbackErr=%v", err, stringValue(result["state"]), decryptErr) + return false + } + if saveErr := recipientStore.SaveSession(recipientSession); saveErr != nil { + log.Printf("listener local secure ack skipped: recipient process incoming err=%v state=%s recipientSaveErr=%v", err, stringValue(result["state"]), saveErr) + return false + } + } + if err := fileStore.SaveSession(candidateSession); err != nil { + log.Printf("listener local secure ack skipped: save sender session error: %v", err) + return false + } + if targetSession := s.activeSessionByDID(recipientDID); targetSession != nil { + if rpcCall := targetSession.secureRPC(); rpcCall != nil { + warnings := message.FlushQueuedSecureOutbox(ctx, s.resolved, s.manager, recipientRecord, senderRecord.DID, func(method string, params map[string]any) (map[string]any, error) { + return rpcCall(ctx, method, params) + }) + log.Printf("listener local secure ack delivered recipient=%s sender=%s flush_warnings=%v", recipientRecord.DID, senderRecord.DID, warnings) + } + log.Printf("listener local secure ack delivered recipient=%s sender=%s", recipientRecord.DID, senderRecord.DID) + return true + } + if s.hasRuntimeSessionForDID(recipientDID) { + s.queueLocalNotification(recipientDID, map[string]any{ + "method": "direct.incoming", + "params": notification, + }) + log.Printf("listener local secure ack queued for recipient=%s sender=%s until session activates", recipientRecord.DID, senderRecord.DID) + return true + } + log.Printf("listener local secure ack fallback to network: recipient session not managed recipient=%s sender=%s", recipientRecord.DID, senderRecord.DID) + return false +} + +func (s *Supervisor) activeSessionByDID(did string) *session { + if strings.TrimSpace(did) == "" { + return nil + } + s.sessionsMu.Lock() + defer s.sessionsMu.Unlock() + for _, item := range s.sessions { + record := item.currentRecord() + if record != nil && record.DID == did { + return item + } + } + return nil +} + +func (s *Supervisor) recordByDID(did string) *identity.StoredIdentity { + if strings.TrimSpace(did) == "" || s.manager == nil { + return nil + } + identities, err := s.manager.List() + if err != nil { + return nil + } + for _, summary := range identities { + if summary.DID != did { + continue + } + record, err := s.manager.Load(summary.IdentityName) + if err == nil && record != nil { + return record + } + } + return nil +} + +func (s *Supervisor) hasRuntimeSessionForDID(did string) bool { + if strings.TrimSpace(did) == "" { + return false + } + s.sessionsMu.Lock() + defer s.sessionsMu.Unlock() + for _, item := range s.sessions { + if record := item.currentRecord(); record != nil && record.DID == did { + return true + } + if s.manager == nil { + continue + } + record, err := s.manager.Load(item.identityName) + if err == nil && record != nil && record.DID == did { + return true + } + } + return false +} + +func (s *Supervisor) queueLocalNotification(recipientDID string, notification map[string]any) { + if strings.TrimSpace(recipientDID) == "" || notification == nil { + return + } + s.localNotificationsMu.Lock() + defer s.localNotificationsMu.Unlock() + s.localNotifications[recipientDID] = append(s.localNotifications[recipientDID], notification) +} + +func (s *Supervisor) flushQueuedLocalNotifications(targetSession *session) { + if targetSession == nil { + return + } + record := targetSession.currentRecord() + if record == nil || strings.TrimSpace(record.DID) == "" { + return + } + s.localNotificationsMu.Lock() + queued := append([]map[string]any(nil), s.localNotifications[record.DID]...) + delete(s.localNotifications, record.DID) + s.localNotificationsMu.Unlock() + for _, notification := range queued { + s.handleNotification(targetSession.ctx, targetSession, notification) + } +} + +func structToMap(value any) map[string]any { + raw, err := json.Marshal(value) + if err != nil { + return map[string]any{} + } + var result map[string]any + if err := json.Unmarshal(raw, &result); err != nil { + return map[string]any{} + } + return result +} + +func plaintextBodyToNotificationBody(plaintext map[string]any) map[string]any { + body := map[string]any{} + for _, key := range []string{"conversation_id", "reply_to_message_id", "annotations"} { + if value, ok := plaintext[key]; ok && value != nil { + body[key] = value + } + } + if text := stringValue(plaintext["text"]); text != "" { + body["text"] = text + } + if payload, ok := plaintext["payload"]; ok && payload != nil { + body["payload"] = payload + } + if payloadB64U := stringValue(plaintext["payload_b64u"]); payloadB64U != "" { + body["payload_b64u"] = payloadB64U + } + return body +} + func (s *Supervisor) dispatchHostNotification(ctx context.Context, event *HostNotificationEvent, shouldNotify bool) { if !shouldNotify || event == nil || s.hostNotify == nil { return @@ -543,6 +969,21 @@ func messageRecordFromDirectIncoming(notification map[string]any, identityName s if sentAt == "" { sentAt = time.Now().UTC().Format(time.RFC3339) } + contentValue := body["text"] + if text := stringValue(body["text"]); text == "" { + switch { + case body["payload"] != nil: + contentValue = body["payload"] + case stringValue(body["payload_b64u"]) != "": + contentValue = body["payload_b64u"] + default: + contentValue = body + } + } + content := stringValue(contentValue) + if content == "" { + content = metadataValue(contentValue) + } return store.MessageRecord{ MsgID: stringValue(meta["message_id"]), OwnerDID: targetDID, @@ -551,7 +992,8 @@ func messageRecordFromDirectIncoming(notification map[string]any, identityName s SenderDID: senderDID, ReceiverDID: targetDID, ContentType: contentType, - Content: stringValue(body["text"]), + Content: content, + IsE2EE: stringValue(meta["security_profile"]) == "direct-e2ee" || stringValue(params["secure_state"]) == "decrypted", SentAt: sentAt, IsRead: false, Metadata: metadataValue(params), @@ -1022,8 +1464,13 @@ func (s *Supervisor) runSessionLoop(session *session) { session.markConnected(record, client) session.signalInitial(nil) s.refreshStatus() + s.flushQueuedLocalNotifications(session) + publishCtx, publishCancel := context.WithCancel(session.ctx) + go s.retryPublishSecurePrekeys(publishCtx, record) + go s.pollUnreadSecureDirectInbox(publishCtx, session, client) err = s.consumeNotifications(session.ctx, session, client) + publishCancel() _ = client.Close() session.markDisconnected(err) s.refreshStatus() @@ -1037,6 +1484,161 @@ func (s *Supervisor) runSessionLoop(session *session) { } } +func (s *Supervisor) retryPublishSecurePrekeys(ctx context.Context, record *identity.StoredIdentity) { + for { + warnings := message.PublishSecurePrekeys(ctx, s.resolved, s.manager, record) + if len(warnings) == 0 { + return + } + log.Printf("listener secure prekey publish retry identity=%s warnings=%s", record.IdentityName, strings.Join(warnings, "; ")) + if !sleepWithContext(ctx, time.Second) { + return + } + } +} + +func (s *Supervisor) syncUnreadSecureDirectInbox(ctx context.Context, session *session, client *WSClient) { + record := session.currentRecord() + if record == nil { + return + } + syncCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + params := message.BuildInboxRPCParams(record, message.InboxRequest{Scope: "direct", UnreadOnly: true, Limit: 100}) + result, err := client.SendRPC(syncCtx, "inbox.get", params) + if err != nil { + return + } + items, _ := result["messages"].([]any) + for _, item := range items { + messageView, ok := item.(map[string]any) + if !ok || !isSecureDirectWireContentType(stringValue(messageView["content_type"])) { + continue + } + ownerDID := stringValue(messageView["receiver_did"]) + if ownerDID == "" { + ownerDID = session.record.DID + } + if _, err := store.GetMessageByID(syncCtx, s.db, stringValue(messageView["id"]), ownerDID, session.identityName); err == nil { + continue + } else if !errors.Is(err, sql.ErrNoRows) { + continue + } + notification, err := secureNotificationFromMessageView(messageView) + if err != nil { + continue + } + log.Printf("listener secure backlog replay identity=%s message_id=%s content_type=%s", session.identityName, stringValue(messageView["id"]), stringValue(messageView["content_type"])) + s.handleNotification(syncCtx, session, notification) + } +} + +func (s *Supervisor) pollUnreadSecureDirectInbox(ctx context.Context, session *session, client *WSClient) { + s.syncUnreadSecureDirectInbox(ctx, session, client) + s.syncPendingConfirmationSecureHistory(ctx, session, client) + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.syncUnreadSecureDirectInbox(ctx, session, client) + s.syncPendingConfirmationSecureHistory(ctx, session, client) + } + } +} + +func (s *Supervisor) syncPendingConfirmationSecureHistory(ctx context.Context, session *session, client *WSClient) { + record := session.currentRecord() + if record == nil { + return + } + peerDIDs := s.pendingConfirmationPeerDIDs(record.IdentityName) + if len(peerDIDs) == 0 { + return + } + for _, peerDID := range peerDIDs { + syncCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + params, buildErr := message.BuildHistoryRPCParams(record, message.HistoryRequest{With: peerDID, Limit: 50}) + if buildErr != nil { + cancel() + continue + } + result, err := client.SendRPC(syncCtx, "direct.get_history", params) + cancel() + if err != nil { + continue + } + items, _ := result["messages"].([]any) + for _, item := range items { + messageView, ok := item.(map[string]any) + if !ok || !isSecureDirectWireContentType(stringValue(messageView["content_type"])) { + continue + } + if stringValue(messageView["sender_did"]) == record.DID { + continue + } + ownerDID := stringValue(messageView["receiver_did"]) + if ownerDID == "" { + ownerDID = record.DID + } + if _, err := store.GetMessageByID(ctx, s.db, stringValue(messageView["id"]), ownerDID, session.identityName); err == nil { + continue + } else if !errors.Is(err, sql.ErrNoRows) { + continue + } + notification, err := secureNotificationFromMessageView(messageView) + if err != nil { + continue + } + s.handleNotification(ctx, session, notification) + } + } +} + +func (s *Supervisor) pendingConfirmationPeerDIDs(identityName string) []string { + if s.manager == nil || strings.TrimSpace(identityName) == "" { + return nil + } + paths, err := s.manager.PathsForIdentity(identityName) + if err != nil { + return nil + } + entries, err := filepath.Glob(filepath.Join(paths.IdentityDir, "p5-e2ee-sessions", "*.json")) + if err != nil { + return nil + } + peers := make([]string, 0, len(entries)) + seen := map[string]struct{}{} + for _, path := range entries { + var payload struct { + PeerDID string `json:"peer_did"` + Status string `json:"status"` + } + if err := readJSONFile(path, &payload); err != nil { + continue + } + if payload.Status != "pending-confirmation" || strings.TrimSpace(payload.PeerDID) == "" { + continue + } + if _, ok := seen[payload.PeerDID]; ok { + continue + } + seen[payload.PeerDID] = struct{}{} + peers = append(peers, payload.PeerDID) + } + return peers +} + +func readJSONFile(path string, out any) error { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(raw, out) +} + func (s *Supervisor) connectSession(identityName string) (*identity.StoredIdentity, *WSClient, error) { record, err := s.manager.Load(identityName) if err != nil { @@ -1089,6 +1691,18 @@ func (s *session) currentClient() *WSClient { return s.client } +func (s *session) secureRPC() func(context.Context, string, map[string]any) (map[string]any, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if s.secureRPCCall != nil { + return s.secureRPCCall + } + if s.client == nil { + return nil + } + return s.client.SendRPC +} + func (s *session) currentRecord() *identity.StoredIdentity { s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/runtime/listener/server_test.go b/internal/runtime/listener/server_test.go index 504c522..5f3eaaf 100644 --- a/internal/runtime/listener/server_test.go +++ b/internal/runtime/listener/server_test.go @@ -2,17 +2,25 @@ package listener import ( "context" + "crypto/ecdh" "encoding/json" + "fmt" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" + "sync" "sync/atomic" "testing" "time" + anp "github.com/agent-network-protocol/anp/golang" + directe2ee "github.com/agent-network-protocol/anp/golang/direct_e2ee" + "github.com/agentconnect/awiki-cli/internal/anpsdk" appconfig "github.com/agentconnect/awiki-cli/internal/config" "github.com/agentconnect/awiki-cli/internal/identity" + "github.com/agentconnect/awiki-cli/internal/message" "github.com/agentconnect/awiki-cli/internal/store" "github.com/coder/websocket" ) @@ -162,6 +170,428 @@ func TestMessageRecordFromGroupIncomingUsesProtocolFieldsOnly(t *testing.T) { } } +func TestHandleNotificationDecryptsSecureDirectIncomingAndStoresPlaintext(t *testing.T) { + t.Parallel() + + root := t.TempDir() + paths := appconfig.Paths{ + WorkspaceHomeDir: filepath.Join(root, ".awiki-cli"), + IdentityDir: filepath.Join(root, "identities"), + LegacyCredentialsDir: filepath.Join(root, "legacy"), + DataDir: filepath.Join(root, "data"), + StateDir: filepath.Join(root, "state"), + DatabaseFile: filepath.Join(root, "data", "awiki-cli.db"), + } + manager := identity.NewManager(paths) + aliceGenerated, err := identity.GenerateIdentity(identity.GenerateOptions{ + Hostname: "awiki.ai", + PathPrefix: []string{"user"}, + ProofDomain: "awiki.ai", + }) + if err != nil { + t.Fatalf("GenerateIdentity(alice) error = %v", err) + } + if _, err := manager.Save(identity.SaveInput{ + IdentityName: "alice", + DID: aliceGenerated.DID, + UniqueID: aliceGenerated.UniqueID, + UserID: "user-alice-123", + DisplayName: "Alice", + Handle: "alice", + JWTToken: "token-123", + DIDDocument: aliceGenerated.DIDDocument, + Key1PrivatePEM: aliceGenerated.Key1PrivatePEM, + Key1PublicPEM: aliceGenerated.Key1PublicPEM, + E2EESigningPrivatePEM: aliceGenerated.E2EESigningPrivatePEM, + E2EEAgreementPrivatePEM: aliceGenerated.E2EEAgreementPrivatePEM, + }); err != nil { + t.Fatalf("manager.Save(alice) error = %v", err) + } + resolved := &appconfig.Resolved{ + Paths: paths, + ServiceBaseURL: "https://awiki.test", + DIDDomain: "awiki.ai", + RuntimeMode: "websocket", + ActiveIdentity: "alice", + } + supervisor, err := NewSupervisor(resolved) + if err != nil { + t.Fatalf("NewSupervisor() error = %v", err) + } + defer supervisor.Close() + + record, err := manager.Load("alice") + if err != nil { + t.Fatalf("manager.Load(alice) error = %v", err) + } + aliceClient, err := message.NewSecureE2EEClientForRecord(context.Background(), manager, record, func(string, map[string]any) (map[string]any, error) { + return map[string]any{}, nil + }) + if err != nil { + t.Fatalf("NewSecureE2EEClientForRecord(alice) error = %v", err) + } + aliceBundle, err := aliceClient.EnsureFreshPrekeyBundle() + if err != nil { + t.Fatalf("EnsureFreshPrekeyBundle(alice) error = %v", err) + } + alicePaths, err := manager.PathsForIdentity("alice") + if err != nil { + t.Fatalf("PathsForIdentity(alice) error = %v", err) + } + aliceOPKStore, err := anpsdk.NewFileOneTimePrekeyStore(filepath.Join(alicePaths.IdentityDir, "p5-one-time-prekeys")) + if err != nil { + t.Fatalf("NewFileOneTimePrekeyStore(alice) error = %v", err) + } + aliceOPKs, err := aliceOPKStore.ListOneTimePrekeys() + if err != nil { + t.Fatalf("ListOneTimePrekeys(alice) error = %v", err) + } + if len(aliceOPKs) == 0 { + t.Fatal("expected at least one Alice OPK") + } + + bobGenerated, err := identity.GenerateIdentity(identity.GenerateOptions{ + Hostname: "awiki.ai", + PathPrefix: []string{"user"}, + ProofDomain: "awiki.ai", + }) + if err != nil { + t.Fatalf("GenerateIdentity(bob) error = %v", err) + } + if _, err := manager.Save(identity.SaveInput{ + IdentityName: "bob", + DID: bobGenerated.DID, + UniqueID: bobGenerated.UniqueID, + UserID: "user-bob-123", + DisplayName: "Bob", + Handle: "bob", + JWTToken: "token-bob", + DIDDocument: bobGenerated.DIDDocument, + Key1PrivatePEM: bobGenerated.Key1PrivatePEM, + Key1PublicPEM: bobGenerated.Key1PublicPEM, + E2EESigningPrivatePEM: bobGenerated.E2EESigningPrivatePEM, + E2EEAgreementPrivatePEM: bobGenerated.E2EEAgreementPrivatePEM, + }); err != nil { + t.Fatalf("manager.Save(bob) error = %v", err) + } + bobPrivate, err := privateKeyFromPEM(t, bobGenerated.E2EEAgreementPrivatePEM) + if err != nil { + t.Fatalf("privateKeyFromPEM(bob) error = %v", err) + } + recipientStaticPublic, err := directe2ee.ExtractX25519PublicKey(record.DIDDocument, aliceBundle.StaticKeyAgreementID) + if err != nil { + t.Fatalf("ExtractX25519PublicKey(alice) error = %v", err) + } + recipientSignedPrekey := decodeFixed32Value(t, aliceBundle.SignedPrekey.PublicKeyB64U) + recipientOneTimePrekey := decodeFixed32Value(t, aliceOPKs[0].PublicKeyB64U) + metadata := directe2ee.DirectEnvelopeMetadata{ + SenderDID: bobGenerated.DID, + RecipientDID: record.DID, + MessageID: "msg-secure-init-001", + Profile: "anp.direct.e2ee.v1", + SecurityProfile: "direct-e2ee", + } + sessionBuilder := directe2ee.DirectE2eeSession{} + _, _, initBody, err := sessionBuilder.InitiateSessionWithOPK( + metadata, + "msg-secure-init-001", + bobGenerated.DID+"#key-3", + bobPrivate, + aliceBundle, + recipientStaticPublic, + recipientSignedPrekey, + &recipientOneTimePrekey, + aliceOPKs[0].KeyID, + directe2ee.NewTextPlaintext("text/plain", "hello secure listener"), + ) + if err != nil { + t.Fatalf("InitiateSessionWithOPK() error = %v", err) + } + notification := map[string]any{ + "jsonrpc": "2.0", + "method": "direct.incoming", + "params": map[string]any{ + "meta": map[string]any{ + "sender_did": bobGenerated.DID, + "message_id": metadata.MessageID, + "created_at": "2026-04-07T00:00:00Z", + "profile": metadata.Profile, + "security_profile": metadata.SecurityProfile, + "content_type": "application/anp-direct-init+json", + "target": map[string]any{ + "kind": "agent", + "did": record.DID, + }, + }, + "body": mustJSONMap(t, initBody), + }, + } + + var ackSent atomic.Int32 + supervisor.handleNotification(context.Background(), &session{ + identityName: "alice", + record: record, + secureRPCCall: func(_ context.Context, method string, params map[string]any) (map[string]any, error) { + if method != "direct.send" { + return nil, fmt.Errorf("unexpected rpc method: %s", method) + } + ackSent.Add(1) + meta := params["meta"].(map[string]any) + return map[string]any{ + "accepted": true, + "message_id": meta["message_id"], + "operation_id": meta["operation_id"], + "target_did": meta["target"].(map[string]any)["did"], + "accepted_at": "2026-04-07T00:00:01Z", + }, nil + }, + }, notification) + + row, err := store.GetMessageByID(context.Background(), supervisor.db, metadata.MessageID, record.DID, record.IdentityName) + if err != nil { + t.Fatalf("GetMessageByID() error = %v", err) + } + if got := row["content"]; got != "hello secure listener" { + t.Fatalf("stored content = %#v, want plaintext", got) + } + if got := row["content_type"]; got != "text/plain" { + t.Fatalf("stored content_type = %#v, want text/plain", got) + } + if got := row["is_e2ee"]; got != int64(1) { + t.Fatalf("stored is_e2ee = %#v, want 1", got) + } + sessionEntries, err := os.ReadDir(filepath.Join(alicePaths.IdentityDir, "p5-e2ee-sessions")) + if err != nil { + t.Fatalf("ReadDir(session store) error = %v", err) + } + if len(sessionEntries) != 1 { + t.Fatalf("len(sessionEntries) = %d, want 1", len(sessionEntries)) + } + sessionBytes, err := os.ReadFile(filepath.Join(alicePaths.IdentityDir, "p5-e2ee-sessions", sessionEntries[0].Name())) + if err != nil { + t.Fatalf("ReadFile(session) error = %v", err) + } + var savedSession map[string]any + if err := json.Unmarshal(sessionBytes, &savedSession); err != nil { + t.Fatalf("json.Unmarshal(session) error = %v", err) + } + if got := savedSession["status"]; got != "established" { + t.Fatalf("saved session status = %#v, want established", got) + } + if _, _, err := aliceOPKStore.LoadOneTimePrekey(aliceOPKs[0].KeyID); err == nil { + t.Fatalf("one-time prekey %s should be consumed by secure listener decrypt", aliceOPKs[0].KeyID) + } + if ackSent.Load() != 1 { + t.Fatalf("ackSent = %d, want 1 auto secure ack", ackSent.Load()) + } +} + +func TestDeliverLocalSecureAckInProcessPromotesPendingInitiatorSession(t *testing.T) { + t.Parallel() + + root := t.TempDir() + paths := appconfig.Paths{ + WorkspaceHomeDir: filepath.Join(root, ".awiki-cli"), + IdentityDir: filepath.Join(root, "identities"), + LegacyCredentialsDir: filepath.Join(root, "legacy"), + DataDir: filepath.Join(root, "data"), + StateDir: filepath.Join(root, "state"), + DatabaseFile: filepath.Join(root, "data", "awiki-cli.db"), + } + manager := identity.NewManager(paths) + resolved := &appconfig.Resolved{ + Paths: paths, + ServiceBaseURL: "https://awiki.test", + DIDDomain: "awiki.ai", + RuntimeMode: "websocket", + ActiveIdentity: "alice", + } + supervisor, err := NewSupervisor(resolved) + if err != nil { + t.Fatalf("NewSupervisor() error = %v", err) + } + defer supervisor.Close() + + aliceGenerated, err := identity.GenerateIdentity(identity.GenerateOptions{Hostname: "awiki.ai", PathPrefix: []string{"user"}, ProofDomain: "awiki.ai"}) + if err != nil { + t.Fatalf("GenerateIdentity(alice) error = %v", err) + } + if _, err := manager.Save(identity.SaveInput{ + IdentityName: "alice", + DID: aliceGenerated.DID, + UniqueID: aliceGenerated.UniqueID, + UserID: "user-alice", + DisplayName: "Alice", + Handle: "alice", + JWTToken: "token-alice", + DIDDocument: aliceGenerated.DIDDocument, + Key1PrivatePEM: aliceGenerated.Key1PrivatePEM, + Key1PublicPEM: aliceGenerated.Key1PublicPEM, + E2EESigningPrivatePEM: aliceGenerated.E2EESigningPrivatePEM, + E2EEAgreementPrivatePEM: aliceGenerated.E2EEAgreementPrivatePEM, + }); err != nil { + t.Fatalf("manager.Save(alice) error = %v", err) + } + bobGenerated, err := identity.GenerateIdentity(identity.GenerateOptions{Hostname: "awiki.ai", PathPrefix: []string{"user"}, ProofDomain: "awiki.ai"}) + if err != nil { + t.Fatalf("GenerateIdentity(bob) error = %v", err) + } + if _, err := manager.Save(identity.SaveInput{ + IdentityName: "bob", + DID: bobGenerated.DID, + UniqueID: bobGenerated.UniqueID, + UserID: "user-bob", + DisplayName: "Bob", + Handle: "bob", + JWTToken: "token-bob", + DIDDocument: bobGenerated.DIDDocument, + Key1PrivatePEM: bobGenerated.Key1PrivatePEM, + Key1PublicPEM: bobGenerated.Key1PublicPEM, + E2EESigningPrivatePEM: bobGenerated.E2EESigningPrivatePEM, + E2EEAgreementPrivatePEM: bobGenerated.E2EEAgreementPrivatePEM, + }); err != nil { + t.Fatalf("manager.Save(bob) error = %v", err) + } + + aliceRecord, err := manager.Load("alice") + if err != nil { + t.Fatalf("manager.Load(alice) error = %v", err) + } + bobRecord, err := manager.Load("bob") + if err != nil { + t.Fatalf("manager.Load(bob) error = %v", err) + } + + bobClient, err := message.NewSecureE2EEClientForRecord(context.Background(), manager, bobRecord, func(string, map[string]any) (map[string]any, error) { + return map[string]any{}, nil + }) + if err != nil { + t.Fatalf("NewSecureE2EEClientForRecord(bob) error = %v", err) + } + bobBundle, err := bobClient.EnsureFreshPrekeyBundle() + if err != nil { + t.Fatalf("EnsureFreshPrekeyBundle(bob) error = %v", err) + } + bobPaths, err := manager.PathsForIdentity("bob") + if err != nil { + t.Fatalf("PathsForIdentity(bob) error = %v", err) + } + bobOPKStore, err := anpsdk.NewFileOneTimePrekeyStore(filepath.Join(bobPaths.IdentityDir, "p5-one-time-prekeys")) + if err != nil { + t.Fatalf("NewFileOneTimePrekeyStore(bob) error = %v", err) + } + bobOPKs, err := bobOPKStore.ListOneTimePrekeys() + if err != nil { + t.Fatalf("ListOneTimePrekeys(bob) error = %v", err) + } + if len(bobOPKs) == 0 { + t.Fatal("expected at least one Bob OPK") + } + + alicePrivate, err := privateKeyFromPEM(t, aliceGenerated.E2EEAgreementPrivatePEM) + if err != nil { + t.Fatalf("privateKeyFromPEM(alice) error = %v", err) + } + recipientStaticPublic, err := directe2ee.ExtractX25519PublicKey(bobRecord.DIDDocument, bobBundle.StaticKeyAgreementID) + if err != nil { + t.Fatalf("ExtractX25519PublicKey(bob) error = %v", err) + } + recipientSignedPrekey := decodeFixed32Value(t, bobBundle.SignedPrekey.PublicKeyB64U) + recipientOneTimePrekey := decodeFixed32Value(t, bobOPKs[0].PublicKeyB64U) + initMetadata := directe2ee.DirectEnvelopeMetadata{ + SenderDID: aliceRecord.DID, + RecipientDID: bobRecord.DID, + MessageID: "msg-pending-init-001", + Profile: "anp.direct.e2ee.v1", + SecurityProfile: "direct-e2ee", + } + builder := directe2ee.DirectE2eeSession{} + aliceSession, _, initBody, err := builder.InitiateSessionWithOPK( + initMetadata, + "msg-pending-init-001", + aliceRecord.DID+"#key-3", + alicePrivate, + bobBundle, + recipientStaticPublic, + recipientSignedPrekey, + &recipientOneTimePrekey, + bobOPKs[0].KeyID, + directe2ee.NewTextPlaintext("text/plain", "hello pending"), + ) + if err != nil { + t.Fatalf("InitiateSessionWithOPK() error = %v", err) + } + alicePaths, err := manager.PathsForIdentity("alice") + if err != nil { + t.Fatalf("PathsForIdentity(alice) error = %v", err) + } + aliceSessionStore, err := anpsdk.NewFileSessionStore(filepath.Join(alicePaths.IdentityDir, "p5-e2ee-sessions")) + if err != nil { + t.Fatalf("NewFileSessionStore(alice) error = %v", err) + } + if err := aliceSessionStore.SaveSession(aliceSession); err != nil { + t.Fatalf("SaveSession(alice) error = %v", err) + } + + bobStatic, err := privateKeyFromPEM(t, bobGenerated.E2EEAgreementPrivatePEM) + if err != nil { + t.Fatalf("privateKeyFromPEM(bob) error = %v", err) + } + bobSPKStore, err := anpsdk.NewFileSignedPrekeyStore(filepath.Join(bobPaths.IdentityDir, "p5-signed-prekeys")) + if err != nil { + t.Fatalf("NewFileSignedPrekeyStore(bob) error = %v", err) + } + bobSPKMaterial, _, err := bobSPKStore.LoadSignedPrekey(initBody.RecipientSignedPrekeyID) + if err != nil { + t.Fatalf("LoadSignedPrekey(bob) error = %v", err) + } + bobSPKPrivate, err := ecdh.X25519().NewPrivateKey(bobSPKMaterial.Bytes) + if err != nil { + t.Fatalf("NewPrivateKey(bob spk) error = %v", err) + } + bobOPKMaterial, _, err := bobOPKStore.LoadOneTimePrekey(initBody.RecipientOneTimePrekeyID) + if err != nil { + t.Fatalf("LoadOneTimePrekey(bob) error = %v", err) + } + bobOPKPrivate, err := ecdh.X25519().NewPrivateKey(bobOPKMaterial.Bytes) + if err != nil { + t.Fatalf("NewPrivateKey(bob opk) error = %v", err) + } + senderStaticPublic, err := directe2ee.ExtractX25519PublicKey(aliceRecord.DIDDocument, initBody.SenderStaticKeyAgreementID) + if err != nil { + t.Fatalf("ExtractX25519PublicKey(alice) error = %v", err) + } + bobSession, _, err := builder.AcceptIncomingInitWithOPK(initMetadata, bobRecord.DID+"#key-3", bobStatic, bobSPKPrivate, bobOPKPrivate, senderStaticPublic, initBody) + if err != nil { + t.Fatalf("AcceptIncomingInitWithOPK() error = %v", err) + } + bobSessionStore, err := anpsdk.NewFileSessionStore(filepath.Join(bobPaths.IdentityDir, "p5-e2ee-sessions")) + if err != nil { + t.Fatalf("NewFileSessionStore(bob) error = %v", err) + } + if err := bobSessionStore.SaveSession(bobSession); err != nil { + t.Fatalf("SaveSession(bob) error = %v", err) + } + + supervisor.sessions["alice"] = &session{identityName: "alice", record: aliceRecord} + supervisor.sessions["bob"] = &session{identityName: "bob", record: bobRecord} + + ackID := "ack-" + aliceSession.SessionID + if ok := supervisor.deliverLocalSecureAckInProcess(context.Background(), bobRecord, aliceRecord.DID, aliceSession.SessionID, initMetadata.MessageID, ackID); !ok { + t.Fatal("deliverLocalSecureAckInProcess() = false, want true") + } + updatedAliceSession, ok, err := aliceSessionStore.FindByPeerDID(bobRecord.DID) + if err != nil { + t.Fatalf("FindByPeerDID(alice) error = %v", err) + } + if !ok { + t.Fatal("expected Alice session to remain present") + } + if updatedAliceSession.Status != directe2ee.SessionStatusEstablished { + t.Fatalf("Alice session status = %q, want established", updatedAliceSession.Status) + } +} + func TestRecordsFromGroupStateChangedBuildsMemberAndSystemMessage(t *testing.T) { t.Parallel() @@ -218,6 +648,38 @@ func TestSessionLoopReconnectsAndStoresNotifications(t *testing.T) { return } defer conn.Close(websocket.StatusNormalClosure, "done") + var writeMu sync.Mutex + go func() { + for { + _, raw, err := conn.Read(r.Context()) + if err != nil { + return + } + var request map[string]any + if err := json.Unmarshal(raw, &request); err != nil { + continue + } + if request["method"] != "inbox.get" { + continue + } + response, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": request["id"], + "result": map[string]any{ + "messages": []any{}, + }, + }) + if err != nil { + return + } + writeMu.Lock() + err = conn.Write(r.Context(), websocket.MessageText, response) + writeMu.Unlock() + if err != nil { + return + } + } + }() index := connectionCount.Add(1) payload := map[string]any{ @@ -246,7 +708,10 @@ func TestSessionLoopReconnectsAndStoresNotifications(t *testing.T) { } writeCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() - if err := conn.Write(writeCtx, websocket.MessageText, raw); err != nil { + writeMu.Lock() + err = conn.Write(writeCtx, websocket.MessageText, raw) + writeMu.Unlock() + if err != nil { t.Errorf("conn.Write() error = %v", err) return } @@ -401,3 +866,39 @@ func createTestIdentity(t *testing.T, manager *identity.Manager, input identity. t.Fatalf("Save() error = %v", err) } } + +func privateKeyFromPEM(t *testing.T, pemValue string) (*ecdh.PrivateKey, error) { + t.Helper() + privateKey, err := anp.PrivateKeyFromPEM(pemValue) + if err != nil { + return nil, err + } + return ecdh.X25519().NewPrivateKey(privateKey.Bytes) +} + +func decodeFixed32Value(t *testing.T, value string) [32]byte { + t.Helper() + decoded, err := anp.DecodeBase64URL(value) + if err != nil { + t.Fatalf("DecodeBase64URL(%q) error = %v", value, err) + } + if len(decoded) != 32 { + t.Fatalf("DecodeBase64URL(%q) len = %d, want 32", value, len(decoded)) + } + var result [32]byte + copy(result[:], decoded) + return result +} + +func mustJSONMap(t *testing.T, value any) map[string]any { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + var result map[string]any + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + return result +} diff --git a/internal/store/import.go b/internal/store/import.go index 9a677ad..13e44ef 100644 --- a/internal/store/import.go +++ b/internal/store/import.go @@ -14,9 +14,7 @@ import ( func ScanLegacyDatabase(ctx context.Context, paths appconfig.Paths) (*LegacyScan, error) { legacyPath := strings.TrimSpace(paths.LegacyDataDir) - if strings.HasSuffix(strings.ToLower(legacyPath), ".db") { - legacyPath = legacyPath - } else { + if !strings.HasSuffix(strings.ToLower(legacyPath), ".db") { legacyPath = filepath.Join(paths.LegacyDataDir, "database", "awiki.db") } scan := &LegacyScan{ From 28f551590273f9ddde7079166afb86b3244cfeec Mon Sep 17 00:00:00 2001 From: changshan Date: Sat, 2 May 2026 08:18:21 +0800 Subject: [PATCH 02/14] Prepare CLI group E2EE flows without a daemon dependency The CLI needs a stable integration seam for P6 before real MLS is connected, so this introduces exec-based provider plumbing and diagnostic commands while documenting that the architecture draft remains contract-test only for this slice. Constraint: awiki-cli must stay pure Go and must not depend on a resident MLS process. Constraint: plaintext must be passed through stdin rather than argv. Rejected: Wire group create/add/send into live OpenMLS now | would imply production E2EE before service and SDK contracts are proven. Confidence: high Scope-risk: moderate Directive: Do not advertise real group E2EE from CLI commands until anp-mls uses real OpenMLS state and system tests cover the loop. Tested: go test ./internal/message ./internal/cli; git diff --check Not-tested: Real anp-mls installation discovery, transparent group send/decrypt happy path, packaged release binary inclusion --- CLAUDE.md | 2 + docs/architecture/go-cli+rust-mls.md | 2194 ++++++++++++++++++ docs/installation.md | 2 + internal/cli/group_e2ee.go | 144 ++ internal/cli/group_test.go | 20 + internal/cli/root.go | 8 + internal/cmdmeta/catalog.go | 5 + internal/message/group_e2ee_provider.go | 121 + internal/message/group_e2ee_provider_test.go | 51 + 9 files changed, 2547 insertions(+) create mode 100644 docs/architecture/go-cli+rust-mls.md create mode 100644 internal/cli/group_e2ee.go create mode 100644 internal/message/group_e2ee_provider.go create mode 100644 internal/message/group_e2ee_provider_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 00fd48a..6518eec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,7 @@ **internal/cli/debug.go**: `debug db query`、`debug db handle-history` 与 `debug db import-v1` 的 CLI 处理器。 **internal/cli/msg.go**: `msg send/inbox/history/mark-read` 的 CLI 处理器,现已支持 direct + group plain messaging。 **internal/cli/group.go**: `group create/get/join/add/remove/leave/update/members/messages` 的 CLI 处理器。 +**internal/cli/group_e2ee.go**: P6 group E2EE contract-test 诊断/维护命令处理器;只暴露本地 exec provider/status/KeyPackage 计划,不宣称真实 OpenMLS 已可用。 **internal/identity/types.go**: identity store、legacy scan、command result 等核心类型。 **internal/identity/layout.go**: identity 根目录、index.json、路径与安全写入辅助。 **internal/identity/store.go**: 当前 v2 identity store 的读写、默认 identity 管理。 @@ -92,6 +93,7 @@ **internal/message/attachment_service.go**: direct/group attachment send 与 `msg attachment download` 的业务编排层。 **internal/message/secure.go**: P5 direct E2EE secure send 的首版编排层,使用 ANP Go SDK direct_e2ee、本地文件会话/预密钥存储和 HTTP JSON-RPC;key-service 请求绑定当前 DID 文档里 `ANPMessageService.serviceDid`,并在有可用 sidecar OPK 时优先用 OPK 建链(本地保存 `p5-one-time-prekeys/`);HTTP inbox/history 现已接入入站密文解密与会话推进,并会顺带补发本地 prekey bundle;轮询路径解密 direct-init 成功后会自动发送 encrypted ACK 并尝试 flush 该 peer 的 `e2ee_outbox`;当 initiator 仍处于 `pending-confirmation` 时,新的 secure 发送会进入 `e2ee_outbox` 排队。 **internal/message/secure_control.go**: secure 控制面与恢复辅助,负责 secure ack/init payload、pending 阶段的 `e2ee_outbox` 排队、secure outbox flush,以及 `msg secure status/init/repair/failed/retry/drop` 需要的本地会话/发件箱读取与重试逻辑。 +**internal/message/group_e2ee_provider.go**: `anp-mls` exec provider 抽象;JSON request 走 stdin、response 走 stdout、日志/错误走 stderr,默认 MLS 状态目录为 `/mls`,保持 Go 主工程 pure Go / no CGO。 **internal/message/group_wire.go**: group 标准面和 local-only RPC 参数构造器。 **internal/message/http_client.go**: direct/group message 与 group lifecycle 的 HTTP JSON-RPC adapter。 **internal/message/ws_proxy_client.go**: websocket 模式下通过本地 bridge 调用 listener/daemon 的 direct/group adapter。 diff --git a/docs/architecture/go-cli+rust-mls.md b/docs/architecture/go-cli+rust-mls.md new file mode 100644 index 0000000..00ffb91 --- /dev/null +++ b/docs/architecture/go-cli+rust-mls.md @@ -0,0 +1,2194 @@ +> **阶段口径(2026-05 P6 contract-first slice)** +> +> 本文是早期 Rust/OpenMLS 工程方案草稿,不代表当前已落地能力。 +> 当前实现只落地 `anp-mls` one-shot exec provider、P6 wire/storage/API 与 +> `contract-test` 非加密 artifact 骨架;**不宣称真实 OpenMLS 群端到端加密已可用**。 +> HTTP server / daemon、OpenMLS `StorageProvider`、真实 MLS group state 持久化、 +> snapshot、多设备同步等内容均是后续设计方向,不属于本阶段验收范围。 +> 对外 discovery 必须继续隐藏 `anp.group.e2ee.v1` / `group-e2ee`,并由 feature flag +> 显式开启测试面。 + +下面是按你新约束改过的完整方案:**API 同时提供 HTTP JSON API 和 CLI API,但系统不能依赖一个常驻后台进程。** + +核心设计思想是: + +> Rust OpenMLS 组件不是“必须一直运行的 daemon”,而是一个独立二进制 `awiki-mls`。 +> 它既可以用 `awiki-mls serve` 提供 HTTP JSON API,也可以被 Go CLI 每次按需调用,执行一次命令后退出。 +> 所有 MLS 状态必须落盘,不能依赖进程内存。 + +OpenMLS 本身是 Rust 实现的 MLS 协议库,目标是作为端到端加密应用的构建块;MLS 的标准规范是 IETF RFC 9420。([GitHub][1]) OpenMLS 的 `MlsGroup` 状态会持续写入 `StorageProvider`,后续可以通过 `GroupId` 从 provider 重新 load,这正好适合“不依赖常驻进程”的设计。([OpenMLS Book][2]) + +--- + +# 1. 总体结论 + +你的新方案应该从: + +```text +Go CLI + Rust OpenMLS sidecar +``` + +调整为: + +```text +Go CLI + Rust OpenMLS command/server binary +``` + +也就是: + +```text +awiki-cli / ANP Go SDK + | + | 方式 A:exec 调用 Rust CLI,一次一进程 + | 方式 B:HTTP JSON 调用 Rust server,可选 + v +awiki-mls + | + v +OpenMLS + 本地持久化 StorageProvider +``` + +其中默认推荐: + +```text +默认模式:CLI exec mode +可选模式:HTTP server mode +``` + +也就是说: + +* **不要求 `awiki-mls` 常驻运行** +* **Go CLI 默认每次调用 Rust CLI** +* **HTTP JSON API 只是加速、调试、服务化、长期运行场景用** +* **两种 API 调用同一套 Rust service core** +* **状态全部保存在本地数据库中** + +--- + +# 2. 最终架构 + +```text +┌──────────────────────────────────────────────────────┐ +│ awiki-cli / ANP Go SDK │ +│ │ +│ - ANP/P6 消息封装 │ +│ - DID / did:wba 身份 │ +│ - 群业务逻辑 │ +│ - 消息收发 / relay / 跨域路由 │ +│ - MLSProvider 抽象接口 │ +└───────────────────────┬──────────────────────────────┘ + │ + ┌─────────────┴─────────────┐ + │ │ + ▼ ▼ +┌──────────────────────┐ ┌──────────────────────────┐ +│ ExecProvider │ │ HTTPProvider │ +│ │ │ │ +│ os/exec 调用 │ │ 调用 127.0.0.1 / UDS │ +│ awiki-mls command │ │ awiki-mls serve │ +└──────────┬───────────┘ └───────────┬──────────────┘ + │ │ + └─────────────┬──────────────┘ + ▼ +┌──────────────────────────────────────────────────────┐ +│ awiki-mls │ +│ Rust binary │ +│ │ +│ CLI mode: │ +│ awiki-mls group create --json-in - │ +│ awiki-mls message encrypt --json-in - │ +│ │ +│ Server mode: │ +│ awiki-mls serve --listen 127.0.0.1:8742 │ +│ │ +│ Shared core: │ +│ OpenMlsEngine │ +│ StorageRepository │ +│ OperationLog │ +│ LockManager │ +└───────────────────────┬──────────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────┐ +│ Local MLS Storage │ +│ │ +│ ~/.awiki/mls/sidecar.db │ +│ ~/.awiki/mls/locks/ │ +│ ~/.awiki/mls/snapshots/ │ +└──────────────────────────────────────────────────────┘ +``` + +--- + +# 3. 两种 API 的定位 + +## 3.1 CLI API:默认路径 + +这是你当前 Go CLI 最适合用的方式。 + +Go 层通过 `os/exec` 调用: + +```bash +awiki-mls group create --json-in - +awiki-mls key-package generate --json-in - +awiki-mls group add-member --json-in - +awiki-mls welcome process --json-in - +awiki-mls message encrypt --json-in - +awiki-mls message decrypt --json-in - +awiki-mls group restore --json-in - +awiki-mls snapshot export --json-in - +awiki-mls snapshot import --json-in - +``` + +特点: + +```text +1. 不需要后台进程 +2. 每次命令启动一次 Rust 进程 +3. 命令执行完立即退出 +4. 状态从本地 DB load,执行后写回 DB +5. Go CLI 仍然是纯 Go 主工程 +``` + +这是第一阶段的主路径。 + +--- + +## 3.2 HTTP JSON API:可选路径 + +用于: + +```text +1. 本地开发调试 +2. 长时间运行的 agent runtime +3. awiki desktop / daemon 模式 +4. 性能测试 +5. 集成测试 +``` + +启动方式: + +```bash +awiki-mls serve \ + --listen 127.0.0.1:8742 \ + --data-dir ~/.awiki/mls +``` + +HTTP API 不能作为唯一依赖。Go CLI 不能假设它一定存在。 + +--- + +# 4. Go 侧 Provider 策略 + +Go 层定义统一接口: + +```go +type MLSProvider interface { + GenerateKeyPackage(ctx context.Context, req GenerateKeyPackageRequest) (*GenerateKeyPackageResponse, error) + CreateGroup(ctx context.Context, req CreateGroupRequest) (*CreateGroupResponse, error) + AddMember(ctx context.Context, req AddMemberRequest) (*AddMemberResponse, error) + ProcessWelcome(ctx context.Context, req ProcessWelcomeRequest) (*ProcessWelcomeResponse, error) + Encrypt(ctx context.Context, req EncryptRequest) (*EncryptResponse, error) + Decrypt(ctx context.Context, req DecryptRequest) (*DecryptResponse, error) + RestoreGroup(ctx context.Context, req RestoreGroupRequest) (*RestoreGroupResponse, error) + ExportSnapshot(ctx context.Context, req ExportSnapshotRequest) (*ExportSnapshotResponse, error) + ImportSnapshot(ctx context.Context, req ImportSnapshotRequest) (*ImportSnapshotResponse, error) +} +``` + +实现三个 provider: + +```go +type ExecProvider struct { + BinaryPath string + DataDir string + Timeout time.Duration +} + +type HTTPProvider struct { + Endpoint string + Token string + Client *http.Client +} + +type AutoProvider struct { + HTTP *HTTPProvider + Exec *ExecProvider +} +``` + +推荐默认策略: + +```text +AWIKI_MLS_MODE=auto +``` + +`auto` 模式逻辑: + +```text +1. 检查 AWIKI_MLS_ENDPOINT 是否存在 +2. 尝试 GET /healthz +3. 如果 HTTP server 可用,走 HTTPProvider +4. 如果 HTTP server 不可用,走 ExecProvider +5. 永远不要求用户先手动启动后台进程 +``` + +配置项: + +```bash +AWIKI_MLS_MODE=auto # auto | exec | http +AWIKI_MLS_BIN=awiki-mls +AWIKI_MLS_ENDPOINT=http://127.0.0.1:8742 +AWIKI_MLS_DATA_DIR=~/.awiki/mls +AWIKI_MLS_TIMEOUT=15s +``` + +--- + +# 5. Rust 侧二进制设计 + +Rust 项目叫: + +```text +awiki-mls +``` + +目录建议: + +```text +awiki-mls/ + Cargo.toml + src/ + main.rs + cli.rs + http.rs + api/ + mod.rs + types.rs + error.rs + engine/ + mod.rs + key_package.rs + group.rs + welcome.rs + message.rs + snapshot.rs + storage/ + mod.rs + sqlite.rs + lock.rs + operations.rs + security/ + aad.rs + canonical_json.rs + redaction.rs + tests/ + cli_flow_test.rs + http_flow_test.rs + parity_test.rs +``` + +关键原则: + +```text +CLI 和 HTTP 不各写一套逻辑。 +它们必须调用同一个 OpenMlsEngine。 +``` + +也就是: + +```rust +struct OpenMlsEngine { + storage: StorageRepository, + lock_manager: LockManager, + operation_log: OperationLog, +} +``` + +CLI 调用: + +```rust +engine.create_group(req) +``` + +HTTP 调用: + +```rust +engine.create_group(req) +``` + +这样可以保证: + +```text +HTTP JSON API 和 CLI API 行为完全一致。 +``` + +--- + +# 6. 统一 API Envelope + +为了让 HTTP 和 CLI 共享同一个协议,建议所有 API 都用统一 JSON envelope。 + +## 6.1 请求结构 + +```json +{ + "api_version": "awiki-mls/v1", + "request_id": "req_01HT...", + "agent_did": "did:wba:example.com:agent:alice", + "device_id": "default", + "params": {} +} +``` + +## 6.2 成功响应 + +```json +{ + "ok": true, + "api_version": "awiki-mls/v1", + "request_id": "req_01HT...", + "result": {} +} +``` + +## 6.3 失败响应 + +```json +{ + "ok": false, + "api_version": "awiki-mls/v1", + "request_id": "req_01HT...", + "error": { + "code": "EPOCH_MISMATCH", + "message": "Group epoch mismatch", + "details": { + "expected_epoch": 3, + "actual_epoch": 2 + } + } +} +``` + +CLI 也输出同样结构。 + +例如: + +```bash +awiki-mls group create --json-in create_group.json +``` + +stdout: + +```json +{ + "ok": true, + "api_version": "awiki-mls/v1", + "request_id": "req_01HT...", + "result": { + "anp_group_id": "group1", + "mls_group_id": "base64url...", + "epoch": 0 + } +} +``` + +stderr 只打印日志,stdout 只打印机器可读 JSON。 + +--- + +# 7. HTTP API 与 CLI API 一一映射 + +| 能力 | HTTP JSON API | CLI API | +| ----------------- | ------------------------------------------ | -------------------------------------------- | +| 健康检查 | `GET /healthz` | `awiki-mls health --json` | +| 版本检查 | `GET /v1/version` | `awiki-mls version --json` | +| 生成 KeyPackage | `POST /v1/key-packages/generate` | `awiki-mls key-package generate --json-in -` | +| 创建群 | `POST /v1/groups/create` | `awiki-mls group create --json-in -` | +| 添加成员 | `POST /v1/groups/{group_id}/members/add` | `awiki-mls group add-member --json-in -` | +| 合并 pending commit | `POST /v1/groups/{group_id}/commits/merge` | `awiki-mls group commit-merge --json-in -` | +| 处理 Welcome | `POST /v1/welcomes/process` | `awiki-mls welcome process --json-in -` | +| 加密消息 | `POST /v1/groups/{group_id}/encrypt` | `awiki-mls message encrypt --json-in -` | +| 解密消息 | `POST /v1/groups/{group_id}/decrypt` | `awiki-mls message decrypt --json-in -` | +| 恢复群状态 | `POST /v1/groups/{group_id}/restore` | `awiki-mls group restore --json-in -` | +| 导出 snapshot | 默认关闭或本地-only | `awiki-mls snapshot export --json-in -` | +| 导入 snapshot | 默认关闭或本地-only | `awiki-mls snapshot import --json-in -` | + +注意:**snapshot API 不建议默认暴露 HTTP**。它涉及敏感 key material,第一阶段建议只允许 CLI 调用,HTTP 版本必须通过 `--enable-snapshot-api` 显式开启。 + +--- + +# 8. 为什么不依赖常驻进程也能工作 + +每一次 CLI 调用都执行这个流程: + +```text +1. Go CLI 启动 awiki-mls 子进程 +2. awiki-mls 读取 JSON 请求 +3. 打开 ~/.awiki/mls/sidecar.db +4. 获取对应 agent/group 的文件锁 +5. 根据 anp_group_id 查 mls_group_id +6. 从 OpenMLS StorageProvider load MlsGroup +7. 执行 create/add/encrypt/decrypt 等操作 +8. OpenMLS 写回 StorageProvider +9. awiki-mls 写 metadata / operation log +10. 输出 JSON response +11. 进程退出 +``` + +这个模型的关键是: + +```text +进程不是状态载体。 +本地 storage 才是状态载体。 +``` + +OpenMLS 官方文档说明,`MlsGroup` 会持续写入配置的 `StorageProvider`,之后可以通过 `GroupId` 重新 load。([OpenMLS Book][2]) 所以 `awiki-mls` 完全可以按需启动、按需退出。 + +--- + +# 9. 本地存储设计 + +## 9.1 路径 + +默认路径: + +```text +~/.awiki/mls/ + sidecar.db + sidecar.lock + locks/ + snapshots/ + runtime/ +``` + +建议权限: + +```text +~/.awiki/mls/ 0700 +~/.awiki/mls/sidecar.db 0600 +~/.awiki/mls/snapshots/ 0700 +``` + +## 9.2 数据库分层 + +本地 DB 分两层: + +```text +1. OpenMLS provider storage +2. ANP/P6 metadata storage +``` + +OpenMLS provider storage 保存: + +```text +- group state +- key material +- signature keys +- key package private material +``` + +ANP/P6 metadata storage 保存: + +```text +- agent_did / device_id +- anp_group_id -> mls_group_id 映射 +- member_did -> leaf_index 映射 +- epoch 记录 +- request_id 幂等记录 +- pending commit 状态 +``` + +OpenMLS README 中也列出了 sqlite provider 相关 feature,因此第一阶段可以优先使用 SQLite 存储。([GitHub][1]) + +--- + +# 10. 数据表建议 + +## 10.1 group bindings + +```sql +CREATE TABLE group_bindings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_did TEXT NOT NULL, + device_id TEXT NOT NULL, + anp_group_id TEXT NOT NULL, + mls_group_id BLOB NOT NULL, + epoch INTEGER NOT NULL, + self_leaf_index INTEGER, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(agent_did, device_id, anp_group_id) +); +``` + +`status` 可选值: + +```text +active +pending_commit +stale +corrupted +deleted +``` + +## 10.2 members + +```sql +CREATE TABLE group_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_did TEXT NOT NULL, + device_id TEXT NOT NULL, + anp_group_id TEXT NOT NULL, + member_did TEXT NOT NULL, + member_device_id TEXT, + leaf_index INTEGER, + credential_hash TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(agent_did, device_id, anp_group_id, member_did, member_device_id) +); +``` + +## 10.3 key packages + +```sql +CREATE TABLE key_packages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_did TEXT NOT NULL, + device_id TEXT NOT NULL, + key_package_id TEXT NOT NULL, + key_package BLOB NOT NULL, + used INTEGER DEFAULT 0, + expires_at TEXT, + created_at TEXT NOT NULL, + UNIQUE(agent_did, device_id, key_package_id) +); +``` + +## 10.4 operation log + +```sql +CREATE TABLE operations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id TEXT NOT NULL, + operation_type TEXT NOT NULL, + input_hash TEXT NOT NULL, + status TEXT NOT NULL, + response_json TEXT, + error_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(request_id) +); +``` + +作用: + +```text +1. 防止 CLI 重试导致重复执行 +2. 支持崩溃恢复 +3. 支持 pending commit 查询 +4. 支持 Go CLI 重新拿上次结果 +``` + +## 10.5 pending commits + +```sql +CREATE TABLE pending_commits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id TEXT NOT NULL, + agent_did TEXT NOT NULL, + device_id TEXT NOT NULL, + anp_group_id TEXT NOT NULL, + old_epoch INTEGER NOT NULL, + new_epoch INTEGER NOT NULL, + commit BLOB NOT NULL, + welcome BLOB, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(request_id) +); +``` + +`status`: + +```text +created +delivered +merged +aborted +expired +``` + +--- + +# 11. 文件锁设计 + +因为你不能保证只有一个 CLI 进程在跑,所以必须做锁。 + +否则可能出现: + +```text +两个 awiki-cli 同时 encrypt +两个 awiki-cli 同时 add_member +一个 HTTP server 和一个 CLI command 同时写同一个 group +``` + +建议锁粒度: + +```text +identity lock: + ~/.awiki/mls/locks/identity_.lock + +group lock: + ~/.awiki/mls/locks/group_.lock +``` + +规则: + +| 操作 | 锁 | +| -------------------- | -------------------------- | +| generate_key_package | identity lock | +| create_group | identity lock + group lock | +| add_member | group lock | +| commit_merge | group lock | +| process_welcome | identity lock + group lock | +| encrypt | group lock | +| decrypt | group lock | +| restore | group lock | +| snapshot export | identity lock 或全局 lock | +| snapshot import | 全局 lock | + +注意:`decrypt` 也建议加写锁。MLS 在处理消息时可能更新本地状态、删除旧密钥或推进 ratchet,所以不能把 decrypt 当成纯读操作。 + +OpenMLS 文档特别提醒,StorageProvider 中包含敏感 key material,并且 OpenMLS 会为了 forward secrecy 删除旧 key material;storage 实现必须确保删除不可恢复。([OpenMLS Book][2]) 因此锁和存储一致性非常重要。 + +--- + +# 12. 核心 API 详细设计 + +## 12.1 generate_key_package + +### HTTP + +```http +POST /v1/key-packages/generate +``` + +### CLI + +```bash +awiki-mls key-package generate --json-in - +``` + +### Request + +```json +{ + "api_version": "awiki-mls/v1", + "request_id": "req_kp_001", + "agent_did": "did:wba:example.com:agent:bob", + "device_id": "default", + "params": { + "credential_identity": "did:wba:example.com:agent:bob", + "ciphersuite": "MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519", + "expires_at": "2026-05-01T00:00:00Z" + } +} +``` + +### Response + +```json +{ + "ok": true, + "api_version": "awiki-mls/v1", + "request_id": "req_kp_001", + "result": { + "key_package_id": "kp_01HT...", + "agent_did": "did:wba:example.com:agent:bob", + "device_id": "default", + "ciphersuite": "MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519", + "key_package": "base64url...", + "created_at": "2026-04-26T12:00:00Z", + "expires_at": "2026-05-01T00:00:00Z" + } +} +``` + +### 行为 + +```text +1. 生成 Bob 的 MLS KeyPackage +2. 保存对应 private init key material +3. 返回 public KeyPackage +4. 标记 used=false +``` + +--- + +## 12.2 create_group + +### HTTP + +```http +POST /v1/groups/create +``` + +### CLI + +```bash +awiki-mls group create --json-in - +``` + +### Request + +```json +{ + "api_version": "awiki-mls/v1", + "request_id": "req_group_create_001", + "agent_did": "did:wba:example.com:agent:alice", + "device_id": "default", + "params": { + "anp_group_id": "anp-group-001", + "credential_identity": "did:wba:example.com:agent:alice", + "use_ratchet_tree_extension": true + } +} +``` + +### Response + +```json +{ + "ok": true, + "api_version": "awiki-mls/v1", + "request_id": "req_group_create_001", + "result": { + "anp_group_id": "anp-group-001", + "mls_group_id": "base64url...", + "epoch": 0, + "self_leaf_index": 0, + "members": [ + { + "agent_did": "did:wba:example.com:agent:alice", + "device_id": "default", + "leaf_index": 0 + } + ] + } +} +``` + +### 行为 + +```text +1. 创建 MLS group +2. 写入 OpenMLS storage +3. 写入 group_bindings +4. 初始 epoch = 0 +5. Alice 是第一个成员 +``` + +--- + +## 12.3 add_member + +这里要特别设计好,因为没有后台进程时,`add_member` 之后可能 Go CLI 还没来得及发送 commit/welcome 就崩溃。 + +所以推荐支持两种策略: + +```text +1. immediate_merge:PoC / 本地测试用 +2. staged:真实 ANP/P6 网络发送用 +``` + +第一阶段可以默认 `staged`。 + +### HTTP + +```http +POST /v1/groups/{anp_group_id}/members/add +``` + +### CLI + +```bash +awiki-mls group add-member --json-in - +``` + +### Request + +```json +{ + "api_version": "awiki-mls/v1", + "request_id": "req_add_bob_001", + "agent_did": "did:wba:example.com:agent:alice", + "device_id": "default", + "params": { + "anp_group_id": "anp-group-001", + "target_agent_did": "did:wba:example.com:agent:bob", + "target_device_id": "default", + "key_package": "base64url...", + "merge_strategy": "staged", + "aad": { + "anp_version": "1.0", + "profile": "group-e2ee", + "operation": "group.add", + "anp_group_id": "anp-group-001", + "sender_did": "did:wba:example.com:agent:alice", + "target_did": "did:wba:example.com:agent:bob", + "message_id": "msg_add_bob_001", + "created_at": "2026-04-26T12:00:00Z" + } + } +} +``` + +### Response + +```json +{ + "ok": true, + "api_version": "awiki-mls/v1", + "request_id": "req_add_bob_001", + "result": { + "anp_group_id": "anp-group-001", + "mls_group_id": "base64url...", + "old_epoch": 0, + "new_epoch": 1, + "merge_strategy": "staged", + "pending_commit_id": "pc_req_add_bob_001", + "commit": "base64url...", + "welcome": "base64url...", + "ratchet_tree": "base64url-optional", + "added_member": { + "agent_did": "did:wba:example.com:agent:bob", + "device_id": "default" + } + } +} +``` + +### staged 模式流程 + +```text +1. Alice 本地生成 Add Commit 和 Welcome +2. sidecar 保存 pending_commit +3. sidecar 暂不 merge 到新 epoch +4. Go CLI 把 commit/welcome 发送到 ANP 网络 +5. 发送成功后,Go CLI 调用 commit_merge +6. sidecar merge pending commit,Alice 进入 epoch 1 +``` + +### 为什么需要 staged + +否则会出现: + +```text +Alice 本地已经 epoch=1 +但 commit/welcome 没发出去 +Bob 还不知道自己被加入 +群状态不一致 +``` + +为了第一阶段快速 PoC,也可以允许: + +```json +"merge_strategy": "immediate" +``` + +但真实网络场景建议用: + +```json +"merge_strategy": "staged" +``` + +--- + +## 12.4 commit_merge + +虽然你最初列出的最小闭环没有单独写 `commit_merge`,但真实发送流程里最好加这个支持命令。 + +### HTTP + +```http +POST /v1/groups/{anp_group_id}/commits/merge +``` + +### CLI + +```bash +awiki-mls group commit-merge --json-in - +``` + +### Request + +```json +{ + "api_version": "awiki-mls/v1", + "request_id": "req_merge_001", + "agent_did": "did:wba:example.com:agent:alice", + "device_id": "default", + "params": { + "anp_group_id": "anp-group-001", + "pending_commit_id": "pc_req_add_bob_001", + "delivery_result": "accepted" + } +} +``` + +### Response + +```json +{ + "ok": true, + "api_version": "awiki-mls/v1", + "request_id": "req_merge_001", + "result": { + "anp_group_id": "anp-group-001", + "mls_group_id": "base64url...", + "old_epoch": 0, + "new_epoch": 1, + "status": "merged" + } +} +``` + +--- + +## 12.5 process_welcome + +### HTTP + +```http +POST /v1/welcomes/process +``` + +### CLI + +```bash +awiki-mls welcome process --json-in - +``` + +### Request + +```json +{ + "api_version": "awiki-mls/v1", + "request_id": "req_process_welcome_001", + "agent_did": "did:wba:example.com:agent:bob", + "device_id": "default", + "params": { + "anp_group_id": "anp-group-001", + "welcome": "base64url...", + "ratchet_tree": "base64url-optional" + } +} +``` + +### Response + +```json +{ + "ok": true, + "api_version": "awiki-mls/v1", + "request_id": "req_process_welcome_001", + "result": { + "anp_group_id": "anp-group-001", + "mls_group_id": "base64url...", + "epoch": 1, + "self_leaf_index": 1, + "members": [ + { + "agent_did": "did:wba:example.com:agent:alice", + "leaf_index": 0 + }, + { + "agent_did": "did:wba:example.com:agent:bob", + "leaf_index": 1 + } + ] + } +} +``` + +### 行为 + +```text +1. Bob 收到 Welcome +2. 调用 process_welcome +3. sidecar 创建 Bob 本地 group state +4. 写入 group_bindings +5. Bob 之后可以 decrypt/encrypt 群消息 +``` + +--- + +## 12.6 encrypt + +OpenMLS 的 AAD 是 authenticated 但不 encrypted 的数据,适合放 ANP 元数据,例如 group_id、sender_did、message_id、epoch 等。AAD 在传输中可以被查看,但不能被篡改。([OpenMLS Book][3]) + +### HTTP + +```http +POST /v1/groups/{anp_group_id}/encrypt +``` + +### CLI + +```bash +awiki-mls message encrypt --json-in - +``` + +### Request + +```json +{ + "api_version": "awiki-mls/v1", + "request_id": "req_encrypt_001", + "agent_did": "did:wba:example.com:agent:alice", + "device_id": "default", + "params": { + "anp_group_id": "anp-group-001", + "plaintext": "base64url...", + "aad": { + "anp_version": "1.0", + "profile": "group-e2ee", + "security_profile": "mls-rfc9420", + "operation": "group.send", + "anp_group_id": "anp-group-001", + "mls_group_id": "base64url...", + "epoch": 1, + "sender_did": "did:wba:example.com:agent:alice", + "message_id": "msg_001", + "content_type": "text/plain", + "created_at": "2026-04-26T12:00:00Z" + } + } +} +``` + +### Response + +```json +{ + "ok": true, + "api_version": "awiki-mls/v1", + "request_id": "req_encrypt_001", + "result": { + "anp_group_id": "anp-group-001", + "mls_group_id": "base64url...", + "epoch": 1, + "ciphertext": "base64url...", + "aad_hash": "base64url...", + "sender_leaf_index": 0 + } +} +``` + +### AAD 序列化 + +建议: + +```text +JSON Canonicalization Scheme / JCS +UTF-8 bytes +SHA-256 hash for aad_hash +``` + +ANP 外层 envelope 和 MLS AAD 必须绑定。 + +--- + +## 12.7 decrypt + +### HTTP + +```http +POST /v1/groups/{anp_group_id}/decrypt +``` + +### CLI + +```bash +awiki-mls message decrypt --json-in - +``` + +### Request + +```json +{ + "api_version": "awiki-mls/v1", + "request_id": "req_decrypt_001", + "agent_did": "did:wba:example.com:agent:bob", + "device_id": "default", + "params": { + "anp_group_id": "anp-group-001", + "ciphertext": "base64url...", + "expected_aad": { + "anp_version": "1.0", + "profile": "group-e2ee", + "security_profile": "mls-rfc9420", + "operation": "group.send", + "anp_group_id": "anp-group-001", + "mls_group_id": "base64url...", + "epoch": 1, + "sender_did": "did:wba:example.com:agent:alice", + "message_id": "msg_001", + "content_type": "text/plain", + "created_at": "2026-04-26T12:00:00Z" + } + } +} +``` + +### Response + +```json +{ + "ok": true, + "api_version": "awiki-mls/v1", + "request_id": "req_decrypt_001", + "result": { + "anp_group_id": "anp-group-001", + "mls_group_id": "base64url...", + "epoch": 1, + "sender": { + "leaf_index": 0, + "agent_did": "did:wba:example.com:agent:alice" + }, + "plaintext": "base64url...", + "aad": { + "anp_version": "1.0", + "profile": "group-e2ee", + "security_profile": "mls-rfc9420", + "operation": "group.send", + "anp_group_id": "anp-group-001", + "mls_group_id": "base64url...", + "epoch": 1, + "sender_did": "did:wba:example.com:agent:alice", + "message_id": "msg_001", + "content_type": "text/plain", + "created_at": "2026-04-26T12:00:00Z" + } + } +} +``` + +### Go 层必须校验 + +```text +1. 外层 group_id == AAD anp_group_id +2. 外层 sender_did == AAD sender_did +3. 外层 message_id == AAD message_id +4. 外层 created_at == AAD created_at +5. AAD epoch == MLS 解密返回 epoch +6. sender leaf_index 能映射到 sender_did +7. content_type 符合预期 +``` + +--- + +# 13. restore 与 snapshot 重新定义 + +因为不依赖后台进程,所以这里要区分两个概念: + +```text +1. group restore / state load +2. snapshot export/import +``` + +--- + +## 13.1 group restore:每次操作自动发生 + +在这个设计里,`restore` 不是“把状态恢复到后台进程内存”。 + +因为没有常驻进程。 + +真正的 restore 是: + +```text +从本地 StorageProvider load group state。 +``` + +所以每次命令都会自动做: + +```text +load group -> execute operation -> persist group +``` + +显式 restore API 主要用于: + +```text +1. 检查 group state 是否存在 +2. 检查当前 epoch +3. 检查成员映射 +4. 检查状态是否损坏 +5. 给 Go CLI 做诊断 +``` + +### HTTP + +```http +POST /v1/groups/{anp_group_id}/restore +``` + +### CLI + +```bash +awiki-mls group restore --json-in - +``` + +### Response + +```json +{ + "ok": true, + "api_version": "awiki-mls/v1", + "request_id": "req_restore_001", + "result": { + "anp_group_id": "anp-group-001", + "mls_group_id": "base64url...", + "epoch": 3, + "self_leaf_index": 0, + "status": "active", + "restored": true + } +} +``` + +--- + +## 13.2 snapshot export/import:备份与迁移 + +`snapshot` 不是常规消息流程的一部分。 + +它只用于: + +```text +1. 开发调试 +2. 设备迁移 +3. 本地灾备 +4. 测试复现 +``` + +不建议默认频繁导出 snapshot,因为 snapshot 可能保留旧 key material,影响 forward secrecy。OpenMLS 文档明确提到,为了 forward secrecy,旧 key material 会被删除,StorageProvider 实现必须确保删除不可恢复且没有副本。([OpenMLS Book][2]) + +### snapshot export CLI + +```bash +awiki-mls snapshot export --json-in - +``` + +Request: + +```json +{ + "api_version": "awiki-mls/v1", + "request_id": "req_snapshot_export_001", + "agent_did": "did:wba:example.com:agent:alice", + "device_id": "default", + "params": { + "anp_group_id": "anp-group-001", + "output_file": "~/.awiki/mls/snapshots/group1.snapshot.enc", + "encryption": { + "type": "passphrase", + "kdf": "argon2id" + } + } +} +``` + +Response: + +```json +{ + "ok": true, + "api_version": "awiki-mls/v1", + "request_id": "req_snapshot_export_001", + "result": { + "snapshot_file": "~/.awiki/mls/snapshots/group1.snapshot.enc", + "snapshot_version": "awiki-mls-snapshot/v1", + "anp_group_id": "anp-group-001", + "epoch": 3, + "created_at": "2026-04-26T12:00:00Z" + } +} +``` + +### snapshot import CLI + +```bash +awiki-mls snapshot import --json-in - +``` + +Request: + +```json +{ + "api_version": "awiki-mls/v1", + "request_id": "req_snapshot_import_001", + "agent_did": "did:wba:example.com:agent:alice", + "device_id": "default", + "params": { + "snapshot_file": "~/.awiki/mls/snapshots/group1.snapshot.enc", + "mode": "create_if_missing", + "encryption": { + "type": "passphrase" + } + } +} +``` + +安全策略: + +```text +1. snapshot 必须加密 +2. 默认不允许覆盖本地更新的 epoch +3. 默认不上传云端 +4. 默认不保留多版本历史 snapshot +5. HTTP snapshot API 默认关闭 +``` + +--- + +# 14. Go CLI 调用 Rust CLI 的方式 + +不要把敏感内容放在命令行参数里,因为命令行参数可能被系统进程列表看到。 + +错误示例: + +```bash +awiki-mls message encrypt --plaintext "secret text" +``` + +推荐方式: + +```bash +awiki-mls message encrypt --json-in - +``` + +Go 代码逻辑: + +```go +func (p *ExecProvider) call(ctx context.Context, command []string, req any, resp any) error { + body, err := json.Marshal(req) + if err != nil { + return err + } + + args := append(command, "--json-in", "-") + cmd := exec.CommandContext(ctx, p.BinaryPath, args...) + cmd.Stdin = bytes.NewReader(body) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return parseExecError(err, stderr.String()) + } + + return json.Unmarshal(stdout.Bytes(), resp) +} +``` + +原则: + +```text +1. JSON request 走 stdin +2. JSON response 走 stdout +3. 日志走 stderr +4. Go 不解析 stderr 作为正常结果 +5. 所有二进制字段 base64url 编码 +6. plaintext 不进入 argv +``` + +--- + +# 15. Go CLI 对用户暴露的命令 + +用户不一定直接调用 `awiki-mls`。 + +你可以在 `awiki-cli` 里包一层: + +```bash +awiki-cli p6 key-package create +awiki-cli p6 group create +awiki-cli p6 group add-member +awiki-cli p6 welcome process +awiki-cli p6 message encrypt +awiki-cli p6 message decrypt +awiki-cli p6 group restore +awiki-cli p6 snapshot export +awiki-cli p6 snapshot import +``` + +Go CLI 内部调用: + +```text +awiki-cli p6 message encrypt + | + v +ExecProvider or HTTPProvider + | + v +awiki-mls message encrypt +``` + +--- + +# 16. 完整最小流程 + +## 16.1 Bob 生成 KeyPackage + +```bash +awiki-cli p6 key-package create \ + --agent-did did:wba:example.com:agent:bob \ + --out bob_key_package.json +``` + +内部调用: + +```bash +awiki-mls key-package generate --json-in - +``` + +输出 Bob 的 public KeyPackage。 + +--- + +## 16.2 Alice 创建群 + +```bash +awiki-cli p6 group create \ + --agent-did did:wba:example.com:agent:alice \ + --group-id anp-group-001 +``` + +内部调用: + +```bash +awiki-mls group create --json-in - +``` + +Alice 本地创建 group state,epoch = 0。 + +--- + +## 16.3 Alice 添加 Bob + +```bash +awiki-cli p6 group add-member \ + --agent-did did:wba:example.com:agent:alice \ + --group-id anp-group-001 \ + --key-package bob_key_package.json \ + --merge-strategy staged \ + --out add_bob_result.json +``` + +内部调用: + +```bash +awiki-mls group add-member --json-in - +``` + +返回: + +```text +commit +welcome +pending_commit_id +new_epoch +``` + +--- + +## 16.4 Go CLI 发送 ANP 消息 + +Alice 的 Go CLI 发送两条 ANP 消息: + +```text +1. anp-group-mls-commit+json +2. anp-group-mls-welcome+json +``` + +Bob 收到 welcome。 + +--- + +## 16.5 Alice merge pending commit + +发送成功后: + +```bash +awiki-cli p6 group commit-merge \ + --agent-did did:wba:example.com:agent:alice \ + --group-id anp-group-001 \ + --pending-commit-id pc_req_add_bob_001 +``` + +内部调用: + +```bash +awiki-mls group commit-merge --json-in - +``` + +Alice 本地进入 epoch = 1。 + +--- + +## 16.6 Bob process welcome + +```bash +awiki-cli p6 welcome process \ + --agent-did did:wba:example.com:agent:bob \ + --group-id anp-group-001 \ + --welcome welcome.json +``` + +内部调用: + +```bash +awiki-mls welcome process --json-in - +``` + +Bob 本地生成 group state,epoch = 1。 + +--- + +## 16.7 Alice 发送加密消息 + +```bash +awiki-cli p6 message encrypt \ + --agent-did did:wba:example.com:agent:alice \ + --group-id anp-group-001 \ + --text "hello bob" +``` + +内部调用: + +```bash +awiki-mls message encrypt --json-in - +``` + +Go CLI 把返回的 ciphertext 封装成: + +```json +{ + "type": "application/anp-group-mls-cipher+json", + "group_id": "anp-group-001", + "sender_did": "did:wba:example.com:agent:alice", + "message_id": "msg_001", + "created_at": "2026-04-26T12:00:00Z", + "mls": { + "mls_group_id": "base64url...", + "epoch": 1, + "aad": {}, + "ciphertext": "base64url..." + } +} +``` + +--- + +## 16.8 Bob 解密消息 + +```bash +awiki-cli p6 message decrypt \ + --agent-did did:wba:example.com:agent:bob \ + --group-id anp-group-001 \ + --message cipher_message.json +``` + +内部调用: + +```bash +awiki-mls message decrypt --json-in - +``` + +Bob 拿到 plaintext。 + +--- + +# 17. HTTP server 模式 + +HTTP server 模式只是一种加速路径。 + +启动: + +```bash +awiki-mls serve \ + --listen 127.0.0.1:8742 \ + --data-dir ~/.awiki/mls \ + --auth-token-file ~/.awiki/mls/runtime/token +``` + +Go CLI 使用: + +```bash +AWIKI_MLS_MODE=http awiki-cli p6 message encrypt ... +``` + +或者自动: + +```bash +AWIKI_MLS_MODE=auto awiki-cli p6 message encrypt ... +``` + +`auto` 会先尝试 HTTP: + +```http +GET /healthz +``` + +失败则 fallback 到 exec。 + +HTTP server 也不应该把 group state 只保存在内存里。第一阶段建议 HTTP 每次请求也走: + +```text +load group from storage +execute +persist +``` + +这样即使 HTTP server 被 kill,也不会丢状态。 + +--- + +# 18. HTTP 安全设计 + +HTTP server 只绑定本地: + +```text +127.0.0.1 +``` + +或者 Unix Domain Socket: + +```text +~/.awiki/mls/runtime/awiki-mls.sock +``` + +不允许默认监听: + +```text +0.0.0.0 +``` + +认证: + +```http +Authorization: Bearer +``` + +token 文件: + +```text +~/.awiki/mls/runtime/token +``` + +权限: + +```text +0600 +``` + +HTTP snapshot API 默认关闭: + +```bash +awiki-mls serve --enable-snapshot-api=false +``` + +--- + +# 19. 错误码设计 + +| 错误码 | 含义 | +| ------------------------------- | ------------------- | +| `BAD_REQUEST` | 请求 JSON 错误 | +| `UNSUPPORTED_API_VERSION` | API 版本不兼容 | +| `GROUP_NOT_FOUND` | 本地没有 group state | +| `KEY_PACKAGE_NOT_FOUND` | 找不到 KeyPackage 私有材料 | +| `KEY_PACKAGE_ALREADY_USED` | KeyPackage 已使用 | +| `EPOCH_MISMATCH` | epoch 不一致 | +| `AAD_MISMATCH` | AAD 与预期不一致 | +| `DECRYPT_FAILED` | 解密失败 | +| `STORAGE_LOCKED` | 本地锁被占用 | +| `PENDING_COMMIT_NOT_FOUND` | 找不到 pending commit | +| `PENDING_COMMIT_ALREADY_MERGED` | commit 已经 merge | +| `SNAPSHOT_REJECTED` | snapshot 导入被拒绝 | +| `INTERNAL_ERROR` | 内部错误 | + +CLI exit code 建议: + +| Exit Code | 含义 | +| --------: | --------------------- | +| 0 | 成功 | +| 10 | bad request | +| 11 | not found | +| 20 | state conflict | +| 21 | epoch mismatch | +| 22 | AAD mismatch | +| 30 | crypto/decrypt failed | +| 40 | storage locked | +| 50 | internal error | + +--- + +# 20. 幂等性与崩溃恢复 + +所有写操作都必须带 `request_id`: + +```text +generate_key_package +create_group +add_member +commit_merge +process_welcome +encrypt +decrypt +snapshot import +``` + +处理逻辑: + +```text +1. 收到 request_id +2. 查询 operations 表 +3. 如果已成功且 input_hash 相同,直接返回上次 response +4. 如果已成功但 input_hash 不同,返回 REQUEST_ID_CONFLICT +5. 如果之前执行中崩溃,进入 recovery 逻辑 +``` + +典型恢复场景: + +## 场景一:add_member 成功,但 Go CLI 崩溃 + +```text +1. pending_commit 已写入 DB +2. commit/welcome response 可能没被 Go 拿到 +3. Go CLI 使用同一个 request_id 重试 +4. awiki-mls 返回同一个 commit/welcome +``` + +## 场景二:commit/welcome 已发送,但 commit_merge 前崩溃 + +```text +1. operations 表里 pending_commit 状态是 created +2. Go CLI 下次启动可以 list pending commits +3. 用户或自动流程继续 commit_merge +``` + +增加命令: + +```bash +awiki-mls group pending-commits --json-in - +``` + +Go 包一层: + +```bash +awiki-cli p6 group pending-commits --group-id anp-group-001 +``` + +--- + +# 21. ANP/P6 消息类型 + +第一阶段建议定义 3 个 wire message: + +```text +application/anp-group-mls-commit+json +application/anp-group-mls-welcome+json +application/anp-group-mls-cipher+json +``` + +## 21.1 Commit + +```json +{ + "type": "application/anp-group-mls-commit+json", + "anp_version": "1.0", + "group_id": "anp-group-001", + "sender_did": "did:wba:example.com:agent:alice", + "message_id": "msg_commit_001", + "created_at": "2026-04-26T12:00:00Z", + "mls": { + "mls_group_id": "base64url...", + "old_epoch": 0, + "new_epoch": 1, + "commit": "base64url..." + } +} +``` + +## 21.2 Welcome + +```json +{ + "type": "application/anp-group-mls-welcome+json", + "anp_version": "1.0", + "group_id": "anp-group-001", + "sender_did": "did:wba:example.com:agent:alice", + "target_did": "did:wba:example.com:agent:bob", + "message_id": "msg_welcome_001", + "created_at": "2026-04-26T12:00:00Z", + "mls": { + "mls_group_id": "base64url...", + "epoch": 1, + "welcome": "base64url...", + "ratchet_tree": "base64url-optional" + } +} +``` + +## 21.3 Cipher + +```json +{ + "type": "application/anp-group-mls-cipher+json", + "anp_version": "1.0", + "group_id": "anp-group-001", + "sender_did": "did:wba:example.com:agent:alice", + "message_id": "msg_001", + "created_at": "2026-04-26T12:00:00Z", + "content_type": "text/plain", + "mls": { + "mls_group_id": "base64url...", + "epoch": 1, + "aad": { + "anp_version": "1.0", + "profile": "group-e2ee", + "security_profile": "mls-rfc9420", + "operation": "group.send", + "anp_group_id": "anp-group-001", + "mls_group_id": "base64url...", + "epoch": 1, + "sender_did": "did:wba:example.com:agent:alice", + "message_id": "msg_001", + "content_type": "text/plain", + "created_at": "2026-04-26T12:00:00Z" + }, + "ciphertext": "base64url..." + } +} +``` + +--- + +# 22. CLI 与 HTTP 一致性测试 + +必须加一个 parity test: + +```text +同一个输入: + CLI 输出 + HTTP 输出 + +除了时间戳、随机值、request_id 外,语义必须一致。 +``` + +测试矩阵: + +```text +1. generate_key_package CLI vs HTTP +2. create_group CLI vs HTTP +3. add_member CLI vs HTTP +4. process_welcome CLI vs HTTP +5. encrypt CLI vs HTTP +6. decrypt CLI vs HTTP +7. restore CLI vs HTTP +``` + +--- + +# 23. 第一阶段验收标准 + +## 23.1 无后台进程测试 + +这个是你新约束下最重要的验收。 + +```bash +awiki-cli p6 group create ... +# awiki-mls 进程退出 + +awiki-cli p6 key-package create ... +# awiki-mls 进程退出 + +awiki-cli p6 group add-member ... +# awiki-mls 进程退出 + +awiki-cli p6 welcome process ... +# awiki-mls 进程退出 + +awiki-cli p6 message encrypt ... +# awiki-mls 进程退出 + +awiki-cli p6 message decrypt ... +# awiki-mls 进程退出 +``` + +通过标准: + +```text +1. 每一步都不依赖常驻进程 +2. 每一步都能从 storage restore 状态 +3. Alice/Bob 能完成加密解密 +4. kill 所有 awiki-mls 进程后,下一次命令仍能继续 +``` + +--- + +## 23.2 HTTP server 可选测试 + +```bash +awiki-mls serve --listen 127.0.0.1:8742 +AWIKI_MLS_MODE=http awiki-cli p6 message encrypt ... +``` + +通过标准: + +```text +1. HTTP 模式结果与 CLI 模式一致 +2. kill HTTP server 后,AWIKI_MLS_MODE=auto 能 fallback 到 exec +3. HTTP server 重启后能恢复 group state +``` + +--- + +## 23.3 snapshot 测试 + +```bash +awiki-cli p6 snapshot export --group-id anp-group-001 +rm -rf ~/.awiki/mls/test-restore +awiki-cli p6 snapshot import --snapshot group1.snapshot.enc +awiki-cli p6 group restore --group-id anp-group-001 +``` + +通过标准: + +```text +1. snapshot 是加密文件 +2. import 后能 restore group +3. epoch 不倒退 +4. 不允许默认覆盖更新状态 +``` + +--- + +# 24. 里程碑拆分 + +## M0:Rust 单体 CLI PoC + +目标: + +```text +先不做 HTTP。 +只做 awiki-mls CLI。 +``` + +完成: + +```text +awiki-mls key-package generate +awiki-mls group create +awiki-mls group add-member +awiki-mls welcome process +awiki-mls message encrypt +awiki-mls message decrypt +``` + +验收: + +```text +Alice/Bob 在没有后台进程的情况下完成加密解密。 +``` + +--- + +## M1:持久化与 restore + +完成: + +```text +1. SQLite storage +2. group_bindings +3. operation_log +4. file lock +5. group restore +``` + +验收: + +```text +每个命令都是独立进程。 +重启后仍可继续 encrypt/decrypt。 +``` + +--- + +## M2:Go ExecProvider 接入 + +完成: + +```text +1. Go MLSProvider interface +2. ExecProvider +3. awiki-cli p6 子命令 +4. JSON stdin/stdout 调用 +``` + +验收: + +```text +awiki-cli 不需要常驻 sidecar。 +Go CLI 可以完整跑通 P6 最小闭环。 +``` + +--- + +## M3:HTTP JSON API + +完成: + +```text +1. awiki-mls serve +2. /healthz +3. /v1/version +4. 所有核心 HTTP endpoints +5. token auth +``` + +验收: + +```text +AWIKI_MLS_MODE=http 可用。 +AWIKI_MLS_MODE=auto 可 fallback。 +``` + +--- + +## M4:staged add_member 与崩溃恢复 + +完成: + +```text +1. pending_commits +2. commit_merge +3. request_id 幂等 +4. pending commit list +``` + +验收: + +```text +add_member 之后 Go CLI 崩溃,重试可恢复。 +``` + +--- + +## M5:snapshot export/import + +完成: + +```text +1. 加密 snapshot +2. import 冲突检查 +3. epoch 防倒退 +4. 默认不开放 HTTP snapshot API +``` + +--- + +# 25. 第一阶段不做的事情 + +为了避免范围失控,第一阶段不做: + +```text +1. 不做 OpenMLS Go 重写 +2. 不做 Rust FFI +3. 不做常驻 daemon 依赖 +4. 不做多设备同步 +5. 不做完整 remove member +6. 不做 external commit +7. 不做云端 snapshot 自动备份 +8. 不做大型附件直接 MLS 加密 +9. 不做跨实现互操作承诺 +``` + +大型附件后续应该采用: + +```text +MLS 加密内容密钥 +内容本身用 AEAD 流式加密 +``` + +不要直接把大文件塞进 MLS application message。 + +--- + +# 26. 推荐默认策略 + +最终建议: + +```text +默认: + AWIKI_MLS_MODE=exec + +开发: + AWIKI_MLS_MODE=auto + +服务化: + AWIKI_MLS_MODE=http +``` + +也就是说: + +```text +awiki-cli 默认不依赖任何后台进程。 +``` + +HTTP server 只是优化: + +```text +1. 减少进程启动开销 +2. 方便本地 agent runtime 长期运行 +3. 方便调试 +4. 方便后续桌面端 / daemon 集成 +``` + +--- + +# 27. 最终一句话方案 + +你应该把第一阶段设计成: + +> **`awiki-mls` 是一个 Rust OpenMLS 能力二进制,支持 HTTP server mode 和 one-shot CLI mode;`awiki-cli` 默认通过 exec 调用 CLI mode,不依赖常驻后台进程;所有 MLS group state、key package、pending commit、operation log 都持久化到本地 storage,HTTP 和 CLI 共用同一个 Rust engine。** + +这样既满足: + +```text +Go CLI 当前架构 +``` + +也满足: + +```text +不能保证后台进程一直运行 +``` + +还保留了未来升级路径: + +```text +exec mode -> HTTP mode -> daemon mode -> Go 原生 MLS provider +``` + +[1]: https://github.com/openmls/openmls "GitHub - openmls/openmls: Rust implementation of the Messaging Layer Security (MLS) protocol · GitHub" +[2]: https://book.openmls.tech/user_manual/persistence.html "Persistence of group state - OpenMLS Book" +[3]: https://book.openmls.tech/user_manual/aad.html "Using Additional Authenticated Data (AAD) - OpenMLS Book" diff --git a/docs/installation.md b/docs/installation.md index 70bd6a6..b1b7c49 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -116,6 +116,7 @@ awiki-cli 默认采用单根目录工作区模型,默认路径如下: | runtime 目录 | `~/.awiki-cli/runtime/` | 无 | | 缓存目录 | `~/.awiki-cli/cache/` | 无 | | 日志目录 | `~/.awiki-cli/logs/` | 无 | +| MLS 状态目录(P6 group E2EE contract-test/未来 OpenMLS) | `~/.awiki-cli/mls/` | 无 | > 说明:`~/.awiki-cli/` 是跨平台固定的工作区目录(Windows 对应 `%USERPROFILE%\.awiki-cli\`),也是默认唯一入口。 > `AWIKI_CLI_WORKSPACE_HOME_DIR` 只负责切换整个工作区根目录;`config / data / runtime / cache` 不再允许分别配置。 @@ -128,6 +129,7 @@ awiki-cli 默认采用单根目录工作区模型,默认路径如下: > - `data/awiki-cli.db` > - `cache/` > - `runtime/` +> - `mls/`(由 `anp-mls` exec provider 使用;MLS 私有状态不写入主业务 SQLite) > - `logs/` > - workspace upgrade 元数据 > - upgrade lock / journal diff --git a/internal/cli/group_e2ee.go b/internal/cli/group_e2ee.go new file mode 100644 index 0000000..ebf717b --- /dev/null +++ b/internal/cli/group_e2ee.go @@ -0,0 +1,144 @@ +package cli + +import ( + "fmt" + "time" + + "github.com/agentconnect/awiki-cli/internal/identity" + "github.com/agentconnect/awiki-cli/internal/message" + "github.com/spf13/cobra" +) + +func (a *App) runGroupE2EEStatus(cmd *cobra.Command, args []string) error { + group, _ := cmd.Flags().GetString("group") + service, format, err := a.messageService() + if err != nil { + return a.messageExit(err, "Run `awiki-cli doctor` to inspect configuration and identity state.") + } + provider := message.NewDefaultMLSExecProvider(service.Config()) + plan := map[string]any{ + "action": "group.e2ee.status", + "identity": a.globals.Identity, + "runtime_mode": service.Config().RuntimeMode, + "profile": message.GroupE2EEProfile, + "security_profile": message.GroupE2EESecurityProfile, + "artifact_mode": message.GroupE2EEContractArtifactMode, + "provider": "exec", + "binary": provider.BinaryPath, + "mls_data_dir": provider.DataDir, + "group": group, + "discovery_advertised": false, + } + if a.globals.DryRun { + return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, map[string]any{"plan": plan}, "Dry run: group e2ee status planned", nil, a.identityMeta()) + } + agentDID, identityErr := activeIdentityDID(service, a.globals.Identity) + warnings := []string(nil) + if identityErr != nil { + warnings = append(warnings, fmt.Sprintf("Active identity DID unavailable: %v", identityErr)) + } + provider.Timeout = 5 * time.Second + resp, callErr := provider.Call(cmd.Context(), "group", "status", message.MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: fmt.Sprintf("group-e2ee-status-%d", time.Now().UnixNano()), + AgentDID: agentDID, + ContractTestEnabled: true, + Params: map[string]any{"group_did": group}, + }) + data := map[string]any{"plan": plan, "available": callErr == nil} + if callErr != nil { + warnings = append(warnings, fmt.Sprintf("anp-mls exec provider unavailable: %v", callErr)) + } else if resp != nil { + data["mls"] = resp.Result + } + return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, data, "Group E2EE contract-test status inspected", warnings, a.identityMeta()) +} + +func (a *App) runGroupE2EEPublishKeyPackage(cmd *cobra.Command, args []string) error { + device, _ := cmd.Flags().GetString("device") + contractTest, _ := cmd.Flags().GetBool("contract-test") + service, format, err := a.messageService() + if err != nil { + return a.messageExit(err, "Run `awiki-cli doctor` to inspect configuration and identity state.") + } + provider := message.NewDefaultMLSExecProvider(service.Config()) + plan := map[string]any{ + "action": "group.e2ee.publish_key_package", + "identity": a.globals.Identity, + "runtime_mode": service.Config().RuntimeMode, + "provider": "exec", + "binary": provider.BinaryPath, + "mls_data_dir": provider.DataDir, + "device": device, + "contract_test_only": contractTest, + } + if a.globals.DryRun { + return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, map[string]any{"plan": plan}, "Dry run: group e2ee key package publish planned", nil, a.identityMeta()) + } + ownerDID, identityErr := activeIdentityDID(service, a.globals.Identity) + if identityErr != nil { + return a.messageExit(identityErr, "Create or select an identity before generating a group E2EE KeyPackage.") + } + resp, callErr := provider.Call(cmd.Context(), "key-package", "generate", message.MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: fmt.Sprintf("group-e2ee-key-package-%d", time.Now().UnixNano()), + AgentDID: ownerDID, + DeviceID: device, + ContractTestEnabled: contractTest, + Params: map[string]any{ + "owner_did": ownerDID, + "device_id": device, + }, + }) + if callErr != nil { + return a.messageExit(callErr, "Install anp-mls or rerun with --dry-run while group E2EE remains contract-test only.") + } + return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, map[string]any{"plan": plan, "mls": resp.Result}, "Generated group E2EE contract-test KeyPackage", nil, a.identityMeta()) +} + +func (a *App) runGroupE2EEPending(cmd *cobra.Command, args []string) error { + service, format, err := a.messageService() + if err != nil { + return a.messageExit(err, "Run `awiki-cli doctor` to inspect configuration and identity state.") + } + provider := message.NewDefaultMLSExecProvider(service.Config()) + data := map[string]any{ + "plan": map[string]any{ + "action": "group.e2ee.pending", + "identity": a.globals.Identity, + "runtime_mode": service.Config().RuntimeMode, + "provider": "exec", + "mls_data_dir": provider.DataDir, + }, + "pending": []any{}, + "note": "contract-test skeleton only; real OpenMLS pending queue will be added with MLS state integration", + } + return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, data, "Group E2EE pending queue inspected", nil, a.identityMeta()) +} + +func (a *App) runGroupE2EERepair(cmd *cobra.Command, args []string) error { + group, _ := cmd.Flags().GetString("group") + service, format, err := a.messageService() + if err != nil { + return a.messageExit(err, "Run `awiki-cli doctor` to inspect configuration and identity state.") + } + provider := message.NewDefaultMLSExecProvider(service.Config()) + plan := map[string]any{ + "action": "group.e2ee.repair", + "identity": a.globals.Identity, + "runtime_mode": service.Config().RuntimeMode, + "provider": "exec", + "mls_data_dir": provider.DataDir, + "group": group, + "scope": "replay pending notices and verify local MLS DB summary", + } + return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, map[string]any{"plan": plan}, "Group E2EE repair planned", nil, a.identityMeta()) +} + +func activeIdentityDID(service *message.Service, name string) (string, error) { + record, err := identity.NewManager(service.Config().Paths).Load(name) + if err != nil { + return "", err + } + return record.DID, nil +} diff --git a/internal/cli/group_test.go b/internal/cli/group_test.go index 8aed2f9..3793415 100644 --- a/internal/cli/group_test.go +++ b/internal/cli/group_test.go @@ -70,6 +70,24 @@ func TestGroupDryRunPlansRenderStableContracts(t *testing.T) { } }, }, + { + name: "group e2ee status exposes exec provider data dir without advertising discovery", + spec: "group.e2ee.status", + setFlags: map[string]string{"group": "did:wba:example.com:groups:demo:e1_group"}, + wantSummary: "Dry run: group e2ee status planned", + wantAction: "group.e2ee.status", + verifyPlan: func(t *testing.T, plan map[string]any) { + if plan["provider"] != "exec" { + t.Fatalf("plan.provider = %#v, want exec", plan["provider"]) + } + if plan["discovery_advertised"] != false { + t.Fatalf("plan.discovery_advertised = %#v, want false", plan["discovery_advertised"]) + } + if plan["mls_data_dir"] == "" { + t.Fatal("plan.mls_data_dir should be populated") + } + }, + }, } for _, tc := range cases { @@ -94,6 +112,8 @@ func TestGroupDryRunPlansRenderStableContracts(t *testing.T) { return app.runGroupKick(cmd, nil) case "group.messages": return app.runGroupMessages(cmd, nil) + case "group.e2ee.status": + return app.runGroupE2EEStatus(cmd, nil) default: t.Fatalf("unsupported spec %q", tc.spec) return nil diff --git a/internal/cli/root.go b/internal/cli/root.go index 3c5b9f7..4746ef2 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -241,6 +241,14 @@ func (a *App) handlerFor(spec cmdmeta.CommandSpec) func(*cobra.Command, []string return a.runGroupMembers case "group.messages": return a.runGroupMessages + case "group.e2ee.status": + return a.runGroupE2EEStatus + case "group.e2ee.publish-key-package": + return a.runGroupE2EEPublishKeyPackage + case "group.e2ee.pending": + return a.runGroupE2EEPending + case "group.e2ee.repair": + return a.runGroupE2EERepair case "page.create": return a.runPageCreate case "page.list": diff --git a/internal/cmdmeta/catalog.go b/internal/cmdmeta/catalog.go index a00ab31..56d711c 100644 --- a/internal/cmdmeta/catalog.go +++ b/internal/cmdmeta/catalog.go @@ -166,6 +166,11 @@ func defaultSpecs() []CommandSpec { {Name: "group.update", Use: "update", Short: "Update group profile or policy", Phase: "phase5", Implemented: true, Handler: "group.update", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "name", Type: "string", Usage: "New group display name"}, {Name: "description", Type: "string", Usage: "New group description"}, {Name: "discoverability", Type: "string", Usage: "Discoverability mode"}, {Name: "admission-mode", Type: "string", Usage: "Admission mode"}, {Name: "slug", Type: "string", Usage: "New group slug"}, {Name: "goal", Type: "string", Usage: "New group goal"}, {Name: "rules", Type: "string", Usage: "New group rules"}, {Name: "message-prompt", Type: "string", Usage: "New group prompt"}, {Name: "doc-url", Type: "string", Usage: "New group document URL"}, {Name: "attachments-allowed", Type: "bool", Usage: "Allow attachments"}, {Name: "max-members", Type: "string", Usage: "Maximum group members"}, {Name: "member-max-messages", Type: "int", Usage: "Per-member message limit"}, {Name: "member-max-total-chars", Type: "int", Usage: "Per-member total char limit"}}}, {Name: "group.members", Use: "members", Short: "List active group members", Phase: "phase5", Implemented: true, Handler: "group.members", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "limit", Type: "int", Usage: "Maximum number of rows", Default: "100"}}}, {Name: "group.messages", Use: "messages", Short: "List group messages", Phase: "phase5", Implemented: true, Handler: "group.messages", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "limit", Type: "int", Usage: "Maximum number of rows", Default: "50"}, {Name: "cursor", Type: "string", Usage: "Pagination cursor"}}}, + {Name: "group.e2ee", Use: "e2ee", Short: "Inspect test-only group E2EE state", Phase: "phase6", Implemented: true}, + {Name: "group.e2ee.status", Use: "status", Short: "Inspect local group E2EE MLS provider status", Phase: "phase6", Implemented: true, Handler: "group.e2ee.status", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID"}}}, + {Name: "group.e2ee.publish-key-package", Use: "publish-key-package", Short: "Plan a test-only group E2EE KeyPackage publish", Phase: "phase6", Implemented: true, Handler: "group.e2ee.publish-key-package", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "device", Type: "string", Usage: "Local MLS device id", Default: "default"}, {Name: "contract-test", Type: "bool", Usage: "Use non-cryptographic contract-test artifacts"}}}, + {Name: "group.e2ee.pending", Use: "pending", Short: "Inspect pending group E2EE contract-test work", Phase: "phase6", Implemented: true, Handler: "group.e2ee.pending", Outputs: []string{"json", "pretty", "table"}}, + {Name: "group.e2ee.repair", Use: "repair", Short: "Plan a group E2EE contract-test repair pass", Phase: "phase6", Implemented: true, Handler: "group.e2ee.repair", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}}}, {Name: "group.code", Use: "code", Short: "Inspect or manage group join codes", Phase: "phase5", Implemented: false}, {Name: "group.code.get", Use: "get", Short: "Show group join code status", Phase: "phase5", Implemented: false, Handler: "stub", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}}}, {Name: "group.code.refresh", Use: "refresh", Short: "Rotate the current group join code", Phase: "phase5", Implemented: false, Handler: "stub", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}}}, diff --git a/internal/message/group_e2ee_provider.go b/internal/message/group_e2ee_provider.go new file mode 100644 index 0000000..8035b91 --- /dev/null +++ b/internal/message/group_e2ee_provider.go @@ -0,0 +1,121 @@ +package message + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "time" + + appconfig "github.com/agentconnect/awiki-cli/internal/config" +) + +const ( + GroupE2EEProfile = "anp.group.e2ee.v1" + GroupE2EESecurityProfile = "group-e2ee" + GroupE2EEContractArtifactMode = "contract-test" + DefaultANPMLSBinary = "anp-mls" +) + +type MLSRequest struct { + APIVersion string `json:"api_version"` + RequestID string `json:"request_id"` + AgentDID string `json:"agent_did,omitempty"` + DeviceID string `json:"device_id,omitempty"` + ContractTestEnabled bool `json:"contract_test_enabled,omitempty"` + Params map[string]any `json:"params"` +} + +type MLSResponse struct { + OK bool `json:"ok"` + APIVersion string `json:"api_version"` + RequestID string `json:"request_id"` + Result map[string]any `json:"result,omitempty"` + Error *MLSError `json:"error,omitempty"` +} + +type MLSError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type MLSCommandRunner interface { + Run(ctx context.Context, binary string, args []string, stdin []byte) (stdout []byte, stderr []byte, err error) +} + +type OSMLSCommandRunner struct{} + +func (OSMLSCommandRunner) Run(ctx context.Context, binary string, args []string, stdin []byte) ([]byte, []byte, error) { + cmd := exec.CommandContext(ctx, binary, args...) + cmd.Stdin = bytes.NewReader(stdin) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return stdout.Bytes(), stderr.Bytes(), err +} + +type MLSExecProvider struct { + BinaryPath string + DataDir string + Timeout time.Duration + Runner MLSCommandRunner +} + +func NewDefaultMLSExecProvider(resolved *appconfig.Resolved) MLSExecProvider { + return MLSExecProvider{ + BinaryPath: DefaultANPMLSBinary, + DataDir: DefaultMLSDataDir(resolved), + Timeout: 15 * time.Second, + } +} + +func DefaultMLSDataDir(resolved *appconfig.Resolved) string { + if resolved == nil || resolved.Paths.WorkspaceHomeDir == "" { + return filepath.Join(".awiki-cli", "mls") + } + return filepath.Join(resolved.Paths.WorkspaceHomeDir, "mls") +} + +func (p MLSExecProvider) Call(ctx context.Context, domain string, action string, req MLSRequest) (*MLSResponse, error) { + binary := p.BinaryPath + if binary == "" { + binary = DefaultANPMLSBinary + } + timeout := p.Timeout + if timeout <= 0 { + timeout = 15 * time.Second + } + runner := p.Runner + if runner == nil { + runner = OSMLSCommandRunner{} + } + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + args := []string{domain, action, "--json-in", "-"} + if p.DataDir != "" { + args = append(args, "--data-dir", p.DataDir) + } + stdout, stderr, err := runner.Run(ctx, binary, args, body) + if err != nil && len(stdout) == 0 { + return nil, fmt.Errorf("anp-mls exec failed: %w: %s", err, string(stderr)) + } + var resp MLSResponse + if decodeErr := json.Unmarshal(stdout, &resp); decodeErr != nil { + return nil, fmt.Errorf("decode anp-mls response: %w: stderr=%s", decodeErr, string(stderr)) + } + if !resp.OK { + if resp.Error != nil { + return &resp, fmt.Errorf("anp-mls error %s: %s", resp.Error.Code, resp.Error.Message) + } + return &resp, fmt.Errorf("anp-mls returned ok=false") + } + return &resp, nil +} diff --git a/internal/message/group_e2ee_provider_test.go b/internal/message/group_e2ee_provider_test.go new file mode 100644 index 0000000..1018b3b --- /dev/null +++ b/internal/message/group_e2ee_provider_test.go @@ -0,0 +1,51 @@ +package message + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +type recordingMLSRunner struct { + args []string + stdin []byte +} + +func (r *recordingMLSRunner) Run(_ context.Context, _ string, args []string, stdin []byte) ([]byte, []byte, error) { + r.args = append([]string(nil), args...) + r.stdin = append([]byte(nil), stdin...) + return []byte(`{"ok":true,"api_version":"anp-mls/v1","request_id":"req-1","result":{"non_cryptographic":true}}`), nil, nil +} + +func TestMLSExecProviderPassesPlaintextOnStdinNotArgv(t *testing.T) { + runner := &recordingMLSRunner{} + provider := MLSExecProvider{BinaryPath: "anp-mls", DataDir: t.TempDir(), Runner: runner} + _, err := provider.Call(context.Background(), "message", "encrypt", MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "req-1", + ContractTestEnabled: true, + Params: map[string]any{ + "application_plaintext": map[string]any{"text": "super secret"}, + }, + }) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(runner.args, " "), "super secret") { + t.Fatalf("plaintext leaked into argv: %#v", runner.args) + } + if !strings.Contains(string(runner.stdin), "super secret") { + t.Fatalf("plaintext request was not sent via stdin: %s", string(runner.stdin)) + } + var req MLSRequest + if err := json.Unmarshal(runner.stdin, &req); err != nil { + t.Fatal(err) + } + if !req.ContractTestEnabled { + t.Fatal("contract test flag not preserved") + } + if got := runner.args[len(runner.args)-2]; got != "--data-dir" { + t.Fatalf("args missing --data-dir before final value: %#v", runner.args) + } +} From abaf665c0f94d0bc714e75a3159cd319da05cf35 Mon Sep 17 00:00:00 2001 From: changshan Date: Sat, 2 May 2026 09:21:10 +0800 Subject: [PATCH 03/14] Enable CLI orchestration for real group E2EE The CLI now treats group E2EE as a real anp-mls exec-backed flow instead of a dry diagnostic skeleton. The implementation keeps Go pure-Go/no-CGO, sends plaintext only through stdin to anp-mls, publishes opaque P6 payloads to message-service, and stores only derived group summaries in the business SQLite database. Constraint: awiki-cli must not link Rust/OpenMLS or store MLS private material in its business DB Constraint: System tests require AWIKI_ANP_MLS_BINARY/runtime path discovery before PATH fallback Rejected: Keep group E2EE as contract-test-only CLI commands | real MLS lane requires create/add/send/decrypt orchestration from normal CLI surfaces Confidence: medium Scope-risk: moderate Directive: Do not put application plaintext in anp-mls argv or message-service group.e2ee.send bodies Tested: go test ./internal/message ./internal/doctor -count=1 Tested: go test ./internal/cli -run TestGroupDryRunPlansRenderStableContracts -count=1 Tested: CGO_ENABLED=0 go test ./... -run '^$' -count=1 Tested: go vet ./internal/message ./internal/doctor ./internal/cli Not-tested: Full internal/cli suite; existing TestRuntimeValidationErrorsUseStableCodes timed out during remote upgrade/DID replace path --- CLAUDE.md | 6 +- docs/architecture/awiki-command-v2.md | 4 +- docs/architecture/go-cli+rust-mls.md | 5 +- docs/installation.md | 13 +- internal/cli/group.go | 35 +- internal/cli/group_e2ee.go | 25 +- internal/cli/group_test.go | 16 + internal/cmdmeta/catalog.go | 4 +- internal/doctor/doctor.go | 32 ++ internal/doctor/doctor_test.go | 9 +- internal/message/group_e2ee_provider.go | 114 +++++- internal/message/group_e2ee_provider_test.go | 59 ++++ internal/message/group_e2ee_service.go | 344 +++++++++++++++++++ internal/message/group_service.go | 61 +++- internal/message/group_wire.go | 173 ++++++++++ internal/message/group_wire_test.go | 101 ++++++ internal/message/http_client.go | 55 +++ internal/message/service.go | 14 +- internal/message/types.go | 31 +- 19 files changed, 1022 insertions(+), 79 deletions(-) create mode 100644 internal/message/group_e2ee_service.go diff --git a/CLAUDE.md b/CLAUDE.md index 6518eec..98c05ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ **internal/cli/debug.go**: `debug db query`、`debug db handle-history` 与 `debug db import-v1` 的 CLI 处理器。 **internal/cli/msg.go**: `msg send/inbox/history/mark-read` 的 CLI 处理器,现已支持 direct + group plain messaging。 **internal/cli/group.go**: `group create/get/join/add/remove/leave/update/members/messages` 的 CLI 处理器。 -**internal/cli/group_e2ee.go**: P6 group E2EE contract-test 诊断/维护命令处理器;只暴露本地 exec provider/status/KeyPackage 计划,不宣称真实 OpenMLS 已可用。 +**internal/cli/group_e2ee.go**: P6 group E2EE 诊断/维护命令处理器;支持本地 exec provider/status/KeyPackage 发布路径,`contract-test` 仅在显式 flag 下启用。 **internal/identity/types.go**: identity store、legacy scan、command result 等核心类型。 **internal/identity/layout.go**: identity 根目录、index.json、路径与安全写入辅助。 **internal/identity/store.go**: 当前 v2 identity store 的读写、默认 identity 管理。 @@ -93,7 +93,7 @@ **internal/message/attachment_service.go**: direct/group attachment send 与 `msg attachment download` 的业务编排层。 **internal/message/secure.go**: P5 direct E2EE secure send 的首版编排层,使用 ANP Go SDK direct_e2ee、本地文件会话/预密钥存储和 HTTP JSON-RPC;key-service 请求绑定当前 DID 文档里 `ANPMessageService.serviceDid`,并在有可用 sidecar OPK 时优先用 OPK 建链(本地保存 `p5-one-time-prekeys/`);HTTP inbox/history 现已接入入站密文解密与会话推进,并会顺带补发本地 prekey bundle;轮询路径解密 direct-init 成功后会自动发送 encrypted ACK 并尝试 flush 该 peer 的 `e2ee_outbox`;当 initiator 仍处于 `pending-confirmation` 时,新的 secure 发送会进入 `e2ee_outbox` 排队。 **internal/message/secure_control.go**: secure 控制面与恢复辅助,负责 secure ack/init payload、pending 阶段的 `e2ee_outbox` 排队、secure outbox flush,以及 `msg secure status/init/repair/failed/retry/drop` 需要的本地会话/发件箱读取与重试逻辑。 -**internal/message/group_e2ee_provider.go**: `anp-mls` exec provider 抽象;JSON request 走 stdin、response 走 stdout、日志/错误走 stderr,默认 MLS 状态目录为 `/mls`,保持 Go 主工程 pure Go / no CGO。 +**internal/message/group_e2ee_provider.go**: `anp-mls` exec provider 抽象;按 `AWIKI_ANP_MLS_BINARY`、测试/运行时注入路径、`PATH` 顺序发现二进制;JSON request 走 stdin、response 走 stdout、日志/错误走 stderr,默认 MLS 状态目录为 `/mls`,保持 Go 主工程 pure Go / no CGO。 **internal/message/group_wire.go**: group 标准面和 local-only RPC 参数构造器。 **internal/message/http_client.go**: direct/group message 与 group lifecycle 的 HTTP JSON-RPC adapter。 **internal/message/ws_proxy_client.go**: websocket 模式下通过本地 bridge 调用 listener/daemon 的 direct/group adapter。 @@ -216,7 +216,7 @@ - `msg` 域中 direct plain 已实现,P5 secure direct 出站首版已接入;HTTP inbox/history 的 P5 入站自动解密、轮询 direct-init 自动 ACK 与 outbox flush 已接入;websocket listener 已能解密 secure incoming、自动 first-reply/ack,并在 secure ack 后尝试 flush `e2ee_outbox`;`msg secure status/init/repair/failed/retry/drop` 有首版,但完整 runtime 联调与更强系统测试仍未完成 - `group` 域的 plain lifecycle / local view / group messaging 已接入,`people` 仍大多为 stub;`page` 已完成 content pages 首版 -- secure E2EE 业务流目前只完成 P5 出站首版;group E2EE、发布链路和完整实时收件处理属于后续阶段 +- secure E2EE 业务流目前只完成 P5 出站首版;group E2EE 已接入 CLI 侧 `anp-mls` exec 编排、KeyPackage 发布、group-e2ee create/add/send 与轮询消息本地解密分支,但真实 OpenMLS 完整验收仍依赖 `anp-mls` 与 message-service P6 后端联调。 ## 开发与验证约定 diff --git a/docs/architecture/awiki-command-v2.md b/docs/architecture/awiki-command-v2.md index 5d1f92d..fd0354c 100644 --- a/docs/architecture/awiki-command-v2.md +++ b/docs/architecture/awiki-command-v2.md @@ -117,10 +117,10 @@ awiki-cli msg mark-read MSG_ID... `msg attachment download` 会按 `message_id` 分页扫描 direct history 或 group messages,直到命中目标附件消息,而不是只检查最新一页结果。 -awiki-cli group create --name "Agent War Room" [--description "..."] [--discoverability private|listed|public] [--admission-mode admin-add|open-join] [--slug agent-war-room] [--goal "..."] [--rules "..."] [--message-prompt "..."] [--doc-url "https://..."] [--attachments-allowed] [--max-members 500] [--member-max-messages 10] [--member-max-total-chars 2000] [--identity alice] +awiki-cli group create --name "Agent War Room" [--description "..."] [--discoverability private|listed|public] [--admission-mode admin-add|open-join] [--message-security-profile transport-protected|group-e2ee] [--e2ee] [--slug agent-war-room] [--goal "..."] [--rules "..."] [--message-prompt "..."] [--doc-url "https://..."] [--attachments-allowed] [--max-members 500] [--member-max-messages 10] [--member-max-total-chars 2000] [--identity alice] awiki-cli group get --group GROUP_DID [--identity alice] awiki-cli group join --group GROUP_DID [--reason "..."] [--identity alice] -awiki-cli group add --group GROUP_DID --member did:wba:... [--role member|admin] [--reason "..."] [--identity alice] +awiki-cli group add --group GROUP_DID --member did:wba:... [--role member|admin] [--reason "..."] [--e2ee] [--identity alice] awiki-cli group remove --group GROUP_DID --member did:wba:... [--reason "..."] [--identity alice] awiki-cli group members --group GROUP_DID [--limit 100] [--identity alice] awiki-cli group messages --group GROUP_DID [--limit 50] [--cursor CURSOR] [--identity alice] diff --git a/docs/architecture/go-cli+rust-mls.md b/docs/architecture/go-cli+rust-mls.md index 00ffb91..f87d111 100644 --- a/docs/architecture/go-cli+rust-mls.md +++ b/docs/architecture/go-cli+rust-mls.md @@ -1,8 +1,9 @@ > **阶段口径(2026-05 P6 contract-first slice)** > > 本文是早期 Rust/OpenMLS 工程方案草稿,不代表当前已落地能力。 -> 当前实现只落地 `anp-mls` one-shot exec provider、P6 wire/storage/API 与 -> `contract-test` 非加密 artifact 骨架;**不宣称真实 OpenMLS 群端到端加密已可用**。 +> 当前 CLI 实现已接入 `anp-mls` one-shot exec provider、P6 wire/storage/API、KeyPackage 发布、 +> `group create --message-security-profile group-e2ee` / `--e2ee`、group-e2ee add/send/decrypt 编排。 +> `contract-test` 非加密 artifact 只能通过显式 flag 启用;真实 OpenMLS 可用性仍以 `anp-mls` 后端和系统测试验收为准。 > HTTP server / daemon、OpenMLS `StorageProvider`、真实 MLS group state 持久化、 > snapshot、多设备同步等内容均是后续设计方向,不属于本阶段验收范围。 > 对外 discovery 必须继续隐藏 `anp.group.e2ee.v1` / `group-e2ee`,并由 feature flag diff --git a/docs/installation.md b/docs/installation.md index b1b7c49..8328bc8 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -116,7 +116,7 @@ awiki-cli 默认采用单根目录工作区模型,默认路径如下: | runtime 目录 | `~/.awiki-cli/runtime/` | 无 | | 缓存目录 | `~/.awiki-cli/cache/` | 无 | | 日志目录 | `~/.awiki-cli/logs/` | 无 | -| MLS 状态目录(P6 group E2EE contract-test/未来 OpenMLS) | `~/.awiki-cli/mls/` | 无 | +| MLS 状态目录(P6 group E2EE / `anp-mls`) | `~/.awiki-cli/mls/` | 无 | > 说明:`~/.awiki-cli/` 是跨平台固定的工作区目录(Windows 对应 `%USERPROFILE%\.awiki-cli\`),也是默认唯一入口。 > `AWIKI_CLI_WORKSPACE_HOME_DIR` 只负责切换整个工作区根目录;`config / data / runtime / cache` 不再允许分别配置。 @@ -135,6 +135,17 @@ awiki-cli 默认采用单根目录工作区模型,默认路径如下: > - upgrade lock / journal > - 备份快照 + +### 3.3 `anp-mls` binary discovery + +Group E2EE commands keep the Go CLI pure-Go/no-CGO by invoking the Rust `anp-mls` binary as a one-shot process. Discovery order is: + +1. `AWIKI_ANP_MLS_BINARY` absolute path override. +2. Runtime/test injected provider path. +3. `PATH` lookup for `anp-mls`. + +Plain direct/group messaging does not require this binary. `awiki-cli doctor` reports an informational `anp_mls` check when the binary is missing; group E2EE commands return an actionable remediation error. + ### 3.2 config.yaml 配置文件位于 `~/.awiki-cli/config.yaml`。推荐先执行 `awiki-cli init` 自动创建最小配置;如需手动创建,可参考仓库根目录的 `config.template.yaml`,或直接使用下面的模板: diff --git a/internal/cli/group.go b/internal/cli/group.go index e267291..256c454 100644 --- a/internal/cli/group.go +++ b/internal/cli/group.go @@ -13,6 +13,8 @@ func (a *App) runGroupCreate(cmd *cobra.Command, args []string) error { description, _ := cmd.Flags().GetString("description") discoverability, _ := cmd.Flags().GetString("discoverability") admissionMode, _ := cmd.Flags().GetString("admission-mode") + messageSecurityProfile, _ := cmd.Flags().GetString("message-security-profile") + e2ee, _ := cmd.Flags().GetBool("e2ee") slug, _ := cmd.Flags().GetString("slug") goal, _ := cmd.Flags().GetString("goal") rules, _ := cmd.Flags().GetString("rules") @@ -27,20 +29,22 @@ func (a *App) runGroupCreate(cmd *cobra.Command, args []string) error { return a.messageExit(err, "Run `awiki-cli doctor` to inspect configuration and identity state.") } request := message.GroupCreateRequest{ - IdentityName: a.globals.Identity, - Name: name, - Description: description, - Discoverability: discoverability, - AdmissionMode: admissionMode, - Slug: slug, - Goal: goal, - Rules: rules, - MessagePrompt: messagePrompt, - DocURL: docURL, - AttachmentsAllowed: attachmentsAllowed, - MaxMembers: maxMembers, - MemberMaxMessages: memberMaxMessages, - MemberMaxTotalChars: memberMaxTotalChars, + IdentityName: a.globals.Identity, + Name: name, + Description: description, + Discoverability: discoverability, + AdmissionMode: admissionMode, + MessageSecurityProfile: messageSecurityProfile, + E2EE: e2ee, + Slug: slug, + Goal: goal, + Rules: rules, + MessagePrompt: messagePrompt, + DocURL: docURL, + AttachmentsAllowed: attachmentsAllowed, + MaxMembers: maxMembers, + MemberMaxMessages: memberMaxMessages, + MemberMaxTotalChars: memberMaxTotalChars, } if a.globals.DryRun { return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, map[string]any{"plan": map[string]any{"action": "group.create", "identity": a.globals.Identity, "runtime_mode": service.Config().RuntimeMode, "request": request}}, "Dry run: group create planned", nil, a.identityMeta()) @@ -100,11 +104,12 @@ func (a *App) runGroupMemberMutation(cmd *cobra.Command, publicAction string, me member, _ := cmd.Flags().GetString("member") role, _ := cmd.Flags().GetString("role") reason, _ := cmd.Flags().GetString("reason") + e2ee, _ := cmd.Flags().GetBool("e2ee") service, format, err := a.messageService() if err != nil { return a.messageExit(err, "Run `awiki-cli doctor` to inspect configuration and identity state.") } - request := message.GroupMemberRequest{IdentityName: a.globals.Identity, Group: group, Member: member, Role: role, ReasonText: reason} + request := message.GroupMemberRequest{IdentityName: a.globals.Identity, Group: group, Member: member, Role: role, ReasonText: reason, E2EE: e2ee} if a.globals.DryRun { return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, map[string]any{"plan": map[string]any{"action": "group." + publicAction, "identity": a.globals.Identity, "runtime_mode": service.Config().RuntimeMode, "request": request}}, "Dry run: group membership change planned", nil, a.identityMeta()) } diff --git a/internal/cli/group_e2ee.go b/internal/cli/group_e2ee.go index ebf717b..9daca32 100644 --- a/internal/cli/group_e2ee.go +++ b/internal/cli/group_e2ee.go @@ -43,7 +43,7 @@ func (a *App) runGroupE2EEStatus(cmd *cobra.Command, args []string) error { RequestID: fmt.Sprintf("group-e2ee-status-%d", time.Now().UnixNano()), AgentDID: agentDID, ContractTestEnabled: true, - Params: map[string]any{"group_did": group}, + Params: map[string]any{"agent_did": agentDID, "group_did": group}, }) data := map[string]any{"plan": plan, "available": callErr == nil} if callErr != nil { @@ -75,25 +75,12 @@ func (a *App) runGroupE2EEPublishKeyPackage(cmd *cobra.Command, args []string) e if a.globals.DryRun { return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, map[string]any{"plan": plan}, "Dry run: group e2ee key package publish planned", nil, a.identityMeta()) } - ownerDID, identityErr := activeIdentityDID(service, a.globals.Identity) - if identityErr != nil { - return a.messageExit(identityErr, "Create or select an identity before generating a group E2EE KeyPackage.") - } - resp, callErr := provider.Call(cmd.Context(), "key-package", "generate", message.MLSRequest{ - APIVersion: "anp-mls/v1", - RequestID: fmt.Sprintf("group-e2ee-key-package-%d", time.Now().UnixNano()), - AgentDID: ownerDID, - DeviceID: device, - ContractTestEnabled: contractTest, - Params: map[string]any{ - "owner_did": ownerDID, - "device_id": device, - }, - }) - if callErr != nil { - return a.messageExit(callErr, "Install anp-mls or rerun with --dry-run while group E2EE remains contract-test only.") + result, publishErr := service.PublishGroupE2EEKeyPackage(cmd.Context(), a.globals.Identity, device, contractTest) + if publishErr != nil { + return a.messageExit(publishErr, "Install anp-mls, set AWIKI_ANP_MLS_BINARY, and ensure message-service group E2EE APIs are enabled.") } - return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, map[string]any{"plan": plan, "mls": resp.Result}, "Generated group E2EE contract-test KeyPackage", nil, a.identityMeta()) + result.Data["plan"] = plan + return a.renderMessageResult(cmd, format, result) } func (a *App) runGroupE2EEPending(cmd *cobra.Command, args []string) error { diff --git a/internal/cli/group_test.go b/internal/cli/group_test.go index 3793415..c979800 100644 --- a/internal/cli/group_test.go +++ b/internal/cli/group_test.go @@ -88,6 +88,22 @@ func TestGroupDryRunPlansRenderStableContracts(t *testing.T) { } }, }, + { + name: "group create e2ee alias maps to group-e2ee request", + spec: "group.create", + setFlags: map[string]string{"name": "Secret Group", "e2ee": "true"}, + wantSummary: "Dry run: group create planned", + wantAction: "group.create", + verifyPlan: func(t *testing.T, plan map[string]any) { + request, ok := plan["request"].(map[string]any) + if !ok { + t.Fatalf("plan.request type = %T, want map[string]any", plan["request"]) + } + if request["E2EE"] != true { + t.Fatalf("request.E2EE = %#v, want true", request["E2EE"]) + } + }, + }, } for _, tc := range cases { diff --git a/internal/cmdmeta/catalog.go b/internal/cmdmeta/catalog.go index 56d711c..731417d 100644 --- a/internal/cmdmeta/catalog.go +++ b/internal/cmdmeta/catalog.go @@ -157,10 +157,10 @@ func defaultSpecs() []CommandSpec { {Name: "msg.secure.retry", Use: "retry ", Short: "Retry one failed secure outbox item", Phase: "phase5", Implemented: true, Handler: "msg.secure.retry", SideEffect: true, Outputs: []string{"json", "pretty"}}, {Name: "msg.secure.drop", Use: "drop ", Short: "Drop one failed secure outbox item", Phase: "phase5", Implemented: true, Handler: "msg.secure.drop", SideEffect: true, Outputs: []string{"json", "pretty"}}, {Name: "group", Use: "group", Short: "Group lifecycle commands", Phase: "phase1", Implemented: true}, - {Name: "group.create", Use: "create", Short: "Create a new group", Phase: "phase5", Implemented: true, Handler: "group.create", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "name", Type: "string", Usage: "Group display name", Required: true}, {Name: "description", Type: "string", Usage: "Group description"}, {Name: "discoverability", Type: "string", Usage: "Discoverability mode", Default: "private"}, {Name: "admission-mode", Type: "string", Usage: "Admission mode", Default: "open-join"}, {Name: "slug", Type: "string", Usage: "Group slug"}, {Name: "goal", Type: "string", Usage: "Group goal"}, {Name: "rules", Type: "string", Usage: "Group rules"}, {Name: "message-prompt", Type: "string", Usage: "Default group prompt"}, {Name: "doc-url", Type: "string", Usage: "Group document URL"}, {Name: "attachments-allowed", Type: "bool", Usage: "Allow attachments"}, {Name: "max-members", Type: "string", Usage: "Maximum group members"}, {Name: "member-max-messages", Type: "int", Usage: "Per-member message limit"}, {Name: "member-max-total-chars", Type: "int", Usage: "Per-member total char limit"}}}, + {Name: "group.create", Use: "create", Short: "Create a new group", Phase: "phase5", Implemented: true, Handler: "group.create", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "name", Type: "string", Usage: "Group display name", Required: true}, {Name: "description", Type: "string", Usage: "Group description"}, {Name: "discoverability", Type: "string", Usage: "Discoverability mode", Default: "private"}, {Name: "admission-mode", Type: "string", Usage: "Admission mode", Default: "open-join"}, {Name: "message-security-profile", Type: "string", Usage: "Message security profile", Default: "transport-protected", Choices: []string{"transport-protected", "group-e2ee"}}, {Name: "e2ee", Type: "bool", Usage: "Alias for --message-security-profile group-e2ee"}, {Name: "slug", Type: "string", Usage: "Group slug"}, {Name: "goal", Type: "string", Usage: "Group goal"}, {Name: "rules", Type: "string", Usage: "Group rules"}, {Name: "message-prompt", Type: "string", Usage: "Default group prompt"}, {Name: "doc-url", Type: "string", Usage: "Group document URL"}, {Name: "attachments-allowed", Type: "bool", Usage: "Allow attachments"}, {Name: "max-members", Type: "string", Usage: "Maximum group members"}, {Name: "member-max-messages", Type: "int", Usage: "Per-member message limit"}, {Name: "member-max-total-chars", Type: "int", Usage: "Per-member total char limit"}}}, {Name: "group.get", Use: "get", Short: "Show group details", Aliases: []string{"show"}, Phase: "phase5", Implemented: true, Handler: "group.get", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}}}, {Name: "group.join", Use: "join", Short: "Join an open group", Phase: "phase5", Implemented: true, Handler: "group.join", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "reason", Type: "string", Usage: "Join reason"}}}, - {Name: "group.add", Use: "add", Short: "Add a member to a group", Phase: "phase5", Implemented: true, Handler: "group.add", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "member", Type: "string", Usage: "Member DID or handle", Required: true}, {Name: "role", Type: "string", Usage: "Member role", Default: "member"}}}, + {Name: "group.add", Use: "add", Short: "Add a member to a group", Phase: "phase5", Implemented: true, Handler: "group.add", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "member", Type: "string", Usage: "Member DID or handle", Required: true}, {Name: "role", Type: "string", Usage: "Member role", Default: "member"}, {Name: "e2ee", Type: "bool", Usage: "Force group E2EE add-member orchestration when cache is unavailable"}}}, {Name: "group.remove", Use: "remove", Short: "Remove a member from a group", Aliases: []string{"kick"}, Phase: "phase5", Implemented: true, Handler: "group.remove", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "member", Type: "string", Usage: "Member DID or handle", Required: true}, {Name: "reason", Type: "string", Usage: "Removal reason"}}}, {Name: "group.leave", Use: "leave", Short: "Leave a group", Phase: "phase5", Implemented: true, Handler: "group.leave", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}}}, {Name: "group.update", Use: "update", Short: "Update group profile or policy", Phase: "phase5", Implemented: true, Handler: "group.update", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "name", Type: "string", Usage: "New group display name"}, {Name: "description", Type: "string", Usage: "New group description"}, {Name: "discoverability", Type: "string", Usage: "Discoverability mode"}, {Name: "admission-mode", Type: "string", Usage: "Admission mode"}, {Name: "slug", Type: "string", Usage: "New group slug"}, {Name: "goal", Type: "string", Usage: "New group goal"}, {Name: "rules", Type: "string", Usage: "New group rules"}, {Name: "message-prompt", Type: "string", Usage: "New group prompt"}, {Name: "doc-url", Type: "string", Usage: "New group document URL"}, {Name: "attachments-allowed", Type: "bool", Usage: "Allow attachments"}, {Name: "max-members", Type: "string", Usage: "Maximum group members"}, {Name: "member-max-messages", Type: "int", Usage: "Per-member message limit"}, {Name: "member-max-total-chars", Type: "int", Usage: "Per-member total char limit"}}}, diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index c04810d..e504fe2 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -10,6 +10,7 @@ import ( "github.com/agentconnect/awiki-cli/internal/buildinfo" "github.com/agentconnect/awiki-cli/internal/config" "github.com/agentconnect/awiki-cli/internal/identity" + "github.com/agentconnect/awiki-cli/internal/message" runtimecfg "github.com/agentconnect/awiki-cli/internal/runtime" listenerrt "github.com/agentconnect/awiki-cli/internal/runtime/listener" "github.com/agentconnect/awiki-cli/internal/store" @@ -45,6 +46,7 @@ func Run(resolved *config.Resolved) Report { runtimeCheck(resolved), identityStoreCheck(resolved), sqliteCheck(resolved), + anpMLSCheck(resolved), upgradeStateCheck(resolved), legacyCheck(resolved), } @@ -70,6 +72,36 @@ func Run(resolved *config.Resolved) Report { return Report{Checks: checks, Summary: summary, Counts: counts} } +func anpMLSCheck(resolved *config.Resolved) Check { + provider := message.NewDefaultMLSExecProvider(resolved) + binary, err := provider.ResolveBinaryPath() + status := "ok" + summary := "anp-mls binary is available for group E2EE operations" + if err != nil { + status = "info" + summary = "anp-mls binary not found; plain messaging is unaffected, but group E2EE commands will fail" + } + return Check{ + Name: "anp_mls", + Status: status, + Summary: summary, + Details: map[string]any{ + "binary": binary, + "data_dir": provider.DataDir, + "env_override": message.ANPMLSBinaryEnv, + "plain_unaffected": true, + "error": errorString(err), + }, + } +} + +func errorString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + func buildCheck(resolved *config.Resolved) Check { info := buildinfo.Current() status := "ok" diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go index 4b3875f..b4b1990 100644 --- a/internal/doctor/doctor_test.go +++ b/internal/doctor/doctor_test.go @@ -42,7 +42,7 @@ func TestRunReturnsStableReportContracts(t *testing.T) { OK: 4, Warn: 2, Error: 0, - Info: 3, + Info: 4, }, wantStatuses: map[string]string{ "build": "ok", @@ -52,6 +52,7 @@ func TestRunReturnsStableReportContracts(t *testing.T) { "runtime": "ok", "identity_store": "warn", "sqlite": "info", + "anp_mls": "info", "workspace_upgrade": "ok", "legacy_paths": "info", }, @@ -120,7 +121,7 @@ INSERT INTO contact_handle_bindings ( OK: 8, Warn: 0, Error: 0, - Info: 1, + Info: 2, }, wantStatuses: map[string]string{ "build": "ok", @@ -130,6 +131,7 @@ INSERT INTO contact_handle_bindings ( "runtime": "ok", "identity_store": "ok", "sqlite": "ok", + "anp_mls": "info", "workspace_upgrade": "ok", "legacy_paths": "info", }, @@ -176,7 +178,7 @@ INSERT INTO contact_handle_bindings ( OK: 2, Warn: 2, Error: 2, - Info: 3, + Info: 4, }, wantStatuses: map[string]string{ "build": "ok", @@ -186,6 +188,7 @@ INSERT INTO contact_handle_bindings ( "runtime": "ok", "identity_store": "warn", "sqlite": "info", + "anp_mls": "info", "workspace_upgrade": "warn", "legacy_paths": "info", }, diff --git a/internal/message/group_e2ee_provider.go b/internal/message/group_e2ee_provider.go index 8035b91..d4be87b 100644 --- a/internal/message/group_e2ee_provider.go +++ b/internal/message/group_e2ee_provider.go @@ -5,8 +5,10 @@ import ( "context" "encoding/json" "fmt" + "os" "os/exec" "path/filepath" + "strings" "time" appconfig "github.com/agentconnect/awiki-cli/internal/config" @@ -17,6 +19,7 @@ const ( GroupE2EESecurityProfile = "group-e2ee" GroupE2EEContractArtifactMode = "contract-test" DefaultANPMLSBinary = "anp-mls" + ANPMLSBinaryEnv = "AWIKI_ANP_MLS_BINARY" ) type MLSRequest struct { @@ -67,9 +70,8 @@ type MLSExecProvider struct { func NewDefaultMLSExecProvider(resolved *appconfig.Resolved) MLSExecProvider { return MLSExecProvider{ - BinaryPath: DefaultANPMLSBinary, - DataDir: DefaultMLSDataDir(resolved), - Timeout: 15 * time.Second, + DataDir: DefaultMLSDataDir(resolved), + Timeout: 15 * time.Second, } } @@ -80,11 +82,40 @@ func DefaultMLSDataDir(resolved *appconfig.Resolved) string { return filepath.Join(resolved.Paths.WorkspaceHomeDir, "mls") } -func (p MLSExecProvider) Call(ctx context.Context, domain string, action string, req MLSRequest) (*MLSResponse, error) { - binary := p.BinaryPath - if binary == "" { - binary = DefaultANPMLSBinary +func (p MLSExecProvider) ResolveBinaryPath() (string, error) { + candidates := make([]string, 0, 3) + if envPath := strings.TrimSpace(os.Getenv(ANPMLSBinaryEnv)); envPath != "" { + candidates = append(candidates, envPath) + } + if injected := strings.TrimSpace(p.BinaryPath); injected != "" { + candidates = append(candidates, injected) + } + candidates = append(candidates, DefaultANPMLSBinary) + + seen := map[string]struct{}{} + for _, candidate := range candidates { + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + if filepath.IsAbs(candidate) || strings.ContainsRune(candidate, filepath.Separator) { + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate, nil + } + continue + } + if resolved, err := exec.LookPath(candidate); err == nil { + return resolved, nil + } } + return "", fmt.Errorf( + "unable to locate anp-mls binary (checked %s, injected path, then PATH). Set %s to an absolute anp-mls path, build/install anp-mls, or run `awiki-cli doctor` for diagnostics", + ANPMLSBinaryEnv, + ANPMLSBinaryEnv, + ) +} + +func (p MLSExecProvider) Call(ctx context.Context, domain string, action string, req MLSRequest) (*MLSResponse, error) { timeout := p.Timeout if timeout <= 0 { timeout = 15 * time.Second @@ -93,10 +124,23 @@ func (p MLSExecProvider) Call(ctx context.Context, domain string, action string, if runner == nil { runner = OSMLSCommandRunner{} } + binary := strings.TrimSpace(p.BinaryPath) + if binary == "" || p.Runner == nil { + resolvedBinary, err := p.ResolveBinaryPath() + if err != nil { + return nil, err + } + binary = resolvedBinary + } body, err := json.Marshal(req) if err != nil { return nil, err } + if p.DataDir != "" { + if err := os.MkdirAll(p.DataDir, 0o700); err != nil { + return nil, fmt.Errorf("prepare anp-mls data dir %s: %w", p.DataDir, err) + } + } ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() args := []string{domain, action, "--json-in", "-"} @@ -119,3 +163,59 @@ func (p MLSExecProvider) Call(ctx context.Context, domain string, action string, } return &resp, nil } + +func (p MLSExecProvider) GenerateKeyPackage(ctx context.Context, req MLSRequest) (map[string]any, error) { + resp, err := p.Call(ctx, "key-package", "generate", req) + if err != nil { + return nil, err + } + return resp.Result, nil +} + +func (p MLSExecProvider) CreateGroup(ctx context.Context, req MLSRequest) (map[string]any, error) { + resp, err := p.Call(ctx, "group", "create", req) + if err != nil { + return nil, err + } + return resp.Result, nil +} + +func (p MLSExecProvider) AddMember(ctx context.Context, req MLSRequest) (map[string]any, error) { + resp, err := p.Call(ctx, "group", "add-member", req) + if err != nil { + return nil, err + } + return resp.Result, nil +} + +func (p MLSExecProvider) ProcessWelcome(ctx context.Context, req MLSRequest) (map[string]any, error) { + resp, err := p.Call(ctx, "welcome", "process", req) + if err != nil { + return nil, err + } + return resp.Result, nil +} + +func (p MLSExecProvider) Encrypt(ctx context.Context, req MLSRequest) (map[string]any, error) { + resp, err := p.Call(ctx, "message", "encrypt", req) + if err != nil { + return nil, err + } + return resp.Result, nil +} + +func (p MLSExecProvider) Decrypt(ctx context.Context, req MLSRequest) (map[string]any, error) { + resp, err := p.Call(ctx, "message", "decrypt", req) + if err != nil { + return nil, err + } + return resp.Result, nil +} + +func (p MLSExecProvider) Status(ctx context.Context, req MLSRequest) (map[string]any, error) { + resp, err := p.Call(ctx, "group", "status", req) + if err != nil { + return nil, err + } + return resp.Result, nil +} diff --git a/internal/message/group_e2ee_provider_test.go b/internal/message/group_e2ee_provider_test.go index 1018b3b..8003668 100644 --- a/internal/message/group_e2ee_provider_test.go +++ b/internal/message/group_e2ee_provider_test.go @@ -3,6 +3,8 @@ package message import ( "context" "encoding/json" + "os" + "path/filepath" "strings" "testing" ) @@ -12,6 +14,53 @@ type recordingMLSRunner struct { stdin []byte } +func TestMLSExecProviderBinaryDiscoveryOrder(t *testing.T) { + t.Setenv(ANPMLSBinaryEnv, "") + pathDir := t.TempDir() + pathBinary := filepath.Join(pathDir, "anp-mls") + if err := os.WriteFile(pathBinary, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", pathDir) + + injected := filepath.Join(t.TempDir(), "runtime-anp-mls") + if err := os.WriteFile(injected, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + provider := MLSExecProvider{BinaryPath: injected} + got, err := provider.ResolveBinaryPath() + if err != nil { + t.Fatal(err) + } + if got != injected { + t.Fatalf("ResolveBinaryPath injected = %q, want %q", got, injected) + } + + envBinary := filepath.Join(t.TempDir(), "env-anp-mls") + if err := os.WriteFile(envBinary, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv(ANPMLSBinaryEnv, envBinary) + got, err = provider.ResolveBinaryPath() + if err != nil { + t.Fatal(err) + } + if got != envBinary { + t.Fatalf("ResolveBinaryPath env = %q, want %q", got, envBinary) + } + + t.Setenv(ANPMLSBinaryEnv, "") + provider.BinaryPath = "" + got, err = provider.ResolveBinaryPath() + if err != nil { + t.Fatal(err) + } + if got != pathBinary { + t.Fatalf("ResolveBinaryPath PATH = %q, want %q", got, pathBinary) + } +} + func (r *recordingMLSRunner) Run(_ context.Context, _ string, args []string, stdin []byte) ([]byte, []byte, error) { r.args = append([]string(nil), args...) r.stdin = append([]byte(nil), stdin...) @@ -24,8 +73,12 @@ func TestMLSExecProviderPassesPlaintextOnStdinNotArgv(t *testing.T) { _, err := provider.Call(context.Background(), "message", "encrypt", MLSRequest{ APIVersion: "anp-mls/v1", RequestID: "req-1", + AgentDID: "did:wba:example.com:users:alice:e1", + DeviceID: "device-1", ContractTestEnabled: true, Params: map[string]any{ + "agent_did": "did:wba:example.com:users:alice:e1", + "device_id": "device-1", "application_plaintext": map[string]any{"text": "super secret"}, }, }) @@ -45,6 +98,12 @@ func TestMLSExecProviderPassesPlaintextOnStdinNotArgv(t *testing.T) { if !req.ContractTestEnabled { t.Fatal("contract test flag not preserved") } + if req.AgentDID == "" || req.Params["agent_did"] != req.AgentDID { + t.Fatalf("agent_did not preserved in envelope and params: %#v", req) + } + if req.DeviceID == "" || req.Params["device_id"] != req.DeviceID { + t.Fatalf("device_id not preserved in envelope and params: %#v", req) + } if got := runner.args[len(runner.args)-2]; got != "--data-dir" { t.Fatalf("args missing --data-dir before final value: %#v", runner.args) } diff --git a/internal/message/group_e2ee_service.go b/internal/message/group_e2ee_service.go new file mode 100644 index 0000000..38d8941 --- /dev/null +++ b/internal/message/group_e2ee_service.go @@ -0,0 +1,344 @@ +package message + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/agentconnect/awiki-cli/internal/identity" + "github.com/agentconnect/awiki-cli/internal/store" +) + +func (s *Service) publishGroupE2EEKeyPackage(ctx context.Context, record *identity.StoredIdentity, deviceID string, contractTest bool) (map[string]any, map[string]any, error) { + provider := s.groupMLSProvider() + if strings.TrimSpace(deviceID) == "" { + deviceID = "default" + } + packageResult, err := provider.GenerateKeyPackage(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-key-package-" + generateOperationID(), + AgentDID: record.DID, + DeviceID: deviceID, + ContractTestEnabled: contractTest, + Params: map[string]any{ + "agent_did": record.DID, + "device_id": deviceID, + "owner_did": record.DID, + }, + }) + if err != nil { + return nil, nil, err + } + transport, _, err := s.httpTransport(record) + if err != nil { + return packageResult, nil, err + } + published, err := transport.PublishGroupE2EEKeyPackage(ctx, packageResult) + if err != nil { + return packageResult, nil, err + } + return packageResult, published, nil +} + +func (s *Service) PublishGroupE2EEKeyPackage(ctx context.Context, identityName string, deviceID string, contractTest bool) (*CommandResult, error) { + record, err := s.requireActiveIdentity(identityName) + if err != nil { + return nil, err + } + packageResult, published, err := s.publishGroupE2EEKeyPackage(ctx, record, deviceID, contractTest) + if err != nil { + return nil, err + } + return &CommandResult{ + Data: map[string]any{ + "mls": packageResult, + "published": published, + }, + Summary: "Published group E2EE KeyPackage", + }, nil +} + +func (s *Service) createGroupE2EE(ctx context.Context, record *identity.StoredIdentity, groupDID string) (map[string]any, []string) { + provider := s.groupMLSProvider() + mlsHead, err := provider.CreateGroup(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-create-" + generateOperationID(), + AgentDID: record.DID, + DeviceID: "default", + Params: map[string]any{ + "agent_did": record.DID, + "device_id": "default", + "group_did": groupDID, + }, + }) + if err != nil { + return nil, []string{fmt.Sprintf("Group E2EE MLS create failed: %v", err)} + } + transport, _, err := s.httpTransport(record) + if err != nil { + return map[string]any{"mls": mlsHead}, []string{fmt.Sprintf("Group E2EE service transport unavailable: %v", err)} + } + delivery, err := transport.CreateGroupE2EE(ctx, groupDID, mlsHead) + if err != nil { + return map[string]any{"mls": mlsHead}, []string{fmt.Sprintf("Group E2EE create delivery failed: %v", err)} + } + warnings := s.persistGroupE2EESummary(ctx, record, groupDID, mlsHead, delivery) + return map[string]any{"mls": mlsHead, "delivery": delivery}, warnings +} + +func (s *Service) addGroupMemberE2EE(ctx context.Context, record *identity.StoredIdentity, groupDID string, memberDID string) (map[string]any, []string) { + transport, _, err := s.httpTransport(record) + if err != nil { + return nil, []string{fmt.Sprintf("Group E2EE service transport unavailable: %v", err)} + } + leasedPackage, err := transport.GetGroupE2EEKeyPackage(ctx, memberDID) + if err != nil { + return nil, []string{fmt.Sprintf("Group E2EE member KeyPackage lookup failed: %v", err)} + } + provider := s.groupMLSProvider() + mlsHead, err := provider.AddMember(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-add-" + generateOperationID(), + AgentDID: record.DID, + DeviceID: "default", + Params: map[string]any{ + "agent_did": record.DID, + "device_id": "default", + "group_did": groupDID, + "member_did": memberDID, + "group_key_package": leasedPackage["group_key_package"], + "key_package_id": leasedPackage["key_package_id"], + "target_key_package": leasedPackage, + }, + }) + if err != nil { + return map[string]any{"leased_key_package": redactedKeyPackageSummary(leasedPackage)}, []string{fmt.Sprintf("Group E2EE MLS add-member failed: %v", err)} + } + if keyPackageID := stringFromAny(leasedPackage["key_package_id"]); keyPackageID != "" { + mlsHead["key_package_id"] = keyPackageID + } + delivery, err := transport.AddGroupE2EE(ctx, groupDID, memberDID, mlsHead) + if err != nil { + return map[string]any{"mls": mlsHead, "leased_key_package": redactedKeyPackageSummary(leasedPackage)}, []string{fmt.Sprintf("Group E2EE add delivery failed: %v", err)} + } + warnings := s.persistGroupE2EESummary(ctx, record, groupDID, mlsHead, delivery) + return map[string]any{"mls": mlsHead, "delivery": delivery, "leased_key_package": redactedKeyPackageSummary(leasedPackage)}, warnings +} + +func (s *Service) sendGroupE2EE(ctx context.Context, record *identity.StoredIdentity, request SendRequest) (*CommandResult, error) { + provider := s.groupMLSProvider() + groupStateRef := s.localGroupStateRef(ctx, record, request.Group) + encryptResult, err := provider.Encrypt(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-encrypt-" + generateOperationID(), + AgentDID: record.DID, + DeviceID: "default", + Params: map[string]any{ + "agent_did": record.DID, + "device_id": "default", + "group_did": request.Group, + "group_state_ref": groupStateRef, + "message_type": request.MessageType, + "application_plaintext": map[string]any{ + "application_content_type": contentTypeForMessageType(request.MessageType), + "text": request.Text, + }, + }, + }) + if err != nil { + return nil, err + } + cipher, _ := encryptResult["group_cipher_object"].(map[string]any) + if len(cipher) == 0 { + return nil, fmt.Errorf("anp-mls encrypt response missing group_cipher_object") + } + transport, warnings, err := s.httpTransport(record) + if err != nil { + return nil, err + } + delivery, err := transport.SendGroupE2EE(ctx, request.Group, cipher) + if err != nil { + return nil, err + } + return s.persistGroupE2EESendResult(ctx, record, request, delivery, encryptResult, warnings) +} + +func (s *Service) maybeDecryptGroupMessages(ctx context.Context, record *identity.StoredIdentity, groupDID string, raw map[string]any) ([]string, map[string]any) { + messages := messagesFromResult(raw["messages"]) + if len(messages) == 0 { + return nil, raw + } + provider := s.groupMLSProvider() + warnings := make([]string, 0) + for _, item := range messages { + cipher := groupCipherObjectFromMessage(item) + if len(cipher) == 0 { + continue + } + plain, err := provider.Decrypt(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-decrypt-" + generateOperationID(), + AgentDID: record.DID, + DeviceID: "default", + Params: map[string]any{ + "agent_did": record.DID, + "device_id": "default", + "group_did": groupDID, + "group_cipher_object": cipher, + "private_message_b64u": cipher["private_message_b64u"], + }, + }) + if err != nil { + warnings = append(warnings, fmt.Sprintf("Group E2EE decrypt failed for message %s: %v", stringFromAny(item["id"]), err)) + continue + } + if appPlaintext, ok := plain["application_plaintext"].(map[string]any); ok { + item["content"] = defaultString(stringFromAny(appPlaintext["text"]), metadataString(appPlaintext)) + item["content_type"] = defaultString(stringFromAny(appPlaintext["application_content_type"]), "text/plain") + item["decrypted"] = true + } + } + raw["messages"] = messages + return compactWarnings(warnings), raw +} + +func (s *Service) persistGroupE2EESummary(ctx context.Context, record *identity.StoredIdentity, groupDID string, mls map[string]any, delivery map[string]any) []string { + db, err := store.Open(s.resolved.Paths) + if err != nil { + return []string{fmt.Sprintf("Failed to open local store for group E2EE summary: %v", err)} + } + defer db.Close() + if err := store.EnsureSchema(ctx, db); err != nil { + return []string{fmt.Sprintf("Failed to ensure local schema for group E2EE summary: %v", err)} + } + metadata := map[string]any{ + "message_security_profile": GroupE2EESecurityProfile, + "group_e2ee": map[string]any{ + "crypto_group_id_b64u": stringFromAny(firstNonNil(mls["crypto_group_id_b64u"], delivery["crypto_group_id_b64u"])), + "epoch": stringFromAny(firstNonNil(mls["epoch"], delivery["epoch"])), + "epoch_authenticator": stringFromAny(firstNonNil(mls["epoch_authenticator"], mls["epoch_authenticator_b64u"], delivery["epoch_authenticator"])), + "suite": stringFromAny(firstNonNil(mls["suite"], delivery["suite"])), + "updated_at": stringFromAny(delivery["updated_at"]), + "operation_id": stringFromAny(delivery["operation_id"]), + }, + } + if err := store.UpsertGroup(ctx, db, store.GroupRecord{ + OwnerDID: record.DID, + GroupID: groupStorageKey(groupDID), + GroupDID: groupDID, + MembershipStatus: "active", + Metadata: metadataString(metadata), + CredentialName: record.IdentityName, + }); err != nil { + return []string{fmt.Sprintf("Failed to persist group E2EE summary: %v", err)} + } + return nil +} + +func (s *Service) persistGroupE2EESendResult(ctx context.Context, record *identity.StoredIdentity, request SendRequest, result *groupSendResult, encryptResult map[string]any, warnings []string) (*CommandResult, error) { + commandResult, err := s.persistGroupSendResult(ctx, record, request, result, warnings, "http") + if err != nil { + return nil, err + } + if message, ok := commandResult.Data["message"].(map[string]any); ok { + message["secure"] = true + message["security_profile"] = GroupE2EESecurityProfile + } + commandResult.Data["e2ee"] = map[string]any{ + "encrypted": true, + "group_state_ref": groupStateRefFromCipher(encryptResult), + "cipher_object_sent": true, + } + commandResult.Summary = fmt.Sprintf("Sent a group %s message with group E2EE", request.MessageType) + return commandResult, nil +} + +func (s *Service) localGroupStateRef(ctx context.Context, record *identity.StoredIdentity, groupDID string) map[string]any { + ref := map[string]any{"group_did": groupDID} + snapshot, err := s.readCachedGroupSnapshot(ctx, record, groupDID) + if err != nil { + return ref + } + if version := stringFromAny(snapshot["group_state_version"]); version != "" { + ref["group_state_version"] = version + } + metadata := decodeMetadataMap(snapshot["metadata"]) + if e2ee, ok := metadata["group_e2ee"].(map[string]any); ok { + if epoch := stringFromAny(e2ee["epoch"]); epoch != "" { + ref["epoch"] = epoch + } + } + return ref +} + +func groupRequestUsesE2EE(request GroupCreateRequest) bool { + return request.E2EE || strings.TrimSpace(request.MessageSecurityProfile) == GroupE2EESecurityProfile +} + +func groupSnapshotUsesE2EE(snapshot map[string]any) bool { + if len(snapshot) == 0 { + return false + } + if stringFromAny(snapshot["message_security_profile"]) == GroupE2EESecurityProfile { + return true + } + if policy, ok := snapshot["group_policy"].(map[string]any); ok { + if stringFromAny(policy["message_security_profile"]) == GroupE2EESecurityProfile { + return true + } + } + metadata := decodeMetadataMap(snapshot["metadata"]) + if stringFromAny(metadata["message_security_profile"]) == GroupE2EESecurityProfile { + return true + } + return false +} + +func decodeMetadataMap(value any) map[string]any { + text := stringFromAny(value) + if text == "" { + if typed, ok := value.(map[string]any); ok { + return typed + } + return nil + } + var result map[string]any + if err := json.Unmarshal([]byte(text), &result); err != nil { + return nil + } + return result +} + +func groupCipherObjectFromMessage(item map[string]any) map[string]any { + for _, key := range []string{"group_cipher_object", "content"} { + if cipher, ok := item[key].(map[string]any); ok { + if nested, ok := cipher["group_cipher_object"].(map[string]any); ok { + return nested + } + if _, ok := cipher["private_message_b64u"]; ok { + return cipher + } + } + } + body, _ := item["body"].(map[string]any) + if cipher, ok := body["group_cipher_object"].(map[string]any); ok { + return cipher + } + return nil +} + +func groupStateRefFromCipher(encryptResult map[string]any) map[string]any { + cipher, _ := encryptResult["group_cipher_object"].(map[string]any) + ref, _ := cipher["group_state_ref"].(map[string]any) + return ref +} + +func redactedKeyPackageSummary(raw map[string]any) map[string]any { + return map[string]any{ + "target_did": raw["target_did"], + "key_package_id": raw["key_package_id"], + "leased": true, + "private_material": false, + } +} diff --git a/internal/message/group_service.go b/internal/message/group_service.go index f1b3197..ce863f2 100644 --- a/internal/message/group_service.go +++ b/internal/message/group_service.go @@ -34,15 +34,24 @@ func (s *Service) CreateGroup(ctx context.Context, request GroupCreateRequest) ( } groupDID := stringFromAny(result["group_did"]) warnings = append(warnings, s.syncGroupState(ctx, record, groupDID, true)...) + var e2eeResult map[string]any + if groupRequestUsesE2EE(request) { + e2eeCandidate, e2eeWarnings := s.createGroupE2EE(ctx, record, groupDID) + e2eeResult, warnings = appendE2EEResult(warnings, e2eeCandidate, e2eeWarnings) + } snapshot, _ := s.readCachedGroupSnapshot(ctx, record, groupDID) members, _ := s.readCachedGroupMembers(ctx, record, groupDID, 100) + data := map[string]any{ + "group": snapshot, + "members": members, + "delivery": result, + "source": groupControlSource(result), + } + if e2eeResult != nil { + data["e2ee"] = e2eeResult + } return &CommandResult{ - Data: map[string]any{ - "group": snapshot, - "members": members, - "delivery": result, - "source": groupControlSource(result), - }, + Data: data, Summary: fmt.Sprintf("Created group %s", groupDID), Warnings: compactWarnings(warnings), }, nil @@ -134,7 +143,16 @@ func (s *Service) mutateGroupMember(ctx context.Context, request GroupMemberRequ warnings = append(warnings, s.syncGroupState(ctx, record, request.Group, true)...) snapshot, _ := s.readCachedGroupSnapshot(ctx, record, request.Group) members, _ := s.readCachedGroupMembers(ctx, record, request.Group, 100) - return &CommandResult{Data: map[string]any{"group": snapshot, "members": members, "delivery": result, "member": map[string]any{"did": memberDID, "handle": memberHandle}}, Summary: fmt.Sprintf("Updated group membership via %s", action), Warnings: compactWarnings(warnings)}, nil + var e2eeResult map[string]any + if action == "add" && (request.E2EE || groupSnapshotUsesE2EE(snapshot)) { + e2eeCandidate, e2eeWarnings := s.addGroupMemberE2EE(ctx, record, request.Group, memberDID) + e2eeResult, warnings = appendE2EEResult(warnings, e2eeCandidate, e2eeWarnings) + } + data := map[string]any{"group": snapshot, "members": members, "delivery": result, "member": map[string]any{"did": memberDID, "handle": memberHandle}} + if e2eeResult != nil { + data["e2ee"] = e2eeResult + } + return &CommandResult{Data: data, Summary: fmt.Sprintf("Updated group membership via %s", action), Warnings: compactWarnings(warnings)}, nil } func (s *Service) LeaveGroup(ctx context.Context, request GroupLeaveRequest) (*CommandResult, error) { @@ -257,6 +275,11 @@ func (s *Service) GroupMessages(ctx context.Context, request GroupMessagesReques warnings = append(warnings, httpWarnings...) } warnings = append(warnings, s.persistGroupMessages(ctx, record, request.Group, result)...) + decryptWarnings, decryptedResult := s.maybeDecryptGroupMessages(ctx, record, request.Group, result) + warnings = append(warnings, decryptWarnings...) + if decryptedResult != nil { + result = decryptedResult + } messages, _ := s.readCachedGroupMessages(ctx, record, request.Group, request.Limit, request.Cursor) if len(messages) == 0 { messages = messagesFromResult(result["messages"]) @@ -273,12 +296,26 @@ func (s *Service) sendGroup(ctx context.Context, request SendRequest) (*CommandR return nil, ErrTextRequired } if request.SecureMode == "on" { - return nil, ErrSecureNotSupported + // For groups, --secure on selects the explicit group E2EE path when the + // cached group summary indicates group-e2ee. + record, err := s.requireActiveIdentity(request.IdentityName) + if err != nil { + return nil, err + } + snapshot, _ := s.readCachedGroupSnapshot(ctx, record, request.Group) + if !groupSnapshotUsesE2EE(snapshot) { + return nil, ErrSecureNotSupported + } + return s.sendGroupE2EE(ctx, record, request) } record, err := s.requireActiveIdentity(request.IdentityName) if err != nil { return nil, err } + snapshot, _ := s.readCachedGroupSnapshot(ctx, record, request.Group) + if groupSnapshotUsesE2EE(snapshot) { + return s.sendGroupE2EE(ctx, record, request) + } sourceMode := s.runtimeConfig().Mode transport, warnings, err := s.transportFor(record) if err != nil { @@ -303,6 +340,14 @@ func (s *Service) sendGroup(ctx context.Context, request SendRequest) (*CommandR return s.persistGroupSendResult(ctx, record, request, result, warnings, sourceMode) } +func appendE2EEResult(warnings []string, result map[string]any, e2eeWarnings []string) (map[string]any, []string) { + warnings = append(warnings, e2eeWarnings...) + if result == nil { + return nil, warnings + } + return result, warnings +} + func (s *Service) syncGroupState(ctx context.Context, record *identity.StoredIdentity, groupDID string, includeMembers bool) []string { if strings.TrimSpace(groupDID) == "" { return nil diff --git a/internal/message/group_wire.go b/internal/message/group_wire.go index a4eed03..69e6f00 100644 --- a/internal/message/group_wire.go +++ b/internal/message/group_wire.go @@ -24,6 +24,10 @@ func BuildGroupCreateRPCParams(record *identity.StoredIdentity, manager *identit if len(policy) == 0 { policy = buildGroupPolicyPatch("open-join", boolPtr(true), "500", nil, nil) } + if securityProfile := normalizedGroupSecurityProfile(request); securityProfile != "" { + policy["message_security_profile"] = securityProfile + policy["bootstrap_security_profile"] = securityProfile + } meta := map[string]any{ "anp_version": "1.0", "profile": "anp.group.base.v1", @@ -172,6 +176,104 @@ func BuildGroupSendRPCParams(record *identity.StoredIdentity, manager *identity. }, nil } +func BuildGroupE2EECreateRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, mlsHead map[string]any) (map[string]any, error) { + return buildGroupE2EERPCParams(record, manager, groupDID, "group.e2ee.create", e2eeHeadBody(groupDID, "", mlsHead), "") +} + +func BuildGroupE2EEAddRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, memberDID string, mlsHead map[string]any) (map[string]any, error) { + body := e2eeHeadBody(groupDID, memberDID, mlsHead) + if value, ok := mlsHead["welcome_b64u"]; ok { + body["welcome_b64u"] = value + } + if value, ok := mlsHead["commit_b64u"]; ok { + body["commit_b64u"] = value + } + if value, ok := mlsHead["ratchet_tree_b64u"]; ok { + body["ratchet_tree_b64u"] = value + } + if value, ok := mlsHead["key_package_id"]; ok { + body["key_package_id"] = value + body["subject_key_package_id"] = value + } + return buildGroupE2EERPCParams(record, manager, groupDID, "group.e2ee.add", body, "") +} + +func BuildGroupE2EESendRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, cipher map[string]any) (map[string]any, error) { + return buildGroupE2EERPCParams(record, manager, groupDID, "group.e2ee.send", map[string]any{"group_cipher_object": cipher}, "application/anp-group-cipher+json") +} + +func BuildGroupE2EEPublishKeyPackageRPCParams(record *identity.StoredIdentity, manager *identity.Manager, serviceDID string, packageResult map[string]any) (map[string]any, error) { + serviceDID = strings.TrimSpace(serviceDID) + if serviceDID == "" { + return nil, fmt.Errorf("message service did is required") + } + auth, err := newAuthContext(record, manager) + if err != nil { + return nil, err + } + groupKeyPackage, _ := packageResult["group_key_package"].(map[string]any) + if len(groupKeyPackage) == 0 { + return nil, fmt.Errorf("group_key_package is required") + } + meta := map[string]any{ + "anp_version": "1.0", + "profile": GroupE2EEProfile, + "security_profile": GroupE2EESecurityProfile, + "sender_did": record.DID, + "target": map[string]any{"kind": "service", "did": serviceDID}, + "operation_id": "op-" + generateOperationID(), + "created_at": nowRFC3339(), + "content_type": "application/json", + } + body := map[string]any{"group_key_package": groupKeyPackage} + payload := signedPayload{Method: "group.e2ee.publish_key_package", Meta: meta, Body: body} + originProof, err := buildOriginProof(auth, payload) + if err != nil { + return nil, err + } + return map[string]any{ + "meta": meta, + "auth": map[string]any{"scheme": OriginProofScheme, "origin_proof": originProof}, + "body": body, + }, nil +} + +func BuildGroupE2EEGetKeyPackageRPCParams(record *identity.StoredIdentity, manager *identity.Manager, serviceDID string, targetDID string) (map[string]any, error) { + serviceDID = strings.TrimSpace(serviceDID) + targetDID = strings.TrimSpace(targetDID) + if serviceDID == "" { + return nil, fmt.Errorf("message service did is required") + } + if targetDID == "" { + return nil, ErrMemberRequired + } + auth, err := newAuthContext(record, manager) + if err != nil { + return nil, err + } + meta := map[string]any{ + "anp_version": "1.0", + "profile": GroupE2EEProfile, + "security_profile": GroupE2EESecurityProfile, + "sender_did": record.DID, + "target": map[string]any{"kind": "service", "did": serviceDID}, + "operation_id": "op-" + generateOperationID(), + "created_at": nowRFC3339(), + "content_type": "application/json", + } + body := map[string]any{"target_did": targetDID} + payload := signedPayload{Method: "group.e2ee.get_key_package", Meta: meta, Body: body} + originProof, err := buildOriginProof(auth, payload) + if err != nil { + return nil, err + } + return map[string]any{ + "meta": meta, + "auth": map[string]any{"scheme": OriginProofScheme, "origin_proof": originProof}, + "body": body, + }, nil +} + func BuildGroupGetRPCParams(record *identity.StoredIdentity, request GroupGetRequest) (map[string]any, error) { groupDID := strings.TrimSpace(request.Group) if groupDID == "" { @@ -348,6 +450,77 @@ func buildGroupPolicyPatch(admissionMode string, attachmentsAllowed *bool, maxMe return patch } +func normalizedGroupSecurityProfile(request GroupCreateRequest) string { + if request.E2EE { + return GroupE2EESecurityProfile + } + switch strings.TrimSpace(request.MessageSecurityProfile) { + case "", "transport-protected": + return "" + case GroupE2EESecurityProfile: + return GroupE2EESecurityProfile + default: + return strings.TrimSpace(request.MessageSecurityProfile) + } +} + +func buildGroupE2EERPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, method string, body map[string]any, contentType string) (map[string]any, error) { + groupDID = strings.TrimSpace(groupDID) + if groupDID == "" { + return nil, ErrGroupRequired + } + auth, err := newAuthContext(record, manager) + if err != nil { + return nil, err + } + if contentType == "" { + contentType = "application/json" + } + meta := map[string]any{ + "anp_version": "1.0", + "profile": GroupE2EEProfile, + "security_profile": GroupE2EESecurityProfile, + "sender_did": record.DID, + "target": map[string]any{"kind": "group", "did": groupDID}, + "operation_id": "op-" + generateOperationID(), + "created_at": nowRFC3339(), + "content_type": contentType, + } + if method == "group.e2ee.send" { + meta["message_id"] = "msg-" + generateOperationID() + } + payload := signedPayload{Method: method, Meta: meta, Body: body} + originProof, err := buildOriginProof(auth, payload) + if err != nil { + return nil, err + } + return map[string]any{ + "meta": meta, + "auth": map[string]any{"scheme": OriginProofScheme, "origin_proof": originProof}, + "body": body, + }, nil +} + +func e2eeHeadBody(groupDID string, memberDID string, mlsHead map[string]any) map[string]any { + body := map[string]any{ + "group_state_ref": map[string]any{ + "group_did": groupDID, + }, + } + for _, key := range []string{"crypto_group_id_b64u", "epoch", "epoch_authenticator", "epoch_authenticator_b64u", "suite", "last_handshake_digest"} { + if value, ok := mlsHead[key]; ok { + body[key] = value + } + } + if value, ok := body["epoch_authenticator_b64u"]; ok { + body["epoch_authenticator"] = value + } + if memberDID != "" { + body["subject_did"] = memberDID + } + return body +} + func boolPtr(value bool) *bool { return &value } diff --git a/internal/message/group_wire_test.go b/internal/message/group_wire_test.go index a176862..aa6bf80 100644 --- a/internal/message/group_wire_test.go +++ b/internal/message/group_wire_test.go @@ -138,6 +138,89 @@ func TestBuildGroupCreateRPCParamsAppliesDefaultPolicyContract(t *testing.T) { } } +func TestBuildGroupCreateRPCParamsAppliesGroupE2EEProfile(t *testing.T) { + t.Parallel() + + record := testStoredIdentity(t) + params, err := BuildGroupCreateRPCParams( + record, + nil, + "did:wba:awiki.ai:services:message:e1_service", + GroupCreateRequest{Name: "Encrypted Group", E2EE: true}, + ) + if err != nil { + t.Fatalf("BuildGroupCreateRPCParams() error = %v", err) + } + body := mustMapValue(t, params["body"], "params.body") + policy := mustMapValue(t, body["group_policy"], "body.group_policy") + if got := stringFromAny(policy["message_security_profile"]); got != GroupE2EESecurityProfile { + t.Fatalf("message_security_profile = %q, want %q", got, GroupE2EESecurityProfile) + } + if got := stringFromAny(policy["bootstrap_security_profile"]); got != GroupE2EESecurityProfile { + t.Fatalf("bootstrap_security_profile = %q, want %q", got, GroupE2EESecurityProfile) + } +} + +func TestBuildGroupE2EESendRPCParamsSendsOnlyOpaqueCipherObject(t *testing.T) { + t.Parallel() + + record := testStoredIdentity(t) + params, err := BuildGroupE2EESendRPCParams(record, nil, "did:wba:awiki.ai:groups:demo:e1_group", map[string]any{ + "crypto_group_id_b64u": "Y3J5cHRv", + "epoch": "1", + "private_message_b64u": "Y2lwaGVy", + "epoch_authenticator": "YXV0aA", + "group_state_ref": map[string]any{"group_did": "did:wba:awiki.ai:groups:demo:e1_group"}, + }) + if err != nil { + t.Fatalf("BuildGroupE2EESendRPCParams() error = %v", err) + } + meta := mustMapValue(t, params["meta"], "params.meta") + if got := stringFromAny(meta["profile"]); got != GroupE2EEProfile { + t.Fatalf("meta.profile = %q, want %q", got, GroupE2EEProfile) + } + if got := stringFromAny(meta["security_profile"]); got != GroupE2EESecurityProfile { + t.Fatalf("meta.security_profile = %q, want %q", got, GroupE2EESecurityProfile) + } + if got := stringFromAny(meta["content_type"]); got != "application/anp-group-cipher+json" { + t.Fatalf("meta.content_type = %q, want group cipher", got) + } + body := mustMapValue(t, params["body"], "params.body") + if _, ok := body["application_plaintext"]; ok { + t.Fatalf("plaintext leaked into E2EE send body: %#v", body) + } + if _, ok := body["group_cipher_object"]; !ok { + t.Fatalf("group_cipher_object missing: %#v", body) + } +} + +func TestBuildGroupE2EEAddRPCParamsIncludesConsumedKeyPackageID(t *testing.T) { + t.Parallel() + + record := testStoredIdentity(t) + params, err := BuildGroupE2EEAddRPCParams(record, nil, "did:wba:awiki.ai:groups:demo:e1_group", "did:wba:awiki.ai:user:bob:e1_bob", map[string]any{ + "crypto_group_id_b64u": "Y3J5cHRv", + "epoch": "2", + "epoch_authenticator": "YXV0aDI", + "welcome_b64u": "d2VsY29tZQ", + "commit_b64u": "Y29tbWl0", + "key_package_id": "kp-bob-1", + }) + if err != nil { + t.Fatalf("BuildGroupE2EEAddRPCParams() error = %v", err) + } + body := mustMapValue(t, params["body"], "params.body") + if got := stringFromAny(body["subject_did"]); got != "did:wba:awiki.ai:user:bob:e1_bob" { + t.Fatalf("subject_did = %q, want bob", got) + } + if got := stringFromAny(body["key_package_id"]); got != "kp-bob-1" { + t.Fatalf("key_package_id = %q, want leased id", got) + } + if got := stringFromAny(body["subject_key_package_id"]); got != "kp-bob-1" { + t.Fatalf("subject_key_package_id = %q, want leased id", got) + } +} + func TestBuildGroupMembersRPCParamsDefaultsLimitToHundred(t *testing.T) { t.Parallel() @@ -184,3 +267,21 @@ func TestBuildGroupMessagesRPCParamsDefaultsLimitToFifty(t *testing.T) { t.Fatalf("body.skip should be absent when skip is zero: %#v", body) } } + +func testStoredIdentity(t *testing.T) *identity.StoredIdentity { + t.Helper() + generated, err := identity.GenerateIdentity(identity.GenerateOptions{ + Hostname: "awiki.ai", + PathPrefix: []string{"user"}, + ProofDomain: "awiki.ai", + }) + if err != nil { + t.Fatalf("GenerateIdentity() error = %v", err) + } + return &identity.StoredIdentity{ + IdentityName: "alice", + DID: generated.DID, + DIDDocument: generated.DIDDocument, + Key1PrivatePEM: generated.Key1PrivatePEM, + } +} diff --git a/internal/message/http_client.go b/internal/message/http_client.go index 49efcd1..b712cac 100644 --- a/internal/message/http_client.go +++ b/internal/message/http_client.go @@ -151,6 +151,21 @@ func (t *HTTPTransport) SendGroup(ctx context.Context, request SendRequest) (*gr return &result, nil } +func (t *HTTPTransport) SendGroupE2EE(ctx context.Context, groupDID string, cipher map[string]any) (*groupSendResult, error) { + params, err := BuildGroupE2EESendRPCParams(t.auth.record, nil, groupDID, cipher) + if err != nil { + return nil, err + } + var result groupSendResult + if err := t.rpcCall(ctx, "group.e2ee.send", params, &result); err != nil { + return nil, err + } + if result.GroupDID == "" { + result.GroupDID = groupDID + } + return &result, nil +} + func (t *HTTPTransport) GetInbox(ctx context.Context, request InboxRequest) (map[string]any, error) { params := map[string]any{ "meta": map[string]any{ @@ -225,6 +240,46 @@ func (t *HTTPTransport) CreateGroup(ctx context.Context, request GroupCreateRequ return t.rpcMapCall(ctx, "group.create", params) } +func (t *HTTPTransport) PublishGroupE2EEKeyPackage(ctx context.Context, packageResult map[string]any) (map[string]any, error) { + serviceDID, err := t.GetMessageServiceDID(ctx) + if err != nil { + return nil, err + } + params, err := BuildGroupE2EEPublishKeyPackageRPCParams(t.auth.record, nil, serviceDID, packageResult) + if err != nil { + return nil, err + } + return t.rpcMapCall(ctx, "group.e2ee.publish_key_package", params) +} + +func (t *HTTPTransport) GetGroupE2EEKeyPackage(ctx context.Context, targetDID string) (map[string]any, error) { + serviceDID, err := t.GetMessageServiceDID(ctx) + if err != nil { + return nil, err + } + params, err := BuildGroupE2EEGetKeyPackageRPCParams(t.auth.record, nil, serviceDID, targetDID) + if err != nil { + return nil, err + } + return t.rpcMapCall(ctx, "group.e2ee.get_key_package", params) +} + +func (t *HTTPTransport) CreateGroupE2EE(ctx context.Context, groupDID string, mlsHead map[string]any) (map[string]any, error) { + params, err := BuildGroupE2EECreateRPCParams(t.auth.record, nil, groupDID, mlsHead) + if err != nil { + return nil, err + } + return t.rpcMapCall(ctx, "group.e2ee.create", params) +} + +func (t *HTTPTransport) AddGroupE2EE(ctx context.Context, groupDID string, memberDID string, mlsHead map[string]any) (map[string]any, error) { + params, err := BuildGroupE2EEAddRPCParams(t.auth.record, nil, groupDID, memberDID, mlsHead) + if err != nil { + return nil, err + } + return t.rpcMapCall(ctx, "group.e2ee.add", params) +} + func (t *HTTPTransport) GetGroupInfo(ctx context.Context, request GroupInfoRequest) (map[string]any, error) { params, err := BuildGroupGetInfoRPCParams(t.auth.record, request) if err != nil { diff --git a/internal/message/service.go b/internal/message/service.go index 526138b..1132002 100644 --- a/internal/message/service.go +++ b/internal/message/service.go @@ -19,9 +19,10 @@ import ( ) type Service struct { - resolved *appconfig.Resolved - manager *identity.Manager - remote *identity.RemoteClient + resolved *appconfig.Resolved + manager *identity.Manager + remote *identity.RemoteClient + mlsProvider *MLSExecProvider } func NewService(resolved *appconfig.Resolved) (*Service, error) { @@ -36,6 +37,13 @@ func NewService(resolved *appconfig.Resolved) (*Service, error) { }, nil } +func (s *Service) groupMLSProvider() MLSExecProvider { + if s != nil && s.mlsProvider != nil { + return *s.mlsProvider + } + return NewDefaultMLSExecProvider(s.resolved) +} + func (s *Service) Config() *appconfig.Resolved { return s.resolved } diff --git a/internal/message/types.go b/internal/message/types.go index c76d9f3..b545045 100644 --- a/internal/message/types.go +++ b/internal/message/types.go @@ -112,20 +112,22 @@ type directSendResult struct { } type GroupCreateRequest struct { - IdentityName string - Name string - Description string - Discoverability string - AdmissionMode string - Slug string - Goal string - Rules string - MessagePrompt string - DocURL string - AttachmentsAllowed *bool - MaxMembers string - MemberMaxMessages *int64 - MemberMaxTotalChars *int64 + IdentityName string + Name string + Description string + Discoverability string + AdmissionMode string + MessageSecurityProfile string + E2EE bool + Slug string + Goal string + Rules string + MessagePrompt string + DocURL string + AttachmentsAllowed *bool + MaxMembers string + MemberMaxMessages *int64 + MemberMaxTotalChars *int64 } type GroupGetRequest struct { @@ -152,6 +154,7 @@ type GroupMemberRequest struct { Member string Role string ReasonText string + E2EE bool } type GroupLeaveRequest struct { From f4a82fdae4780a92757ff05ac4a242f1848f1636 Mon Sep 17 00:00:00 2001 From: changshan Date: Sat, 2 May 2026 09:32:02 +0800 Subject: [PATCH 04/14] Keep provider-only fields out of group KeyPackage publish Task-8 focused E2E exposed that anp-mls may include device-scoped provider metadata in the generated KeyPackage result while message-service accepts a tighter public KeyPackage schema. The CLI now whitelists the service payload fields before signing and publishing, so device_id and any private provider-only fields stay local to anp-mls/CLI orchestration. Constraint: message-service rejects unsupported body.group_key_package fields with RPC 1003 Constraint: MLS private/provider metadata must not leak into service storage Rejected: Require message-service to accept all provider fields | unnecessarily broadens server storage contract and does not fix private-field leakage Confidence: high Scope-risk: narrow Directive: Keep group.e2ee.publish_key_package body limited to service-supported public KeyPackage fields Tested: go test ./internal/message -run 'TestBuildGroupE2EEPublishKeyPackageRPCParamsStripsProviderOnlyFields|TestBuildGroupE2EEAddRPCParamsIncludesConsumedKeyPackageID|TestBuildGroupE2EESendRPCParamsSendsOnlyOpaqueCipherObject|TestMLSExecProvider' -count=1 Tested: go test ./internal/message -count=1 Tested: go vet ./internal/message --- internal/message/group_wire.go | 21 ++++++++++++++++++ internal/message/group_wire_test.go | 34 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/internal/message/group_wire.go b/internal/message/group_wire.go index 69e6f00..fe65554 100644 --- a/internal/message/group_wire.go +++ b/internal/message/group_wire.go @@ -215,6 +215,7 @@ func BuildGroupE2EEPublishKeyPackageRPCParams(record *identity.StoredIdentity, m if len(groupKeyPackage) == 0 { return nil, fmt.Errorf("group_key_package is required") } + groupKeyPackage = sanitizeGroupKeyPackageForService(groupKeyPackage) meta := map[string]any{ "anp_version": "1.0", "profile": GroupE2EEProfile, @@ -274,6 +275,26 @@ func BuildGroupE2EEGetKeyPackageRPCParams(record *identity.StoredIdentity, manag }, nil } +func sanitizeGroupKeyPackageForService(input map[string]any) map[string]any { + allowed := map[string]struct{}{ + "owner_did": {}, + "key_package_id": {}, + "suite": {}, + "mls_key_package_b64u": {}, + "did_wba_binding": {}, + "expires_at": {}, + "non_cryptographic": {}, + "artifact_mode": {}, + } + output := make(map[string]any, len(input)) + for key, value := range input { + if _, ok := allowed[key]; ok { + output[key] = value + } + } + return output +} + func BuildGroupGetRPCParams(record *identity.StoredIdentity, request GroupGetRequest) (map[string]any, error) { groupDID := strings.TrimSpace(request.Group) if groupDID == "" { diff --git a/internal/message/group_wire_test.go b/internal/message/group_wire_test.go index aa6bf80..cba1d18 100644 --- a/internal/message/group_wire_test.go +++ b/internal/message/group_wire_test.go @@ -221,6 +221,40 @@ func TestBuildGroupE2EEAddRPCParamsIncludesConsumedKeyPackageID(t *testing.T) { } } +func TestBuildGroupE2EEPublishKeyPackageRPCParamsStripsProviderOnlyFields(t *testing.T) { + t.Parallel() + + record := testStoredIdentity(t) + params, err := BuildGroupE2EEPublishKeyPackageRPCParams(record, nil, "did:wba:awiki.ai:services:message:e1_service", map[string]any{ + "group_key_package": map[string]any{ + "owner_did": record.DID, + "key_package_id": "kp-bob-main", + "suite": "MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519", + "mls_key_package_b64u": "a3A", + "did_wba_binding": map[string]any{"agent_did": record.DID}, + "device_id": "bob-main", + "private_key_package_b64u": "must-not-leak", + }, + }) + if err != nil { + t.Fatalf("BuildGroupE2EEPublishKeyPackageRPCParams() error = %v", err) + } + body := mustMapValue(t, params["body"], "params.body") + groupKeyPackage := mustMapValue(t, body["group_key_package"], "body.group_key_package") + if _, ok := groupKeyPackage["device_id"]; ok { + t.Fatalf("device_id leaked into service KeyPackage payload: %#v", groupKeyPackage) + } + if _, ok := groupKeyPackage["private_key_package_b64u"]; ok { + t.Fatalf("private provider field leaked into service KeyPackage payload: %#v", groupKeyPackage) + } + if got := stringFromAny(groupKeyPackage["key_package_id"]); got != "kp-bob-main" { + t.Fatalf("key_package_id = %q, want kp-bob-main", got) + } + if _, ok := params["auth"]; !ok { + t.Fatalf("auth missing from publish params: %#v", params) + } +} + func TestBuildGroupMembersRPCParamsDefaultsLimitToHundred(t *testing.T) { t.Parallel() From 7c5841009ac43dbc9040f527d2e98cf18892ea32 Mon Sep 17 00:00:00 2001 From: changshan Date: Sat, 2 May 2026 22:53:27 +0800 Subject: [PATCH 05/14] Harden group E2EE helper readiness Doctor now verifies the anp-mls compatibility contract and reports MLS state health before reviewers try the group E2EE path. The release helper documents and stages the Rust binary without changing the pure-Go CLI boundary. Constraint: awiki-cli must stay pure Go/no CGO and invoke anp-mls through stdin/stdout Rejected: vendor Rust into the Go binary | would violate the pure-Go packaging boundary Confidence: high Scope-risk: narrow Tested: go test ./internal/message ./internal/doctor ./internal/cli Tested: go vet ./internal/message ./internal/doctor Tested: scripts/release/build-anp-mls.sh --dry-run --- CLAUDE.md | 2 + docs/installation.md | 25 +- internal/doctor/doctor.go | 255 ++++++++++++++++++- internal/doctor/doctor_test.go | 107 ++++++++ internal/message/group_e2ee_provider.go | 100 +++++++- internal/message/group_e2ee_provider_test.go | 43 ++++ scripts/release/build-anp-mls.sh | 93 +++++++ 7 files changed, 609 insertions(+), 16 deletions(-) create mode 100755 scripts/release/build-anp-mls.sh diff --git a/CLAUDE.md b/CLAUDE.md index 98c05ac..f5dad1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,6 +126,8 @@ **docs/plan/phase-0/audit-findings.md**: Phase 0 审计冲突与裁决。 **docs/plan/phase-0/adr-index.md**: Phase 0 ADR 索引。 +**scripts/release/build-anp-mls.sh**: 本地 release hardening 辅助脚本,从同级 `../anp/anp/rust` 构建 `anp-mls` 并 stage 到 `dist/anp-mls/-/`,用于 awiki-cli group E2EE helper 的打包或 `AWIKI_ANP_MLS_BINARY` 注入验证。 + ## 当前实现边界 ### 已实现 diff --git a/docs/installation.md b/docs/installation.md index 8328bc8..ccf3651 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -136,7 +136,7 @@ awiki-cli 默认采用单根目录工作区模型,默认路径如下: > - 备份快照 -### 3.3 `anp-mls` binary discovery +### 3.3 `anp-mls` binary discovery and release staging Group E2EE commands keep the Go CLI pure-Go/no-CGO by invoking the Rust `anp-mls` binary as a one-shot process. Discovery order is: @@ -146,6 +146,29 @@ Group E2EE commands keep the Go CLI pure-Go/no-CGO by invoking the Rust `anp-mls Plain direct/group messaging does not require this binary. `awiki-cli doctor` reports an informational `anp_mls` check when the binary is missing; group E2EE commands return an actionable remediation error. +`awiki-cli doctor` also probes compatibility with the stable machine-readable contract: + +```bash +anp-mls system version --json-in - +``` + +The response must include `api_version`, `binary_name`, `binary_version` or `build_version`, and `supported_commands`. The supported API for this CLI build is `anp-mls/v1`; a mismatch is reported as a warning with remediation instead of breaking plain messaging. + +For local release preparation, stage the Rust helper from the sibling ANP repository: + +```bash +scripts/release/build-anp-mls.sh --dry-run +scripts/release/build-anp-mls.sh +``` + +By default the script builds `../anp/anp/rust` with Cargo and copies `anp-mls` to `dist/anp-mls/-/anp-mls`. Release jobs can bundle that file beside the `awiki-cli` archive, or users can place it on `PATH`. If a deployment keeps the helper outside the archive, set: + +```bash +export AWIKI_ANP_MLS_BINARY=/absolute/path/to/anp-mls +``` + +The MLS private state directory remains `~/.awiki-cli/mls/` (or the current `AWIKI_CLI_WORKSPACE_HOME_DIR` equivalent). `doctor` reports the directory permissions plus `state.db` and `state.lock` status, including warnings when cached group-E2EE groups exist but MLS state is missing. + ### 3.2 config.yaml 配置文件位于 `~/.awiki-cli/config.yaml`。推荐先执行 `awiki-cli init` 自动创建最小配置;如需手动创建,可参考仓库根目录的 `config.template.yaml`,或直接使用下面的模板: diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index e504fe2..2185e42 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -2,10 +2,13 @@ package doctor import ( "context" + "database/sql" "errors" + "fmt" "os" "path/filepath" "strings" + "time" "github.com/agentconnect/awiki-cli/internal/buildinfo" "github.com/agentconnect/awiki-cli/internal/config" @@ -74,24 +77,248 @@ func Run(resolved *config.Resolved) Report { func anpMLSCheck(resolved *config.Resolved) Check { provider := message.NewDefaultMLSExecProvider(resolved) - binary, err := provider.ResolveBinaryPath() + binary, resolveErr := provider.ResolveBinaryPath() + state := inspectMLSState(resolved, provider.DataDir) + details := map[string]any{ + "binary": binary, + "data_dir": provider.DataDir, + "env_override": message.ANPMLSBinaryEnv, + "plain_unaffected": true, + "resolve_error": errorString(resolveErr), + "remediation": anpMLSRemediation(resolveErr, nil, nil, state), + "data_dir_status": state.DataDirStatus, + "data_dir_exists": state.DataDirExists, + "data_dir_error": state.DataDirError, + "state_db": state.StateDBPath, + "state_db_status": state.StateDBStatus, + "state_db_error": state.StateDBError, + "state_lock": state.StateLockPath, + "state_lock_status": state.StateLockStatus, + "state_lock_error": state.StateLockError, + "e2ee_group_count": state.E2EEGroupCount, + } status := "ok" - summary := "anp-mls binary is available for group E2EE operations" - if err != nil { + summary := "anp-mls binary and compatibility probe are ready for group E2EE operations" + if resolveErr != nil { status = "info" summary = "anp-mls binary not found; plain messaging is unaffected, but group E2EE commands will fail" + details["remediation"] = anpMLSRemediation(resolveErr, nil, nil, state) + return Check{Name: "anp_mls", Status: status, Summary: summary, Details: details} } - return Check{ - Name: "anp_mls", - Status: status, - Summary: summary, - Details: map[string]any{ - "binary": binary, - "data_dir": provider.DataDir, - "env_override": message.ANPMLSBinaryEnv, - "plain_unaffected": true, - "error": errorString(err), - }, + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + versionInfo, probeErr := provider.ProbeVersion(ctx) + details["version"] = versionInfo + details["probe_error"] = errorString(probeErr) + compatErr := anpMLSCompatibilityError(versionInfo) + details["compatibility_error"] = errorString(compatErr) + details["remediation"] = anpMLSRemediation(resolveErr, probeErr, compatErr, state) + if probeErr != nil { + status = "warn" + summary = "anp-mls binary is present but the version compatibility probe failed" + } else if compatErr != nil { + status = "warn" + summary = "anp-mls binary version is not compatible with this awiki-cli build" + } else if state.HasWarning() { + status = "warn" + summary = "anp-mls binary is compatible but MLS state needs attention" + } + return Check{Name: "anp_mls", Status: status, Summary: summary, Details: details} +} + +type mlsStateInspection struct { + DataDirExists bool + DataDirStatus string + DataDirError string + StateDBPath string + StateDBStatus string + StateDBError string + StateLockPath string + StateLockStatus string + StateLockError string + E2EEGroupCount int +} + +func (s mlsStateInspection) HasWarning() bool { + return strings.HasPrefix(s.DataDirStatus, "warn") || strings.HasPrefix(s.StateDBStatus, "warn") || strings.HasPrefix(s.StateLockStatus, "warn") +} + +func inspectMLSState(resolved *config.Resolved, dataDir string) mlsStateInspection { + state := mlsStateInspection{ + DataDirStatus: "missing", + StateDBPath: filepath.Join(dataDir, "state.db"), + StateDBStatus: "missing", + StateLockPath: filepath.Join(dataDir, "state.lock"), + StateLockStatus: "missing", + } + if strings.TrimSpace(dataDir) == "" { + state.DataDirStatus = "not_configured" + state.StateDBPath = "" + state.StateLockPath = "" + return state + } + state.E2EEGroupCount = cachedGroupE2EECount(resolved) + if info, err := os.Stat(dataDir); err != nil { + if os.IsNotExist(err) { + if state.E2EEGroupCount > 0 { + state.DataDirStatus = "warn_missing_with_cached_groups" + } else { + state.DataDirStatus = "missing" + } + } else { + state.DataDirStatus = "warn_stat_failed" + state.DataDirError = err.Error() + } + return state + } else if !info.IsDir() { + state.DataDirStatus = "warn_not_directory" + return state + } + state.DataDirExists = true + state.DataDirStatus = "ok" + if err := canReadDir(dataDir); err != nil { + state.DataDirStatus = "warn_not_readable" + state.DataDirError = err.Error() + } else if err := canWriteDir(dataDir); err != nil { + state.DataDirStatus = "warn_not_writable" + state.DataDirError = err.Error() + } + + if info, err := os.Stat(state.StateDBPath); err != nil { + if os.IsNotExist(err) { + if state.E2EEGroupCount > 0 { + state.StateDBStatus = "warn_missing_with_cached_groups" + } else { + state.StateDBStatus = "missing" + } + } else { + state.StateDBStatus = "warn_stat_failed" + state.StateDBError = err.Error() + } + } else if info.IsDir() { + state.StateDBStatus = "warn_not_file" + } else if err := canReadFile(state.StateDBPath); err != nil { + state.StateDBStatus = "warn_not_readable" + state.StateDBError = err.Error() + } else { + state.StateDBStatus = "ok" + } + + if info, err := os.Stat(state.StateLockPath); err != nil { + if os.IsNotExist(err) { + state.StateLockStatus = "missing" + } else { + state.StateLockStatus = "warn_stat_failed" + state.StateLockError = err.Error() + } + } else if info.IsDir() { + state.StateLockStatus = "warn_not_file" + } else if err := canReadFile(state.StateLockPath); err != nil { + state.StateLockStatus = "warn_not_readable" + state.StateLockError = err.Error() + } else if time.Since(info.ModTime()) > 15*time.Minute { + state.StateLockStatus = "warn_stale_candidate" + } else { + state.StateLockStatus = "present_active_or_recent" + } + return state +} + +func canReadDir(path string) error { + entries, err := os.ReadDir(path) + if err != nil { + return err + } + _ = entries + return nil +} + +func canWriteDir(path string) error { + probe, err := os.CreateTemp(path, ".awiki-cli-doctor-*") + if err != nil { + return err + } + name := probe.Name() + if err := probe.Close(); err != nil { + _ = os.Remove(name) + return err + } + return os.Remove(name) +} + +func canReadFile(path string) error { + file, err := os.Open(path) + if err != nil { + return err + } + return file.Close() +} + +func cachedGroupE2EECount(resolved *config.Resolved) int { + if resolved == nil || strings.TrimSpace(resolved.Paths.DatabaseFile) == "" || !pathExists(resolved.Paths.DatabaseFile) { + return 0 + } + db, err := store.OpenReadOnly(resolved.Paths.DatabaseFile) + if err != nil { + return 0 + } + defer db.Close() + var tableCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'groups'`).Scan(&tableCount); err != nil || tableCount == 0 { + return 0 + } + var count sql.NullInt64 + if err := db.QueryRow(`SELECT COUNT(*) FROM groups WHERE metadata LIKE ?`, "%group-e2ee%").Scan(&count); err != nil { + return 0 + } + if !count.Valid { + return 0 + } + return int(count.Int64) +} + +func anpMLSCompatibilityError(info *message.MLSVersionInfo) error { + if info == nil { + return fmt.Errorf("missing version info") + } + if info.APIVersion != "anp-mls/v1" { + return fmt.Errorf("api_version %q is not supported; want anp-mls/v1", info.APIVersion) + } + if info.BinaryName != "anp-mls" { + return fmt.Errorf("binary_name %q is not supported; want anp-mls", info.BinaryName) + } + if !containsString(info.SupportedCommands, "system version") { + return fmt.Errorf("supported_commands does not include system version") + } + return nil +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if strings.EqualFold(strings.TrimSpace(value), want) { + return true + } + } + return false +} + +func anpMLSRemediation(resolveErr error, probeErr error, compatErr error, state mlsStateInspection) string { + switch { + case resolveErr != nil: + return "Build anp-mls from ../anp/anp/rust, put it next to release artifacts or on PATH, or set AWIKI_ANP_MLS_BINARY to the absolute binary path. Plain messaging does not require anp-mls." + case probeErr != nil: + return "Install a current anp-mls build that supports `anp-mls system version --json-in -`; rebuild from ../anp/anp/rust if this probe fails." + case compatErr != nil: + return "Replace anp-mls with a build that reports api_version anp-mls/v1, binary_name anp-mls, and supported command `system version`." + case state.DataDirStatus == "warn_not_writable" || state.DataDirStatus == "warn_not_readable": + return "Fix permissions on the MLS data directory or move the workspace with AWIKI_CLI_WORKSPACE_HOME_DIR." + case state.StateDBStatus == "warn_missing_with_cached_groups": + return "The business database has cached group-e2ee groups but MLS state.db is missing; restore the MLS data directory from backup before sending encrypted group messages." + case strings.HasPrefix(state.StateLockStatus, "warn"): + return "If no anp-mls process is running, remove stale state.lock after backing up the MLS data directory." + default: + return "No action required." } } diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go index b4b1990..7f4267f 100644 --- a/internal/doctor/doctor_test.go +++ b/internal/doctor/doctor_test.go @@ -4,12 +4,14 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" "github.com/agentconnect/awiki-cli/internal/buildinfo" appconfig "github.com/agentconnect/awiki-cli/internal/config" "github.com/agentconnect/awiki-cli/internal/identity" + "github.com/agentconnect/awiki-cli/internal/message" runtimecfg "github.com/agentconnect/awiki-cli/internal/runtime" "github.com/agentconnect/awiki-cli/internal/store" "github.com/agentconnect/awiki-cli/internal/upgrade" @@ -250,6 +252,8 @@ func resolveDoctorConfigForWorkspace(t *testing.T, workspace string, keepEnvHits t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) t.Setenv("AWIKI_CLI_WORKSPACE_HOME_DIR", workspace) + t.Setenv("AWIKI_ANP_MLS_BINARY", "") + t.Setenv("PATH", t.TempDir()) resolved, err := appconfig.Resolve(appconfig.Overrides{}) if err != nil { t.Fatalf("Resolve() error = %v", err) @@ -273,3 +277,106 @@ func checkByName(t *testing.T, report Report, name string) Check { t.Fatalf("check %q not found", name) return Check{} } + +func TestANPMLSDoctorCompatibilityAndStateDiagnostics(t *testing.T) { + resolved := resolveDoctorConfig(t, false) + binDir := t.TempDir() + writeFakeANPMLS(t, binDir, `{"ok":true,"api_version":"anp-mls/v1","request_id":"doctor-system-version","result":{"api_version":"anp-mls/v1","binary_name":"anp-mls","binary_version":"test","supported_commands":["system version","key-package generate","group create"]}}`) + t.Setenv("PATH", binDir) + mlsDir := filepath.Join(resolved.Paths.WorkspaceHomeDir, "mls") + if err := os.MkdirAll(mlsDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mlsDir, "state.db"), []byte("sqlite placeholder"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(mlsDir, "state.lock"), []byte("lock"), 0o600); err != nil { + t.Fatal(err) + } + + report := Run(resolved) + check := checkByName(t, report, "anp_mls") + if check.Status != "ok" { + t.Fatalf("anp_mls status = %q, want ok; details=%#v", check.Status, check.Details) + } + if check.Details["data_dir_status"] != "ok" || check.Details["state_db_status"] != "ok" { + t.Fatalf("unexpected MLS state details: %#v", check.Details) + } + version, ok := check.Details["version"].(*message.MLSVersionInfo) + if !ok || version.BinaryName != "anp-mls" { + t.Fatalf("version detail = %#v", check.Details["version"]) + } + if check.Details["remediation"] != "No action required." { + t.Fatalf("remediation = %#v", check.Details["remediation"]) + } +} + +func TestANPMLSDoctorVersionMismatchIsActionableWarning(t *testing.T) { + resolved := resolveDoctorConfig(t, false) + binDir := t.TempDir() + writeFakeANPMLS(t, binDir, `{"ok":true,"api_version":"anp-mls/v0","request_id":"doctor-system-version","result":{"api_version":"anp-mls/v0","binary_name":"anp-mls","binary_version":"old","supported_commands":["system version"]}}`) + t.Setenv("PATH", binDir) + + report := Run(resolved) + check := checkByName(t, report, "anp_mls") + if check.Status != "warn" { + t.Fatalf("anp_mls status = %q, want warn; details=%#v", check.Status, check.Details) + } + if got := check.Details["compatibility_error"]; !strings.Contains(got.(string), "api_version") { + t.Fatalf("compatibility_error = %#v", got) + } + if got := check.Details["remediation"].(string); !strings.Contains(got, "api_version anp-mls/v1") { + t.Fatalf("remediation = %q", got) + } +} + +func TestANPMLSDoctorWarnsWhenCachedE2EEGroupsHaveNoMLSState(t *testing.T) { + resolved := resolveDoctorConfig(t, false) + binDir := t.TempDir() + writeFakeANPMLS(t, binDir, `{"ok":true,"api_version":"anp-mls/v1","request_id":"doctor-system-version","result":{"api_version":"anp-mls/v1","binary_name":"anp-mls","binary_version":"test","supported_commands":["system version"]}}`) + t.Setenv("PATH", binDir) + db, err := store.Open(resolved.Paths) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err := store.EnsureSchema(context.Background(), db); err != nil { + t.Fatal(err) + } + if err := store.UpsertGroup(context.Background(), db, store.GroupRecord{ + OwnerDID: "did:wba:alice.example", + GroupID: "group-1", + Name: "Secret group", + Metadata: `{"message_security_profile":"group-e2ee"}`, + CredentialName: "alice", + }); err != nil { + t.Fatal(err) + } + + report := Run(resolved) + check := checkByName(t, report, "anp_mls") + if check.Status != "warn" { + t.Fatalf("anp_mls status = %q, want warn; details=%#v", check.Status, check.Details) + } + if check.Details["e2ee_group_count"] != 1 { + t.Fatalf("e2ee_group_count = %#v, want 1", check.Details["e2ee_group_count"]) + } + if check.Details["data_dir_status"] != "warn_missing_with_cached_groups" { + t.Fatalf("data_dir_status = %#v", check.Details["data_dir_status"]) + } +} + +func writeFakeANPMLS(t *testing.T, dir string, response string) string { + t.Helper() + if os.PathSeparator == ';' { + t.Skip("shell-script fake anp-mls is only used on Unix-like test hosts") + } + path := filepath.Join(dir, "anp-mls") + script := "#!/bin/sh\n" + + "if [ \"$1 $2 $3 $4\" != \"system version --json-in -\" ]; then echo unexpected args >&2; exit 2; fi\n" + + "printf '%s' '" + strings.ReplaceAll(response, "'", "'\\''") + "'\n" + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return path +} diff --git a/internal/message/group_e2ee_provider.go b/internal/message/group_e2ee_provider.go index d4be87b..7eb463f 100644 --- a/internal/message/group_e2ee_provider.go +++ b/internal/message/group_e2ee_provider.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "time" @@ -44,6 +45,16 @@ type MLSError struct { Message string `json:"message"` } +// MLSVersionInfo is the machine-readable compatibility contract returned by +// `anp-mls system version --json-in -`. +type MLSVersionInfo struct { + APIVersion string `json:"api_version"` + BinaryName string `json:"binary_name"` + BinaryVersion string `json:"binary_version,omitempty"` + BuildVersion string `json:"build_version,omitempty"` + SupportedCommands []string `json:"supported_commands"` +} + type MLSCommandRunner interface { Run(ctx context.Context, binary string, args []string, stdin []byte) (stdout []byte, stderr []byte, err error) } @@ -99,7 +110,7 @@ func (p MLSExecProvider) ResolveBinaryPath() (string, error) { } seen[candidate] = struct{}{} if filepath.IsAbs(candidate) || strings.ContainsRune(candidate, filepath.Separator) { - if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + if info, err := os.Stat(candidate); err == nil && isExecutableFile(info) { return candidate, nil } continue @@ -115,6 +126,93 @@ func (p MLSExecProvider) ResolveBinaryPath() (string, error) { ) } + +func isExecutableFile(info os.FileInfo) bool { + if info == nil || info.IsDir() { + return false + } + if runtime.GOOS == "windows" { + return true + } + return info.Mode()&0o111 != 0 +} + +func (p MLSExecProvider) ProbeVersion(ctx context.Context) (*MLSVersionInfo, error) { + timeout := p.Timeout + if timeout <= 0 { + timeout = 15 * time.Second + } + runner := p.Runner + if runner == nil { + runner = OSMLSCommandRunner{} + } + binary := strings.TrimSpace(p.BinaryPath) + if binary == "" || p.Runner == nil { + resolvedBinary, err := p.ResolveBinaryPath() + if err != nil { + return nil, err + } + binary = resolvedBinary + } + req := MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "doctor-system-version", + Params: map[string]any{}, + } + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + stdout, stderr, err := runner.Run(ctx, binary, []string{"system", "version", "--json-in", "-"}, body) + if err != nil && len(stdout) == 0 { + return nil, fmt.Errorf("anp-mls version probe failed: %w: %s", err, string(stderr)) + } + var resp MLSResponse + if decodeErr := json.Unmarshal(stdout, &resp); decodeErr != nil { + return nil, fmt.Errorf("decode anp-mls version response: %w: stderr=%s", decodeErr, string(stderr)) + } + if !resp.OK { + if resp.Error != nil { + return nil, fmt.Errorf("anp-mls version probe error %s: %s", resp.Error.Code, resp.Error.Message) + } + return nil, fmt.Errorf("anp-mls version probe returned ok=false") + } + info, err := versionInfoFromResponse(resp) + if err != nil { + return nil, err + } + return info, nil +} + +func versionInfoFromResponse(resp MLSResponse) (*MLSVersionInfo, error) { + resultBytes, err := json.Marshal(resp.Result) + if err != nil { + return nil, err + } + var info MLSVersionInfo + if err := json.Unmarshal(resultBytes, &info); err != nil { + return nil, fmt.Errorf("decode anp-mls version result: %w", err) + } + if info.APIVersion == "" { + info.APIVersion = resp.APIVersion + } + if strings.TrimSpace(info.APIVersion) == "" { + return nil, fmt.Errorf("anp-mls version response missing api_version") + } + if strings.TrimSpace(info.BinaryName) == "" { + return nil, fmt.Errorf("anp-mls version response missing binary_name") + } + if strings.TrimSpace(info.BinaryVersion) == "" && strings.TrimSpace(info.BuildVersion) == "" { + return nil, fmt.Errorf("anp-mls version response missing binary_version/build_version") + } + if len(info.SupportedCommands) == 0 { + return nil, fmt.Errorf("anp-mls version response missing supported_commands") + } + return &info, nil +} + func (p MLSExecProvider) Call(ctx context.Context, domain string, action string, req MLSRequest) (*MLSResponse, error) { timeout := p.Timeout if timeout <= 0 { diff --git a/internal/message/group_e2ee_provider_test.go b/internal/message/group_e2ee_provider_test.go index 8003668..1c5fcd5 100644 --- a/internal/message/group_e2ee_provider_test.go +++ b/internal/message/group_e2ee_provider_test.go @@ -64,6 +64,9 @@ func TestMLSExecProviderBinaryDiscoveryOrder(t *testing.T) { func (r *recordingMLSRunner) Run(_ context.Context, _ string, args []string, stdin []byte) ([]byte, []byte, error) { r.args = append([]string(nil), args...) r.stdin = append([]byte(nil), stdin...) + if strings.Join(args, " ") == "system version --json-in -" { + return []byte(`{"ok":true,"api_version":"anp-mls/v1","request_id":"doctor-system-version","result":{"api_version":"anp-mls/v1","binary_name":"anp-mls","binary_version":"test","supported_commands":["system version","message encrypt"]}}`), nil, nil + } return []byte(`{"ok":true,"api_version":"anp-mls/v1","request_id":"req-1","result":{"non_cryptographic":true}}`), nil, nil } @@ -108,3 +111,43 @@ func TestMLSExecProviderPassesPlaintextOnStdinNotArgv(t *testing.T) { t.Fatalf("args missing --data-dir before final value: %#v", runner.args) } } + +func TestMLSExecProviderRejectsNonExecutablePath(t *testing.T) { + if os.PathSeparator == ';' { + t.Skip("Windows executable bit semantics differ") + } + t.Setenv(ANPMLSBinaryEnv, "") + nonExecutable := filepath.Join(t.TempDir(), "anp-mls") + if err := os.WriteFile(nonExecutable, []byte("#!/bin/sh\n"), 0o600); err != nil { + t.Fatal(err) + } + provider := MLSExecProvider{BinaryPath: nonExecutable} + if got, err := provider.ResolveBinaryPath(); err == nil { + t.Fatalf("ResolveBinaryPath() = %q, nil error; want non-executable path rejected", got) + } +} + +func TestMLSExecProviderProbeVersionUsesStableSystemContract(t *testing.T) { + runner := &recordingMLSRunner{} + provider := MLSExecProvider{BinaryPath: "anp-mls", DataDir: t.TempDir(), Runner: runner} + info, err := provider.ProbeVersion(context.Background()) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(runner.args, " "); got != "system version --json-in -" { + t.Fatalf("ProbeVersion args = %q, want stable system version probe", got) + } + if strings.Contains(strings.Join(runner.args, " "), provider.DataDir) { + t.Fatalf("ProbeVersion should not require or expose data dir args: %#v", runner.args) + } + if info.APIVersion != "anp-mls/v1" || info.BinaryName != "anp-mls" || info.BinaryVersion == "" { + t.Fatalf("ProbeVersion info = %#v", info) + } + var req MLSRequest + if err := json.Unmarshal(runner.stdin, &req); err != nil { + t.Fatal(err) + } + if req.RequestID != "doctor-system-version" || req.Params == nil { + t.Fatalf("ProbeVersion stdin request = %#v", req) + } +} diff --git a/scripts/release/build-anp-mls.sh b/scripts/release/build-anp-mls.sh new file mode 100755 index 0000000..e7931ec --- /dev/null +++ b/scripts/release/build-anp-mls.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Build and stage the Rust anp-mls binary for awiki-cli release artifacts. + +Usage: + scripts/release/build-anp-mls.sh [--dry-run] [--output DIR] + +Environment: + ANP_MLS_SOURCE_DIR Path to the anp/anp/rust crate (default: ../anp/anp/rust) + AWIKI_ANP_MLS_RELEASE_DIR Output directory (default: dist/anp-mls) + CARGO Cargo binary (default: cargo) + +The staged binary can be bundled next to awiki-cli artifacts or installed on PATH. +Users can always override discovery with AWIKI_ANP_MLS_BINARY=/absolute/path/to/anp-mls. +USAGE +} + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source_dir="${ANP_MLS_SOURCE_DIR:-$(cd "${repo_root}/.." && pwd)/anp/anp/rust}" +output_dir="${AWIKI_ANP_MLS_RELEASE_DIR:-${repo_root}/dist/anp-mls}" +dry_run=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + dry_run=1 + shift + ;; + --output) + if [[ $# -lt 2 ]]; then + echo "--output requires a directory" >&2 + exit 2 + fi + output_dir="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +cargo_bin="${CARGO:-cargo}" +manifest_path="${source_dir}/Cargo.toml" +if [[ ! -f "${manifest_path}" ]]; then + echo "anp-mls Cargo.toml not found: ${manifest_path}" >&2 + echo "Set ANP_MLS_SOURCE_DIR to the sibling anp/anp/rust crate." >&2 + exit 1 +fi + +case "$(uname -s)" in + Darwin) os_name="darwin" ;; + Linux) os_name="linux" ;; + *) os_name="$(uname -s | tr '[:upper:]' '[:lower:]')" ;; +esac +case "$(uname -m)" in + x86_64|amd64) arch_name="amd64" ;; + arm64|aarch64) arch_name="arm64" ;; + *) arch_name="$(uname -m)" ;; +esac + +bin_name="anp-mls" +if [[ "${os_name}" == "windows" ]]; then + bin_name="anp-mls.exe" +fi +src_bin="${source_dir}/target/release/${bin_name}" +stage_dir="${output_dir}/${os_name}-${arch_name}" +staged_bin="${stage_dir}/${bin_name}" + +if [[ ${dry_run} -eq 1 ]]; then + cat < ${staged_bin} +EOF_DRY + exit 0 +fi + +"${cargo_bin}" build --manifest-path "${manifest_path}" --bin anp-mls --release +mkdir -p "${stage_dir}" +cp "${src_bin}" "${staged_bin}" +chmod 0755 "${staged_bin}" + +echo "anp-mls staged at ${staged_bin}" +echo "Set AWIKI_ANP_MLS_BINARY=${staged_bin} or place it on PATH for awiki-cli group E2EE." From b0856f4cb9e4aee300c446166c9bb5e479291873 Mon Sep 17 00:00:00 2001 From: changshan Date: Sun, 3 May 2026 00:36:43 +0800 Subject: [PATCH 06/14] Make group E2EE CLI restore named-device MLS state The real MLS Alice/Bob loop publishes Bob's KeyPackage under a named device, while one-shot message reads previously tried only the default device state. The CLI now keeps anp-mls state agent/device-scoped, processes local welcome notices for stored identities, scans local device state during decrypt, and strips provider-local plaintext/OpenMLS fields before sending opaque P6 objects to the service. Constraint: Go CLI must remain pure Go / no CGO and invoke anp-mls as a one-shot helper. Constraint: Group E2EE remains hidden/test-only; this does not enable public discovery. Rejected: Share one MLS SQLite DB across local identities | OpenMLS private KeyPackage state is not namespaced and Alice add consumed Bob's local material. Confidence: high Scope-risk: moderate Directive: Do not send provider-local OpenMLS fields or application plaintext in group.e2ee.send payloads. Tested: go test ./internal/message ./internal/doctor ./internal/cli Tested: go vet ./internal/message ./internal/doctor Tested: focused awiki-system-test group E2EE local/negative target passed (2 passed). Tested: root make local-test passed (84 passed, 11 skipped). --- CLAUDE.md | 9 +- docs/installation.md | 2 +- internal/cli/group.go | 2 +- internal/message/group_e2ee_provider.go | 98 +++++++++++++- internal/message/group_e2ee_provider_test.go | 36 +++++ internal/message/group_e2ee_service.go | 131 +++++++++++++++++-- internal/message/group_service.go | 8 +- internal/message/group_service_test.go | 68 ++++++++++ internal/message/group_wire.go | 21 ++- internal/message/group_wire_test.go | 26 +++- 10 files changed, 365 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f5dad1f..b1dbb6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,13 +93,14 @@ **internal/message/attachment_service.go**: direct/group attachment send 与 `msg attachment download` 的业务编排层。 **internal/message/secure.go**: P5 direct E2EE secure send 的首版编排层,使用 ANP Go SDK direct_e2ee、本地文件会话/预密钥存储和 HTTP JSON-RPC;key-service 请求绑定当前 DID 文档里 `ANPMessageService.serviceDid`,并在有可用 sidecar OPK 时优先用 OPK 建链(本地保存 `p5-one-time-prekeys/`);HTTP inbox/history 现已接入入站密文解密与会话推进,并会顺带补发本地 prekey bundle;轮询路径解密 direct-init 成功后会自动发送 encrypted ACK 并尝试 flush 该 peer 的 `e2ee_outbox`;当 initiator 仍处于 `pending-confirmation` 时,新的 secure 发送会进入 `e2ee_outbox` 排队。 **internal/message/secure_control.go**: secure 控制面与恢复辅助,负责 secure ack/init payload、pending 阶段的 `e2ee_outbox` 排队、secure outbox flush,以及 `msg secure status/init/repair/failed/retry/drop` 需要的本地会话/发件箱读取与重试逻辑。 -**internal/message/group_e2ee_provider.go**: `anp-mls` exec provider 抽象;按 `AWIKI_ANP_MLS_BINARY`、测试/运行时注入路径、`PATH` 顺序发现二进制;JSON request 走 stdin、response 走 stdout、日志/错误走 stderr,默认 MLS 状态目录为 `/mls`,保持 Go 主工程 pure Go / no CGO。 -**internal/message/group_wire.go**: group 标准面和 local-only RPC 参数构造器。 +**internal/message/group_e2ee_provider.go**: `anp-mls` exec provider 抽象;按 `AWIKI_ANP_MLS_BINARY`、测试/运行时注入路径、`PATH` 顺序发现二进制;JSON request 走 stdin、response 走 stdout、日志/错误走 stderr,默认 MLS 根目录为 `/mls`,实际 OpenMLS 私有状态按 agent/device 分到子目录,并可扫描同一 agent 下的本地 device state 供收件解密恢复,保持 Go 主工程 pure Go / no CGO。 +**internal/message/group_e2ee_service.go**: group E2EE 业务编排层;负责 KeyPackage 发布、owner create/add、send encrypt、messages decrypt,并在同一工作区存在目标成员身份时本地处理 add 返回的 welcome notice,使 one-shot `anp-mls` agent/device 状态可恢复。 +**internal/message/group_wire.go**: group 标准面和 local-only RPC 参数构造器;group E2EE send 会在签名/发送前裁剪 provider-local MLS 字段,只把 P6 service 允许的 opaque cipher 字段送到 message-service。 **internal/message/http_client.go**: direct/group message 与 group lifecycle 的 HTTP JSON-RPC adapter。 **internal/message/ws_proxy_client.go**: websocket 模式下通过本地 bridge 调用 listener/daemon 的 direct/group adapter。 **internal/message/service.go**: direct inbox/send/history/mark-read 的业务编排层,融合 transport、identity、store;支持收件后自动 DID→Handle 补全,以及按 handle 聚合历史 DID 消息。 **internal/message/contact_sync.go**: direct inbox/history 的联系人补全与 Handle 历史 DID 聚合辅助。 -**internal/message/group_service.go**: group lifecycle、group message、本地群缓存同步与群 inbox 聚合逻辑。 +**internal/message/group_service.go**: group lifecycle、group message、本地群缓存同步与群 inbox 聚合逻辑;E2EE 群消息在落本地 message view 前先尝试解密,避免返回缓存时覆盖已解密展示内容。 **internal/message/helpers.go**: message 域常用值转换和解码辅助。 **internal/message/proof_test.go**: origin_proof round-trip 测试。 **internal/message/group_wire_test.go**: group RPC 参数构造与签名测试。 @@ -218,7 +219,7 @@ - `msg` 域中 direct plain 已实现,P5 secure direct 出站首版已接入;HTTP inbox/history 的 P5 入站自动解密、轮询 direct-init 自动 ACK 与 outbox flush 已接入;websocket listener 已能解密 secure incoming、自动 first-reply/ack,并在 secure ack 后尝试 flush `e2ee_outbox`;`msg secure status/init/repair/failed/retry/drop` 有首版,但完整 runtime 联调与更强系统测试仍未完成 - `group` 域的 plain lifecycle / local view / group messaging 已接入,`people` 仍大多为 stub;`page` 已完成 content pages 首版 -- secure E2EE 业务流目前只完成 P5 出站首版;group E2EE 已接入 CLI 侧 `anp-mls` exec 编排、KeyPackage 发布、group-e2ee create/add/send 与轮询消息本地解密分支,但真实 OpenMLS 完整验收仍依赖 `anp-mls` 与 message-service P6 后端联调。 +- secure E2EE 业务流目前只完成 P5 出站首版;group E2EE 已接入 CLI 侧 `anp-mls` exec 编排、KeyPackage 发布、group-e2ee create/add/send 与轮询消息本地解密分支,并已有隐藏 focused target 覆盖真实 OpenMLS Alice/Bob 最小闭环;对外 discovery 仍保持隐藏,完整 MLS 群管理能力仍未实现。 ## 开发与验证约定 diff --git a/docs/installation.md b/docs/installation.md index ccf3651..9923934 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -167,7 +167,7 @@ By default the script builds `../anp/anp/rust` with Cargo and copies `anp-mls` t export AWIKI_ANP_MLS_BINARY=/absolute/path/to/anp-mls ``` -The MLS private state directory remains `~/.awiki-cli/mls/` (or the current `AWIKI_CLI_WORKSPACE_HOME_DIR` equivalent). `doctor` reports the directory permissions plus `state.db` and `state.lock` status, including warnings when cached group-E2EE groups exist but MLS state is missing. +The MLS private state root remains `~/.awiki-cli/mls/` (or the current `AWIKI_CLI_WORKSPACE_HOME_DIR` equivalent). Runtime OpenMLS state is agent/device-scoped under that root (`mls/agents///state.db`) so two local identities do not share private KeyPackage storage. Incoming decrypt first tries the default device and then scans the local agent-scoped device directories, allowing one-shot CLI commands to restore state for KeyPackages published with a named device. `doctor` reports the root directory permissions plus state file status, including warnings when cached group-E2EE groups exist but MLS state is missing. ### 3.2 config.yaml diff --git a/internal/cli/group.go b/internal/cli/group.go index 256c454..286c80f 100644 --- a/internal/cli/group.go +++ b/internal/cli/group.go @@ -120,7 +120,7 @@ func (a *App) runGroupMemberMutation(cmd *cobra.Command, publicAction string, me result, err = service.RemoveGroupMember(cmd.Context(), request) } if err != nil { - return a.messageExit(err, "Make sure the group and member exist and the active identity has the required role.") + return a.messageExit(err, "Make sure the group and member exist and the active identity has the owner role required for membership changes.") } if result == nil { return commandResultMissing(cmd.CommandPath()) diff --git a/internal/message/group_e2ee_provider.go b/internal/message/group_e2ee_provider.go index 7eb463f..892149c 100644 --- a/internal/message/group_e2ee_provider.go +++ b/internal/message/group_e2ee_provider.go @@ -3,6 +3,8 @@ package message import ( "bytes" "context" + "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" "os" @@ -126,7 +128,6 @@ func (p MLSExecProvider) ResolveBinaryPath() (string, error) { ) } - func isExecutableFile(info os.FileInfo) bool { if info == nil || info.IsDir() { return false @@ -234,16 +235,17 @@ func (p MLSExecProvider) Call(ctx context.Context, domain string, action string, if err != nil { return nil, err } - if p.DataDir != "" { - if err := os.MkdirAll(p.DataDir, 0o700); err != nil { - return nil, fmt.Errorf("prepare anp-mls data dir %s: %w", p.DataDir, err) + dataDir := p.effectiveDataDir(req) + if dataDir != "" { + if err := os.MkdirAll(dataDir, 0o700); err != nil { + return nil, fmt.Errorf("prepare anp-mls data dir %s: %w", dataDir, err) } } ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() args := []string{domain, action, "--json-in", "-"} - if p.DataDir != "" { - args = append(args, "--data-dir", p.DataDir) + if dataDir != "" { + args = append(args, "--data-dir", dataDir) } stdout, stderr, err := runner.Run(ctx, binary, args, body) if err != nil && len(stdout) == 0 { @@ -262,6 +264,90 @@ func (p MLSExecProvider) Call(ctx context.Context, domain string, action string, return &resp, nil } +func (p MLSExecProvider) effectiveDataDir(req MLSRequest) string { + baseDir := strings.TrimSpace(p.DataDir) + if baseDir == "" { + return "" + } + agentDID := strings.TrimSpace(req.AgentDID) + if agentDID == "" { + for _, key := range []string{"agent_did", "owner_did", "actor_did", "sender_did", "recipient_did"} { + if value := stringFromAny(req.Params[key]); value != "" { + agentDID = value + break + } + } + } + if agentDID == "" { + return baseDir + } + deviceID := strings.TrimSpace(req.DeviceID) + if deviceID == "" { + deviceID = stringFromAny(req.Params["device_id"]) + } + if deviceID == "" { + deviceID = "default" + } + return filepath.Join(baseDir, "agents", mlsAgentKey(agentDID), safeMLSPathComponent(deviceID)) +} + +func (p MLSExecProvider) candidateDeviceIDs(agentDID string) []string { + agentDID = strings.TrimSpace(agentDID) + if agentDID == "" { + return []string{"default"} + } + candidates := []string{"default"} + baseDir := strings.TrimSpace(p.DataDir) + if baseDir == "" { + return candidates + } + entries, err := os.ReadDir(filepath.Join(baseDir, "agents", mlsAgentKey(agentDID))) + if err != nil { + return candidates + } + seen := map[string]struct{}{"default": {}} + for _, entry := range entries { + if !entry.IsDir() { + continue + } + deviceID := strings.TrimSpace(entry.Name()) + if deviceID == "" { + continue + } + if _, ok := seen[deviceID]; ok { + continue + } + seen[deviceID] = struct{}{} + candidates = append(candidates, deviceID) + } + return candidates +} + +func mlsAgentKey(agentDID string) string { + sum := sha256.Sum256([]byte(agentDID)) + return base64.RawURLEncoding.EncodeToString(sum[:])[:24] +} + +func safeMLSPathComponent(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "default" + } + var builder strings.Builder + for _, r := range value { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.': + builder.WriteRune(r) + default: + builder.WriteByte('_') + } + } + if builder.Len() == 0 { + return "default" + } + return builder.String() +} + func (p MLSExecProvider) GenerateKeyPackage(ctx context.Context, req MLSRequest) (map[string]any, error) { resp, err := p.Call(ctx, "key-package", "generate", req) if err != nil { diff --git a/internal/message/group_e2ee_provider_test.go b/internal/message/group_e2ee_provider_test.go index 1c5fcd5..def7fd5 100644 --- a/internal/message/group_e2ee_provider_test.go +++ b/internal/message/group_e2ee_provider_test.go @@ -110,6 +110,42 @@ func TestMLSExecProviderPassesPlaintextOnStdinNotArgv(t *testing.T) { if got := runner.args[len(runner.args)-2]; got != "--data-dir" { t.Fatalf("args missing --data-dir before final value: %#v", runner.args) } + dataDir := runner.args[len(runner.args)-1] + if !strings.Contains(dataDir, filepath.Join("agents")) || !strings.HasSuffix(dataDir, filepath.Join("agents", filepath.Base(filepath.Dir(dataDir)), "device-1")) { + t.Fatalf("data dir = %q, want agent-scoped device directory", dataDir) + } + if strings.Contains(dataDir, "did:wba") { + t.Fatalf("data dir leaked raw DID: %q", dataDir) + } +} + +func TestMLSExecProviderCandidateDeviceIDsScansAgentScopedState(t *testing.T) { + root := t.TempDir() + agentDID := "did:wba:example.com:users:bob:e1" + agentDir := filepath.Join(root, "agents", mlsAgentKey(agentDID)) + if err := os.MkdirAll(filepath.Join(agentDir, "bob-main"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(agentDir, "bob.backup"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentDir, "not-a-device"), []byte("state"), 0o600); err != nil { + t.Fatal(err) + } + + got := MLSExecProvider{DataDir: root}.candidateDeviceIDs(agentDID) + want := map[string]bool{"default": true, "bob-main": true, "bob.backup": true} + if len(got) != len(want) { + t.Fatalf("candidateDeviceIDs() = %#v, want keys %#v", got, want) + } + for _, deviceID := range got { + if !want[deviceID] { + t.Fatalf("candidateDeviceIDs() returned unexpected device %q in %#v", deviceID, got) + } + } + if got[0] != "default" { + t.Fatalf("candidateDeviceIDs()[0] = %q, want default first", got[0]) + } } func TestMLSExecProviderRejectsNonExecutablePath(t *testing.T) { diff --git a/internal/message/group_e2ee_service.go b/internal/message/group_e2ee_service.go index 38d8941..2ab8d15 100644 --- a/internal/message/group_e2ee_service.go +++ b/internal/message/group_e2ee_service.go @@ -123,7 +123,13 @@ func (s *Service) addGroupMemberE2EE(ctx context.Context, record *identity.Store return map[string]any{"mls": mlsHead, "leased_key_package": redactedKeyPackageSummary(leasedPackage)}, []string{fmt.Sprintf("Group E2EE add delivery failed: %v", err)} } warnings := s.persistGroupE2EESummary(ctx, record, groupDID, mlsHead, delivery) - return map[string]any{"mls": mlsHead, "delivery": delivery, "leased_key_package": redactedKeyPackageSummary(leasedPackage)}, warnings + localWelcome, localWelcomeWarnings := s.processLocalGroupWelcome(ctx, memberDID, groupDID, delivery, leasedPackage) + warnings = append(warnings, localWelcomeWarnings...) + result := map[string]any{"mls": mlsHead, "delivery": delivery, "leased_key_package": redactedKeyPackageSummary(leasedPackage)} + if localWelcome != nil { + result["local_welcome"] = localWelcome + } + return result, warnings } func (s *Service) sendGroupE2EE(ctx context.Context, record *identity.StoredIdentity, request SendRequest) (*CommandResult, error) { @@ -170,25 +176,14 @@ func (s *Service) maybeDecryptGroupMessages(ctx context.Context, record *identit return nil, raw } provider := s.groupMLSProvider() + deviceIDs := provider.candidateDeviceIDs(record.DID) warnings := make([]string, 0) for _, item := range messages { cipher := groupCipherObjectFromMessage(item) if len(cipher) == 0 { continue } - plain, err := provider.Decrypt(ctx, MLSRequest{ - APIVersion: "anp-mls/v1", - RequestID: "group-e2ee-decrypt-" + generateOperationID(), - AgentDID: record.DID, - DeviceID: "default", - Params: map[string]any{ - "agent_did": record.DID, - "device_id": "default", - "group_did": groupDID, - "group_cipher_object": cipher, - "private_message_b64u": cipher["private_message_b64u"], - }, - }) + plain, err := decryptGroupCipherWithDevices(ctx, provider, record.DID, groupDID, cipher, deviceIDs) if err != nil { warnings = append(warnings, fmt.Sprintf("Group E2EE decrypt failed for message %s: %v", stringFromAny(item["id"]), err)) continue @@ -203,6 +198,37 @@ func (s *Service) maybeDecryptGroupMessages(ctx context.Context, record *identit return compactWarnings(warnings), raw } +func decryptGroupCipherWithDevices(ctx context.Context, provider MLSExecProvider, agentDID string, groupDID string, cipher map[string]any, deviceIDs []string) (map[string]any, error) { + if len(deviceIDs) == 0 { + deviceIDs = []string{"default"} + } + var lastErr error + for _, deviceID := range deviceIDs { + deviceID = defaultString(strings.TrimSpace(deviceID), "default") + plain, err := provider.Decrypt(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-decrypt-" + generateOperationID(), + AgentDID: agentDID, + DeviceID: deviceID, + Params: map[string]any{ + "agent_did": agentDID, + "device_id": deviceID, + "group_did": groupDID, + "group_cipher_object": cipher, + "private_message_b64u": cipher["private_message_b64u"], + }, + }) + if err == nil { + return plain, nil + } + lastErr = err + } + if lastErr != nil { + return nil, lastErr + } + return nil, fmt.Errorf("no candidate MLS device state found") +} + func (s *Service) persistGroupE2EESummary(ctx context.Context, record *identity.StoredIdentity, groupDID string, mls map[string]any, delivery map[string]any) []string { db, err := store.Open(s.resolved.Paths) if err != nil { @@ -254,6 +280,79 @@ func (s *Service) persistGroupE2EESendResult(ctx context.Context, record *identi return commandResult, nil } +func (s *Service) processLocalGroupWelcome(ctx context.Context, memberDID string, groupDID string, delivery map[string]any, leasedPackage map[string]any) (map[string]any, []string) { + notice := e2eeNoticeObject(delivery) + welcomeB64U := stringFromAny(notice["welcome_b64u"]) + if welcomeB64U == "" { + return nil, nil + } + memberRecord, err := s.localIdentityByDID(memberDID) + if err != nil { + return nil, nil + } + deviceID := groupE2EEWelcomeDeviceID(leasedPackage) + provider := s.groupMLSProvider() + welcomeResult, err := provider.ProcessWelcome(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-welcome-" + generateOperationID(), + AgentDID: memberRecord.DID, + DeviceID: deviceID, + Params: map[string]any{ + "agent_did": memberRecord.DID, + "device_id": deviceID, + "group_did": groupDID, + "welcome_b64u": welcomeB64U, + "group_state_ref": map[string]any{"group_did": groupDID}, + }, + }) + if err != nil { + return nil, []string{fmt.Sprintf("Group E2EE local welcome processing failed for member %s: %v", memberDID, err)} + } + warnings := s.persistGroupE2EESummary(ctx, memberRecord, groupDID, welcomeResult, delivery) + return map[string]any{ + "processed": true, + "group_did": groupDID, + "member_did": memberRecord.DID, + "device_id": deviceID, + "epoch": welcomeResult["epoch"], + }, warnings +} + +func (s *Service) localIdentityByDID(did string) (*identity.StoredIdentity, error) { + if s == nil || s.manager == nil { + return nil, identity.ErrIdentityNotFound + } + summaries, err := s.manager.List() + if err != nil { + return nil, err + } + for _, summary := range summaries { + if summary.DID == did { + return s.manager.Load(summary.IdentityName) + } + } + return nil, identity.ErrIdentityNotFound +} + +func e2eeNoticeObject(delivery map[string]any) map[string]any { + if notice, ok := delivery["e2ee_notice"].(map[string]any); ok { + return notice + } + return nil +} + +func groupE2EEWelcomeDeviceID(leasedPackage map[string]any) string { + if groupKeyPackage, ok := leasedPackage["group_key_package"].(map[string]any); ok { + if deviceID := stringFromAny(groupKeyPackage["device_id"]); deviceID != "" { + return deviceID + } + } + if deviceID := stringFromAny(leasedPackage["device_id"]); deviceID != "" { + return deviceID + } + return "default" +} + func (s *Service) localGroupStateRef(ctx context.Context, record *identity.StoredIdentity, groupDID string) map[string]any { ref := map[string]any{"group_did": groupDID} snapshot, err := s.readCachedGroupSnapshot(ctx, record, groupDID) @@ -276,6 +375,10 @@ func groupRequestUsesE2EE(request GroupCreateRequest) bool { return request.E2EE || strings.TrimSpace(request.MessageSecurityProfile) == GroupE2EESecurityProfile } +func groupMemberMutationUsesE2EE(request GroupMemberRequest, preMutationSnapshot map[string]any, postMutationSnapshot map[string]any) bool { + return request.E2EE || groupSnapshotUsesE2EE(preMutationSnapshot) || groupSnapshotUsesE2EE(postMutationSnapshot) +} + func groupSnapshotUsesE2EE(snapshot map[string]any) bool { if len(snapshot) == 0 { return false diff --git a/internal/message/group_service.go b/internal/message/group_service.go index ce863f2..e47a004 100644 --- a/internal/message/group_service.go +++ b/internal/message/group_service.go @@ -127,6 +127,10 @@ func (s *Service) mutateGroupMember(ctx context.Context, request GroupMemberRequ return nil, err } request.Member = memberDID + var preMutationSnapshot map[string]any + if action == "add" { + preMutationSnapshot, _ = s.readCachedGroupSnapshot(ctx, record, request.Group) + } transport, warnings, err := s.groupControlTransport(record) if err != nil { return nil, err @@ -144,7 +148,7 @@ func (s *Service) mutateGroupMember(ctx context.Context, request GroupMemberRequ snapshot, _ := s.readCachedGroupSnapshot(ctx, record, request.Group) members, _ := s.readCachedGroupMembers(ctx, record, request.Group, 100) var e2eeResult map[string]any - if action == "add" && (request.E2EE || groupSnapshotUsesE2EE(snapshot)) { + if action == "add" && groupMemberMutationUsesE2EE(request, preMutationSnapshot, snapshot) { e2eeCandidate, e2eeWarnings := s.addGroupMemberE2EE(ctx, record, request.Group, memberDID) e2eeResult, warnings = appendE2EEResult(warnings, e2eeCandidate, e2eeWarnings) } @@ -274,12 +278,12 @@ func (s *Service) GroupMessages(ctx context.Context, request GroupMessagesReques warnings = append(warnings, websocketHTTPFallbackWarning(wsErr)) warnings = append(warnings, httpWarnings...) } - warnings = append(warnings, s.persistGroupMessages(ctx, record, request.Group, result)...) decryptWarnings, decryptedResult := s.maybeDecryptGroupMessages(ctx, record, request.Group, result) warnings = append(warnings, decryptWarnings...) if decryptedResult != nil { result = decryptedResult } + warnings = append(warnings, s.persistGroupMessages(ctx, record, request.Group, result)...) messages, _ := s.readCachedGroupMessages(ctx, record, request.Group, request.Limit, request.Cursor) if len(messages) == 0 { messages = messagesFromResult(result["messages"]) diff --git a/internal/message/group_service_test.go b/internal/message/group_service_test.go index 0037f4f..b0cc6f1 100644 --- a/internal/message/group_service_test.go +++ b/internal/message/group_service_test.go @@ -129,6 +129,74 @@ func TestShouldUseCachedGroupFallbackRejectsInactiveViewerErrors(t *testing.T) { } } +func TestGroupMemberMutationUsesPreMutationE2EESnapshot(t *testing.T) { + t.Parallel() + + preMutation := map[string]any{ + "metadata": `{"message_security_profile":"group-e2ee","group_e2ee":{"epoch":"1"}}`, + } + postMutation := map[string]any{ + "metadata": `{"group_state_version":"2"}`, + } + if !groupMemberMutationUsesE2EE(GroupMemberRequest{}, preMutation, postMutation) { + t.Fatal("groupMemberMutationUsesE2EE() = false, want true from pre-mutation E2EE summary") + } + if !groupMemberMutationUsesE2EE(GroupMemberRequest{E2EE: true}, nil, nil) { + t.Fatal("groupMemberMutationUsesE2EE() = false, want true from explicit E2EE request") + } + if groupMemberMutationUsesE2EE(GroupMemberRequest{}, nil, postMutation) { + t.Fatal("groupMemberMutationUsesE2EE() = true, want false for non-E2EE snapshots") + } +} + +func TestLocalIdentityByDIDFindsStoredMemberForWelcomeProcessing(t *testing.T) { + t.Parallel() + + resolved := testResolvedConfig(t) + manager := identity.NewManager(resolved.Paths) + createTestIdentity(t, manager, identity.SaveInput{ + IdentityName: "alice", + UserID: "user-alice", + DisplayName: "Alice", + Handle: "alice", + }) + createTestIdentity(t, manager, identity.SaveInput{ + IdentityName: "bob", + UserID: "user-bob", + DisplayName: "Bob", + Handle: "bob", + }) + bob, err := manager.Load("bob") + if err != nil { + t.Fatalf("Load(bob) error = %v", err) + } + service := &Service{resolved: resolved, manager: manager} + + found, err := service.localIdentityByDID(bob.DID) + if err != nil { + t.Fatalf("localIdentityByDID() error = %v", err) + } + if found.IdentityName != "bob" { + t.Fatalf("localIdentityByDID() identity = %q, want bob", found.IdentityName) + } +} + +func TestGroupE2EEWelcomeDeviceIDUsesPublicKeyPackageDevice(t *testing.T) { + t.Parallel() + + leasedPackage := map[string]any{ + "group_key_package": map[string]any{ + "device_id": "bob-main", + }, + } + if got := groupE2EEWelcomeDeviceID(leasedPackage); got != "bob-main" { + t.Fatalf("groupE2EEWelcomeDeviceID() = %q, want bob-main", got) + } + if got := groupE2EEWelcomeDeviceID(nil); got != "default" { + t.Fatalf("groupE2EEWelcomeDeviceID(nil) = %q, want default", got) + } +} + func writeCachedGroupState(t *testing.T, resolved *appconfig.Resolved, record *identity.StoredIdentity, group store.GroupRecord, members []store.GroupMemberRecord) { t.Helper() diff --git a/internal/message/group_wire.go b/internal/message/group_wire.go index fe65554..4b6cceb 100644 --- a/internal/message/group_wire.go +++ b/internal/message/group_wire.go @@ -199,7 +199,25 @@ func BuildGroupE2EEAddRPCParams(record *identity.StoredIdentity, manager *identi } func BuildGroupE2EESendRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, cipher map[string]any) (map[string]any, error) { - return buildGroupE2EERPCParams(record, manager, groupDID, "group.e2ee.send", map[string]any{"group_cipher_object": cipher}, "application/anp-group-cipher+json") + return buildGroupE2EERPCParams(record, manager, groupDID, "group.e2ee.send", map[string]any{"group_cipher_object": sanitizeGroupCipherObjectForService(cipher)}, "application/anp-group-cipher+json") +} + +func sanitizeGroupCipherObjectForService(cipher map[string]any) map[string]any { + sanitized := make(map[string]any) + for _, key := range []string{ + "crypto_group_id_b64u", + "epoch", + "private_message_b64u", + "group_state_ref", + "epoch_authenticator", + "non_cryptographic", + "artifact_mode", + } { + if value, ok := cipher[key]; ok { + sanitized[key] = value + } + } + return sanitized } func BuildGroupE2EEPublishKeyPackageRPCParams(record *identity.StoredIdentity, manager *identity.Manager, serviceDID string, packageResult map[string]any) (map[string]any, error) { @@ -278,6 +296,7 @@ func BuildGroupE2EEGetKeyPackageRPCParams(record *identity.StoredIdentity, manag func sanitizeGroupKeyPackageForService(input map[string]any) map[string]any { allowed := map[string]struct{}{ "owner_did": {}, + "device_id": {}, "key_package_id": {}, "suite": {}, "mls_key_package_b64u": {}, diff --git a/internal/message/group_wire_test.go b/internal/message/group_wire_test.go index cba1d18..45f8833 100644 --- a/internal/message/group_wire_test.go +++ b/internal/message/group_wire_test.go @@ -166,11 +166,13 @@ func TestBuildGroupE2EESendRPCParamsSendsOnlyOpaqueCipherObject(t *testing.T) { record := testStoredIdentity(t) params, err := BuildGroupE2EESendRPCParams(record, nil, "did:wba:awiki.ai:groups:demo:e1_group", map[string]any{ - "crypto_group_id_b64u": "Y3J5cHRv", - "epoch": "1", - "private_message_b64u": "Y2lwaGVy", - "epoch_authenticator": "YXV0aA", - "group_state_ref": map[string]any{"group_did": "did:wba:awiki.ai:groups:demo:e1_group"}, + "crypto_group_id_b64u": "Y3J5cHRv", + "openmls_group_id_b64u": "provider-local", + "epoch": "1", + "private_message_b64u": "Y2lwaGVy", + "epoch_authenticator": "YXV0aA", + "group_state_ref": map[string]any{"group_did": "did:wba:awiki.ai:groups:demo:e1_group"}, + "application_plaintext": map[string]any{"text": "secret"}, }) if err != nil { t.Fatalf("BuildGroupE2EESendRPCParams() error = %v", err) @@ -189,9 +191,19 @@ func TestBuildGroupE2EESendRPCParamsSendsOnlyOpaqueCipherObject(t *testing.T) { if _, ok := body["application_plaintext"]; ok { t.Fatalf("plaintext leaked into E2EE send body: %#v", body) } + groupCipher := mustMapValue(t, body["group_cipher_object"], "body.group_cipher_object") if _, ok := body["group_cipher_object"]; !ok { t.Fatalf("group_cipher_object missing: %#v", body) } + if _, ok := groupCipher["openmls_group_id_b64u"]; ok { + t.Fatalf("provider-local OpenMLS group id leaked into service body: %#v", groupCipher) + } + if _, ok := groupCipher["application_plaintext"]; ok { + t.Fatalf("plaintext leaked into service cipher object: %#v", groupCipher) + } + if got := stringFromAny(groupCipher["crypto_group_id_b64u"]); got != "Y3J5cHRv" { + t.Fatalf("crypto_group_id_b64u = %q, want Y3J5cHRv", got) + } } func TestBuildGroupE2EEAddRPCParamsIncludesConsumedKeyPackageID(t *testing.T) { @@ -241,8 +253,8 @@ func TestBuildGroupE2EEPublishKeyPackageRPCParamsStripsProviderOnlyFields(t *tes } body := mustMapValue(t, params["body"], "params.body") groupKeyPackage := mustMapValue(t, body["group_key_package"], "body.group_key_package") - if _, ok := groupKeyPackage["device_id"]; ok { - t.Fatalf("device_id leaked into service KeyPackage payload: %#v", groupKeyPackage) + if got := stringFromAny(groupKeyPackage["device_id"]); got != "bob-main" { + t.Fatalf("device_id = %q, want public device binding", got) } if _, ok := groupKeyPackage["private_key_package_b64u"]; ok { t.Fatalf("private provider field leaked into service KeyPackage payload: %#v", groupKeyPackage) From 638b47a54f41c1f4c039975839b084c8bd68204f Mon Sep 17 00:00:00 2001 From: changshan Date: Sun, 3 May 2026 18:33:53 +0800 Subject: [PATCH 07/14] Keep CLI Group E2EE orchestration protocol-correct while hidden The CLI now drives the hidden Group E2EE loop through the P6 target/security matrix, ratchet-tree welcome replay, explicit send AAD metadata, and durable notice repair without introducing a background process or CGO. The change also strengthens doctor/install diagnostics around the one-shot anp-mls binary and scoped state. Constraint: Group E2EE must remain hidden/test-only and must not imply public product support. Constraint: anp-mls receives plaintext only over stdin/stdout JSON, never argv. Rejected: Cache MLS epoch as P4 group_state_version | service state and MLS epochs are separate and must be bound independently. Rejected: Implement broad group lifecycle commands | v1 scope is publish/create/add/welcome/send/decrypt plus repair diagnostics. Confidence: high Scope-risk: moderate Directive: Keep help/discovery copy conservative until the service explicitly enables public discovery after security review. Tested: go test ./internal/message ./internal/cli ./internal/cmdmeta ./internal/doctor -count=1; go vet ./internal/message ./internal/doctor; focused awiki-system-test CLI real-MLS loop 2 passed Not-tested: Public beta packaging across all OS release artifacts --- CLAUDE.md | 10 +- docs/installation.md | 2 +- ...up-e2ee-p6-conformance-before-discovery.md | 39 +++ docs/pr-notes/group-e2ee-v1-pr-closeout.md | 35 +++ internal/cli/group_e2ee.go | 54 ++-- internal/cli/group_test.go | 37 +++ internal/cmdmeta/catalog.go | 4 +- internal/doctor/doctor.go | 201 ++++++++++----- internal/doctor/doctor_test.go | 116 +++++++++ internal/message/group_e2ee_provider.go | 1 + internal/message/group_e2ee_service.go | 237 ++++++++++++++++-- internal/message/group_service_test.go | 21 ++ internal/message/group_wire.go | 100 ++++++-- internal/message/group_wire_test.go | 107 +++++++- internal/message/http_client.go | 33 ++- 15 files changed, 865 insertions(+), 132 deletions(-) create mode 100644 docs/pr-notes/group-e2ee-p6-conformance-before-discovery.md create mode 100644 docs/pr-notes/group-e2ee-v1-pr-closeout.md diff --git a/CLAUDE.md b/CLAUDE.md index b1dbb6e..faaa094 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ **internal/cmdmeta/catalog.go**: 静态命令元数据目录,作为 schema/命令骨架的事实来源。 **internal/config/config.go**: 单根目录工作区路径解析(默认 `~/.awiki-cli/`)、仅支持 `AWIKI_CLI_WORKSPACE_HOME_DIR` 作为工作区环境变量,并统一解析 `config.yaml`;旧 `config.json` 由 workspace upgrade 在首次访问时自动迁移到 `config.yaml`,其余历史业务环境变量不再驱动 awiki-cli 行为;默认 `ANPMessageService` 从 `service_base_url` 推导而不是从 `did_domain` 推导。 **internal/output/output.go**: 统一 success/error JSON envelope、`--jq`、table/ndjson 渲染。 -**internal/doctor/doctor.go**: 诊断实现,检查构建、配置、env、identity store、SQLite、legacy 路径与 legacy DB;SQLite 检查会额外暴露 `contact_handle_bindings` 历史映射表状态与行数。 +**internal/doctor/doctor.go**: 诊断实现,检查构建、配置、env、identity store、SQLite、legacy 路径、legacy DB 与 `anp-mls` binary/版本/状态目录;SQLite 检查会额外暴露 `contact_handle_bindings` 历史映射表状态与行数,MLS 检查会同时扫描 root 与 agent/device-scoped `state.db`/`state.lock`。 **internal/docs/topics.go**: CLI 内建 docs 主题索引,`skills` 主题引用当前 single-entry `skills/SKILL.md` 与懒加载 `skills/references/*.md` 拓扑。 **internal/anpsdk/registry.go**: ANP Go SDK 的远端模块依赖入口,统一暴露 DID WBA、HTTP Signatures、direct_e2ee 等后续 Phase 要用到的基础能力。 **internal/authsdk/session.go**: 基于 ANP SDK `DIDWbaAuthHeader` 的身份鉴权封装,负责 HTTP/WSS hop auth、401 重试、JWT token 捕获与持久化。 @@ -64,7 +64,7 @@ **internal/cli/debug.go**: `debug db query`、`debug db handle-history` 与 `debug db import-v1` 的 CLI 处理器。 **internal/cli/msg.go**: `msg send/inbox/history/mark-read` 的 CLI 处理器,现已支持 direct + group plain messaging。 **internal/cli/group.go**: `group create/get/join/add/remove/leave/update/members/messages` 的 CLI 处理器。 -**internal/cli/group_e2ee.go**: P6 group E2EE 诊断/维护命令处理器;支持本地 exec provider/status/KeyPackage 发布路径,`contract-test` 仅在显式 flag 下启用。 +**internal/cli/group_e2ee.go**: P6 group E2EE 诊断/维护命令处理器;支持本地 exec provider/status/KeyPackage 发布路径,以及 hidden/test-only `group.e2ee.notice` pending/repair 拉取与 welcome 重放;`contract-test` 仅在显式 flag 下启用。 **internal/identity/types.go**: identity store、legacy scan、command result 等核心类型。 **internal/identity/layout.go**: identity 根目录、index.json、路径与安全写入辅助。 **internal/identity/store.go**: 当前 v2 identity store 的读写、默认 identity 管理。 @@ -94,9 +94,9 @@ **internal/message/secure.go**: P5 direct E2EE secure send 的首版编排层,使用 ANP Go SDK direct_e2ee、本地文件会话/预密钥存储和 HTTP JSON-RPC;key-service 请求绑定当前 DID 文档里 `ANPMessageService.serviceDid`,并在有可用 sidecar OPK 时优先用 OPK 建链(本地保存 `p5-one-time-prekeys/`);HTTP inbox/history 现已接入入站密文解密与会话推进,并会顺带补发本地 prekey bundle;轮询路径解密 direct-init 成功后会自动发送 encrypted ACK 并尝试 flush 该 peer 的 `e2ee_outbox`;当 initiator 仍处于 `pending-confirmation` 时,新的 secure 发送会进入 `e2ee_outbox` 排队。 **internal/message/secure_control.go**: secure 控制面与恢复辅助,负责 secure ack/init payload、pending 阶段的 `e2ee_outbox` 排队、secure outbox flush,以及 `msg secure status/init/repair/failed/retry/drop` 需要的本地会话/发件箱读取与重试逻辑。 **internal/message/group_e2ee_provider.go**: `anp-mls` exec provider 抽象;按 `AWIKI_ANP_MLS_BINARY`、测试/运行时注入路径、`PATH` 顺序发现二进制;JSON request 走 stdin、response 走 stdout、日志/错误走 stderr,默认 MLS 根目录为 `/mls`,实际 OpenMLS 私有状态按 agent/device 分到子目录,并可扫描同一 agent 下的本地 device state 供收件解密恢复,保持 Go 主工程 pure Go / no CGO。 -**internal/message/group_e2ee_service.go**: group E2EE 业务编排层;负责 KeyPackage 发布、owner create/add、send encrypt、messages decrypt,并在同一工作区存在目标成员身份时本地处理 add 返回的 welcome notice,使 one-shot `anp-mls` agent/device 状态可恢复。 -**internal/message/group_wire.go**: group 标准面和 local-only RPC 参数构造器;group E2EE send 会在签名/发送前裁剪 provider-local MLS 字段,只把 P6 service 允许的 opaque cipher 字段送到 message-service。 -**internal/message/http_client.go**: direct/group message 与 group lifecycle 的 HTTP JSON-RPC adapter。 +**internal/message/group_e2ee_service.go**: group E2EE 业务编排层;负责 KeyPackage 发布、owner create/add、send encrypt、messages decrypt、P6 notice pending/repair、ratchet tree welcome process,以及 MLS AAD 元数据传入 `anp-mls`;在同一工作区存在目标成员身份时也会本地处理 add 返回的 welcome notice,使 one-shot `anp-mls` agent/device 状态可恢复。 +**internal/message/group_wire.go**: group 标准面和 local-only RPC 参数构造器;P6 publish/get/notice 使用 `transport-protected` service/agent target,create 使用 service target,add/send 使用 group target;group E2EE send 会在签名/发送前裁剪 provider-local MLS 字段,只把 P6 service 允许的 opaque cipher 字段送到 message-service。 +**internal/message/http_client.go**: direct/group message、group lifecycle 与 hidden/test-only P6 notice pull/mark-delivered 的 HTTP JSON-RPC adapter。 **internal/message/ws_proxy_client.go**: websocket 模式下通过本地 bridge 调用 listener/daemon 的 direct/group adapter。 **internal/message/service.go**: direct inbox/send/history/mark-read 的业务编排层,融合 transport、identity、store;支持收件后自动 DID→Handle 补全,以及按 handle 聚合历史 DID 消息。 **internal/message/contact_sync.go**: direct inbox/history 的联系人补全与 Handle 历史 DID 聚合辅助。 diff --git a/docs/installation.md b/docs/installation.md index 9923934..75e9460 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -167,7 +167,7 @@ By default the script builds `../anp/anp/rust` with Cargo and copies `anp-mls` t export AWIKI_ANP_MLS_BINARY=/absolute/path/to/anp-mls ``` -The MLS private state root remains `~/.awiki-cli/mls/` (or the current `AWIKI_CLI_WORKSPACE_HOME_DIR` equivalent). Runtime OpenMLS state is agent/device-scoped under that root (`mls/agents///state.db`) so two local identities do not share private KeyPackage storage. Incoming decrypt first tries the default device and then scans the local agent-scoped device directories, allowing one-shot CLI commands to restore state for KeyPackages published with a named device. `doctor` reports the root directory permissions plus state file status, including warnings when cached group-E2EE groups exist but MLS state is missing. +The MLS private state root remains `~/.awiki-cli/mls/` (or the current `AWIKI_CLI_WORKSPACE_HOME_DIR` equivalent). Runtime OpenMLS state is agent/device-scoped under that root (`mls/agents///state.db`) so two local identities do not share private KeyPackage storage. Incoming decrypt first tries the default device and then scans the local agent-scoped device directories, allowing one-shot CLI commands to restore state for KeyPackages published with a named device. `group e2ee pending` / `repair` use the hidden/test-only P6 `group.e2ee.notice` pull path to list and replay durable welcome notices; repair passes `welcome_b64u + ratchet_tree_b64u` back to `anp-mls` and marks only successfully processed notices delivered. `doctor` reports the root directory permissions plus state file status, including warnings when cached group-E2EE groups exist but MLS state is missing. ### 3.2 config.yaml diff --git a/docs/pr-notes/group-e2ee-p6-conformance-before-discovery.md b/docs/pr-notes/group-e2ee-p6-conformance-before-discovery.md new file mode 100644 index 0000000..d0e3039 --- /dev/null +++ b/docs/pr-notes/group-e2ee-p6-conformance-before-discovery.md @@ -0,0 +1,39 @@ +# Group E2EE Step B P6 Conformance Notes — awiki-cli + +## Scope + +- Update CLI P6 wire builders to match the current method matrix: + - publish/get/notice: `transport-protected` and service/agent target. + - create: `group-e2ee` with service target plus `body.group_did`. + - add/send: `group-e2ee` with group target. +- Pass `ratchet_tree_b64u` through add/welcome processing. +- Pass stable message/operation IDs and P6 AAD metadata into `anp-mls` encrypt/decrypt. +- Refresh the service group state before encrypted send and avoid treating MLS epoch as P4 `group_state_version`. +- Replace pending/repair skeletons with hidden/test-only `group.e2ee.notice` pull, welcome replay, and mark-delivered. + +## Public discovery stance + +- CLI commands remain diagnostics/maintenance for hidden Group E2EE v1 focused validation. +- Do not update public help/docs to imply broad Group E2EE support; discovery remains controlled by message-service and must stay hidden by default. + +## Config / packaging impact + +- No new config key. +- `AWIKI_ANP_MLS_BINARY` and release-staged `anp-mls` remain the install path. +- Go CLI remains pure Go / no CGO; plaintext and MLS private state stay out of argv and service storage. + +## Fresh validation evidence + +- `go test ./internal/message ./internal/cli ./internal/cmdmeta ./internal/doctor -count=1` → passed (`internal/message` 3.329s, `internal/cli` 55.483s, `internal/cmdmeta` 3.412s, `internal/doctor` 6.761s). +- `go vet ./internal/message ./internal/doctor` → passed. +- Focused CLI system loop via `awiki-system-test` with `--with-message-v2 --use-local-anp` → passed: 2 passed in 18.65s. +- After focused system tests, the local environment was stopped and published ANP Python/Rust dependencies were restored. + +## Rollback + +- Revert the Step B wire/service additions. Pending/repair would return to diagnostic-only behavior and public discovery must remain disabled. + +## Caveats + +- Still no remove/leave, External Commit, attachment group E2EE, cloud snapshot, or product-wide public beta claim. +- No k1 DID compatibility is included. diff --git a/docs/pr-notes/group-e2ee-v1-pr-closeout.md b/docs/pr-notes/group-e2ee-v1-pr-closeout.md new file mode 100644 index 0000000..267c804 --- /dev/null +++ b/docs/pr-notes/group-e2ee-v1-pr-closeout.md @@ -0,0 +1,35 @@ +# Group E2EE v1 PR Closeout Notes — awiki-cli + +## Scope + +- Extend `awiki-cli doctor` to inspect both legacy/root MLS state and real agent/device-scoped `anp-mls` state directories. +- Clean user-facing E2EE diagnostic wording so status/pending/repair no longer describe the real MLS path as a contract-test scaffold. +- Keep the Go CLI pure Go / no CGO and keep `anp-mls` as an exec provider using stdin/stdout. + +## Commits / branch context + +- Current branch is ahead of origin with recent Group E2EE work through `b0856f4 Make group E2EE CLI restore named-device MLS state`. +- This closeout is Step A only; it does not implement P6 conformance Step B. + +## Config / migration impact + +- No CLI business SQLite schema migration. +- No new config key. +- `AWIKI_ANP_MLS_BINARY` remains the explicit binary override. +- Doctor now reports scoped MLS state under `/mls/agents///state.db` and `state.lock` when present. + +## Validation + +- Fresh evidence collected in this Ralph pass: + - `go test -count=1 ./internal/doctor ./internal/message ./internal/cli` → passed after the deslop pass (`internal/doctor` 3.643s, `internal/message` 1.256s, `internal/cli` 54.811s). + - `go vet ./internal/doctor ./internal/message` → passed. + +## Rollback + +- Revert doctor scoped-state scan and CLI wording changes. This does not affect plain messaging. + +## Caveats + +- `group e2ee pending` / `repair` now use the hidden/test-only P6 notice pull/replay path; public discovery still requires the separate gate in Step B notes. +- Group E2EE remains hidden/test-only; no public discovery. +- No k1 DID compatibility work is included. diff --git a/internal/cli/group_e2ee.go b/internal/cli/group_e2ee.go index 9daca32..49df414 100644 --- a/internal/cli/group_e2ee.go +++ b/internal/cli/group_e2ee.go @@ -22,7 +22,6 @@ func (a *App) runGroupE2EEStatus(cmd *cobra.Command, args []string) error { "runtime_mode": service.Config().RuntimeMode, "profile": message.GroupE2EEProfile, "security_profile": message.GroupE2EESecurityProfile, - "artifact_mode": message.GroupE2EEContractArtifactMode, "provider": "exec", "binary": provider.BinaryPath, "mls_data_dir": provider.DataDir, @@ -39,11 +38,10 @@ func (a *App) runGroupE2EEStatus(cmd *cobra.Command, args []string) error { } provider.Timeout = 5 * time.Second resp, callErr := provider.Call(cmd.Context(), "group", "status", message.MLSRequest{ - APIVersion: "anp-mls/v1", - RequestID: fmt.Sprintf("group-e2ee-status-%d", time.Now().UnixNano()), - AgentDID: agentDID, - ContractTestEnabled: true, - Params: map[string]any{"agent_did": agentDID, "group_did": group}, + APIVersion: "anp-mls/v1", + RequestID: fmt.Sprintf("group-e2ee-status-%d", time.Now().UnixNano()), + AgentDID: agentDID, + Params: map[string]any{"agent_did": agentDID, "group_did": group}, }) data := map[string]any{"plan": plan, "available": callErr == nil} if callErr != nil { @@ -51,7 +49,7 @@ func (a *App) runGroupE2EEStatus(cmd *cobra.Command, args []string) error { } else if resp != nil { data["mls"] = resp.Result } - return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, data, "Group E2EE contract-test status inspected", warnings, a.identityMeta()) + return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, data, "Group E2EE local MLS status inspected", warnings, a.identityMeta()) } func (a *App) runGroupE2EEPublishKeyPackage(cmd *cobra.Command, args []string) error { @@ -84,23 +82,29 @@ func (a *App) runGroupE2EEPublishKeyPackage(cmd *cobra.Command, args []string) e } func (a *App) runGroupE2EEPending(cmd *cobra.Command, args []string) error { + group, _ := cmd.Flags().GetString("group") service, format, err := a.messageService() if err != nil { return a.messageExit(err, "Run `awiki-cli doctor` to inspect configuration and identity state.") } provider := message.NewDefaultMLSExecProvider(service.Config()) - data := map[string]any{ - "plan": map[string]any{ - "action": "group.e2ee.pending", - "identity": a.globals.Identity, - "runtime_mode": service.Config().RuntimeMode, - "provider": "exec", - "mls_data_dir": provider.DataDir, - }, - "pending": []any{}, - "note": "contract-test skeleton only; real OpenMLS pending queue will be added with MLS state integration", - } - return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, data, "Group E2EE pending queue inspected", nil, a.identityMeta()) + plan := map[string]any{ + "action": "group.e2ee.pending", + "identity": a.globals.Identity, + "runtime_mode": service.Config().RuntimeMode, + "provider": "exec", + "mls_data_dir": provider.DataDir, + "group": group, + } + if a.globals.DryRun { + return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, map[string]any{"plan": plan}, "Dry run: group e2ee pending planned", nil, a.identityMeta()) + } + result, pendingErr := service.PullGroupE2EENotices(cmd.Context(), a.globals.Identity, group, 50) + if pendingErr != nil { + return a.messageExit(pendingErr, "Ensure message-service group E2EE test flag is enabled for focused validation; discovery remains hidden by default.") + } + result.Data["plan"] = plan + return a.renderMessageResult(cmd, format, result) } func (a *App) runGroupE2EERepair(cmd *cobra.Command, args []string) error { @@ -117,9 +121,17 @@ func (a *App) runGroupE2EERepair(cmd *cobra.Command, args []string) error { "provider": "exec", "mls_data_dir": provider.DataDir, "group": group, - "scope": "replay pending notices and verify local MLS DB summary", + "scope": "pull durable P6 notices, replay welcome-delivery, and mark processed notices delivered", } - return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, map[string]any{"plan": plan}, "Group E2EE repair planned", nil, a.identityMeta()) + if a.globals.DryRun { + return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, map[string]any{"plan": plan}, "Dry run: group e2ee repair planned", nil, a.identityMeta()) + } + result, repairErr := service.RepairGroupE2EENotices(cmd.Context(), a.globals.Identity, group, 50) + if repairErr != nil { + return a.messageExit(repairErr, "Install anp-mls, set AWIKI_ANP_MLS_BINARY, and ensure message-service group E2EE APIs are enabled for focused validation.") + } + result.Data["plan"] = plan + return a.renderMessageResult(cmd, format, result) } func activeIdentityDID(service *message.Service, name string) (string, error) { diff --git a/internal/cli/group_test.go b/internal/cli/group_test.go index c979800..0408ef7 100644 --- a/internal/cli/group_test.go +++ b/internal/cli/group_test.go @@ -83,11 +83,44 @@ func TestGroupDryRunPlansRenderStableContracts(t *testing.T) { if plan["discovery_advertised"] != false { t.Fatalf("plan.discovery_advertised = %#v, want false", plan["discovery_advertised"]) } + if _, ok := plan["artifact_mode"]; ok { + t.Fatalf("plan.artifact_mode should not be exposed by real MLS status diagnostics: %#v", plan) + } if plan["mls_data_dir"] == "" { t.Fatal("plan.mls_data_dir should be populated") } }, }, + { + name: "group e2ee pending plans P6 notice pull", + spec: "group.e2ee.pending", + setFlags: map[string]string{"group": "did:wba:example.com:groups:demo:e1_group"}, + wantSummary: "Dry run: group e2ee pending planned", + wantAction: "group.e2ee.pending", + verifyPlan: func(t *testing.T, plan map[string]any) { + if plan["provider"] != "exec" { + t.Fatalf("plan.provider = %#v, want exec", plan["provider"]) + } + if plan["group"] != "did:wba:example.com:groups:demo:e1_group" { + t.Fatalf("plan.group = %#v, want group DID", plan["group"]) + } + }, + }, + { + name: "group e2ee repair plans P6 notice replay", + spec: "group.e2ee.repair", + setFlags: map[string]string{"group": "did:wba:example.com:groups:demo:e1_group"}, + wantSummary: "Dry run: group e2ee repair planned", + wantAction: "group.e2ee.repair", + verifyPlan: func(t *testing.T, plan map[string]any) { + if plan["provider"] != "exec" { + t.Fatalf("plan.provider = %#v, want exec", plan["provider"]) + } + if plan["scope"] == "" { + t.Fatalf("plan.scope should describe durable notice replay: %#v", plan) + } + }, + }, { name: "group create e2ee alias maps to group-e2ee request", spec: "group.create", @@ -130,6 +163,10 @@ func TestGroupDryRunPlansRenderStableContracts(t *testing.T) { return app.runGroupMessages(cmd, nil) case "group.e2ee.status": return app.runGroupE2EEStatus(cmd, nil) + case "group.e2ee.pending": + return app.runGroupE2EEPending(cmd, nil) + case "group.e2ee.repair": + return app.runGroupE2EERepair(cmd, nil) default: t.Fatalf("unsupported spec %q", tc.spec) return nil diff --git a/internal/cmdmeta/catalog.go b/internal/cmdmeta/catalog.go index 731417d..28203a7 100644 --- a/internal/cmdmeta/catalog.go +++ b/internal/cmdmeta/catalog.go @@ -169,8 +169,8 @@ func defaultSpecs() []CommandSpec { {Name: "group.e2ee", Use: "e2ee", Short: "Inspect test-only group E2EE state", Phase: "phase6", Implemented: true}, {Name: "group.e2ee.status", Use: "status", Short: "Inspect local group E2EE MLS provider status", Phase: "phase6", Implemented: true, Handler: "group.e2ee.status", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID"}}}, {Name: "group.e2ee.publish-key-package", Use: "publish-key-package", Short: "Plan a test-only group E2EE KeyPackage publish", Phase: "phase6", Implemented: true, Handler: "group.e2ee.publish-key-package", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "device", Type: "string", Usage: "Local MLS device id", Default: "default"}, {Name: "contract-test", Type: "bool", Usage: "Use non-cryptographic contract-test artifacts"}}}, - {Name: "group.e2ee.pending", Use: "pending", Short: "Inspect pending group E2EE contract-test work", Phase: "phase6", Implemented: true, Handler: "group.e2ee.pending", Outputs: []string{"json", "pretty", "table"}}, - {Name: "group.e2ee.repair", Use: "repair", Short: "Plan a group E2EE contract-test repair pass", Phase: "phase6", Implemented: true, Handler: "group.e2ee.repair", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}}}, + {Name: "group.e2ee.pending", Use: "pending", Short: "Pull pending group E2EE P6 notices", Phase: "phase6", Implemented: true, Handler: "group.e2ee.pending", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Optional group DID filter"}}}, + {Name: "group.e2ee.repair", Use: "repair", Short: "Replay pending group E2EE P6 notices", Phase: "phase6", Implemented: true, Handler: "group.e2ee.repair", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Optional group DID filter"}}}, {Name: "group.code", Use: "code", Short: "Inspect or manage group join codes", Phase: "phase5", Implemented: false}, {Name: "group.code.get", Use: "get", Short: "Show group join code status", Phase: "phase5", Implemented: false, Handler: "stub", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}}}, {Name: "group.code.refresh", Use: "refresh", Short: "Rotate the current group join code", Phase: "phase5", Implemented: false, Handler: "stub", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}}}, diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 2185e42..58cbe81 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -80,22 +80,27 @@ func anpMLSCheck(resolved *config.Resolved) Check { binary, resolveErr := provider.ResolveBinaryPath() state := inspectMLSState(resolved, provider.DataDir) details := map[string]any{ - "binary": binary, - "data_dir": provider.DataDir, - "env_override": message.ANPMLSBinaryEnv, - "plain_unaffected": true, - "resolve_error": errorString(resolveErr), - "remediation": anpMLSRemediation(resolveErr, nil, nil, state), - "data_dir_status": state.DataDirStatus, - "data_dir_exists": state.DataDirExists, - "data_dir_error": state.DataDirError, - "state_db": state.StateDBPath, - "state_db_status": state.StateDBStatus, - "state_db_error": state.StateDBError, - "state_lock": state.StateLockPath, - "state_lock_status": state.StateLockStatus, - "state_lock_error": state.StateLockError, - "e2ee_group_count": state.E2EEGroupCount, + "binary": binary, + "data_dir": provider.DataDir, + "env_override": message.ANPMLSBinaryEnv, + "plain_unaffected": true, + "resolve_error": errorString(resolveErr), + "remediation": anpMLSRemediation(resolveErr, nil, nil, state), + "data_dir_status": state.DataDirStatus, + "data_dir_exists": state.DataDirExists, + "data_dir_error": state.DataDirError, + "state_db": state.StateDBPath, + "state_db_status": state.StateDBStatus, + "state_db_error": state.StateDBError, + "state_lock": state.StateLockPath, + "state_lock_status": state.StateLockStatus, + "state_lock_error": state.StateLockError, + "scoped_states": state.ScopedStates, + "scoped_state_count": state.ScopedStateCount, + "scoped_state_db_count": state.ScopedStateDBCount, + "scoped_state_lock_count": state.ScopedStateLockCount, + "scoped_state_warning_count": state.ScopedStateWarningCount, + "e2ee_group_count": state.E2EEGroupCount, } status := "ok" summary := "anp-mls binary and compatibility probe are ready for group E2EE operations" @@ -128,20 +133,37 @@ func anpMLSCheck(resolved *config.Resolved) Check { } type mlsStateInspection struct { - DataDirExists bool - DataDirStatus string - DataDirError string - StateDBPath string - StateDBStatus string - StateDBError string - StateLockPath string - StateLockStatus string - StateLockError string - E2EEGroupCount int + DataDirExists bool + DataDirStatus string + DataDirError string + StateDBPath string + StateDBStatus string + StateDBError string + StateLockPath string + StateLockStatus string + StateLockError string + ScopedStates []mlsScopedStateInspection + ScopedStateCount int + ScopedStateDBCount int + ScopedStateLockCount int + ScopedStateWarningCount int + E2EEGroupCount int +} + +type mlsScopedStateInspection struct { + AgentKey string `json:"agent_key"` + DeviceID string `json:"device_id"` + Dir string `json:"dir"` + StateDBPath string `json:"state_db"` + StateDBStatus string `json:"state_db_status"` + StateDBError string `json:"state_db_error,omitempty"` + StateLockPath string `json:"state_lock"` + StateLockStatus string `json:"state_lock_status"` + StateLockError string `json:"state_lock_error,omitempty"` } func (s mlsStateInspection) HasWarning() bool { - return strings.HasPrefix(s.DataDirStatus, "warn") || strings.HasPrefix(s.StateDBStatus, "warn") || strings.HasPrefix(s.StateLockStatus, "warn") + return strings.HasPrefix(s.DataDirStatus, "warn") || strings.HasPrefix(s.StateDBStatus, "warn") || strings.HasPrefix(s.StateLockStatus, "warn") || s.ScopedStateWarningCount > 0 } func inspectMLSState(resolved *config.Resolved, dataDir string) mlsStateInspection { @@ -185,44 +207,107 @@ func inspectMLSState(resolved *config.Resolved, dataDir string) mlsStateInspecti state.DataDirError = err.Error() } - if info, err := os.Stat(state.StateDBPath); err != nil { - if os.IsNotExist(err) { - if state.E2EEGroupCount > 0 { - state.StateDBStatus = "warn_missing_with_cached_groups" - } else { - state.StateDBStatus = "missing" + state.ScopedStates = inspectScopedMLSStates(dataDir) + state.ScopedStateCount = len(state.ScopedStates) + for _, scoped := range state.ScopedStates { + if scoped.StateDBStatus == "ok" { + state.ScopedStateDBCount++ + } + if scoped.StateLockStatus != "missing" { + state.ScopedStateLockCount++ + } + if strings.HasPrefix(scoped.StateDBStatus, "warn") || strings.HasPrefix(scoped.StateLockStatus, "warn") { + state.ScopedStateWarningCount++ + } + } + + state.StateDBStatus, state.StateDBError = inspectMLSStateDB(state.StateDBPath) + if state.StateDBStatus == "missing" && state.E2EEGroupCount > 0 && state.ScopedStateDBCount == 0 { + state.StateDBStatus = "warn_missing_with_cached_groups" + } + + state.StateLockStatus, state.StateLockError = inspectMLSLock(state.StateLockPath) + return state +} + +func inspectScopedMLSStates(dataDir string) []mlsScopedStateInspection { + agentsDir := filepath.Join(dataDir, "agents") + agentEntries, err := os.ReadDir(agentsDir) + if err != nil { + return nil + } + states := []mlsScopedStateInspection{} + for _, agentEntry := range agentEntries { + if !agentEntry.IsDir() { + continue + } + agentKey := agentEntry.Name() + agentDir := filepath.Join(agentsDir, agentKey) + deviceEntries, err := os.ReadDir(agentDir) + if err != nil { + states = append(states, mlsScopedStateInspection{ + AgentKey: agentKey, + Dir: agentDir, + StateDBStatus: "warn_read_devices_failed", + StateDBError: err.Error(), + StateLockStatus: "missing", + }) + continue + } + for _, deviceEntry := range deviceEntries { + if !deviceEntry.IsDir() { + continue } - } else { - state.StateDBStatus = "warn_stat_failed" - state.StateDBError = err.Error() + deviceID := deviceEntry.Name() + dir := filepath.Join(agentDir, deviceID) + dbPath := filepath.Join(dir, "state.db") + lockPath := filepath.Join(dir, "state.lock") + dbStatus, dbErr := inspectMLSStateDB(dbPath) + lockStatus, lockErr := inspectMLSLock(lockPath) + states = append(states, mlsScopedStateInspection{ + AgentKey: agentKey, + DeviceID: deviceID, + Dir: dir, + StateDBPath: dbPath, + StateDBStatus: dbStatus, + StateDBError: dbErr, + StateLockPath: lockPath, + StateLockStatus: lockStatus, + StateLockError: lockErr, + }) + } + } + return states +} + +func inspectMLSStateDB(path string) (string, string) { + if info, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return "missing", "" } + return "warn_stat_failed", err.Error() } else if info.IsDir() { - state.StateDBStatus = "warn_not_file" - } else if err := canReadFile(state.StateDBPath); err != nil { - state.StateDBStatus = "warn_not_readable" - state.StateDBError = err.Error() - } else { - state.StateDBStatus = "ok" + return "warn_not_file", "" + } else if err := canReadFile(path); err != nil { + return "warn_not_readable", err.Error() } + return "ok", "" +} - if info, err := os.Stat(state.StateLockPath); err != nil { +func inspectMLSLock(path string) (string, string) { + if info, err := os.Stat(path); err != nil { if os.IsNotExist(err) { - state.StateLockStatus = "missing" - } else { - state.StateLockStatus = "warn_stat_failed" - state.StateLockError = err.Error() + return "missing", "" } + return "warn_stat_failed", err.Error() } else if info.IsDir() { - state.StateLockStatus = "warn_not_file" - } else if err := canReadFile(state.StateLockPath); err != nil { - state.StateLockStatus = "warn_not_readable" - state.StateLockError = err.Error() + return "warn_not_file", "" + } else if err := canReadFile(path); err != nil { + return "warn_not_readable", err.Error() } else if time.Since(info.ModTime()) > 15*time.Minute { - state.StateLockStatus = "warn_stale_candidate" - } else { - state.StateLockStatus = "present_active_or_recent" + return "warn_stale_candidate", "" } - return state + return "present_active_or_recent", "" } func canReadDir(path string) error { @@ -314,9 +399,9 @@ func anpMLSRemediation(resolveErr error, probeErr error, compatErr error, state case state.DataDirStatus == "warn_not_writable" || state.DataDirStatus == "warn_not_readable": return "Fix permissions on the MLS data directory or move the workspace with AWIKI_CLI_WORKSPACE_HOME_DIR." case state.StateDBStatus == "warn_missing_with_cached_groups": - return "The business database has cached group-e2ee groups but MLS state.db is missing; restore the MLS data directory from backup before sending encrypted group messages." - case strings.HasPrefix(state.StateLockStatus, "warn"): - return "If no anp-mls process is running, remove stale state.lock after backing up the MLS data directory." + return "The business database has cached group-e2ee groups but no root or agent/device-scoped MLS state.db was found; restore the MLS data directory from backup before sending encrypted group messages." + case strings.HasPrefix(state.StateLockStatus, "warn") || state.ScopedStateWarningCount > 0: + return "If no anp-mls process is running, inspect root and agent/device-scoped state.lock files, then remove stale locks only after backing up the MLS data directory." default: return "No action required." } diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go index 7f4267f..01f6358 100644 --- a/internal/doctor/doctor_test.go +++ b/internal/doctor/doctor_test.go @@ -2,6 +2,8 @@ package doctor import ( "context" + "crypto/sha256" + "encoding/base64" "os" "path/filepath" "strings" @@ -366,6 +368,115 @@ func TestANPMLSDoctorWarnsWhenCachedE2EEGroupsHaveNoMLSState(t *testing.T) { } } +func TestANPMLSDoctorAcceptsAgentDeviceScopedState(t *testing.T) { + resolved := resolveDoctorConfig(t, false) + binDir := t.TempDir() + writeFakeANPMLS(t, binDir, `{"ok":true,"api_version":"anp-mls/v1","request_id":"doctor-system-version","result":{"api_version":"anp-mls/v1","binary_name":"anp-mls","binary_version":"test","supported_commands":["system version"]}}`) + t.Setenv("PATH", binDir) + db, err := store.Open(resolved.Paths) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err := store.EnsureSchema(context.Background(), db); err != nil { + t.Fatal(err) + } + if err := store.UpsertGroup(context.Background(), db, store.GroupRecord{ + OwnerDID: "did:wba:alice.example", + GroupID: "group-1", + Name: "Secret group", + Metadata: `{"message_security_profile":"group-e2ee"}`, + CredentialName: "alice", + }); err != nil { + t.Fatal(err) + } + scopedDir := filepath.Join( + resolved.Paths.WorkspaceHomeDir, + "mls", + "agents", + testMLSAgentKey("did:wba:alice.example"), + "laptop", + ) + if err := os.MkdirAll(scopedDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(scopedDir, "state.db"), []byte("sqlite placeholder"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(scopedDir, "state.lock"), []byte("lock"), 0o600); err != nil { + t.Fatal(err) + } + + report := Run(resolved) + check := checkByName(t, report, "anp_mls") + if check.Status != "ok" { + t.Fatalf("anp_mls status = %q, want ok; details=%#v", check.Status, check.Details) + } + if check.Details["state_db_status"] != "missing" { + t.Fatalf("root state_db_status = %#v, want missing when scoped state exists", check.Details["state_db_status"]) + } + if check.Details["scoped_state_db_count"] != 1 { + t.Fatalf("scoped_state_db_count = %#v, want 1", check.Details["scoped_state_db_count"]) + } + if check.Details["scoped_state_lock_count"] != 1 { + t.Fatalf("scoped_state_lock_count = %#v, want 1", check.Details["scoped_state_lock_count"]) + } + scoped, ok := check.Details["scoped_states"].([]mlsScopedStateInspection) + if !ok || len(scoped) != 1 { + t.Fatalf("scoped_states = %#v, want one scoped state", check.Details["scoped_states"]) + } + if scoped[0].AgentKey != testMLSAgentKey("did:wba:alice.example") || scoped[0].DeviceID != "laptop" { + t.Fatalf("scoped state identity = %#v", scoped[0]) + } + if check.Details["remediation"] != "No action required." { + t.Fatalf("remediation = %#v", check.Details["remediation"]) + } +} + +func TestANPMLSDoctorWarnsOnStaleScopedLock(t *testing.T) { + resolved := resolveDoctorConfig(t, false) + binDir := t.TempDir() + writeFakeANPMLS(t, binDir, `{"ok":true,"api_version":"anp-mls/v1","request_id":"doctor-system-version","result":{"api_version":"anp-mls/v1","binary_name":"anp-mls","binary_version":"test","supported_commands":["system version"]}}`) + t.Setenv("PATH", binDir) + scopedDir := filepath.Join( + resolved.Paths.WorkspaceHomeDir, + "mls", + "agents", + testMLSAgentKey("did:wba:alice.example"), + "default", + ) + if err := os.MkdirAll(scopedDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(scopedDir, "state.db"), []byte("sqlite placeholder"), 0o600); err != nil { + t.Fatal(err) + } + lockPath := filepath.Join(scopedDir, "state.lock") + if err := os.WriteFile(lockPath, []byte("lock"), 0o600); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-30 * time.Minute) + if err := os.Chtimes(lockPath, old, old); err != nil { + t.Fatal(err) + } + + report := Run(resolved) + check := checkByName(t, report, "anp_mls") + if check.Status != "warn" { + t.Fatalf("anp_mls status = %q, want warn; details=%#v", check.Status, check.Details) + } + if check.Details["scoped_state_warning_count"] != 1 { + t.Fatalf("scoped_state_warning_count = %#v, want 1", check.Details["scoped_state_warning_count"]) + } + scoped := check.Details["scoped_states"].([]mlsScopedStateInspection) + if scoped[0].StateLockStatus != "warn_stale_candidate" { + t.Fatalf("scoped state lock status = %#v, want stale warning", scoped[0]) + } + if got := check.Details["remediation"].(string); !strings.Contains(got, "agent/device-scoped state.lock") { + t.Fatalf("remediation = %q", got) + } +} + func writeFakeANPMLS(t *testing.T, dir string, response string) string { t.Helper() if os.PathSeparator == ';' { @@ -380,3 +491,8 @@ func writeFakeANPMLS(t *testing.T, dir string, response string) string { } return path } + +func testMLSAgentKey(agentDID string) string { + sum := sha256.Sum256([]byte(agentDID)) + return base64.RawURLEncoding.EncodeToString(sum[:])[:24] +} diff --git a/internal/message/group_e2ee_provider.go b/internal/message/group_e2ee_provider.go index 892149c..5202a06 100644 --- a/internal/message/group_e2ee_provider.go +++ b/internal/message/group_e2ee_provider.go @@ -20,6 +20,7 @@ import ( const ( GroupE2EEProfile = "anp.group.e2ee.v1" GroupE2EESecurityProfile = "group-e2ee" + GroupE2EETransportProfile = "transport-protected" GroupE2EEContractArtifactMode = "contract-test" DefaultANPMLSBinary = "anp-mls" ANPMLSBinaryEnv = "AWIKI_ANP_MLS_BINARY" diff --git a/internal/message/group_e2ee_service.go b/internal/message/group_e2ee_service.go index 2ab8d15..18bd034 100644 --- a/internal/message/group_e2ee_service.go +++ b/internal/message/group_e2ee_service.go @@ -118,6 +118,9 @@ func (s *Service) addGroupMemberE2EE(ctx context.Context, record *identity.Store if keyPackageID := stringFromAny(leasedPackage["key_package_id"]); keyPackageID != "" { mlsHead["key_package_id"] = keyPackageID } + if groupKeyPackage, ok := leasedPackage["group_key_package"]; ok { + mlsHead["group_key_package"] = groupKeyPackage + } delivery, err := transport.AddGroupE2EE(ctx, groupDID, memberDID, mlsHead) if err != nil { return map[string]any{"mls": mlsHead, "leased_key_package": redactedKeyPackageSummary(leasedPackage)}, []string{fmt.Sprintf("Group E2EE add delivery failed: %v", err)} @@ -134,18 +137,27 @@ func (s *Service) addGroupMemberE2EE(ctx context.Context, record *identity.Store func (s *Service) sendGroupE2EE(ctx context.Context, record *identity.StoredIdentity, request SendRequest) (*CommandResult, error) { provider := s.groupMLSProvider() + warnings := s.syncGroupState(ctx, record, request.Group, false) groupStateRef := s.localGroupStateRef(ctx, record, request.Group) + operationID := "op-" + generateOperationID() + messageID := "msg-" + generateOperationID() + contentType := "application/anp-group-cipher+json" encryptResult, err := provider.Encrypt(ctx, MLSRequest{ APIVersion: "anp-mls/v1", RequestID: "group-e2ee-encrypt-" + generateOperationID(), AgentDID: record.DID, DeviceID: "default", Params: map[string]any{ - "agent_did": record.DID, - "device_id": "default", - "group_did": request.Group, - "group_state_ref": groupStateRef, - "message_type": request.MessageType, + "agent_did": record.DID, + "device_id": "default", + "group_did": request.Group, + "group_state_ref": groupStateRef, + "sender_did": record.DID, + "content_type": contentType, + "security_profile": GroupE2EESecurityProfile, + "message_id": messageID, + "operation_id": operationID, + "message_type": request.MessageType, "application_plaintext": map[string]any{ "application_content_type": contentTypeForMessageType(request.MessageType), "text": request.Text, @@ -159,11 +171,12 @@ func (s *Service) sendGroupE2EE(ctx context.Context, record *identity.StoredIden if len(cipher) == 0 { return nil, fmt.Errorf("anp-mls encrypt response missing group_cipher_object") } - transport, warnings, err := s.httpTransport(record) + transport, transportWarnings, err := s.httpTransport(record) + warnings = append(warnings, transportWarnings...) if err != nil { return nil, err } - delivery, err := transport.SendGroupE2EE(ctx, request.Group, cipher) + delivery, err := transport.SendGroupE2EE(ctx, request.Group, cipher, operationID, messageID) if err != nil { return nil, err } @@ -183,7 +196,8 @@ func (s *Service) maybeDecryptGroupMessages(ctx context.Context, record *identit if len(cipher) == 0 { continue } - plain, err := decryptGroupCipherWithDevices(ctx, provider, record.DID, groupDID, cipher, deviceIDs) + aad := groupE2EEAADParamsFromMessage(groupDID, item, cipher) + plain, err := decryptGroupCipherWithDevices(ctx, provider, record.DID, groupDID, cipher, aad, deviceIDs) if err != nil { warnings = append(warnings, fmt.Sprintf("Group E2EE decrypt failed for message %s: %v", stringFromAny(item["id"]), err)) continue @@ -198,25 +212,32 @@ func (s *Service) maybeDecryptGroupMessages(ctx context.Context, record *identit return compactWarnings(warnings), raw } -func decryptGroupCipherWithDevices(ctx context.Context, provider MLSExecProvider, agentDID string, groupDID string, cipher map[string]any, deviceIDs []string) (map[string]any, error) { +func decryptGroupCipherWithDevices(ctx context.Context, provider MLSExecProvider, agentDID string, groupDID string, cipher map[string]any, aad map[string]any, deviceIDs []string) (map[string]any, error) { if len(deviceIDs) == 0 { deviceIDs = []string{"default"} } var lastErr error for _, deviceID := range deviceIDs { deviceID = defaultString(strings.TrimSpace(deviceID), "default") + params := map[string]any{ + "agent_did": agentDID, + "recipient_did": agentDID, + "device_id": deviceID, + "group_did": groupDID, + "group_cipher_object": cipher, + "private_message_b64u": cipher["private_message_b64u"], + } + for key, value := range aad { + if value != nil { + params[key] = value + } + } plain, err := provider.Decrypt(ctx, MLSRequest{ APIVersion: "anp-mls/v1", RequestID: "group-e2ee-decrypt-" + generateOperationID(), AgentDID: agentDID, DeviceID: deviceID, - Params: map[string]any{ - "agent_did": agentDID, - "device_id": deviceID, - "group_did": groupDID, - "group_cipher_object": cipher, - "private_message_b64u": cipher["private_message_b64u"], - }, + Params: params, }) if err == nil { return plain, nil @@ -286,6 +307,10 @@ func (s *Service) processLocalGroupWelcome(ctx context.Context, memberDID string if welcomeB64U == "" { return nil, nil } + ratchetTreeB64U := stringFromAny(notice["ratchet_tree_b64u"]) + if ratchetTreeB64U == "" { + return nil, []string{"Group E2EE local welcome processing skipped: notice missing ratchet_tree_b64u"} + } memberRecord, err := s.localIdentityByDID(memberDID) if err != nil { return nil, nil @@ -298,11 +323,12 @@ func (s *Service) processLocalGroupWelcome(ctx context.Context, memberDID string AgentDID: memberRecord.DID, DeviceID: deviceID, Params: map[string]any{ - "agent_did": memberRecord.DID, - "device_id": deviceID, - "group_did": groupDID, - "welcome_b64u": welcomeB64U, - "group_state_ref": map[string]any{"group_did": groupDID}, + "agent_did": memberRecord.DID, + "device_id": deviceID, + "group_did": groupDID, + "welcome_b64u": welcomeB64U, + "ratchet_tree_b64u": ratchetTreeB64U, + "group_state_ref": map[string]any{"group_did": groupDID}, }, }) if err != nil { @@ -318,6 +344,126 @@ func (s *Service) processLocalGroupWelcome(ctx context.Context, memberDID string }, warnings } +func (s *Service) PullGroupE2EENotices(ctx context.Context, identityName string, groupDID string, limit int) (*CommandResult, error) { + record, err := s.requireActiveIdentity(identityName) + if err != nil { + return nil, err + } + transport, warnings, err := s.httpTransport(record) + if err != nil { + return nil, err + } + result, err := transport.PullGroupE2EENotices(ctx, groupDID, limit, false) + if err != nil { + return nil, err + } + return &CommandResult{ + Data: map[string]any{ + "notices": noticesFromResult(result["notices"]), + "pending_count": result["pending_count"], + "group": groupDID, + }, + Summary: "Pulled group E2EE pending notices", + Warnings: warnings, + }, nil +} + +func (s *Service) RepairGroupE2EENotices(ctx context.Context, identityName string, groupDID string, limit int) (*CommandResult, error) { + record, err := s.requireActiveIdentity(identityName) + if err != nil { + return nil, err + } + transport, warnings, err := s.httpTransport(record) + if err != nil { + return nil, err + } + pending, err := transport.PullGroupE2EENotices(ctx, groupDID, limit, false) + if err != nil { + return nil, err + } + processed := make([]map[string]any, 0) + noticeIDs := make([]string, 0) + for _, notice := range noticesFromResult(pending["notices"]) { + if stringFromAny(notice["notice_type"]) != "welcome-delivery" { + continue + } + targetGroupDID := defaultString(stringFromAny(notice["group_did"]), groupDID) + if targetGroupDID == "" { + warnings = append(warnings, "Group E2EE repair skipped welcome notice without group_did") + continue + } + if recipient := firstNonEmptyString(notice["recipient_did"], notice["member_did"], notice["subject_did"]); recipient != "" && recipient != record.DID { + warnings = append(warnings, fmt.Sprintf("Group E2EE repair skipped notice for different recipient %s", recipient)) + continue + } + welcome, noticeWarnings := s.processGroupWelcomeNotice(ctx, record, targetGroupDID, notice) + warnings = append(warnings, noticeWarnings...) + if welcome != nil { + processed = append(processed, welcome) + if noticeID := stringFromAny(notice["notice_id"]); noticeID != "" { + noticeIDs = append(noticeIDs, noticeID) + } + } + } + delivered := map[string]any(nil) + if len(noticeIDs) > 0 { + delivered, err = transport.MarkGroupE2EENoticesDelivered(ctx, groupDID, noticeIDs) + if err != nil { + warnings = append(warnings, fmt.Sprintf("Group E2EE repair processed notices but failed to mark delivered: %v", err)) + } + } + return &CommandResult{ + Data: map[string]any{ + "processed": processed, + "processed_count": len(processed), + "pending_count": pending["pending_count"], + "delivered_result": delivered, + "group": groupDID, + }, + Summary: "Replayed group E2EE pending notices", + Warnings: compactWarnings(warnings), + }, nil +} + +func (s *Service) processGroupWelcomeNotice(ctx context.Context, record *identity.StoredIdentity, groupDID string, notice map[string]any) (map[string]any, []string) { + welcomeB64U := stringFromAny(notice["welcome_b64u"]) + if welcomeB64U == "" { + return nil, []string{"Group E2EE repair skipped welcome notice missing welcome_b64u"} + } + ratchetTreeB64U := stringFromAny(notice["ratchet_tree_b64u"]) + if ratchetTreeB64U == "" { + return nil, []string{"Group E2EE repair skipped welcome notice missing ratchet_tree_b64u"} + } + deviceID := defaultString(stringFromAny(notice["device_id"]), "default") + provider := s.groupMLSProvider() + welcomeResult, err := provider.ProcessWelcome(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-welcome-repair-" + generateOperationID(), + AgentDID: record.DID, + DeviceID: deviceID, + Params: map[string]any{ + "agent_did": record.DID, + "device_id": deviceID, + "group_did": groupDID, + "welcome_b64u": welcomeB64U, + "ratchet_tree_b64u": ratchetTreeB64U, + "group_state_ref": firstNonNil(notice["group_state_ref"], map[string]any{"group_did": groupDID}), + }, + }) + if err != nil { + return nil, []string{fmt.Sprintf("Group E2EE repair welcome processing failed: %v", err)} + } + warnings := s.persistGroupE2EESummary(ctx, record, groupDID, welcomeResult, notice) + return map[string]any{ + "processed": true, + "notice_id": notice["notice_id"], + "group_did": groupDID, + "member_did": record.DID, + "device_id": deviceID, + "epoch": welcomeResult["epoch"], + }, warnings +} + func (s *Service) localIdentityByDID(did string) (*identity.StoredIdentity, error) { if s == nil || s.manager == nil { return nil, identity.ErrIdentityNotFound @@ -354,18 +500,25 @@ func groupE2EEWelcomeDeviceID(leasedPackage map[string]any) string { } func (s *Service) localGroupStateRef(ctx context.Context, record *identity.StoredIdentity, groupDID string) map[string]any { - ref := map[string]any{"group_did": groupDID} snapshot, err := s.readCachedGroupSnapshot(ctx, record, groupDID) if err != nil { - return ref + return map[string]any{"group_did": groupDID} } - if version := stringFromAny(snapshot["group_state_version"]); version != "" { + return groupStateRefFromSnapshot(groupDID, snapshot) +} + +func groupStateRefFromSnapshot(groupDID string, snapshot map[string]any) map[string]any { + ref := map[string]any{"group_did": groupDID} + metadata := decodeMetadataMap(snapshot["metadata"]) + if version := firstNonEmptyString(snapshot["group_state_version"], metadata["group_state_version"]); version != "" { ref["group_state_version"] = version } - metadata := decodeMetadataMap(snapshot["metadata"]) if e2ee, ok := metadata["group_e2ee"].(map[string]any); ok { - if epoch := stringFromAny(e2ee["epoch"]); epoch != "" { - ref["epoch"] = epoch + if version := stringFromAny(e2ee["group_state_version"]); version != "" { + ref["group_state_version"] = version + } + if cryptoGroupID := stringFromAny(e2ee["crypto_group_id_b64u"]); cryptoGroupID != "" { + ref["crypto_group_id_b64u"] = cryptoGroupID } } return ref @@ -431,12 +584,42 @@ func groupCipherObjectFromMessage(item map[string]any) map[string]any { return nil } +func groupE2EEAADParamsFromMessage(groupDID string, item map[string]any, cipher map[string]any) map[string]any { + receipt, _ := item["receipt"].(map[string]any) + groupStateRef, _ := cipher["group_state_ref"].(map[string]any) + if len(groupStateRef) == 0 { + groupStateRef = map[string]any{"group_did": groupDID} + } + params := map[string]any{ + "group_state_ref": groupStateRef, + "sender_did": stringFromAny(item["sender_did"]), + "content_type": "application/anp-group-cipher+json", + "security_profile": GroupE2EESecurityProfile, + "message_id": firstNonEmptyString(item["message_id"], item["id"]), + "operation_id": firstNonEmptyString(item["operation_id"], receipt["operation_id"]), + } + return params +} + func groupStateRefFromCipher(encryptResult map[string]any) map[string]any { cipher, _ := encryptResult["group_cipher_object"].(map[string]any) ref, _ := cipher["group_state_ref"].(map[string]any) return ref } +func firstNonEmptyString(values ...any) string { + for _, value := range values { + if text := stringFromAny(value); text != "" { + return text + } + } + return "" +} + +func noticesFromResult(value any) []map[string]any { + return messagesFromResult(value) +} + func redactedKeyPackageSummary(raw map[string]any) map[string]any { return map[string]any{ "target_did": raw["target_did"], diff --git a/internal/message/group_service_test.go b/internal/message/group_service_test.go index b0cc6f1..438c256 100644 --- a/internal/message/group_service_test.go +++ b/internal/message/group_service_test.go @@ -149,6 +149,27 @@ func TestGroupMemberMutationUsesPreMutationE2EESnapshot(t *testing.T) { } } +func TestGroupStateRefFromSnapshotUsesServerStateVersionNotMLSEpoch(t *testing.T) { + t.Parallel() + + ref := groupStateRefFromSnapshot("did:wba:example.com:groups:demo:e1", map[string]any{ + "metadata": `{"group_state_version":"2","group_e2ee":{"epoch":"1","crypto_group_id_b64u":"Y3J5cHRv"}}`, + }) + if got := stringFromAny(ref["group_state_version"]); got != "2" { + t.Fatalf("group_state_version = %q, want server version 2", got) + } + if got := stringFromAny(ref["crypto_group_id_b64u"]); got != "Y3J5cHRv" { + t.Fatalf("crypto_group_id_b64u = %q, want cached crypto id", got) + } + + ref = groupStateRefFromSnapshot("did:wba:example.com:groups:demo:e1", map[string]any{ + "metadata": `{"group_e2ee":{"epoch":"7","crypto_group_id_b64u":"Y3J5cHRv"}}`, + }) + if _, ok := ref["group_state_version"]; ok { + t.Fatalf("group_state_version = %#v, want absent when only MLS epoch is cached", ref["group_state_version"]) + } +} + func TestLocalIdentityByDIDFindsStoredMemberForWelcomeProcessing(t *testing.T) { t.Parallel() diff --git a/internal/message/group_wire.go b/internal/message/group_wire.go index 4b6cceb..1c3bf06 100644 --- a/internal/message/group_wire.go +++ b/internal/message/group_wire.go @@ -176,8 +176,8 @@ func BuildGroupSendRPCParams(record *identity.StoredIdentity, manager *identity. }, nil } -func BuildGroupE2EECreateRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, mlsHead map[string]any) (map[string]any, error) { - return buildGroupE2EERPCParams(record, manager, groupDID, "group.e2ee.create", e2eeHeadBody(groupDID, "", mlsHead), "") +func BuildGroupE2EECreateRPCParams(record *identity.StoredIdentity, manager *identity.Manager, serviceDID string, groupDID string, mlsHead map[string]any) (map[string]any, error) { + return buildGroupE2EERPCParams(record, manager, "service", serviceDID, "group.e2ee.create", e2eeHeadBody(groupDID, "", mlsHead), "", "", "", GroupE2EESecurityProfile) } func BuildGroupE2EEAddRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, memberDID string, mlsHead map[string]any) (map[string]any, error) { @@ -195,11 +195,14 @@ func BuildGroupE2EEAddRPCParams(record *identity.StoredIdentity, manager *identi body["key_package_id"] = value body["subject_key_package_id"] = value } - return buildGroupE2EERPCParams(record, manager, groupDID, "group.e2ee.add", body, "") + if value, ok := mlsHead["group_key_package"]; ok { + body["group_key_package"] = value + } + return buildGroupE2EERPCParams(record, manager, "group", groupDID, "group.e2ee.add", body, "", "", "", GroupE2EESecurityProfile) } -func BuildGroupE2EESendRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, cipher map[string]any) (map[string]any, error) { - return buildGroupE2EERPCParams(record, manager, groupDID, "group.e2ee.send", map[string]any{"group_cipher_object": sanitizeGroupCipherObjectForService(cipher)}, "application/anp-group-cipher+json") +func BuildGroupE2EESendRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, cipher map[string]any, operationID string, messageID string) (map[string]any, error) { + return buildGroupE2EERPCParams(record, manager, "group", groupDID, "group.e2ee.send", map[string]any{"group_cipher_object": sanitizeGroupCipherObjectForService(cipher)}, "application/anp-group-cipher+json", operationID, messageID, GroupE2EESecurityProfile) } func sanitizeGroupCipherObjectForService(cipher map[string]any) map[string]any { @@ -237,7 +240,7 @@ func BuildGroupE2EEPublishKeyPackageRPCParams(record *identity.StoredIdentity, m meta := map[string]any{ "anp_version": "1.0", "profile": GroupE2EEProfile, - "security_profile": GroupE2EESecurityProfile, + "security_profile": GroupE2EETransportProfile, "sender_did": record.DID, "target": map[string]any{"kind": "service", "did": serviceDID}, "operation_id": "op-" + generateOperationID(), @@ -273,7 +276,7 @@ func BuildGroupE2EEGetKeyPackageRPCParams(record *identity.StoredIdentity, manag meta := map[string]any{ "anp_version": "1.0", "profile": GroupE2EEProfile, - "security_profile": GroupE2EESecurityProfile, + "security_profile": GroupE2EETransportProfile, "sender_did": record.DID, "target": map[string]any{"kind": "service", "did": serviceDID}, "operation_id": "op-" + generateOperationID(), @@ -293,6 +296,57 @@ func BuildGroupE2EEGetKeyPackageRPCParams(record *identity.StoredIdentity, manag }, nil } +func BuildGroupE2EENoticeRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, limit int, markDelivered bool, noticeIDs []string) (map[string]any, error) { + auth, err := newAuthContext(record, manager) + if err != nil { + return nil, err + } + if limit <= 0 { + limit = 50 + } + if limit > 100 { + limit = 100 + } + meta := map[string]any{ + "anp_version": "1.0", + "profile": GroupE2EEProfile, + "security_profile": GroupE2EETransportProfile, + "sender_did": record.DID, + "target": map[string]any{"kind": "agent", "did": record.DID}, + "operation_id": "op-" + generateOperationID(), + "created_at": nowRFC3339(), + "content_type": "application/json", + } + body := map[string]any{"limit": limit} + if strings.TrimSpace(groupDID) != "" { + body["group_did"] = strings.TrimSpace(groupDID) + } + if markDelivered { + body["mark_delivered"] = true + } + if len(noticeIDs) > 0 { + ids := make([]string, 0, len(noticeIDs)) + for _, noticeID := range noticeIDs { + if trimmed := strings.TrimSpace(noticeID); trimmed != "" { + ids = append(ids, trimmed) + } + } + if len(ids) > 0 { + body["notice_ids"] = ids + } + } + payload := signedPayload{Method: "group.e2ee.notice", Meta: meta, Body: body} + originProof, err := buildOriginProof(auth, payload) + if err != nil { + return nil, err + } + return map[string]any{ + "meta": meta, + "auth": map[string]any{"scheme": OriginProofScheme, "origin_proof": originProof}, + "body": body, + }, nil +} + func sanitizeGroupKeyPackageForService(input map[string]any) map[string]any { allowed := map[string]struct{}{ "owner_did": {}, @@ -504,9 +558,13 @@ func normalizedGroupSecurityProfile(request GroupCreateRequest) string { } } -func buildGroupE2EERPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, method string, body map[string]any, contentType string) (map[string]any, error) { - groupDID = strings.TrimSpace(groupDID) - if groupDID == "" { +func buildGroupE2EERPCParams(record *identity.StoredIdentity, manager *identity.Manager, targetKind string, targetDID string, method string, body map[string]any, contentType string, operationID string, messageID string, securityProfile string) (map[string]any, error) { + targetKind = strings.TrimSpace(targetKind) + targetDID = strings.TrimSpace(targetDID) + if targetKind == "" { + targetKind = "group" + } + if targetDID == "" { return nil, ErrGroupRequired } auth, err := newAuthContext(record, manager) @@ -516,18 +574,30 @@ func buildGroupE2EERPCParams(record *identity.StoredIdentity, manager *identity. if contentType == "" { contentType = "application/json" } + operationID = strings.TrimSpace(operationID) + if operationID == "" { + operationID = "op-" + generateOperationID() + } + securityProfile = strings.TrimSpace(securityProfile) + if securityProfile == "" { + securityProfile = GroupE2EESecurityProfile + } meta := map[string]any{ "anp_version": "1.0", "profile": GroupE2EEProfile, - "security_profile": GroupE2EESecurityProfile, + "security_profile": securityProfile, "sender_did": record.DID, - "target": map[string]any{"kind": "group", "did": groupDID}, - "operation_id": "op-" + generateOperationID(), + "target": map[string]any{"kind": targetKind, "did": targetDID}, + "operation_id": operationID, "created_at": nowRFC3339(), "content_type": contentType, } if method == "group.e2ee.send" { - meta["message_id"] = "msg-" + generateOperationID() + messageID = strings.TrimSpace(messageID) + if messageID == "" { + messageID = "msg-" + generateOperationID() + } + meta["message_id"] = messageID } payload := signedPayload{Method: method, Meta: meta, Body: body} originProof, err := buildOriginProof(auth, payload) @@ -543,6 +613,7 @@ func buildGroupE2EERPCParams(record *identity.StoredIdentity, manager *identity. func e2eeHeadBody(groupDID string, memberDID string, mlsHead map[string]any) map[string]any { body := map[string]any{ + "group_did": groupDID, "group_state_ref": map[string]any{ "group_did": groupDID, }, @@ -556,6 +627,7 @@ func e2eeHeadBody(groupDID string, memberDID string, mlsHead map[string]any) map body["epoch_authenticator"] = value } if memberDID != "" { + body["member_did"] = memberDID body["subject_did"] = memberDID } return body diff --git a/internal/message/group_wire_test.go b/internal/message/group_wire_test.go index 45f8833..a44dd0c 100644 --- a/internal/message/group_wire_test.go +++ b/internal/message/group_wire_test.go @@ -173,7 +173,7 @@ func TestBuildGroupE2EESendRPCParamsSendsOnlyOpaqueCipherObject(t *testing.T) { "epoch_authenticator": "YXV0aA", "group_state_ref": map[string]any{"group_did": "did:wba:awiki.ai:groups:demo:e1_group"}, "application_plaintext": map[string]any{"text": "secret"}, - }) + }, "op-e2ee-send", "msg-e2ee-send") if err != nil { t.Fatalf("BuildGroupE2EESendRPCParams() error = %v", err) } @@ -187,6 +187,12 @@ func TestBuildGroupE2EESendRPCParamsSendsOnlyOpaqueCipherObject(t *testing.T) { if got := stringFromAny(meta["content_type"]); got != "application/anp-group-cipher+json" { t.Fatalf("meta.content_type = %q, want group cipher", got) } + if got := stringFromAny(meta["operation_id"]); got != "op-e2ee-send" { + t.Fatalf("meta.operation_id = %q, want op-e2ee-send", got) + } + if got := stringFromAny(meta["message_id"]); got != "msg-e2ee-send" { + t.Fatalf("meta.message_id = %q, want msg-e2ee-send", got) + } body := mustMapValue(t, params["body"], "params.body") if _, ok := body["application_plaintext"]; ok { t.Fatalf("plaintext leaked into E2EE send body: %#v", body) @@ -216,7 +222,9 @@ func TestBuildGroupE2EEAddRPCParamsIncludesConsumedKeyPackageID(t *testing.T) { "epoch_authenticator": "YXV0aDI", "welcome_b64u": "d2VsY29tZQ", "commit_b64u": "Y29tbWl0", + "ratchet_tree_b64u": "cmF0Y2hldA", "key_package_id": "kp-bob-1", + "group_key_package": map[string]any{"owner_did": "did:wba:awiki.ai:user:bob:e1_bob", "key_package_id": "kp-bob-1", "device_id": "phone"}, }) if err != nil { t.Fatalf("BuildGroupE2EEAddRPCParams() error = %v", err) @@ -225,12 +233,22 @@ func TestBuildGroupE2EEAddRPCParamsIncludesConsumedKeyPackageID(t *testing.T) { if got := stringFromAny(body["subject_did"]); got != "did:wba:awiki.ai:user:bob:e1_bob" { t.Fatalf("subject_did = %q, want bob", got) } + if got := stringFromAny(body["member_did"]); got != "did:wba:awiki.ai:user:bob:e1_bob" { + t.Fatalf("member_did = %q, want bob", got) + } if got := stringFromAny(body["key_package_id"]); got != "kp-bob-1" { t.Fatalf("key_package_id = %q, want leased id", got) } + groupKeyPackage := mustMapValue(t, body["group_key_package"], "body.group_key_package") + if got := stringFromAny(groupKeyPackage["device_id"]); got != "phone" { + t.Fatalf("group_key_package.device_id = %q, want phone", got) + } if got := stringFromAny(body["subject_key_package_id"]); got != "kp-bob-1" { t.Fatalf("subject_key_package_id = %q, want leased id", got) } + if got := stringFromAny(body["ratchet_tree_b64u"]); got != "cmF0Y2hldA" { + t.Fatalf("ratchet_tree_b64u = %q, want ratchet tree", got) + } } func TestBuildGroupE2EEPublishKeyPackageRPCParamsStripsProviderOnlyFields(t *testing.T) { @@ -265,6 +283,93 @@ func TestBuildGroupE2EEPublishKeyPackageRPCParamsStripsProviderOnlyFields(t *tes if _, ok := params["auth"]; !ok { t.Fatalf("auth missing from publish params: %#v", params) } + meta := mustMapValue(t, params["meta"], "params.meta") + if got := stringFromAny(meta["security_profile"]); got != "transport-protected" { + t.Fatalf("publish security_profile = %q, want transport-protected", got) + } +} + +func TestBuildGroupE2EECreateRPCParamsUsesServiceTarget(t *testing.T) { + t.Parallel() + + record := testStoredIdentity(t) + params, err := BuildGroupE2EECreateRPCParams(record, nil, "did:wba:awiki.ai:services:message:e1_service", "did:wba:awiki.ai:groups:demo:e1_group", map[string]any{ + "crypto_group_id_b64u": "Y3J5cHRv", + "epoch": "0", + "epoch_authenticator": "YXV0aA", + }) + if err != nil { + t.Fatalf("BuildGroupE2EECreateRPCParams() error = %v", err) + } + meta := mustMapValue(t, params["meta"], "params.meta") + target := mustMapValue(t, meta["target"], "meta.target") + if got := stringFromAny(target["kind"]); got != "service" { + t.Fatalf("create target.kind = %q, want service", got) + } + if got := stringFromAny(target["did"]); got != "did:wba:awiki.ai:services:message:e1_service" { + t.Fatalf("create target.did = %q, want service DID", got) + } + body := mustMapValue(t, params["body"], "params.body") + if got := stringFromAny(body["group_did"]); got != "did:wba:awiki.ai:groups:demo:e1_group" { + t.Fatalf("body.group_did = %q, want group DID", got) + } + ref := mustMapValue(t, body["group_state_ref"], "body.group_state_ref") + if got := stringFromAny(ref["group_did"]); got != "did:wba:awiki.ai:groups:demo:e1_group" { + t.Fatalf("group_state_ref.group_did = %q, want group DID", got) + } +} + +func TestBuildGroupE2EENoticeRPCParamsUsesTransportProtectedAgentTarget(t *testing.T) { + t.Parallel() + + record := testStoredIdentity(t) + params, err := BuildGroupE2EENoticeRPCParams(record, nil, "did:wba:awiki.ai:groups:demo:e1_group", 500, true, []string{"notice-1"}) + if err != nil { + t.Fatalf("BuildGroupE2EENoticeRPCParams() error = %v", err) + } + meta := mustMapValue(t, params["meta"], "params.meta") + if got := stringFromAny(meta["security_profile"]); got != GroupE2EETransportProfile { + t.Fatalf("notice security_profile = %q, want transport-protected", got) + } + target := mustMapValue(t, meta["target"], "meta.target") + if got := stringFromAny(target["kind"]); got != "agent" { + t.Fatalf("notice target.kind = %q, want agent", got) + } + if got := stringFromAny(target["did"]); got != record.DID { + t.Fatalf("notice target.did = %q, want identity DID", got) + } + body := mustMapValue(t, params["body"], "params.body") + if got := intValueFromAny(body["limit"], 0); got != 100 { + t.Fatalf("notice limit = %d, want capped 100", got) + } + if got := stringFromAny(body["group_did"]); got != "did:wba:awiki.ai:groups:demo:e1_group" { + t.Fatalf("notice group_did = %q, want group DID", got) + } + ids, ok := body["notice_ids"].([]string) + if !ok || len(ids) != 1 || ids[0] != "notice-1" { + t.Fatalf("notice_ids = %#v, want [notice-1]", body["notice_ids"]) + } + if got := boolFromAny(body["mark_delivered"]); !got { + t.Fatalf("mark_delivered = %v, want true", got) + } +} + +func TestBuildGroupE2EEGetKeyPackageUsesTransportProtectedServiceTarget(t *testing.T) { + t.Parallel() + + record := testStoredIdentity(t) + params, err := BuildGroupE2EEGetKeyPackageRPCParams(record, nil, "did:wba:awiki.ai:services:message:e1_service", "did:wba:awiki.ai:users:bob:e1_bob") + if err != nil { + t.Fatalf("BuildGroupE2EEGetKeyPackageRPCParams() error = %v", err) + } + meta := mustMapValue(t, params["meta"], "params.meta") + if got := stringFromAny(meta["security_profile"]); got != "transport-protected" { + t.Fatalf("get security_profile = %q, want transport-protected", got) + } + target := mustMapValue(t, meta["target"], "meta.target") + if got := stringFromAny(target["kind"]); got != "service" { + t.Fatalf("get target.kind = %q, want service", got) + } } func TestBuildGroupMembersRPCParamsDefaultsLimitToHundred(t *testing.T) { diff --git a/internal/message/http_client.go b/internal/message/http_client.go index b712cac..9039ef8 100644 --- a/internal/message/http_client.go +++ b/internal/message/http_client.go @@ -151,11 +151,12 @@ func (t *HTTPTransport) SendGroup(ctx context.Context, request SendRequest) (*gr return &result, nil } -func (t *HTTPTransport) SendGroupE2EE(ctx context.Context, groupDID string, cipher map[string]any) (*groupSendResult, error) { - params, err := BuildGroupE2EESendRPCParams(t.auth.record, nil, groupDID, cipher) +func (t *HTTPTransport) SendGroupE2EE(ctx context.Context, groupDID string, cipher map[string]any, operationID string, messageID string) (*groupSendResult, error) { + params, err := BuildGroupE2EESendRPCParams(t.auth.record, nil, groupDID, cipher, operationID, messageID) if err != nil { return nil, err } + meta, _ := params["meta"].(map[string]any) var result groupSendResult if err := t.rpcCall(ctx, "group.e2ee.send", params, &result); err != nil { return nil, err @@ -163,6 +164,12 @@ func (t *HTTPTransport) SendGroupE2EE(ctx context.Context, groupDID string, ciph if result.GroupDID == "" { result.GroupDID = groupDID } + if result.MessageID == "" { + result.MessageID = stringFromAny(meta["message_id"]) + } + if result.OperationID == "" { + result.OperationID = stringFromAny(meta["operation_id"]) + } return &result, nil } @@ -265,7 +272,11 @@ func (t *HTTPTransport) GetGroupE2EEKeyPackage(ctx context.Context, targetDID st } func (t *HTTPTransport) CreateGroupE2EE(ctx context.Context, groupDID string, mlsHead map[string]any) (map[string]any, error) { - params, err := BuildGroupE2EECreateRPCParams(t.auth.record, nil, groupDID, mlsHead) + serviceDID, err := t.GetMessageServiceDID(ctx) + if err != nil { + return nil, err + } + params, err := BuildGroupE2EECreateRPCParams(t.auth.record, nil, serviceDID, groupDID, mlsHead) if err != nil { return nil, err } @@ -280,6 +291,22 @@ func (t *HTTPTransport) AddGroupE2EE(ctx context.Context, groupDID string, membe return t.rpcMapCall(ctx, "group.e2ee.add", params) } +func (t *HTTPTransport) PullGroupE2EENotices(ctx context.Context, groupDID string, limit int, markDelivered bool) (map[string]any, error) { + params, err := BuildGroupE2EENoticeRPCParams(t.auth.record, nil, groupDID, limit, markDelivered, nil) + if err != nil { + return nil, err + } + return t.rpcMapCall(ctx, "group.e2ee.notice", params) +} + +func (t *HTTPTransport) MarkGroupE2EENoticesDelivered(ctx context.Context, groupDID string, noticeIDs []string) (map[string]any, error) { + params, err := BuildGroupE2EENoticeRPCParams(t.auth.record, nil, groupDID, len(noticeIDs), true, noticeIDs) + if err != nil { + return nil, err + } + return t.rpcMapCall(ctx, "group.e2ee.notice", params) +} + func (t *HTTPTransport) GetGroupInfo(ctx context.Context, request GroupInfoRequest) (map[string]any, error) { params, err := BuildGroupGetInfoRPCParams(t.auth.record, request) if err != nil { From 2495e03c7f9cec6d7f177f4ae4f5f4ffb198ed5e Mon Sep 17 00:00:00 2001 From: changshan Date: Sun, 3 May 2026 20:39:14 +0800 Subject: [PATCH 08/14] feat: bind published Group E2EE KeyPackages to the active DID The CLI now signs anp-mls KeyPackage did_wba_binding objects with the active identity key before publication, keeping private MLS material in anp-mls while giving message-service a cryptographic ownership proof to verify. Constraint: awiki-cli must remain pure Go/no CGO and Group E2EE discovery stays hidden. Rejected: Trust provider-supplied binding proof | the provider cannot know the CLI identity key and would leave publish verification shape-only. Confidence: high Scope-risk: moderate Directive: Do not pass plaintext or private key material through argv; keep signing in-process and anp-mls JSON over stdin/stdout. Tested: cd awiki-cli && go test ./internal/anpsdk ./internal/message Tested: focused Group E2EE system test after deslop: 2 passed in 23.19s --- CLAUDE.md | 4 +- internal/anpsdk/registry.go | 3 + internal/message/group_e2ee_service.go | 119 ++++++++++++++++++++++++- internal/message/group_wire_test.go | 52 +++++++++++ 4 files changed, 175 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index faaa094..ecdd6fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,14 +87,14 @@ **internal/store/import_test.go**: legacy SQLite 导入测试。 **internal/message/types.go**: direct/group message 与 group lifecycle 的命令输入/输出模型和 transport 错误定义。 **internal/message/auth.go**: direct message 的 hop-level auth 与本地 key / did document 读取。 -**internal/message/proof.go**: 基于 ANP Go SDK 0.8.6 的 RFC 9421 origin proof 薄封装。 +**internal/message/proof.go**: 基于 ANP Go SDK 0.8.7 的 RFC 9421 origin proof 薄封装。 **internal/message/attachment.go**: 附件文件读取、manifest 组装、控制面/数据面 HTTP 交互与下载解析辅助。 **internal/message/attachment_wire.go**: 附件 control-plane、download ticket 与 direct/group attachment manifest 的 RPC 参数构造器。 **internal/message/attachment_service.go**: direct/group attachment send 与 `msg attachment download` 的业务编排层。 **internal/message/secure.go**: P5 direct E2EE secure send 的首版编排层,使用 ANP Go SDK direct_e2ee、本地文件会话/预密钥存储和 HTTP JSON-RPC;key-service 请求绑定当前 DID 文档里 `ANPMessageService.serviceDid`,并在有可用 sidecar OPK 时优先用 OPK 建链(本地保存 `p5-one-time-prekeys/`);HTTP inbox/history 现已接入入站密文解密与会话推进,并会顺带补发本地 prekey bundle;轮询路径解密 direct-init 成功后会自动发送 encrypted ACK 并尝试 flush 该 peer 的 `e2ee_outbox`;当 initiator 仍处于 `pending-confirmation` 时,新的 secure 发送会进入 `e2ee_outbox` 排队。 **internal/message/secure_control.go**: secure 控制面与恢复辅助,负责 secure ack/init payload、pending 阶段的 `e2ee_outbox` 排队、secure outbox flush,以及 `msg secure status/init/repair/failed/retry/drop` 需要的本地会话/发件箱读取与重试逻辑。 **internal/message/group_e2ee_provider.go**: `anp-mls` exec provider 抽象;按 `AWIKI_ANP_MLS_BINARY`、测试/运行时注入路径、`PATH` 顺序发现二进制;JSON request 走 stdin、response 走 stdout、日志/错误走 stderr,默认 MLS 根目录为 `/mls`,实际 OpenMLS 私有状态按 agent/device 分到子目录,并可扫描同一 agent 下的本地 device state 供收件解密恢复,保持 Go 主工程 pure Go / no CGO。 -**internal/message/group_e2ee_service.go**: group E2EE 业务编排层;负责 KeyPackage 发布、owner create/add、send encrypt、messages decrypt、P6 notice pending/repair、ratchet tree welcome process,以及 MLS AAD 元数据传入 `anp-mls`;在同一工作区存在目标成员身份时也会本地处理 add 返回的 welcome notice,使 one-shot `anp-mls` agent/device 状态可恢复。 +**internal/message/group_e2ee_service.go**: group E2EE 业务编排层;负责 KeyPackage 发布前用当前 DID `key-1` 生成 strict Appendix-B `did_wba_binding.proof`、owner create/add、send encrypt、messages decrypt、P6 notice pending/repair、ratchet tree welcome process,以及 MLS AAD 元数据传入 `anp-mls`;在同一工作区存在目标成员身份时也会本地处理 add 返回的 welcome notice,使 one-shot `anp-mls` agent/device 状态可恢复。 **internal/message/group_wire.go**: group 标准面和 local-only RPC 参数构造器;P6 publish/get/notice 使用 `transport-protected` service/agent target,create 使用 service target,add/send 使用 group target;group E2EE send 会在签名/发送前裁剪 provider-local MLS 字段,只把 P6 service 允许的 opaque cipher 字段送到 message-service。 **internal/message/http_client.go**: direct/group message、group lifecycle 与 hidden/test-only P6 notice pull/mark-delivered 的 HTTP JSON-RPC adapter。 **internal/message/ws_proxy_client.go**: websocket 模式下通过本地 bridge 调用 listener/daemon 的 direct/group adapter。 diff --git a/internal/anpsdk/registry.go b/internal/anpsdk/registry.go index 3b654a9..bd8c39f 100644 --- a/internal/anpsdk/registry.go +++ b/internal/anpsdk/registry.go @@ -37,6 +37,7 @@ type ( RFC9421OriginProof = anpproof.RFC9421OriginProof RFC9421OriginProofGenerationOptions = anpproof.RFC9421OriginProofGenerationOptions RFC9421OriginProofVerificationOptions = anpproof.RFC9421OriginProofVerificationOptions + DidWbaBindingVerificationOptions = anpproof.DidWbaBindingVerificationOptions ) var ( @@ -72,6 +73,8 @@ var ( VerifyIMProofWithDocument = anpproof.VerifyIMProofWithDocument GenerateGroupReceiptProof = anpproof.GenerateGroupReceiptProof VerifyGroupReceiptProof = anpproof.VerifyGroupReceiptProof + GenerateDidWbaBinding = anpproof.GenerateDidWbaBinding + VerifyDidWbaBinding = anpproof.VerifyDidWbaBinding BuildSignedRequestObject = anpproof.BuildSignedRequestObject CanonicalizeSignedRequestObject = anpproof.CanonicalizeSignedRequestObject BuildLogicalTargetURI = anpproof.BuildLogicalTargetURI diff --git a/internal/message/group_e2ee_service.go b/internal/message/group_e2ee_service.go index 18bd034..b8a1752 100644 --- a/internal/message/group_e2ee_service.go +++ b/internal/message/group_e2ee_service.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" + "github.com/agentconnect/awiki-cli/internal/anpsdk" "github.com/agentconnect/awiki-cli/internal/identity" "github.com/agentconnect/awiki-cli/internal/store" ) @@ -30,6 +31,10 @@ func (s *Service) publishGroupE2EEKeyPackage(ctx context.Context, record *identi if err != nil { return nil, nil, err } + packageResult, err = signGroupKeyPackageDIDWBABinding(record, packageResult) + if err != nil { + return nil, nil, err + } transport, _, err := s.httpTransport(record) if err != nil { return packageResult, nil, err @@ -41,6 +46,83 @@ func (s *Service) publishGroupE2EEKeyPackage(ctx context.Context, record *identi return packageResult, published, nil } +func signGroupKeyPackageDIDWBABinding(record *identity.StoredIdentity, packageResult map[string]any) (map[string]any, error) { + if record == nil { + return nil, fmt.Errorf("identity record is required") + } + if len(packageResult) == 0 { + return nil, fmt.Errorf("anp-mls key-package response is empty") + } + groupKeyPackage, ok := packageResult["group_key_package"].(map[string]any) + if !ok || len(groupKeyPackage) == 0 { + return nil, fmt.Errorf("anp-mls key-package response missing group_key_package") + } + binding, ok := groupKeyPackage["did_wba_binding"].(map[string]any) + if !ok || len(binding) == 0 { + return nil, fmt.Errorf("group_key_package.did_wba_binding is required") + } + ownerDID := stringFromAny(groupKeyPackage["owner_did"]) + if ownerDID == "" { + ownerDID = record.DID + } + if ownerDID != record.DID { + return nil, fmt.Errorf("group_key_package.owner_did must match active identity") + } + agentDID := stringFromAny(binding["agent_did"]) + if agentDID == "" { + agentDID = record.DID + } + if agentDID != record.DID { + return nil, fmt.Errorf("did_wba_binding.agent_did must match active identity") + } + verificationMethod := verificationMethodID(record.DIDDocument) + if verificationMethod == "" { + return nil, fmt.Errorf("active DID document does not expose a signing verification method") + } + leafSignatureKey := stringFromAny(binding["leaf_signature_key_b64u"]) + if leafSignatureKey == "" { + return nil, fmt.Errorf("did_wba_binding.leaf_signature_key_b64u is required") + } + issuedAt := stringFromAny(binding["issued_at"]) + if issuedAt == "" { + return nil, fmt.Errorf("did_wba_binding.issued_at is required") + } + expiresAt := stringFromAny(binding["expires_at"]) + if expiresAt == "" { + return nil, fmt.Errorf("did_wba_binding.expires_at is required") + } + privateKey, err := loadPrivateKeyMaterial(record.Key1PrivatePEM) + if err != nil { + return nil, fmt.Errorf("load active identity signing key: %w", err) + } + signedBinding, err := anpsdk.GenerateDidWbaBinding( + record.DID, + verificationMethod, + leafSignatureKey, + privateKey, + issuedAt, + expiresAt, + issuedAt, + ) + if err != nil { + return nil, fmt.Errorf("sign did_wba_binding: %w", err) + } + signedPackageResult := cloneStringAnyMap(packageResult) + signedGroupKeyPackage := cloneStringAnyMap(groupKeyPackage) + signedGroupKeyPackage["owner_did"] = record.DID + signedGroupKeyPackage["did_wba_binding"] = signedBinding + signedPackageResult["group_key_package"] = signedGroupKeyPackage + return signedPackageResult, nil +} + +func cloneStringAnyMap(source map[string]any) map[string]any { + clone := make(map[string]any, len(source)) + for key, value := range source { + clone[key] = value + } + return clone +} + func (s *Service) PublishGroupE2EEKeyPackage(ctx context.Context, identityName string, deviceID string, contractTest bool) (*CommandResult, error) { record, err := s.requireActiveIdentity(identityName) if err != nil { @@ -383,6 +465,7 @@ func (s *Service) RepairGroupE2EENotices(ctx context.Context, identityName strin } processed := make([]map[string]any, 0) noticeIDs := make([]string, 0) + provider := s.groupMLSProvider() for _, notice := range noticesFromResult(pending["notices"]) { if stringFromAny(notice["notice_type"]) != "welcome-delivery" { continue @@ -397,13 +480,28 @@ func (s *Service) RepairGroupE2EENotices(ctx context.Context, identityName strin continue } welcome, noticeWarnings := s.processGroupWelcomeNotice(ctx, record, targetGroupDID, notice) - warnings = append(warnings, noticeWarnings...) if welcome != nil { processed = append(processed, welcome) if noticeID := stringFromAny(notice["notice_id"]); noticeID != "" { noticeIDs = append(noticeIDs, noticeID) } + continue } + if s.groupWelcomeAlreadyAvailable(ctx, provider, record, targetGroupDID, notice) { + processed = append(processed, map[string]any{ + "processed": true, + "already_restored": true, + "notice_id": notice["notice_id"], + "group_did": targetGroupDID, + "member_did": record.DID, + "device_id": defaultString(stringFromAny(notice["device_id"]), "default"), + }) + if noticeID := stringFromAny(notice["notice_id"]); noticeID != "" { + noticeIDs = append(noticeIDs, noticeID) + } + continue + } + warnings = append(warnings, noticeWarnings...) } delivered := map[string]any(nil) if len(noticeIDs) > 0 { @@ -425,6 +523,25 @@ func (s *Service) RepairGroupE2EENotices(ctx context.Context, identityName strin }, nil } +func (s *Service) groupWelcomeAlreadyAvailable(ctx context.Context, provider MLSExecProvider, record *identity.StoredIdentity, groupDID string, notice map[string]any) bool { + deviceID := defaultString(stringFromAny(notice["device_id"]), "default") + resp, err := provider.Call(ctx, "group", "status", MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-welcome-status-" + generateOperationID(), + AgentDID: record.DID, + DeviceID: deviceID, + Params: map[string]any{ + "agent_did": record.DID, + "device_id": deviceID, + "group_did": groupDID, + }, + }) + if err != nil || resp == nil { + return false + } + return stringFromAny(resp.Result["status"]) == "active" +} + func (s *Service) processGroupWelcomeNotice(ctx context.Context, record *identity.StoredIdentity, groupDID string, notice map[string]any) (map[string]any, []string) { welcomeB64U := stringFromAny(notice["welcome_b64u"]) if welcomeB64U == "" { diff --git a/internal/message/group_wire_test.go b/internal/message/group_wire_test.go index a44dd0c..ed6ed4c 100644 --- a/internal/message/group_wire_test.go +++ b/internal/message/group_wire_test.go @@ -3,6 +3,7 @@ package message import ( "testing" + "github.com/agentconnect/awiki-cli/internal/anpsdk" "github.com/agentconnect/awiki-cli/internal/identity" ) @@ -289,6 +290,57 @@ func TestBuildGroupE2EEPublishKeyPackageRPCParamsStripsProviderOnlyFields(t *tes } } +func TestSignGroupKeyPackageDIDWBABindingAddsStrictObjectProof(t *testing.T) { + t.Parallel() + + record := testStoredIdentity(t) + providerResult := map[string]any{ + "group_key_package": map[string]any{ + "owner_did": record.DID, + "device_id": "alice-main", + "key_package_id": "kp-alice-main", + "suite": "MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519", + "mls_key_package_b64u": "a3A", + "did_wba_binding": map[string]any{ + "agent_did": record.DID, + "verification_method": record.DID + "#provider-placeholder", + "leaf_signature_key_b64u": "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY", + "issued_at": "2026-01-01T00:00:00Z", + "expires_at": "2099-01-01T00:00:00Z", + }, + }, + } + + signedResult, err := signGroupKeyPackageDIDWBABinding(record, providerResult) + if err != nil { + t.Fatalf("signGroupKeyPackageDIDWBABinding() error = %v", err) + } + groupKeyPackage := mustMapValue(t, signedResult["group_key_package"], "signed.group_key_package") + binding := mustMapValue(t, groupKeyPackage["did_wba_binding"], "signed.did_wba_binding") + if got := stringFromAny(binding["verification_method"]); got != verificationMethodID(record.DIDDocument) { + t.Fatalf("verification_method = %q, want active identity method", got) + } + proof := mustMapValue(t, binding["proof"], "did_wba_binding.proof") + if got := stringFromAny(proof["verificationMethod"]); got != verificationMethodID(record.DIDDocument) { + t.Fatalf("proof.verificationMethod = %q, want active identity method", got) + } + if got := stringFromAny(proof["proofValue"]); got == "" || got[0] != 'z' { + t.Fatalf("proofValue = %q, want multibase z proof", got) + } + if err := anpsdk.VerifyDidWbaBinding(binding, record.DIDDocument, anpsdk.DidWbaBindingVerificationOptions{ + Now: "2026-01-02T00:00:00Z", + ExpectedLeafSignatureKey: "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY", + ExpectedCredentialIdentity: record.DID, + }); err != nil { + t.Fatalf("signed did_wba_binding did not verify: %v", err) + } + originalGroupKeyPackage := mustMapValue(t, providerResult["group_key_package"], "provider.group_key_package") + originalBinding := mustMapValue(t, originalGroupKeyPackage["did_wba_binding"], "provider.did_wba_binding") + if _, ok := originalBinding["proof"]; ok { + t.Fatalf("signing should not mutate provider result: %#v", originalBinding) + } +} + func TestBuildGroupE2EECreateRPCParamsUsesServiceTarget(t *testing.T) { t.Parallel() From 611de3b5e856afcfbbfa24a01a2b3de0f109d611 Mon Sep 17 00:00:00 2001 From: changshan Date: Sun, 3 May 2026 23:00:57 +0800 Subject: [PATCH 09/14] Keep E2EE exits cryptographically safe in one-shot CLI flows PR-A needs awiki-cli to route E2EE membership exits through MLS pending commits without making same-epoch local-terminal self-leave look safe. The CLI now prepares remove commits, finalizes only after hidden service acceptance, repairs commit-delivery notices for one-shot clients, and blocks non-advancing self-leave before submitting any P6 leave mutation. Constraint: Public discovery remains hidden/test-only for group E2EE. Constraint: OpenMLS 0.8 self-leave can be local-terminal without advancing the group epoch. Rejected: Fall back to public group.remove/group.leave for E2EE groups | would separate membership changes from cryptographic epoch changes. Rejected: Submit same-epoch self-leave to group.e2ee.leave | service acceptance would rely on delivery suppression rather than MLS exclusion. Confidence: high Scope-risk: moderate Directive: Do not enable E2EE self-leave until anp-mls exposes an epoch-advancing commit or a reviewed leave-request/remaining-member flow exists. Tested: go test ./internal/message -run 'TestLeaveGroupE2EERejectsLocalTerminalSelfLeaveBeforeServiceSubmit|TestUnsupportedGroupE2EESelfLeaveReasonDetectsNonAdvancingEpoch' -v Tested: go test ./internal/message ./internal/cli ./internal/cmdmeta Tested: go test ./... Tested: go vet ./... Tested: git diff --check --- CLAUDE.md | 8 +- docs/installation.md | 2 +- ...up-e2ee-p6-conformance-before-discovery.md | 2 +- internal/cli/group_test.go | 5 +- internal/cli/msg.go | 2 + internal/cmdmeta/catalog.go | 2 +- internal/message/group_e2ee_provider.go | 40 +++ internal/message/group_e2ee_provider_test.go | 46 +++ internal/message/group_e2ee_service.go | 314 +++++++++++++++++- internal/message/group_service.go | 40 ++- internal/message/group_service_test.go | 100 ++++++ internal/message/group_wire.go | 57 ++++ internal/message/group_wire_test.go | 87 +++++ internal/message/http_client.go | 16 + internal/message/http_client_test.go | 40 +++ internal/message/types.go | 15 +- 16 files changed, 757 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ecdd6fa..d3bc390 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ **internal/cli/id.go**: `id` 域命令处理器,包含 create/list/current/use/register/bind/resolve/recover/profile/import-v1,以及公开但危险的维护命令 `replace-did`。 **internal/cli/debug.go**: `debug db query`、`debug db handle-history` 与 `debug db import-v1` 的 CLI 处理器。 **internal/cli/msg.go**: `msg send/inbox/history/mark-read` 的 CLI 处理器,现已支持 direct + group plain messaging。 -**internal/cli/group.go**: `group create/get/join/add/remove/leave/update/members/messages` 的 CLI 处理器。 +**internal/cli/group.go**: `group create/get/join/add/remove/leave/update/members/messages` 的 CLI 处理器;E2EE 群 remove/leave 会路由到 hidden `group.e2ee.remove/leave` 组合编排而不是公开 P4-only 方法。 **internal/cli/group_e2ee.go**: P6 group E2EE 诊断/维护命令处理器;支持本地 exec provider/status/KeyPackage 发布路径,以及 hidden/test-only `group.e2ee.notice` pending/repair 拉取与 welcome 重放;`contract-test` 仅在显式 flag 下启用。 **internal/identity/types.go**: identity store、legacy scan、command result 等核心类型。 **internal/identity/layout.go**: identity 根目录、index.json、路径与安全写入辅助。 @@ -94,8 +94,8 @@ **internal/message/secure.go**: P5 direct E2EE secure send 的首版编排层,使用 ANP Go SDK direct_e2ee、本地文件会话/预密钥存储和 HTTP JSON-RPC;key-service 请求绑定当前 DID 文档里 `ANPMessageService.serviceDid`,并在有可用 sidecar OPK 时优先用 OPK 建链(本地保存 `p5-one-time-prekeys/`);HTTP inbox/history 现已接入入站密文解密与会话推进,并会顺带补发本地 prekey bundle;轮询路径解密 direct-init 成功后会自动发送 encrypted ACK 并尝试 flush 该 peer 的 `e2ee_outbox`;当 initiator 仍处于 `pending-confirmation` 时,新的 secure 发送会进入 `e2ee_outbox` 排队。 **internal/message/secure_control.go**: secure 控制面与恢复辅助,负责 secure ack/init payload、pending 阶段的 `e2ee_outbox` 排队、secure outbox flush,以及 `msg secure status/init/repair/failed/retry/drop` 需要的本地会话/发件箱读取与重试逻辑。 **internal/message/group_e2ee_provider.go**: `anp-mls` exec provider 抽象;按 `AWIKI_ANP_MLS_BINARY`、测试/运行时注入路径、`PATH` 顺序发现二进制;JSON request 走 stdin、response 走 stdout、日志/错误走 stderr,默认 MLS 根目录为 `/mls`,实际 OpenMLS 私有状态按 agent/device 分到子目录,并可扫描同一 agent 下的本地 device state 供收件解密恢复,保持 Go 主工程 pure Go / no CGO。 -**internal/message/group_e2ee_service.go**: group E2EE 业务编排层;负责 KeyPackage 发布前用当前 DID `key-1` 生成 strict Appendix-B `did_wba_binding.proof`、owner create/add、send encrypt、messages decrypt、P6 notice pending/repair、ratchet tree welcome process,以及 MLS AAD 元数据传入 `anp-mls`;在同一工作区存在目标成员身份时也会本地处理 add 返回的 welcome notice,使 one-shot `anp-mls` agent/device 状态可恢复。 -**internal/message/group_wire.go**: group 标准面和 local-only RPC 参数构造器;P6 publish/get/notice 使用 `transport-protected` service/agent target,create 使用 service target,add/send 使用 group target;group E2EE send 会在签名/发送前裁剪 provider-local MLS 字段,只把 P6 service 允许的 opaque cipher 字段送到 message-service。 +**internal/message/group_e2ee_service.go**: group E2EE 业务编排层;负责 KeyPackage 发布前用当前 DID `key-1` 生成 strict Appendix-B `did_wba_binding.proof`、owner create/add/remove、阻断 OpenMLS 0.8 local-terminal/non-advancing self-leave、pending commit finalize/abort、send encrypt、messages decrypt、P6 notice pending/repair、ratchet tree welcome/commit notice process,以及 MLS AAD 元数据传入 `anp-mls`;在同一工作区存在目标成员身份时也会本地处理 add 返回的 welcome notice,使 one-shot `anp-mls` agent/device 状态可恢复。 +**internal/message/group_wire.go**: group 标准面和 local-only RPC 参数构造器;P6 publish/get/notice 使用 `transport-protected` service/agent target,create 使用 service target,add/remove/leave/send 使用 group target;group E2EE send/remove/leave 会在签名/发送前裁剪 provider-local MLS/private 字段,只把 P6 service 允许的 opaque cipher/commit 字段送到 message-service。 **internal/message/http_client.go**: direct/group message、group lifecycle 与 hidden/test-only P6 notice pull/mark-delivered 的 HTTP JSON-RPC adapter。 **internal/message/ws_proxy_client.go**: websocket 模式下通过本地 bridge 调用 listener/daemon 的 direct/group adapter。 **internal/message/service.go**: direct inbox/send/history/mark-read 的业务编排层,融合 transport、identity、store;支持收件后自动 DID→Handle 补全,以及按 handle 聚合历史 DID 消息。 @@ -219,7 +219,7 @@ - `msg` 域中 direct plain 已实现,P5 secure direct 出站首版已接入;HTTP inbox/history 的 P5 入站自动解密、轮询 direct-init 自动 ACK 与 outbox flush 已接入;websocket listener 已能解密 secure incoming、自动 first-reply/ack,并在 secure ack 后尝试 flush `e2ee_outbox`;`msg secure status/init/repair/failed/retry/drop` 有首版,但完整 runtime 联调与更强系统测试仍未完成 - `group` 域的 plain lifecycle / local view / group messaging 已接入,`people` 仍大多为 stub;`page` 已完成 content pages 首版 -- secure E2EE 业务流目前只完成 P5 出站首版;group E2EE 已接入 CLI 侧 `anp-mls` exec 编排、KeyPackage 发布、group-e2ee create/add/send 与轮询消息本地解密分支,并已有隐藏 focused target 覆盖真实 OpenMLS Alice/Bob 最小闭环;对外 discovery 仍保持隐藏,完整 MLS 群管理能力仍未实现。 +- secure E2EE 业务流目前只完成 P5 出站首版;group E2EE 已接入 CLI 侧 `anp-mls` exec 编排、KeyPackage 发布、group-e2ee create/add/remove/send、阻断不安全 self-leave、pending commit finalize/abort、commit-delivery repair 与轮询消息本地解密分支,并已有隐藏 focused target 覆盖真实 OpenMLS Alice/Bob 最小闭环;对外 discovery 仍保持隐藏,External Commit/recovery、安全 leave-request 与完整 MLS 群管理能力仍未实现。 ## 开发与验证约定 diff --git a/docs/installation.md b/docs/installation.md index 75e9460..e6a4189 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -167,7 +167,7 @@ By default the script builds `../anp/anp/rust` with Cargo and copies `anp-mls` t export AWIKI_ANP_MLS_BINARY=/absolute/path/to/anp-mls ``` -The MLS private state root remains `~/.awiki-cli/mls/` (or the current `AWIKI_CLI_WORKSPACE_HOME_DIR` equivalent). Runtime OpenMLS state is agent/device-scoped under that root (`mls/agents///state.db`) so two local identities do not share private KeyPackage storage. Incoming decrypt first tries the default device and then scans the local agent-scoped device directories, allowing one-shot CLI commands to restore state for KeyPackages published with a named device. `group e2ee pending` / `repair` use the hidden/test-only P6 `group.e2ee.notice` pull path to list and replay durable welcome notices; repair passes `welcome_b64u + ratchet_tree_b64u` back to `anp-mls` and marks only successfully processed notices delivered. `doctor` reports the root directory permissions plus state file status, including warnings when cached group-E2EE groups exist but MLS state is missing. +The MLS private state root remains `~/.awiki-cli/mls/` (or the current `AWIKI_CLI_WORKSPACE_HOME_DIR` equivalent). Runtime OpenMLS state is agent/device-scoped under that root (`mls/agents///state.db`) so two local identities do not share private KeyPackage storage. Incoming decrypt first tries the default device and then scans the local agent-scoped device directories, allowing one-shot CLI commands to restore state for KeyPackages published with a named device. `group e2ee pending` / `repair` use the hidden/test-only P6 `group.e2ee.notice` pull path to list and replay durable welcome and commit-delivery notices; repair passes `welcome_b64u + ratchet_tree_b64u` or opaque `commit_b64u` public commit artifacts back to `anp-mls` and marks only successfully processed notices delivered. E2EE group removal uses local pending commit prepare plus hidden `group.e2ee.remove`, finalizing local MLS state only after service acceptance and aborting pending commits on deterministic service rejection. E2EE self-leave is intentionally blocked when `anp-mls` returns the OpenMLS 0.8 local-terminal/non-advancing leave artifact; the CLI aborts that pending local artifact before service submission and returns an actionable error to use owner/admin remove until a safe epoch-advancing leave-request flow exists. `doctor` reports the root directory permissions plus state file status, including warnings when cached group-E2EE groups exist but MLS state is missing. ### 3.2 config.yaml diff --git a/docs/pr-notes/group-e2ee-p6-conformance-before-discovery.md b/docs/pr-notes/group-e2ee-p6-conformance-before-discovery.md index d0e3039..44ea4c5 100644 --- a/docs/pr-notes/group-e2ee-p6-conformance-before-discovery.md +++ b/docs/pr-notes/group-e2ee-p6-conformance-before-discovery.md @@ -35,5 +35,5 @@ ## Caveats -- Still no remove/leave, External Commit, attachment group E2EE, cloud snapshot, or product-wide public beta claim. +- Owner/admin remove is routed through hidden PR-A `group.e2ee.remove` orchestration with local pending commit finalize/abort semantics. E2EE self-leave is not submitted to `group.e2ee.leave` when `anp-mls` only returns the OpenMLS 0.8 local-terminal/non-advancing artifact; the CLI aborts that local pending artifact and tells users to use owner/admin remove until a safe epoch-advancing leave-request flow exists. Still no External Commit, attachment group E2EE, cloud snapshot, or product-wide public beta claim. - No k1 DID compatibility is included. diff --git a/internal/cli/group_test.go b/internal/cli/group_test.go index 0408ef7..4e2d108 100644 --- a/internal/cli/group_test.go +++ b/internal/cli/group_test.go @@ -38,7 +38,7 @@ func TestGroupDryRunPlansRenderStableContracts(t *testing.T) { { name: "group remove uses kick action and member", spec: "group.remove", - setFlags: map[string]string{"group": "did:wba:example.com:groups:demo:e1_group", "member": "bob"}, + setFlags: map[string]string{"group": "did:wba:example.com:groups:demo:e1_group", "member": "bob", "e2ee": "true"}, wantSummary: "Dry run: group membership change planned", wantAction: "group.kick", verifyPlan: func(t *testing.T, plan map[string]any) { @@ -49,6 +49,9 @@ func TestGroupDryRunPlansRenderStableContracts(t *testing.T) { if request["Member"] != "bob" { t.Fatalf("request.Member = %#v, want %q", request["Member"], "bob") } + if request["E2EE"] != true { + t.Fatalf("request.E2EE = %#v, want true", request["E2EE"]) + } }, }, { diff --git a/internal/cli/msg.go b/internal/cli/msg.go index b883499..1e3a9a4 100644 --- a/internal/cli/msg.go +++ b/internal/cli/msg.go @@ -74,6 +74,8 @@ func (a *App) messageExit(err error, hint string) error { return output.NewExitError("identity_required", 3, err.Error(), "Complete user setup with `awiki-cli id register --handle ...` or recover an existing handle before using msg commands.") case errors.Is(err, message.ErrSecureNotSupported): return output.NewExitError("unsupported_mode", 1, err.Error(), "Secure messaging is currently supported only for direct text messaging.") + case errors.Is(err, message.ErrGroupE2EESelfLeaveUnsupported): + return output.NewExitError("unsupported_mode", 1, err.Error(), "For PR-A group E2EE, ask a group owner/admin to remove the member; self-leave requires a future epoch-advancing leave-request flow.") case errors.Is(err, message.ErrTransportUnavailable): return output.NewExitError("transport_unavailable", 1, err.Error(), "Start the websocket listener/daemon or switch runtime.mode back to http.") default: diff --git a/internal/cmdmeta/catalog.go b/internal/cmdmeta/catalog.go index 28203a7..cbc3df5 100644 --- a/internal/cmdmeta/catalog.go +++ b/internal/cmdmeta/catalog.go @@ -161,7 +161,7 @@ func defaultSpecs() []CommandSpec { {Name: "group.get", Use: "get", Short: "Show group details", Aliases: []string{"show"}, Phase: "phase5", Implemented: true, Handler: "group.get", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}}}, {Name: "group.join", Use: "join", Short: "Join an open group", Phase: "phase5", Implemented: true, Handler: "group.join", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "reason", Type: "string", Usage: "Join reason"}}}, {Name: "group.add", Use: "add", Short: "Add a member to a group", Phase: "phase5", Implemented: true, Handler: "group.add", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "member", Type: "string", Usage: "Member DID or handle", Required: true}, {Name: "role", Type: "string", Usage: "Member role", Default: "member"}, {Name: "e2ee", Type: "bool", Usage: "Force group E2EE add-member orchestration when cache is unavailable"}}}, - {Name: "group.remove", Use: "remove", Short: "Remove a member from a group", Aliases: []string{"kick"}, Phase: "phase5", Implemented: true, Handler: "group.remove", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "member", Type: "string", Usage: "Member DID or handle", Required: true}, {Name: "reason", Type: "string", Usage: "Removal reason"}}}, + {Name: "group.remove", Use: "remove", Short: "Remove a member from a group", Aliases: []string{"kick"}, Phase: "phase5", Implemented: true, Handler: "group.remove", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "member", Type: "string", Usage: "Member DID or handle", Required: true}, {Name: "reason", Type: "string", Usage: "Removal reason"}, {Name: "e2ee", Type: "bool", Usage: "Force group E2EE remove-member orchestration when cache is unavailable"}}}, {Name: "group.leave", Use: "leave", Short: "Leave a group", Phase: "phase5", Implemented: true, Handler: "group.leave", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}}}, {Name: "group.update", Use: "update", Short: "Update group profile or policy", Phase: "phase5", Implemented: true, Handler: "group.update", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "name", Type: "string", Usage: "New group display name"}, {Name: "description", Type: "string", Usage: "New group description"}, {Name: "discoverability", Type: "string", Usage: "Discoverability mode"}, {Name: "admission-mode", Type: "string", Usage: "Admission mode"}, {Name: "slug", Type: "string", Usage: "New group slug"}, {Name: "goal", Type: "string", Usage: "New group goal"}, {Name: "rules", Type: "string", Usage: "New group rules"}, {Name: "message-prompt", Type: "string", Usage: "New group prompt"}, {Name: "doc-url", Type: "string", Usage: "New group document URL"}, {Name: "attachments-allowed", Type: "bool", Usage: "Allow attachments"}, {Name: "max-members", Type: "string", Usage: "Maximum group members"}, {Name: "member-max-messages", Type: "int", Usage: "Per-member message limit"}, {Name: "member-max-total-chars", Type: "int", Usage: "Per-member total char limit"}}}, {Name: "group.members", Use: "members", Short: "List active group members", Phase: "phase5", Implemented: true, Handler: "group.members", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "limit", Type: "int", Usage: "Maximum number of rows", Default: "100"}}}, diff --git a/internal/message/group_e2ee_provider.go b/internal/message/group_e2ee_provider.go index 5202a06..2a95b93 100644 --- a/internal/message/group_e2ee_provider.go +++ b/internal/message/group_e2ee_provider.go @@ -373,6 +373,38 @@ func (p MLSExecProvider) AddMember(ctx context.Context, req MLSRequest) (map[str return resp.Result, nil } +func (p MLSExecProvider) RemoveMember(ctx context.Context, req MLSRequest) (map[string]any, error) { + resp, err := p.Call(ctx, "group", "remove-member", req) + if err != nil { + return nil, err + } + return resp.Result, nil +} + +func (p MLSExecProvider) LeaveGroup(ctx context.Context, req MLSRequest) (map[string]any, error) { + resp, err := p.Call(ctx, "group", "leave", req) + if err != nil { + return nil, err + } + return resp.Result, nil +} + +func (p MLSExecProvider) CommitFinalize(ctx context.Context, req MLSRequest) (map[string]any, error) { + resp, err := p.Call(ctx, "group", "commit-finalize", req) + if err != nil { + return nil, err + } + return resp.Result, nil +} + +func (p MLSExecProvider) CommitAbort(ctx context.Context, req MLSRequest) (map[string]any, error) { + resp, err := p.Call(ctx, "group", "commit-abort", req) + if err != nil { + return nil, err + } + return resp.Result, nil +} + func (p MLSExecProvider) ProcessWelcome(ctx context.Context, req MLSRequest) (map[string]any, error) { resp, err := p.Call(ctx, "welcome", "process", req) if err != nil { @@ -381,6 +413,14 @@ func (p MLSExecProvider) ProcessWelcome(ctx context.Context, req MLSRequest) (ma return resp.Result, nil } +func (p MLSExecProvider) ProcessCommit(ctx context.Context, req MLSRequest) (map[string]any, error) { + resp, err := p.Call(ctx, "commit", "process", req) + if err != nil { + return nil, err + } + return resp.Result, nil +} + func (p MLSExecProvider) Encrypt(ctx context.Context, req MLSRequest) (map[string]any, error) { resp, err := p.Call(ctx, "message", "encrypt", req) if err != nil { diff --git a/internal/message/group_e2ee_provider_test.go b/internal/message/group_e2ee_provider_test.go index def7fd5..4d12c4e 100644 --- a/internal/message/group_e2ee_provider_test.go +++ b/internal/message/group_e2ee_provider_test.go @@ -119,6 +119,52 @@ func TestMLSExecProviderPassesPlaintextOnStdinNotArgv(t *testing.T) { } } +func TestMLSExecProviderMembershipLifecycleUsesStableCommands(t *testing.T) { + cases := []struct { + name string + call func(context.Context, MLSExecProvider, MLSRequest) (map[string]any, error) + want string + }{ + {name: "remove", call: func(ctx context.Context, p MLSExecProvider, req MLSRequest) (map[string]any, error) { + return p.RemoveMember(ctx, req) + }, want: "group remove-member --json-in -"}, + {name: "leave", call: func(ctx context.Context, p MLSExecProvider, req MLSRequest) (map[string]any, error) { + return p.LeaveGroup(ctx, req) + }, want: "group leave --json-in -"}, + {name: "finalize", call: func(ctx context.Context, p MLSExecProvider, req MLSRequest) (map[string]any, error) { + return p.CommitFinalize(ctx, req) + }, want: "group commit-finalize --json-in -"}, + {name: "abort", call: func(ctx context.Context, p MLSExecProvider, req MLSRequest) (map[string]any, error) { + return p.CommitAbort(ctx, req) + }, want: "group commit-abort --json-in -"}, + {name: "commit process", call: func(ctx context.Context, p MLSExecProvider, req MLSRequest) (map[string]any, error) { + return p.ProcessCommit(ctx, req) + }, want: "commit process --json-in -"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + runner := &recordingMLSRunner{} + provider := MLSExecProvider{BinaryPath: "anp-mls", Runner: runner} + _, err := tc.call(context.Background(), provider, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "req-" + tc.name, + AgentDID: "did:wba:example.com:users:alice:e1_alice", + Params: map[string]any{ + "agent_did": "did:wba:example.com:users:alice:e1_alice", + "group_did": "did:wba:example.com:groups:demo:e1_group", + "operation_id": "op-1", + }, + }) + if err != nil { + t.Fatalf("provider call error = %v", err) + } + if got := strings.Join(runner.args, " "); got != tc.want { + t.Fatalf("args = %q, want %q", got, tc.want) + } + }) + } +} + func TestMLSExecProviderCandidateDeviceIDsScansAgentScopedState(t *testing.T) { root := t.TempDir() agentDID := "did:wba:example.com:users:bob:e1" diff --git a/internal/message/group_e2ee_service.go b/internal/message/group_e2ee_service.go index b8a1752..031c717 100644 --- a/internal/message/group_e2ee_service.go +++ b/internal/message/group_e2ee_service.go @@ -3,7 +3,10 @@ package message import ( "context" "encoding/json" + "errors" "fmt" + "net/http" + "strconv" "strings" "github.com/agentconnect/awiki-cli/internal/anpsdk" @@ -217,6 +220,226 @@ func (s *Service) addGroupMemberE2EE(ctx context.Context, record *identity.Store return result, warnings } +func (s *Service) removeGroupMemberE2EE(ctx context.Context, record *identity.StoredIdentity, request GroupMemberRequest) (map[string]any, []string, error) { + operationID := "op-" + generateOperationID() + provider := s.groupMLSProvider() + prepared, err := provider.RemoveMember(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-remove-" + generateOperationID(), + AgentDID: record.DID, + DeviceID: "default", + Params: map[string]any{ + "agent_did": record.DID, + "actor_did": record.DID, + "device_id": "default", + "group_did": request.Group, + "member_did": request.Member, + "subject_did": request.Member, + "operation_id": operationID, + "group_state_ref": s.localGroupStateRef(ctx, record, request.Group), + }, + }) + if err != nil { + return nil, nil, err + } + return s.submitPreparedGroupE2EECommit(ctx, record, request.Group, request.Member, request.ReasonText, prepared, func(transport *HTTPTransport) (map[string]any, error) { + return transport.RemoveGroupE2EE(ctx, request.Group, request.Member, prepared, request.ReasonText) + }) +} + +func (s *Service) leaveGroupE2EE(ctx context.Context, record *identity.StoredIdentity, request GroupLeaveRequest) (map[string]any, []string, error) { + operationID := "op-" + generateOperationID() + provider := s.groupMLSProvider() + prepared, err := provider.LeaveGroup(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-leave-" + generateOperationID(), + AgentDID: record.DID, + DeviceID: "default", + Params: map[string]any{ + "agent_did": record.DID, + "actor_did": record.DID, + "device_id": "default", + "group_did": request.Group, + "subject_did": record.DID, + "operation_id": operationID, + "group_state_ref": s.localGroupStateRef(ctx, record, request.Group), + }, + }) + if err != nil { + return nil, nil, err + } + if reason := unsupportedGroupE2EESelfLeaveReason(prepared); reason != "" { + data := map[string]any{"mls_prepare": prepared, "subject_did": record.DID} + warnings := []string{"Group E2EE self-leave is unsupported in PR-A because anp-mls cannot produce an epoch-advancing remove commit for the leaving member."} + if abortResult, abortErr := s.abortPreparedGroupE2EECommit(ctx, record, request.Group, prepared); abortErr != nil { + warnings = append(warnings, fmt.Sprintf("Group E2EE local-terminal leave pending commit abort failed: %v", abortErr)) + } else { + data["mls_abort"] = abortResult + warnings = append(warnings, "Group E2EE local-terminal leave pending commit aborted before service submission.") + } + return data, warnings, fmt.Errorf("%w: %s; ask a group owner/admin to remove this member until an epoch-advancing leave-request flow is available", ErrGroupE2EESelfLeaveUnsupported, reason) + } + return s.submitPreparedGroupE2EECommit(ctx, record, request.Group, record.DID, "", prepared, func(transport *HTTPTransport) (map[string]any, error) { + return transport.LeaveGroupE2EE(ctx, request.Group, prepared) + }) +} + +func (s *Service) submitPreparedGroupE2EECommit( + ctx context.Context, + record *identity.StoredIdentity, + groupDID string, + subjectDID string, + reasonText string, + prepared map[string]any, + submit func(*HTTPTransport) (map[string]any, error), +) (map[string]any, []string, error) { + transport, warnings, err := s.httpTransport(record) + if err != nil { + return map[string]any{"mls_prepare": prepared}, warnings, err + } + delivery, err := submit(transport) + if err != nil { + if shouldAbortGroupE2EEPendingCommit(err) { + if abortResult, abortErr := s.abortPreparedGroupE2EECommit(ctx, record, groupDID, prepared); abortErr != nil { + warnings = append(warnings, fmt.Sprintf("Group E2EE pending commit abort failed after service rejection: %v", abortErr)) + } else { + warnings = append(warnings, "Group E2EE pending commit aborted after deterministic service rejection.") + return map[string]any{"mls_prepare": prepared, "mls_abort": abortResult, "subject_did": subjectDID, "reason_text": reasonText}, warnings, fmt.Errorf("%w; local group E2EE pending commit aborted", err) + } + } else { + warnings = append(warnings, "Group E2EE pending commit left intact after retryable or unknown service failure; retry with the same operation_id or inspect group e2ee status before finalize/abort.") + } + return map[string]any{"mls_prepare": prepared, "subject_did": subjectDID, "reason_text": reasonText}, warnings, fmt.Errorf("%w; local group E2EE pending commit retained for retry", err) + } + finalized, finalizeErr := s.finalizePreparedGroupE2EECommit(ctx, record, groupDID, prepared) + if finalizeErr != nil { + warnings = append(warnings, fmt.Sprintf("Group E2EE service accepted commit but local finalize failed: %v", finalizeErr)) + } + summarySource := prepared + if finalized != nil { + summarySource = finalized + } + warnings = append(warnings, s.persistGroupE2EESummary(ctx, record, groupDID, summarySource, delivery)...) + return map[string]any{ + "mls_prepare": prepared, + "mls_finalize": finalized, + "delivery": delivery, + "subject_did": subjectDID, + "reason_text": reasonText, + }, warnings, nil +} + +func (s *Service) finalizePreparedGroupE2EECommit(ctx context.Context, record *identity.StoredIdentity, groupDID string, prepared map[string]any) (map[string]any, error) { + provider := s.groupMLSProvider() + return provider.CommitFinalize(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-commit-finalize-" + generateOperationID(), + AgentDID: record.DID, + DeviceID: "default", + Params: pendingCommitParams(record, groupDID, prepared), + }) +} + +func (s *Service) abortPreparedGroupE2EECommit(ctx context.Context, record *identity.StoredIdentity, groupDID string, prepared map[string]any) (map[string]any, error) { + provider := s.groupMLSProvider() + return provider.CommitAbort(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-commit-abort-" + generateOperationID(), + AgentDID: record.DID, + DeviceID: "default", + Params: pendingCommitParams(record, groupDID, prepared), + }) +} + +func unsupportedGroupE2EESelfLeaveReason(prepared map[string]any) string { + if len(prepared) == 0 { + return "" + } + if stringFromAny(prepared["artifact_type"]) == "local-terminal-leave" { + return "anp-mls returned a local-terminal leave artifact instead of an MLS epoch-advancing commit" + } + fromEpoch, hasFromEpoch := int64FromAny(prepared["from_epoch"]) + toEpoch, hasToEpoch := int64FromAny(prepared["to_epoch"]) + if !hasToEpoch { + toEpoch, hasToEpoch = int64FromAny(prepared["epoch"]) + } + if hasFromEpoch && hasToEpoch && toEpoch <= fromEpoch { + return fmt.Sprintf("anp-mls returned non-advancing leave epochs from_epoch=%d to_epoch=%d", fromEpoch, toEpoch) + } + return "" +} + +func int64FromAny(value any) (int64, bool) { + switch typed := value.(type) { + case int: + return int64(typed), true + case int8: + return int64(typed), true + case int16: + return int64(typed), true + case int32: + return int64(typed), true + case int64: + return typed, true + case uint: + return int64(typed), true + case uint8: + return int64(typed), true + case uint16: + return int64(typed), true + case uint32: + return int64(typed), true + case uint64: + if typed > uint64(^uint64(0)>>1) { + return 0, false + } + return int64(typed), true + case float64: + if typed != float64(int64(typed)) { + return 0, false + } + return int64(typed), true + case string: + parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + if err != nil { + return 0, false + } + return parsed, true + default: + return 0, false + } +} + +func pendingCommitParams(record *identity.StoredIdentity, groupDID string, prepared map[string]any) map[string]any { + params := map[string]any{ + "agent_did": record.DID, + "actor_did": record.DID, + "device_id": "default", + "group_did": groupDID, + "commit_b64u": prepared["commit_b64u"], + } + for _, key := range []string{"pending_commit_id", "operation_id", "subject_did", "subject_status", "from_epoch", "to_epoch"} { + if value, ok := prepared[key]; ok { + params[key] = value + } + } + return params +} + +func shouldAbortGroupE2EEPendingCommit(err error) bool { + var serviceErr *ServiceError + if !errors.As(err, &serviceErr) { + return false + } + if serviceErr.StatusCode >= http.StatusInternalServerError { + return false + } + if serviceErr.StatusCode >= http.StatusBadRequest { + return true + } + return serviceErr.RPCCode >= 2000 +} + func (s *Service) sendGroupE2EE(ctx context.Context, record *identity.StoredIdentity, request SendRequest) (*CommandResult, error) { provider := s.groupMLSProvider() warnings := s.syncGroupState(ctx, record, request.Group, false) @@ -467,18 +690,35 @@ func (s *Service) RepairGroupE2EENotices(ctx context.Context, identityName strin noticeIDs := make([]string, 0) provider := s.groupMLSProvider() for _, notice := range noticesFromResult(pending["notices"]) { - if stringFromAny(notice["notice_type"]) != "welcome-delivery" { + noticeType := stringFromAny(notice["notice_type"]) + if noticeType != "welcome-delivery" && noticeType != "commit-delivery" { continue } targetGroupDID := defaultString(stringFromAny(notice["group_did"]), groupDID) if targetGroupDID == "" { - warnings = append(warnings, "Group E2EE repair skipped welcome notice without group_did") + warnings = append(warnings, fmt.Sprintf("Group E2EE repair skipped %s notice without group_did", noticeType)) continue } - if recipient := firstNonEmptyString(notice["recipient_did"], notice["member_did"], notice["subject_did"]); recipient != "" && recipient != record.DID { + recipient := firstNonEmptyString(notice["recipient_did"], notice["member_did"]) + if noticeType == "welcome-delivery" && recipient == "" { + recipient = stringFromAny(notice["subject_did"]) + } + if recipient != "" && recipient != record.DID { warnings = append(warnings, fmt.Sprintf("Group E2EE repair skipped notice for different recipient %s", recipient)) continue } + if noticeType == "commit-delivery" { + commit, noticeWarnings := s.processGroupCommitNotice(ctx, record, targetGroupDID, notice) + if commit != nil { + processed = append(processed, commit) + if noticeID := stringFromAny(notice["notice_id"]); noticeID != "" { + noticeIDs = append(noticeIDs, noticeID) + } + continue + } + warnings = append(warnings, noticeWarnings...) + continue + } welcome, noticeWarnings := s.processGroupWelcomeNotice(ctx, record, targetGroupDID, notice) if welcome != nil { processed = append(processed, welcome) @@ -523,6 +763,74 @@ func (s *Service) RepairGroupE2EENotices(ctx context.Context, identityName strin }, nil } +func (s *Service) processGroupCommitNotice(ctx context.Context, record *identity.StoredIdentity, groupDID string, notice map[string]any) (map[string]any, []string) { + commitB64U := stringFromAny(notice["commit_b64u"]) + if commitB64U == "" { + return nil, []string{"Group E2EE repair skipped commit notice missing commit_b64u"} + } + deviceID := defaultString(stringFromAny(notice["device_id"]), "default") + groupStateRef, _ := notice["group_state_ref"].(map[string]any) + if len(groupStateRef) == 0 { + groupStateRef = map[string]any{ + "group_did": groupDID, + } + if cryptoGroupID := stringFromAny(notice["crypto_group_id_b64u"]); cryptoGroupID != "" { + groupStateRef["crypto_group_id_b64u"] = cryptoGroupID + } + if fromEpoch := stringFromAny(notice["from_epoch"]); fromEpoch != "" { + groupStateRef["epoch"] = fromEpoch + } + } + params := map[string]any{ + "agent_did": record.DID, + "device_id": deviceID, + "group_did": groupDID, + "group_state_ref": groupStateRef, + "commit_b64u": commitB64U, + "ratchet_tree_b64u": notice["ratchet_tree_b64u"], + "group_info_b64u": notice["group_info_b64u"], + "operation_id": notice["operation_id"], + "notice_id": notice["notice_id"], + "actor_did": notice["actor_did"], + "subject_did": notice["subject_did"], + "subject_status": notice["subject_status"], + "from_epoch": notice["from_epoch"], + "to_epoch": notice["to_epoch"], + "crypto_group_id_b64u": notice["crypto_group_id_b64u"], + "epoch_authenticator": firstNonNil(notice["epoch_authenticator"], notice["epoch_authenticator_b64u"]), + } + provider := s.groupMLSProvider() + commitResult, err := provider.ProcessCommit(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-commit-repair-" + generateOperationID(), + AgentDID: record.DID, + DeviceID: deviceID, + Params: params, + }) + if err != nil { + return nil, []string{fmt.Sprintf("Group E2EE repair commit processing failed: %v", err)} + } + warnings := []string(nil) + subjectDID := stringFromAny(notice["subject_did"]) + subjectStatus := stringFromAny(notice["subject_status"]) + if subjectDID == record.DID && (subjectStatus == "removed" || subjectStatus == "left") { + warnings = append(warnings, s.markCachedGroupLeft(ctx, record, groupDID)...) + } else { + warnings = append(warnings, s.persistGroupE2EESummary(ctx, record, groupDID, commitResult, notice)...) + } + return map[string]any{ + "processed": true, + "notice_type": "commit-delivery", + "notice_id": notice["notice_id"], + "group_did": groupDID, + "member_did": record.DID, + "device_id": deviceID, + "epoch": firstNonNil(commitResult["epoch"], notice["to_epoch"]), + "subject_did": subjectDID, + "subject_status": subjectStatus, + }, warnings +} + func (s *Service) groupWelcomeAlreadyAvailable(ctx context.Context, provider MLSExecProvider, record *identity.StoredIdentity, groupDID string, notice map[string]any) bool { deviceID := defaultString(stringFromAny(notice["device_id"]), "default") resp, err := provider.Call(ctx, "group", "status", MLSRequest{ diff --git a/internal/message/group_service.go b/internal/message/group_service.go index e47a004..88f67c8 100644 --- a/internal/message/group_service.go +++ b/internal/message/group_service.go @@ -128,9 +128,30 @@ func (s *Service) mutateGroupMember(ctx context.Context, request GroupMemberRequ } request.Member = memberDID var preMutationSnapshot map[string]any - if action == "add" { + if action == "add" || action == "remove" { preMutationSnapshot, _ = s.readCachedGroupSnapshot(ctx, record, request.Group) } + if action == "remove" && groupMemberMutationUsesE2EE(request, preMutationSnapshot, nil) { + e2eeResult, e2eeWarnings, err := s.removeGroupMemberE2EE(ctx, record, request) + if err != nil { + return nil, err + } + warnings := append([]string(nil), e2eeWarnings...) + warnings = append(warnings, s.syncGroupState(ctx, record, request.Group, true)...) + snapshot, _ := s.readCachedGroupSnapshot(ctx, record, request.Group) + members, _ := s.readCachedGroupMembers(ctx, record, request.Group, 100) + return &CommandResult{ + Data: map[string]any{ + "group": snapshot, + "members": members, + "delivery": e2eeResult["delivery"], + "member": map[string]any{"did": memberDID, "handle": memberHandle}, + "e2ee": e2eeResult, + }, + Summary: "Removed member from group with group E2EE", + Warnings: compactWarnings(warnings), + }, nil + } transport, warnings, err := s.groupControlTransport(record) if err != nil { return nil, err @@ -171,6 +192,23 @@ func (s *Service) LeaveGroup(ctx context.Context, request GroupLeaveRequest) (*C if snapshotErr == nil && isActiveGroupOwner(cachedSnapshot) { return nil, ErrGroupOwnerCannotLeave } + if groupSnapshotUsesE2EE(cachedSnapshot) { + e2eeResult, e2eeWarnings, err := s.leaveGroupE2EE(ctx, record, request) + if err != nil { + return nil, err + } + warnings := append([]string(nil), e2eeWarnings...) + warnings = append(warnings, s.markCachedGroupLeft(ctx, record, request.Group)...) + return &CommandResult{ + Data: map[string]any{ + "delivery": e2eeResult["delivery"], + "group": request.Group, + "e2ee": e2eeResult, + }, + Summary: fmt.Sprintf("Left group %s with group E2EE", request.Group), + Warnings: compactWarnings(warnings), + }, nil + } transport, warnings, err := s.groupControlTransport(record) if err != nil { return nil, err diff --git a/internal/message/group_service_test.go b/internal/message/group_service_test.go index 438c256..0ff7508 100644 --- a/internal/message/group_service_test.go +++ b/internal/message/group_service_test.go @@ -3,6 +3,9 @@ package message import ( "context" "errors" + "fmt" + "net/http" + "strings" "testing" appconfig "github.com/agentconnect/awiki-cli/internal/config" @@ -49,6 +52,83 @@ func TestLeaveGroupRejectsActiveOwnerFromCachedSnapshot(t *testing.T) { } } +type groupLeaveSafetyMLSRunner struct { + calls []string +} + +func (r *groupLeaveSafetyMLSRunner) Run(_ context.Context, _ string, args []string, _ []byte) ([]byte, []byte, error) { + call := strings.Join(args, " ") + r.calls = append(r.calls, call) + switch call { + case "group leave --json-in -": + return []byte(`{"ok":true,"api_version":"anp-mls/v1","request_id":"req-leave","result":{"pending_commit_id":"pc-local-terminal-leave","operation_id":"op-leave","status":"pending","artifact_type":"local-terminal-leave","subject_status":"left","from_epoch":"4","to_epoch":"4","epoch":"4","commit_b64u":"bG9jYWwtdGVybWluYWw","crypto_group_id_b64u":"Y3J5cHRv","epoch_authenticator":"YXV0aA"}}`), nil, nil + case "group commit-abort --json-in -": + return []byte(`{"ok":true,"api_version":"anp-mls/v1","request_id":"req-abort","result":{"pending_commit_id":"pc-local-terminal-leave","status":"aborted","subject_status":"left"}}`), nil, nil + default: + return []byte(`{"ok":true,"api_version":"anp-mls/v1","request_id":"req-other","result":{}}`), nil, nil + } +} + +func TestLeaveGroupE2EERejectsLocalTerminalSelfLeaveBeforeServiceSubmit(t *testing.T) { + t.Parallel() + + resolved := testResolvedConfig(t) + manager := identity.NewManager(resolved.Paths) + createTestIdentity(t, manager, identity.SaveInput{ + IdentityName: "alice", + UserID: "user-123", + DisplayName: "Alice", + Handle: "alice", + }) + record, err := manager.Load("alice") + if err != nil { + t.Fatalf("Load() error = %v", err) + } + runner := &groupLeaveSafetyMLSRunner{} + provider := MLSExecProvider{BinaryPath: "anp-mls", Runner: runner} + service := &Service{resolved: resolved, manager: manager, mlsProvider: &provider} + + result, warnings, err := service.leaveGroupE2EE(context.Background(), record, GroupLeaveRequest{ + Group: "did:wba:awiki.ai:groups:test-e2ee-leave", + }) + if !errors.Is(err, ErrGroupE2EESelfLeaveUnsupported) { + t.Fatalf("leaveGroupE2EE() error = %v, want %v", err, ErrGroupE2EESelfLeaveUnsupported) + } + if !strings.Contains(err.Error(), "owner/admin") { + t.Fatalf("leaveGroupE2EE() error = %v, want actionable owner/admin removal guidance", err) + } + if got := strings.Join(runner.calls, ","); got != "group leave --json-in -,group commit-abort --json-in -" { + t.Fatalf("MLS calls = %q, want leave prepare followed by local abort only", got) + } + abort, ok := result["mls_abort"].(map[string]any) + if !ok { + t.Fatalf("mls_abort missing from result: %#v", result) + } + if got := stringFromAny(abort["status"]); got != "aborted" { + t.Fatalf("mls_abort.status = %q, want aborted", got) + } + if got := strings.Join(warnings, "\n"); !strings.Contains(got, "aborted before service submission") { + t.Fatalf("warnings = %#v, want abort-before-submit warning", warnings) + } +} + +func TestUnsupportedGroupE2EESelfLeaveReasonDetectsNonAdvancingEpoch(t *testing.T) { + t.Parallel() + + if reason := unsupportedGroupE2EESelfLeaveReason(map[string]any{"from_epoch": "7", "to_epoch": "7"}); !strings.Contains(reason, "non-advancing") { + t.Fatalf("same epoch reason = %q, want non-advancing", reason) + } + if reason := unsupportedGroupE2EESelfLeaveReason(map[string]any{"from_epoch": "7", "epoch": "6"}); !strings.Contains(reason, "non-advancing") { + t.Fatalf("regressed epoch reason = %q, want non-advancing", reason) + } + if reason := unsupportedGroupE2EESelfLeaveReason(map[string]any{"artifact_type": "local-terminal-leave", "from_epoch": "7", "to_epoch": "8"}); !strings.Contains(reason, "local-terminal") { + t.Fatalf("local terminal reason = %q, want local-terminal", reason) + } + if reason := unsupportedGroupE2EESelfLeaveReason(map[string]any{"from_epoch": "7", "to_epoch": "8"}); reason != "" { + t.Fatalf("advancing epoch reason = %q, want empty", reason) + } +} + func TestMarkCachedGroupLeftClearsMembersAndResetsRole(t *testing.T) { t.Parallel() @@ -149,6 +229,26 @@ func TestGroupMemberMutationUsesPreMutationE2EESnapshot(t *testing.T) { } } +func TestShouldAbortGroupE2EEPendingCommitOnlyForDeterministicServiceRejection(t *testing.T) { + t.Parallel() + + if !shouldAbortGroupE2EEPendingCommit(&ServiceError{StatusCode: http.StatusForbidden, Message: "forbidden"}) { + t.Fatal("403 service rejection should abort pending commit") + } + if !shouldAbortGroupE2EEPendingCommit(&ServiceError{RPCCode: 2501, Message: "inactive member"}) { + t.Fatal("2xxx RPC rejection should abort pending commit") + } + if shouldAbortGroupE2EEPendingCommit(&ServiceError{StatusCode: http.StatusServiceUnavailable, Message: "retry later"}) { + t.Fatal("5xx service failure should leave pending commit intact") + } + if shouldAbortGroupE2EEPendingCommit(&ServiceError{RPCCode: 1503, Message: "temporarily unavailable"}) { + t.Fatal("1xxx retryable RPC failure should leave pending commit intact") + } + if shouldAbortGroupE2EEPendingCommit(fmt.Errorf("connection reset")) { + t.Fatal("transport/network errors should leave pending commit intact") + } +} + func TestGroupStateRefFromSnapshotUsesServerStateVersionNotMLSEpoch(t *testing.T) { t.Parallel() diff --git a/internal/message/group_wire.go b/internal/message/group_wire.go index 1c3bf06..e12ee38 100644 --- a/internal/message/group_wire.go +++ b/internal/message/group_wire.go @@ -201,6 +201,19 @@ func BuildGroupE2EEAddRPCParams(record *identity.StoredIdentity, manager *identi return buildGroupE2EERPCParams(record, manager, "group", groupDID, "group.e2ee.add", body, "", "", "", GroupE2EESecurityProfile) } +func BuildGroupE2EERemoveRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, memberDID string, preparedCommit map[string]any, reasonText string) (map[string]any, error) { + body := e2eeMembershipCommitBody(groupDID, memberDID, "removed", preparedCommit) + if reason := strings.TrimSpace(reasonText); reason != "" { + body["reason_text"] = reason + } + return buildGroupE2EERPCParams(record, manager, "group", groupDID, "group.e2ee.remove", body, "", stringFromAny(preparedCommit["operation_id"]), "", GroupE2EESecurityProfile) +} + +func BuildGroupE2EELeaveRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, preparedCommit map[string]any) (map[string]any, error) { + body := e2eeMembershipCommitBody(groupDID, record.DID, "left", preparedCommit) + return buildGroupE2EERPCParams(record, manager, "group", groupDID, "group.e2ee.leave", body, "", stringFromAny(preparedCommit["operation_id"]), "", GroupE2EESecurityProfile) +} + func BuildGroupE2EESendRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, cipher map[string]any, operationID string, messageID string) (map[string]any, error) { return buildGroupE2EERPCParams(record, manager, "group", groupDID, "group.e2ee.send", map[string]any{"group_cipher_object": sanitizeGroupCipherObjectForService(cipher)}, "application/anp-group-cipher+json", operationID, messageID, GroupE2EESecurityProfile) } @@ -633,6 +646,50 @@ func e2eeHeadBody(groupDID string, memberDID string, mlsHead map[string]any) map return body } +func e2eeMembershipCommitBody(groupDID string, subjectDID string, defaultSubjectStatus string, preparedCommit map[string]any) map[string]any { + body := e2eeHeadBody(groupDID, subjectDID, preparedCommit) + for _, key := range []string{ + "pending_commit_id", + "operation_id", + "commit_b64u", + "ratchet_tree_b64u", + "group_info_b64u", + "from_epoch", + "to_epoch", + "actor_did", + "subject_status", + } { + if value, ok := preparedCommit[key]; ok { + body[key] = value + } + } + if _, ok := body["epoch"]; !ok { + if value, ok := preparedCommit["to_epoch"]; ok { + body["epoch"] = value + } + } + if _, ok := body["epoch_authenticator"]; !ok { + if value, ok := preparedCommit["epoch_authenticator_b64u"]; ok { + body["epoch_authenticator"] = value + } + } + if _, ok := body["subject_status"]; !ok && defaultSubjectStatus != "" { + body["subject_status"] = defaultSubjectStatus + } + groupStateRef, _ := body["group_state_ref"].(map[string]any) + if len(groupStateRef) == 0 { + groupStateRef = map[string]any{"group_did": groupDID} + body["group_state_ref"] = groupStateRef + } + if cryptoGroupID := stringFromAny(body["crypto_group_id_b64u"]); cryptoGroupID != "" { + groupStateRef["crypto_group_id_b64u"] = cryptoGroupID + } + if fromEpoch := stringFromAny(body["from_epoch"]); fromEpoch != "" { + groupStateRef["epoch"] = fromEpoch + } + return body +} + func boolPtr(value bool) *bool { return &value } diff --git a/internal/message/group_wire_test.go b/internal/message/group_wire_test.go index ed6ed4c..d7165e4 100644 --- a/internal/message/group_wire_test.go +++ b/internal/message/group_wire_test.go @@ -252,6 +252,93 @@ func TestBuildGroupE2EEAddRPCParamsIncludesConsumedKeyPackageID(t *testing.T) { } } +func TestBuildGroupE2EERemoveRPCParamsUsesHiddenCompositeMutation(t *testing.T) { + t.Parallel() + + record := testStoredIdentity(t) + params, err := BuildGroupE2EERemoveRPCParams(record, nil, "did:wba:awiki.ai:groups:demo:e1_group", "did:wba:awiki.ai:user:bob:e1_bob", map[string]any{ + "pending_commit_id": "pc-remove-1", + "operation_id": "op-remove-1", + "crypto_group_id_b64u": "Y3J5cHRv", + "from_epoch": "4", + "to_epoch": "5", + "commit_b64u": "Y29tbWl0", + "ratchet_tree_b64u": "cmF0Y2hldA", + "group_info_b64u": "Z3JvdXBpbmZv", + "epoch_authenticator_b64u": "YXV0aDU", + "application_plaintext": "must-not-leak", + "provider_private_material": "must-not-leak", + "group_state_ref": map[string]any{"group_did": "did:wba:awiki.ai:groups:demo:e1_group", "epoch": "4"}, + }, "cleanup") + if err != nil { + t.Fatalf("BuildGroupE2EERemoveRPCParams() error = %v", err) + } + meta := mustMapValue(t, params["meta"], "params.meta") + if got := stringFromAny(meta["profile"]); got != GroupE2EEProfile { + t.Fatalf("meta.profile = %q, want %q", got, GroupE2EEProfile) + } + if got := stringFromAny(meta["security_profile"]); got != GroupE2EESecurityProfile { + t.Fatalf("meta.security_profile = %q, want %q", got, GroupE2EESecurityProfile) + } + if got := stringFromAny(meta["operation_id"]); got != "op-remove-1" { + t.Fatalf("meta.operation_id = %q, want prepared operation id", got) + } + target := mustMapValue(t, meta["target"], "meta.target") + if got := stringFromAny(target["kind"]); got != "group" { + t.Fatalf("target.kind = %q, want group", got) + } + body := mustMapValue(t, params["body"], "params.body") + if got := stringFromAny(body["subject_did"]); got != "did:wba:awiki.ai:user:bob:e1_bob" { + t.Fatalf("subject_did = %q, want bob", got) + } + if got := stringFromAny(body["subject_status"]); got != "removed" { + t.Fatalf("subject_status = %q, want removed", got) + } + if got := stringFromAny(body["commit_b64u"]); got != "Y29tbWl0" { + t.Fatalf("commit_b64u = %q, want opaque commit", got) + } + if _, ok := body["application_plaintext"]; ok { + t.Fatalf("plaintext leaked into remove body: %#v", body) + } + if _, ok := body["provider_private_material"]; ok { + t.Fatalf("provider private material leaked into remove body: %#v", body) + } + ref := mustMapValue(t, body["group_state_ref"], "body.group_state_ref") + if got := stringFromAny(ref["epoch"]); got != "4" { + t.Fatalf("group_state_ref.epoch = %q, want from_epoch/pre-remove epoch", got) + } +} + +func TestBuildGroupE2EELeaveRPCParamsUsesActorAsSubject(t *testing.T) { + t.Parallel() + + record := testStoredIdentity(t) + params, err := BuildGroupE2EELeaveRPCParams(record, nil, "did:wba:awiki.ai:groups:demo:e1_group", map[string]any{ + "operation_id": "op-leave-1", + "pending_commit_id": "pc-leave-1", + "crypto_group_id_b64u": "Y3J5cHRv", + "from_epoch": "5", + "to_epoch": "6", + "commit_b64u": "Y29tbWl0LWxlYXZl", + }) + if err != nil { + t.Fatalf("BuildGroupE2EELeaveRPCParams() error = %v", err) + } + body := mustMapValue(t, params["body"], "params.body") + if got := stringFromAny(body["subject_did"]); got != record.DID { + t.Fatalf("subject_did = %q, want actor DID", got) + } + if got := stringFromAny(body["member_did"]); got != record.DID { + t.Fatalf("member_did = %q, want actor DID", got) + } + if got := stringFromAny(body["subject_status"]); got != "left" { + t.Fatalf("subject_status = %q, want left", got) + } + if got := stringFromAny(body["epoch"]); got != "6" { + t.Fatalf("epoch = %q, want to_epoch", got) + } +} + func TestBuildGroupE2EEPublishKeyPackageRPCParamsStripsProviderOnlyFields(t *testing.T) { t.Parallel() diff --git a/internal/message/http_client.go b/internal/message/http_client.go index 9039ef8..a2ac4c7 100644 --- a/internal/message/http_client.go +++ b/internal/message/http_client.go @@ -291,6 +291,22 @@ func (t *HTTPTransport) AddGroupE2EE(ctx context.Context, groupDID string, membe return t.rpcMapCall(ctx, "group.e2ee.add", params) } +func (t *HTTPTransport) RemoveGroupE2EE(ctx context.Context, groupDID string, memberDID string, preparedCommit map[string]any, reasonText string) (map[string]any, error) { + params, err := BuildGroupE2EERemoveRPCParams(t.auth.record, nil, groupDID, memberDID, preparedCommit, reasonText) + if err != nil { + return nil, err + } + return t.rpcMapCall(ctx, "group.e2ee.remove", params) +} + +func (t *HTTPTransport) LeaveGroupE2EE(ctx context.Context, groupDID string, preparedCommit map[string]any) (map[string]any, error) { + params, err := BuildGroupE2EELeaveRPCParams(t.auth.record, nil, groupDID, preparedCommit) + if err != nil { + return nil, err + } + return t.rpcMapCall(ctx, "group.e2ee.leave", params) +} + func (t *HTTPTransport) PullGroupE2EENotices(ctx context.Context, groupDID string, limit int, markDelivered bool) (map[string]any, error) { params, err := BuildGroupE2EENoticeRPCParams(t.auth.record, nil, groupDID, limit, markDelivered, nil) if err != nil { diff --git a/internal/message/http_client_test.go b/internal/message/http_client_test.go index 4587777..b25eddd 100644 --- a/internal/message/http_client_test.go +++ b/internal/message/http_client_test.go @@ -301,6 +301,46 @@ func TestHTTPTransportGroupMethodsUseExpectedRPCMethods(t *testing.T) { } }, }, + { + name: "e2ee remove member", + wantMethod: "group.e2ee.remove", + call: func(transport *HTTPTransport) error { + _, err := transport.RemoveGroupE2EE(context.Background(), "did:group", "did:member", map[string]any{ + "operation_id": "op-remove", + "pending_commit_id": "pc-remove", + "crypto_group_id_b64u": "Y3J5cHRv", + "from_epoch": "1", + "to_epoch": "2", + "commit_b64u": "Y29tbWl0", + }, "cleanup") + return err + }, + verifyBody: func(t *testing.T, body map[string]any) { + if body["member_did"] != "did:member" || body["commit_b64u"] != "Y29tbWl0" || body["reason_text"] != "cleanup" { + t.Fatalf("body = %#v, want member/commit/reason", body) + } + }, + }, + { + name: "e2ee leave group", + wantMethod: "group.e2ee.leave", + call: func(transport *HTTPTransport) error { + _, err := transport.LeaveGroupE2EE(context.Background(), "did:group", map[string]any{ + "operation_id": "op-leave", + "pending_commit_id": "pc-leave", + "crypto_group_id_b64u": "Y3J5cHRv", + "from_epoch": "2", + "to_epoch": "3", + "commit_b64u": "Y29tbWl0LWxlYXZl", + }) + return err + }, + verifyBody: func(t *testing.T, body map[string]any) { + if body["group_did"] != "did:group" || body["subject_status"] != "left" || body["commit_b64u"] != "Y29tbWl0LWxlYXZl" { + t.Fatalf("body = %#v, want group/left/commit", body) + } + }, + }, { name: "list members", wantMethod: "group.list_members", diff --git a/internal/message/types.go b/internal/message/types.go index b545045..d7657ed 100644 --- a/internal/message/types.go +++ b/internal/message/types.go @@ -24,13 +24,14 @@ var ( ErrDownloadTargetConflict = errors.New( "attachment download accepts either --with or --group, but not both", ) - ErrAttachmentNotFound = errors.New("attachment not found in message content") - ErrAttachmentIDRequired = errors.New("attachment_id is required for messages with multiple attachments") - ErrAttachmentMessageInvalid = errors.New("message is not an attachment manifest") - ErrAttachmentSenderRequired = errors.New("attachment message sender_did is required") - ErrTransportUnavailable = errors.New("message transport is unavailable") - ErrSecureNotSupported = errors.New("secure messaging is not supported for this command yet") - ErrMessageNotFound = errors.New("message not found") + ErrAttachmentNotFound = errors.New("attachment not found in message content") + ErrAttachmentIDRequired = errors.New("attachment_id is required for messages with multiple attachments") + ErrAttachmentMessageInvalid = errors.New("message is not an attachment manifest") + ErrAttachmentSenderRequired = errors.New("attachment message sender_did is required") + ErrTransportUnavailable = errors.New("message transport is unavailable") + ErrSecureNotSupported = errors.New("secure messaging is not supported for this command yet") + ErrGroupE2EESelfLeaveUnsupported = errors.New("group E2EE self-leave is not cryptographically supported yet") + ErrMessageNotFound = errors.New("message not found") ) type CommandResult struct { From b71f55eaabb22aa99ced1da9e13d226c1e90ef8f Mon Sep 17 00:00:00 2001 From: changshan Date: Sun, 3 May 2026 23:30:31 +0800 Subject: [PATCH 10/14] Route E2EE self-leave through safe leave requests PR-B1 requires one-shot CLI clients to avoid OpenMLS local-terminal self-leave artifacts. E2EE group leave now creates a hidden leave_request control-plane record, while owner/admin processing uses the existing epoch-advancing remove-member orchestration and carries the leave request id into the hidden remove payload. Constraint: Public group E2EE discovery remains hidden/test-only during PR-B1 Constraint: Go CLI must remain pure Go and delegate MLS commits to anp-mls Rejected: Submit group.e2ee.leave with a same-epoch local-terminal artifact | fails cryptographic exclusion semantics Confidence: high Scope-risk: moderate Directive: Do not route E2EE group leave back to provider.LeaveGroup unless anp-mls can prove epoch-advancing remaining-member exclusion Tested: go test -run 'TestBuildGroupE2EE|TestHTTPTransportGroupMethods|TestLeaveGroupE2EE|TestGroupDryRunPlans' ./internal/message ./internal/cli Tested: go test ./... Tested: go vet ./... --- CLAUDE.md | 6 +- docs/architecture/awiki-command-v2.md | 5 +- docs/installation.md | 2 +- ...up-e2ee-p6-conformance-before-discovery.md | 2 +- internal/cli/group.go | 4 +- internal/cli/group_e2ee.go | 39 ++++++++ internal/cli/group_test.go | 34 +++++++ internal/cli/root.go | 2 + internal/cmdmeta/catalog.go | 3 +- internal/message/group_e2ee_service.go | 93 ++++++++++++------- internal/message/group_service.go | 5 +- internal/message/group_service_test.go | 92 +++++++++--------- internal/message/group_wire.go | 19 +++- internal/message/group_wire_test.go | 35 ++++++- internal/message/http_client.go | 12 ++- internal/message/http_client_test.go | 20 +++- internal/message/types.go | 21 ++++- skills/references/04-groups.md | 4 +- 18 files changed, 294 insertions(+), 104 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d3bc390..0170ef7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ **internal/cli/debug.go**: `debug db query`、`debug db handle-history` 与 `debug db import-v1` 的 CLI 处理器。 **internal/cli/msg.go**: `msg send/inbox/history/mark-read` 的 CLI 处理器,现已支持 direct + group plain messaging。 **internal/cli/group.go**: `group create/get/join/add/remove/leave/update/members/messages` 的 CLI 处理器;E2EE 群 remove/leave 会路由到 hidden `group.e2ee.remove/leave` 组合编排而不是公开 P4-only 方法。 -**internal/cli/group_e2ee.go**: P6 group E2EE 诊断/维护命令处理器;支持本地 exec provider/status/KeyPackage 发布路径,以及 hidden/test-only `group.e2ee.notice` pending/repair 拉取与 welcome 重放;`contract-test` 仅在显式 flag 下启用。 +**internal/cli/group_e2ee.go**: P6 group E2EE 诊断/维护命令处理器;支持本地 exec provider/status/KeyPackage 发布路径、hidden/test-only `group.e2ee.notice` pending/repair 拉取与 welcome 重放,以及 PR-B1 `process-leave-request` owner/admin epoch-advancing remove 编排;`contract-test` 仅在显式 flag 下启用。 **internal/identity/types.go**: identity store、legacy scan、command result 等核心类型。 **internal/identity/layout.go**: identity 根目录、index.json、路径与安全写入辅助。 **internal/identity/store.go**: 当前 v2 identity store 的读写、默认 identity 管理。 @@ -94,7 +94,7 @@ **internal/message/secure.go**: P5 direct E2EE secure send 的首版编排层,使用 ANP Go SDK direct_e2ee、本地文件会话/预密钥存储和 HTTP JSON-RPC;key-service 请求绑定当前 DID 文档里 `ANPMessageService.serviceDid`,并在有可用 sidecar OPK 时优先用 OPK 建链(本地保存 `p5-one-time-prekeys/`);HTTP inbox/history 现已接入入站密文解密与会话推进,并会顺带补发本地 prekey bundle;轮询路径解密 direct-init 成功后会自动发送 encrypted ACK 并尝试 flush 该 peer 的 `e2ee_outbox`;当 initiator 仍处于 `pending-confirmation` 时,新的 secure 发送会进入 `e2ee_outbox` 排队。 **internal/message/secure_control.go**: secure 控制面与恢复辅助,负责 secure ack/init payload、pending 阶段的 `e2ee_outbox` 排队、secure outbox flush,以及 `msg secure status/init/repair/failed/retry/drop` 需要的本地会话/发件箱读取与重试逻辑。 **internal/message/group_e2ee_provider.go**: `anp-mls` exec provider 抽象;按 `AWIKI_ANP_MLS_BINARY`、测试/运行时注入路径、`PATH` 顺序发现二进制;JSON request 走 stdin、response 走 stdout、日志/错误走 stderr,默认 MLS 根目录为 `/mls`,实际 OpenMLS 私有状态按 agent/device 分到子目录,并可扫描同一 agent 下的本地 device state 供收件解密恢复,保持 Go 主工程 pure Go / no CGO。 -**internal/message/group_e2ee_service.go**: group E2EE 业务编排层;负责 KeyPackage 发布前用当前 DID `key-1` 生成 strict Appendix-B `did_wba_binding.proof`、owner create/add/remove、阻断 OpenMLS 0.8 local-terminal/non-advancing self-leave、pending commit finalize/abort、send encrypt、messages decrypt、P6 notice pending/repair、ratchet tree welcome/commit notice process,以及 MLS AAD 元数据传入 `anp-mls`;在同一工作区存在目标成员身份时也会本地处理 add 返回的 welcome notice,使 one-shot `anp-mls` agent/device 状态可恢复。 +**internal/message/group_e2ee_service.go**: group E2EE 业务编排层;负责 KeyPackage 发布前用当前 DID `key-1` 生成 strict Appendix-B `did_wba_binding.proof`、owner create/add/remove、PR-B1 leave_request 创建与 owner/admin process-leave-request epoch-advancing remove、pending commit finalize/abort、send encrypt、messages decrypt、P6 notice pending/repair、ratchet tree welcome/commit notice process,以及 MLS AAD 元数据传入 `anp-mls`;在同一工作区存在目标成员身份时也会本地处理 add 返回的 welcome notice,使 one-shot `anp-mls` agent/device 状态可恢复。 **internal/message/group_wire.go**: group 标准面和 local-only RPC 参数构造器;P6 publish/get/notice 使用 `transport-protected` service/agent target,create 使用 service target,add/remove/leave/send 使用 group target;group E2EE send/remove/leave 会在签名/发送前裁剪 provider-local MLS/private 字段,只把 P6 service 允许的 opaque cipher/commit 字段送到 message-service。 **internal/message/http_client.go**: direct/group message、group lifecycle 与 hidden/test-only P6 notice pull/mark-delivered 的 HTTP JSON-RPC adapter。 **internal/message/ws_proxy_client.go**: websocket 模式下通过本地 bridge 调用 listener/daemon 的 direct/group adapter。 @@ -219,7 +219,7 @@ - `msg` 域中 direct plain 已实现,P5 secure direct 出站首版已接入;HTTP inbox/history 的 P5 入站自动解密、轮询 direct-init 自动 ACK 与 outbox flush 已接入;websocket listener 已能解密 secure incoming、自动 first-reply/ack,并在 secure ack 后尝试 flush `e2ee_outbox`;`msg secure status/init/repair/failed/retry/drop` 有首版,但完整 runtime 联调与更强系统测试仍未完成 - `group` 域的 plain lifecycle / local view / group messaging 已接入,`people` 仍大多为 stub;`page` 已完成 content pages 首版 -- secure E2EE 业务流目前只完成 P5 出站首版;group E2EE 已接入 CLI 侧 `anp-mls` exec 编排、KeyPackage 发布、group-e2ee create/add/remove/send、阻断不安全 self-leave、pending commit finalize/abort、commit-delivery repair 与轮询消息本地解密分支,并已有隐藏 focused target 覆盖真实 OpenMLS Alice/Bob 最小闭环;对外 discovery 仍保持隐藏,External Commit/recovery、安全 leave-request 与完整 MLS 群管理能力仍未实现。 +- secure E2EE 业务流目前只完成 P5 出站首版;group E2EE 已接入 CLI 侧 `anp-mls` exec 编排、KeyPackage 发布、group-e2ee create/add/remove/send、安全 self-leave request、owner/admin process-leave-request、pending commit finalize/abort、commit-delivery repair 与轮询消息本地解密分支,并已有隐藏 focused target 覆盖真实 OpenMLS Alice/Bob 最小闭环;对外 discovery 仍保持隐藏,External Commit/recovery 与完整 MLS 群管理能力仍未实现;安全 leave-request 已处于 hidden/test-only PR-B1 路径。 ## 开发与验证约定 diff --git a/docs/architecture/awiki-command-v2.md b/docs/architecture/awiki-command-v2.md index fd0354c..8f7ab5b 100644 --- a/docs/architecture/awiki-command-v2.md +++ b/docs/architecture/awiki-command-v2.md @@ -121,11 +121,12 @@ awiki-cli group create --name "Agent War Room" [--description "..."] [--discover awiki-cli group get --group GROUP_DID [--identity alice] awiki-cli group join --group GROUP_DID [--reason "..."] [--identity alice] awiki-cli group add --group GROUP_DID --member did:wba:... [--role member|admin] [--reason "..."] [--e2ee] [--identity alice] -awiki-cli group remove --group GROUP_DID --member did:wba:... [--reason "..."] [--identity alice] +awiki-cli group remove --group GROUP_DID --member did:wba:... [--reason "..."] [--e2ee] [--identity alice] awiki-cli group members --group GROUP_DID [--limit 100] [--identity alice] awiki-cli group messages --group GROUP_DID [--limit 50] [--cursor CURSOR] [--identity alice] awiki-cli group update --group GROUP_DID [--name "..."] [--description "..."] [--discoverability private|listed|public] [--admission-mode admin-add|open-join] [--slug "..."] [--goal "..."] [--rules "..."] [--message-prompt "..."] [--doc-url "https://..."] [--attachments-allowed=true|false] [--max-members 500] [--member-max-messages 10] [--member-max-total-chars 2000] [--identity alice] -awiki-cli group leave --group GROUP_DID [--identity alice] +awiki-cli group leave --group GROUP_DID [--reason "..."] [--e2ee] [--identity alice] +awiki-cli group e2ee process-leave-request --group GROUP_DID --member did:wba:... [--leave-request-id LR_ID] [--reason "..."] [--identity alice] 测试与示例约定: diff --git a/docs/installation.md b/docs/installation.md index e6a4189..a50ff1d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -167,7 +167,7 @@ By default the script builds `../anp/anp/rust` with Cargo and copies `anp-mls` t export AWIKI_ANP_MLS_BINARY=/absolute/path/to/anp-mls ``` -The MLS private state root remains `~/.awiki-cli/mls/` (or the current `AWIKI_CLI_WORKSPACE_HOME_DIR` equivalent). Runtime OpenMLS state is agent/device-scoped under that root (`mls/agents///state.db`) so two local identities do not share private KeyPackage storage. Incoming decrypt first tries the default device and then scans the local agent-scoped device directories, allowing one-shot CLI commands to restore state for KeyPackages published with a named device. `group e2ee pending` / `repair` use the hidden/test-only P6 `group.e2ee.notice` pull path to list and replay durable welcome and commit-delivery notices; repair passes `welcome_b64u + ratchet_tree_b64u` or opaque `commit_b64u` public commit artifacts back to `anp-mls` and marks only successfully processed notices delivered. E2EE group removal uses local pending commit prepare plus hidden `group.e2ee.remove`, finalizing local MLS state only after service acceptance and aborting pending commits on deterministic service rejection. E2EE self-leave is intentionally blocked when `anp-mls` returns the OpenMLS 0.8 local-terminal/non-advancing leave artifact; the CLI aborts that pending local artifact before service submission and returns an actionable error to use owner/admin remove until a safe epoch-advancing leave-request flow exists. `doctor` reports the root directory permissions plus state file status, including warnings when cached group-E2EE groups exist but MLS state is missing. +The MLS private state root remains `~/.awiki-cli/mls/` (or the current `AWIKI_CLI_WORKSPACE_HOME_DIR` equivalent). Runtime OpenMLS state is agent/device-scoped under that root (`mls/agents///state.db`) so two local identities do not share private KeyPackage storage. Incoming decrypt first tries the default device and then scans the local agent-scoped device directories, allowing one-shot CLI commands to restore state for KeyPackages published with a named device. `group e2ee pending` / `repair` use the hidden/test-only P6 `group.e2ee.notice` pull path to list and replay durable welcome and commit-delivery notices; repair passes `welcome_b64u + ratchet_tree_b64u` or opaque `commit_b64u` public commit artifacts back to `anp-mls` and marks only successfully processed notices delivered. E2EE group removal uses local pending commit prepare plus hidden `group.e2ee.remove`, finalizing local MLS state only after service acceptance and aborting pending commits on deterministic service rejection. E2EE `group leave` now creates a hidden/test-only `group.e2ee.leave_request` control-plane record instead of preparing a local-terminal self-leave; an owner/admin processes it with `group e2ee process-leave-request --group --member [--leave-request-id ]`, which reuses the epoch-advancing remove-member orchestration. `doctor` reports the root directory permissions plus state file status, including warnings when cached group-E2EE groups exist but MLS state is missing. ### 3.2 config.yaml diff --git a/docs/pr-notes/group-e2ee-p6-conformance-before-discovery.md b/docs/pr-notes/group-e2ee-p6-conformance-before-discovery.md index 44ea4c5..84f42f5 100644 --- a/docs/pr-notes/group-e2ee-p6-conformance-before-discovery.md +++ b/docs/pr-notes/group-e2ee-p6-conformance-before-discovery.md @@ -35,5 +35,5 @@ ## Caveats -- Owner/admin remove is routed through hidden PR-A `group.e2ee.remove` orchestration with local pending commit finalize/abort semantics. E2EE self-leave is not submitted to `group.e2ee.leave` when `anp-mls` only returns the OpenMLS 0.8 local-terminal/non-advancing artifact; the CLI aborts that local pending artifact and tells users to use owner/admin remove until a safe epoch-advancing leave-request flow exists. Still no External Commit, attachment group E2EE, cloud snapshot, or product-wide public beta claim. +- Owner/admin remove is routed through hidden PR-A `group.e2ee.remove` orchestration with local pending commit finalize/abort semantics. E2EE `group leave` is now routed to hidden/test-only `group.e2ee.leave_request`; owner/admin processing uses `group e2ee process-leave-request` and the existing epoch-advancing `group.e2ee.remove` orchestration instead of submitting a same-epoch local-terminal leave artifact. Still no External Commit, attachment group E2EE, cloud snapshot, or product-wide public beta claim. - No k1 DID compatibility is included. diff --git a/internal/cli/group.go b/internal/cli/group.go index 286c80f..e4607ff 100644 --- a/internal/cli/group.go +++ b/internal/cli/group.go @@ -136,11 +136,13 @@ func (a *App) runGroupMemberMutation(cmd *cobra.Command, publicAction string, me func (a *App) runGroupLeave(cmd *cobra.Command, args []string) error { group, _ := cmd.Flags().GetString("group") + reason, _ := cmd.Flags().GetString("reason") + e2ee, _ := cmd.Flags().GetBool("e2ee") service, format, err := a.messageService() if err != nil { return a.messageExit(err, "Run `awiki-cli doctor` to inspect configuration and identity state.") } - request := message.GroupLeaveRequest{IdentityName: a.globals.Identity, Group: group} + request := message.GroupLeaveRequest{IdentityName: a.globals.Identity, Group: group, ReasonText: reason, E2EE: e2ee} if a.globals.DryRun { return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, map[string]any{"plan": map[string]any{"action": "group.leave", "identity": a.globals.Identity, "runtime_mode": service.Config().RuntimeMode, "request": request}}, "Dry run: group leave planned", nil, a.identityMeta()) } diff --git a/internal/cli/group_e2ee.go b/internal/cli/group_e2ee.go index 49df414..3a68e57 100644 --- a/internal/cli/group_e2ee.go +++ b/internal/cli/group_e2ee.go @@ -134,6 +134,45 @@ func (a *App) runGroupE2EERepair(cmd *cobra.Command, args []string) error { return a.renderMessageResult(cmd, format, result) } +func (a *App) runGroupE2EEProcessLeaveRequest(cmd *cobra.Command, args []string) error { + group, _ := cmd.Flags().GetString("group") + member, _ := cmd.Flags().GetString("member") + leaveRequestID, _ := cmd.Flags().GetString("leave-request-id") + reason, _ := cmd.Flags().GetString("reason") + service, format, err := a.messageService() + if err != nil { + return a.messageExit(err, "Run `awiki-cli doctor` to inspect configuration and identity state.") + } + provider := message.NewDefaultMLSExecProvider(service.Config()) + request := message.GroupE2EEProcessLeaveRequest{ + IdentityName: a.globals.Identity, + Group: group, + Member: member, + LeaveRequestID: leaveRequestID, + ReasonText: reason, + } + plan := map[string]any{ + "action": "group.e2ee.process_leave_request", + "identity": a.globals.Identity, + "runtime_mode": service.Config().RuntimeMode, + "provider": "exec", + "mls_data_dir": provider.DataDir, + "group": group, + "member": member, + "leave_request_id": leaveRequestID, + "request": request, + } + if a.globals.DryRun { + return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, map[string]any{"plan": plan}, "Dry run: group e2ee leave request process planned", nil, a.identityMeta()) + } + result, processErr := service.ProcessGroupE2EELeaveRequest(cmd.Context(), request) + if processErr != nil { + return a.messageExit(processErr, "Ensure the leave request exists, the active identity can remove members, and anp-mls/message-service group E2EE APIs are enabled.") + } + result.Data["plan"] = plan + return a.renderMessageResult(cmd, format, result) +} + func activeIdentityDID(service *message.Service, name string) (string, error) { record, err := identity.NewManager(service.Config().Paths).Load(name) if err != nil { diff --git a/internal/cli/group_test.go b/internal/cli/group_test.go index 4e2d108..04fa9af 100644 --- a/internal/cli/group_test.go +++ b/internal/cli/group_test.go @@ -54,6 +54,23 @@ func TestGroupDryRunPlansRenderStableContracts(t *testing.T) { } }, }, + + { + name: "group leave e2ee plans hidden leave request", + spec: "group.leave", + setFlags: map[string]string{"group": "did:wba:example.com:groups:demo:e1_group", "e2ee": "true", "reason": "done"}, + wantSummary: "Dry run: group leave planned", + wantAction: "group.leave", + verifyPlan: func(t *testing.T, plan map[string]any) { + request, ok := plan["request"].(map[string]any) + if !ok { + t.Fatalf("plan.request type = %T, want map[string]any", plan["request"]) + } + if request["E2EE"] != true || request["ReasonText"] != "done" { + t.Fatalf("request = %#v, want E2EE leave request plan", request) + } + }, + }, { name: "group messages includes cursor and limit", spec: "group.messages", @@ -124,6 +141,19 @@ func TestGroupDryRunPlansRenderStableContracts(t *testing.T) { } }, }, + + { + name: "group e2ee process leave request plans owner remove", + spec: "group.e2ee.process-leave-request", + setFlags: map[string]string{"group": "did:wba:example.com:groups:demo:e1_group", "member": "bob", "leave-request-id": "lr-bob-1"}, + wantSummary: "Dry run: group e2ee leave request process planned", + wantAction: "group.e2ee.process_leave_request", + verifyPlan: func(t *testing.T, plan map[string]any) { + if plan["member"] != "bob" || plan["leave_request_id"] != "lr-bob-1" { + t.Fatalf("plan = %#v, want member and leave request id", plan) + } + }, + }, { name: "group create e2ee alias maps to group-e2ee request", spec: "group.create", @@ -164,12 +194,16 @@ func TestGroupDryRunPlansRenderStableContracts(t *testing.T) { return app.runGroupKick(cmd, nil) case "group.messages": return app.runGroupMessages(cmd, nil) + case "group.leave": + return app.runGroupLeave(cmd, nil) case "group.e2ee.status": return app.runGroupE2EEStatus(cmd, nil) case "group.e2ee.pending": return app.runGroupE2EEPending(cmd, nil) case "group.e2ee.repair": return app.runGroupE2EERepair(cmd, nil) + case "group.e2ee.process-leave-request": + return app.runGroupE2EEProcessLeaveRequest(cmd, nil) default: t.Fatalf("unsupported spec %q", tc.spec) return nil diff --git a/internal/cli/root.go b/internal/cli/root.go index 4746ef2..7eb454d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -249,6 +249,8 @@ func (a *App) handlerFor(spec cmdmeta.CommandSpec) func(*cobra.Command, []string return a.runGroupE2EEPending case "group.e2ee.repair": return a.runGroupE2EERepair + case "group.e2ee.process-leave-request": + return a.runGroupE2EEProcessLeaveRequest case "page.create": return a.runPageCreate case "page.list": diff --git a/internal/cmdmeta/catalog.go b/internal/cmdmeta/catalog.go index cbc3df5..a2cf6a2 100644 --- a/internal/cmdmeta/catalog.go +++ b/internal/cmdmeta/catalog.go @@ -162,7 +162,7 @@ func defaultSpecs() []CommandSpec { {Name: "group.join", Use: "join", Short: "Join an open group", Phase: "phase5", Implemented: true, Handler: "group.join", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "reason", Type: "string", Usage: "Join reason"}}}, {Name: "group.add", Use: "add", Short: "Add a member to a group", Phase: "phase5", Implemented: true, Handler: "group.add", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "member", Type: "string", Usage: "Member DID or handle", Required: true}, {Name: "role", Type: "string", Usage: "Member role", Default: "member"}, {Name: "e2ee", Type: "bool", Usage: "Force group E2EE add-member orchestration when cache is unavailable"}}}, {Name: "group.remove", Use: "remove", Short: "Remove a member from a group", Aliases: []string{"kick"}, Phase: "phase5", Implemented: true, Handler: "group.remove", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "member", Type: "string", Usage: "Member DID or handle", Required: true}, {Name: "reason", Type: "string", Usage: "Removal reason"}, {Name: "e2ee", Type: "bool", Usage: "Force group E2EE remove-member orchestration when cache is unavailable"}}}, - {Name: "group.leave", Use: "leave", Short: "Leave a group", Phase: "phase5", Implemented: true, Handler: "group.leave", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}}}, + {Name: "group.leave", Use: "leave", Short: "Leave a group", Phase: "phase5", Implemented: true, Handler: "group.leave", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "reason", Type: "string", Usage: "Leave reason"}, {Name: "e2ee", Type: "bool", Usage: "Force group E2EE leave-request orchestration when cache is unavailable"}}}, {Name: "group.update", Use: "update", Short: "Update group profile or policy", Phase: "phase5", Implemented: true, Handler: "group.update", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "name", Type: "string", Usage: "New group display name"}, {Name: "description", Type: "string", Usage: "New group description"}, {Name: "discoverability", Type: "string", Usage: "Discoverability mode"}, {Name: "admission-mode", Type: "string", Usage: "Admission mode"}, {Name: "slug", Type: "string", Usage: "New group slug"}, {Name: "goal", Type: "string", Usage: "New group goal"}, {Name: "rules", Type: "string", Usage: "New group rules"}, {Name: "message-prompt", Type: "string", Usage: "New group prompt"}, {Name: "doc-url", Type: "string", Usage: "New group document URL"}, {Name: "attachments-allowed", Type: "bool", Usage: "Allow attachments"}, {Name: "max-members", Type: "string", Usage: "Maximum group members"}, {Name: "member-max-messages", Type: "int", Usage: "Per-member message limit"}, {Name: "member-max-total-chars", Type: "int", Usage: "Per-member total char limit"}}}, {Name: "group.members", Use: "members", Short: "List active group members", Phase: "phase5", Implemented: true, Handler: "group.members", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "limit", Type: "int", Usage: "Maximum number of rows", Default: "100"}}}, {Name: "group.messages", Use: "messages", Short: "List group messages", Phase: "phase5", Implemented: true, Handler: "group.messages", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "limit", Type: "int", Usage: "Maximum number of rows", Default: "50"}, {Name: "cursor", Type: "string", Usage: "Pagination cursor"}}}, @@ -171,6 +171,7 @@ func defaultSpecs() []CommandSpec { {Name: "group.e2ee.publish-key-package", Use: "publish-key-package", Short: "Plan a test-only group E2EE KeyPackage publish", Phase: "phase6", Implemented: true, Handler: "group.e2ee.publish-key-package", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "device", Type: "string", Usage: "Local MLS device id", Default: "default"}, {Name: "contract-test", Type: "bool", Usage: "Use non-cryptographic contract-test artifacts"}}}, {Name: "group.e2ee.pending", Use: "pending", Short: "Pull pending group E2EE P6 notices", Phase: "phase6", Implemented: true, Handler: "group.e2ee.pending", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Optional group DID filter"}}}, {Name: "group.e2ee.repair", Use: "repair", Short: "Replay pending group E2EE P6 notices", Phase: "phase6", Implemented: true, Handler: "group.e2ee.repair", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Optional group DID filter"}}}, + {Name: "group.e2ee.process-leave-request", Use: "process-leave-request", Short: "Process a pending group E2EE leave request", Phase: "phase6", Implemented: true, Handler: "group.e2ee.process-leave-request", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "member", Type: "string", Usage: "Leaving member DID or handle", Required: true}, {Name: "leave-request-id", Type: "string", Usage: "Leave request id to consume"}, {Name: "reason", Type: "string", Usage: "Owner/admin processing reason"}}}, {Name: "group.code", Use: "code", Short: "Inspect or manage group join codes", Phase: "phase5", Implemented: false}, {Name: "group.code.get", Use: "get", Short: "Show group join code status", Phase: "phase5", Implemented: false, Handler: "stub", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}}}, {Name: "group.code.refresh", Use: "refresh", Short: "Rotate the current group join code", Phase: "phase5", Implemented: false, Handler: "stub", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}}}, diff --git a/internal/message/group_e2ee_service.go b/internal/message/group_e2ee_service.go index 031c717..7e01501 100644 --- a/internal/message/group_e2ee_service.go +++ b/internal/message/group_e2ee_service.go @@ -243,45 +243,74 @@ func (s *Service) removeGroupMemberE2EE(ctx context.Context, record *identity.St return nil, nil, err } return s.submitPreparedGroupE2EECommit(ctx, record, request.Group, request.Member, request.ReasonText, prepared, func(transport *HTTPTransport) (map[string]any, error) { - return transport.RemoveGroupE2EE(ctx, request.Group, request.Member, prepared, request.ReasonText) + return transport.RemoveGroupE2EE(ctx, request.Group, request.Member, prepared, request.ReasonText, request.LeaveRequestID) }) } func (s *Service) leaveGroupE2EE(ctx context.Context, record *identity.StoredIdentity, request GroupLeaveRequest) (map[string]any, []string, error) { - operationID := "op-" + generateOperationID() - provider := s.groupMLSProvider() - prepared, err := provider.LeaveGroup(ctx, MLSRequest{ - APIVersion: "anp-mls/v1", - RequestID: "group-e2ee-leave-" + generateOperationID(), - AgentDID: record.DID, - DeviceID: "default", - Params: map[string]any{ - "agent_did": record.DID, - "actor_did": record.DID, - "device_id": "default", - "group_did": request.Group, - "subject_did": record.DID, - "operation_id": operationID, - "group_state_ref": s.localGroupStateRef(ctx, record, request.Group), - }, - }) + transport, warnings, err := s.httpTransport(record) if err != nil { - return nil, nil, err + return nil, warnings, err } - if reason := unsupportedGroupE2EESelfLeaveReason(prepared); reason != "" { - data := map[string]any{"mls_prepare": prepared, "subject_did": record.DID} - warnings := []string{"Group E2EE self-leave is unsupported in PR-A because anp-mls cannot produce an epoch-advancing remove commit for the leaving member."} - if abortResult, abortErr := s.abortPreparedGroupE2EECommit(ctx, record, request.Group, prepared); abortErr != nil { - warnings = append(warnings, fmt.Sprintf("Group E2EE local-terminal leave pending commit abort failed: %v", abortErr)) - } else { - data["mls_abort"] = abortResult - warnings = append(warnings, "Group E2EE local-terminal leave pending commit aborted before service submission.") - } - return data, warnings, fmt.Errorf("%w: %s; ask a group owner/admin to remove this member until an epoch-advancing leave-request flow is available", ErrGroupE2EESelfLeaveUnsupported, reason) + delivery, err := transport.CreateGroupE2EELeaveRequest(ctx, request.Group, request.ReasonText) + if err != nil { + return nil, warnings, err + } + requestID := firstNonEmptyString(stringFromAny(delivery["leave_request_id"]), stringFromAny(delivery["request_id"])) + data := map[string]any{ + "delivery": delivery, + "group_did": request.Group, + "subject_did": record.DID, + "subject_status": "leave_requested", + "leave_request_id": requestID, + } + warnings = append(warnings, "Group E2EE leave request created; an owner/admin must process it with `group e2ee process-leave-request` to advance the MLS epoch.") + return data, warnings, nil +} + +func (s *Service) ProcessGroupE2EELeaveRequest(ctx context.Context, request GroupE2EEProcessLeaveRequest) (*CommandResult, error) { + if strings.TrimSpace(request.Group) == "" { + return nil, ErrGroupRequired } - return s.submitPreparedGroupE2EECommit(ctx, record, request.Group, record.DID, "", prepared, func(transport *HTTPTransport) (map[string]any, error) { - return transport.LeaveGroupE2EE(ctx, request.Group, prepared) - }) + if strings.TrimSpace(request.Member) == "" { + return nil, ErrMemberRequired + } + record, err := s.requireActiveIdentity(request.IdentityName) + if err != nil { + return nil, err + } + memberDID, memberHandle, err := s.resolveTarget(ctx, request.Member) + if err != nil { + return nil, err + } + mutation := GroupMemberRequest{ + IdentityName: request.IdentityName, + Group: request.Group, + Member: memberDID, + ReasonText: firstNonEmptyString(strings.TrimSpace(request.ReasonText), "leave request processed by owner/admin"), + E2EE: true, + LeaveRequestID: strings.TrimSpace(request.LeaveRequestID), + } + e2eeResult, e2eeWarnings, err := s.removeGroupMemberE2EE(ctx, record, mutation) + if err != nil { + return nil, err + } + warnings := append([]string(nil), e2eeWarnings...) + warnings = append(warnings, s.syncGroupState(ctx, record, request.Group, true)...) + snapshot, _ := s.readCachedGroupSnapshot(ctx, record, request.Group) + members, _ := s.readCachedGroupMembers(ctx, record, request.Group, 100) + return &CommandResult{ + Data: map[string]any{ + "group": snapshot, + "members": members, + "delivery": e2eeResult["delivery"], + "member": map[string]any{"did": memberDID, "handle": memberHandle}, + "leave_request_id": mutation.LeaveRequestID, + "e2ee": e2eeResult, + }, + Summary: "Processed group E2EE leave request with epoch-advancing remove", + Warnings: compactWarnings(warnings), + }, nil } func (s *Service) submitPreparedGroupE2EECommit( diff --git a/internal/message/group_service.go b/internal/message/group_service.go index 88f67c8..eb71103 100644 --- a/internal/message/group_service.go +++ b/internal/message/group_service.go @@ -192,20 +192,19 @@ func (s *Service) LeaveGroup(ctx context.Context, request GroupLeaveRequest) (*C if snapshotErr == nil && isActiveGroupOwner(cachedSnapshot) { return nil, ErrGroupOwnerCannotLeave } - if groupSnapshotUsesE2EE(cachedSnapshot) { + if request.E2EE || groupSnapshotUsesE2EE(cachedSnapshot) { e2eeResult, e2eeWarnings, err := s.leaveGroupE2EE(ctx, record, request) if err != nil { return nil, err } warnings := append([]string(nil), e2eeWarnings...) - warnings = append(warnings, s.markCachedGroupLeft(ctx, record, request.Group)...) return &CommandResult{ Data: map[string]any{ "delivery": e2eeResult["delivery"], "group": request.Group, "e2ee": e2eeResult, }, - Summary: fmt.Sprintf("Left group %s with group E2EE", request.Group), + Summary: fmt.Sprintf("Requested group E2EE leave for %s", request.Group), Warnings: compactWarnings(warnings), }, nil } diff --git a/internal/message/group_service_test.go b/internal/message/group_service_test.go index 0ff7508..19acb7c 100644 --- a/internal/message/group_service_test.go +++ b/internal/message/group_service_test.go @@ -2,9 +2,11 @@ package message import ( "context" + "encoding/json" "errors" "fmt" "net/http" + "net/http/httptest" "strings" "testing" @@ -52,66 +54,62 @@ func TestLeaveGroupRejectsActiveOwnerFromCachedSnapshot(t *testing.T) { } } -type groupLeaveSafetyMLSRunner struct { - calls []string -} - -func (r *groupLeaveSafetyMLSRunner) Run(_ context.Context, _ string, args []string, _ []byte) ([]byte, []byte, error) { - call := strings.Join(args, " ") - r.calls = append(r.calls, call) - switch call { - case "group leave --json-in -": - return []byte(`{"ok":true,"api_version":"anp-mls/v1","request_id":"req-leave","result":{"pending_commit_id":"pc-local-terminal-leave","operation_id":"op-leave","status":"pending","artifact_type":"local-terminal-leave","subject_status":"left","from_epoch":"4","to_epoch":"4","epoch":"4","commit_b64u":"bG9jYWwtdGVybWluYWw","crypto_group_id_b64u":"Y3J5cHRv","epoch_authenticator":"YXV0aA"}}`), nil, nil - case "group commit-abort --json-in -": - return []byte(`{"ok":true,"api_version":"anp-mls/v1","request_id":"req-abort","result":{"pending_commit_id":"pc-local-terminal-leave","status":"aborted","subject_status":"left"}}`), nil, nil - default: - return []byte(`{"ok":true,"api_version":"anp-mls/v1","request_id":"req-other","result":{}}`), nil, nil - } -} - -func TestLeaveGroupE2EERejectsLocalTerminalSelfLeaveBeforeServiceSubmit(t *testing.T) { +func TestLeaveGroupE2EECreatesLeaveRequestWithoutLocalMLSLeave(t *testing.T) { t.Parallel() - resolved := testResolvedConfig(t) - manager := identity.NewManager(resolved.Paths) - createTestIdentity(t, manager, identity.SaveInput{ - IdentityName: "alice", - UserID: "user-123", - DisplayName: "Alice", - Handle: "alice", - }) - record, err := manager.Load("alice") - if err != nil { - t.Fatalf("Load() error = %v", err) - } - runner := &groupLeaveSafetyMLSRunner{} - provider := MLSExecProvider{BinaryPath: "anp-mls", Runner: runner} - service := &Service{resolved: resolved, manager: manager, mlsProvider: &provider} + var captured rpcRequestEnvelope + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = decodeRPCRequest(t, r) + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": captured.ID, + "result": map[string]any{ + "accepted": true, + "leave_request_id": "lr-bob-1", + "delivery_state": "pending_owner_action", + }, + }) + })) + defer server.Close() + + service, _, record := newMessageServiceForTest(t, server.URL) + provider := MLSExecProvider{BinaryPath: "anp-mls", Runner: &groupLeaveSafetyMLSRunner{}} + service.mlsProvider = &provider result, warnings, err := service.leaveGroupE2EE(context.Background(), record, GroupLeaveRequest{ - Group: "did:wba:awiki.ai:groups:test-e2ee-leave", + Group: "did:wba:awiki.ai:groups:test-e2ee-leave", + ReasonText: "done", }) - if !errors.Is(err, ErrGroupE2EESelfLeaveUnsupported) { - t.Fatalf("leaveGroupE2EE() error = %v, want %v", err, ErrGroupE2EESelfLeaveUnsupported) + if err != nil { + t.Fatalf("leaveGroupE2EE() error = %v", err) } - if !strings.Contains(err.Error(), "owner/admin") { - t.Fatalf("leaveGroupE2EE() error = %v, want actionable owner/admin removal guidance", err) + if captured.Method != "group.e2ee.leave_request" { + t.Fatalf("captured.Method = %q, want leave_request", captured.Method) } - if got := strings.Join(runner.calls, ","); got != "group leave --json-in -,group commit-abort --json-in -" { - t.Fatalf("MLS calls = %q, want leave prepare followed by local abort only", got) + body := mustMapValue(t, captured.Params["body"], "params.body") + if got := stringFromAny(body["subject_status"]); got != "leave_requested" { + t.Fatalf("subject_status = %q, want leave_requested", got) } - abort, ok := result["mls_abort"].(map[string]any) - if !ok { - t.Fatalf("mls_abort missing from result: %#v", result) + if got := stringFromAny(body["reason_text"]); got != "done" { + t.Fatalf("reason_text = %q, want done", got) } - if got := stringFromAny(abort["status"]); got != "aborted" { - t.Fatalf("mls_abort.status = %q, want aborted", got) + if got := stringFromAny(result["leave_request_id"]); got != "lr-bob-1" { + t.Fatalf("leave_request_id = %q, want lr-bob-1", got) } - if got := strings.Join(warnings, "\n"); !strings.Contains(got, "aborted before service submission") { - t.Fatalf("warnings = %#v, want abort-before-submit warning", warnings) + if got := strings.Join(warnings, "\n"); !strings.Contains(got, "owner/admin") { + t.Fatalf("warnings = %#v, want owner/admin processing guidance", warnings) } } +type groupLeaveSafetyMLSRunner struct { + calls []string +} + +func (r *groupLeaveSafetyMLSRunner) Run(_ context.Context, _ string, args []string, _ []byte) ([]byte, []byte, error) { + r.calls = append(r.calls, strings.Join(args, " ")) + return []byte(`{"ok":true,"api_version":"anp-mls/v1","request_id":"req-other","result":{}}`), nil, nil +} + func TestUnsupportedGroupE2EESelfLeaveReasonDetectsNonAdvancingEpoch(t *testing.T) { t.Parallel() diff --git a/internal/message/group_wire.go b/internal/message/group_wire.go index e12ee38..cf438f4 100644 --- a/internal/message/group_wire.go +++ b/internal/message/group_wire.go @@ -201,14 +201,31 @@ func BuildGroupE2EEAddRPCParams(record *identity.StoredIdentity, manager *identi return buildGroupE2EERPCParams(record, manager, "group", groupDID, "group.e2ee.add", body, "", "", "", GroupE2EESecurityProfile) } -func BuildGroupE2EERemoveRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, memberDID string, preparedCommit map[string]any, reasonText string) (map[string]any, error) { +func BuildGroupE2EERemoveRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, memberDID string, preparedCommit map[string]any, reasonText string, leaveRequestID string) (map[string]any, error) { body := e2eeMembershipCommitBody(groupDID, memberDID, "removed", preparedCommit) if reason := strings.TrimSpace(reasonText); reason != "" { body["reason_text"] = reason } + if requestID := strings.TrimSpace(leaveRequestID); requestID != "" { + body["leave_request_id"] = requestID + } return buildGroupE2EERPCParams(record, manager, "group", groupDID, "group.e2ee.remove", body, "", stringFromAny(preparedCommit["operation_id"]), "", GroupE2EESecurityProfile) } +func BuildGroupE2EELeaveRequestRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, reasonText string) (map[string]any, error) { + body := map[string]any{ + "group_did": strings.TrimSpace(groupDID), + "subject_did": record.DID, + "member_did": record.DID, + "subject_status": "leave_requested", + "group_state_ref": map[string]any{"group_did": strings.TrimSpace(groupDID)}, + } + if reason := strings.TrimSpace(reasonText); reason != "" { + body["reason_text"] = reason + } + return buildGroupE2EERPCParams(record, manager, "group", groupDID, "group.e2ee.leave_request", body, "", "", "", GroupE2EETransportProfile) +} + func BuildGroupE2EELeaveRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, preparedCommit map[string]any) (map[string]any, error) { body := e2eeMembershipCommitBody(groupDID, record.DID, "left", preparedCommit) return buildGroupE2EERPCParams(record, manager, "group", groupDID, "group.e2ee.leave", body, "", stringFromAny(preparedCommit["operation_id"]), "", GroupE2EESecurityProfile) diff --git a/internal/message/group_wire_test.go b/internal/message/group_wire_test.go index d7165e4..bab7f18 100644 --- a/internal/message/group_wire_test.go +++ b/internal/message/group_wire_test.go @@ -269,7 +269,7 @@ func TestBuildGroupE2EERemoveRPCParamsUsesHiddenCompositeMutation(t *testing.T) "application_plaintext": "must-not-leak", "provider_private_material": "must-not-leak", "group_state_ref": map[string]any{"group_did": "did:wba:awiki.ai:groups:demo:e1_group", "epoch": "4"}, - }, "cleanup") + }, "cleanup", "leave-req-1") if err != nil { t.Fatalf("BuildGroupE2EERemoveRPCParams() error = %v", err) } @@ -297,6 +297,9 @@ func TestBuildGroupE2EERemoveRPCParamsUsesHiddenCompositeMutation(t *testing.T) if got := stringFromAny(body["commit_b64u"]); got != "Y29tbWl0" { t.Fatalf("commit_b64u = %q, want opaque commit", got) } + if got := stringFromAny(body["leave_request_id"]); got != "leave-req-1" { + t.Fatalf("leave_request_id = %q, want leave-req-1", got) + } if _, ok := body["application_plaintext"]; ok { t.Fatalf("plaintext leaked into remove body: %#v", body) } @@ -309,6 +312,36 @@ func TestBuildGroupE2EERemoveRPCParamsUsesHiddenCompositeMutation(t *testing.T) } } +func TestBuildGroupE2EELeaveRequestRPCParamsUsesHiddenTransportProtectedControlPlane(t *testing.T) { + t.Parallel() + + record := testStoredIdentity(t) + params, err := BuildGroupE2EELeaveRequestRPCParams(record, nil, "did:wba:awiki.ai:groups:demo:e1_group", "done for now") + if err != nil { + t.Fatalf("BuildGroupE2EELeaveRequestRPCParams() error = %v", err) + } + meta := mustMapValue(t, params["meta"], "params.meta") + if got := stringFromAny(meta["profile"]); got != GroupE2EEProfile { + t.Fatalf("meta.profile = %q, want %q", got, GroupE2EEProfile) + } + if got := stringFromAny(meta["security_profile"]); got != GroupE2EETransportProfile { + t.Fatalf("meta.security_profile = %q, want transport protected", got) + } + if _, ok := meta["message_id"]; ok { + t.Fatalf("leave_request meta must not look like a public message: %#v", meta) + } + body := mustMapValue(t, params["body"], "params.body") + if got := stringFromAny(body["subject_did"]); got != record.DID { + t.Fatalf("subject_did = %q, want actor DID", got) + } + if got := stringFromAny(body["subject_status"]); got != "leave_requested" { + t.Fatalf("subject_status = %q, want leave_requested", got) + } + if got := stringFromAny(body["reason_text"]); got != "done for now" { + t.Fatalf("reason_text = %q, want reason", got) + } +} + func TestBuildGroupE2EELeaveRPCParamsUsesActorAsSubject(t *testing.T) { t.Parallel() diff --git a/internal/message/http_client.go b/internal/message/http_client.go index a2ac4c7..3a6822b 100644 --- a/internal/message/http_client.go +++ b/internal/message/http_client.go @@ -291,14 +291,22 @@ func (t *HTTPTransport) AddGroupE2EE(ctx context.Context, groupDID string, membe return t.rpcMapCall(ctx, "group.e2ee.add", params) } -func (t *HTTPTransport) RemoveGroupE2EE(ctx context.Context, groupDID string, memberDID string, preparedCommit map[string]any, reasonText string) (map[string]any, error) { - params, err := BuildGroupE2EERemoveRPCParams(t.auth.record, nil, groupDID, memberDID, preparedCommit, reasonText) +func (t *HTTPTransport) RemoveGroupE2EE(ctx context.Context, groupDID string, memberDID string, preparedCommit map[string]any, reasonText string, leaveRequestID string) (map[string]any, error) { + params, err := BuildGroupE2EERemoveRPCParams(t.auth.record, nil, groupDID, memberDID, preparedCommit, reasonText, leaveRequestID) if err != nil { return nil, err } return t.rpcMapCall(ctx, "group.e2ee.remove", params) } +func (t *HTTPTransport) CreateGroupE2EELeaveRequest(ctx context.Context, groupDID string, reasonText string) (map[string]any, error) { + params, err := BuildGroupE2EELeaveRequestRPCParams(t.auth.record, nil, groupDID, reasonText) + if err != nil { + return nil, err + } + return t.rpcMapCall(ctx, "group.e2ee.leave_request", params) +} + func (t *HTTPTransport) LeaveGroupE2EE(ctx context.Context, groupDID string, preparedCommit map[string]any) (map[string]any, error) { params, err := BuildGroupE2EELeaveRPCParams(t.auth.record, nil, groupDID, preparedCommit) if err != nil { diff --git a/internal/message/http_client_test.go b/internal/message/http_client_test.go index b25eddd..836ee53 100644 --- a/internal/message/http_client_test.go +++ b/internal/message/http_client_test.go @@ -312,12 +312,26 @@ func TestHTTPTransportGroupMethodsUseExpectedRPCMethods(t *testing.T) { "from_epoch": "1", "to_epoch": "2", "commit_b64u": "Y29tbWl0", - }, "cleanup") + }, "cleanup", "leave-req-1") return err }, verifyBody: func(t *testing.T, body map[string]any) { - if body["member_did"] != "did:member" || body["commit_b64u"] != "Y29tbWl0" || body["reason_text"] != "cleanup" { - t.Fatalf("body = %#v, want member/commit/reason", body) + if body["member_did"] != "did:member" || body["commit_b64u"] != "Y29tbWl0" || body["reason_text"] != "cleanup" || body["leave_request_id"] != "leave-req-1" { + t.Fatalf("body = %#v, want member/commit/reason/leave request", body) + } + }, + }, + + { + name: "e2ee leave request", + wantMethod: "group.e2ee.leave_request", + call: func(transport *HTTPTransport) error { + _, err := transport.CreateGroupE2EELeaveRequest(context.Background(), "did:group", "done") + return err + }, + verifyBody: func(t *testing.T, body map[string]any) { + if body["group_did"] != "did:group" || body["subject_status"] != "leave_requested" || body["reason_text"] != "done" { + t.Fatalf("body = %#v, want leave request control body", body) } }, }, diff --git a/internal/message/types.go b/internal/message/types.go index d7657ed..38b308e 100644 --- a/internal/message/types.go +++ b/internal/message/types.go @@ -150,17 +150,28 @@ type GroupJoinRequest struct { } type GroupMemberRequest struct { + IdentityName string + Group string + Member string + Role string + ReasonText string + E2EE bool + LeaveRequestID string +} + +type GroupLeaveRequest struct { IdentityName string Group string - Member string - Role string ReasonText string E2EE bool } -type GroupLeaveRequest struct { - IdentityName string - Group string +type GroupE2EEProcessLeaveRequest struct { + IdentityName string + Group string + Member string + LeaveRequestID string + ReasonText string } type GroupUpdateRequest struct { diff --git a/skills/references/04-groups.md b/skills/references/04-groups.md index 5e6b95e..a9029d5 100644 --- a/skills/references/04-groups.md +++ b/skills/references/04-groups.md @@ -40,6 +40,7 @@ - 需要查看元数据或策略 -> `group get` - 需要加入一个开放群组 -> `group join` - 需要添加或移除单个成员 -> `group add` / `group remove` +- E2EE 成员想安全离开 -> `group leave --e2ee` 创建 hidden leave_request,owner/admin 再用 `group e2ee process-leave-request` 处理 - 需要修改名称、描述或策略 -> `group update` - 需要向群中发送文本 -> 使用 `03-messaging.md` @@ -50,7 +51,8 @@ - `awiki-cli group join --group [--reason "..."]` - `awiki-cli group add --group --member [--role ...]` - `awiki-cli group remove --group --member [--reason "..."]` -- `awiki-cli group leave --group ` +- `awiki-cli group leave --group [--reason "..."] [--e2ee]` +- `awiki-cli group e2ee process-leave-request --group --member [--leave-request-id ]` - `awiki-cli group update --group [--name ...] [--description ...] [...]` - `awiki-cli group members --group [--limit ]` - `awiki-cli group messages --group [--limit ] [--cursor ]` From ba7f77f4d895523969c2b9c468706b888ead4d6c Mon Sep 17 00:00:00 2001 From: changshan Date: Mon, 4 May 2026 09:29:41 +0800 Subject: [PATCH 11/14] Recover stale Group E2EE epochs before sending After an owner/admin remove is accepted, a one-shot CLI process may still hold a pending local MLS commit and initially encrypt at the previous epoch. Detect the service epoch-mismatch response, finalize any local pending commit reported by anp-mls status, repair notices, and retry encryption so remaining members can continue sending ciphertext without falling back to plaintext group messages. Constraint: Group E2EE remains hidden/test-only; no multi-device, cloud snapshot, rejoin, or public discovery expansion. Rejected: Fall back to group.base send on epoch mismatch | would store application plaintext in the service DB for E2EE groups. Confidence: high Scope-risk: moderate Directive: Do not bypass group.e2ee.send for known E2EE groups; stale epochs must repair/fail closed, not downgrade. Tested: go test -run 'Test.*GroupE2EE|Test.*Leave|TestGroup' ./internal/message ./internal/cli ./internal/cmdmeta Tested: go vet ./... Tested: live awiki-system-test lifecycle target passed: 3 passed in 26.50s Tested: git diff --check --- CLAUDE.md | 2 +- internal/message/group_e2ee_service.go | 112 +++++++++++++++++++++++++ internal/message/group_service.go | 2 +- 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0170ef7..543180a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,7 +94,7 @@ **internal/message/secure.go**: P5 direct E2EE secure send 的首版编排层,使用 ANP Go SDK direct_e2ee、本地文件会话/预密钥存储和 HTTP JSON-RPC;key-service 请求绑定当前 DID 文档里 `ANPMessageService.serviceDid`,并在有可用 sidecar OPK 时优先用 OPK 建链(本地保存 `p5-one-time-prekeys/`);HTTP inbox/history 现已接入入站密文解密与会话推进,并会顺带补发本地 prekey bundle;轮询路径解密 direct-init 成功后会自动发送 encrypted ACK 并尝试 flush 该 peer 的 `e2ee_outbox`;当 initiator 仍处于 `pending-confirmation` 时,新的 secure 发送会进入 `e2ee_outbox` 排队。 **internal/message/secure_control.go**: secure 控制面与恢复辅助,负责 secure ack/init payload、pending 阶段的 `e2ee_outbox` 排队、secure outbox flush,以及 `msg secure status/init/repair/failed/retry/drop` 需要的本地会话/发件箱读取与重试逻辑。 **internal/message/group_e2ee_provider.go**: `anp-mls` exec provider 抽象;按 `AWIKI_ANP_MLS_BINARY`、测试/运行时注入路径、`PATH` 顺序发现二进制;JSON request 走 stdin、response 走 stdout、日志/错误走 stderr,默认 MLS 根目录为 `/mls`,实际 OpenMLS 私有状态按 agent/device 分到子目录,并可扫描同一 agent 下的本地 device state 供收件解密恢复,保持 Go 主工程 pure Go / no CGO。 -**internal/message/group_e2ee_service.go**: group E2EE 业务编排层;负责 KeyPackage 发布前用当前 DID `key-1` 生成 strict Appendix-B `did_wba_binding.proof`、owner create/add/remove、PR-B1 leave_request 创建与 owner/admin process-leave-request epoch-advancing remove、pending commit finalize/abort、send encrypt、messages decrypt、P6 notice pending/repair、ratchet tree welcome/commit notice process,以及 MLS AAD 元数据传入 `anp-mls`;在同一工作区存在目标成员身份时也会本地处理 add 返回的 welcome notice,使 one-shot `anp-mls` agent/device 状态可恢复。 +**internal/message/group_e2ee_service.go**: group E2EE 业务编排层;负责 KeyPackage 发布前用当前 DID `key-1` 生成 strict Appendix-B `did_wba_binding.proof`、owner create/add/remove、PR-B1 leave_request 创建与 owner/admin process-leave-request epoch-advancing remove、stale send epoch mismatch 时从 anp-mls pending status finalize/repair 后重试、pending commit finalize/abort、send encrypt、messages decrypt、P6 notice pending/repair、ratchet tree welcome/commit notice process,以及 MLS AAD 元数据传入 `anp-mls`;在同一工作区存在目标成员身份时也会本地处理 add 返回的 welcome notice,使 one-shot `anp-mls` agent/device 状态可恢复。 **internal/message/group_wire.go**: group 标准面和 local-only RPC 参数构造器;P6 publish/get/notice 使用 `transport-protected` service/agent target,create 使用 service target,add/remove/leave/send 使用 group target;group E2EE send/remove/leave 会在签名/发送前裁剪 provider-local MLS/private 字段,只把 P6 service 允许的 opaque cipher/commit 字段送到 message-service。 **internal/message/http_client.go**: direct/group message、group lifecycle 与 hidden/test-only P6 notice pull/mark-delivered 的 HTTP JSON-RPC adapter。 **internal/message/ws_proxy_client.go**: websocket 模式下通过本地 bridge 调用 listener/daemon 的 direct/group adapter。 diff --git a/internal/message/group_e2ee_service.go b/internal/message/group_e2ee_service.go index 7e01501..192e71f 100644 --- a/internal/message/group_e2ee_service.go +++ b/internal/message/group_e2ee_service.go @@ -511,12 +511,97 @@ func (s *Service) sendGroupE2EE(ctx context.Context, record *identity.StoredIden return nil, err } delivery, err := transport.SendGroupE2EE(ctx, request.Group, cipher, operationID, messageID) + if err != nil && isGroupE2EEEpochMismatch(err) { + if finalized, finalizeErr := s.finalizePendingGroupE2EECommitFromStatus(ctx, record, request.Group); finalizeErr == nil && finalized { + warnings = append(warnings, "Group E2EE local pending commit finalized after service epoch mismatch.") + } + repairResult, repairErr := s.RepairGroupE2EENotices(ctx, record.IdentityName, request.Group, 50) + if repairErr != nil { + warnings = append(warnings, fmt.Sprintf("Group E2EE send saw stale epoch and notice repair failed: %v", repairErr)) + return nil, err + } + warnings = append(warnings, "Group E2EE local epoch was stale; repaired pending notices and retried send.") + if repairResult != nil { + warnings = append(warnings, repairResult.Warnings...) + } + operationID = "op-" + generateOperationID() + messageID = "msg-" + generateOperationID() + groupStateRef = s.localGroupStateRef(ctx, record, request.Group) + encryptResult, err = provider.Encrypt(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-encrypt-retry-" + generateOperationID(), + AgentDID: record.DID, + DeviceID: "default", + Params: map[string]any{ + "agent_did": record.DID, + "device_id": "default", + "group_did": request.Group, + "group_state_ref": groupStateRef, + "sender_did": record.DID, + "content_type": contentType, + "security_profile": GroupE2EESecurityProfile, + "message_id": messageID, + "operation_id": operationID, + "message_type": request.MessageType, + "application_plaintext": map[string]any{ + "application_content_type": contentTypeForMessageType(request.MessageType), + "text": request.Text, + }, + }, + }) + if err != nil { + return nil, err + } + cipher, _ = encryptResult["group_cipher_object"].(map[string]any) + if len(cipher) == 0 { + return nil, fmt.Errorf("anp-mls retry encrypt response missing group_cipher_object") + } + delivery, err = transport.SendGroupE2EE(ctx, request.Group, cipher, operationID, messageID) + } if err != nil { return nil, err } return s.persistGroupE2EESendResult(ctx, record, request, delivery, encryptResult, warnings) } +func (s *Service) finalizePendingGroupE2EECommitFromStatus(ctx context.Context, record *identity.StoredIdentity, groupDID string) (bool, error) { + provider := s.groupMLSProvider() + status, err := provider.Status(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-pending-status-" + generateOperationID(), + AgentDID: record.DID, + DeviceID: "default", + Params: map[string]any{ + "agent_did": record.DID, + "device_id": "default", + "group_did": groupDID, + }, + }) + if err != nil { + return false, err + } + for _, pending := range messagesFromResult(status["pending_commits"]) { + pendingID := stringFromAny(pending["pending_commit_id"]) + if pendingID == "" { + continue + } + _, finalizeErr := s.finalizePreparedGroupE2EECommit(ctx, record, groupDID, map[string]any{"pending_commit_id": pendingID}) + if finalizeErr != nil { + return false, finalizeErr + } + return true, nil + } + return false, nil +} + +func isGroupE2EEEpochMismatch(err error) bool { + if err == nil { + return false + } + text := strings.ToLower(err.Error()) + return strings.Contains(text, "group.e2ee.send") && strings.Contains(text, "epoch mismatch") +} + func (s *Service) maybeDecryptGroupMessages(ctx context.Context, record *identity.StoredIdentity, groupDID string, raw map[string]any) ([]string, map[string]any) { messages := messagesFromResult(raw["messages"]) if len(messages) == 0 { @@ -978,6 +1063,33 @@ func groupStateRefFromSnapshot(groupDID string, snapshot map[string]any) map[str return ref } +func (s *Service) groupHasLocalE2EEState(ctx context.Context, record *identity.StoredIdentity, groupDID string) bool { + if strings.TrimSpace(groupDID) == "" || record == nil { + return false + } + provider := s.groupMLSProvider() + resp, err := provider.Status(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-send-detect-" + generateOperationID(), + AgentDID: record.DID, + Params: map[string]any{ + "agent_did": record.DID, + "group_did": groupDID, + }, + }) + if err != nil || len(resp) == 0 { + return false + } + status := strings.ToLower(strings.TrimSpace(stringFromAny(resp["status"]))) + if status == "active" || status == "pending_commit" { + return true + } + if stringFromAny(resp["crypto_group_id_b64u"]) != "" { + return true + } + return false +} + func groupRequestUsesE2EE(request GroupCreateRequest) bool { return request.E2EE || strings.TrimSpace(request.MessageSecurityProfile) == GroupE2EESecurityProfile } diff --git a/internal/message/group_service.go b/internal/message/group_service.go index eb71103..c5a625c 100644 --- a/internal/message/group_service.go +++ b/internal/message/group_service.go @@ -354,7 +354,7 @@ func (s *Service) sendGroup(ctx context.Context, request SendRequest) (*CommandR return nil, err } snapshot, _ := s.readCachedGroupSnapshot(ctx, record, request.Group) - if groupSnapshotUsesE2EE(snapshot) { + if groupSnapshotUsesE2EE(snapshot) || s.groupHasLocalE2EEState(ctx, record, request.Group) { return s.sendGroupE2EE(ctx, record, request) } sourceMode := s.runtimeConfig().Mode From debbe39484cfb50413fd09553df074a581ea8374 Mon Sep 17 00:00:00 2001 From: changshan Date: Mon, 4 May 2026 11:04:41 +0800 Subject: [PATCH 12/14] Repair group E2EE epochs across durable notices and device state One-shot CLI recovery now compares local MLS state with the hidden service crypto head, safely finalizes accepted pending commits, replays durable welcome/commit notices, treats duplicate already-applied commits as delivered, and fails closed with needs_snapshot_or_readd when continuity cannot be proven. Status/send paths scan agent/device-scoped MLS state so members added through non-default KeyPackages can repair missed add commits and resume encrypted sends without a resident process. Constraint: PR-B2 is recovery hardening only; group E2EE remains hidden/test-only with no public discovery, multi-device, k1 compatibility, cloud snapshot, External Commit, or rejoin scope. Rejected: Always encrypt/process repair on the default device | KeyPackage-published members store MLS state under their actual device id and would be stranded after welcome repair. Rejected: Finalize any local pending commit during repair | local commits are finalized only when the service head proves the target epoch was accepted. Confidence: high Scope-risk: moderate Directive: Keep recovery fail-closed on missing notice gaps until a separately reviewed snapshot/re-add protocol exists. Tested: go test ./internal/message ./internal/cli ./internal/cmdmeta Tested: AWIKI_GROUP_E2EE_CONTRACT_TEST=1 uv run python manage_local_test_env.py run-tests --with-message-v2 --use-local-anp tests_v2/cli/test_awiki_cli_group_e2ee_recovery_local.py Not-tested: Full root make local-test. --- CLAUDE.md | 4 +- internal/cli/group_e2ee.go | 30 +- internal/message/group_e2ee_service.go | 482 ++++++++++++++++++++++--- internal/message/group_service_test.go | 229 ++++++++++++ internal/message/group_wire.go | 35 ++ internal/message/group_wire_test.go | 33 ++ internal/message/http_client.go | 8 + 7 files changed, 746 insertions(+), 75 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 543180a..b7c7b59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ **internal/cli/debug.go**: `debug db query`、`debug db handle-history` 与 `debug db import-v1` 的 CLI 处理器。 **internal/cli/msg.go**: `msg send/inbox/history/mark-read` 的 CLI 处理器,现已支持 direct + group plain messaging。 **internal/cli/group.go**: `group create/get/join/add/remove/leave/update/members/messages` 的 CLI 处理器;E2EE 群 remove/leave 会路由到 hidden `group.e2ee.remove/leave` 组合编排而不是公开 P4-only 方法。 -**internal/cli/group_e2ee.go**: P6 group E2EE 诊断/维护命令处理器;支持本地 exec provider/status/KeyPackage 发布路径、hidden/test-only `group.e2ee.notice` pending/repair 拉取与 welcome 重放,以及 PR-B1 `process-leave-request` owner/admin epoch-advancing remove 编排;`contract-test` 仅在显式 flag 下启用。 +**internal/cli/group_e2ee.go**: P6 group E2EE 诊断/维护命令处理器;支持本地 exec provider/status/KeyPackage 发布路径、hidden/test-only `group.e2ee.head` local/service epoch 对比、`group.e2ee.notice` pending/repair 拉取与 welcome/commit 重放、PR-B2 accepted pending commit 安全 finalize / unrecoverable gap fail-closed 诊断,以及 PR-B1 `process-leave-request` owner/admin epoch-advancing remove 编排;`contract-test` 仅在显式 flag 下启用。 **internal/identity/types.go**: identity store、legacy scan、command result 等核心类型。 **internal/identity/layout.go**: identity 根目录、index.json、路径与安全写入辅助。 **internal/identity/store.go**: 当前 v2 identity store 的读写、默认 identity 管理。 @@ -94,7 +94,7 @@ **internal/message/secure.go**: P5 direct E2EE secure send 的首版编排层,使用 ANP Go SDK direct_e2ee、本地文件会话/预密钥存储和 HTTP JSON-RPC;key-service 请求绑定当前 DID 文档里 `ANPMessageService.serviceDid`,并在有可用 sidecar OPK 时优先用 OPK 建链(本地保存 `p5-one-time-prekeys/`);HTTP inbox/history 现已接入入站密文解密与会话推进,并会顺带补发本地 prekey bundle;轮询路径解密 direct-init 成功后会自动发送 encrypted ACK 并尝试 flush 该 peer 的 `e2ee_outbox`;当 initiator 仍处于 `pending-confirmation` 时,新的 secure 发送会进入 `e2ee_outbox` 排队。 **internal/message/secure_control.go**: secure 控制面与恢复辅助,负责 secure ack/init payload、pending 阶段的 `e2ee_outbox` 排队、secure outbox flush,以及 `msg secure status/init/repair/failed/retry/drop` 需要的本地会话/发件箱读取与重试逻辑。 **internal/message/group_e2ee_provider.go**: `anp-mls` exec provider 抽象;按 `AWIKI_ANP_MLS_BINARY`、测试/运行时注入路径、`PATH` 顺序发现二进制;JSON request 走 stdin、response 走 stdout、日志/错误走 stderr,默认 MLS 根目录为 `/mls`,实际 OpenMLS 私有状态按 agent/device 分到子目录,并可扫描同一 agent 下的本地 device state 供收件解密恢复,保持 Go 主工程 pure Go / no CGO。 -**internal/message/group_e2ee_service.go**: group E2EE 业务编排层;负责 KeyPackage 发布前用当前 DID `key-1` 生成 strict Appendix-B `did_wba_binding.proof`、owner create/add/remove、PR-B1 leave_request 创建与 owner/admin process-leave-request epoch-advancing remove、stale send epoch mismatch 时从 anp-mls pending status finalize/repair 后重试、pending commit finalize/abort、send encrypt、messages decrypt、P6 notice pending/repair、ratchet tree welcome/commit notice process,以及 MLS AAD 元数据传入 `anp-mls`;在同一工作区存在目标成员身份时也会本地处理 add 返回的 welcome notice,使 one-shot `anp-mls` agent/device 状态可恢复。 +**internal/message/group_e2ee_service.go**: group E2EE 业务编排层;负责 KeyPackage 发布前用当前 DID `key-1` 生成 strict Appendix-B `did_wba_binding.proof`、owner create/add/remove、PR-B1 leave_request 创建与 owner/admin process-leave-request epoch-advancing remove、stale send epoch mismatch 时从 anp-mls pending status finalize/repair 后重试、pending commit finalize/abort、send encrypt、messages decrypt、P6 notice pending/repair、ratchet tree welcome/commit notice process,以及 MLS AAD 元数据传入 `anp-mls`;PR-B2 status/repair 会读取 hidden `group.e2ee.head`,仅在 service head 已接受对应 target epoch 时 finalize 本地 pending commit,重复 commit notice 以 local epoch 判定幂等 delivered,缺失 notice gap 输出 `needs_snapshot_or_readd` 并 fail closed;在同一工作区存在目标成员身份时也会本地处理 add 返回的 welcome notice,使 one-shot `anp-mls` agent/device 状态可恢复。 **internal/message/group_wire.go**: group 标准面和 local-only RPC 参数构造器;P6 publish/get/notice 使用 `transport-protected` service/agent target,create 使用 service target,add/remove/leave/send 使用 group target;group E2EE send/remove/leave 会在签名/发送前裁剪 provider-local MLS/private 字段,只把 P6 service 允许的 opaque cipher/commit 字段送到 message-service。 **internal/message/http_client.go**: direct/group message、group lifecycle 与 hidden/test-only P6 notice pull/mark-delivered 的 HTTP JSON-RPC adapter。 **internal/message/ws_proxy_client.go**: websocket 模式下通过本地 bridge 调用 listener/daemon 的 direct/group adapter。 diff --git a/internal/cli/group_e2ee.go b/internal/cli/group_e2ee.go index 3a68e57..9f64fd5 100644 --- a/internal/cli/group_e2ee.go +++ b/internal/cli/group_e2ee.go @@ -1,9 +1,6 @@ package cli import ( - "fmt" - "time" - "github.com/agentconnect/awiki-cli/internal/identity" "github.com/agentconnect/awiki-cli/internal/message" "github.com/spf13/cobra" @@ -31,25 +28,12 @@ func (a *App) runGroupE2EEStatus(cmd *cobra.Command, args []string) error { if a.globals.DryRun { return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, map[string]any{"plan": plan}, "Dry run: group e2ee status planned", nil, a.identityMeta()) } - agentDID, identityErr := activeIdentityDID(service, a.globals.Identity) - warnings := []string(nil) - if identityErr != nil { - warnings = append(warnings, fmt.Sprintf("Active identity DID unavailable: %v", identityErr)) - } - provider.Timeout = 5 * time.Second - resp, callErr := provider.Call(cmd.Context(), "group", "status", message.MLSRequest{ - APIVersion: "anp-mls/v1", - RequestID: fmt.Sprintf("group-e2ee-status-%d", time.Now().UnixNano()), - AgentDID: agentDID, - Params: map[string]any{"agent_did": agentDID, "group_did": group}, - }) - data := map[string]any{"plan": plan, "available": callErr == nil} - if callErr != nil { - warnings = append(warnings, fmt.Sprintf("anp-mls exec provider unavailable: %v", callErr)) - } else if resp != nil { - data["mls"] = resp.Result - } - return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, data, "Group E2EE local MLS status inspected", warnings, a.identityMeta()) + result, statusErr := service.InspectGroupE2EEStatus(cmd.Context(), a.globals.Identity, group, 50) + if statusErr != nil { + return a.messageExit(statusErr, "Install anp-mls, set AWIKI_ANP_MLS_BINARY, and ensure message-service group E2EE APIs are enabled for focused validation.") + } + result.Data["plan"] = plan + return a.renderMessageResult(cmd, format, result) } func (a *App) runGroupE2EEPublishKeyPackage(cmd *cobra.Command, args []string) error { @@ -121,7 +105,7 @@ func (a *App) runGroupE2EERepair(cmd *cobra.Command, args []string) error { "provider": "exec", "mls_data_dir": provider.DataDir, "group": group, - "scope": "pull durable P6 notices, replay welcome-delivery, and mark processed notices delivered", + "scope": "compare local MLS status to service head, safely finalize accepted pending commits, replay welcome/commit notices, and fail closed on unrecoverable gaps", } if a.globals.DryRun { return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, map[string]any{"plan": plan}, "Dry run: group e2ee repair planned", nil, a.identityMeta()) diff --git a/internal/message/group_e2ee_service.go b/internal/message/group_e2ee_service.go index 192e71f..10468a3 100644 --- a/internal/message/group_e2ee_service.go +++ b/internal/message/group_e2ee_service.go @@ -472,6 +472,12 @@ func shouldAbortGroupE2EEPendingCommit(err error) bool { func (s *Service) sendGroupE2EE(ctx context.Context, record *identity.StoredIdentity, request SendRequest) (*CommandResult, error) { provider := s.groupMLSProvider() warnings := s.syncGroupState(ctx, record, request.Group, false) + deviceID := "default" + if status, candidateDeviceID, statusErr := groupE2EEStatusForRecovery(ctx, provider, record.DID, request.Group, ""); statusErr != nil { + warnings = append(warnings, fmt.Sprintf("Group E2EE send could not inspect device-scoped MLS status before encrypt: %v", statusErr)) + } else if strings.EqualFold(stringFromAny(status["status"]), "active") { + deviceID = candidateDeviceID + } groupStateRef := s.localGroupStateRef(ctx, record, request.Group) operationID := "op-" + generateOperationID() messageID := "msg-" + generateOperationID() @@ -480,10 +486,10 @@ func (s *Service) sendGroupE2EE(ctx context.Context, record *identity.StoredIden APIVersion: "anp-mls/v1", RequestID: "group-e2ee-encrypt-" + generateOperationID(), AgentDID: record.DID, - DeviceID: "default", + DeviceID: deviceID, Params: map[string]any{ "agent_did": record.DID, - "device_id": "default", + "device_id": deviceID, "group_did": request.Group, "group_state_ref": groupStateRef, "sender_did": record.DID, @@ -526,15 +532,20 @@ func (s *Service) sendGroupE2EE(ctx context.Context, record *identity.StoredIden } operationID = "op-" + generateOperationID() messageID = "msg-" + generateOperationID() + if status, candidateDeviceID, statusErr := groupE2EEStatusForRecovery(ctx, provider, record.DID, request.Group, deviceID); statusErr != nil { + warnings = append(warnings, fmt.Sprintf("Group E2EE send could not inspect device-scoped MLS status after repair: %v", statusErr)) + } else if strings.EqualFold(stringFromAny(status["status"]), "active") { + deviceID = candidateDeviceID + } groupStateRef = s.localGroupStateRef(ctx, record, request.Group) encryptResult, err = provider.Encrypt(ctx, MLSRequest{ APIVersion: "anp-mls/v1", RequestID: "group-e2ee-encrypt-retry-" + generateOperationID(), AgentDID: record.DID, - DeviceID: "default", + DeviceID: deviceID, Params: map[string]any{ "agent_did": record.DID, - "device_id": "default", + "device_id": deviceID, "group_did": request.Group, "group_state_ref": groupStateRef, "sender_did": record.DID, @@ -763,6 +774,68 @@ func (s *Service) processLocalGroupWelcome(ctx context.Context, memberDID string }, warnings } +func (s *Service) InspectGroupE2EEStatus(ctx context.Context, identityName string, groupDID string, limit int) (*CommandResult, error) { + record, err := s.requireActiveIdentity(identityName) + if err != nil { + return nil, err + } + if limit <= 0 { + limit = 50 + } + provider := s.groupMLSProvider() + localStatus, localDeviceID, localErr := groupE2EEStatusForRecovery(ctx, provider, record.DID, groupDID, "") + warnings := []string(nil) + if localErr != nil { + warnings = append(warnings, fmt.Sprintf("Group E2EE local MLS status unavailable: %v", localErr)) + } + + transport, transportWarnings, transportErr := s.httpTransport(record) + warnings = append(warnings, transportWarnings...) + var serviceHead map[string]any + var pending map[string]any + if transportErr != nil { + warnings = append(warnings, fmt.Sprintf("Group E2EE service status unavailable: %v", transportErr)) + } else { + var headErr error + serviceHead, headErr = transport.GetGroupE2EEHead(ctx, groupDID) + if headErr != nil { + warnings = append(warnings, fmt.Sprintf("Group E2EE service head unavailable: %v", headErr)) + } + var pendingErr error + pending, pendingErr = transport.PullGroupE2EENotices(ctx, groupDID, limit, false) + if pendingErr != nil { + warnings = append(warnings, fmt.Sprintf("Group E2EE pending notice status unavailable: %v", pendingErr)) + } + } + + pendingNotices := noticesFromResult(nil) + pendingNoticeCount := 0 + if pending != nil { + pendingNotices = noticesFromResult(pending["notices"]) + if count, ok := int64FromAny(pending["pending_count"]); ok { + pendingNoticeCount = int(count) + } else { + pendingNoticeCount = len(pendingNotices) + } + } + diagnosis := groupE2EERecoveryDiagnosis(localStatus, serviceHead, pendingNoticeCount, localErr) + return &CommandResult{ + Data: map[string]any{ + "group": groupDID, + "available": localErr == nil, + "mls": localStatus, + "local": localStatus, + "local_device_id": localDeviceID, + "service_head": serviceHead, + "pending_notices": pendingNotices, + "pending_notice_count": pendingNoticeCount, + "diagnosis": diagnosis, + }, + Summary: "Group E2EE recovery status inspected", + Warnings: compactWarnings(warnings), + }, nil +} + func (s *Service) PullGroupE2EENotices(ctx context.Context, identityName string, groupDID string, limit int) (*CommandResult, error) { record, err := s.requireActiveIdentity(identityName) if err != nil { @@ -796,6 +869,16 @@ func (s *Service) RepairGroupE2EENotices(ctx context.Context, identityName strin if err != nil { return nil, err } + serviceHead, headErr := transport.GetGroupE2EEHead(ctx, groupDID) + if headErr != nil { + warnings = append(warnings, fmt.Sprintf("Group E2EE service head unavailable during repair: %v", headErr)) + } + finalizedPending := []map[string]any(nil) + if len(serviceHead) > 0 { + var finalizeWarnings []string + finalizedPending, finalizeWarnings = s.finalizeAcceptedPendingGroupE2EECommits(ctx, record, groupDID, serviceHead) + warnings = append(warnings, finalizeWarnings...) + } pending, err := transport.PullGroupE2EENotices(ctx, groupDID, limit, false) if err != nil { return nil, err @@ -864,13 +947,31 @@ func (s *Service) RepairGroupE2EENotices(ctx context.Context, identityName strin warnings = append(warnings, fmt.Sprintf("Group E2EE repair processed notices but failed to mark delivered: %v", err)) } } + localStatus, localDeviceID, localErr := groupE2EEStatusForRecovery(ctx, provider, record.DID, groupDID, "") + if localErr != nil { + warnings = append(warnings, fmt.Sprintf("Group E2EE local MLS status unavailable after repair: %v", localErr)) + } + remainingPending := len(noticesFromResult(pending["notices"])) - len(noticeIDs) + if remainingPending < 0 { + remainingPending = 0 + } + diagnosis := groupE2EERecoveryDiagnosis(localStatus, serviceHead, remainingPending, localErr) + if action := stringFromAny(diagnosis["next_action"]); action == "needs_snapshot_or_readd" { + warnings = append(warnings, "Group E2EE repair could not prove epoch continuity; fail closed and ask an owner/admin to re-add this single-device member with a fresh KeyPackage.") + } return &CommandResult{ Data: map[string]any{ - "processed": processed, - "processed_count": len(processed), - "pending_count": pending["pending_count"], - "delivered_result": delivered, - "group": groupDID, + "processed": processed, + "processed_count": len(processed), + "finalized_pending_commits": finalizedPending, + "finalized_pending_count": len(finalizedPending), + "pending_count": pending["pending_count"], + "delivered_result": delivered, + "group": groupDID, + "local": localStatus, + "local_device_id": localDeviceID, + "service_head": serviceHead, + "diagnosis": diagnosis, }, Summary: "Replayed group E2EE pending notices", Warnings: compactWarnings(warnings), @@ -882,7 +983,6 @@ func (s *Service) processGroupCommitNotice(ctx context.Context, record *identity if commitB64U == "" { return nil, []string{"Group E2EE repair skipped commit notice missing commit_b64u"} } - deviceID := defaultString(stringFromAny(notice["device_id"]), "default") groupStateRef, _ := notice["group_state_ref"].(map[string]any) if len(groupStateRef) == 0 { groupStateRef = map[string]any{ @@ -895,54 +995,336 @@ func (s *Service) processGroupCommitNotice(ctx context.Context, record *identity groupStateRef["epoch"] = fromEpoch } } - params := map[string]any{ - "agent_did": record.DID, - "device_id": deviceID, - "group_did": groupDID, - "group_state_ref": groupStateRef, - "commit_b64u": commitB64U, - "ratchet_tree_b64u": notice["ratchet_tree_b64u"], - "group_info_b64u": notice["group_info_b64u"], - "operation_id": notice["operation_id"], - "notice_id": notice["notice_id"], - "actor_did": notice["actor_did"], - "subject_did": notice["subject_did"], - "subject_status": notice["subject_status"], - "from_epoch": notice["from_epoch"], - "to_epoch": notice["to_epoch"], - "crypto_group_id_b64u": notice["crypto_group_id_b64u"], - "epoch_authenticator": firstNonNil(notice["epoch_authenticator"], notice["epoch_authenticator_b64u"]), - } provider := s.groupMLSProvider() - commitResult, err := provider.ProcessCommit(ctx, MLSRequest{ + deviceIDs := groupE2EERecoveryDeviceIDs(provider, record.DID, stringFromAny(notice["device_id"])) + warnings := []string(nil) + for _, deviceID := range deviceIDs { + params := map[string]any{ + "agent_did": record.DID, + "device_id": deviceID, + "group_did": groupDID, + "group_state_ref": groupStateRef, + "commit_b64u": commitB64U, + "ratchet_tree_b64u": notice["ratchet_tree_b64u"], + "group_info_b64u": notice["group_info_b64u"], + "operation_id": notice["operation_id"], + "notice_id": notice["notice_id"], + "actor_did": notice["actor_did"], + "subject_did": notice["subject_did"], + "subject_status": notice["subject_status"], + "from_epoch": notice["from_epoch"], + "to_epoch": notice["to_epoch"], + "crypto_group_id_b64u": notice["crypto_group_id_b64u"], + "epoch_authenticator": firstNonNil(notice["epoch_authenticator"], notice["epoch_authenticator_b64u"]), + } + commitResult, err := provider.ProcessCommit(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-commit-repair-" + generateOperationID(), + AgentDID: record.DID, + DeviceID: deviceID, + Params: params, + }) + if err != nil { + if alreadyApplied, statusWarnings := s.groupCommitNoticeAlreadyApplied(ctx, provider, record, groupDID, deviceID, notice); alreadyApplied { + return map[string]any{ + "processed": true, + "already_applied": true, + "notice_type": "commit-delivery", + "notice_id": notice["notice_id"], + "group_did": groupDID, + "member_did": record.DID, + "device_id": deviceID, + "epoch": notice["to_epoch"], + "subject_did": notice["subject_did"], + "subject_status": notice["subject_status"], + }, statusWarnings + } + warnings = append(warnings, fmt.Sprintf("Group E2EE repair commit processing failed on device %s: %v", deviceID, err)) + continue + } + resultWarnings := []string(nil) + if persistWarnings := s.persistGroupE2EESummary(ctx, record, groupDID, commitResult, notice); len(persistWarnings) > 0 { + resultWarnings = append(resultWarnings, persistWarnings...) + } + return map[string]any{ + "processed": true, + "notice_type": "commit-delivery", + "notice_id": notice["notice_id"], + "group_did": groupDID, + "member_did": record.DID, + "device_id": deviceID, + "epoch": commitResult["epoch"], + "subject_did": notice["subject_did"], + "subject_status": notice["subject_status"], + }, resultWarnings + } + return nil, compactWarnings(warnings) +} + +func (s *Service) finalizeAcceptedPendingGroupE2EECommits(ctx context.Context, record *identity.StoredIdentity, groupDID string, serviceHead map[string]any) ([]map[string]any, []string) { + provider := s.groupMLSProvider() + status, err := provider.Status(ctx, MLSRequest{ APIVersion: "anp-mls/v1", - RequestID: "group-e2ee-commit-repair-" + generateOperationID(), + RequestID: "group-e2ee-pending-repair-status-" + generateOperationID(), AgentDID: record.DID, - DeviceID: deviceID, - Params: params, + DeviceID: "default", + Params: map[string]any{ + "agent_did": record.DID, + "device_id": "default", + "group_did": groupDID, + }, }) if err != nil { - return nil, []string{fmt.Sprintf("Group E2EE repair commit processing failed: %v", err)} + return nil, []string{fmt.Sprintf("Group E2EE pending commit status unavailable during repair: %v", err)} } + finalized := make([]map[string]any, 0) warnings := []string(nil) - subjectDID := stringFromAny(notice["subject_did"]) - subjectStatus := stringFromAny(notice["subject_status"]) - if subjectDID == record.DID && (subjectStatus == "removed" || subjectStatus == "left") { - warnings = append(warnings, s.markCachedGroupLeft(ctx, record, groupDID)...) - } else { - warnings = append(warnings, s.persistGroupE2EESummary(ctx, record, groupDID, commitResult, notice)...) + for _, pending := range messagesFromResult(status["pending_commits"]) { + pendingID := stringFromAny(pending["pending_commit_id"]) + if pendingID == "" { + continue + } + if !groupE2EEPendingCommitAcceptedByService(pending, serviceHead) { + warnings = append(warnings, fmt.Sprintf("Group E2EE pending commit %s retained: service head has not accepted its target epoch.", pendingID)) + continue + } + result, finalizeErr := s.finalizePreparedGroupE2EECommit(ctx, record, groupDID, map[string]any{"pending_commit_id": pendingID}) + if finalizeErr != nil { + warnings = append(warnings, fmt.Sprintf("Group E2EE pending commit %s matched service head but local finalize failed: %v", pendingID, finalizeErr)) + continue + } + finalized = append(finalized, result) + } + return finalized, warnings +} + +func groupE2EEPendingCommitAcceptedByService(pending map[string]any, serviceHead map[string]any) bool { + if len(pending) == 0 || len(serviceHead) == 0 { + return false + } + if groupDID := stringFromAny(pending["group_did"]); groupDID != "" { + if serviceGroupDID := stringFromAny(serviceHead["group_did"]); serviceGroupDID != "" && serviceGroupDID != groupDID { + return false + } + } + if cryptoGroupID := stringFromAny(pending["crypto_group_id_b64u"]); cryptoGroupID != "" { + if serviceCryptoGroupID := stringFromAny(serviceHead["crypto_group_id_b64u"]); serviceCryptoGroupID != "" && serviceCryptoGroupID != cryptoGroupID { + return false + } + } + toEpoch, hasToEpoch := int64FromAny(pending["to_epoch"]) + serviceEpoch, hasServiceEpoch := int64FromAny(serviceHead["epoch"]) + return hasToEpoch && hasServiceEpoch && serviceEpoch >= toEpoch +} + +func (s *Service) groupCommitNoticeAlreadyApplied(ctx context.Context, provider MLSExecProvider, record *identity.StoredIdentity, groupDID string, deviceID string, notice map[string]any) (bool, []string) { + toEpoch, hasToEpoch := int64FromAny(notice["to_epoch"]) + if !hasToEpoch { + return false, nil + } + status, err := provider.Status(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-commit-duplicate-status-" + generateOperationID(), + AgentDID: record.DID, + DeviceID: deviceID, + Params: map[string]any{ + "agent_did": record.DID, + "device_id": deviceID, + "group_did": groupDID, + }, + }) + if err != nil { + return false, []string{fmt.Sprintf("Group E2EE repair could not inspect local status after commit failure: %v", err)} + } + localEpoch, hasLocalEpoch := groupE2EELocalEpochFromStatus(status) + if !hasLocalEpoch || localEpoch < toEpoch { + return false, nil + } + if noticeCryptoGroupID := stringFromAny(notice["crypto_group_id_b64u"]); noticeCryptoGroupID != "" { + if localCryptoGroupID := stringFromAny(status["crypto_group_id_b64u"]); localCryptoGroupID != "" && localCryptoGroupID != noticeCryptoGroupID { + return false, nil + } + } + return true, []string{"Group E2EE repair treated duplicate/already-applied commit notice as delivered."} +} + +func groupE2EERecoveryDiagnosis(localStatus map[string]any, serviceHead map[string]any, pendingNoticeCount int, localErr error) map[string]any { + diagnosis := map[string]any{ + "state": "unknown", + "next_action": "inspect", + "fail_closed": true, + "pending_notice_count": pendingNoticeCount, + } + if localErr != nil { + diagnosis["local_error"] = localErr.Error() + } + if localText := stringFromAny(localStatus["status"]); localText != "" { + diagnosis["local_status"] = localText + } + if serviceHead != nil { + diagnosis["actor_membership_status"] = serviceHead["actor_membership_status"] + diagnosis["actor_recovery_eligible"] = serviceHead["actor_recovery_eligible"] + diagnosis["service_epoch"] = serviceHead["epoch"] + } + if localEpoch, ok := groupE2EELocalEpochFromStatus(localStatus); ok { + diagnosis["local_epoch"] = strconv.FormatInt(localEpoch, 10) + } + pendingCommitCount := len(messagesFromResult(localStatus["pending_commits"])) + diagnosis["pending_commit_count"] = pendingCommitCount + + actorStatus := strings.ToLower(strings.TrimSpace(stringFromAny(serviceHead["actor_membership_status"]))) + if actorStatus == "removed" || actorStatus == "left" || actorStatus == "non_member" || actorStatus == "inactive" { + diagnosis["state"] = "inactive" + diagnosis["next_action"] = "fail_closed" + diagnosis["fail_closed"] = true + return diagnosis + } + if pendingCommitCount > 0 { + diagnosis["state"] = "pending_commit" + diagnosis["next_action"] = "run_group_e2ee_repair" + diagnosis["fail_closed"] = false + return diagnosis + } + if pendingNoticeCount > 0 { + diagnosis["state"] = "pending_notices" + diagnosis["next_action"] = "run_group_e2ee_repair" + diagnosis["fail_closed"] = false + return diagnosis + } + localEpoch, hasLocalEpoch := groupE2EELocalEpochFromStatus(localStatus) + serviceEpoch, hasServiceEpoch := int64FromAny(serviceHead["epoch"]) + localState := strings.ToLower(strings.TrimSpace(stringFromAny(localStatus["status"]))) + if localErr != nil || localState == "" || localState == "empty" || !hasLocalEpoch { + diagnosis["state"] = "missing_state" + diagnosis["next_action"] = "needs_snapshot_or_readd" + diagnosis["fail_closed"] = true + return diagnosis + } + if hasLocalEpoch && hasServiceEpoch { + switch { + case localEpoch == serviceEpoch: + diagnosis["state"] = "in_sync" + diagnosis["next_action"] = "none" + diagnosis["fail_closed"] = false + case localEpoch < serviceEpoch: + diagnosis["state"] = "epoch_lag" + diagnosis["epoch_gap"] = serviceEpoch - localEpoch + diagnosis["next_action"] = "needs_snapshot_or_readd" + diagnosis["fail_closed"] = true + default: + diagnosis["state"] = "local_ahead" + diagnosis["epoch_gap"] = localEpoch - serviceEpoch + diagnosis["next_action"] = "stop_and_inspect" + diagnosis["fail_closed"] = true + } + return diagnosis + } + diagnosis["state"] = "local_only" + diagnosis["next_action"] = "inspect_service_head" + diagnosis["fail_closed"] = true + return diagnosis +} + +func groupE2EELocalEpochFromStatus(status map[string]any) (int64, bool) { + for _, value := range []any{status["epoch"], status["local_epoch"]} { + if epoch, ok := int64FromAny(value); ok { + return epoch, true + } + } + for _, binding := range messagesFromResult(status["bindings"]) { + if epoch, ok := int64FromAny(binding["epoch"]); ok { + return epoch, true + } + } + return 0, false +} + +func groupE2EEStatusForRecovery(ctx context.Context, provider MLSExecProvider, agentDID string, groupDID string, preferredDeviceID string) (map[string]any, string, error) { + deviceIDs := groupE2EERecoveryDeviceIDs(provider, agentDID, preferredDeviceID) + var best map[string]any + bestDeviceID := "default" + bestRank := -1 + var bestEpoch int64 + var lastErr error + for _, deviceID := range deviceIDs { + status, err := provider.Status(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-status-" + generateOperationID(), + AgentDID: agentDID, + DeviceID: deviceID, + Params: map[string]any{ + "agent_did": agentDID, + "device_id": deviceID, + "group_did": groupDID, + }, + }) + if err != nil { + lastErr = err + continue + } + rank := groupE2EEStatusRank(status) + epoch, hasEpoch := groupE2EELocalEpochFromStatus(status) + if !hasEpoch { + epoch = -1 + } + if best == nil || rank > bestRank || (rank == bestRank && epoch > bestEpoch) { + best = status + bestDeviceID = deviceID + bestRank = rank + bestEpoch = epoch + } + } + if best != nil { + best["device_id"] = bestDeviceID + return best, bestDeviceID, nil + } + if lastErr != nil { + return nil, "", lastErr + } + return map[string]any{"status": "empty", "device_id": bestDeviceID}, bestDeviceID, nil +} + +func groupE2EERecoveryDeviceIDs(provider MLSExecProvider, agentDID string, preferredDeviceID string) []string { + ordered := make([]string, 0) + seen := make(map[string]struct{}) + add := func(deviceID string) { + deviceID = defaultString(strings.TrimSpace(deviceID), "default") + if _, ok := seen[deviceID]; ok { + return + } + seen[deviceID] = struct{}{} + ordered = append(ordered, deviceID) + } + if preferredDeviceID != "" { + add(preferredDeviceID) + } + for _, deviceID := range provider.candidateDeviceIDs(agentDID) { + add(deviceID) + } + if len(ordered) == 0 { + add("default") + } + return ordered +} + +func groupE2EEStatusRank(status map[string]any) int { + state := strings.ToLower(strings.TrimSpace(stringFromAny(status["status"]))) + switch state { + case "active": + return 3 + case "left", "removed", "inactive": + return 2 + case "empty", "": + if _, ok := groupE2EELocalEpochFromStatus(status); ok { + return 1 + } + return 0 + default: + if _, ok := groupE2EELocalEpochFromStatus(status); ok { + return 1 + } + return 0 } - return map[string]any{ - "processed": true, - "notice_type": "commit-delivery", - "notice_id": notice["notice_id"], - "group_did": groupDID, - "member_did": record.DID, - "device_id": deviceID, - "epoch": firstNonNil(commitResult["epoch"], notice["to_epoch"]), - "subject_did": subjectDID, - "subject_status": subjectStatus, - }, warnings } func (s *Service) groupWelcomeAlreadyAvailable(ctx context.Context, provider MLSExecProvider, record *identity.StoredIdentity, groupDID string, notice map[string]any) bool { diff --git a/internal/message/group_service_test.go b/internal/message/group_service_test.go index 19acb7c..a4413ff 100644 --- a/internal/message/group_service_test.go +++ b/internal/message/group_service_test.go @@ -7,6 +7,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -247,6 +249,233 @@ func TestShouldAbortGroupE2EEPendingCommitOnlyForDeterministicServiceRejection(t } } +func TestInspectGroupE2EEStatusComparesLocalEpochToServiceHead(t *testing.T) { + t.Parallel() + + groupDID := "did:wba:awiki.ai:groups:repair:e1_group" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + envelope := decodeRPCRequest(t, r) + var result map[string]any + switch envelope.Method { + case "group.e2ee.head": + result = map[string]any{ + "group_did": groupDID, + "crypto_group_id_b64u": "crypto-1", + "epoch": "2", + "actor_membership_status": "active", + "actor_recovery_eligible": true, + "epoch_authenticator_b64u": "auth-2", + "latest_notice_cursor": "notice-commit-2", + } + case "group.e2ee.notice": + result = map[string]any{ + "pending_count": 1, + "notices": []map[string]any{{ + "notice_id": "notice-commit-2", + "notice_type": "commit-delivery", + "group_did": groupDID, + "crypto_group_id_b64u": "crypto-1", + "from_epoch": "1", + "to_epoch": "2", + }}, + } + default: + t.Fatalf("unexpected RPC method %q", envelope.Method) + } + _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": envelope.ID, "result": result}) + })) + defer server.Close() + + service, _, record := newMessageServiceForTest(t, server.URL) + service.mlsProvider = &MLSExecProvider{ + BinaryPath: "anp-mls", + Runner: &groupE2EEStatusMLSRunner{status: map[string]any{ + "status": "active", + "epoch": "1", + "crypto_group_id_b64u": "crypto-1", + "pending_commits": []map[string]any{}, + }}, + } + result, err := service.InspectGroupE2EEStatus(context.Background(), record.IdentityName, groupDID, 50) + if err != nil { + t.Fatalf("InspectGroupE2EEStatus() error = %v", err) + } + diagnosis := mustMapValue(t, result.Data["diagnosis"], "diagnosis") + if got := stringFromAny(diagnosis["state"]); got != "pending_notices" { + t.Fatalf("diagnosis.state = %q, want pending_notices: %#v", got, diagnosis) + } + if got := stringFromAny(diagnosis["next_action"]); got != "run_group_e2ee_repair" { + t.Fatalf("diagnosis.next_action = %q, want repair: %#v", got, diagnosis) + } + if got := intValueFromAny(result.Data["pending_notice_count"], 0); got != 1 { + t.Fatalf("pending_notice_count = %d, want 1", got) + } +} + +func TestProcessGroupCommitNoticeTreatsDuplicateAsAlreadyApplied(t *testing.T) { + t.Parallel() + + groupDID := "did:wba:awiki.ai:groups:already-applied:e1_group" + service := &Service{ + mlsProvider: &MLSExecProvider{ + BinaryPath: "anp-mls", + Runner: &groupE2EEStatusMLSRunner{ + status: map[string]any{ + "status": "active", + "epoch": "2", + "crypto_group_id_b64u": "crypto-1", + "pending_commits": []map[string]any{}, + }, + failCommitProcess: true, + }, + }, + } + record := &identity.StoredIdentity{IdentityName: "bob", DID: "did:wba:awiki.ai:users:bob:e1_bob"} + processed, warnings := service.processGroupCommitNotice(context.Background(), record, groupDID, map[string]any{ + "notice_id": "notice-2", + "notice_type": "commit-delivery", + "group_did": groupDID, + "crypto_group_id_b64u": "crypto-1", + "from_epoch": "1", + "to_epoch": "2", + "commit_b64u": "opaque-commit", + }) + if processed == nil { + t.Fatalf("processed = nil, warnings = %#v", warnings) + } + if got := boolFromAny(processed["already_applied"]); !got { + t.Fatalf("already_applied = %#v, want true: %#v", processed["already_applied"], processed) + } + if !warningContains(warnings, "already-applied") { + t.Fatalf("warnings = %#v, want already-applied guidance", warnings) + } +} + +func TestGroupE2EEStatusForRecoveryScansNonDefaultDevice(t *testing.T) { + t.Parallel() + + groupDID := "did:wba:awiki.ai:groups:scan-device:e1_group" + agentDID := "did:wba:awiki.ai:users:bob:e1_bob" + dataDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dataDir, "agents", mlsAgentKey(agentDID), "bob-main"), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + provider := MLSExecProvider{ + BinaryPath: "anp-mls", + DataDir: dataDir, + Runner: &groupE2EEStatusMLSRunner{statusByDevice: map[string]map[string]any{ + "default": {"status": "empty"}, + "bob-main": {"status": "active", "epoch": "2", "crypto_group_id_b64u": "crypto-1"}, + }}, + } + + status, deviceID, err := groupE2EEStatusForRecovery(context.Background(), provider, agentDID, groupDID, "") + if err != nil { + t.Fatalf("groupE2EEStatusForRecovery() error = %v", err) + } + if deviceID != "bob-main" { + t.Fatalf("deviceID = %q, want bob-main; status=%#v", deviceID, status) + } + if got := stringFromAny(status["epoch"]); got != "2" { + t.Fatalf("epoch = %q, want 2: %#v", got, status) + } +} + +func TestProcessGroupCommitNoticeScansNonDefaultDeviceWhenNoticeOmitsDevice(t *testing.T) { + t.Parallel() + + groupDID := "did:wba:awiki.ai:groups:commit-scan:e1_group" + record := &identity.StoredIdentity{IdentityName: "bob", DID: "did:wba:awiki.ai:users:bob:e1_bob"} + dataDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dataDir, "agents", mlsAgentKey(record.DID), "bob-main"), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + service := &Service{ + resolved: testResolvedConfig(t), + mlsProvider: &MLSExecProvider{ + BinaryPath: "anp-mls", + DataDir: dataDir, + Runner: &groupE2EEStatusMLSRunner{ + statusByDevice: map[string]map[string]any{ + "default": {"status": "empty"}, + "bob-main": {"status": "active", "epoch": "1", "crypto_group_id_b64u": "crypto-1"}, + }, + failCommitProcessDevices: map[string]bool{"default": true}, + commitResultByDevice: map[string]map[string]any{ + "bob-main": {"status": "active", "epoch": "2", "crypto_group_id_b64u": "crypto-1"}, + }, + }, + }, + } + + processed, warnings := service.processGroupCommitNotice(context.Background(), record, groupDID, map[string]any{ + "notice_id": "notice-2", + "notice_type": "commit-delivery", + "group_did": groupDID, + "crypto_group_id_b64u": "crypto-1", + "from_epoch": "1", + "to_epoch": "2", + "commit_b64u": "opaque-commit", + }) + if processed == nil { + t.Fatalf("processed = nil, warnings = %#v", warnings) + } + if got := stringFromAny(processed["device_id"]); got != "bob-main" { + t.Fatalf("device_id = %q, want bob-main: %#v", got, processed) + } +} + +type groupE2EEStatusMLSRunner struct { + status map[string]any + statusByDevice map[string]map[string]any + commitResultByDevice map[string]map[string]any + failCommitProcess bool + failCommitProcessDevices map[string]bool +} + +func (r *groupE2EEStatusMLSRunner) Run(_ context.Context, _ string, args []string, stdin []byte) ([]byte, []byte, error) { + var req MLSRequest + _ = json.Unmarshal(stdin, &req) + command := strings.Join(args, " ") + deviceID := defaultString(req.DeviceID, stringFromAny(req.Params["device_id"])) + if strings.Contains(command, "commit process") { + if r.failCommitProcess || r.failCommitProcessDevices[deviceID] { + return []byte(fmt.Sprintf(`{"ok":false,"api_version":"anp-mls/v1","request_id":%q,"error":{"code":"group_epoch_mismatch","message":"commit from_epoch does not match local epoch"}}`, req.RequestID)), nil, nil + } + result := r.commitResultByDevice[deviceID] + if result == nil { + result = map[string]any{"status": "active", "epoch": "2"} + } + return []byte(mustJSONForTest(map[string]any{ + "ok": true, + "api_version": "anp-mls/v1", + "request_id": req.RequestID, + "result": result, + })), nil, nil + } + result := r.statusByDevice[deviceID] + if result == nil { + result = r.status + } + if result == nil { + result = map[string]any{"status": "empty"} + } + return []byte(mustJSONForTest(map[string]any{ + "ok": true, + "api_version": "anp-mls/v1", + "request_id": req.RequestID, + "result": result, + })), nil, nil +} + +func mustJSONForTest(value any) string { + raw, err := json.Marshal(value) + if err != nil { + panic(err) + } + return string(raw) +} + func TestGroupStateRefFromSnapshotUsesServerStateVersionNotMLSEpoch(t *testing.T) { t.Parallel() diff --git a/internal/message/group_wire.go b/internal/message/group_wire.go index cf438f4..8482853 100644 --- a/internal/message/group_wire.go +++ b/internal/message/group_wire.go @@ -377,6 +377,41 @@ func BuildGroupE2EENoticeRPCParams(record *identity.StoredIdentity, manager *ide }, nil } +func BuildGroupE2EEHeadRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string) (map[string]any, error) { + groupDID = strings.TrimSpace(groupDID) + if groupDID == "" { + return nil, ErrGroupRequired + } + auth, err := newAuthContext(record, manager) + if err != nil { + return nil, err + } + meta := map[string]any{ + "anp_version": "1.0", + "profile": GroupE2EEProfile, + "security_profile": GroupE2EETransportProfile, + "sender_did": record.DID, + "target": map[string]any{"kind": "group", "did": groupDID}, + "operation_id": "op-" + generateOperationID(), + "created_at": nowRFC3339(), + "content_type": "application/json", + } + body := map[string]any{ + "group_did": groupDID, + "group_state_ref": map[string]any{"group_did": groupDID}, + } + payload := signedPayload{Method: "group.e2ee.head", Meta: meta, Body: body} + originProof, err := buildOriginProof(auth, payload) + if err != nil { + return nil, err + } + return map[string]any{ + "meta": meta, + "auth": map[string]any{"scheme": OriginProofScheme, "origin_proof": originProof}, + "body": body, + }, nil +} + func sanitizeGroupKeyPackageForService(input map[string]any) map[string]any { allowed := map[string]struct{}{ "owner_did": {}, diff --git a/internal/message/group_wire_test.go b/internal/message/group_wire_test.go index bab7f18..d60d6f8 100644 --- a/internal/message/group_wire_test.go +++ b/internal/message/group_wire_test.go @@ -526,6 +526,39 @@ func TestBuildGroupE2EENoticeRPCParamsUsesTransportProtectedAgentTarget(t *testi } } +func TestBuildGroupE2EEHeadRPCParamsUsesTransportProtectedGroupTarget(t *testing.T) { + t.Parallel() + + record := testStoredIdentity(t) + groupDID := "did:wba:awiki.ai:groups:demo:e1_group" + params, err := BuildGroupE2EEHeadRPCParams(record, nil, groupDID) + if err != nil { + t.Fatalf("BuildGroupE2EEHeadRPCParams() error = %v", err) + } + meta := mustMapValue(t, params["meta"], "params.meta") + if got := stringFromAny(meta["profile"]); got != GroupE2EEProfile { + t.Fatalf("head profile = %q, want group E2EE profile", got) + } + if got := stringFromAny(meta["security_profile"]); got != GroupE2EETransportProfile { + t.Fatalf("head security_profile = %q, want transport-protected", got) + } + target := mustMapValue(t, meta["target"], "meta.target") + if got := stringFromAny(target["kind"]); got != "group" { + t.Fatalf("head target.kind = %q, want group", got) + } + if got := stringFromAny(target["did"]); got != groupDID { + t.Fatalf("head target.did = %q, want group DID", got) + } + body := mustMapValue(t, params["body"], "params.body") + if got := stringFromAny(body["group_did"]); got != groupDID { + t.Fatalf("body.group_did = %q, want group DID", got) + } + ref := mustMapValue(t, body["group_state_ref"], "body.group_state_ref") + if got := stringFromAny(ref["group_did"]); got != groupDID { + t.Fatalf("group_state_ref.group_did = %q, want group DID", got) + } +} + func TestBuildGroupE2EEGetKeyPackageUsesTransportProtectedServiceTarget(t *testing.T) { t.Parallel() diff --git a/internal/message/http_client.go b/internal/message/http_client.go index 3a6822b..c7b3d56 100644 --- a/internal/message/http_client.go +++ b/internal/message/http_client.go @@ -331,6 +331,14 @@ func (t *HTTPTransport) MarkGroupE2EENoticesDelivered(ctx context.Context, group return t.rpcMapCall(ctx, "group.e2ee.notice", params) } +func (t *HTTPTransport) GetGroupE2EEHead(ctx context.Context, groupDID string) (map[string]any, error) { + params, err := BuildGroupE2EEHeadRPCParams(t.auth.record, nil, groupDID) + if err != nil { + return nil, err + } + return t.rpcMapCall(ctx, "group.e2ee.head", params) +} + func (t *HTTPTransport) GetGroupInfo(ctx context.Context, request GroupInfoRequest) (map[string]any, error) { params, err := BuildGroupGetInfoRPCParams(t.auth.record, request) if err != nil { From 048b76f39eedb7042f4663d51bd5531609f127b2 Mon Sep 17 00:00:00 2001 From: changshan Date: Mon, 4 May 2026 18:16:21 +0800 Subject: [PATCH 13/14] Enable PR-B3 CLI recovery without P4 membership mutation The CLI now exposes hidden/test-only same-device group E2EE recovery UX: recovery KeyPackage publication, recover-member orchestration through anp-mls prepare/finalize/abort, and needs_snapshot_or_readd recovery artifacts. The recovery path submits group.e2ee.recover_member and keeps P4 group.add out of the flow. Constraint: Worker-3 scope is awiki-cli only after leader correction Constraint: Recovery must stay hidden/test-only and must not mutate P4 membership Rejected: Reuse group.add for recovery | violates PR-B3 P4/P6 separation Confidence: medium Scope-risk: moderate Tested: gofmt on modified Go files Tested: go test ./internal/message ./internal/cli ./internal/cmdmeta Tested: go test ./... Tested: go vet ./... Not-tested: Live message-service/anp-mls PR-B3 end-to-end because sibling service lane is owned by other workers --- CLAUDE.md | 6 +- internal/cli/group_e2ee.go | 46 +++++- internal/cli/root.go | 2 + internal/cmdmeta/catalog.go | 3 +- internal/message/group_e2ee_provider.go | 8 + internal/message/group_e2ee_service.go | 187 ++++++++++++++++++++++-- internal/message/group_service_test.go | 117 +++++++++++++++ internal/message/group_wire.go | 90 +++++++++++- internal/message/group_wire_test.go | 58 ++++++++ internal/message/http_client.go | 20 +++ internal/message/types.go | 7 + 11 files changed, 525 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b7c7b59..cea562e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ **internal/cli/debug.go**: `debug db query`、`debug db handle-history` 与 `debug db import-v1` 的 CLI 处理器。 **internal/cli/msg.go**: `msg send/inbox/history/mark-read` 的 CLI 处理器,现已支持 direct + group plain messaging。 **internal/cli/group.go**: `group create/get/join/add/remove/leave/update/members/messages` 的 CLI 处理器;E2EE 群 remove/leave 会路由到 hidden `group.e2ee.remove/leave` 组合编排而不是公开 P4-only 方法。 -**internal/cli/group_e2ee.go**: P6 group E2EE 诊断/维护命令处理器;支持本地 exec provider/status/KeyPackage 发布路径、hidden/test-only `group.e2ee.head` local/service epoch 对比、`group.e2ee.notice` pending/repair 拉取与 welcome/commit 重放、PR-B2 accepted pending commit 安全 finalize / unrecoverable gap fail-closed 诊断,以及 PR-B1 `process-leave-request` owner/admin epoch-advancing remove 编排;`contract-test` 仅在显式 flag 下启用。 +**internal/cli/group_e2ee.go**: P6 group E2EE 诊断/维护命令处理器;支持本地 exec provider/status/KeyPackage 发布路径、`publish-key-package --recovery --group` recovery KeyPackage 发布、hidden/test-only `group.e2ee.head` local/service epoch 对比、`group.e2ee.notice` pending/repair 拉取与 welcome/commit 重放、PR-B2 accepted pending commit 安全 finalize / unrecoverable gap fail-closed 诊断、PR-B3 `recover-member` owner/admin same-device crypto recovery 编排(prepare -> hidden `group.e2ee.recover_member` -> finalize/abort,禁止 P4 `group.add`),以及 PR-B1 `process-leave-request` owner/admin epoch-advancing remove 编排;`contract-test` 仅在显式 flag 下启用。 **internal/identity/types.go**: identity store、legacy scan、command result 等核心类型。 **internal/identity/layout.go**: identity 根目录、index.json、路径与安全写入辅助。 **internal/identity/store.go**: 当前 v2 identity store 的读写、默认 identity 管理。 @@ -94,7 +94,7 @@ **internal/message/secure.go**: P5 direct E2EE secure send 的首版编排层,使用 ANP Go SDK direct_e2ee、本地文件会话/预密钥存储和 HTTP JSON-RPC;key-service 请求绑定当前 DID 文档里 `ANPMessageService.serviceDid`,并在有可用 sidecar OPK 时优先用 OPK 建链(本地保存 `p5-one-time-prekeys/`);HTTP inbox/history 现已接入入站密文解密与会话推进,并会顺带补发本地 prekey bundle;轮询路径解密 direct-init 成功后会自动发送 encrypted ACK 并尝试 flush 该 peer 的 `e2ee_outbox`;当 initiator 仍处于 `pending-confirmation` 时,新的 secure 发送会进入 `e2ee_outbox` 排队。 **internal/message/secure_control.go**: secure 控制面与恢复辅助,负责 secure ack/init payload、pending 阶段的 `e2ee_outbox` 排队、secure outbox flush,以及 `msg secure status/init/repair/failed/retry/drop` 需要的本地会话/发件箱读取与重试逻辑。 **internal/message/group_e2ee_provider.go**: `anp-mls` exec provider 抽象;按 `AWIKI_ANP_MLS_BINARY`、测试/运行时注入路径、`PATH` 顺序发现二进制;JSON request 走 stdin、response 走 stdout、日志/错误走 stderr,默认 MLS 根目录为 `/mls`,实际 OpenMLS 私有状态按 agent/device 分到子目录,并可扫描同一 agent 下的本地 device state 供收件解密恢复,保持 Go 主工程 pure Go / no CGO。 -**internal/message/group_e2ee_service.go**: group E2EE 业务编排层;负责 KeyPackage 发布前用当前 DID `key-1` 生成 strict Appendix-B `did_wba_binding.proof`、owner create/add/remove、PR-B1 leave_request 创建与 owner/admin process-leave-request epoch-advancing remove、stale send epoch mismatch 时从 anp-mls pending status finalize/repair 后重试、pending commit finalize/abort、send encrypt、messages decrypt、P6 notice pending/repair、ratchet tree welcome/commit notice process,以及 MLS AAD 元数据传入 `anp-mls`;PR-B2 status/repair 会读取 hidden `group.e2ee.head`,仅在 service head 已接受对应 target epoch 时 finalize 本地 pending commit,重复 commit notice 以 local epoch 判定幂等 delivered,缺失 notice gap 输出 `needs_snapshot_or_readd` 并 fail closed;在同一工作区存在目标成员身份时也会本地处理 add 返回的 welcome notice,使 one-shot `anp-mls` agent/device 状态可恢复。 +**internal/message/group_e2ee_service.go**: group E2EE 业务编排层;负责 KeyPackage 发布前用当前 DID `key-1` 生成 strict Appendix-B `did_wba_binding.proof`、owner create/add/remove、PR-B1 leave_request 创建与 owner/admin process-leave-request epoch-advancing remove、PR-B3 recovery KeyPackage tagging 与 owner/admin `recover-member` same-device crypto recovery 编排(anp-mls `recover-member-prepare`、hidden `group.e2ee.recover_member`、service accept 后 finalize、deterministic rejection 后 abort,且不调用/伪装 P4 `group.add`)、stale send epoch mismatch 时从 anp-mls pending status finalize/repair 后重试、pending commit finalize/abort、send encrypt、messages decrypt、P6 notice pending/repair、ratchet tree welcome/commit notice process,以及 MLS AAD 元数据传入 `anp-mls`;PR-B2/PR-B3 status/repair 会读取 hidden `group.e2ee.head`,仅在 service head 已接受对应 target epoch 时 finalize 本地 pending commit,重复 commit notice 以 local epoch 判定幂等 delivered,缺失 notice gap 输出 `needs_snapshot_or_readd` recovery artifact 并 fail closed;在同一工作区存在目标成员身份时也会本地处理 add/recovery 返回的 welcome notice,使 one-shot `anp-mls` agent/device 状态可恢复。 **internal/message/group_wire.go**: group 标准面和 local-only RPC 参数构造器;P6 publish/get/notice 使用 `transport-protected` service/agent target,create 使用 service target,add/remove/leave/send 使用 group target;group E2EE send/remove/leave 会在签名/发送前裁剪 provider-local MLS/private 字段,只把 P6 service 允许的 opaque cipher/commit 字段送到 message-service。 **internal/message/http_client.go**: direct/group message、group lifecycle 与 hidden/test-only P6 notice pull/mark-delivered 的 HTTP JSON-RPC adapter。 **internal/message/ws_proxy_client.go**: websocket 模式下通过本地 bridge 调用 listener/daemon 的 direct/group adapter。 @@ -219,7 +219,7 @@ - `msg` 域中 direct plain 已实现,P5 secure direct 出站首版已接入;HTTP inbox/history 的 P5 入站自动解密、轮询 direct-init 自动 ACK 与 outbox flush 已接入;websocket listener 已能解密 secure incoming、自动 first-reply/ack,并在 secure ack 后尝试 flush `e2ee_outbox`;`msg secure status/init/repair/failed/retry/drop` 有首版,但完整 runtime 联调与更强系统测试仍未完成 - `group` 域的 plain lifecycle / local view / group messaging 已接入,`people` 仍大多为 stub;`page` 已完成 content pages 首版 -- secure E2EE 业务流目前只完成 P5 出站首版;group E2EE 已接入 CLI 侧 `anp-mls` exec 编排、KeyPackage 发布、group-e2ee create/add/remove/send、安全 self-leave request、owner/admin process-leave-request、pending commit finalize/abort、commit-delivery repair 与轮询消息本地解密分支,并已有隐藏 focused target 覆盖真实 OpenMLS Alice/Bob 最小闭环;对外 discovery 仍保持隐藏,External Commit/recovery 与完整 MLS 群管理能力仍未实现;安全 leave-request 已处于 hidden/test-only PR-B1 路径。 +- secure E2EE 业务流目前只完成 P5 出站首版;group E2EE 已接入 CLI 侧 `anp-mls` exec 编排、KeyPackage 发布(含 PR-B3 recovery KeyPackage)、group-e2ee create/add/remove/send、安全 self-leave request、owner/admin process-leave-request、owner/admin recover-member same-device crypto recovery、pending commit finalize/abort、commit-delivery repair 与轮询消息本地解密分支,并已有隐藏 focused target 覆盖真实 OpenMLS Alice/Bob 最小闭环;对外 discovery 仍保持隐藏,External Commit/recovery 与完整 MLS 群管理能力仍未实现;安全 leave-request 已处于 hidden/test-only PR-B1 路径。 ## 开发与验证约定 diff --git a/internal/cli/group_e2ee.go b/internal/cli/group_e2ee.go index 9f64fd5..02ce940 100644 --- a/internal/cli/group_e2ee.go +++ b/internal/cli/group_e2ee.go @@ -38,6 +38,8 @@ func (a *App) runGroupE2EEStatus(cmd *cobra.Command, args []string) error { func (a *App) runGroupE2EEPublishKeyPackage(cmd *cobra.Command, args []string) error { device, _ := cmd.Flags().GetString("device") + group, _ := cmd.Flags().GetString("group") + recovery, _ := cmd.Flags().GetBool("recovery") contractTest, _ := cmd.Flags().GetBool("contract-test") service, format, err := a.messageService() if err != nil { @@ -52,14 +54,16 @@ func (a *App) runGroupE2EEPublishKeyPackage(cmd *cobra.Command, args []string) e "binary": provider.BinaryPath, "mls_data_dir": provider.DataDir, "device": device, + "group": group, + "recovery": recovery, "contract_test_only": contractTest, } if a.globals.DryRun { return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, map[string]any{"plan": plan}, "Dry run: group e2ee key package publish planned", nil, a.identityMeta()) } - result, publishErr := service.PublishGroupE2EEKeyPackage(cmd.Context(), a.globals.Identity, device, contractTest) + result, publishErr := service.PublishGroupE2EEKeyPackage(cmd.Context(), a.globals.Identity, device, group, recovery, contractTest) if publishErr != nil { - return a.messageExit(publishErr, "Install anp-mls, set AWIKI_ANP_MLS_BINARY, and ensure message-service group E2EE APIs are enabled.") + return a.messageExit(publishErr, "Install anp-mls, set AWIKI_ANP_MLS_BINARY, pass --group when --recovery is used, and ensure message-service group E2EE APIs are enabled.") } result.Data["plan"] = plan return a.renderMessageResult(cmd, format, result) @@ -157,6 +161,44 @@ func (a *App) runGroupE2EEProcessLeaveRequest(cmd *cobra.Command, args []string) return a.renderMessageResult(cmd, format, result) } +func (a *App) runGroupE2EERecoverMember(cmd *cobra.Command, args []string) error { + group, _ := cmd.Flags().GetString("group") + member, _ := cmd.Flags().GetString("member") + device, _ := cmd.Flags().GetString("device") + service, format, err := a.messageService() + if err != nil { + return a.messageExit(err, "Run `awiki-cli doctor` to inspect configuration and identity state.") + } + provider := message.NewDefaultMLSExecProvider(service.Config()) + request := message.GroupE2EERecoverMemberRequest{ + IdentityName: a.globals.Identity, + Group: group, + Member: member, + DeviceID: device, + } + plan := map[string]any{ + "action": "group.e2ee.recover_member", + "identity": a.globals.Identity, + "runtime_mode": service.Config().RuntimeMode, + "provider": "exec", + "mls_data_dir": provider.DataDir, + "group": group, + "member": member, + "device": device, + "p4_membership_mutate": false, + "orchestration": []string{"lease recovery KeyPackage", "anp-mls recover-member-prepare", "hidden group.e2ee.recover_member", "finalize on accept", "abort on deterministic rejection"}, + } + if a.globals.DryRun { + return a.renderSuccess(cmd.CommandPath(), format, a.globals.JQ, map[string]any{"plan": plan}, "Dry run: group e2ee recover-member planned", nil, a.identityMeta()) + } + result, recoverErr := service.RecoverGroupE2EEMember(cmd.Context(), request) + if recoverErr != nil { + return a.messageExit(recoverErr, "Ensure the target remains an active P4 member, has published a --recovery --group KeyPackage, and anp-mls/message-service PR-B3 APIs are enabled.") + } + result.Data["plan"] = plan + return a.renderMessageResult(cmd, format, result) +} + func activeIdentityDID(service *message.Service, name string) (string, error) { record, err := identity.NewManager(service.Config().Paths).Load(name) if err != nil { diff --git a/internal/cli/root.go b/internal/cli/root.go index 7eb454d..a24f07a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -249,6 +249,8 @@ func (a *App) handlerFor(spec cmdmeta.CommandSpec) func(*cobra.Command, []string return a.runGroupE2EEPending case "group.e2ee.repair": return a.runGroupE2EERepair + case "group.e2ee.recover-member": + return a.runGroupE2EERecoverMember case "group.e2ee.process-leave-request": return a.runGroupE2EEProcessLeaveRequest case "page.create": diff --git a/internal/cmdmeta/catalog.go b/internal/cmdmeta/catalog.go index a2cf6a2..cf09046 100644 --- a/internal/cmdmeta/catalog.go +++ b/internal/cmdmeta/catalog.go @@ -168,9 +168,10 @@ func defaultSpecs() []CommandSpec { {Name: "group.messages", Use: "messages", Short: "List group messages", Phase: "phase5", Implemented: true, Handler: "group.messages", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "limit", Type: "int", Usage: "Maximum number of rows", Default: "50"}, {Name: "cursor", Type: "string", Usage: "Pagination cursor"}}}, {Name: "group.e2ee", Use: "e2ee", Short: "Inspect test-only group E2EE state", Phase: "phase6", Implemented: true}, {Name: "group.e2ee.status", Use: "status", Short: "Inspect local group E2EE MLS provider status", Phase: "phase6", Implemented: true, Handler: "group.e2ee.status", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID"}}}, - {Name: "group.e2ee.publish-key-package", Use: "publish-key-package", Short: "Plan a test-only group E2EE KeyPackage publish", Phase: "phase6", Implemented: true, Handler: "group.e2ee.publish-key-package", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "device", Type: "string", Usage: "Local MLS device id", Default: "default"}, {Name: "contract-test", Type: "bool", Usage: "Use non-cryptographic contract-test artifacts"}}}, + {Name: "group.e2ee.publish-key-package", Use: "publish-key-package", Short: "Plan a test-only group E2EE KeyPackage publish", Phase: "phase6", Implemented: true, Handler: "group.e2ee.publish-key-package", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "device", Type: "string", Usage: "Local MLS device id", Default: "default"}, {Name: "recovery", Type: "bool", Usage: "Publish a same-device recovery KeyPackage"}, {Name: "group", Type: "string", Usage: "Target group DID for recovery KeyPackages"}, {Name: "contract-test", Type: "bool", Usage: "Use non-cryptographic contract-test artifacts"}}}, {Name: "group.e2ee.pending", Use: "pending", Short: "Pull pending group E2EE P6 notices", Phase: "phase6", Implemented: true, Handler: "group.e2ee.pending", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Optional group DID filter"}}}, {Name: "group.e2ee.repair", Use: "repair", Short: "Replay pending group E2EE P6 notices", Phase: "phase6", Implemented: true, Handler: "group.e2ee.repair", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Optional group DID filter"}}}, + {Name: "group.e2ee.recover-member", Use: "recover-member", Short: "Recover an active same-device group E2EE member", Phase: "phase6", Implemented: true, Handler: "group.e2ee.recover-member", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "member", Type: "string", Usage: "Active member DID or handle to recover", Required: true}, {Name: "device", Type: "string", Usage: "Target MLS device id", Default: "default"}}}, {Name: "group.e2ee.process-leave-request", Use: "process-leave-request", Short: "Process a pending group E2EE leave request", Phase: "phase6", Implemented: true, Handler: "group.e2ee.process-leave-request", SideEffect: true, Outputs: []string{"json", "pretty"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}, {Name: "member", Type: "string", Usage: "Leaving member DID or handle", Required: true}, {Name: "leave-request-id", Type: "string", Usage: "Leave request id to consume"}, {Name: "reason", Type: "string", Usage: "Owner/admin processing reason"}}}, {Name: "group.code", Use: "code", Short: "Inspect or manage group join codes", Phase: "phase5", Implemented: false}, {Name: "group.code.get", Use: "get", Short: "Show group join code status", Phase: "phase5", Implemented: false, Handler: "stub", Outputs: []string{"json", "pretty", "table"}, Flags: []FlagSpec{{Name: "group", Type: "string", Usage: "Group DID", Required: true}}}, diff --git a/internal/message/group_e2ee_provider.go b/internal/message/group_e2ee_provider.go index 2a95b93..e0c108b 100644 --- a/internal/message/group_e2ee_provider.go +++ b/internal/message/group_e2ee_provider.go @@ -381,6 +381,14 @@ func (p MLSExecProvider) RemoveMember(ctx context.Context, req MLSRequest) (map[ return resp.Result, nil } +func (p MLSExecProvider) RecoverMemberPrepare(ctx context.Context, req MLSRequest) (map[string]any, error) { + resp, err := p.Call(ctx, "group", "recover-member-prepare", req) + if err != nil { + return nil, err + } + return resp.Result, nil +} + func (p MLSExecProvider) LeaveGroup(ctx context.Context, req MLSRequest) (map[string]any, error) { resp, err := p.Call(ctx, "group", "leave", req) if err != nil { diff --git a/internal/message/group_e2ee_service.go b/internal/message/group_e2ee_service.go index 10468a3..e5c7516 100644 --- a/internal/message/group_e2ee_service.go +++ b/internal/message/group_e2ee_service.go @@ -14,26 +14,36 @@ import ( "github.com/agentconnect/awiki-cli/internal/store" ) -func (s *Service) publishGroupE2EEKeyPackage(ctx context.Context, record *identity.StoredIdentity, deviceID string, contractTest bool) (map[string]any, map[string]any, error) { +func (s *Service) publishGroupE2EEKeyPackage(ctx context.Context, record *identity.StoredIdentity, deviceID string, groupDID string, recovery bool, contractTest bool) (map[string]any, map[string]any, error) { provider := s.groupMLSProvider() if strings.TrimSpace(deviceID) == "" { deviceID = "default" } + groupDID = strings.TrimSpace(groupDID) + if recovery && groupDID == "" { + return nil, nil, fmt.Errorf("group DID is required when publishing a recovery KeyPackage") + } + params := map[string]any{ + "agent_did": record.DID, + "device_id": deviceID, + "owner_did": record.DID, + } + if recovery { + params["purpose"] = "recovery" + params["group_did"] = groupDID + } packageResult, err := provider.GenerateKeyPackage(ctx, MLSRequest{ APIVersion: "anp-mls/v1", RequestID: "group-e2ee-key-package-" + generateOperationID(), AgentDID: record.DID, DeviceID: deviceID, ContractTestEnabled: contractTest, - Params: map[string]any{ - "agent_did": record.DID, - "device_id": deviceID, - "owner_did": record.DID, - }, + Params: params, }) if err != nil { return nil, nil, err } + packageResult = tagGroupKeyPackagePurpose(packageResult, groupDID, deviceID, recovery) packageResult, err = signGroupKeyPackageDIDWBABinding(record, packageResult) if err != nil { return nil, nil, err @@ -49,6 +59,23 @@ func (s *Service) publishGroupE2EEKeyPackage(ctx context.Context, record *identi return packageResult, published, nil } +func tagGroupKeyPackagePurpose(packageResult map[string]any, groupDID string, deviceID string, recovery bool) map[string]any { + if !recovery { + return packageResult + } + tagged := cloneStringAnyMap(packageResult) + groupKeyPackage, _ := packageResult["group_key_package"].(map[string]any) + if len(groupKeyPackage) == 0 { + return tagged + } + taggedPackage := cloneStringAnyMap(groupKeyPackage) + taggedPackage["purpose"] = "recovery" + taggedPackage["group_did"] = groupDID + taggedPackage["device_id"] = defaultString(strings.TrimSpace(deviceID), "default") + tagged["group_key_package"] = taggedPackage + return tagged +} + func signGroupKeyPackageDIDWBABinding(record *identity.StoredIdentity, packageResult map[string]any) (map[string]any, error) { if record == nil { return nil, fmt.Errorf("identity record is required") @@ -126,19 +153,24 @@ func cloneStringAnyMap(source map[string]any) map[string]any { return clone } -func (s *Service) PublishGroupE2EEKeyPackage(ctx context.Context, identityName string, deviceID string, contractTest bool) (*CommandResult, error) { +func (s *Service) PublishGroupE2EEKeyPackage(ctx context.Context, identityName string, deviceID string, groupDID string, recovery bool, contractTest bool) (*CommandResult, error) { record, err := s.requireActiveIdentity(identityName) if err != nil { return nil, err } - packageResult, published, err := s.publishGroupE2EEKeyPackage(ctx, record, deviceID, contractTest) + packageResult, published, err := s.publishGroupE2EEKeyPackage(ctx, record, deviceID, groupDID, recovery, contractTest) if err != nil { return nil, err } return &CommandResult{ Data: map[string]any{ - "mls": packageResult, - "published": published, + "mls": packageResult, + "published": published, + "recovery": recovery, + "group": strings.TrimSpace(groupDID), + "device_id": defaultString(strings.TrimSpace(deviceID), "default"), + "argv_safe": true, + "p4_mutates": false, }, Summary: "Published group E2EE KeyPackage", }, nil @@ -313,6 +345,110 @@ func (s *Service) ProcessGroupE2EELeaveRequest(ctx context.Context, request Grou }, nil } +func (s *Service) RecoverGroupE2EEMember(ctx context.Context, request GroupE2EERecoverMemberRequest) (*CommandResult, error) { + if strings.TrimSpace(request.Group) == "" { + return nil, ErrGroupRequired + } + if strings.TrimSpace(request.Member) == "" { + return nil, ErrMemberRequired + } + record, err := s.requireActiveIdentity(request.IdentityName) + if err != nil { + return nil, err + } + memberDID, memberHandle, err := s.resolveTarget(ctx, request.Member) + if err != nil { + return nil, err + } + deviceID := defaultString(strings.TrimSpace(request.DeviceID), "default") + transport, warnings, err := s.httpTransport(record) + if err != nil { + return nil, err + } + serviceHead, headErr := transport.GetGroupE2EEHead(ctx, request.Group) + if headErr != nil { + warnings = append(warnings, fmt.Sprintf("Group E2EE service head unavailable before recovery: %v", headErr)) + } else if !boolFromAny(serviceHead["actor_recovery_eligible"]) { + return nil, fmt.Errorf("group E2EE recovery requires the actor to be an active P4 group member/admin; status=%s", stringFromAny(serviceHead["actor_membership_status"])) + } + leasedPackage, err := transport.GetGroupE2EERecoveryKeyPackage(ctx, request.Group, memberDID, deviceID) + if err != nil { + return nil, err + } + provider := s.groupMLSProvider() + operationID := "op-" + generateOperationID() + prepared, err := provider.RecoverMemberPrepare(ctx, MLSRequest{ + APIVersion: "anp-mls/v1", + RequestID: "group-e2ee-recover-member-prepare-" + generateOperationID(), + AgentDID: record.DID, + DeviceID: "default", + Params: map[string]any{ + "agent_did": record.DID, + "actor_did": record.DID, + "device_id": "default", + "group_did": request.Group, + "target": map[string]any{"agent_did": memberDID, "device_id": deviceID}, + "target_did": memberDID, + "target_device_id": deviceID, + "recovery_key_package_id": leasedPackage["key_package_id"], + "group_key_package": leasedPackage["group_key_package"], + "target_key_package": leasedPackage, + "operation_id": operationID, + "group_state_ref": s.localGroupStateRef(ctx, record, request.Group), + "p4_membership_mutate": false, + "forbidden_p4_method": "group.add", + "recovery_operation_purpose": "same-device-crypto-recovery", + }, + }) + if err != nil { + return nil, err + } + delivery, submitErr := transport.RecoverGroupE2EEMember(ctx, request.Group, memberDID, deviceID, prepared, leasedPackage) + if submitErr != nil { + if shouldAbortGroupE2EEPendingCommit(submitErr) { + abortResult, abortErr := s.abortPreparedGroupE2EECommit(ctx, record, request.Group, prepared) + if abortErr != nil { + warnings = append(warnings, fmt.Sprintf("Group E2EE recovery pending commit abort failed after service rejection: %v", abortErr)) + } else { + warnings = append(warnings, "Group E2EE recovery pending commit aborted after deterministic service rejection.") + return nil, fmt.Errorf("%w; local group E2EE recovery pending commit aborted: %v", submitErr, abortResult) + } + } + return nil, submitErr + } + finalized, finalizeErr := s.finalizePreparedGroupE2EECommit(ctx, record, request.Group, prepared) + if finalizeErr != nil { + warnings = append(warnings, fmt.Sprintf("Group E2EE recovery accepted by service but local finalize failed: %v", finalizeErr)) + } + summarySource := prepared + if finalized != nil { + summarySource = finalized + } + warnings = append(warnings, s.persistGroupE2EESummary(ctx, record, request.Group, summarySource, delivery)...) + localWelcome, localWelcomeWarnings := s.processLocalGroupWelcome(ctx, memberDID, request.Group, delivery, leasedPackage) + warnings = append(warnings, localWelcomeWarnings...) + data := map[string]any{ + "group": request.Group, + "member": map[string]any{"did": memberDID, "handle": memberHandle}, + "target": map[string]any{"agent_did": memberDID, "device_id": deviceID}, + "recovery_key_package": redactedKeyPackageSummary(leasedPackage), + "mls_prepare": prepared, + "mls_finalize": finalized, + "delivery": delivery, + "p4_membership_mutate": false, + "forbidden_p4_method": "group.add", + "argv_sensitive_fields": "stdin-json-only", + } + if localWelcome != nil { + data["local_welcome"] = localWelcome + } + return &CommandResult{ + Data: data, + Summary: "Recovered active group E2EE member without P4 membership mutation", + Warnings: compactWarnings(warnings), + }, nil +} + func (s *Service) submitPreparedGroupE2EECommit( ctx context.Context, record *identity.StoredIdentity, @@ -819,6 +955,7 @@ func (s *Service) InspectGroupE2EEStatus(ctx context.Context, identityName strin } } diagnosis := groupE2EERecoveryDiagnosis(localStatus, serviceHead, pendingNoticeCount, localErr) + recoveryArtifact := groupE2EERecoveryArtifact(record, groupDID, localDeviceID, localStatus, serviceHead, diagnosis) return &CommandResult{ Data: map[string]any{ "group": groupDID, @@ -830,6 +967,7 @@ func (s *Service) InspectGroupE2EEStatus(ctx context.Context, identityName strin "pending_notices": pendingNotices, "pending_notice_count": pendingNoticeCount, "diagnosis": diagnosis, + "recovery_artifact": recoveryArtifact, }, Summary: "Group E2EE recovery status inspected", Warnings: compactWarnings(warnings), @@ -957,8 +1095,9 @@ func (s *Service) RepairGroupE2EENotices(ctx context.Context, identityName strin } diagnosis := groupE2EERecoveryDiagnosis(localStatus, serviceHead, remainingPending, localErr) if action := stringFromAny(diagnosis["next_action"]); action == "needs_snapshot_or_readd" { - warnings = append(warnings, "Group E2EE repair could not prove epoch continuity; fail closed and ask an owner/admin to re-add this single-device member with a fresh KeyPackage.") + warnings = append(warnings, "Group E2EE repair could not prove epoch continuity; fail closed and ask an owner/admin to run group e2ee recover-member after this member publishes a --recovery --group KeyPackage.") } + recoveryArtifact := groupE2EERecoveryArtifact(record, groupDID, localDeviceID, localStatus, serviceHead, diagnosis) return &CommandResult{ Data: map[string]any{ "processed": processed, @@ -972,12 +1111,38 @@ func (s *Service) RepairGroupE2EENotices(ctx context.Context, identityName strin "local_device_id": localDeviceID, "service_head": serviceHead, "diagnosis": diagnosis, + "recovery_artifact": recoveryArtifact, }, Summary: "Replayed group E2EE pending notices", Warnings: compactWarnings(warnings), }, nil } +func groupE2EERecoveryArtifact(record *identity.StoredIdentity, groupDID string, deviceID string, localStatus map[string]any, serviceHead map[string]any, diagnosis map[string]any) map[string]any { + if stringFromAny(diagnosis["next_action"]) != "needs_snapshot_or_readd" { + return nil + } + artifact := map[string]any{ + "recovery_type": "same-device-owner-assisted", + "group_did": groupDID, + "member_did": record.DID, + "device_id": defaultString(strings.TrimSpace(deviceID), "default"), + "diagnosis_state": diagnosis["state"], + "fail_closed": true, + "p4_membership_mutate": false, + "publish_command": fmt.Sprintf("group e2ee publish-key-package --recovery --group %s --device %s", groupDID, defaultString(strings.TrimSpace(deviceID), "default")), + "owner_command": fmt.Sprintf("group e2ee recover-member --group %s --member %s --device %s", groupDID, record.DID, defaultString(strings.TrimSpace(deviceID), "default")), + } + if localEpoch, ok := groupE2EELocalEpochFromStatus(localStatus); ok { + artifact["local_epoch"] = strconv.FormatInt(localEpoch, 10) + } + if serviceHead != nil { + artifact["service_epoch"] = serviceHead["epoch"] + artifact["member_status"] = serviceHead["actor_membership_status"] + } + return artifact +} + func (s *Service) processGroupCommitNotice(ctx context.Context, record *identity.StoredIdentity, groupDID string, notice map[string]any) (map[string]any, []string) { commitB64U := stringFromAny(notice["commit_b64u"]) if commitB64U == "" { diff --git a/internal/message/group_service_test.go b/internal/message/group_service_test.go index a4413ff..6e6e55a 100644 --- a/internal/message/group_service_test.go +++ b/internal/message/group_service_test.go @@ -578,3 +578,120 @@ func writeCachedGroupState(t *testing.T, resolved *appconfig.Resolved, record *i t.Fatalf("ReplaceGroupMembers() error = %v", err) } } + +func TestRecoverGroupE2EEMemberUsesRecoverMemberWithoutGroupAdd(t *testing.T) { + t.Parallel() + + groupDID := "did:wba:awiki.ai:groups:recover:e1_group" + bobDID := "did:wba:awiki.ai:user:bob:e1_bob" + methods := []string(nil) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + envelope := decodeRPCRequest(t, r) + methods = append(methods, envelope.Method) + var result map[string]any + switch envelope.Method { + case "anp.get_capabilities": + result = map[string]any{"service_did": "did:wba:awiki.ai:services:message:e1_service"} + case "group.e2ee.head": + result = map[string]any{ + "group_did": groupDID, + "epoch": "5", + "actor_membership_status": "active", + "actor_recovery_eligible": true, + } + case "group.e2ee.get_key_package": + body := mustMapValue(t, envelope.Params["body"], "get_key_package.body") + if got := stringFromAny(body["purpose"]); got != "recovery" { + t.Fatalf("get_key_package purpose = %q, want recovery", got) + } + result = map[string]any{ + "key_package_id": "kp-recovery-1", + "group_key_package": map[string]any{ + "owner_did": bobDID, + "purpose": "recovery", + "group_did": groupDID, + "device_id": "bob-main", + }, + } + case "group.e2ee.recover_member": + body := mustMapValue(t, envelope.Params["body"], "recover_member.body") + if _, ok := body["member_did"]; ok { + t.Fatalf("recover_member body must not include P4 member_did: %#v", body) + } + target := mustMapValue(t, body["target"], "recover_member.target") + if got := stringFromAny(target["agent_did"]); got != bobDID { + t.Fatalf("target.agent_did = %q, want bob", got) + } + result = map[string]any{"accepted": true, "epoch": "6", "operation_id": stringFromAny(mustMapValue(t, envelope.Params["meta"], "recover_member.meta")["operation_id"])} + default: + t.Fatalf("unexpected RPC method %q", envelope.Method) + } + _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": envelope.ID, "result": result}) + })) + defer server.Close() + + service, _, record := newMessageServiceForTest(t, server.URL) + runner := &groupE2EERecoverMLSRunner{} + service.mlsProvider = &MLSExecProvider{BinaryPath: "anp-mls", Runner: runner} + + result, err := service.RecoverGroupE2EEMember(context.Background(), GroupE2EERecoverMemberRequest{ + IdentityName: record.IdentityName, + Group: groupDID, + Member: bobDID, + DeviceID: "bob-main", + }) + if err != nil { + t.Fatalf("RecoverGroupE2EEMember() error = %v", err) + } + if got := boolFromAny(result.Data["p4_membership_mutate"]); got { + t.Fatalf("p4_membership_mutate = true, want false") + } + for _, method := range methods { + if method == "group.add" { + t.Fatalf("recovery must not call group.add; methods=%#v", methods) + } + } + if !runner.sawRecoverPrepare || !runner.sawFinalize { + t.Fatalf("runner prepare/finalize = %v/%v, calls=%#v", runner.sawRecoverPrepare, runner.sawFinalize, runner.calls) + } +} + +type groupE2EERecoverMLSRunner struct { + calls []string + sawRecoverPrepare bool + sawFinalize bool +} + +func (r *groupE2EERecoverMLSRunner) Run(_ context.Context, _ string, args []string, stdin []byte) ([]byte, []byte, error) { + var req MLSRequest + _ = json.Unmarshal(stdin, &req) + command := strings.Join(args, " ") + r.calls = append(r.calls, command) + result := map[string]any{"status": "active", "epoch": "6", "crypto_group_id_b64u": "crypto-1"} + if strings.Contains(command, "recover-member-prepare") { + r.sawRecoverPrepare = true + if _, ok := req.Params["p4_membership_mutate"]; !ok { + return []byte(fmt.Sprintf(`{"ok":false,"api_version":"anp-mls/v1","request_id":%q,"error":{"code":"missing_guard","message":"missing no-P4 guard"}}`, req.RequestID)), nil, nil + } + result = map[string]any{ + "operation_id": stringFromAny(req.Params["operation_id"]), + "pending_commit_id": "pc-recover-1", + "crypto_group_id_b64u": "crypto-1", + "from_epoch": "5", + "to_epoch": "6", + "epoch": "6", + "epoch_authenticator_b64u": "auth-6", + "commit_b64u": "opaque-commit", + "welcome_b64u": "opaque-welcome", + } + } + if strings.Contains(command, "commit-finalize") { + r.sawFinalize = true + } + return []byte(mustJSONForTest(map[string]any{ + "ok": true, + "api_version": "anp-mls/v1", + "request_id": req.RequestID, + "result": result, + })), nil, nil +} diff --git a/internal/message/group_wire.go b/internal/message/group_wire.go index 8482853..93e7070 100644 --- a/internal/message/group_wire.go +++ b/internal/message/group_wire.go @@ -291,8 +291,22 @@ func BuildGroupE2EEPublishKeyPackageRPCParams(record *identity.StoredIdentity, m } func BuildGroupE2EEGetKeyPackageRPCParams(record *identity.StoredIdentity, manager *identity.Manager, serviceDID string, targetDID string) (map[string]any, error) { + return buildGroupE2EEGetKeyPackageRPCParams(record, manager, serviceDID, map[string]any{"target_did": strings.TrimSpace(targetDID)}) +} + +func BuildGroupE2EEGetRecoveryKeyPackageRPCParams(record *identity.StoredIdentity, manager *identity.Manager, serviceDID string, groupDID string, targetDID string, deviceID string) (map[string]any, error) { + body := map[string]any{ + "target_did": targetDID, + "purpose": "recovery", + "group_did": strings.TrimSpace(groupDID), + "device_id": defaultString(strings.TrimSpace(deviceID), "default"), + } + return buildGroupE2EEGetKeyPackageRPCParams(record, manager, serviceDID, body) +} + +func buildGroupE2EEGetKeyPackageRPCParams(record *identity.StoredIdentity, manager *identity.Manager, serviceDID string, body map[string]any) (map[string]any, error) { serviceDID = strings.TrimSpace(serviceDID) - targetDID = strings.TrimSpace(targetDID) + targetDID := strings.TrimSpace(stringFromAny(body["target_did"])) if serviceDID == "" { return nil, fmt.Errorf("message service did is required") } @@ -313,7 +327,6 @@ func BuildGroupE2EEGetKeyPackageRPCParams(record *identity.StoredIdentity, manag "created_at": nowRFC3339(), "content_type": "application/json", } - body := map[string]any{"target_did": targetDID} payload := signedPayload{Method: "group.e2ee.get_key_package", Meta: meta, Body: body} originProof, err := buildOriginProof(auth, payload) if err != nil { @@ -326,6 +339,11 @@ func BuildGroupE2EEGetKeyPackageRPCParams(record *identity.StoredIdentity, manag }, nil } +func BuildGroupE2EERecoverMemberRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, memberDID string, deviceID string, prepared map[string]any, leasedPackage map[string]any) (map[string]any, error) { + body := e2eeRecoveryCommitBody(groupDID, memberDID, deviceID, prepared, leasedPackage) + return buildGroupE2EERPCParams(record, manager, "group", groupDID, "group.e2ee.recover_member", body, "", stringFromAny(prepared["operation_id"]), "", GroupE2EESecurityProfile) +} + func BuildGroupE2EENoticeRPCParams(record *identity.StoredIdentity, manager *identity.Manager, groupDID string, limit int, markDelivered bool, noticeIDs []string) (map[string]any, error) { auth, err := newAuthContext(record, manager) if err != nil { @@ -742,6 +760,74 @@ func e2eeMembershipCommitBody(groupDID string, subjectDID string, defaultSubject return body } +func e2eeRecoveryCommitBody(groupDID string, memberDID string, deviceID string, prepared map[string]any, leasedPackage map[string]any) map[string]any { + body := map[string]any{ + "group_did": groupDID, + "group_state_ref": map[string]any{ + "group_did": groupDID, + }, + "target": map[string]any{ + "agent_did": memberDID, + "device_id": defaultString(strings.TrimSpace(deviceID), "default"), + }, + } + for _, key := range []string{ + "crypto_group_id_b64u", + "epoch", + "epoch_authenticator", + "epoch_authenticator_b64u", + "suite", + "last_handshake_digest", + "pending_commit_id", + "operation_id", + "commit_b64u", + "welcome_b64u", + "ratchet_tree_b64u", + "group_info_b64u", + "from_epoch", + "to_epoch", + "old_generation_id", + "new_generation_id", + } { + if value, ok := prepared[key]; ok { + body[key] = value + } + } + if _, ok := body["epoch"]; !ok { + if value, ok := prepared["to_epoch"]; ok { + body["epoch"] = value + } + } + if _, ok := body["epoch_authenticator"]; !ok { + if value, ok := prepared["epoch_authenticator_b64u"]; ok { + body["epoch_authenticator"] = value + } + } + keyPackageID := firstNonEmptyString( + stringFromAny(prepared["recovery_key_package_id"]), + stringFromAny(prepared["key_package_id"]), + stringFromAny(leasedPackage["key_package_id"]), + ) + if keyPackageID != "" { + body["recovery_key_package_id"] = keyPackageID + } + if groupKeyPackage, ok := leasedPackage["group_key_package"].(map[string]any); ok && len(groupKeyPackage) > 0 { + body["group_key_package"] = sanitizeGroupKeyPackageForService(groupKeyPackage) + } + groupStateRef, _ := body["group_state_ref"].(map[string]any) + if len(groupStateRef) == 0 { + groupStateRef = map[string]any{"group_did": groupDID} + body["group_state_ref"] = groupStateRef + } + if cryptoGroupID := stringFromAny(body["crypto_group_id_b64u"]); cryptoGroupID != "" { + groupStateRef["crypto_group_id_b64u"] = cryptoGroupID + } + if fromEpoch := stringFromAny(body["from_epoch"]); fromEpoch != "" { + groupStateRef["epoch"] = fromEpoch + } + return body +} + func boolPtr(value bool) *bool { return &value } diff --git a/internal/message/group_wire_test.go b/internal/message/group_wire_test.go index d60d6f8..adcf262 100644 --- a/internal/message/group_wire_test.go +++ b/internal/message/group_wire_test.go @@ -641,3 +641,61 @@ func testStoredIdentity(t *testing.T) *identity.StoredIdentity { Key1PrivatePEM: generated.Key1PrivatePEM, } } + +func TestBuildGroupE2EERecoverMemberRPCParamsAvoidsP4MembershipFields(t *testing.T) { + t.Parallel() + + record := testStoredIdentity(t) + params, err := BuildGroupE2EERecoverMemberRPCParams(record, nil, "did:wba:awiki.ai:groups:demo:e1_group", "did:wba:awiki.ai:user:bob:e1_bob", "bob-main", map[string]any{ + "operation_id": "op-recover-1", + "pending_commit_id": "pc-recover-1", + "crypto_group_id_b64u": "Y3J5cHRv", + "from_epoch": "5", + "to_epoch": "6", + "commit_b64u": "Y29tbWl0", + "welcome_b64u": "d2VsY29tZQ", + "ratchet_tree_b64u": "cmF0Y2hldA", + "epoch_authenticator_b64u": "YXV0aDY", + "application_plaintext": "must-not-leak", + "member_did": "must-not-be-forwarded", + }, map[string]any{ + "key_package_id": "kp-recovery-1", + "group_key_package": map[string]any{ + "owner_did": "did:wba:awiki.ai:user:bob:e1_bob", + "purpose": "recovery", + "device_id": "bob-main", + "private_key_package_b64u": "must-not-leak", + }, + }) + if err != nil { + t.Fatalf("BuildGroupE2EERecoverMemberRPCParams() error = %v", err) + } + meta := mustMapValue(t, params["meta"], "params.meta") + if got := stringFromAny(meta["operation_id"]); got != "op-recover-1" { + t.Fatalf("operation_id = %q, want prepared operation", got) + } + body := mustMapValue(t, params["body"], "params.body") + if _, ok := body["member_did"]; ok { + t.Fatalf("recover_member must not carry P4 member_did: %#v", body) + } + if _, ok := body["role"]; ok { + t.Fatalf("recover_member must not carry P4 role: %#v", body) + } + target := mustMapValue(t, body["target"], "body.target") + if got := stringFromAny(target["agent_did"]); got != "did:wba:awiki.ai:user:bob:e1_bob" { + t.Fatalf("target.agent_did = %q, want bob", got) + } + if got := stringFromAny(target["device_id"]); got != "bob-main" { + t.Fatalf("target.device_id = %q, want bob-main", got) + } + if got := stringFromAny(body["recovery_key_package_id"]); got != "kp-recovery-1" { + t.Fatalf("recovery_key_package_id = %q, want kp", got) + } + if _, ok := body["application_plaintext"]; ok { + t.Fatalf("plaintext leaked into recovery body: %#v", body) + } + recoveryPackage := mustMapValue(t, body["group_key_package"], "body.group_key_package") + if _, ok := recoveryPackage["private_key_package_b64u"]; ok { + t.Fatalf("private KeyPackage material leaked: %#v", recoveryPackage) + } +} diff --git a/internal/message/http_client.go b/internal/message/http_client.go index c7b3d56..6ee19c7 100644 --- a/internal/message/http_client.go +++ b/internal/message/http_client.go @@ -271,6 +271,18 @@ func (t *HTTPTransport) GetGroupE2EEKeyPackage(ctx context.Context, targetDID st return t.rpcMapCall(ctx, "group.e2ee.get_key_package", params) } +func (t *HTTPTransport) GetGroupE2EERecoveryKeyPackage(ctx context.Context, groupDID string, targetDID string, deviceID string) (map[string]any, error) { + serviceDID, err := t.GetMessageServiceDID(ctx) + if err != nil { + return nil, err + } + params, err := BuildGroupE2EEGetRecoveryKeyPackageRPCParams(t.auth.record, nil, serviceDID, groupDID, targetDID, deviceID) + if err != nil { + return nil, err + } + return t.rpcMapCall(ctx, "group.e2ee.get_key_package", params) +} + func (t *HTTPTransport) CreateGroupE2EE(ctx context.Context, groupDID string, mlsHead map[string]any) (map[string]any, error) { serviceDID, err := t.GetMessageServiceDID(ctx) if err != nil { @@ -291,6 +303,14 @@ func (t *HTTPTransport) AddGroupE2EE(ctx context.Context, groupDID string, membe return t.rpcMapCall(ctx, "group.e2ee.add", params) } +func (t *HTTPTransport) RecoverGroupE2EEMember(ctx context.Context, groupDID string, memberDID string, deviceID string, prepared map[string]any, leasedPackage map[string]any) (map[string]any, error) { + params, err := BuildGroupE2EERecoverMemberRPCParams(t.auth.record, nil, groupDID, memberDID, deviceID, prepared, leasedPackage) + if err != nil { + return nil, err + } + return t.rpcMapCall(ctx, "group.e2ee.recover_member", params) +} + func (t *HTTPTransport) RemoveGroupE2EE(ctx context.Context, groupDID string, memberDID string, preparedCommit map[string]any, reasonText string, leaveRequestID string) (map[string]any, error) { params, err := BuildGroupE2EERemoveRPCParams(t.auth.record, nil, groupDID, memberDID, preparedCommit, reasonText, leaveRequestID) if err != nil { diff --git a/internal/message/types.go b/internal/message/types.go index 38b308e..fc5bfdd 100644 --- a/internal/message/types.go +++ b/internal/message/types.go @@ -174,6 +174,13 @@ type GroupE2EEProcessLeaveRequest struct { ReasonText string } +type GroupE2EERecoverMemberRequest struct { + IdentityName string + Group string + Member string + DeviceID string +} + type GroupUpdateRequest struct { IdentityName string Group string From 4e60ca0463efc50042e3b823b690aeb7a33379ef Mon Sep 17 00:00:00 2001 From: changshan Date: Mon, 4 May 2026 19:17:53 +0800 Subject: [PATCH 14/14] Preserve recovery KeyPackage bindings at the CLI boundary PR-B3 recovery KeyPackages must reach message-service with purpose, group_did, and device_id intact, while normal KeyPackages must not send empty optional recovery fields. The recover-member result also stops echoing a group.add debug marker so hidden/test-only recovery cannot be mistaken for P4 membership mutation. Constraint: Group E2EE stays hidden/test-only and recover-member must not mutate or imply P4 group.add. Rejected: Keep group.add as a forbidden-method debug echo | focused E2E treats any public group.add marker as an overclaim and it is unnecessary for orchestration. Confidence: high Scope-risk: narrow Directive: Do not drop purpose/group_did from recovery group_key_package payloads; service recovery lookup depends on them. Tested: go test ./internal/message -run 'PublishKeyPackage|SanitizeGroupKeyPackage|RecoverMember|GroupE2EE' Tested: go test ./internal/message ./internal/cli ./internal/cmdmeta Tested: go test ./... Tested: go vet ./... Tested: awiki-system-test focused CLI PR-B3 E2E 5 passed with --with-message-v2 --use-local-anp Not-tested: Public discovery enablement; intentionally out of scope. --- CLAUDE.md | 2 +- internal/message/group_e2ee_service.go | 2 -- internal/message/group_wire.go | 5 ++++ internal/message/group_wire_test.go | 35 ++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cea562e..3b93fd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -219,7 +219,7 @@ - `msg` 域中 direct plain 已实现,P5 secure direct 出站首版已接入;HTTP inbox/history 的 P5 入站自动解密、轮询 direct-init 自动 ACK 与 outbox flush 已接入;websocket listener 已能解密 secure incoming、自动 first-reply/ack,并在 secure ack 后尝试 flush `e2ee_outbox`;`msg secure status/init/repair/failed/retry/drop` 有首版,但完整 runtime 联调与更强系统测试仍未完成 - `group` 域的 plain lifecycle / local view / group messaging 已接入,`people` 仍大多为 stub;`page` 已完成 content pages 首版 -- secure E2EE 业务流目前只完成 P5 出站首版;group E2EE 已接入 CLI 侧 `anp-mls` exec 编排、KeyPackage 发布(含 PR-B3 recovery KeyPackage)、group-e2ee create/add/remove/send、安全 self-leave request、owner/admin process-leave-request、owner/admin recover-member same-device crypto recovery、pending commit finalize/abort、commit-delivery repair 与轮询消息本地解密分支,并已有隐藏 focused target 覆盖真实 OpenMLS Alice/Bob 最小闭环;对外 discovery 仍保持隐藏,External Commit/recovery 与完整 MLS 群管理能力仍未实现;安全 leave-request 已处于 hidden/test-only PR-B1 路径。 +- secure E2EE 业务流目前只完成 P5 出站首版;group E2EE 已接入 CLI 侧 `anp-mls` exec 编排、KeyPackage 发布(含 PR-B3 recovery KeyPackage)、group-e2ee create/add/remove/send、安全 self-leave request、owner/admin process-leave-request、owner/admin recover-member same-DID/device crypto recovery、pending commit finalize/abort、commit-delivery repair 与轮询消息本地解密分支,并已有隐藏 focused target 覆盖真实 OpenMLS Alice/Bob 最小闭环与 PR-B3 recovery;对外 discovery 仍保持隐藏,External Commit、多设备、cloud snapshot 与完整 MLS 群管理能力仍未实现;安全 leave-request 已处于 hidden/test-only PR-B1 路径。 ## 开发与验证约定 diff --git a/internal/message/group_e2ee_service.go b/internal/message/group_e2ee_service.go index e5c7516..74cc2ec 100644 --- a/internal/message/group_e2ee_service.go +++ b/internal/message/group_e2ee_service.go @@ -396,7 +396,6 @@ func (s *Service) RecoverGroupE2EEMember(ctx context.Context, request GroupE2EER "operation_id": operationID, "group_state_ref": s.localGroupStateRef(ctx, record, request.Group), "p4_membership_mutate": false, - "forbidden_p4_method": "group.add", "recovery_operation_purpose": "same-device-crypto-recovery", }, }) @@ -436,7 +435,6 @@ func (s *Service) RecoverGroupE2EEMember(ctx context.Context, request GroupE2EER "mls_finalize": finalized, "delivery": delivery, "p4_membership_mutate": false, - "forbidden_p4_method": "group.add", "argv_sensitive_fields": "stdin-json-only", } if localWelcome != nil { diff --git a/internal/message/group_wire.go b/internal/message/group_wire.go index 93e7070..5f0d03e 100644 --- a/internal/message/group_wire.go +++ b/internal/message/group_wire.go @@ -439,12 +439,17 @@ func sanitizeGroupKeyPackageForService(input map[string]any) map[string]any { "mls_key_package_b64u": {}, "did_wba_binding": {}, "expires_at": {}, + "purpose": {}, + "group_did": {}, "non_cryptographic": {}, "artifact_mode": {}, } output := make(map[string]any, len(input)) for key, value := range input { if _, ok := allowed[key]; ok { + if (key == "group_did" || key == "purpose") && strings.TrimSpace(stringFromAny(value)) == "" { + continue + } output[key] = value } } diff --git a/internal/message/group_wire_test.go b/internal/message/group_wire_test.go index adcf262..3689495 100644 --- a/internal/message/group_wire_test.go +++ b/internal/message/group_wire_test.go @@ -384,6 +384,8 @@ func TestBuildGroupE2EEPublishKeyPackageRPCParamsStripsProviderOnlyFields(t *tes "mls_key_package_b64u": "a3A", "did_wba_binding": map[string]any{"agent_did": record.DID}, "device_id": "bob-main", + "purpose": "recovery", + "group_did": "did:wba:awiki.ai:groups:demo:e1_group", "private_key_package_b64u": "must-not-leak", }, }) @@ -401,6 +403,12 @@ func TestBuildGroupE2EEPublishKeyPackageRPCParamsStripsProviderOnlyFields(t *tes if got := stringFromAny(groupKeyPackage["key_package_id"]); got != "kp-bob-main" { t.Fatalf("key_package_id = %q, want kp-bob-main", got) } + if got := stringFromAny(groupKeyPackage["purpose"]); got != "recovery" { + t.Fatalf("purpose = %q, want recovery", got) + } + if got := stringFromAny(groupKeyPackage["group_did"]); got != "did:wba:awiki.ai:groups:demo:e1_group" { + t.Fatalf("group_did = %q, want recovery group DID", got) + } if _, ok := params["auth"]; !ok { t.Fatalf("auth missing from publish params: %#v", params) } @@ -410,6 +418,27 @@ func TestBuildGroupE2EEPublishKeyPackageRPCParamsStripsProviderOnlyFields(t *tes } } +func TestSanitizeGroupKeyPackageForServiceOmitsEmptyOptionalRecoveryFields(t *testing.T) { + t.Parallel() + + got := sanitizeGroupKeyPackageForService(map[string]any{ + "owner_did": "did:wba:awiki.ai:user:bob:e1_bob", + "device_id": "bob-main", + "key_package_id": "kp-bob-main", + "suite": "MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519", + "mls_key_package_b64u": "a3A", + "did_wba_binding": map[string]any{"agent_did": "did:wba:awiki.ai:user:bob:e1_bob"}, + "purpose": "", + "group_did": "", + }) + if _, ok := got["purpose"]; ok { + t.Fatalf("empty purpose must not be sent to service: %#v", got) + } + if _, ok := got["group_did"]; ok { + t.Fatalf("empty group_did must not be sent to service: %#v", got) + } +} + func TestSignGroupKeyPackageDIDWBABindingAddsStrictObjectProof(t *testing.T) { t.Parallel() @@ -698,4 +727,10 @@ func TestBuildGroupE2EERecoverMemberRPCParamsAvoidsP4MembershipFields(t *testing if _, ok := recoveryPackage["private_key_package_b64u"]; ok { t.Fatalf("private KeyPackage material leaked: %#v", recoveryPackage) } + if got := stringFromAny(recoveryPackage["purpose"]); got != "recovery" { + t.Fatalf("recovery group_key_package.purpose = %q, want recovery", got) + } + if got := stringFromAny(recoveryPackage["device_id"]); got != "bob-main" { + t.Fatalf("recovery group_key_package.device_id = %q, want bob-main", got) + } }