diff --git a/README.md b/README.md index 40effee..2632b26 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,10 @@ goes missing **and** when one appears that nobody listed. space on the phone, which is a key one of them could collide on. - **`mls`** — the DM half of MLS stage 1. It keeps a device's KeyPackages topped up, opens a DM group, joins from a Welcome, sends, and reads the group - log in order. Each app hands it the socket, the storage and the pins through + log in order. For linking a device, `addOwnDevice` puts a new device of yours + in every DM straight away and says where in each group's log it went in. + `groupPositions` says where each log stood when the history snapshot was + taken. Each app hands it the socket, the storage and the pins through the interfaces in `src/mls/interfaces.ts`. `mlsPinsFromPeerPins` builds the pins from the peer pins `@gryt/crypto` already keeps. `encodeMlsDmContent` and `decodeMlsDmContent` write and read what goes inside diff --git a/src/index.ts b/src/index.ts index 189bb0a..8500d6e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -106,6 +106,7 @@ export { export { applyMlsReaction, mlsReactionAction, type MlsReaction } from "./mls/reactions.js"; export type { DmSealingMode, + MlsAddOwnDeviceOptions, MlsClaimedKeyPackage, MlsDecryptedMessage, MlsDeviceRecord, @@ -113,12 +114,14 @@ export type { MlsDmDriver, MlsDmDriverOptions, MlsDmEvents, + MlsGroupPosition, MlsGroupRecord, MlsGroupView, MlsKeyPackageRecord, MlsListedDevice, MlsLogEntry, MlsOwnDevice, + MlsOwnDeviceAdd, MlsPins, MlsRefusal, MlsReply, diff --git a/src/mls/addOwnDevice.test.ts b/src/mls/addOwnDevice.test.ts new file mode 100644 index 0000000..0e05aa8 --- /dev/null +++ b/src/mls/addOwnDevice.test.ts @@ -0,0 +1,251 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + asIdentityScope, + createMlsDevice, + derivePersonKeyPair, + pinPeerKey, + pinPersonKey, + type PeerPin, + type VerifiedDmKeyBinding, + type VerifiedPersonKeyBinding, +} from "@gryt/crypto"; + +import { FakeDeliveryService, MemoryMlsStore } from "./deliveryService.fake.ts"; +import { createMlsDmDriver, MlsDriverError } from "./dmDriver.ts"; +import type { MlsDecryptedMessage, MlsOwnDeviceAdd, MlsTransport } from "./interfaces.ts"; +import { mlsPinsFromPeerPins } from "./pins.ts"; + +const SCOPE = asIdentityScope("srv:pairing-test"); +const seedOf = (n: number) => Uint8Array.from({ length: 32 }, (_, i) => (i * n + n) % 251); +const SEEDS: Record = { kari: seedOf(3), ola: seedOf(5), mallory: seedOf(7), per: seedOf(11) }; +const text = (s: string) => new TextEncoder().encode(s); +const read = (m: MlsDecryptedMessage) => new TextDecoder().decode(m.plaintext); + +/* Holds the first call to one transport method until `release`, and says when it got there. */ +function gate(transport: MlsTransport, method: "commit" | "send") { + let reached!: () => void; + let release!: () => void; + const atGate = new Promise((r) => (reached = r)); + const open = new Promise((r) => (release = r)); + let first = true; + const inner = transport[method].bind(transport) as (req: unknown) => Promise; + (transport as unknown as Record Promise>)[method] = async (req) => { + if (first) { + first = false; + reached(); + await open; + } + return inner(req); + }; + return { atGate, release }; +} + +function device(fake: FakeDeliveryService, who: string, name: string, seed = SEEDS[who]) { + let pinned: Record = {}; + const pinStore = { read: () => structuredClone(pinned), write: (p: Record) => void (pinned = structuredClone(p)) }; + for (const other of Object.keys(SEEDS).filter((p) => p !== who)) { + pinPeerKey(pinStore, SCOPE, other, { dmPublicKey: new Uint8Array(32), identityThumbprint: `id-${other}` } as VerifiedDmKeyBinding); + const personPublicKey = derivePersonKeyPair(SEEDS[other], SCOPE).publicKey; + pinPersonKey(pinStore, SCOPE, other, { personPublicKey, identityThumbprint: `id-${other}`, scope: SCOPE, signedAt: 0 } as VerifiedPersonKeyBinding); + } + const store = new MemoryMlsStore(); + const messages: MlsDecryptedMessage[] = []; + const undecryptable: { conversationId: string; seq: number; reason: string }[] = []; + const { transport, socket } = fake.connect(who); + const driver = createMlsDmDriver({ + transport, + store, + scope: SCOPE, + serverUserId: who, + capability: { version: 1, ciphersuites: [1], retentionDays: 30 }, + pins: mlsPinsFromPeerPins({ + store: pinStore, + scope: SCOPE, + serverUserId: who, + ownPersonKey: derivePersonKeyPair(seed, SCOPE).publicKey, + membersOf: (conversationId) => fake.conversations.get(conversationId) ?? [], + seen: new Set(), + }), + events: { + onMessage: (m) => void messages.push(m), + onUndecryptable: (i) => void undecryptable.push(i), + }, + newDevice: () => createMlsDevice({ seed, scope: SCOPE, deviceName: name }), + }); + socket.driver = driver; + const id = () => store.device!.deviceId; + return { driver, store, messages, undecryptable, transport, socket, id, texts: () => messages.map(read) }; +} + +/* Kari's laptop in a DM with Ola, one message each way. `publishDesk` starts her new desktop. */ +async function pairingSetup() { + const fake = new FakeDeliveryService(SCOPE); + const dm = fake.dm("kari", "ola"); + const laptop = device(fake, "kari", "laptop"); + const ola = device(fake, "ola", "phone"); + await laptop.driver.start(); + await ola.driver.start(); + await laptop.driver.send(dm, "ola", text("hei")); + await fake.settle(); + await ola.driver.send(dm, "kari", text("hei selv")); + await fake.settle(); + const desk = device(fake, "kari", "desk"); + const publishDesk = async () => { + await desk.driver.start(); + await fake.settle(); + }; + return { fake, dm, laptop, ola, desk, publishDesk }; +} + +const addFor = (results: MlsOwnDeviceAdd[], conversationId: string) => results.find((r) => r.conversationId === conversationId)!; + +describe("adding your own new device (pairing)", () => { + it("adds the new device to all three DMs, with progress for each", async () => { + const fake = new FakeDeliveryService(SCOPE); + const laptop = device(fake, "kari", "laptop"); + const peers = ["ola", "mallory", "per"].map((who) => ({ who, dm: fake.dm("kari", who), d: device(fake, who, "phone") })); + await laptop.driver.start(); + for (const p of peers) await p.d.driver.start(); + for (const p of peers) await laptop.driver.send(p.dm, p.who, text(`hei ${p.who}`)); + await fake.settle(); + const desk = device(fake, "kari", "desk"); + await desk.driver.start(); + await fake.settle(); + const commitsBefore = fake.stats.commits; + + const progress: [number, number][] = []; + const order = [peers[2].dm, peers[0].dm]; + const results = await laptop.driver.addOwnDevice(desk.id(), { order, onProgress: (p) => void progress.push([p.done, p.total]) }); + await fake.settle(); + + assert.deepEqual(progress, [[1, 3], [2, 3], [3, 3]]); + assert.deepEqual(results.map((r) => r.conversationId), [peers[2].dm, peers[0].dm, peers[1].dm], "the order asked for, then the rest"); + assert.ok(results.every((r) => r.outcome === "added" && r.add !== null)); + assert.equal(fake.stats.commits - commitsBefore, 3, "one commit per group, with nothing waiting for a send"); + assert.equal(desk.store.groups.size, 3); + + for (const p of peers) await p.d.driver.send(p.dm, "kari", text(`from ${p.who}`)); + await fake.settle(); + assert.deepEqual(desk.texts().sort(), ["from mallory", "from ola", "from per"]); + assert.deepEqual(desk.undecryptable, []); + + const again = await laptop.driver.addOwnDevice(desk.id(), { order: results.map((r) => r.conversationId) }); + assert.deepEqual(again, results, "a second call adds nothing and reports the same places"); + assert.equal(fake.stats.commits - commitsBefore, 3); + }); + + it("records exactly where the device went in, for the snapshot, the tail and the late ones", async () => { + const { fake, dm, laptop, ola, desk, publishDesk } = await pairingSetup(); + const [snap] = await laptop.driver.groupPositions(); + assert.deepEqual(snap, { conversationId: dm, groupId: fake.groups.get(dm)!.groupId, seq: fake.groups.get(dm)!.headSeq, epoch: 1 }); + + // Two messages between the snapshot and the add: the tail. + await ola.driver.send(dm, "kari", text("tail 1")); + await ola.driver.send(dm, "kari", text("tail 2")); + await fake.settle(); + // Ola misses the desk's arrival, so her next send won't add it herself. + ola.socket.online = false; + await publishDesk(); + + // Ola encrypts one more in the old epoch; it reaches the server only after the add. + const late = gate(ola.transport, "send"); + const lateSend = ola.driver.send(dm, "kari", text("late")); + await late.atGate; + const [result] = await laptop.driver.addOwnDevice(desk.id()); + late.release(); + await lateSend; + await fake.settle(); + + const log = fake.groups.get(dm)!.log; + const addEntry = log.find((e) => e.seq === result.add!.seq)!; + assert.equal(result.outcome, "added"); + assert.equal(addEntry.kind, "commit"); + assert.equal(addEntry.senderDeviceId, laptop.id()); + assert.equal(result.add!.epoch, addEntry.epoch + 1); + assert.equal(desk.store.groups.get(dm)!.joinedEpoch, result.add!.epoch, "the epoch the new device starts in"); + + const tail = laptop.messages.filter((m) => m.seq > snap.seq && m.seq < result.add!.seq).map(read); + assert.deepEqual(tail, ["tail 1", "tail 2"]); + const lateOnes = laptop.messages.filter((m) => m.seq > result.add!.seq && m.epoch < result.add!.epoch); + assert.deepEqual(lateOnes.map(read), ["late"]); + assert.equal(lateOnes[0].seq, result.add!.seq + 1); + assert.deepEqual(desk.texts(), [], "none of it is readable on the new device"); + }); + + it("catches up and adds again after a stale_epoch race", async () => { + const { fake, dm, laptop, ola, desk, publishDesk } = await pairingSetup(); + const tablet = device(fake, "ola", "tablet"); + await tablet.driver.start(); + await fake.settle(); + await ola.driver.send(dm, "kari", text("tablet in")); + await fake.settle(); + await publishDesk(); + + // Ola takes her tablet out while Kari's add is on its way. + const held = gate(laptop.transport, "commit"); + const adding = laptop.driver.addOwnDevice(desk.id()); + await held.atGate; + await ola.driver.removeOwnDevice(tablet.id()); + const removal = fake.groups.get(dm)!.log.at(-1)!; + held.release(); + const [result] = await adding; + await fake.settle(); + + assert.equal(removal.kind, "commit"); + assert.equal(fake.stats.staleEpoch, 1); + assert.equal(result.outcome, "added"); + assert.deepEqual(result.add, { seq: removal.seq + 1, epoch: removal.epoch + 2 }); + await ola.driver.send(dm, "kari", text("after")); + await fake.settle(); + assert.deepEqual(desk.texts(), ["after"]); + }); + + it("takes a peer's commit as the add when it lands at the same moment", async () => { + const { fake, dm, laptop, ola, desk, publishDesk } = await pairingSetup(); + await publishDesk(); + const commitsBefore = fake.stats.commits; + + const held = gate(laptop.transport, "commit"); + const adding = laptop.driver.addOwnDevice(desk.id()); + await held.atGate; + // Ola's next send adds every device it finds, the desk included. + await ola.driver.send(dm, "kari", text("hei desk")); + const olaCommit = fake.groups.get(dm)!.log.findLast((e) => e.kind === "commit")!; + held.release(); + const [result] = await adding; + await fake.settle(); + + assert.equal(olaCommit.senderServerUserId, "ola"); + assert.equal(fake.stats.staleEpoch, 1); + assert.equal(fake.stats.commits - commitsBefore, 1, "the desk went in once"); + assert.deepEqual(result, { + conversationId: dm, + groupId: fake.groups.get(dm)!.groupId, + outcome: "added_by_other", + add: { seq: olaCommit.seq, epoch: olaCommit.epoch + 1 }, + }); + assert.deepEqual(desk.texts(), ["hei desk"]); + }); + + it("refuses a device under somebody else's person key, and one the server doesn't list", async () => { + const { fake, dm, laptop } = await pairingSetup(); + // The server slips in a device under Kari's name, certified by Mallory's person key. + const planted = device(fake, "kari", "desk", SEEDS.mallory); + await planted.driver.start(); + await fake.settle(); + const epoch = fake.groups.get(dm)!.epoch; + const progress: unknown[] = []; + + const notOwn = (e: unknown) => e instanceof MlsDriverError && e.code === "not_own_device"; + await assert.rejects(laptop.driver.addOwnDevice(planted.id(), { onProgress: (p) => void progress.push(p) }), notOwn); + await assert.rejects(laptop.driver.addOwnDevice("no-such-device"), notOwn); + await assert.rejects(laptop.driver.addOwnDevice(laptop.id()), notOwn); + + assert.equal(fake.groups.get(dm)!.epoch, epoch, "no commit"); + assert.deepEqual(progress, []); + assert.equal(planted.store.groups.size, 0); + assert.deepEqual(fake.welcomes.filter((w) => w.deviceId === planted.id()), []); + }); +}); diff --git a/src/mls/dmDriver.ts b/src/mls/dmDriver.ts index 65311f5..6ef28b0 100644 --- a/src/mls/dmDriver.ts +++ b/src/mls/dmDriver.ts @@ -36,6 +36,7 @@ import type { MlsRefusal, MlsWelcomeDelivery, } from "./interfaces.js"; +import type { MlsAddOwnDeviceOptions, MlsGroupPosition, MlsOwnDeviceAdd } from "./interfaces.js"; /** Five devices per person per server (design, section 1). The server holds the same line. */ const MLS_MAX_DEVICES_PER_PERSON = 5; @@ -53,6 +54,7 @@ const RENEW_BEFORE_S = 7 * 86_400; export type MlsDriverErrorCode = | "refused" + | "not_own_device" | "no_device" | "waiting_for_welcome" | "not_in_group" @@ -170,6 +172,19 @@ export function createMlsDmDriver(options: MlsDmDriverOptions): MlsDmDriver { await store.saveGroup(open.rec); } + /** Where each device went into each group, as this driver saw it happen. Memory only. */ + const joins = new Map>(); + + function noteJoins(conversationId: string, before: MlsGroupState, after: MlsGroupState, seq: number, byThisDevice: boolean) { + if (before === after) return; + const had = new Set(mlsGroupMembers(before, scope).map((m) => m.certificate.deviceId)); + const here = joins.get(conversationId) ?? new Map(); + for (const { certificate } of mlsGroupMembers(after, scope)) { + if (!had.has(certificate.deviceId)) here.set(certificate.deviceId, { seq, epoch: epochOf(after), byThisDevice }); + } + if (here.size) joins.set(conversationId, here); + } + async function load(conversationId: string): Promise { const rec = await store.loadGroup(conversationId); return rec ? { rec, state: decodeMlsGroupState(rec.state, trustFor(conversationId)) } : null; @@ -234,6 +249,7 @@ export function createMlsDmDriver(options: MlsDmDriverOptions): MlsDmDriver { const current = epochOf(open.state); if (e.kind === "commit") { + const before = open.state; const pending = open.rec.pending; if (pending && sameBytes(pending.commit, e.data)) { open.state = decodeMlsGroupState(pending.state, trustFor(open.rec.conversationId)); @@ -257,6 +273,7 @@ export function createMlsDmDriver(options: MlsDmDriverOptions): MlsDmDriver { } } open.rec = { ...open.rec, pending: undefined }; + noteJoins(open.rec.conversationId, before, open.state, e.seq, !!pending && sameBytes(pending.commit, e.data)); await notePeople(open.rec.conversationId, open.state); } else if (e.kind === "proposal") { if (e.epoch === current) { @@ -280,6 +297,7 @@ export function createMlsDmDriver(options: MlsDmDriverOptions): MlsDmDriver { await events.onMessage({ conversationId: open.rec.conversationId, seq: e.seq, + epoch: e.epoch, senderServerUserId: e.senderServerUserId, senderDeviceId: e.senderDeviceId, plaintext, @@ -328,6 +346,7 @@ export function createMlsDmDriver(options: MlsDmDriverOptions): MlsDmDriver { welcome: c.welcome, }); if (r.ok) { + noteJoins(open.rec.conversationId, open.state, c.state, r.seq, true); open.state = c.state; forgetMlsSecrets(c.consumed); open.rec = { ...open.rec, pending: undefined }; @@ -667,6 +686,105 @@ export function createMlsDmDriver(options: MlsDmDriverOptions): MlsDmDriver { })); } + // ── Pairing ───────────────────────────────────────────────────────── + + async function groupPositions(): Promise { + const out: MlsGroupPosition[] = []; + for (const rec of await store.listGroups()) { + const pos = await run(rec.conversationId, async () => { + const open = await load(rec.conversationId); + return open && { conversationId: open.rec.conversationId, groupId: open.rec.groupId, seq: open.rec.cursor, epoch: epochOf(open.state) }; + }); + if (pos) out.push(pos); + } + return out; + } + + /** A claimed package for `deviceId`, checked to be yours under your own person key. Null when the server has none. */ + async function claimOwn(conversationId: string, deviceId: string, ownPersonKey: Uint8Array): Promise { + const d = await ownDevice(); + // One more claim when the first package won't read, as with a peer's. + for (let round = 0; round < 2; round++) { + const claim = await transport.claimKeyPackages({ conversationId, deviceId: d.deviceId, devices: [{ serverUserId: self, deviceId }] }); + if (!claim.ok) throw refused("Claiming KeyPackages", claim); + const kp = claim.keyPackages.find((k) => k.serverUserId === self && k.deviceId === deviceId); + if (!kp) return null; + let certificate: DeviceCertificate; + try { + ({ certificate } = await readMlsKeyPackage(kp.keyPackage, scope)); + } catch { + continue; + } + if (certificate.deviceId !== deviceId || !sameBytes(certificate.personPublicKey, ownPersonKey)) { + throw new MlsDriverError("not_own_device", "That device's certificate isn't signed by your person key."); + } + return kp.keyPackage; + } + return null; + } + + async function addOwnDeviceTo(conversationId: string, deviceId: string, ownPersonKey: Uint8Array): Promise { + const open = await load(conversationId); + const result = (outcome: MlsOwnDeviceAdd["outcome"], error?: string): MlsOwnDeviceAdd => { + const j = joins.get(conversationId)?.get(deviceId); + const add = outcome === "failed" || !j ? null : { seq: j.seq, epoch: j.epoch }; + return { conversationId, groupId: open?.rec.groupId ?? "", outcome, add, ...(error ? { error } : {}) }; + }; + if (!open || !(await catchUp(open))) return result("failed", "not_in_group"); + const inTree = (s: MlsGroupState) => mlsGroupMembers(s, scope).some((m) => m.certificate.deviceId === deviceId); + const settled = () => result(joins.get(conversationId)?.get(deviceId)?.byThisDevice ? "added" : "added_by_other"); + if (inTree(open.state)) return settled(); + + let full = false; + for (let round = 0; round < 2 && !inTree(open.state); round++) { + const keyPackage = await claimOwn(conversationId, deviceId, ownPersonKey); + if (!keyPackage) return result("failed", "no_key_package"); + let stale = false; + await commitWithRetry(open, async (s) => { + if (inTree(s)) return null; + const mine = mlsGroupMembers(s, scope).filter((m) => sameBytes(m.certificate.personPublicKey, ownPersonKey)); + if ((full = mine.length >= MLS_MAX_DEVICES_PER_PERSON)) return null; + try { + return await addMlsMembers(s, [keyPackage]); + } catch { + stale = true; + return null; + } + }); + if (!stale) break; + } + if (inTree(open.state)) return settled(); + return result("failed", full ? "too_many_devices" : "no_key_package"); + } + + async function addOwnDevice(deviceId: string, options: MlsAddOwnDeviceOptions = {}): Promise { + const d = await ownDevice(); + const listed = await transport.listDevices({}); + if (!listed.ok) throw refused("Listing devices", listed); + if (deviceId === d.deviceId || !listed.devices.some((x) => x.serverUserId === self && x.deviceId === deviceId)) { + throw new MlsDriverError("not_own_device", "The server doesn't list that device as one of yours."); + } + const ownPersonKey = readDeviceCertificate(d.certificate, scope).personPublicKey; + + const ids = (await store.listGroups()).map((r) => r.conversationId); + const first = (options.order ?? []).filter((id) => ids.includes(id)); + const ordered = [...new Set([...first, ...ids])]; + const results: MlsOwnDeviceAdd[] = []; + for (const conversationId of ordered) { + let result: MlsOwnDeviceAdd; + try { + result = await run(conversationId, () => addOwnDeviceTo(conversationId, deviceId, ownPersonKey)); + } catch (e) { + if (e instanceof MlsDriverError && e.code === "not_own_device") throw e; + const groupId = (await store.loadGroup(conversationId))?.groupId ?? ""; + result = { conversationId, groupId, outcome: "failed", add: null, error: e instanceof MlsDriverError ? e.code : "error" }; + } + results.push(result); + options.onProgress?.({ done: results.length, total: ordered.length, result }); + } + return results; + } + return { async start() { await ownDevice(); @@ -691,5 +809,7 @@ export function createMlsDmDriver(options: MlsDmDriverOptions): MlsDmDriver { if (!r.ok) throw refused("Removing the device", r); await handleDevicesChanged({ serverUserId: self }); }, + groupPositions, + addOwnDevice, }; } diff --git a/src/mls/interfaces.ts b/src/mls/interfaces.ts index b59f444..e71549d 100644 --- a/src/mls/interfaces.ts +++ b/src/mls/interfaces.ts @@ -208,6 +208,8 @@ export interface MlsPins { export interface MlsDecryptedMessage { conversationId: string; seq: number; + /** The epoch it was sent in. A pairing tail wants the old-epoch ones that land after an add. */ + epoch: number; senderServerUserId: string; senderDeviceId: string; plaintext: Uint8Array; @@ -245,6 +247,35 @@ export interface MlsOwnDevice extends MlsDeviceRef { thisDevice: boolean; } +/** A place in one group's log: a seq, and the epoch this device is in there. */ +export interface MlsGroupPosition { + conversationId: string; + groupId: string; + seq: number; + epoch: number; +} + +/** + * Where one of your devices joined one group. `add` is the commit's seq and the epoch the device + * starts in, or null when it didn't go in, or went in before this driver was running. + */ +export interface MlsOwnDeviceAdd { + conversationId: string; + groupId: string; + /** "added_by_other" when another member's commit put it in first. */ + outcome: "added" | "added_by_other" | "failed"; + add: { seq: number; epoch: number } | null; + /** Why it failed: an MlsDriverError code, or "too_many_devices", "no_key_package" or "error". */ + error?: string; +} + +export interface MlsAddOwnDeviceOptions { + /** Conversation ids to do first, most recently active first. The rest follow. */ + order?: readonly string[]; + /** After each group, with how many are done. */ + onProgress?(progress: { done: number; total: number; result: MlsOwnDeviceAdd }): void; +} + export interface MlsDmDriverOptions { transport: MlsTransport; store: MlsStateStore; @@ -278,4 +309,11 @@ export interface MlsDmDriver { ownDevices(): Promise; /** Your own devices only. Every group you share drops it on the next pass. */ removeOwnDevice(deviceId: string): Promise; + /** Pairing: each group's cursor now, which is what the history snapshot covers. */ + groupPositions(): Promise; + /** + * Pairing: adds one of your own new devices to every group now, and says where it went in. + * Throws `not_own_device` for a device the server doesn't list as yours, or not under your person key. + */ + addOwnDevice(deviceId: string, options?: MlsAddOwnDeviceOptions): Promise; }