Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/mls/deliveryService.fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export class FakeDeliveryService {
scope: IdentityScope;
conversations = new Map<string, string[]>();
devices = new Map<string, string[]>();
/** Ids their owner removed, as `${serverUserId}:${deviceId}`. Anything naming one is refused (GRYT-1555). */
removed = new Set<string>();
/** Every call that named a device, in order, by device id. */
callsBy = new Map<string, string[]>();
/** When each device id first published, as `mls:devices` reports it. */
deviceAddedAt = new Map<string, string>();
keyPackages: KeyPackageRow[] = [];
Expand Down Expand Up @@ -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 }));
Expand Down Expand Up @@ -308,6 +313,14 @@ export class FakeDeliveryService {
for (const [name, method] of Object.entries(methods) as [string, (req: unknown) => Promise<unknown>][]) {
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;
Expand Down
48 changes: 45 additions & 3 deletions src/mls/dmDriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[] = [];
Expand All @@ -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. */
Expand Down Expand Up @@ -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");
Expand Down
42 changes: 40 additions & 2 deletions src/mls/dmDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import type {
MlsLogEntry,
MlsOwnDevice,
MlsRefusal,
MlsReply,
MlsTransport,
MlsWelcomeDelivery,
} from "./interfaces.js";

Expand All @@ -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;
Expand Down Expand Up @@ -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<Req, Res extends object>(call: (req: Req) => Promise<MlsReply<Res>>) {
return async (req: Req): Promise<MlsReply<Res>> => {
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<string, MlsTrust>();
/** What the engine asks about every leaf: is it somebody in this conversation, by pin. */
Expand Down Expand Up @@ -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<void> {
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);
Expand Down
5 changes: 5 additions & 0 deletions src/mls/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading