diff --git a/apps/backend/docs/mls-key-packages.md b/apps/backend/docs/mls-key-packages.md new file mode 100644 index 0000000..e5d22f0 --- /dev/null +++ b/apps/backend/docs/mls-key-packages.md @@ -0,0 +1,206 @@ +# MLS key packages + +Group messaging uses MLS (RFC 9420). Before a device can be added to a group it +must have published at least one **KeyPackage** — a signed bundle containing its +credential, its HPKE init public key, and the cipher suite it supports. Any +existing group member can then build an `Add` proposal for that device without +the device being online. + +This document describes the backend surface for publishing and claiming those +key packages (issue #365). + +## What the server stores + +Table: `mls_key_packages` + +| Column | Type | Notes | +| -------------- | ----------- | --------------------------------------------------------- | +| `id` | `uuid` | Primary key. | +| `device_id` | `uuid` | FK → `devices.id`, `ON DELETE CASCADE`. | +| `cipher_suite` | `integer` | IANA MLS cipher suite id the package was generated for. | +| `key_package` | `text` | Base64 of the TLS-serialised KeyPackage. Public material. | +| `package_hash` | `text` | SHA-256 (hex) of `key_package` — dedupe key. | +| `expires_at` | `timestamp` | Optional. Nullable means "does not expire". | +| `consumed` | `boolean` | Flipped when the package is handed out. Never deleted. | +| `consumed_at` | `timestamp` | When the package was claimed. | +| `created_at` | `timestamp` | Publication time; claims are FIFO on this column. | + +Indexes: + +- `mls_key_packages_device_hash_idx` — unique on `(device_id, package_hash)`. + Makes re-uploading a package a no-op instead of a duplicate row. The hash is + the conflict target rather than `key_package` itself because a KeyPackage can + be up to 4 KiB, which exceeds the btree index row limit. +- `mls_key_packages_available_idx` — partial index on + `(device_id, cipher_suite, created_at) WHERE consumed = false`, so claiming + the next package is a single index lookup. + +### Only public material is stored + +A KeyPackage contains no secrets. The HPKE init private key and the signature +private key that correspond to it stay on the device and are never uploaded. A +compromise of `mls_key_packages` reveals which devices exist and which cipher +suites they support — the same information `GET /devices` already exposes to the +owner — and nothing that lets an attacker read group messages. + +### Single-use, and why it is enforced + +MLS treats a KeyPackage as single-use. If the same package were consumed by two +different `Add` proposals, the joining device would derive its initial secrets +from the same init key in both groups, so compromising one group's state would +compromise the other's. The fetch endpoint therefore claims a package inside a +transaction using `SELECT ... FOR UPDATE SKIP LOCKED` and flips `consumed` +before returning: two members adding the same device at the same moment receive +two different packages, and an exhausted device returns `409` rather than a +package that has already been handed out. + +`consumed` is a flag rather than a delete so the history of which packages were +issued stays auditable — the same choice `device_prekeys` makes for one-time +prekeys. + +## Endpoints + +Both endpoints require `Authorization: Bearer `. + +### POST /devices/:id/mls-key-packages + +Publishes a batch of key packages for a device. `:id` must be a device owned by +the authenticated user. + +Request: + +```json +{ + "cipherSuite": 1, + "keyPackages": [ + { "keyPackage": "base64-tls-serialised-keypackage" }, + { "keyPackage": "base64-...", "expiresAt": "2026-12-31T00:00:00.000Z" } + ] +} +``` + +- `cipherSuite` — positive integer, applied to every package in the batch. + Publish separate batches to support several suites. +- `keyPackages` — 1 to 100 entries. +- `keyPackage` — base64, decoding to 32–4096 bytes (validated by + `src/lib/keys.ts`). +- `expiresAt` — optional ISO-8601 timestamp, must be in the future. + +Response `200`: + +```json +{ "uploaded": 2, "duplicates": 0, "capped": false, "remaining": 2 } +``` + +- `uploaded` — rows actually inserted. +- `duplicates` — packages the device had already published (dropped by the + unique index, or collapsed within the batch itself). +- `capped` — true when the batch was trimmed to fit the per-device cap. +- `remaining` — unconsumed, unexpired packages the device now holds. + +Uploads are idempotent: retrying a request that timed out mid-flight re-sends +the same packages and reports them as `duplicates` instead of inflating the +inventory. + +Errors: + +| Status | Body | When | +| ------ | ------------------------------------------------------------------ | ----------------------------------------------------- | +| `400` | `{ "error": "Validation failed", "issues": [...] }` | Malformed base64, wrong size, empty or oversize batch | +| `400` | `{ "error": "expiresAt must be in the future" }` | A package in the batch is already expired | +| `403` | `{ "error": "Only the device owner may upload MLS key packages" }` | Caller does not own `:id` | +| `403` | `{ "error": "Device is revoked" }` | Device has a `revokedAt` | +| `404` | `{ "error": "Device not found" }` | No such device | +| `422` | `{ "error": "MLS key package cap of 100 reached..." }` | Device already holds 100 unconsumed packages | + +### GET /users/:userId/devices/:deviceId/mls-key-package + +Claims exactly one key package so the caller can put it in an `Add` proposal. +`:deviceId` must belong to `:userId` and must not be revoked, otherwise the +route returns `404` — a caller cannot probe for devices without already knowing +who owns them. + +Optional query parameter: + +- `cipherSuite` — positive integer. Restricts the claim to packages published + for that suite. A group can only add a device whose package matches the + group's cipher suite, so callers adding to an existing group should always + pass it. + +Response `200`: + +```json +{ + "deviceId": "uuid", + "cipherSuite": 1, + "keyPackage": "base64-tls-serialised-keypackage", + "expiresAt": null, + "remaining": 41 +} +``` + +Response `409` when the device has no packages left: + +```json +{ "error": "No MLS key packages available for this device", "remaining": 0 } +``` + +A `409` is not a permanent failure. The target device replenishes when it next +comes online (see below); the caller should retry then, or add the remaining +devices and re-run the `Add` for this one later. + +## Replenishment + +Every claim recounts the device's available stock. When that count drops to +`10` or fewer, the server emits: + +```text +event: mls_key_packages_low +payload: { deviceId, remaining, threshold: 10, cap: 100 } +``` + +to two rooms: + +- `room:device:` — the device itself, so it uploads a fresh batch + immediately if it is connected. +- `room:user:` — the owner's other devices, so the prompt can be + surfaced even when the low device is offline. + +This mirrors the one-time prekey inventory: a device that reaches zero cannot be +added to any new group until it comes back online, so clients should treat the +signal as a cue to top back up to the cap rather than waiting for exhaustion. + +Clients that miss the signal can also read the current count from the +`remaining` field of their own upload responses. + +## Client sequence + +```text +Device A (joining) Backend Device B (adding A) + | | | + |-- POST /devices/:id | | + | /mls-key-packages ---------->| | + |<- { uploaded, remaining } -----| | + | | | + | |<- GET /users/:u/devices/:d --| + | | /mls-key-package?cipherSuite=1 + | |-- claim + mark consumed ---->| + | | { keyPackage } ----------->| + | | | + | | builds Add proposal + Commit + | | sends Welcome to device A + |<- mls_key_packages_low --------| | + | (when stock <= 10) | | + |-- POST /devices/:id | | + | /mls-key-packages ---------->| | +``` + +## Implementation references + +- schema: `apps/backend/src/db/schema.ts` (`mlsKeyPackages`) +- migration: `apps/backend/drizzle/0001_mls_key_packages.sql` +- claim + replenishment: `apps/backend/src/services/mlsKeyPackages.ts` +- upload endpoint: `apps/backend/src/routes/devices.ts` +- fetch endpoint: `apps/backend/src/routes/users.ts` +- key material validation: `apps/backend/src/lib/keys.ts` +- tests: `apps/backend/src/__tests__/mlsKeyPackages.test.ts` diff --git a/apps/backend/drizzle/0001_mls_key_packages.sql b/apps/backend/drizzle/0001_mls_key_packages.sql new file mode 100644 index 0000000..ceeb6ec --- /dev/null +++ b/apps/backend/drizzle/0001_mls_key_packages.sql @@ -0,0 +1,15 @@ +CREATE TABLE "mls_key_packages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "device_id" uuid NOT NULL, + "cipher_suite" integer NOT NULL, + "key_package" text NOT NULL, + "package_hash" text NOT NULL, + "expires_at" timestamp, + "consumed" boolean DEFAULT false NOT NULL, + "consumed_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "mls_key_packages" ADD CONSTRAINT "mls_key_packages_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "mls_key_packages_device_hash_idx" ON "mls_key_packages" USING btree ("device_id","package_hash");--> statement-breakpoint +CREATE INDEX "mls_key_packages_available_idx" ON "mls_key_packages" USING btree ("device_id","cipher_suite","created_at") WHERE "mls_key_packages"."consumed" = false; \ No newline at end of file diff --git a/apps/backend/drizzle/meta/0001_snapshot.json b/apps/backend/drizzle/meta/0001_snapshot.json index 40c99ab..b6da186 100644 --- a/apps/backend/drizzle/meta/0001_snapshot.json +++ b/apps/backend/drizzle/meta/0001_snapshot.json @@ -1,4 +1,5 @@ { + "id": "37b0d0bc-ef02-4702-9b83-e32f9a13938a", "id": "9ea97ad2-6999-44b6-a433-e809d3209adf", "prevId": "d5682005-cccf-4e2e-992d-d66a5d6d3f4c", "version": "7", @@ -1020,6 +1021,141 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mls_key_packages": { + "name": "mls_key_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cipher_suite": { + "name": "cipher_suite", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key_package": { + "name": "key_package", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_hash": { + "name": "package_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consumed": { + "name": "consumed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mls_key_packages_device_hash_idx": { + "name": "mls_key_packages_device_hash_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "package_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mls_key_packages_available_idx": { + "name": "mls_key_packages_available_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cipher_suite", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mls_key_packages\".\"consumed\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mls_key_packages_device_id_devices_id_fk": { + "name": "mls_key_packages_device_id_devices_id_fk", + "tableFrom": "mls_key_packages", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, "checkConstraints": { "messages_system_payload_only_on_system_type": { "name": "messages_system_payload_only_on_system_type", diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index 347a106..8bb5a41 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -12,6 +12,8 @@ { "idx": 1, "version": "7", + "when": 1785395076224, + "tag": "0001_mls_key_packages", "when": 1785129818340, "tag": "0001_add_system_payload_to_messages", "breakpoints": true diff --git a/apps/backend/src/__tests__/mlsKeyPackages.test.ts b/apps/backend/src/__tests__/mlsKeyPackages.test.ts new file mode 100644 index 0000000..077c221 --- /dev/null +++ b/apps/backend/src/__tests__/mlsKeyPackages.test.ts @@ -0,0 +1,433 @@ +/** + * Tests for the MLS key package endpoints (#365). + * + * POST /devices/:id/mls-key-packages + * GET /users/:userId/devices/:deviceId/mls-key-package + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockDeviceFindFirst = vi.fn(); +const mockSelect = vi.fn(); +const mockInsert = vi.fn(); +const mockTransaction = vi.fn(); +const mockEmit = vi.fn(); +const mockTo = vi.fn(() => ({ emit: mockEmit })); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + devices: { findFirst: mockDeviceFindFirst }, + devicePrekeys: { findFirst: vi.fn() }, + users: { findFirst: vi.fn() }, + conversationMembers: { findMany: vi.fn().mockResolvedValue([]) }, + }, + select: mockSelect, + insert: mockInsert, + update: vi.fn(), + delete: vi.fn(), + transaction: mockTransaction, + }, +})); + +vi.mock('../db/schema.js', () => ({ + users: {}, + wallets: {}, + devices: { id: 'id', userId: 'userId', revokedAt: 'revokedAt' }, + devicePrekeys: {}, + conversationMembers: {}, + messages: {}, + mlsKeyPackages: { + id: 'id', + deviceId: 'deviceId', + cipherSuite: 'cipherSuite', + keyPackage: 'keyPackage', + packageHash: 'packageHash', + expiresAt: 'expiresAt', + consumed: 'consumed', + consumedAt: 'consumedAt', + createdAt: 'createdAt', + }, +})); + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((col: unknown, val: unknown) => ({ op: 'eq', col, val })), + and: vi.fn((...args: unknown[]) => ({ op: 'and', args })), + or: vi.fn((...args: unknown[]) => ({ op: 'or', args })), + ne: vi.fn(), + gt: vi.fn((col: unknown, val: unknown) => ({ op: 'gt', col, val })), + asc: vi.fn((col: unknown) => col), + desc: vi.fn((col: unknown) => col), + count: vi.fn(() => 'count(*)'), + isNull: vi.fn((col: unknown) => ({ op: 'isNull', col })), + inArray: vi.fn(), + ilike: vi.fn(), + exists: vi.fn(), + sql: vi.fn(), +})); + +vi.mock('../lib/redis.js', () => ({ redis: null })); +vi.mock('../lib/socket.js', () => ({ getSocketServer: vi.fn(() => ({ to: mockTo })) })); +vi.mock('../lib/conversationCache.js', () => ({ invalidateConversationCaches: vi.fn() })); +vi.mock('../services/deviceRevocation.js', () => ({ markDeviceRevoked: vi.fn() })); +vi.mock('../services/presence.js', () => ({ + isOnline: vi.fn().mockResolvedValue(false), + deriveDevicePresence: vi.fn(), +})); +vi.mock('../services/deliveryPipeline.js', () => ({ + deviceRoom: (id: string) => `room:device:${id}`, +})); + +vi.mock('../middleware/auth.js', () => ({ + requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { + (req as express.Request & { auth: { userId: string; deviceId: string } }).auth = { + userId: OWNER_ID, + deviceId: DEVICE_ID, + }; + next(); + }, +})); + +const OWNER_ID = 'owner-user-id'; +const DEVICE_ID = 'device-1'; + +const { devicesRouter } = await import('../routes/devices.js'); +const { usersRouter } = await import('../routes/users.js'); +const { MLS_KEY_PACKAGE_CAP, MLS_KEY_PACKAGE_LOW_WATERMARK } = + await import('../services/mlsKeyPackages.js'); + +function makeApp() { + const app = express(); + app.use(express.json()); + app.use('/devices', devicesRouter); + app.use('/users', usersRouter); + return app; +} + +/** Valid base64 for a `bytes`-long payload (within the 32–4096 byte window). */ +function keyPackageOf(bytes: number, fill = 'A'): string { + return Buffer.alloc(bytes, fill).toString('base64'); +} + +const PACKAGE_A = keyPackageOf(128, 'a'); +const PACKAGE_B = keyPackageOf(128, 'b'); + +const ACTIVE_DEVICE = { id: DEVICE_ID, userId: OWNER_ID, revokedAt: null }; + +/** `db.select().from().where()` used by countAvailableKeyPackages. */ +function setupAvailableCount(...totals: number[]) { + mockSelect.mockReset(); + for (const total of totals) { + mockSelect.mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ total }]), + }), + }); + } +} + +/** `db.insert().values().onConflictDoNothing().returning()`. */ +function setupInsert(insertedIds: string[]) { + const returning = vi.fn().mockResolvedValue(insertedIds.map((id) => ({ id }))); + const onConflictDoNothing = vi.fn().mockReturnValue({ returning }); + const values = vi.fn().mockReturnValue({ onConflictDoNothing }); + mockInsert.mockReturnValue({ values }); + return { values, returning }; +} + +/** `db.transaction()` used by claimKeyPackage. */ +function setupClaim(candidate: Record | null) { + const updateWhere = vi.fn().mockResolvedValue(undefined); + const tx = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + for: vi.fn().mockResolvedValue(candidate ? [candidate] : []), + }), + }), + }), + }), + }), + update: vi.fn().mockReturnValue({ set: vi.fn().mockReturnValue({ where: updateWhere }) }), + }; + mockTransaction.mockImplementation(async (cb: (t: typeof tx) => unknown) => cb(tx)); + return { tx, updateWhere }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockTo.mockReturnValue({ emit: mockEmit }); +}); + +// ── Upload ──────────────────────────────────────────────────────────────────── + +describe('POST /devices/:id/mls-key-packages', () => { + const body = { cipherSuite: 1, keyPackages: [{ keyPackage: PACKAGE_A }] }; + + it('returns 404 when the device does not exist', async () => { + mockDeviceFindFirst.mockResolvedValue(undefined); + + const res = await request(makeApp()).post('/devices/nope/mls-key-packages').send(body); + + expect(res.status).toBe(404); + }); + + it('returns 403 when the caller is not the device owner', async () => { + mockDeviceFindFirst.mockResolvedValue({ ...ACTIVE_DEVICE, userId: 'someone-else' }); + + const res = await request(makeApp()).post(`/devices/${DEVICE_ID}/mls-key-packages`).send(body); + + expect(res.status).toBe(403); + expect(res.body.error).toMatch(/owner/i); + }); + + it('returns 403 when the device is revoked', async () => { + mockDeviceFindFirst.mockResolvedValue({ ...ACTIVE_DEVICE, revokedAt: new Date() }); + + const res = await request(makeApp()).post(`/devices/${DEVICE_ID}/mls-key-packages`).send(body); + + expect(res.status).toBe(403); + expect(res.body.error).toMatch(/revoked/i); + }); + + it('rejects a key package that is not valid base64', async () => { + const res = await request(makeApp()) + .post(`/devices/${DEVICE_ID}/mls-key-packages`) + .send({ cipherSuite: 1, keyPackages: [{ keyPackage: 'not base64!!' }] }); + + expect(res.status).toBe(400); + expect(JSON.stringify(res.body)).toMatch(/base64/i); + }); + + it('rejects a key package below the minimum MLS size', async () => { + const res = await request(makeApp()) + .post(`/devices/${DEVICE_ID}/mls-key-packages`) + .send({ cipherSuite: 1, keyPackages: [{ keyPackage: keyPackageOf(8) }] }); + + expect(res.status).toBe(400); + }); + + it('rejects a key package above the maximum MLS size', async () => { + const res = await request(makeApp()) + .post(`/devices/${DEVICE_ID}/mls-key-packages`) + .send({ cipherSuite: 1, keyPackages: [{ keyPackage: keyPackageOf(5000) }] }); + + expect(res.status).toBe(400); + }); + + it('rejects an empty batch', async () => { + const res = await request(makeApp()) + .post(`/devices/${DEVICE_ID}/mls-key-packages`) + .send({ cipherSuite: 1, keyPackages: [] }); + + expect(res.status).toBe(400); + }); + + it('rejects an already-expired key package', async () => { + mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); + + const res = await request(makeApp()) + .post(`/devices/${DEVICE_ID}/mls-key-packages`) + .send({ + cipherSuite: 1, + keyPackages: [{ keyPackage: PACKAGE_A, expiresAt: '2020-01-01T00:00:00.000Z' }], + }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/expiresAt/); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it('stores a batch and reports the resulting inventory', async () => { + mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); + setupAvailableCount(0); + const { values } = setupInsert(['row-a', 'row-b']); + + const res = await request(makeApp()) + .post(`/devices/${DEVICE_ID}/mls-key-packages`) + .send({ + cipherSuite: 1, + keyPackages: [{ keyPackage: PACKAGE_A }, { keyPackage: PACKAGE_B }], + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ uploaded: 2, duplicates: 0, capped: false, remaining: 2 }); + + // Only public material plus derived metadata is persisted. + const rows = values.mock.calls[0]![0] as Record[]; + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ deviceId: DEVICE_ID, cipherSuite: 1, keyPackage: PACKAGE_A }); + expect(rows[0]!['packageHash']).toMatch(/^[0-9a-f]{64}$/); + expect(rows[0]!['expiresAt']).toBeNull(); + }); + + it('collapses duplicates inside a single batch', async () => { + mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); + setupAvailableCount(0); + const { values } = setupInsert(['row-a']); + + const res = await request(makeApp()) + .post(`/devices/${DEVICE_ID}/mls-key-packages`) + .send({ + cipherSuite: 1, + keyPackages: [{ keyPackage: PACKAGE_A }, { keyPackage: PACKAGE_A }], + }); + + expect(res.status).toBe(200); + expect((values.mock.calls[0]![0] as unknown[]).length).toBe(1); + expect(res.body.uploaded).toBe(1); + }); + + it('reports packages the device had already published as duplicates', async () => { + mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); + setupAvailableCount(5); + // Two sent, one survives the ON CONFLICT DO NOTHING. + setupInsert(['row-b']); + + const res = await request(makeApp()) + .post(`/devices/${DEVICE_ID}/mls-key-packages`) + .send({ + cipherSuite: 1, + keyPackages: [{ keyPackage: PACKAGE_A }, { keyPackage: PACKAGE_B }], + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ uploaded: 1, duplicates: 1, capped: false, remaining: 6 }); + }); + + it('returns 422 when the per-device cap is already reached', async () => { + mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); + setupAvailableCount(MLS_KEY_PACKAGE_CAP); + + const res = await request(makeApp()).post(`/devices/${DEVICE_ID}/mls-key-packages`).send(body); + + expect(res.status).toBe(422); + expect(res.body.error).toMatch(/cap/i); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it('trims the batch to the remaining cap space', async () => { + mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); + setupAvailableCount(MLS_KEY_PACKAGE_CAP - 1); // one slot left + const { values } = setupInsert(['row-a']); + + const res = await request(makeApp()) + .post(`/devices/${DEVICE_ID}/mls-key-packages`) + .send({ + cipherSuite: 1, + keyPackages: [{ keyPackage: PACKAGE_A }, { keyPackage: PACKAGE_B }], + }); + + expect(res.status).toBe(200); + expect((values.mock.calls[0]![0] as unknown[]).length).toBe(1); + expect(res.body.capped).toBe(true); + expect(res.body.remaining).toBe(MLS_KEY_PACKAGE_CAP); + }); +}); + +// ── Fetch + consume ─────────────────────────────────────────────────────────── + +describe('GET /users/:userId/devices/:deviceId/mls-key-package', () => { + const url = `/users/${OWNER_ID}/devices/${DEVICE_ID}/mls-key-package`; + + it('returns 404 when the device does not exist', async () => { + mockDeviceFindFirst.mockResolvedValue(undefined); + + const res = await request(makeApp()).get(`/users/${OWNER_ID}/devices/nope/mls-key-package`); + + expect(res.status).toBe(404); + }); + + it('returns 404 when :userId does not own the device', async () => { + mockDeviceFindFirst.mockResolvedValue({ ...ACTIVE_DEVICE, userId: 'someone-else' }); + + const res = await request(makeApp()).get(url); + + expect(res.status).toBe(404); + }); + + it('returns 404 when the device is revoked', async () => { + mockDeviceFindFirst.mockResolvedValue({ ...ACTIVE_DEVICE, revokedAt: new Date() }); + + const res = await request(makeApp()).get(url); + + expect(res.status).toBe(404); + }); + + it('rejects a non-numeric cipherSuite filter', async () => { + const res = await request(makeApp()).get(`${url}?cipherSuite=abc`); + + expect(res.status).toBe(400); + }); + + it('claims one package and marks it consumed in the same transaction', async () => { + mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); + const { updateWhere } = setupClaim({ + id: 'kp-1', + cipherSuite: 1, + keyPackage: PACKAGE_A, + expiresAt: null, + }); + setupAvailableCount(MLS_KEY_PACKAGE_LOW_WATERMARK + 5); + + const res = await request(makeApp()).get(url); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + deviceId: DEVICE_ID, + cipherSuite: 1, + keyPackage: PACKAGE_A, + remaining: MLS_KEY_PACKAGE_LOW_WATERMARK + 5, + }); + // consumed flipped inside the claim transaction rather than the row deleted + expect(updateWhere).toHaveBeenCalled(); + }); + + it('does not emit a replenishment signal while stock is healthy', async () => { + mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); + setupClaim({ id: 'kp-1', cipherSuite: 1, keyPackage: PACKAGE_A, expiresAt: null }); + setupAvailableCount(MLS_KEY_PACKAGE_LOW_WATERMARK + 1); + + await request(makeApp()).get(url); + + expect(mockEmit).not.toHaveBeenCalled(); + }); + + it('emits mls_key_packages_low to the device and its owner at the watermark', async () => { + mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); + setupClaim({ id: 'kp-1', cipherSuite: 1, keyPackage: PACKAGE_A, expiresAt: null }); + setupAvailableCount(MLS_KEY_PACKAGE_LOW_WATERMARK); + + const res = await request(makeApp()).get(url); + + expect(res.status).toBe(200); + expect(mockTo).toHaveBeenCalledWith(`room:device:${DEVICE_ID}`); + expect(mockTo).toHaveBeenCalledWith(`room:user:${OWNER_ID}`); + expect(mockEmit).toHaveBeenCalledWith( + 'mls_key_packages_low', + expect.objectContaining({ + deviceId: DEVICE_ID, + remaining: MLS_KEY_PACKAGE_LOW_WATERMARK, + threshold: MLS_KEY_PACKAGE_LOW_WATERMARK, + }), + ); + }); + + it('returns 409 and signals replenishment when the device is exhausted', async () => { + mockDeviceFindFirst.mockResolvedValue(ACTIVE_DEVICE); + setupClaim(null); + setupAvailableCount(0); + + const res = await request(makeApp()).get(url); + + expect(res.status).toBe(409); + expect(res.body.remaining).toBe(0); + expect(mockEmit).toHaveBeenCalledWith('mls_key_packages_low', expect.anything()); + }); +}); diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 759ea8d..a135b84 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -286,6 +286,25 @@ export const devicePrekeys = pgTable( ], ); +// ─── MLS key packages (#365) ───────────────────────────────────────────────── +// +// Every device publishes a stock of MLS KeyPackages so any group member can add +// it to a group without the device being online. A KeyPackage is *public* key +// material only — the matching HPKE init private key never leaves the device, +// so nothing secret is stored here. +// +// A KeyPackage is single-use by spec: reusing one across two Add proposals +// breaks forward secrecy for the joining device. `consumed` therefore flips to +// true inside the same transaction that hands the package out (never deleted, +// so audit history survives), mirroring how one-time prekeys are claimed in +// `device_prekeys`. +// +// `packageHash` is the SHA-256 of the base64 package. It exists because the +// package itself can be up to 4 KiB — too large for a btree unique index — but +// idempotent re-uploads still need a conflict target for dedupe. + +export const mlsKeyPackages = pgTable( + 'mls_key_packages', // ─── Device key history (#379 — key-transparency) ──────────────────────────── // // Append-only log of identity-key changes per device. Written whenever a @@ -300,6 +319,27 @@ export const deviceKeyHistory = pgTable( deviceId: uuid('device_id') .notNull() .references(() => devices.id, { onDelete: 'cascade' }), + // IANA MLS cipher suite id (e.g. 1 = MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519). + // A device may publish packages for several suites; group adds must match. + cipherSuite: integer('cipher_suite').notNull(), + // Base64 of the TLS-serialised MLS KeyPackage. Public material only. + keyPackage: text('key_package').notNull(), + // SHA-256 (hex) of `keyPackage` — dedupe key, see note above. + packageHash: text('package_hash').notNull(), + expiresAt: timestamp('expires_at'), + consumed: boolean('consumed').notNull().default(false), + consumedAt: timestamp('consumed_at'), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('mls_key_packages_device_hash_idx').on(table.deviceId, table.packageHash), + // Fast claim of the next available package for a device + suite. + index('mls_key_packages_available_idx') + .on(table.deviceId, table.cipherSuite, table.createdAt) + .where(sql`${table.consumed} = false`), + ], +); + userId: uuid('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), diff --git a/apps/backend/src/lib/keys.ts b/apps/backend/src/lib/keys.ts index 45c60ed..ea0099f 100644 --- a/apps/backend/src/lib/keys.ts +++ b/apps/backend/src/lib/keys.ts @@ -83,8 +83,10 @@ export const SignatureSchema = z .superRefine(b64LengthRefinement(ED25519_SIG_BYTES, 'signature')); /** - * No endpoint currently accepts MLS key packages — this schema exists so one - * is ready to route through it as soon as such an endpoint is added. + * Validates a base64 TLS-serialised MLS KeyPackage. Used by + * `POST /devices/:id/mls-key-packages` (#365) — the size window is the only + * structural check the server can make without an MLS implementation, so the + * endpoint pairs it with device-ownership and expiry checks. */ export const MlsKeyPackageSchema = z .string() diff --git a/apps/backend/src/routes/devices.ts b/apps/backend/src/routes/devices.ts index e0e10a3..ae5ad55 100644 --- a/apps/backend/src/routes/devices.ts +++ b/apps/backend/src/routes/devices.ts @@ -14,6 +14,13 @@ import { Keypair } from '@stellar/stellar-sdk'; import { eq, and, ne, count, desc, isNull, inArray, sql } from 'drizzle-orm'; import { z } from 'zod'; import { db } from '../db/index.js'; +import { + devices, + devicePrekeys, + mlsKeyPackages, + conversationMembers, + messages, +} from '../db/schema.js'; import { devices, devicePrekeys, conversationMembers, messages, wallets } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; @@ -21,11 +28,21 @@ import { DeviceLinkVerifySchema, type DeviceLinkVerifyBody } from '../schemas/au import { createDeviceLinkNonce, consumeDeviceLinkNonce } from '../lib/nonce.js'; import { getSocketServer } from '../lib/socket.js'; import { invalidateConversationCaches } from '../lib/conversationCache.js'; -import { SignedPreKeyEntrySchema, PreKeyEntrySchema, verifyEd25519Signature } from '../lib/keys.js'; +import { + SignedPreKeyEntrySchema, + PreKeyEntrySchema, + MlsKeyPackageSchema, + verifyEd25519Signature, +} from '../lib/keys.js'; import { conversationRoom } from '../services/roomManager.js'; import { redis } from '../lib/redis.js'; import { markDeviceRevoked } from '../services/deviceRevocation.js'; import { + MLS_KEY_PACKAGE_CAP, + MLS_KEY_PACKAGE_MAX_BATCH, + countAvailableKeyPackages, + hashKeyPackage, +} from '../services/mlsKeyPackages.js'; PREKEY_LOW_THRESHOLD, countAvailableOneTimePreKeys, releasePrekeysLowLatch, @@ -45,6 +62,29 @@ const UploadPreKeysSchema = z.object({ oneTimePreKeys: z.array(PreKeyEntrySchema).min(1, 'At least one one-time prekey is required'), }); +const RegisterDeviceSchema = DeviceSchema; + +/** + * MLS key package batch upload. `keyPackage` is validated as base64 of a + * 32–4096 byte TLS-serialised KeyPackage by the shared key validator; the + * handler additionally rejects already-expired packages. + */ +const UploadMlsKeyPackagesSchema = z.object({ + cipherSuite: z.number().int().positive(), + keyPackages: z + .array( + z.object({ + keyPackage: MlsKeyPackageSchema, + expiresAt: z.iso.datetime().optional(), + }), + ) + .min(1, 'At least one key package is required') + .max( + MLS_KEY_PACKAGE_MAX_BATCH, + `At most ${MLS_KEY_PACKAGE_MAX_BATCH} key packages per request`, + ), +}); + /** Maximum number of stored one-time prekeys per device. */ const OTP_CAP = 200; @@ -373,6 +413,103 @@ devicesRouter.post('/:id/prekeys', validate(UploadPreKeysSchema), async (req: Au }); }); +// ─── POST /devices/:id/mls-key-packages ──────────────────────────────────────── +// +// Publishes a batch of public MLS KeyPackages for a device (#365). Only the +// owning device's user may upload, and only public material is accepted — the +// HPKE init private key stays on the device. +// +// Uploads are idempotent: re-sending a package the device already published is +// counted as a duplicate rather than stored twice, so a client that retries a +// timed-out request does not inflate its inventory. + +devicesRouter.post( + '/:id/mls-key-packages', + validate(UploadMlsKeyPackagesSchema), + async (req: AuthRequest, res) => { + const deviceId = req.params['id'] as string; + const callerId = req.auth!.userId; + + const device = await db.query.devices.findFirst({ + where: eq(devices.id, deviceId), + }); + + if (!device) { + res.status(404).json({ error: 'Device not found' }); + return; + } + + if (device.userId !== callerId) { + res.status(403).json({ error: 'Only the device owner may upload MLS key packages' }); + return; + } + + if (device.revokedAt) { + res.status(403).json({ error: 'Device is revoked' }); + return; + } + + const { cipherSuite, keyPackages } = req.body as z.infer; + + // Reject packages that are already past their expiry — storing them would + // only produce inventory that can never be claimed. + const now = Date.now(); + const expired = keyPackages.filter( + (k) => k.expiresAt !== undefined && Date.parse(k.expiresAt) <= now, + ); + + if (expired.length > 0) { + res.status(400).json({ error: 'expiresAt must be in the future' }); + return; + } + + // Enforce the per-device cap on *available* packages before inserting. + const currentCount = await countAvailableKeyPackages(deviceId); + const available = MLS_KEY_PACKAGE_CAP - currentCount; + + if (available <= 0) { + res.status(422).json({ + error: `MLS key package cap of ${MLS_KEY_PACKAGE_CAP} reached. Wait for existing packages to be consumed before uploading more.`, + }); + return; + } + + // Drop duplicates inside the batch itself so the row count we report back + // reflects distinct packages, then trim to the remaining cap space. + const seen = new Set(); + const distinct = keyPackages.filter((k) => { + const hash = hashKeyPackage(k.keyPackage); + if (seen.has(hash)) return false; + seen.add(hash); + return true; + }); + + const trimmed = distinct.slice(0, available); + + const inserted = await db + .insert(mlsKeyPackages) + .values( + trimmed.map((k) => ({ + deviceId, + cipherSuite, + keyPackage: k.keyPackage, + packageHash: hashKeyPackage(k.keyPackage), + expiresAt: k.expiresAt ? new Date(k.expiresAt) : null, + })), + ) + .onConflictDoNothing({ target: [mlsKeyPackages.deviceId, mlsKeyPackages.packageHash] }) + .returning({ id: mlsKeyPackages.id }); + + res.status(200).json({ + uploaded: inserted.length, + duplicates: trimmed.length - inserted.length, + capped: trimmed.length < distinct.length, + remaining: currentCount + inserted.length, + }); + }, +); + +// ─── POST /devices — register a new device for an existing user -------------- // ─── Device linking / re-authentication challenge (#333) ───────────────────── // // Adding a device to an account used to require nothing more than a valid JWT diff --git a/apps/backend/src/routes/users.ts b/apps/backend/src/routes/users.ts index a77430e..5f94635 100644 --- a/apps/backend/src/routes/users.ts +++ b/apps/backend/src/routes/users.ts @@ -9,6 +9,11 @@ import { redis } from '../lib/redis.js'; import { isOnline, deriveDevicePresence } from '../services/presence.js'; import { getSocketServer } from '../lib/socket.js'; import { conversationRoom } from '../services/roomManager.js'; +import { + claimKeyPackage, + countAvailableKeyPackages, + signalReplenishmentIfLow, +} from '../services/mlsKeyPackages.js'; import { signalPrekeysLowIfNeeded } from '../services/prekeyLowSignal.js'; import { actorFromRequest, recordAuditEvent } from '../services/auditLog.js'; import { prekeyConsumedTotal } from '../lib/metrics.js'; @@ -388,6 +393,80 @@ usersRouter.get( }); }); +/** + * GET /users/:userId/devices/:deviceId/mls-key-package + * + * Claims one MLS KeyPackage for a device so the caller can put it in an Add + * proposal (#365). The package is public material; the matching init private + * key never leaves the target device. + * + * The claim is atomic — `consumed` flips inside the same transaction that + * selects the row, so two members adding the same device concurrently get two + * different packages. Reusing a KeyPackage across two Adds would break forward + * secrecy for the joining device, so exhaustion is reported as `409` rather + * than silently handing back an already-used package. + * + * Optional `?cipherSuite=` restricts the claim to packages published for + * that MLS cipher suite; a group can only add a device whose package matches + * the group's suite. + * + * Whenever the remaining stock drops to the low-water mark, an + * `mls_key_packages_low` event is emitted to the device and to its owner — + * the same replenishment prompt the one-time prekey inventory relies on. + */ +usersRouter.get('/:userId/devices/:deviceId/mls-key-package', async (req: AuthRequest, res) => { + const targetUserId = req.params['userId'] as string; + const deviceId = req.params['deviceId'] as string; + + const rawSuite = req.query['cipherSuite']; + let cipherSuite: number | undefined; + + if (typeof rawSuite === 'string' && rawSuite !== '') { + cipherSuite = Number(rawSuite); + if (!Number.isInteger(cipherSuite) || cipherSuite <= 0) { + res.status(400).json({ error: 'cipherSuite must be a positive integer' }); + return; + } + } + + const device = await db.query.devices.findFirst({ + where: eq(devices.id, deviceId), + columns: { id: true, userId: true, revokedAt: true }, + }); + + if (!device || device.userId !== targetUserId || device.revokedAt) { + res.status(404).json({ error: 'Device not found or has been revoked' }); + return; + } + + try { + const claimed = await claimKeyPackage(deviceId, cipherSuite); + const remaining = await countAvailableKeyPackages(deviceId, cipherSuite); + + // Zero remaining is by definition below the watermark, so an exhausted + // device still gets told to replenish before the 409 goes out. + signalReplenishmentIfLow(deviceId, targetUserId, remaining); + + if (!claimed) { + res.status(409).json({ + error: 'No MLS key packages available for this device', + remaining: 0, + }); + return; + } + + res.json({ + deviceId: device.id, + cipherSuite: claimed.cipherSuite, + keyPackage: claimed.keyPackage, + expiresAt: claimed.expiresAt, + remaining, + }); + } catch { + res.status(500).json({ error: 'Failed to claim MLS key package' }); + } +}); + /** * GET /users/:id/key-fingerprint * diff --git a/apps/backend/src/services/mlsKeyPackages.ts b/apps/backend/src/services/mlsKeyPackages.ts new file mode 100644 index 0000000..3df7df4 --- /dev/null +++ b/apps/backend/src/services/mlsKeyPackages.ts @@ -0,0 +1,141 @@ +/** + * MLS key package inventory (#365). + * + * Devices publish a stock of public MLS KeyPackages up front so any group + * member can add them to a group while they are offline. Because a KeyPackage + * is single-use by spec, every hand-out has to be atomic: two concurrent group + * adds must never receive the same package, or the joining device loses forward + * secrecy for one of the two groups. + * + * The claim below therefore runs `SELECT ... FOR UPDATE SKIP LOCKED` inside a + * transaction and flips `consumed` before returning — the same technique the + * X3DH one-time prekey claim uses in `GET /users/:id/devices/:id/key-bundle`. + */ + +import { createHash } from 'node:crypto'; +import { and, asc, count, eq, gt, isNull, or, sql } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { mlsKeyPackages } from '../db/schema.js'; +import { getSocketServer } from '../lib/socket.js'; +import { deviceRoom } from './deliveryPipeline.js'; +import { userRoom } from './roomManager.js'; + +/** Maximum number of unconsumed key packages a device may hold at once. */ +export const MLS_KEY_PACKAGE_CAP = 100; + +/** + * Emit a replenishment signal once a device's unconsumed stock drops to this + * many packages or fewer. Mirrors the one-time prekey low-water behaviour: the + * device should upload a fresh batch before it hits zero, since a device with + * no packages left cannot be added to a new group until it comes online. + */ +export const MLS_KEY_PACKAGE_LOW_WATERMARK = 10; + +/** Largest batch accepted in a single upload request. */ +export const MLS_KEY_PACKAGE_MAX_BATCH = 100; + +export interface ClaimedKeyPackage { + id: string; + cipherSuite: number; + keyPackage: string; + expiresAt: Date | null; +} + +/** SHA-256 (hex) of the base64 package — the dedupe key for re-uploads. */ +export function hashKeyPackage(keyPackageB64: string): string { + return createHash('sha256').update(keyPackageB64, 'utf8').digest('hex'); +} + +/** + * Matches rows that are still handable out: not consumed, and either + * non-expiring or not yet past `expiresAt`. + */ +function availableFilter(deviceId: string, cipherSuite?: number) { + return and( + eq(mlsKeyPackages.deviceId, deviceId), + eq(mlsKeyPackages.consumed, false), + or(isNull(mlsKeyPackages.expiresAt), gt(mlsKeyPackages.expiresAt, sql`now()`)), + ...(cipherSuite === undefined ? [] : [eq(mlsKeyPackages.cipherSuite, cipherSuite)]), + ); +} + +/** Number of key packages currently available for a device. */ +export async function countAvailableKeyPackages( + deviceId: string, + cipherSuite?: number, +): Promise { + const [row] = await db + .select({ total: count() }) + .from(mlsKeyPackages) + .where(availableFilter(deviceId, cipherSuite)); + + return row?.total ?? 0; +} + +/** + * Atomically claims the oldest available key package for a device, marking it + * consumed in the same transaction. Returns `null` when the device's stock is + * exhausted (or holds nothing for the requested cipher suite). + * + * Oldest-first keeps the inventory FIFO so packages are used before they + * expire rather than aging out unused. + */ +export async function claimKeyPackage( + deviceId: string, + cipherSuite?: number, +): Promise { + return db.transaction(async (tx) => { + const [candidate] = await tx + .select({ + id: mlsKeyPackages.id, + cipherSuite: mlsKeyPackages.cipherSuite, + keyPackage: mlsKeyPackages.keyPackage, + expiresAt: mlsKeyPackages.expiresAt, + }) + .from(mlsKeyPackages) + .where(availableFilter(deviceId, cipherSuite)) + .orderBy(asc(mlsKeyPackages.createdAt)) + .limit(1) + .for('update', { skipLocked: true }); + + if (!candidate) return null; + + await tx + .update(mlsKeyPackages) + .set({ consumed: true, consumedAt: new Date() }) + .where(eq(mlsKeyPackages.id, candidate.id)); + + return candidate; + }); +} + +/** + * Tells a device to upload more key packages when its stock runs low. + * + * Emitted to both the device room (so the owning device acts on it immediately) + * and the user room (so a sibling device can surface the prompt if the low + * device is offline). Best-effort: never blocks or fails the claim that + * triggered it. + */ +export function signalReplenishmentIfLow( + deviceId: string, + userId: string, + remaining: number, +): boolean { + if (remaining > MLS_KEY_PACKAGE_LOW_WATERMARK) return false; + + const io = getSocketServer(); + if (!io) return false; + + const payload = { + deviceId, + remaining, + threshold: MLS_KEY_PACKAGE_LOW_WATERMARK, + cap: MLS_KEY_PACKAGE_CAP, + }; + + io.to(deviceRoom(deviceId)).emit('mls_key_packages_low', payload); + io.to(userRoom(userId)).emit('mls_key_packages_low', payload); + + return true; +}