# 消息、媒体与 MLS 数据流

星线 App 当前主聊天界面通过普通消息 API 发送文字或媒体引用。仓库另外实现了 Rust MLS 核心、服务端加密事件流和 iOS 独立加密传输器。本页分别说明实际消息路径与 MLS 协议，尤其明确附件是否经过客户端加密，避免把已有加密模块扩大描述为所有聊天数据已经端到端加密。

## 当前普通消息路径

客户端在单聊调用 `/conversations/{id}/messages`，在群聊调用 `/groups/{id}/messages`。请求必须包含合法 UUID `clientId`，并在非空 `text` 和一个 `mediaId` 中二选一。文字会 trim，最多 2000 个 Unicode 码点。消息写入后返回 `{message}`；首次写入为 201，完全相同的重试为 200。

客户端应为一次用户发送动作生成一次 clientId，失败重试沿用该 ID。服务端不仅检查 ID，还检查范围、正文和媒体是否一致；复用 ID 发送不同内容得到 409 `CLIENT_ID_REUSED`。客户端可先展示 sending，再按回执或同步结果合并，网络失败展示 failed 并提供重试。HTTP 成功代表服务端已接受，不能单凭它显示“对方已读”。

单聊发送要求当前仍是好友且双方均未屏蔽；群发送要求当前是成员。普通消息表保存服务端可读正文和媒体引用。当前 iOS 主 `ChatStore` 与 Android 发送路径均使用这些接口；注册 MLS 密钥包不会自动改变这条链路。

## 分页、已读与删除

单聊列表支持 `after` 增量或 `before` 旧消息分页，非零 after 不能与 before 同时使用；群消息只支持 before。默认 50 条、最多 100 条，返回顺序为时间线升序。最新页和 before 页内部从数据库倒序取数后再反转，因此界面不需要额外反转。第一页不要传 `before=0` 代替“没有游标”。

已读接口发送 `{lastMessageId}`，必须指向当前范围的实际消息，已读位置只前进。单聊回执使用对方 `peerReadMessageId`，群的已读信息按群模型处理。删除消息只对当前用户隐藏；删除聊天还会推进个人历史可见边界。恢复聊天不会恢复已越过的旧历史，也不会向其他参与者发起撤回。

## 附件实际上传与访问

先用原始二进制请求体调用 `POST /media?kind=file&name=...`，成功返回 `{media:{id,kind,name,mimeType,size,createdAt}}`，再把 mediaId 放入消息发送请求。请求体不是 multipart，也不是 base64 JSON。当前实现直接保存上传字节；客户端未生成独立附件密钥，未先把照片或文件封装为密文，也没有下载后的附件解密步骤。因此不能将这些附件称为 MLS 加密附件。

| 类型 | 单项限制 | 其他限制 |
| --- | --- | --- |
| 普通文件、聊天照片 | 10 MiB，即 `10*1024*1024` 字节 | 非空；MIME 需符合类型格式 |
| 自定义贴纸 | 512 KiB | 有效 PNG/JPEG；PNG 宽高均为 1–2048；每账号最多 100 个自定义贴纸 |
| 聊天媒体总量 | 每账号 100 MiB | 普通文件与贴纸共同计入字节配额 |
| 文件名 | 最多 120 个 Unicode 码点 | 禁止控制字符、斜杠与反斜杠 |

`GET /media/{id}` 要求 Bearer token。所有者可下载，其他用户必须能看到引用该媒体的消息，且不能越过个人历史边界或单条隐藏记录。返回 `Content-Disposition: attachment`、`nosniff` 和私有缓存头。当前普通附件下载不实现 Range 分片。UUID 不是访问许可。普通单聊媒体的可见消息授权分支不重新检查好友或屏蔽，因此删好友、屏蔽不等于撤销全部历史媒体访问；群消息授权分支要求当前成员，媒体所有者分支则独立存在。依据：`chat-service/src/database.mjs:239-254`。

iOS 上传照片会先缩放到最长边不超过 2048 像素并转 JPEG，普通文件检查是否为非空规则文件及大小；这属于客户端预处理，不改变服务端可读性。城市贴纸通过 `/stickers/city` 选择服务器内置素材，按账号与素材键去重；`GET /stickers` 列出自定义贴纸，不包含所有内置城市贴纸。

## 新事媒体使用另一套契约

新事图片允许 JPEG/PNG，最大 10 MiB；视频允许 MP4/MOV，最大 30 MiB，检查 `ftyp` 文件标识。每账号新事媒体配额为 500 MiB。一个帖子最多 9 张图片，或仅 1 个视频，不能混发图片和视频。媒体必须属于当前用户、尚未绑定其他帖子。

新事媒体读取依据帖子可见性，支持单个 `bytes=start-end` Range，返回 206 或 416；未绑定帖子的媒体仅所有者可读，超过 24 小时会清理。新事图文与评论保存在服务端，不属于 MLS 聊天事件流。

## MLS 设备与会话建立

加密核心使用 MLS 1.0 和固定套件 `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519`。设备身份包含聊天用户 ID、设备 ID 和签名公钥，密钥包必须通过 OpenMLS 签名、有效性及套件检查，并与已认证用户和路径设备匹配。相同设备不能静默替换签名公钥，每设备最多保存 25 个未使用密钥包。

1. 设备注册密钥包，服务端保存可验证的公钥材料；私钥留在设备。
2. 范围当前是 legacy 时，创建者调用 prepare。每位当前参与者至少要有一个已注册设备，范围变为 preparing。
3. 创建者在本机创建 MLS 群，按设备获取可用密钥包，生成 Commit 与定向 Welcome，使用不同 clientId 依次提交。
4. 目标设备处理 Welcome、核对邀请者身份并固定签名公钥，完成本地持久化后提交 welcome-ack；已有设备处理后续 Commit 并提交 commit-ack。
5. 创建者 activate。服务端确认当前所有设备已加入并处理所需 Commit，范围才变为 active。
6. active 范围接受 application 密文事件。客户端处理 MLS 消息，核对加密层认证的发送用户、设备与服务端元数据一致，然后解析应用载荷。

密钥包 TLS 字节最大 65,536 字节，事件 MLS payload 最大 1,048,576 字节，使用规范标准 base64 置于 JSON。事件 JSON 请求体上限为 1,500,000 字节。iOS 独立文字加密载荷为 version=1、kind=text、clientID、text，文字上限另为 65,536 UTF-8 字节；这不等于普通文字接口的 2000 字限制，也不构成大附件传输方案。

## 模式、重试与恢复

一旦存在 preparing、active 或 blocked 范围，旧 POST messages 接口就返回 `E2EE_REQUIRED`，防止改走明文。注册新设备、群成员变化、删除好友或屏蔽可使已存在加密范围 blocked；完成新的成员 Commit、Welcome 和必要确认后，创建者才可再次 activate。不要把等待设备上线解释成消息已经交付。

服务端对每个发送用户、设备、clientId 去重，并比较密文摘要、类型和目标。iOS 把待发送密文、pending invite、ack 和 sequence 连同 MLS 状态保存，重试时使用原密文，不重新加密同一排队消息。独立传输器存在不代表主聊天界面已完成接入；Android 当前未见相应 MLS 发送链路。

当前不能宣称所有文字、附件、新事、实时位置或音视频均已端到端加密，也不能宣称已完成生产安全审计。接口细节见[API 参考](https://document.thestarline.cn/app/api-reference.md)，密钥与威胁边界见[安全和隐私](https://document.thestarline.cn/app/security-and-privacy.md)。

密钥包claim、设备确认、非连续sequence与blocked恢复的详细次序见[MLS协议](https://document.thestarline.cn/app/mls-protocol.md)；重试边界见[限制与错误](https://document.thestarline.cn/app/limits-and-errors.md)。
