diff --git a/src/mls/deliveryService.fake.ts b/src/mls/deliveryService.fake.ts index bcf31c8..2e40bb0 100644 --- a/src/mls/deliveryService.fake.ts +++ b/src/mls/deliveryService.fake.ts @@ -57,6 +57,10 @@ export class FakeDeliveryService { scope: IdentityScope; conversations = new Map(); devices = new Map(); + /** Ids their owner removed, as `${serverUserId}:${deviceId}`. Anything naming one is refused (GRYT-1555). */ + removed = new Set(); + /** Every call that named a device, in order, by device id. */ + callsBy = new Map(); /** When each device id first published, as `mls:devices` reports it. */ deviceAddedAt = new Map(); keyPackages: KeyPackageRow[] = []; @@ -210,6 +214,7 @@ export class FakeDeliveryService { await tick(); if (!this.isDevice(me, deviceId)) return fail("unknown_device", "Publish KeyPackages from this device first."); this.devices.set(me, (this.devices.get(me) ?? []).filter((d) => d !== deviceId)); + this.removed.add(`${me}:${deviceId}`); this.keyPackages = this.keyPackages.filter((k) => !(k.serverUserId === me && k.deviceId === deviceId)); this.welcomes = this.welcomes.filter((w) => !(w.serverUserId === me && w.deviceId === deviceId)); this.push(this.peopleSharing(me), (d) => d.handleDevicesChanged({ serverUserId: me })); @@ -308,6 +313,14 @@ export class FakeDeliveryService { for (const [name, method] of Object.entries(methods) as [string, (req: unknown) => Promise][]) { guarded[name] = async (req) => { this.checkParts(socket, name, req); + const deviceId = (req as { deviceId?: unknown })?.deviceId; + if (typeof deviceId === "string") { + this.callsBy.set(deviceId, [...(this.callsBy.get(deviceId) ?? []), name]); + if (this.removed.has(`${me}:${deviceId}`)) { + await tick(); + return fail("device_removed", "You removed this device from encrypted messages here."); + } + } const reply = await method(req); this.checkParts(socket, name, reply); return reply; diff --git a/src/mls/dmDriver.test.ts b/src/mls/dmDriver.test.ts index 9b5aca7..41e24d7 100644 --- a/src/mls/dmDriver.test.ts +++ b/src/mls/dmDriver.test.ts @@ -43,10 +43,16 @@ function pinStore(people: string[], withPersonKey: string[]) { return { store, pinPerson }; } -type DeviceOptions = { seed?: Uint8Array; capability?: MlsServerCapability | null; unpinned?: string[] }; +type DeviceOptions = { + seed?: Uint8Array; + capability?: MlsServerCapability | null; + unpinned?: string[]; + store?: MemoryMlsStore; +}; function device(fake: FakeDeliveryService, who: string, name: string, opts: DeviceOptions = {}) { - const store = new MemoryMlsStore(); + const store = opts.store ?? new MemoryMlsStore(); + const removed: number[] = []; const messages: MlsDecryptedMessage[] = []; const lost: { conversationId: string; reason: string }[] = []; const undecryptable: { conversationId: string; seq: number; reason: string }[] = []; @@ -73,12 +79,13 @@ function device(fake: FakeDeliveryService, who: string, name: string, opts: Devi onMessage: (m) => void messages.push(m), onGroupLost: (i) => void lost.push(i), onUndecryptable: (i) => void undecryptable.push(i), + onDeviceRemoved: () => void removed.push(Date.now()), }, newDevice: () => createMlsDevice({ seed, scope: SCOPE, deviceName: name }), }); socket.driver = driver; const texts = () => messages.map((m) => `${m.senderServerUserId}: ${new TextDecoder().decode(m.plaintext)}`); - return { driver, store, messages, lost, undecryptable, seen, socket, texts, pinPerson: pins.pinPerson }; + return { driver, store, messages, lost, undecryptable, removed, seen, socket, texts, pinPerson: pins.pinPerson }; } /* A package for `d` made as if the clock said `at`, put first in line for its device. */ @@ -279,6 +286,41 @@ describe("MLS DM driver", () => { assert.equal(fake.groups.get(dm)!.log.at(-1)!.senderDeviceId, laptop.store.device!.deviceId); }); + it("stops a stolen phone for good once it's removed, online or on reconnect (GRYT-1555)", async () => { + const fake = new FakeDeliveryService(SCOPE); + const dm = fake.dm("kari", "ola"); + const laptop = device(fake, "kari", "laptop"); + const phone = device(fake, "kari", "phone"); + const ola = device(fake, "ola", "phone"); + for (const d of [laptop, phone, ola]) await d.driver.start(); + await laptop.driver.send(dm, "ola", text("before")); + await fake.settle(); + const phoneId = phone.store.device!.deviceId; + + // Removed while it's online: the devices push is how it finds out. + await laptop.driver.removeOwnDevice(phoneId); + await fake.settle(); + assert.equal(phone.removed.length, 1); + assert.deepEqual(phone.lost, [{ conversationId: dm, reason: "removed" }]); + + // The same driver asks nothing more of the server. + const called = fake.callsBy.get(phoneId)!.length; + await assert.rejects(phone.driver.start(), { code: "device_removed" }); + assert.equal(fake.callsBy.get(phoneId)!.length, called); + + // Reconnecting with its state still there gets one refused sync, and never publishes. + const reconnected = device(fake, "kari", "phone", { store: phone.store }); + await assert.rejects(reconnected.driver.start(), { code: "device_removed" }); + assert.equal(reconnected.removed.length, 1); + assert.deepEqual(fake.callsBy.get(phoneId)!.slice(called), ["sync"]); + + await ola.driver.send(dm, "kari", text("after")); + await fake.settle(); + assert.deepEqual(laptop.texts(), ["ola: after"]); + assert.deepEqual(phone.texts(), ["kari: before"]); + assert.deepEqual(fake.welcomes.filter((w) => w.deviceId === phoneId), [], "and nobody adds it back"); + }); + it("lists your own devices with the names their certificates carry", async () => { const fake = new FakeDeliveryService(SCOPE); const dm = fake.dm("kari", "ola"); diff --git a/src/mls/dmDriver.ts b/src/mls/dmDriver.ts index 65311f5..9b31801 100644 --- a/src/mls/dmDriver.ts +++ b/src/mls/dmDriver.ts @@ -34,6 +34,8 @@ import type { MlsLogEntry, MlsOwnDevice, MlsRefusal, + MlsReply, + MlsTransport, MlsWelcomeDelivery, } from "./interfaces.js"; @@ -59,7 +61,8 @@ export type MlsDriverErrorCode = | "unexpected_member" | "peer_unverified" | "too_many_attachments" - | "commit_retries"; + | "commit_retries" + | "device_removed"; export class MlsDriverError extends Error { code: MlsDriverErrorCode; @@ -116,7 +119,40 @@ interface Open { } export function createMlsDmDriver(options: MlsDmDriverOptions): MlsDmDriver { - const { transport, store, pins, events, serverUserId: self, capability } = options; + const { store, pins, events, serverUserId: self, capability } = options; + + /* Once the server says `device_removed`, nothing goes to it again from this driver: + no sync, and above all no publish that could register this device back. */ + let removed = false; + const removedError = (r?: MlsRefusal) => + new MlsDriverError("device_removed", "This device was removed from encrypted messages on this server.", r); + function stopOnRemoval(call: (req: Req) => Promise>) { + return async (req: Req): Promise> => { + if (removed) throw removedError(); + const r = await call(req); + if (!r.ok && r.error === "device_removed") { + if (!removed) { + removed = true; + events.onDeviceRemoved?.(); + } + throw removedError(r); + } + return r; + }; + } + const raw = options.transport; + const transport: MlsTransport = { + publishKeyPackages: stopOnRemoval((req) => raw.publishKeyPackages(req)), + claimKeyPackages: stopOnRemoval((req) => raw.claimKeyPackages(req)), + listDevices: stopOnRemoval((req) => raw.listDevices(req)), + removeDevice: stopOnRemoval((req) => raw.removeDevice(req)), + createGroup: stopOnRemoval((req) => raw.createGroup(req)), + commit: stopOnRemoval((req) => raw.commit(req)), + send: stopOnRemoval((req) => raw.send(req)), + fetchLog: stopOnRemoval((req) => raw.fetchLog(req)), + sync: stopOnRemoval((req) => raw.sync(req)), + ackWelcomes: stopOnRemoval((req) => raw.ackWelcomes(req)), + }; const scope = asIdentityScope(options.scope); const trusts = new Map(); /** What the engine asks about every leaf: is it somebody in this conversation, by pin. */ @@ -617,6 +653,8 @@ export function createMlsDmDriver(options: MlsDmDriverOptions): MlsDmDriver { /** Adds wait for the next send; a device that's gone is removed straight away. */ async function handleDevicesChanged(push: { serverUserId: string }): Promise { reconciled.clear(); + // One of yours changed, and it may be this one: a sync is how the server says so. + if (push.serverUserId === self && registered) await transport.sync({ deviceId: (await ownDevice()).deviceId }); for (const rec of await store.listGroups()) { await run(rec.conversationId, async () => { const open = await load(rec.conversationId); diff --git a/src/mls/interfaces.ts b/src/mls/interfaces.ts index b59f444..ca384cb 100644 --- a/src/mls/interfaces.ts +++ b/src/mls/interfaces.ts @@ -225,6 +225,11 @@ export interface MlsDmEvents { /** This device is out of the group: removed, or its state couldn't keep up. */ onGroupLost?(info: { conversationId: string; reason: "removed" | "out_of_sync" | "gap" }): void; onJoined?(info: { conversationId: string; groupId: string }): void; + /** + * The server says this device was removed here (`device_removed`). The driver has stopped for + * good. Wipe this server's MLS state, and don't make a new device until the person asks. + */ + onDeviceRemoved?(): void; } /** Decision 4, per DM. `refused` never falls back to version 1. */