# MLS 设备与加密事件协议

仓库已实现Rust MLS核心、服务端交付接口和iOS独立传输器。当前iOS主ChatStore与Android主聊天仍发送普通text/media，附件也未接入客户端密文封装。本页解释协议状态与恢复要求，不把模块存在等同于全量端到端加密或生产安全审计通过。

## 身份、编码与职责

Rust固定使用MLS 1.0与`MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519`。设备credential串联聊天用户ID与设备ID，签名公钥32字节；会话scopeId的UTF-8字节用作MLS GroupId。

| 对象 | 关键字段与约束 |
| --- | --- |
| Device | userId、deviceId、signatureKey；deviceId为1–80位字母数字/下划线/短横线 |
| KeyPackage | TLS编码MLS字节；1–65536字节；标准base64放入JSON；id为包内容SHA-256 |
| Scope | scopeKind、scopeId、mode、creatorUserId、creatorDeviceId；blocked内部另记blocked_after_sequence |
| Event | sequence、clientId、kind、senderUserId、senderDeviceId、payload、createdAt、可选targetUserId/targetDeviceId |
| Recipient | sequence、userId、deviceId、acknowledgedAt；服务器按设备决定收件与确认 |

服务端验证密钥包的协议签名、套件和账号/设备匹配，以及非Welcome封装的消息类型和群ID；终端负责真正的MLS认证与解密。Welcome封装服务器检查类型、声明收件设备与包可用性；加入终端还必须验证群ID、邀请者credential和签名公钥，不能仅信任HTTP元数据。依据：`chat-crypto/src/lib.rs:15-16,48-98,270-309`。

MLS内容密文并不隐藏全部交付元数据。服务端仍保存范围ID、发送账号和设备、收件设备、事件种类、密文字节数、时间与确认状态，并利用当前成员目录决定谁能领取事件。协议中的加密内容保护不能推导出匿名通信、隐藏社交关系或服务器完全不知道消息活动；公开文档也不应将这些可见元数据称为已加密正文的一部分。依据：`chat-service/src/e2ee-delivery.mjs:36-52,235-255`。

## 注册、目录与claim

设备POST `/e2ee/devices/{deviceId}/key-packages`注册自己的包。相同用户与deviceId已存在时，签名公钥必须相同，否则DEVICE_KEY_CHANGED；每设备最多25个未用包。GET本设备key-packages返回registered、available。iOS登录后通常补到3个可用包，但注册本身不建立加密聊天，也不允许仅据此显示锁标记。

目录`GET /e2ee/{conversations|groups}/{id}/devices?deviceId=...`给出当前参与者的全部已注册设备，并为每设备挑最早可用包。目录读取不预留、不claim包；发起者必须准备应对另一个请求先使用同一包。只有成功写入定向Welcome的事务，才把该包claimed_sequence设为对应事件序号。

同一Welcome的完全相同重试先命中事件去重，仍返回原sequence，不会再次消费包；改新clientId却引用已claim的包得到PACKAGE_USED。不能把公开目录返回的keyPackageId当永久可复用邀请凭证。新设备首次注册会把该用户已有active范围改blocked，记录当时边界；此操作没有另写普通事件提醒，客户端需要重查模式。依据：`chat-service/src/e2ee-delivery.mjs:76-145,187-255`。

## 初次建立的次序

1. 每位当前参与者至少注册一个设备，发起设备调用prepare。仅legacy允许，成功后进入preparing并固定协议创建者账号和设备。
2. 创建者在本机建立MLS群；对每个尚未joined且未invited的目标读取包，核对或固定已知签名公钥。
3. Rust add_member生成Commit与Welcome；先以独立clientId提交Commit并确认本机pending commit，再提交定向Welcome及包ID。
4. 目标设备按sequence读取自己的Welcome，验证邀请者和群ID，持久化本地状态，然后发welcome-acks。
5. 已加入设备处理其收到的后续Commit并持久化，发送commit-acks；创建者发送的Commit由本机完成后推进自己的sequence。
6. 创建者调用activate。服务器确认所有当前注册设备均已加入并处理必要Commit，返回mode=active；此前不能发送application。

preparing只能由创建者建立群。目录中的joined不等于界面在线：它是创建者身份或Welcome已有确认的结果。一个用户有多台已注册设备时，不是只等每用户任意一台确认；离线旧设备也可能阻止激活。依据：`ios-app/Sources/Features/Chat/ChatEncryptedTransport.swift:76-150`、`chat-service/src/e2ee-delivery.mjs:159-172,301-354`。

## 状态迁移与成员变化

| 当前状态 | 允许推进 | 被禁止或要求等待的动作 |
| --- | --- | --- |
| legacy | prepare→preparing | prepare前application为NOT_PREPARED |
| preparing | 创建者Commit/Welcome；收件设备ack；创建者activate→active | application为NOT_ACTIVE，其他设备不得建立群 |
| active | application；创建者新Commit可使其blocked | 直接新Welcome会AWAITING_COMMIT |
| blocked | 创建者新Commit、必要Welcome和ack；完成后activate→active | application首先得到NOT_ACTIVE；不能回退普通消息发送 |

群成员增加、移除或退出、删除好友、屏蔽以及新设备注册会阻塞已有范围。blocked恢复需要边界之后的新Commit；每个剩余设备必须处理该Commit，或通过边界之后的新Welcome加入。activate还检查每设备后续未确认Commit。AWAITING_REKEY、AWAITING_WELCOME和AWAITING_COMMIT是不同等待条件，不该通过循环activate掩盖缺失步骤。

协议创建者设备与个人群owner不同，群主转移不会自动转移创建者权限；当前没有公开创建者迁移或设备注销路由。iOS独立prepare遇blocked直接返回模式，未自动实施完整重键协调，不能把服务端恢复契约描述成已交付的客户端自动恢复。依据：`chat-service/src/e2ee-delivery.mjs:201-211,301-363`、`ios-app/Sources/Features/Chat/ChatEncryptedTransport.swift:78-94`。

## 应用消息、游标与去重

POST events的payload最多1048576字节，JSON最多1500000字节，kind只允许welcome/commit/application。事件幂等键为发送用户+设备+clientId，同一次重试还要保持范围、kind、payload摘要、目标与包ID不变，否则CLIENT_ID_REUSED。重新加密同一业务文字会产生不同密文，不能再用原clientId提交。

GET events按当前设备收件、sequence升序、每页最多100。sequence是全表自增值，并非每会话连续无缺口；其他范围和不发给本设备的事件会造成合法跳号。普通`/events`的id是另一游标，e2ee通知中的messageId可能引用MLS sequence，但仍不能拿它替换普通事件id。

iOS独立文字payload含version=1、kind=text、clientID、text，最多65536个UTF-8字节。设备先把待发送原密文保存，再HTTP提交；响应成功后转为本地已发送消息。接收先验证MLS发送者，再与服务端senderUserId/senderDeviceId比对，解析payload并保存消息与sequence。处理或保存失败不得跳过并推进游标。

Welcome/Commit ack先保存在本地，HTTP确认成功才移出pending；确认丢失可重复提交。自身application回显不会再用接收棘轮解密，而是确认排队消息并推进序号；若既无排队项也无已确认本地消息，按状态异常处理。依据：`ios-app/Sources/Features/Chat/ChatEncryptedTransport.swift:153-263`、`ios-app/Sources/Features/Chat/ChatCryptoDevice.swift:442-510`。

## 信任固定与本地保存失败

iOS把远端账号、设备和签名公钥的对应关系固定在本机Keychain；已知设备的公钥变化时拒绝继续，首次见到设备则返回firstSeen标识。仓库有根据双方身份和公钥生成安全码的函数，但不能把这个辅助函数等同于主聊天已提供完整的用户核对流程，更不能等同于服务端目录透明性或独立身份审计。依据：`ios-app/Sources/Features/Chat/ChatCryptoTrustStore.swift:8-81`。

加密设备操作通过persisted包装：执行后保存checkpoint，若处理或落盘失败则重新读取上次磁盘状态；连恢复也失败，就把设备标为unusable并拒绝继续使用，避免在内存状态与已保存游标不一致时继续发送。checkpoint同时包含原生MLS状态、消息、游标、待发密文、待确认记录和未完成邀请。不能只备份一个sequence数字再丢掉加密群状态；重新生成设备也不能解开旧状态。依据：`ios-app/Sources/Features/Chat/ChatCryptoDevice.swift:553-605`。

## 正常与失败例

正常：甲设备创建preparing，提交C1与发给乙的W1；乙验证W1、保存并ack；甲activate成功后提交A1，响应丢失则沿用同一clientId与同一密文重试，服务端返回原sequence。序号可能为101、104、109，不要求连续。

失败：乙尚未ack Welcome就activate，得到409 AWAITING_WELCOME；换一个已经注册的deviceId却使用不同签名密钥，得到409 DEVICE_KEY_CHANGED；成员变化后直接继续application，得到409 NOT_ACTIVE；把base64编码的普通JSON冒充MLS payload会INVALID_EVENT。错误并不授权回退text/media明文路径。

本页仅说明源码协议。iOS主ChatStore目前只准备包、实际send仍普通messages；Android未接入同等MLS传输；普通文件、新事、位置和音视频均不能据此声明已使用MLS。详见[消息与媒体](https://document.thestarline.cn/app/messaging-and-media.md)、[本地数据格式](https://document.thestarline.cn/app/local-data-formats.md)、[安全和隐私](https://document.thestarline.cn/app/security-and-privacy.md)与[验收矩阵](https://document.thestarline.cn/app/qa-matrix.md)。

源码核对范围：2026-10-23 的当前实现。本轮未执行生产业务请求或原生设备验收。
