From 7e130f98e84b3433e7b502bd107875590388dc16 Mon Sep 17 00:00:00 2001 From: Sivert Date: Mon, 28 Sep 2026 09:37:16 +0200 Subject: [PATCH] Server API: KeyPackage checks and the line for apps from before MLS The MLS section gets what three server PRs change. KeyPackages last 30 days and what an upload is refused for (GRYT-1509, GRYT-1510). And the system line the server writes for apps from before MLS, with the mls_placeholder field newer apps use to hide it, and placeholder: false on mls:send (GRYT-1508). Co-Authored-By: Claude Opus 5.5 --- content/docs/build/server-api.mdx | 54 +++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/content/docs/build/server-api.mdx b/content/docs/build/server-api.mdx index 5640f2b..b4aa012 100644 --- a/content/docs/build/server-api.mdx +++ b/content/docs/build/server-api.mdx @@ -263,13 +263,13 @@ MLS bytes go as binary both ways. | Event | Direction | Notes | |-------|-----------|-------| -| `mls:keypackages:publish` | client → server | `{ deviceId, keyPackages, lastResort? }`. The first publish registers the device. Five devices per member | -| `mls:keypackages:claim` | client → server | `{ conversationId, deviceId, devices? }`. One KeyPackage for each device, and each is handed out once | +| `mls:keypackages:publish` | client → server | `{ deviceId, keyPackages, lastResort? }`. The first publish registers the device. Five devices per member. See [KeyPackages](#keypackages) for what gets refused | +| `mls:keypackages:claim` | client → server | `{ conversationId, deviceId, devices? }`. One KeyPackage for each device, and each is handed out once. Expired ones never are | | `mls:devices` | client → server | `{ conversationId? }`. Your own devices, or everybody's in the conversation | | `mls:device:remove` | client → server | `{ deviceId }`, one of your own | | `mls:group:create` | client → server | `{ conversationId, groupId }`. The first group for a conversation wins, and the other side gets `group_exists` | | `mls:commit` | client → server | `{ conversationId, deviceId, commit, welcome? }`. One commit per epoch. A commit for any other epoch gets `stale_epoch` with the current one | -| `mls:send` | client → server | `{ conversationId, deviceId, message }`. Application messages and proposals | +| `mls:send` | client → server | `{ conversationId, deviceId, message, placeholder? }`. Application messages and proposals. Pass `placeholder: false` for anything that isn't a message, like a reaction, an edit or a delete (see [Apps from before MLS](#apps-from-before-mls)) | | `mls:log:fetch` | client → server | `{ conversationId, after, limit? }`. The log after a cursor. `gap` means some of what you missed has already been dropped | | `mls:sync` | client → server | `{ deviceId }`. Your groups, your waiting Welcomes, and how many KeyPackages you have left | | `mls:welcome:ack` | client → server | `{ deviceId, welcomeIds }`, once a Welcome is saved | @@ -280,6 +280,54 @@ MLS bytes go as binary both ways. The server keeps the log for 30 days, or fewer if whoever runs it sets `MLS_RETENTION_DAYS`. +#### KeyPackages + +A KeyPackage lasts 30 days, from `notBefore` to `notAfter`, and both ends count. +The server checks that against its own clock. On upload it refuses a package that: + +- isn't signed by its own leaf key (`invalid_key_package`) +- doesn't carry a device certificate its person key signed for that leaf key + (`invalid_device_certificate`) +- has a certificate naming a different device from the `deviceId` it's uploaded + under (`device_mismatch`) +- claims more than 30 days (`key_package_lifetime`), has expired + (`key_package_expired`), or hasn't started yet (`key_package_not_yet_valid`) + +The three lifetime refusals include `serverTime`, in seconds, so a device whose +clock is off can make its packages again against the server's time. + +The server doesn't check the certificate's scope, or whose person key signed it. +Clients check both. + +A claim only hands out a package with at least an hour left, and the count in +`mls:sync` leaves out the ones that don't qualify, so a device knows to top up. +Expired packages are pruned every hour. + +For now the server also takes ts-mls's default lifetime (0 to 2^63-1), which +@gryt/crypto 0.6.0 and older write. It keeps those for 30 days from upload. + +#### Apps from before MLS + +An app from before MLS doesn't know `mls:message`. So for every application +message, the server also writes a line into the DM for those apps, from the +`system` sender: + +> @Kari sent an end-to-end encrypted message. Update Gryt to read it. + +It arrives as an ordinary `chat:new`, and it's in the history, with one extra +field: + +```ts +mls_placeholder: { seq: number; sender_server_id: string } +``` + +`seq` is the entry in the group's log that the line stands in for, and +`sender_server_id` is who really sent it. An app that reads MLS should drop any +message with `mls_placeholder`, and count unread from `mls:message` instead. + +The sender's own sockets don't get the line live. Their older apps see it in +the history. Nobody who blocked the sender gets it. + ### Calls Ringing only. The call itself is voice, and goes through the events above.