Skip to content

feat(backend): mls_key_packages schema with upload and fetch endpoints - #530

Open
Olorunfemi20 wants to merge 1 commit into
codebestia:mainfrom
Olorunfemi20:feat/mls-key-packages
Open

feat(backend): mls_key_packages schema with upload and fetch endpoints#530
Olorunfemi20 wants to merge 1 commit into
codebestia:mainfrom
Olorunfemi20:feat/mls-key-packages

Conversation

@Olorunfemi20

Copy link
Copy Markdown
Contributor

closes #365

Summary

Devices can now publish public MLS KeyPackages ahead of time, so any existing group member can add a device to a group without that device being online. This adds the mls_key_packages table, the upload and claim endpoints, and the replenishment signal.

Data model

New table mls_key_packages (migration drizzle/0001_mls_key_packages.sql, generated by drizzle-kit with its meta snapshot):

Column Type Notes
id uuid Primary key
device_id uuid FK to devices.id, ON DELETE CASCADE
cipher_suite integer IANA MLS cipher suite id
key_package text Base64 TLS-serialised KeyPackage, public material only
package_hash text SHA-256 hex of key_package, dedupe key
expires_at timestamp Nullable, meaning "does not expire"
consumed boolean Flipped on hand-out, never deleted
consumed_at timestamp When the package was claimed
created_at timestamp Claims are FIFO on this column

Two notes on the schema:

  • The issue specified deviceId -> user_devices.id. That table no longer exists on mainfeat: conversation mute/archive per-user settings #103/feat: add reusable Avatar component #107 merged user_devices into the canonical devices registry, which is what messages.senderDeviceId, message_envelopes.recipientDeviceId and push_subscriptions.deviceId all reference now. The FK points at devices.id to stay consistent with the rest of the schema.
  • The unique index for dedupe is on (device_id, package_hash) rather than on the package itself. A KeyPackage can be up to 4 KiB, which exceeds the btree index row limit, so the SHA-256 is the conflict target.

Only public material is stored. The HPKE init private key and the signature private key stay on the device.

Endpoints

POST /devices/:id/mls-key-packages

Batch upload, owner-only, rejects revoked devices.

{
  "cipherSuite": 1,
  "keyPackages": [
    { "keyPackage": "base64-..." },
    { "keyPackage": "base64-...", "expiresAt": "2026-12-31T00:00:00.000Z" }
  ]
}

Response:

{ "uploaded": 2, "duplicates": 0, "capped": false, "remaining": 2 }

Server-side validation:

  • keyPackage is routed through the existing MlsKeyPackageSchema in src/lib/keys.ts (valid base64 decoding to 32-4096 bytes). That schema was already written and carried a comment saying no endpoint consumed it yet; this PR wires it up and updates the comment.
  • expiresAt, when present, must parse as ISO-8601 and be in the future. An already-expired package would only create inventory that can never be claimed.
  • Batch size is 1-100 entries, and the per-device inventory of unconsumed packages is capped at 100. At the cap the request is rejected with 422; below it, an oversize batch is trimmed to the remaining slots and capped: true is returned, matching how the one-time prekey endpoint behaves.
  • Duplicates are collapsed within the batch and again at the database level via ON CONFLICT DO NOTHING, so retrying a request that timed out mid-flight reports duplicates instead of inflating the inventory.

GET /users/:userId/devices/:deviceId/mls-key-package

Claims exactly one package. :deviceId must belong to :userId and must not be revoked, otherwise 404 — the same narrowing the existing key-bundle route uses so a caller cannot probe for devices without knowing who owns them.

Optional ?cipherSuite=<n> restricts the claim, since a group can only add a device whose package matches the group's cipher suite.

{
  "deviceId": "uuid",
  "cipherSuite": 1,
  "keyPackage": "base64-...",
  "expiresAt": null,
  "remaining": 41
}

The claim is atomic. SELECT ... FOR UPDATE SKIP LOCKED inside a transaction picks the oldest available row and flips consumed before returning, so two members adding the same device concurrently receive two different packages. This matters beyond bookkeeping: MLS treats a KeyPackage as single-use, and reusing one across two Add proposals would let a compromise of one group's state compromise the other's. Exhaustion therefore returns 409 rather than re-issuing a used package.

consumed is a flag rather than a delete, so the record of which packages were issued stays auditable — the same choice device_prekeys makes.

Replenishment

Every claim recounts the device's available stock. At 10 or fewer remaining, the server emits mls_key_packages_low with { deviceId, remaining, threshold, cap } to two rooms:

  • room:device:<deviceId> so the device tops up immediately if connected
  • room:user:<userId> so a sibling device can surface the prompt when the low device is offline

Exhaustion (remaining: 0) is below the watermark by definition, so a 409 also carries the signal.

Tests

src/__tests__/mlsKeyPackages.test.ts — 21 cases covering ownership and revocation rejection, base64 and size validation at both ends of the MLS window, expired-package rejection, in-batch and cross-request dedupe, the cap and batch trimming, atomic claim with consumed flipped rather than the row deleted, the watermark boundary in both directions, and the exhausted-device path.

Full backend suite passes apart from three files that already fail on main because web-push, socket.io-client and ioredis-mock are not installed in the local workspace. eslint src reports no new problems, and the new files are prettier-clean.

Docs

apps/backend/docs/mls-key-packages.md documents the data model, both endpoints with their full error tables, why single-use is enforced the way it is, the replenishment contract, and the client sequence.

@
feat(backend): mls_key_packages schema and upload/fetch endpoints

Devices publish public MLS KeyPackages up front so any group member can
add them to a group while they are offline.

- mls_key_packages table keyed on devices.id, storing only public key
  material, with a partial index for claiming the next available package
  and a (device_id, package_hash) unique index so re-uploads dedupe
- POST /devices/:id/mls-key-packages uploads a batch, owner-only,
  validating base64/size via the shared key validator, rejecting expired
  packages and capping the per-device inventory at 100
- GET /users/:userId/devices/:deviceId/mls-key-package claims exactly one
  package inside a transaction using FOR UPDATE SKIP LOCKED, flipping
  consumed so a package is never handed out twice; 409 on exhaustion
- emits mls_key_packages_low to the device and user rooms once the
  remaining stock reaches the low-water mark, mirroring prekey
  replenishment

Adds docs/mls-key-packages.md covering the data model, both endpoints,
the single-use guarantee and the replenishment contract.
@
@drips-wave

drips-wave Bot commented Jul 30, 2026

Copy link
Copy Markdown

@Olorunfemi20 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mls_key_packages schema + upload/fetch endpoints

1 participant