From 181c8951471f1b31cdf319153dfccbdabe18fdf7 Mon Sep 17 00:00:00 2001 From: Sivert Date: Mon, 28 Sep 2026 16:32:44 +0200 Subject: [PATCH] Add your own new device to every DM now, and say where it went in (GRYT-1484) The pairing flow's approving device needs to add the newly linked device to every DM straight away, and the history transfer needs each group's add position to work out the tail. addOwnDevice does the adds, refusing a device that isn't under your own person key, and reports each group's position as it goes. groupPositions gives the snapshot's starting point, and decrypted messages now carry their epoch for the late old-epoch ones. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +- src/index.ts | 3 + src/mls/addOwnDevice.test.ts | 251 +++++++++++++++++++++++++++++++++++ src/mls/dmDriver.ts | 120 +++++++++++++++++ src/mls/interfaces.ts | 38 ++++++ 5 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 src/mls/addOwnDevice.test.ts 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; }