diff options
| -rw-r--r-- | src/marmot-client.js | 69 | ||||
| -rw-r--r-- | src/marmot.js | 15 | ||||
| -rw-r--r-- | tools/marmot-multidevice-test.js | 117 |
3 files changed, 184 insertions, 17 deletions
diff --git a/src/marmot-client.js b/src/marmot-client.js index d63cb04..4a79b21 100644 --- a/src/marmot-client.js +++ b/src/marmot-client.js @@ -30,8 +30,8 @@ const REACH_TTL = 10 * 60_000; const now = () => Math.floor(Date.now() / 1000); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAYS, subClosed }) { - const store = marmotStore(scope); +// `store` is injectable for tests outside a browser (no IndexedDB there). +export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAYS, subClosed, store = marmotStore(scope) }) { const groups = new Map(); // mls group id (hex) -> record const subs = new Map(); // route id -> { unsub, gid } const reach = new Map(); // pubkey -> { at, p } @@ -150,8 +150,11 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY return true; } - // Somebody else's KeyPackage, or null when they cannot be invited. - function lookup(pk) { + // Somebody's KeyPackages: one per device (a slot is one installation), the + // best first, at most ten. An invitation adds every one of them, so a person + // with a phone and a laptop gets the group on both — the rule Marmot's + // multi-device design settles on. `lookup` is the best one, or null. + function lookupAll(pk) { const hit = reach.get(pk); if (hit && Date.now() - hit.at < REACH_TTL) return hit.p; const p = (async () => { @@ -159,11 +162,12 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY const newest = lists.sort((a, b) => b.created_at - a.created_at)[0]; const where = [...new Set([...(newest ? writeRelays(newest).slice(0, 5) : []), ...PROFILE_RELAYS])]; const evs = await queryOn(where, { kinds: [M.KIND.KEY_PACKAGE], authors: [pk] }, 2500); - return M.pickKeyPackage(evs.map((e) => M.readKeyPackageEvent(e)).filter(Boolean)); - })().catch(() => null); + return M.pickKeyPackages(evs.map((e) => M.readKeyPackageEvent(e)).filter(Boolean)); + })().catch(() => []); reach.set(pk, { at: Date.now(), p }); return p; } + const lookup = (pk) => lookupAll(pk).then((all) => all[0] || null); // ---- relay traffic ---- @@ -250,8 +254,8 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY return p; } - async function deliverWelcome(g, welcome, cands) { - const s = await signer({ interactive: true }); + async function deliverWelcome(g, welcome, cands, { interactive = true } = {}) { + const s = await signer({ interactive }); if (!s) throw new Error('no signer'); const where = routeRelays(g); for (const c of cands) { @@ -264,10 +268,47 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY // ---- what the app can do ---- async function candidates(pks) { - const cands = await Promise.all(pks.map(lookup)); - const missing = pks.filter((_, i) => !cands[i]); + const lists = await Promise.all(pks.map(lookupAll)); + const missing = pks.filter((_, i) => !lists[i].length); if (missing.length) { const e = new Error('not reachable'); e.missing = missing; throw e; } - return cands; + return lists.flat(); + } + + // Our account's other devices. Each installation publishes its own + // KeyPackage under its own slot; where this account is an admin, a device + // that finds a sibling's fresh package not yet in a group adds it, and the + // Welcome reaches the sibling through our own inbox. The same-account rule + // of Marmot's multi-device direction, applied to the groups we already have + // (a non-admin cannot add anyone, so those groups wait for the spec). + let siblingTimer = 0, siblingBusy = false; + async function siblingAdds() { + if (stopped || !dev || siblingBusy) return; + siblingBusy = true; + try { + const s = await signer(); + if (!s) return; + const mine = await lookupAll(pubkey); + const own = new Set((dev.kps || []).map((k) => k.ref)); + const others = mine.filter((c) => c.slot !== dev.slot && !own.has(c.ref)); + if (!others.length) return; + for (const g of [...groups.values()]) { + if (stopped || g.removed || g.leaving || g.pending || g.fresh || !M.isAdmin(g)) continue; + const have = new Set(M.leafKeys(g.tip)); + const cands = others.filter((c) => !have.has(c.sigKey) && M.fits(g.tip, c)); + if (!cands.length || M.leafKeys(g.tip).length + cands.length > 64) continue; + try { + const p = await commit(g, M.invite(cands)); + await deliverWelcome(g, p.welcome, cands, { interactive: false }); + dlog('marmot: sibling add', g.id.slice(0, 8), cands.length); + } catch (e) { dlog('marmot: sibling add', e); } + await sleep(1500); + } + } catch (e) { dlog('marmot: siblings', e); } + finally { siblingBusy = false; } + } + function scheduleSiblings(delay = 25_000) { + clearTimeout(siblingTimer); + siblingTimer = setTimeout(() => { siblingAdds().then(() => { if (!stopped) scheduleSiblings(6 * 3600_000); }); }, delay); } // A direct chat (no name, both sides admins) or a named group. @@ -355,6 +396,7 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY const g = groups.get(j.g.id); if (g && !stopped && !g.removed && !g.pending) commit(g, []).catch((e) => dlog('marmot: self-update', e)); }, 4000 + Math.random() * 6000); + scheduleSiblings(40_000); return j.g; } @@ -378,6 +420,7 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY } } emit('loaded', null); + scheduleSiblings(); } function resubscribe() { @@ -390,14 +433,14 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY function stop() { stopped = true; rebuilding = true; - clearTimeout(rotateTimer); + clearTimeout(rotateTimer); clearTimeout(siblingTimer); for (const s of subs.values()) { try { s.unsub(); } catch {} } subs.clear(); flush(); } return { - pubkey, groups, load, start, stop, resubscribe, ready, announce, lookup, welcome, accept, + pubkey, groups, load, start, stop, resubscribe, ready, announce, lookup, lookupAll, siblingAdds, welcome, accept, create, send, add, kick, rename, leave, forget, event: (kind, content, tags) => M.appEvent(pubkey, kind, content, tags), // attachments: seal for the epoch a message is about to go out in, and diff --git a/src/marmot.js b/src/marmot.js index 7dd55d6..f6153a0 100644 --- a/src/marmot.js +++ b/src/marmot.js @@ -382,6 +382,7 @@ export function readKeyPackageEvent(ev, at = now()) { return { id: ev.id, pubkey: ev.pubkey, slot: d, ref: i, created_at: ev.created_at, lastResort, keyPackage: kp.keyPackage, + sigKey: bytesToHex(kp.leaf.signatureKey), // which device: the leaf it would occupy components: idList.dec(comp(kp.leaf.extensions, COMP.APP_COMPONENTS)), extensions: caps.extensions, proposals: caps.proposals, client: ev.tags.find((t) => t[0] === "client")?.[1] || "", @@ -389,17 +390,23 @@ export function readKeyPackageEvent(ev, at = now()) { } catch { return null; } } -// One candidate per (author, slot) — the newest, lower id on a tie — then -// single-use packages before last-resort ones, fresher first, lower ref last. -export function pickKeyPackage(candidates) { +// One candidate per (author, slot) — the newest, lower id on a tie: a slot +// is one installation, so this is one package per device. Ordered single-use +// before last-resort, fresher first, lower ref last; at most `max` of them +// (Marmot's multi-device direction caps an account at ten leaves). +export const MAX_DEVICES = 10; +export function pickKeyPackages(candidates, max = MAX_DEVICES) { const slots = new Map(); for (const c of candidates) { const k = c.pubkey + c.slot, cur = slots.get(k); if (!cur || c.created_at > cur.created_at || (c.created_at === cur.created_at && c.id < cur.id)) slots.set(k, c); } return [...slots.values()].sort((a, b) => - (a.lastResort - b.lastResort) || (b.created_at - a.created_at) || (a.ref < b.ref ? -1 : 1))[0] || null; + (a.lastResort - b.lastResort) || (b.created_at - a.created_at) || (a.ref < b.ref ? -1 : 1)).slice(0, max); } +export const pickKeyPackage = (candidates) => pickKeyPackages(candidates, 1)[0] || null; +// the signature keys of every leaf in a state: which devices are already in +export const leafKeys = (state) => mls.members(state).map((m) => bytesToHex(m.signatureKey)); // Can this KeyPackage join a group in this state? export function fits(state, cand) { diff --git a/tools/marmot-multidevice-test.js b/tools/marmot-multidevice-test.js new file mode 100644 index 0000000..264fbcb --- /dev/null +++ b/tools/marmot-multidevice-test.js @@ -0,0 +1,117 @@ +// Multi-device White Noise groups: one account on two coinos devices, against +// the Rust reference (MDK's `wn`), on public relays with throwaway identities. +// +// bun tools/marmot-multidevice-test.js +// +// Needs `wn` (github.com/marmot-protocol/mdk, crates/cli) on PATH or at WN, +// with its daemon on public relays: +// export WN_HOME=… WN_SECRET_STORE=file WN_SOCKET=/tmp/wn.sock +// wn daemon start --discovery-relays wss://nos.lol,wss://relay.primal.net \ +// --default-account-relays wss://nos.lol,wss://relay.primal.net +// +// Covers: both devices publish KeyPackages (one slot each); a wn invitation +// reaches whichever device(s) wn adds; a group founded on device A adds +// device B by itself (the admin's sibling add) through our own inbox; wn +// reads and writes a group holding two leaves of one npub; both devices read. +import { spawnSync } from 'node:child_process'; +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { npubEncode } from 'nostr-tools/nip19'; +// MD_DEBUG=1 shows the client's dlog lines (sibling adds and why they skip) +if (process.env.MD_DEBUG && typeof localStorage === 'undefined') { + globalThis.localStorage = { getItem: (k) => (k === 'coinos-debug' ? '1' : null), setItem() {}, removeItem() {} }; +} +const { marmotClient } = await import('../src/marmot-client.js'); +const { subscribeOn, publishOn } = await import('../src/nostr.js'); +const { unwrapDM } = await import('../src/dm.js'); + +const WN = process.env.WN || 'wn'; +const RELAYS = (process.env.RELAYS || 'wss://relay.coinos.io,wss://nos.lol,wss://relay.primal.net').split(','); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let failed = 0; +const ok = (cond, label, extra) => { if (cond) console.log(' ok ' + label); else { failed++; console.log(' FAIL ' + label + (extra ? ' — ' + extra : '')); } }; +function wn(args, { account } = {}) { + const r = spawnSync(WN, ['--json', ...(account ? ['--account', account] : []), ...args], { encoding: 'utf8', timeout: 60_000 }); + const line = (r.stdout || '').trim().split('\n').filter(Boolean).at(-1) || ''; + try { const j = JSON.parse(line); return j.ok ? j.result : { error: j.error }; } catch { return { error: { message: (r.stderr || r.stdout || 'no output').trim().slice(0, 300) } }; } +} +const waitFor = async (fn, label, ms = 45_000) => { const t = Date.now(); while (Date.now() - t < ms) { const v = await fn(); if (v) return v; await sleep(1500); } console.log(' … timed out waiting for ' + label); return null; }; +const memStore = () => { const m = new Map(); return { all: async () => new Map(m), put: async (k, v) => { m.set(k, structuredClone(v)); }, del: async (k) => { m.delete(k); }, wipe: async () => m.clear() }; }; + +const who = wn(['whoami']); +if (who.error) { console.log('SKIP: wn daemon not reachable (' + who.error.message + ')'); process.exit(0); } + +// one account, two devices +const sk = generateSecretKey(), pk = getPublicKey(sk); +const identity = async () => ({ pubkey: pk, signer: sk }); +const log = { A: [], B: [] }; +const mk = (name) => marmotClient({ + scope: 'test-' + name, pubkey: pk, identity, relays: RELAYS, store: memStore(), + on: (type, g, data) => { if (type === 'message') log[name].push(data); if (type === 'joined') console.log(` [${name}] joined ${g.id.slice(0, 8)} (invited by ${(g.inviter || '').slice(0, 8)})`); }, +}); +const A = mk('A'), B = mk('B'); +// the account's relay lists, so wn can route to us (the sync feature publishes these for a real wallet) +for (const kind of [10002, 10050]) await publishOn(RELAYS, finalizeEvent({ kind, created_at: Math.floor(Date.now() / 1000), tags: RELAYS.map((r) => [kind === 10002 ? 'r' : 'relay', r]), content: '' }, sk)); +// the DM inbox: every Welcome for our npub goes to both devices, as the app's inbox does +// no `since`: a gift wrap's created_at is randomized into the past (NIP-59) +subscribeOn(RELAYS, { kinds: [1059], '#p': [pk] }, async (ev) => { + let got; try { got = await unwrapDM(ev, sk); } catch { return; } + const rumor = got && got.rumor; + if (!rumor || rumor.kind !== 444) return; + for (const c of [A, B]) c.welcome(rumor).catch(() => {}); +}); + +try { + await A.start(); await B.start(); + ok(await A.ready({ interactive: true }), 'device A published a KeyPackage'); + ok(await B.ready({ interactive: true }), 'device B published a KeyPackage'); + await sleep(2000); + const mine = await A.lookupAll(pk); + ok(mine.length === 2 && new Set(mine.map((c) => c.slot)).size === 2, 'the account shows two devices (two slots)', JSON.stringify(mine.map((c) => c.slot.slice(0, 8)))); + + // ---- 1. wn invites the account: which devices get in? ---- + const alice = wn(['create-identity']).account_id; + const check = wn(['keys', 'check', pk]); + ok(check && !check.error, 'wn finds our relay lists + KeyPackages', JSON.stringify(check).slice(0, 200)); + const made = wn(['groups', 'create', 'multidev', pk], { account: alice }); + ok(!made.error, 'wn created a group with our npub', JSON.stringify(made.error || '').slice(0, 200)); + const wnGroup = made.group_id || made.group?.group_id || made.group_id_hex; + const joinedCount = await waitFor(async () => { const n = [A, B].filter((c) => [...c.groups.values()].some((g) => !g.removed)).length; return n ? n : null; }, 'a device joins the wn group', 40_000); + await sleep(6000); // a second Welcome may follow + const inWn = [A, B].filter((c) => [...c.groups.values()].some((g) => !g.removed)).map((c) => (c === A ? 'A' : 'B')); + console.log(' wn invited devices:', inWn.join(',') || 'none', '(MDK adds', inWn.length, 'of our 2 KeyPackages)'); + ok(inWn.length >= 1, 'at least one device joined the wn group'); + + // ---- 2. our group: device A founds it, invites wn, and adds device B on its own ---- + const g = await A.create({ name: 'two devices', members: [alice] }) // account_id is the hex pubkey.catch((e) => { console.log(' create failed:', e.message); return null; }); + ok(!!g, 'device A founded a group with the wn account'); + if (g) { + await A.siblingAdds(); + const gB = await waitFor(async () => [...B.groups.values()].find((x) => x.id === g.id && !x.removed) || null, 'device B joins through the sibling add'); + ok(!!gB, 'device B joined device A\'s group from our own inbox'); + if (gB) { + ok(gB.inviter === pk, 'the Welcome came from our own account', gB.inviter && gB.inviter.slice(0, 8)); + B.accept(gB); + ok(A.accounts(g).length === 2 && A.accounts(g).includes(pk), 'two accounts, three leaves', `accounts=${A.accounts(g).length}`); + } + // wn joins and must still work with two leaves of one npub in the tree + const seen = await waitFor(async () => JSON.stringify(wn(['chats', 'list'], { account: alice })).includes(g.id) || JSON.stringify(wn(['groups', 'invites'], { account: alice })).includes(g.id) || null, 'wn sees our group'); + ok(!!seen, 'wn received the Welcome to our group'); + wn(['groups', 'accept', g.id], { account: alice }); + await sleep(3000); + const show = wn(['groups', 'show', g.id], { account: alice }); + console.log(' wn groups show:', JSON.stringify(show).slice(0, 400)); + const posted = wn(['messages', 'send', g.id, 'hello both devices'], { account: alice }); + ok(!posted.error, 'wn sent into the three-leaf group', JSON.stringify(posted.error || '')); + ok(!!await waitFor(async () => log.A.some((m) => m.content === 'hello both devices') || null, 'A reads wn'), 'device A reads wn'); + ok(!!await waitFor(async () => log.B.some((m) => m.content === 'hello both devices') || null, 'B reads wn'), 'device B reads wn'); + if (gB) { + ok(await B.send(gB, B.event(9, 'from device B', [])), 'device B sent'); + ok(!!await waitFor(async () => JSON.stringify(wn(['messages', 'list', g.id, '--limit', '20'], { account: alice })).includes('from device B') || null, 'wn reads B'), 'wn reads device B'); + ok(!!await waitFor(async () => log.A.some((m) => m.content === 'from device B') || null, 'A reads B'), 'device A reads device B'); + } + ok(await A.send(g, A.event(9, 'from device A', [])), 'device A sent'); + ok(!!await waitFor(async () => JSON.stringify(wn(['messages', 'list', g.id, '--limit', '20'], { account: alice })).includes('from device A') || null, 'wn reads A'), 'wn reads device A'); + } + console.log(failed ? `\n${failed} FAILED` : '\nMULTI-DEVICE OK'); +} catch (e) { console.log('\nCRASH', e.stack || e.message); failed++; } +finally { A.stop(); B.stop(); await sleep(500); process.exit(failed ? 1 : 0); } |
