diff options
| author | Adam Soltys <[email protected]> | 2026-10-02 21:13:27 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-02 21:13:27 -0700 |
| commit | 0d698629a75f21046d6e544b34c42e0b2803b5a9 (patch) | |
| tree | 1f211e1a71be848e8c1dc09d76d6ce148b651214 /src | |
| parent | 544c61e8c27652c71501277b10875eeead0a402c (diff) | |
| download | coinosv3-0d698629a75f21046d6e544b34c42e0b2803b5a9.tar.gz coinosv3-0d698629a75f21046d6e544b34c42e0b2803b5a9.zip | |
White Noise (Marmot) chats: MLS group messaging in native JS
The Chat tab now speaks the protocol the White Noise app speaks, beside
Concord communities and NIP-17 DMs, without anyone having to pick one:
- A two-person group with no name is a direct message. It lands in the
same thread as that person's NIP-17 messages, and a reply goes back the
way their last message came. A first message goes by White Noise only
to someone whose KeyPackage another app published (a coinos wallet
reads NIP-17 on all its devices; an MLS group reaches one).
- Groups get rows of their own in the Messages list, a New group button,
an invitation card, members, add/remove/rename for admins, leave.
- Every wallet publishes a KeyPackage (kind 30443), so it can be invited.
src/mls.js is RFC 9420 for ciphersuite 0x0001 on the noble libs (no
WebAssembly), with the extensions-draft pieces Marmot needs
(app_data_dictionary, app_data_update, app_ephemeral, self_remove).
src/marmot.js is the Marmot layer: components, the account identity
proof, the 30443/444/445 event shapes and the group engine
(publish-before-apply, retained epochs, same-epoch tie-break).
State is per device, in IndexedDB (src/marmot-store.js). The whole stack
is one lazy chunk (72 KB).
Tested against the RFC 9420 vectors (tools/mls-vectors-test.js) and
against the Rust reference, MDK's wn CLI, at the protocol level
(tools/marmot-interop-test.js) and through the real UI
(tools/marmot-chat-test.js).
Not yet: attachments in these chats, push for group messages, the
app-message witness boost in fork selection.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 604 | ||||
| -rw-r--r-- | src/i18n.js | 28 | ||||
| -rw-r--r-- | src/marmot-client.js | 370 | ||||
| -rw-r--r-- | src/marmot-store.js | 74 | ||||
| -rw-r--r-- | src/marmot.js | 886 | ||||
| -rw-r--r-- | src/mls.js | 2178 |
6 files changed, 4129 insertions, 11 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 5f9d1dc..917202e 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -43,6 +43,7 @@ import { t, getLang } from '../i18n.js'; import { inLanguage, isMachinePost } from '../lang-guess.js'; import { animateZap, warmZapSound } from '../zap-animation.js'; import { SIGNER_SILENT } from '../dm.js'; +import { dlog } from '../debug.js'; // The coinos community's join material lives in ../community.js — shared // with the public read-only chat page so the two can never drift. @@ -355,6 +356,7 @@ export function messagesFeature(ctx) { for (const room of rooms.values()) for (const [id, msgs] of room.byChannel) bump(chRead(id), newestFrom(msgs.values(), (m) => !my.includes(m.author))); + for (const g of wnGroups()) bump(wnRead(g.id), wnNewest(g)); if (changed) { bumpMsgRev(); save(s); try { wallet.saveCache(); } catch {} } render(); } @@ -362,6 +364,7 @@ export function messagesFeature(ctx) { const anyUnread = () => { for (const [pk, msgs] of threads) if (dmUnread(pk, msgs)) return true; for (const room of rooms.values()) if (roomUnread(room)) return true; + for (const g of wnGroups()) if (wnUnread(g)) return true; return false; }; @@ -428,6 +431,8 @@ export function messagesFeature(ctx) { for (const room of rooms.values()) for (const [id, msgs] of room.byChannel) if (newestFrom(msgs.values(), (m) => !my.includes(m.author)) > Math.max(seen, s.read[chRead(id)] || 0)) n++; + for (const g of wnGroups()) + if (!g.fresh && wnNewest(g) > Math.max(seen, s.read[wnRead(g.id)] || 0)) n++; return n; } const communityById = (cid) => communities().find((c) => c.community_id === cid); @@ -1198,6 +1203,7 @@ export function messagesFeature(ctx) { } finally { tearingDown = false; } if (wasDm) startDMs(); for (const room of wanted) subscribeRoom(room); + if (wn) wn.resubscribe(); } // ---- subscription watchdog ------------------------------------------------ @@ -1230,6 +1236,7 @@ export function messagesFeature(ctx) { const urls = new Set(); if (dmStarted) for (const u of liveRelayList(DM_RELAYS)) urls.add(u); for (const room of rooms.values()) if (room.subscribed) for (const u of liveRelayList(room.relays)) urls.add(u); + if (wn) for (const u of liveRelayList(wn.relaysInUse())) urls.add(u); const dead = [...urls].filter((u) => !relayAlive(u)); if (!dead.length && !subsDead) { rebuildBackoff = 15_000; return; } const now = Date.now(); @@ -2612,9 +2619,9 @@ export function messagesFeature(ctx) { const threadOf = (peer) => threads.get(peer) || threads.set(peer, new Map()).get(peer); - function noteDM(peer, rumor, mine) { + function noteDM(peer, rumor, mine, wnId) { if (!peer || !rumor.id) return; - threadOf(peer).set(rumor.id, { rumor, mine }); + threadOf(peer).set(rumor.id, wnId ? { rumor, mine, wn: wnId } : { rumor, mine }); bumpMsgRev(); scheduleRepaint(); } @@ -2707,6 +2714,9 @@ export function messagesFeature(ctx) { pendingDirect.set(got.rumor.id, { bundle: b, from: got.author, rid: got.rumor.id }); scheduleRepaint(); } catch {} + } else if (got.rumor.kind === 444 && !isMe(got.author)) { + // a White Noise invitation: the group itself says who is asking + whiteNoise().then((c) => c && c.welcome(got.rumor)).catch(() => {}); } return true; } @@ -2852,6 +2862,8 @@ export function messagesFeature(ctx) { const id = await identity(); if (!id) { noIdToast(); return; } if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; } + const road = await wnRoute(peer).catch(() => null); + if (road) return sendWnDirect(peer, road, text); // Same optimistic shape as channel sends: the rumor is synchronous and on // screen at once. Wrapping the same rumor keeps the id, so the sent-copy // echo folds into this entry instead of duplicating it. @@ -2895,6 +2907,8 @@ export function messagesFeature(ctx) { if (!id) { noIdToast(); return; } if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; } ui.msgSheet = null; + // a reaction travels with the message it answers + if (m.wn) { const g = wn && wn.groups.get(m.wn); if (g) sendWnReaction(g, m, emoji); return; } const rumor = makeDMReaction(id.pubkey, peer, m.rumor.id, emoji, reactEmojiTags(emoji)); const r = dmReacts.get(m.rumor.id) || dmReacts.set(m.rumor.id, new Map()).get(m.rumor.id); const prev = r.get(id.pubkey); @@ -2927,7 +2941,7 @@ export function messagesFeature(ctx) { .slice(0, 30); s.dms = {}; for (const [peer, list] of byRecent) - s.dms[peer] = list.filter((m) => !m.pending).slice(-CACHE_MAX) + s.dms[peer] = list.filter((m) => !m.pending && !m.wn).slice(-CACHE_MAX) .map((m) => { const row = { id: m.rumor.id, from: m.rumor.pubkey, text: m.rumor.content, t: m.rumor.created_at }; // a message's custom emoji ride along, or they'd be text after a reload @@ -2938,6 +2952,275 @@ export function messagesFeature(ctx) { save(s); } + // ---- White Noise (Marmot) ----------------------------------------------- + // The third way a conversation can travel, and the one nobody should have + // to choose: MLS groups over nostr, the protocol the White Noise app + // speaks. A two-person group with no name IS a direct message — it lands + // in the same thread as that person's NIP-17 messages, and a reply goes + // back the way their last message came. Groups with a name or more people + // get rows of their own, beside the DMs. The MLS code is a chunk of its + // own (marmot-client.js), fetched once a wallet is open. + + let wn = null; // the client for the identity we speak as + let wnReadied = false; // asked (once a session) to make us invitable + let wnLoading = null; // { pk, p } while it loads + const wnReactOf = new Map(); // reaction event id -> { target, pk }: what a later delete undoes + + function whiteNoise() { + const pk = identityPk(); + if (!pk || !wallet._cacheKey) return Promise.resolve(null); + if (wn && wn.pubkey === pk) return Promise.resolve(wn); + if (wnLoading && wnLoading.pk === pk) return wnLoading.p; + if (wn) { try { wn.stop(); } catch {} wn = null; } + const p = import('../marmot-client.js').then(async (m) => { + if (identityPk() !== pk) return null; // the identity changed while the chunk loaded + // the relays new groups are founded on can be pointed elsewhere (dev and tests) + let relays = null; + try { relays = JSON.parse(localStorage.getItem('coinos-group-relays') || 'null'); } catch {} + const c = m.marmotClient({ + scope: wallet._cacheKey() + ':' + pk, pubkey: pk, identity, on: onWn, + ...(Array.isArray(relays) && relays.length ? { relays } : {}), + }); + wn = c; + wnLoading = null; + await c.start(); + return c; + }).catch((e) => { console.warn('chat: group chats unavailable', e); wnLoading = null; return null; }); + wnLoading = { pk, p }; + return p; + } + + // One delivered event of a direct group, into that person's thread. + function wnIntoThread(g, peer, m) { + const th = threadOf(peer); + const target = (m.tags.find((x) => x[0] === 'e') || [])[1]; + if (m.kind === 9) { + if (!th.has(m.id) || th.get(m.id).pending) th.set(m.id, { rumor: m, mine: isMe(m.pubkey), wn: g.id }); + } else if (m.kind === 7 && target) { + const key = target + '|' + m.pubkey, at = m.created_at * 1000; + if (at >= (dmReactAt.get(key) || 0)) { + dmReactAt.set(key, at); + (dmReacts.get(target) || dmReacts.set(target, new Map()).get(target)).set(m.pubkey, m.content); + wnReactOf.set(m.id, { target, pk: m.pubkey }); + } + } else if (m.kind === 5) { + // only the author takes a message (or a reaction) back + for (const [, id] of m.tags.filter((x) => x[0] === 'e')) { + const hit = th.get(id); + if (hit && hit.rumor.pubkey === m.pubkey) th.delete(id); + const r = wnReactOf.get(id); + if (r && r.pk === m.pubkey) dmReacts.get(r.target)?.delete(r.pk); + } + } else if (m.kind === 1009 && target) { + const hit = th.get(target); + if (hit && hit.rumor.pubkey === m.pubkey) th.set(target, { ...hit, rumor: { ...hit.rumor, content: m.content }, edited: true }); + } + } + + function wnReplay() { + if (!wn) return; + for (const g of wn.groups.values()) { + const peer = wn.peer(g); + if (!peer) continue; + if (g.fresh) wn.accept(g); // a direct message needs no invitation card + for (const m of g.log) wnIntoThread(g, peer, m); + } + } + + function onWn(type, g, data) { + if (!wn) return; + if (type === 'message') { + const peer = wn.peer(g); + if (peer) wnIntoThread(g, peer, data); + try { noteEmoji(data); } catch {} + } else if (type === 'loaded' || type === 'joined') wnReplay(); + else if (type === 'withdrawn') for (const th of threads.values()) for (const id of data) th.delete(id); + wnFolds.delete(g && g.id); + bumpMsgRev(); + scheduleRepaint(); + } + + // The live direct group with this person (the newest, if a chat was remade). + function wnDirect(peer) { + if (!wn) return null; + let best = null; + for (const g of wn.groups.values()) + if (!g.removed && !g.leaving && wn.peer(g) === peer && (!best || g.at > best.at)) best = g; + return best; + } + + // Which way a message to this person travels: a group record, 'new' (found + // a direct group first), or null for NIP-17. A reply goes back the way + // their last message came. A first message goes by White Noise only to + // someone whose KeyPackage was published by another app: a coinos wallet + // reads NIP-17 on every device it has, where an MLS group reaches just the + // one device whose KeyPackage was picked. + async function wnRoute(peer) { + const c = await whiteNoise(); + if (!c) return null; + await c.load(); + const g = wnDirect(peer); + let theirs = null; + for (const m of threads.get(peer)?.values() || []) + if (!m.mine && (!theirs || m.rumor.created_at > theirs.rumor.created_at)) theirs = m; + if (g) return theirs && !theirs.wn ? null : g; + if (theirs) return null; + const cand = await Promise.race([c.lookup(peer), new Promise((r) => setTimeout(() => r(null), 3500))]); + return cand && cand.client !== 'coinos' ? 'new' : null; + } + + async function sendWnDirect(peer, road, text) { + const c = wn; + const replyTo = ui.msgReplyTo && threadOf(peer).has(ui.msgReplyTo) ? ui.msgReplyTo : null; + ui.msgReplyTo = null; + ui.emojiAc = null; + const inner = c.event(9, text, [...(replyTo ? [['e', replyTo]] : []), ...outboundEmojiTags(text, emojiUrl)]); + const entry = { rumor: inner, mine: true, pending: true, wn: true }; + threadOf(peer).set(inner.id, entry); + clearDraft('dm:' + peer); + ui.msgStick = true; + render(); + try { + const g = road === 'new' ? await c.create({ members: [peer] }) : road; + entry.wn = g.id; + if (!(await c.send(g, inner))) throw new Error(t('msgSendFailed')); + delete entry.pending; + render(); + } catch (e) { + threadOf(peer).delete(inner.id); + toast(e.message === 'no signer' ? t('msgNoIdentity') : (e.message || String(e))); + render(); + } + } + + async function sendWnReaction(g, m, emoji) { + const inner = wn.event(7, emoji, [['e', m.rumor.id], ['p', m.rumor.pubkey], ['k', String(m.rumor.kind)], ...reactEmojiTags(emoji)]); + const ok = await wn.send(g, inner).catch(() => false); + if (!ok) { toast(t('msgSendFailed')); return; } + onWn('message', g, inner); + } + + // ---- groups ---- + + const wnRead = (id) => 'wn:' + id; + // groups with a screen of their own (a direct group lives in its DM thread) + const wnGroups = () => (wn ? [...wn.groups.values()].filter((g) => !wn.peer(g)) : []); + + function wnTitle(g) { + const v = wn.view(g); + if (v.name) return v.name; + const others = wn.accounts(g).filter((pk) => !isMe(pk)); + return others.length ? others.slice(0, 3).map(displayName).join(', ') : t('msgGroupUnnamed'); + } + + // A group's log folded for the screen: chat and system rows in time order + // (edits applied, deletions gone) and who reacted with what. + const wnFolds = new Map(); // gid -> { n, last, rows, reacts } + function wnFold(g) { + const memo = wnFolds.get(g.id), last = g.log.length ? g.log[g.log.length - 1].id : ''; + if (memo && memo.n === g.log.length && memo.last === last) return memo; + const byId = new Map(), reacts = new Map(), reactOf = new Map(); + const sorted = [...g.log].sort((a, b) => a.created_at - b.created_at); + for (const m of sorted) if (m.kind === 9 || m.kind === 1210) byId.set(m.id, { rumor: m, mine: isMe(m.pubkey), wn: g.id }); + for (const m of sorted) { + const target = (m.tags.find((x) => x[0] === 'e') || [])[1]; + if (m.kind === 7 && target) { + (reacts.get(target) || reacts.set(target, new Map()).get(target)).set(m.pubkey, m.content); + reactOf.set(m.id, { target, pk: m.pubkey }); + } else if (m.kind === 5) { + for (const [, id] of m.tags.filter((x) => x[0] === 'e')) { + const hit = byId.get(id); + if (hit && hit.rumor.pubkey === m.pubkey && hit.rumor.kind === 9) byId.delete(id); + const r = reactOf.get(id); + if (r && r.pk === m.pubkey) reacts.get(r.target)?.delete(r.pk); + } + } else if (m.kind === 1009 && target) { + const hit = byId.get(target); + if (hit && hit.rumor.pubkey === m.pubkey) byId.set(target, { ...hit, rumor: { ...hit.rumor, content: m.content }, edited: true }); + } + } + const fold = { n: g.log.length, last, rows: [...byId.values()], reacts }; + wnFolds.set(g.id, fold); + return fold; + } + const wnChat = (g) => wnFold(g).rows.filter((m) => m.rumor.kind === 9); + const wnNewest = (g) => newestFrom(wnChat(g), (m) => !m.mine); + const wnUnread = (g) => !g.fresh && wnNewest(g) > (st().read[wnRead(g.id)] || 0); + + // "Ana added Ben", from a locally derived kind-1210 row + function wnSystemText(m) { + let c; + try { c = JSON.parse(m.content); } catch { return ''; } + const d = c.data || {}; + const a = d.actor ? displayName(d.actor) : '', b = d.subject ? displayName(d.subject) : ''; + switch (c.system_type) { + case 'member_added': return d.actor && d.actor !== d.subject ? t('msgSysAdded', { a, b }) : t('msgSysJoined', { b }); + case 'member_removed': return t('msgSysRemoved', { a, b }); + case 'member_left': return t('msgSysLeft', { b }); + case 'admin_added': return t('msgSysAdminAdded', { b }); + case 'admin_removed': return t('msgSysAdminRemoved', { b }); + case 'group_renamed': return t('msgSysRenamed', { a, name: d.name || '' }); + case 'group_disbanded': return t('msgSysDisbanded', { a }); + default: return c.text || ''; + } + } + + // People being gathered for a group (the New group panel, or Add person): + // each is checked for a KeyPackage as they are picked. + const wnReach = new Map(); // pk -> 'checking' | 'ok' | 'no' + function wnCheck(pk) { + if (wnReach.get(pk) === 'ok' || wnReach.get(pk) === 'checking') return; + wnReach.set(pk, 'checking'); + whiteNoise().then((c) => (c ? c.lookup(pk) : null)).then((cand) => { wnReach.set(pk, cand ? 'ok' : 'no'); scheduleRepaint(); }) + .catch(() => { wnReach.set(pk, 'no'); scheduleRepaint(); }); + } + const wnFail = (e) => { + if (e && e.missing) toast(t('msgGroupSomeCant', { names: e.missing.map(displayName).join(', ') })); + else if (e && e.message === 'no signer') toast(t('msgNoIdentity')); + else toast(t('msgGroupFailed')); + dlog('chat: group', e); + }; + + async function wnCreateGroup() { + const members = (ui.msgGroupPicks || []).filter((pk) => !isMe(pk)); + if (!members.length) { toast(t('msgGroupNeedPeople')); return; } + if (ui.msgBusy) return; + ui.msgBusy = true; render(); + try { + const c = await whiteNoise(); + if (!c) throw new Error('no signer'); + // a group with no name is still a group once it has a third person; + // with two it would be a direct chat, so it takes a name + const name = (ui.msgGroupName || '').trim() || (members.length === 1 ? t('msgGroupUnnamed') : ''); + const g = await c.create({ name, members }); + ui.msgGroupPicks = []; ui.msgGroupName = ''; ui.msgGroupTo = ''; ui.msgHomePanel = null; + dmSearcher.clear(); + ui.msgView = 'grp'; ui.msgPeer = g.id; ui.msgStick = true; + } catch (e) { wnFail(e); } finally { ui.msgBusy = false; render(); } + } + + async function wnSend(g) { + const text = draftFor('wn:' + g.id).trim(); + if (!text || !wn) return; + const replyTo = ui.msgReplyTo && wnFold(g).rows.some((m) => m.rumor.id === ui.msgReplyTo) ? ui.msgReplyTo : null; + ui.msgReplyTo = null; + ui.emojiAc = null; + const inner = wn.event(9, text, [...(replyTo ? [['e', replyTo]] : []), ...outboundEmojiTags(text, emojiUrl)]); + clearDraft('wn:' + g.id); + ui.msgStick = true; + // the engine logs our own event as it encrypts it, so it is on screen at once + const sent = wn.send(g, inner).catch(() => false); + wnFolds.delete(g.id); + render(); + if (!(await sent)) { wnFolds.delete(g.id); toast(t('msgSendFailed')); render(); } + } + + async function wnAct(fn) { + if (ui.msgBusy) return; + ui.msgBusy = true; render(); + try { await fn(); } catch (e) { wnFail(e); } finally { ui.msgBusy = false; render(); } + } + // ---- views -------------------------------------------------------------- const timeLabel = (tms) => { @@ -6369,6 +6652,7 @@ export function messagesFeature(ctx) { const id = await identity(); if (!id) { noIdToast(); return; } if (!(id.signer instanceof Uint8Array) && !id.signer.encryptTo) { toast(t('msgSignerNoDm')); return; } + if (await wnRoute(peer).catch(() => null)) { toast(t('msgGroupNoFiles')); return; } ui.msgUploading = true; render(); try { const enc = await encryptFile(file); @@ -9409,6 +9693,22 @@ export function messagesFeature(ctx) { if (Date.now() - listsSyncedAt > 60_000) syncLists({ force: true }).catch(() => {}); for (const jm of communities()) ensureRoom(jm); + // Group chats ride along: the client loads with the wallet, and standing + // on this screen is what makes us invitable (a KeyPackage on the relays). + whiteNoise().then(async (c) => { + if (!c || wnReadied) return; + wnReadied = true; + await c.load(); + // A login signer has to sign the device's proof once, and may ask its + // owner. Unprompted, that question is put at most once a week. + if (!c.isSetUp() && hook('nostrLoginIdentity')) { + const s = st(); + if (Date.now() - (s.wnAsked || 0) < 7 * 86400_000) return; + s.wnAsked = Date.now(); + save(s); + } + c.ready().catch(() => {}); + }).catch(() => {}); for (const peer of threads.keys()) profileOf(peer); // names and faces, in one batch const dmRows = [...threads.entries()] .map(([peer, m]) => { @@ -9428,6 +9728,7 @@ export function messagesFeature(ctx) { for (const room of rooms.values()) for (const msgs of room.byChannel.values()) newest = Math.max(newest, newestFrom(msgs.values(), (m) => !my.includes(m.author))); + for (const g of wnGroups()) newest = Math.max(newest, wnNewest(g)); markRead(HOME_READ, newest); } @@ -9448,6 +9749,7 @@ export function messagesFeature(ctx) { if (pendingLink && pendingLink.where !== 'communities') kids.push(linkInviteCard()); for (const [rid, inv] of pendingDirect) kids.push(directInviteCard(rid, inv)); + for (const g of wnGroups()) if (g.fresh && !g.removed) kids.push(wnInviteCard(g)); // offer push once — it covers messages AND payments if (typeof Notification !== 'undefined' && Notification.permission === 'default' && !st().pushDismissed) @@ -9472,7 +9774,10 @@ export function messagesFeature(ctx) { // ---- DMs kids.push(h('div', { class: 'row between', style: 'align-items:baseline' }, h('h3', { style: 'margin:0' }, t('msgDmsTitle')), - h('button', { class: 'btn-sm', onClick: () => { ui.msgHomePanel = ui.msgHomePanel === 'newdm' ? null : 'newdm'; if (ui.msgHomePanel === 'newdm') warmSearch(); render(); } }, t('msgNewDm')))); + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-sm', onClick: () => { dmSearcher.clear(); ui.msgHomePanel = ui.msgHomePanel === 'newgroup' ? null : 'newgroup'; if (ui.msgHomePanel === 'newgroup') warmSearch(); render(); } }, t('msgNewGroup')), + h('button', { class: 'btn-sm', onClick: () => { dmSearcher.clear(); ui.msgHomePanel = ui.msgHomePanel === 'newdm' ? null : 'newdm'; if (ui.msgHomePanel === 'newdm') warmSearch(); render(); } }, t('msgNewDm'))))); + if (ui.msgHomePanel === 'newgroup') kids.push(wnNewGroupPanel()); if (ui.msgHomePanel === 'newdm') { const openThread = (pk) => { dmSearcher.clear(); @@ -9507,10 +9812,15 @@ export function messagesFeature(ctx) { // A long DM history must not bury the communities below it: past four, // the rest waits behind "show all". Communities get the same cap. const LIST_PREVIEW = 4; - const shownDms = ui.msgAllDms ? dmRows : dmRows.slice(0, LIST_PREVIEW); + // one list, newest first: a group chat is a conversation like any other + const chatRows = [ + ...dmRows.map((x) => ({ at: x.last.rumor.created_at, dm: x })), + ...wnGroups().filter((g) => !g.fresh).map((g) => ({ at: (wnFold(g).rows.at(-1) || {}).rumor?.created_at || g.at, g })), + ].sort((a, b) => b.at - a.at); + const shownDms = ui.msgAllDms ? chatRows : chatRows.slice(0, LIST_PREVIEW); kids.push( - dmRows.length - ? h('div', { class: 'list' }, shownDms.map(({ peer, last, unread }) => + chatRows.length + ? h('div', { class: 'list' }, shownDms.map((row) => row.g ? wnGroupRow(row.g) : (({ peer, last, unread }) => h('div', { class: 'item chat-thread-row' + (unread ? ' unread' : ''), onClick: () => { ui.msgView = 'dm'; ui.msgPeer = peer; ui.msgStick = true; render(); }, @@ -9522,12 +9832,12 @@ export function messagesFeature(ctx) { h('span', { class: 'chat-time thread-when' }, timeLabel(last.rumor.created_at * 1000))), h('div', { class: 'muted small chat-preview' }, (last.mine ? t('msgYouPrefix') + ' ' : '') + (last.rumor.kind === 15 ? '📎 ' + t('msgPhoto') : last.rumor.content))), // the row's own dot, centred and inset like the communities' below - unread ? h('i', { class: 'thread-dot' }) : null))) + unread ? h('i', { class: 'thread-dot' }) : null))(row.dm))) : h('div', { class: 'muted small' }, t('msgNoDms'))); - if (shownDms.length < dmRows.length) + if (shownDms.length < chatRows.length) kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllDms = true; render(); } }, - t('msgShowAllDms', { n: dmRows.length }))); - else if (ui.msgAllDms && dmRows.length > LIST_PREVIEW) + t('msgShowAllDms', { n: chatRows.length }))); + else if (ui.msgAllDms && chatRows.length > LIST_PREVIEW) kids.push(h('button', { class: 'linklike small', onClick: () => { ui.msgAllDms = false; render(); } }, t('msgShowFewerDms'))); @@ -9901,6 +10211,264 @@ export function messagesFeature(ctx) { null); } + // ---- group chats (White Noise) -------------------------------------------- + + function wnGroupRow(g) { + const name = wnTitle(g); + const last = wnChat(g).at(-1); + const unread = wnUnread(g); + for (const pk of wn.accounts(g).slice(0, 12)) profileOf(pk); + return h('div', { + class: 'item chat-thread-row' + (unread ? ' unread' : ''), + onClick: () => { ui.msgView = 'grp'; ui.msgPeer = g.id; ui.msgStick = true; ui.msgMembers = false; render(); }, + }, + h('div', { class: 'chat-avatar fallback' }, name.slice(0, 2)), + h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, + h('div', { class: 'row between' }, + h('span', { class: 'chat-name' }, name), + last ? h('span', { class: 'chat-time thread-when' }, timeLabel(last.rumor.created_at * 1000)) : null), + h('div', { class: 'muted small chat-preview' }, + g.removed ? t('msgGroupGone') + : last ? (last.mine ? t('msgYouPrefix') : displayName(last.rumor.pubkey) + ':') + ' ' + (last.rumor.content || '📎 ' + t('msgGroupAttachment')) + : t('msgMembers', { n: wn.accounts(g).length }))), + unread ? h('i', { class: 'thread-dot' }) : null); + } + + function wnInviteCard(g) { + return h('div', { class: 'notice info col', style: 'gap:8px' }, + h('div', {}, t('msgGroupInvite', { name: displayName(g.inviter), group: wnTitle(g) })), + h('div', { class: 'row gap6' }, + h('button', { + class: 'btn-primary btn-sm', + onClick: () => { wn.accept(g); ui.msgView = 'grp'; ui.msgPeer = g.id; ui.msgStick = true; render(); }, + }, t('msgJoin')), + h('button', { + class: 'btn-ghost btn-sm', disabled: ui.msgBusy, + onClick: () => wnAct(() => wn.leave(g)), + }, t('msgGroupDecline')))); + } + + // Search, tap, and the person becomes a chip that says whether they can be + // reached. Shared by New group and a group's Add person. + function wnPeoplePicker(picks, onPick, onDrop) { + for (const pk of picks) { profileOf(pk); wnCheck(pk); } + const add = (pk) => { if (!isMe(pk) && !picks.includes(pk)) onPick(pk); dmSearcher.clear(); ui.msgGroupTo = ''; render(); }; + return [ + picks.length ? h('div', { class: 'row gap6', style: 'flex-wrap:wrap' }, picks.map((pk) => { + const state = wnReach.get(pk); + return h('span', { class: 'chat-react' + (state === 'no' ? ' muted' : ' on'), title: state === 'no' ? t('msgGroupCantJoin') : '' }, + displayName(pk), + state === 'checking' ? ' · ' + t('msgGroupChecking') : state === 'no' ? ' · ' + t('msgGroupCantJoin') : '', + ' ', h('button', { class: 'linklike', 'aria-label': t('msgGroupRemove'), onClick: () => { onDrop(pk); render(); } }, '×')); + })) : null, + h('input', { + type: 'text', placeholder: t('msgGroupPeople'), value: ui.msgGroupTo || '', + onInput: (e) => { ui.msgGroupTo = e.target.value; dmSearcher.update(e.target.value); }, + onKeydown: (e) => { if (e.key === 'Enter') { const pk = parseNostrPubkey(ui.msgGroupTo); if (pk) add(pk); } }, + }), + dmSearch.rows === null ? null + : dmSearch.busy ? h('div', { class: 'row gap6', style: 'align-items:center;padding:4px 0' }, + h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('msgSearching'))) + : dmSearch.rows.length + ? h('div', { class: 'list' }, resultRows(h, dmSearch.rows, (r) => add(r.pk), (pk, node) => hook('wrapAvatar', pk, node))) + : h('div', { class: 'small muted' }, t('msgNoMatches')), + ]; + } + + function wnNewGroupPanel() { + const picks = ui.msgGroupPicks || (ui.msgGroupPicks = []); + const ready = picks.length && picks.every((pk) => wnReach.get(pk) === 'ok'); + return h('div', { class: 'col gap6' }, + h('input', { + type: 'text', placeholder: t('msgGroupName'), maxlength: '64', + value: ui.msgGroupName || '', onInput: (e) => { ui.msgGroupName = e.target.value; }, + }), + ...wnPeoplePicker(picks, (pk) => picks.push(pk), (pk) => picks.splice(picks.indexOf(pk), 1)), + h('div', { class: 'row' }, + h('button', { class: 'btn-primary btn-sm', disabled: !ready || ui.msgBusy, onClick: wnCreateGroup }, + ui.msgBusy ? h('span', { class: 'spinner sm' }) : t('msgGroupCreate')))); + } + + function wnMemberPanel(g, admin) { + const v = wn.view(g); + const accounts = wn.accounts(g); + const picks = ui.msgGroupAdd || (ui.msgGroupAdd = []); + return h('div', { class: 'col chat-members' }, + h('div', { class: 'muted small', style: 'padding:8px 12px 2px' }, t('msgGroupInfo')), + ...accounts.map((pk) => h('div', { class: 'item chat-thread-row', onClick: () => openProfile(pk) }, + avatar(pk), + h('div', { class: 'row gap6 grow', style: 'align-items:center;min-width:0' }, + h('span', { class: 'chat-name' }, displayName(pk)), + v.admins.includes(pk) ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null), + admin && !isMe(pk) + ? h('button', { + class: 'btn-ghost btn-sm', disabled: ui.msgBusy, + onClick: (e) => { e.stopPropagation(); wnAct(() => wn.kick(g, pk)); }, + }, t('msgGroupRemove')) + : null)), + admin ? h('div', { class: 'col gap6 chat-invite' }, + ...wnPeoplePicker(picks, (pk) => picks.push(pk), (pk) => picks.splice(picks.indexOf(pk), 1)), + picks.length ? h('div', { class: 'row' }, h('button', { + class: 'btn-primary btn-sm', disabled: ui.msgBusy || !picks.every((pk) => wnReach.get(pk) === 'ok'), + onClick: () => wnAct(async () => { await wn.add(g, [...picks]); ui.msgGroupAdd = []; }), + }, ui.msgBusy ? h('span', { class: 'spinner sm' }) : t('msgAddPerson'))) : null, + h('div', { class: 'row gap6' }, + h('input', { + class: 'grow', type: 'text', placeholder: t('msgGroupName'), maxlength: '64', + value: ui.msgGroupNewName ?? v.name, onInput: (e) => { ui.msgGroupNewName = e.target.value; }, + }), + h('button', { + class: 'btn-sm', disabled: ui.msgBusy, + onClick: () => { + const name = (ui.msgGroupNewName ?? v.name).trim(); + if (name && name !== v.name) wnAct(async () => { await wn.rename(g, name); ui.msgGroupNewName = null; }); + }, + }, t('msgGroupRename')))) : null); + } + + function wnSheet(g) { + const fold = wnFold(g); + const m = fold.rows.find((x) => x.rumor.id === ui.msgSheet); + if (!m) { ui.msgSheet = null; return null; } + const reacts = fold.reacts.get(m.rumor.id); + const myReact = reacts && myPubkeys().map((pk) => reacts.get(pk)).find(Boolean); + const close = () => { ui.msgSheet = null; ui.emojiPick = null; render(); }; + const item = (icon, label, onClick) => h('button', { class: 'msg-sheet-item', onClick }, + h('span', { class: 'msg-sheet-ico' }, icon), label); + return h('div', { + class: 'confirm-pop-backdrop', + onClick: (e) => { if (e.target === e.currentTarget) close(); }, + }, + h('div', { class: 'card col msg-sheet' }, + g.removed ? null : reactRow(myReact, (e2) => { close(); sendWnReaction(g, m, e2); }), + ui.emojiPick || g.removed ? null : item('↩', t('msgReply'), () => { + ui.msgReplyTo = m.rumor.id; + close(); + setTimeout(() => document.getElementById('msg-draft')?.focus(), 50); + }), + ui.emojiPick ? null : item('⧉', t('copy'), async () => { + try { await navigator.clipboard.writeText(m.rumor.content); toast(t('copied')); } catch {} + close(); + }), + !ui.emojiPick && m.mine && !g.removed ? item('×', t('msgDelete'), async () => { + close(); + const del = wn.event(5, '', [['e', m.rumor.id], ['k', '9']]); + if (await wn.send(g, del).catch(() => false)) onWn('message', g, del); + }) : null)); + } + + function wnView() { + const g = wn && wn.groups.get(ui.msgPeer); + if (!g) { + // the groups are still loading (a reload straight into this screen) + if (!wn || wnLoading) { whiteNoise().then(() => scheduleRepaint()); return h('div', { class: 'card col chat-card chat-page' }, h('span', { class: 'spinner' })); } + ui.msgView = 'home'; + return homeView(); + } + const fold = wnFold(g); + const v = wn.view(g); + const my = myPubkeys(); + const accounts = wn.accounts(g); + const admin = wn.isAdmin(g) && !g.removed && !g.leaving; + const live = !g.removed && !g.leaving; + for (const pk of accounts) profileOf(pk); + markRead(wnRead(g.id), wnNewest(g)); + stickToBottom(); + const byId = new Map(fold.rows.map((m) => [m.rumor.id, m])); + const back = () => { ui.msgView = 'home'; ui.msgReplyTo = null; ui.msgSheet = null; ui.msgMembers = false; ui.msgLeaveArm = false; ui.msgGroupAdd = []; ui.msgGroupNewName = null; dmSearcher.clear(false); render(); }; + let lastAuthor = null, lastT = 0; + const rows = fold.rows.map((m) => { + if (m.rumor.kind === 1210) { + lastAuthor = null; + return h('div', { class: 'muted small', style: 'text-align:center;padding:6px 0' }, wnSystemText(m.rumor)); + } + const tms = m.rumor.created_at * 1000; + const grouped = m.rumor.pubkey === lastAuthor && tms - lastT < 5 * 60_000; + lastAuthor = m.rumor.pubkey; lastT = tms; + const em = emojiTagMap(m.rumor.tags); + const reacts = fold.reacts.get(m.rumor.id); + const counts = new Map(); + if (reacts) for (const emoji of reacts.values()) counts.set(emoji, (counts.get(emoji) || 0) + 1); + const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean); + const replyId = (m.rumor.tags.find((x) => x[0] === 'e') || [])[1]; + const src = replyId && byId.get(replyId); + const body = m.rumor.content + ? noteBody(m.rumor.content, 0, em) + : [h('span', { class: 'muted' }, '📎 ' + t('msgGroupAttachment'))]; + return h('div', { class: 'chat-row' + (m.mine ? ' mine' : '') + (grouped ? ' grouped' : '') }, + grouped ? h('div', { class: 'chat-avatar spacer' }) : avatar(m.rumor.pubkey), + h('div', { class: 'chat-body' }, + grouped ? null : h('div', { class: 'chat-meta' }, + h('span', { class: 'chat-name clickable', onClick: () => openProfile(m.rumor.pubkey) }, + displayName(m.rumor.pubkey), + v.admins.includes(m.rumor.pubkey) ? h('span', { class: 'chat-badge' }, t('msgAdmin')) : null), + h('span', { class: 'chat-time' }, timeLabel(tms))), + h('div', { + class: 'chat-bubble clickable' + (emojiJumbo(m.rumor.content, em) ? ' jumbo' : ''), + onClick: (e) => { + if (e.target.closest && e.target.closest('a, button, img:not(.cemoji)')) return; + const sel = window.getSelection && window.getSelection(); + if (sel && String(sel).length) return; + ui.msgSheet = ui.msgSheet === m.rumor.id ? null : m.rumor.id; + render(); + }, + }, + src ? h('div', { class: 'chat-quote' }, + h('span', { class: 'chat-quote-name' }, displayName(src.rumor.pubkey)), + h('span', { class: 'chat-quote-text' }, ...snippetNodes(src.rumor))) : null, + ...body, + m.edited ? h('span', { class: 'chat-edited' }, ' ', t('msgEdited')) : null, + counts.size ? h('div', { class: 'chat-reacts' }, + [...counts.entries()].map(([emoji, n]) => h('span', { + class: 'chat-react clickable' + (emoji === myReact ? ' on' : ''), + onClick: (e) => { e.stopPropagation(); if (live) sendWnReaction(g, m, emoji); }, + }, reactNode(emoji), n > 1 ? ' ' + n : ''))) : null))); + }); + const reply = ui.msgReplyTo && byId.get(ui.msgReplyTo); + if (ui.msgReplyTo && !reply) ui.msgReplyTo = null; + return h('div', { class: 'card col chat-card chat-page' }, + h('div', { class: 'row between chat-head' }, + h('div', { class: 'row gap6', style: 'align-items:center;min-width:0' }, + backBtn(back), + h('div', { + class: 'col clickable', style: 'gap:2px;min-width:0', + onClick: () => { ui.msgMembers = !ui.msgMembers; dmSearcher.clear(false); render(); }, + }, + h('div', { class: 'chat-title' }, wnTitle(g)), + h('div', { class: 'muted small' }, g.removed ? t('msgGroupGone') : t('msgMembers', { n: accounts.length })))), + h('div', { class: 'row gap6', style: 'align-items:center' }, + admin ? h('button', { + class: 'btn-sm', onClick: () => { ui.msgMembers = !ui.msgMembers; dmSearcher.clear(false); render(); }, + }, t('msgAddPerson')) : null, + // the door out: one tap arms it, the second leaves (same as a community) + h('button', { + class: 'btn-sm' + (ui.msgLeaveArm ? ' btn-danger' : ''), title: t('msgGroupLeave'), 'aria-label': t('msgGroupLeave'), disabled: ui.msgBusy, + onClick: () => { + if (!ui.msgLeaveArm) { ui.msgLeaveArm = true; render(); return; } + ui.msgLeaveArm = false; + wnAct(async () => { await wn.leave(g); ui.msgView = 'home'; }); + }, + html: ui.msgLeaveArm ? null : '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/></svg>', + }, ui.msgLeaveArm ? t('msgLeaveConfirmShort') : null))), + ui.msgMembers ? wnMemberPanel(g, admin) : null, + h('div', { + class: 'chat-log', + onScroll: (e) => { + const el = e.target; + ui.msgStick = el.scrollHeight - el.scrollTop - el.clientHeight < 60; + }, + }, ...(rows.length ? rows : [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgNoDmsYet'))])), + reply ? h('div', { class: 'reply-bar' }, + h('div', { class: 'col grow', style: 'gap:1px;min-width:0' }, + h('span', { class: 'small', style: 'font-weight:650' }, '↩ ', displayName(reply.rumor.pubkey)), + h('span', { class: 'small muted chat-quote-text' }, ...snippetNodes(reply.rumor))), + h('button', { class: 'chat-del', style: 'position:static;display:flex;flex-shrink:0', onClick: () => { ui.msgReplyTo = null; render(); } }, '×')) : null, + live + ? composer(t('msgDmPlaceholder'), () => wnSend(g), null, 'wn:' + g.id, null) + : h('div', { class: 'muted small', style: 'text-align:center;padding:12px' }, t('msgGroupGone')), + ui.msgSheet ? wnSheet(g) : null); + } + // ---- dm thread ---------------------------------------------------------- // The DM flavor of the message action sheet: reactions + Reply + Copy. @@ -9938,6 +10506,8 @@ export function messagesFeature(ctx) { if (!peer) { ui.msgView = 'home'; return homeView(); } const thread = threads.get(peer) || new Map(); const msgs = [...thread.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at); + // a first message may go by White Noise: have their KeyPackage looked up before Send + if (!msgs.some((m) => !m.mine)) whiteNoise().then((c) => c && c.lookup(peer)).catch(() => {}); // Looking at the thread is reading it — including anything that lands while // it's still open, since every arrival repaints us. markRead(dmRead(peer), newestFrom(msgs, (m) => !m.mine)); @@ -10323,11 +10893,14 @@ export function messagesFeature(ctx) { if (ui.msgView === 'notifs') return notifView(); if (ui.msgView === 'room') return roomView(); if (ui.msgView === 'dm') return dmView(); + if (ui.msgView === 'grp') return wnView(); return homeView(); } return { id: 'messages', + // group-chat state is device state in IndexedDB: it goes with the account + wipeCache(bases) { import('../marmot-store.js').then((m) => { for (const b of bases) m.wipeMarmot(b); }).catch(() => {}); }, nostrSettingsCards() { return [followHistoryCard(), moderationCard()]; }, // The app came back after being backgrounded. A phone freezes a hidden // tab: the relay sockets are cut and every post made in the meantime is @@ -10613,6 +11186,12 @@ export function messagesFeature(ctx) { document.documentElement.style.removeProperty('--chat-viewport-height'); }); startDMs(); + // Group chats: load this identity's groups once the boot rush is over. + // A seed-derived identity signs silently, so it also becomes invitable + // here; a login signer is only asked when Chat is opened. + setTimeout(() => { + whiteNoise().then((c) => { if (c && !hook('nostrLoginIdentity')) { wnReadied = true; c.ready().catch(() => {}); } }).catch(() => {}); + }, 3000); // Communities are built from CACHE ONLY at boot (subscribe:false) — the // unread dot reads the last-known messages, but none of the gift-wrap // verify/decrypt runs until the user opens Chat (homeView subscribes @@ -10637,6 +11216,9 @@ export function messagesFeature(ctx) { rooms.clear(); threads.clear(); pendingDirect.clear(); + if (wn) { try { wn.stop(); } catch {} } + wn = null; wnLoading = null; wnReadied = false; + wnFolds.clear(); wnReach.clear(); wnReactOf.clear(); seenWraps.clear(); wrapLog = null; // the next account must decrypt wraps this one couldn't clearTimeout(drainTimer); pendingWraps.clear(); diff --git a/src/i18n.js b/src/i18n.js index 3ced02a..db3f7cf 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -460,6 +460,34 @@ const STR = { msgNoDmsYet: 'No messages yet. Say hi!', msgDmPlaceholder: 'Message', msgDmEncrypted: 'Private message · end-to-end encrypted', + msgNewGroup: 'New group', + msgGroupName: 'Group name', + msgGroupPeople: 'Add people by name or npub…', + msgGroupCreate: 'Create group', + msgGroupNeedPeople: 'Add at least one person', + msgGroupChecking: 'checking…', + msgGroupCantJoin: "can't join group chats yet", + msgGroupSomeCant: "{names} can't join group chats yet", + msgGroupInvite: '{name} invited you to {group}', + msgGroupUnnamed: 'Group chat', + msgGroupDecline: 'Decline', + msgGroupInfo: 'Private group · end-to-end encrypted', + msgGroupLeave: 'Leave group', + msgGroupRemove: 'Remove', + msgGroupRename: 'Rename', + msgGroupGone: 'You are no longer in this group', + msgGroupNoFiles: "Attachments aren't available in this chat yet", + msgGroupAttachment: 'Attachment', + msgGroupFailed: "Couldn't update the group. Try again", + msgGroupSetup: 'Your signer is needed once to set up group chats', + msgSysAdded: '{a} added {b}', + msgSysJoined: '{b} joined', + msgSysRemoved: '{a} removed {b}', + msgSysLeft: '{b} left', + msgSysAdminAdded: '{b} is now an admin', + msgSysAdminRemoved: '{b} is no longer an admin', + msgSysRenamed: '{a} named the group {name}', + msgSysDisbanded: '{a} ended the group', msgYouPrefix: 'You:', msgOpen: 'Open', msgNpubPlaceholder: 'npub…', diff --git a/src/marmot-client.js b/src/marmot-client.js new file mode 100644 index 0000000..bd61c41 --- /dev/null +++ b/src/marmot-client.js @@ -0,0 +1,370 @@ +// White Noise in the app: this device's Marmot standing (its MLS signature +// key, proof and published KeyPackage), its groups, and their relay traffic. +// Protocol rules live in marmot.js and storage in marmot-store.js; the +// messages feature hears about everything through `on(type, group, data)`. +// +// One client per (wallet, nostr identity). MLS state is per DEVICE: a second +// device of the same account is a second leaf with its own KeyPackage, and +// only the device whose KeyPackage an inviter picked is in that group. + +import * as M from './marmot.js'; +import { marmotStore } from './marmot-store.js'; +import { subscribeOn, queryOn, publishOn, fetchInboxRelays, PROFILE_RELAYS, finalizeEvent } from './nostr.js'; +import { wrapDM } from './dm.js'; +import { dlog } from './debug.js'; + +// Where the groups we found live. Signed into group state, so every member +// reads and writes the same list. +export const GROUP_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol', 'wss://relay.damus.io']; + +const DAY = 86400; +const KP_REFRESH = 30 * DAY; // republish well inside the 84-day lifetime +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 }) { + const 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 } + const inbox = new Map(); // gid -> queued 445 events + let dev = null, stopped = false, loaded = null, kpBusy = null; + const dirty = new Set(); + let flushTimer = 0, rotateTimer = 0; + + const emit = (type, g, data) => { try { on(type, g, data); } catch (e) { dlog('marmot: listener', e); } }; + + // ---- storage ---- + + function load() { + if (!loaded) + loaded = store.all().then((all) => { + dev = all.get('dev') || null; + for (const [k, v] of all) if (k.startsWith('g:') && v && v.tip) groups.set(v.id, v); + }).catch((e) => { dlog('marmot: store', e); }); + return loaded; + } + const touch = (g) => { + dirty.add(g.id); + clearTimeout(flushTimer); + flushTimer = setTimeout(flush, 400); + }; + async function flush() { + clearTimeout(flushTimer); + const ids = [...dirty]; + dirty.clear(); + await Promise.all(ids.map((id) => groups.has(id) ? store.put('g:' + id, groups.get(id)) : null)).catch((e) => dlog('marmot: save', e)); + } + // before anything of ours leaves: a ratchet that moved must be on disk first + const persist = (g) => { dirty.delete(g.id); return store.put('g:' + g.id, g); }; + const saveDev = () => store.put('dev', dev); + + // ---- this device ---- + + async function signer({ interactive = false } = {}) { + const id = await identity(); + if (!id || !id.signer || id.pubkey !== pubkey) return null; + if (id.signer instanceof Uint8Array) return { raw: id.signer, sign: async (e) => finalizeEvent(e, id.signer) }; + // an interactive signer is a trip through another app per call: never unasked + if (id.signer.interactive && !interactive) return null; + return { raw: null, adapter: id.signer, sign: (e) => id.signer.signEvent(e) }; + } + + async function ownWriteRelays() { + const lists = await queryOn(PROFILE_RELAYS, { kinds: [10002], authors: [pubkey] }, 2500); + const newest = lists.sort((a, b) => b.created_at - a.created_at)[0]; + return newest ? writeRelays(newest) : []; + } + const writeRelays = (ev) => ev.tags.filter((t) => t[0] === 'r' && t[1] && (!t[2] || t[2] === 'write')).map((t) => t[1]).filter(M.relayOk); + + // Make sure other people can invite us: a device record, and a current + // KeyPackage where our NIP-65 write relays say to look. Silent unless + // `interactive`; returns whether we are reachable. + function ready(opts = {}) { + if (!kpBusy) kpBusy = ensure(opts).catch((e) => { dlog('marmot: keypackage', e); return false; }).finally(() => { kpBusy = null; }); + return kpBusy; + } + async function ensure(opts) { + await load(); + const cur = dev && dev.kps && dev.kps.find((k) => k.eventId); + if (cur && cur.notAfter - now() > 14 * DAY && now() - cur.at < KP_REFRESH) return true; + const s = await signer(opts); + if (!s) return false; + if (!dev) { dev = { ...(await M.newDevice(s.sign, pubkey)), kps: [] }; await saveDev(); } + return publishKeyPackage(s); + } + + async function publishKeyPackage(s) { + const kp = M.newKeyPackage(dev); + // the private half is on disk before the public half is findable + dev.kps.push({ ...kp, eventId: null, at: now() }); + await saveDev(); + const ev = await s.sign(M.keyPackageEvent(dev, kp)); + // White Noise looks KeyPackages up through the account's kind 10002 write + // relays (the sync feature publishes that list for a seed-derived + // identity; a login npub's list is its other clients' business). + const own = await ownWriteRelays(); + const ok = await publishOn([...new Set([...own.slice(0, 6), ...PROFILE_RELAYS])], ev); + const rec = dev.kps.find((k) => k.ref === kp.ref); + if (!ok) { dev.kps = dev.kps.filter((k) => k !== rec); await saveDev(); return false; } + rec.eventId = ev.id; + // the slot now holds the replacement: older packages' init keys are done + dev.kps = dev.kps.filter((k) => k === rec); + await saveDev(); + return true; + } + + // Somebody else's KeyPackage, or null when they cannot be invited. + function lookup(pk) { + const hit = reach.get(pk); + if (hit && Date.now() - hit.at < REACH_TTL) return hit.p; + const p = (async () => { + const lists = await queryOn(PROFILE_RELAYS, { kinds: [10002], authors: [pk] }, 2500); + 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); + reach.set(pk, { at: Date.now(), p }); + return p; + } + + // ---- relay traffic ---- + + const routeRelays = (g) => (M.groupView(g.tip).routing || {}).relays || relays; + + function watch(g) { + if (stopped || g.removed) return; + for (const r of M.routes(g)) { + if (subs.has(r.id)) continue; + const unsub = subscribeOn(r.relays, { kinds: [M.KIND.GROUP], '#h': [r.id], since: M.since(g), limit: 500 }, (ev) => queue(g.id, ev)); + subs.set(r.id, { unsub, gid: g.id }); + } + } + function unwatch(gid) { + for (const [id, s] of subs) if (s.gid === gid) { try { s.unsub(); } catch {} subs.delete(id); } + } + + // Relays replay history newest-first and commits only apply oldest-first: + // collect a moment, then feed the batch in timestamp order. + function queue(gid, ev) { + let q = inbox.get(gid); + if (!q) { inbox.set(gid, (q = [])); setTimeout(() => drain(gid), 150); } + q.push(ev); + } + function drain(gid) { + const q = inbox.get(gid) || []; + inbox.delete(gid); + const g = groups.get(gid); + if (!g || stopped) return; + q.sort((a, b) => a.created_at - b.created_at); + for (const ev of q) { + let out; + try { out = M.receive(g, ev); } catch (e) { dlog('marmot: receive', e); continue; } + heard(g, out); + } + touch(g); + } + + function heard(g, out) { + for (const o of out) { + if (o.id) emit('message', g, o); + else if (o.epoch !== undefined) { watch(g); emit('state', g); } + else if (o.removed) { g.removed = true; unwatch(g.id); emit('removed', g); } + else if (o.withdrawn) emit('withdrawn', g, o.withdrawn); + else if (o.selfRemove !== undefined) letGo(g); + else if (o.invalid) dlog('marmot: rejected input', o.invalid); + } + } + + // A member asked to leave: any remaining member may commit it. Wait a + // random beat so a whole group does not answer at once, then look again. + function letGo(g) { + setTimeout(() => { + if (stopped || g.removed || g.leaving || g.pending) return; + if (!(g.tip.pending || []).length) return; + commit(g, []).catch((e) => dlog('marmot: self-remove commit', e)); + }, 1500 + Math.random() * 4000); + } + + // Publish-before-apply: the commit becomes group state only once a relay + // has it. A failed publish is retried with the SAME bytes, and given up + // only when the relays demonstrably never took it. + async function commit(g, proposals) { + const p = M.stage(g, { proposals }); + await persist(g); + const where = routeRelays(g); + let ok = false; + for (let i = 0; i < 3 && !ok; i++) { + if (i) await sleep(1500 * i); + ok = await publishOn(where, p.ev); + } + if (!ok) ok = (await queryOn(where, { ids: [p.ev.id] }, 2500)).length > 0; + // it may have settled while we waited: confirmed by its own echo, or + // beaten by a peer's commit from the same epoch + if (g.pending === p) { + heard(g, ok ? M.confirm(g) : M.drop(g)); + await persist(g); + } + if (!g.via || g.via.digest !== p.edge.digest) throw new Error(ok ? 'another change got there first' : 'relays did not accept the change'); + return p; + } + + async function deliverWelcome(g, welcome, cands) { + const s = await signer({ interactive: true }); + if (!s) throw new Error('no signer'); + const where = routeRelays(g); + for (const c of cands) { + const wrap = await wrapDM(s.raw || s.adapter, c.pubkey, M.welcomeRumor(pubkey, welcome, c.id, where)); + const inboxRelays = await fetchInboxRelays(c.pubkey).catch(() => []); + publishOn([...new Set([...inboxRelays.slice(0, 5), ...where])], wrap); + } + } + + // ---- what the app can do ---- + + async function candidates(pks) { + const cands = await Promise.all(pks.map(lookup)); + const missing = pks.filter((_, i) => !cands[i]); + if (missing.length) { const e = new Error('not reachable'); e.missing = missing; throw e; } + return cands; + } + + // A direct chat (no name, both sides admins) or a named group. + async function create({ name = '', members }) { + if (!(await ready({ interactive: true }))) throw new Error('no signer'); + const cands = await candidates(members); + const direct = !name && members.length === 1; + const { g, welcome } = M.found(dev, { name, relays, invitees: cands, adminKeys: direct ? members : [] }); + groups.set(g.id, g); + await persist(g); + await deliverWelcome(g, welcome, cands); + watch(g); + emit('state', g); + return g; + } + + // `inner` comes from event(): the app shows it before it has left. + async function send(g, inner) { + const ev = M.send(g, inner); + await persist(g); + const ok = await publishOn(routeRelays(g), ev); + if (!ok) { g.log = g.log.filter((m) => m.id !== inner.id); touch(g); } + return ok; + } + + async function add(g, pks) { + const cands = await candidates(pks); + for (const c of cands) if (!M.fits(g.tip, c)) throw new Error('their app cannot join this group'); + const p = await commit(g, M.invite(cands)); + // the Welcome only after the commit is out: joiners must not get ahead of members + await deliverWelcome(g, p.welcome, cands); + } + + const kick = (g, pk) => commit(g, M.remove(g, pk)); + const rename = (g, name) => commit(g, [M.setProfile(name, M.groupView(g.tip).about)]); + + // Leaving: an admin first hands the group to whoever stays (an admin cannot + // self-remove), then the SelfRemove proposal goes out for a peer to commit. + async function leave(g) { + if (!g.removed && !g.leaving && M.accounts(g.tip).length > 1) { + const v = M.groupView(g.tip); + if (v.admins.includes(pubkey)) { + const rest = v.admins.filter((a) => a !== pubkey); + await commit(g, [M.setAdmins(rest.length ? rest : M.accounts(g.tip).filter((a) => a !== pubkey))]); + } + const ev = M.leave(g); + await persist(g); + await publishOn(routeRelays(g), ev); + } + forget(g); + } + + function forget(g) { + unwatch(g.id); + groups.delete(g.id); + dirty.delete(g.id); + store.del('g:' + g.id).catch(() => {}); + emit('gone', g); + } + + // A Welcome from our inbox. Joining is automatic (the group's contents are + // what tell us who is asking); `fresh` marks it for the app to confirm. + async function welcome(rumor) { + await load(); + if (!dev || stopped) return null; + let j; + try { j = M.join(dev, rumor, dev.kps); } catch (e) { dlog('marmot: welcome', e); return null; } + const old = groups.get(j.g.id); + // a retained copy is never silently replaced; a group we were removed from can be rejoined + if (old && !old.removed) return null; + if (old) j.g.log = old.log; + groups.set(j.g.id, j.g); + await persist(j.g); + watch(j.g); + emit('joined', j.g); + // The package that got us in is spent: put a new one in the slot — but + // not at once. Several invitations may have used it while we were away, + // and its private half must still be here when the next Welcome opens. + clearTimeout(rotateTimer); + rotateTimer = setTimeout(() => { + if (!stopped) signer().then((s) => s && publishKeyPackage(s)).catch(() => {}); + }, 45_000); + // …and move our leaf off the keys that package exposed + setTimeout(() => { + 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); + return j.g; + } + + function accept(g) { g.fresh = false; touch(g); emit('state', g); } + + async function start() { + await load(); + if (stopped) return; + for (const g of groups.values()) { + watch(g); + // a commit that was on its way out when the app closed: same bytes, again + if (g.pending) { + const p = g.pending; + publishOn(routeRelays(g), p.ev).then(async (ok) => { + if (g.pending !== p) return; + if (!ok) ok = (await queryOn(routeRelays(g), { ids: [p.ev.id] }, 2500)).length > 0; + if (g.pending !== p) return; + heard(g, ok ? M.confirm(g) : M.drop(g)); + touch(g); + }); + } + } + emit('loaded', null); + } + + function resubscribe() { + for (const s of subs.values()) { try { s.unsub(); } catch {} } + subs.clear(); + for (const g of groups.values()) watch(g); + } + + function stop() { + stopped = true; + clearTimeout(rotateTimer); + for (const s of subs.values()) { try { s.unsub(); } catch {} } + subs.clear(); + flush(); + } + + return { + pubkey, groups, load, start, stop, resubscribe, ready, lookup, welcome, accept, + create, send, add, kick, rename, leave, forget, + event: (kind, content, tags) => M.appEvent(pubkey, kind, content, tags), + view: (g) => M.groupView(g.tip), + peer: (g) => M.directPeer(g), + accounts: (g) => M.accounts(g.tip), + isAdmin: (g) => M.isAdmin(g), + // has this device ever been given its standing (a signed proof)? + isSetUp: () => !!dev, + relaysInUse: () => [...new Set([...groups.values()].filter((g) => !g.removed).flatMap(routeRelays))], + }; +} diff --git a/src/marmot-store.js b/src/marmot-store.js new file mode 100644 index 0000000..ac9b044 --- /dev/null +++ b/src/marmot-store.js @@ -0,0 +1,74 @@ +// Where White Noise (Marmot) state lives: IndexedDB, one record per group. +// +// MLS state is the opposite of everything else this wallet keeps: it cannot +// be refetched or rederived (a lost ratchet is a lost group), it holds +// Uint8Arrays by the dozen (structured clone stores them as-is, JSON would +// triple them), and it is DEVICE state — two devices replaying one leaf's +// ratchet would reuse keys. So it stays out of the localStorage feature +// state and out of the cross-device sync, in a store of its own. +// +// Records are scoped by `scope` (wallet cache key + nostr identity), so two +// wallets or two identities on one browser never read each other's groups. + +const DB = 'coinos-marmot'; +const STORE = 'kv'; + +let dbP = null; +function db() { + if (!dbP) + dbP = new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') return reject(new Error('IndexedDB unavailable')); + const req = indexedDB.open(DB, 1); + req.onupgradeneeded = () => { + if (!req.result.objectStoreNames.contains(STORE)) req.result.createObjectStore(STORE); + }; + req.onsuccess = () => { + // another tab upgrading or deleting the database must not be blocked + req.result.onversionchange = () => { req.result.close(); dbP = null; }; + resolve(req.result); + }; + req.onerror = () => { dbP = null; reject(req.error); }; + }); + return dbP; +} + +async function tx(mode, fn) { + const d = await db(); + return new Promise((resolve, reject) => { + const t = d.transaction(STORE, mode); + let out; + try { out = fn(t.objectStore(STORE)); } catch (e) { reject(e); return; } + t.oncomplete = () => resolve(out && 'result' in out ? out.result : out); + t.onerror = () => reject(t.error); + t.onabort = () => reject(t.error); + }); +} + +export function marmotStore(scope) { + const pre = scope + '|'; + const range = () => IDBKeyRange.bound(pre, pre + ''); + return { + // every record of this scope: Map(name -> value) + async all() { + const out = new Map(); + await tx('readonly', (s) => { + const cur = s.openCursor(range()); + cur.onsuccess = () => { + const c = cur.result; + if (!c) return; + out.set(String(c.key).slice(pre.length), c.value); + c.continue(); + }; + }); + return out; + }, + put: (name, value) => tx('readwrite', (s) => { s.put(value, pre + name); }), + del: (name) => tx('readwrite', (s) => { s.delete(pre + name); }), + wipe: () => tx('readwrite', (s) => { s.delete(range()); }), + }; +} + +// Drop every scope under a wallet (the account-wipe hook). +export function wipeMarmot(walletKey) { + return tx('readwrite', (s) => { s.delete(IDBKeyRange.bound(walletKey, walletKey + '')); }).catch(() => {}); +} diff --git a/src/marmot.js b/src/marmot.js new file mode 100644 index 0000000..bf6f620 --- /dev/null +++ b/src/marmot.js @@ -0,0 +1,886 @@ +// Marmot (White Noise) — MLS group chats over nostr +// https://github.com/marmot-protocol/marmot +// +// The protocol layer above mls.js: Marmot's app components, the account +// identity proof, the nostr event shapes (30443 KeyPackage, 444 Welcome, +// 445 group message) and the per-group engine that decides which MLS bytes +// become group state. Protocol-pure like concord.js: no DOM, no app state, +// no relay I/O. The messages feature drives it and owns storage and sockets. + +import { sha256 } from "@noble/hashes/sha256"; +import { schnorr } from "@noble/curves/secp256k1"; +import { bytesToHex, hexToBytes, concatBytes, randomBytes } from "@noble/hashes/utils"; +import { chacha20poly1305 } from "@noble/ciphers/chacha.js"; +import { base64 } from "@scure/base"; +import { finalizeEvent, generateSecretKey, getEventHash, verifyEvent } from "nostr-tools/pure"; +import * as mls from "./mls.js"; + +export const KIND = { KEY_PACKAGE: 30443, WELCOME: 444, GROUP: 445, CHAT: 9, EDIT: 1009, REACTION: 7, DELETE: 5, SYSTEM: 1210 }; + +export const COMP = { + APP_COMPONENTS: 0x0001, + SAFE_AAD: 0x0002, + LAST_RESORT: 0x0004, + PROFILE: 0x8001, + IMAGE: 0x8002, + ADMINS: 0x8003, + ROUTING: 0x8004, + RETENTION: 0x8005, + AGENT_STREAM: 0x8006, + AVATAR_URL: 0x8007, + MEDIA_V1: 0x8008, + PROOF: 0x8009, + MEDIA_V2: 0x800b, + LIFECYCLE: 0x800c, +}; + +// The agent-stream "receive" role: we understand the component and settle for +// the durable final message. Groups made by White Noise require it. +const EXT_STREAM_RECEIVE = 0xf2d1; + +// Components whose group state we can carry. Anything a group REQUIRES that +// is not here is a group we must not join; anything merely present is kept +// byte-for-byte by the MLS layer. +export const SUPPORTED = [ + COMP.PROFILE, COMP.IMAGE, COMP.ADMINS, COMP.ROUTING, COMP.RETENTION, COMP.AGENT_STREAM, + COMP.AVATAR_URL, COMP.MEDIA_V1, COMP.PROOF, COMP.MEDIA_V2, COMP.LIFECYCLE, +]; + +// Convergence policy v1 (protocol constants, not preferences). +const MAX_REWIND = 5; +const PAST_EPOCHS = 5; + +const KP_LIFETIME = 84 * 86400; // + the hour of skew below = the 7,261,200 s cap +const KP_SKEW = 3600; + +const utf8 = (s) => new TextEncoder().encode(s); +const text = (b) => new TextDecoder("utf-8", { fatal: true }).decode(b); +const now = () => Math.floor(Date.now() / 1000); +const EMPTY = new Uint8Array(0); + +const cmp = (a, b) => { + const n = Math.min(a.length, b.length); + for (let i = 0; i < n; i++) if (a[i] !== b[i]) return a[i] - b[i]; + return a.length - b.length; +}; +const same = (a, b) => a.length === b.length && cmp(a, b) === 0; + +// ---- the Marmot binary profile: TLS structs with QUIC varint vectors ---- + +const u16 = (n) => Uint8Array.of(n >> 8, n & 255); +const u64 = (n) => { + const b = new Uint8Array(8); + new DataView(b.buffer).setBigUint64(0, BigInt(n)); + return b; +}; +const vlen = (n) => + n < 0x40 ? Uint8Array.of(n) + : n < 0x4000 ? Uint8Array.of(0x40 | (n >> 8), n & 255) + : Uint8Array.of(0x80 | (n >>> 24), (n >>> 16) & 255, (n >>> 8) & 255, n & 255); +const vec = (...parts) => { + const b = concatBytes(...parts); + return concatBytes(vlen(b.length), b); +}; + +class Rd { + constructor(b) { this.b = b; this.i = 0; } + get more() { return this.i < this.b.length; } + take(n) { + if (this.i + n > this.b.length) throw new Error("truncated"); + const o = this.b.subarray(this.i, this.i + n); + this.i += n; + return o; + } + u8() { return this.take(1)[0]; } + u16() { const b = this.take(2); return (b[0] << 8) | b[1]; } + u64() { const b = this.take(8); return Number(new DataView(b.buffer, b.byteOffset, 8).getBigUint64(0)); } + len() { + const first = this.u8(), k = first >> 6; + if (k === 3) throw new Error("vector too long"); + let n = first & 63; + for (let j = (1 << k) - 1; j > 0; j--) n = n * 256 + this.u8(); + // canonical bytes only: a longer prefix for the same length is a different encoding + if ((k === 1 && n < 0x40) || (k === 2 && n < 0x4000)) throw new Error("non-minimal length"); + return n; + } + vec() { return this.take(this.len()); } + end() { if (this.more) throw new Error("trailing bytes"); } +} + +// ---- app components ---- + +// AppDataDictionary (MLS extensions draft): entries sorted by id, unique. +export function dictDecode(data) { + const r = new Rd(data); + const body = new Rd(r.vec()); + r.end(); + const out = []; + let prev = -1; + while (body.more) { + const id = body.u16(); + if (id <= prev) throw new Error("app_data_dictionary not sorted"); + prev = id; + out.push({ id, data: body.vec() }); + } + return out; +} +export const dictEncode = (entries) => + vec(...[...entries].sort((a, b) => a.id - b.id).flatMap((e) => [u16(e.id), vec(e.data)])); +const dictExt = (entries) => ({ type: mls.EXT.APP_DATA_DICTIONARY, data: dictEncode(entries) }); +const dictOf = (extensions) => { + const e = (extensions || []).find((x) => x.type === mls.EXT.APP_DATA_DICTIONARY); + return e ? dictDecode(e.data) : []; +}; +const comp = (extensions, id) => dictOf(extensions).find((e) => e.id === id)?.data; + +const idList = { + enc: (ids) => vec(...[...new Set(ids)].sort((a, b) => a - b).map(u16)), + dec(data) { + const r = new Rd(data), body = new Rd(r.vec()), out = []; + r.end(); + while (body.more) out.push(body.u16()); + return out; + }, +}; + +const profile = { + enc: ({ name = "", about = "" }) => concatBytes(vec(utf8(name)), vec(utf8(about))), + dec(data) { + const r = new Rd(data); + const out = { name: text(r.vec()), about: text(r.vec()) }; + r.end(); + return out; + }, +}; + +const admins = { + enc(list) { + const keys = [...new Set(list)].map(hexToBytes).sort(cmp); + if (!keys.length) throw new Error("a group needs an admin"); + return vec(...keys); + }, + dec(data) { + const r = new Rd(data), body = r.vec(), out = []; + r.end(); + if (!body.length || body.length % 32) throw new Error("bad admin policy"); + for (let i = 0; i < body.length; i += 32) { + const k = body.subarray(i, i + 32); + if (i && cmp(body.subarray(i - 32, i), k) >= 0) throw new Error("admin policy not sorted"); + out.push(bytesToHex(k)); + } + return out; + }, +}; + +// The Nostr relay URL profile: absolute ws(s) URL, a host, no credentials or fragment. +export function relayOk(url) { + try { + if (typeof url !== "string" || utf8(url).length > 512) return false; + const u = new URL(url); + return (u.protocol === "wss:" || u.protocol === "ws:") && !!u.hostname && !u.username && !u.password && !u.hash; + } catch { return false; } +} + +const routing = { + enc({ id, relays }) { + const urls = [...new Set(relays)].filter(relayOk).map(utf8).sort(cmp).slice(0, 16); + if (!urls.length) throw new Error("a group needs a relay"); + return concatBytes(hexToBytes(id), vec(...urls.map((u) => vec(u)))); + }, + dec(data) { + const r = new Rd(data); + const id = bytesToHex(r.take(32)); + const body = new Rd(r.vec()), relays = []; + r.end(); + let prev = null; + while (body.more) { + const u = body.vec(); + if (prev && cmp(prev, u) >= 0) throw new Error("relay list not sorted"); + prev = u; + relays.push(text(u)); + } + if (!relays.length || relays.length > 16 || !relays.every(relayOk)) throw new Error("bad relay list"); + return { id, relays }; + }, +}; + +// What a group's signed state says about itself. Throws on malformed state. +const viewMemo = new WeakMap(); +export function groupView(state) { + let v = viewMemo.get(state); + if (v) return v; + const d = dictOf(state.extensions); + const get = (id) => d.find((e) => e.id === id)?.data; + const p = get(COMP.PROFILE), a = get(COMP.ADMINS), r = get(COMP.ROUTING), l = get(COMP.LIFECYCLE), t = get(COMP.RETENTION); + const req = get(COMP.APP_COMPONENTS); + v = { + ...(p ? profile.dec(p) : { name: "", about: "" }), + admins: a ? admins.dec(a) : [], + routing: r ? routing.dec(r) : null, + disbanded: !!l && l.length === 1 && l[0] === 1, + retention: t && t.length === 8 ? new Rd(t).u64() : 0, + required: req ? idList.dec(req) : [], + }; + viewMemo.set(state, v); + return v; +} + +// ---- account identity proof (component 0x8009) ---- +// A normal signed nostr event (kind 450, never published) binds the account +// key to this device's MLS signature key; only signer_pubkey, created_at and +// the signature travel, and every verifier rebuilds the event around them. + +const proofEvent = (pubkey, created_at, sigPub) => ({ + pubkey, + created_at, + kind: 450, + tags: [ + ["d", "marmot.account-identity-proof.v2"], + ["component", "0x8009"], + ["ciphersuite", "0x0001"], + ["signature_scheme", "0x0807"], + ["mls_signature_key", bytesToHex(sigPub)], + ], + content: "Authorize this MLS leaf key for my Marmot account", +}); + +// signEvent: (unsigned event) -> signed event; a raw key or any login signer. +export async function makeProof(signEvent, pubkey, sigPub) { + const want = proofEvent(pubkey, now(), sigPub); + const got = await signEvent({ ...want }); + // a remote signer may hand back anything: accept only our exact event + if (!got || got.pubkey !== pubkey || got.created_at !== want.created_at || got.kind !== 450 || + got.content !== want.content || JSON.stringify(got.tags) !== JSON.stringify(want.tags)) + throw new Error("signer changed the proof event"); + const id = getEventHash(want); + if (got.id !== id || !schnorr.verify(got.sig, id, pubkey)) throw new Error("bad proof signature"); + return concatBytes(hexToBytes(pubkey), u64(want.created_at), hexToBytes(got.sig)); +} + +const proofMemo = new Map(); +export function proofOk(proof, identity, sigPub) { + if (!proof || proof.length !== 104 || !same(proof.subarray(0, 32), identity)) return false; + const key = bytesToHex(proof) + bytesToHex(sigPub); + let ok = proofMemo.get(key); + if (ok === undefined) { + try { + const at = new Rd(proof.subarray(32, 40)).u64(); + const pubkey = bytesToHex(identity); + ok = at >= 1 && schnorr.verify(proof.subarray(40), getEventHash(proofEvent(pubkey, at, sigPub)), pubkey); + } catch { ok = false; } + if (proofMemo.size > 2000) proofMemo.clear(); + proofMemo.set(key, ok); + } + return ok; +} + +// A leaf is a Marmot member leaf when it advertises and carries a valid proof. +function leafOk(leaf) { + const cred = leaf.credential; + if (!cred || cred.type !== 1 || !cred.identity || cred.identity.length !== 32) return false; + let d; + try { d = dictOf(leaf.extensions); } catch { return false; } + const list = d.find((e) => e.id === COMP.APP_COMPONENTS); + try { if (!list || !idList.dec(list.data).includes(COMP.PROOF)) return false; } catch { return false; } + return proofOk(d.find((e) => e.id === COMP.PROOF)?.data, cred.identity, leaf.signatureKey); +} + +// ---- KeyPackages ---- + +export const capabilities = () => ({ + versions: [1], + ciphersuites: [1], + extensions: [mls.EXT.APP_DATA_DICTIONARY, EXT_STREAM_RECEIVE], + proposals: [mls.PROPOSAL.APP_DATA_UPDATE, mls.PROPOSAL.SELF_REMOVE], + credentials: [1], +}); + +// The same three entries White Noise puts on a leaf: what we support, an +// empty SafeAAD list, and the proof. +const leafExtensions = (proof) => [dictExt([ + { id: COMP.APP_COMPONENTS, data: idList.enc([COMP.APP_COMPONENTS, ...SUPPORTED]) }, + { id: COMP.SAFE_AAD, data: idList.enc([]) }, + { id: COMP.PROOF, data: proof }, +])]; + +const lifetime = () => ({ notBefore: now() - KP_SKEW, notAfter: now() + KP_LIFETIME }); + +// A device's standing: its MLS signature key and the account's proof for it. +// One proof covers every KeyPackage and every group leaf made with this key, +// so a remote signer is asked exactly once per device. +export async function newDevice(signEvent, pubkey) { + const sig = mls.generateSignatureKeyPair(); + return { pubkey, sig, proof: await makeProof(signEvent, pubkey, sig.pub), slot: bytesToHex(randomBytes(32)) }; +} + +// Last-resort by default: one published package has to survive several +// inviters finding it before we have been online to rotate it. +export function newKeyPackage(device, { lastResort = true } = {}) { + const life = lifetime(); + const kp = mls.generateKeyPackage({ + sig: device.sig, + identity: hexToBytes(device.pubkey), + capabilities: capabilities(), + leafExtensions: leafExtensions(device.proof), + extensions: lastResort ? [dictExt([{ id: COMP.LAST_RESORT, data: EMPTY }])] : [], + lifetime: life, + }); + return { ref: bytesToHex(kp.ref), keyPackage: kp.keyPackage, message: kp.message, secrets: kp.secrets, notAfter: life.notAfter, lastResort }; +} + +const hex4 = (n) => "0x" + n.toString(16).padStart(4, "0"); + +// The unsigned kind-30443 event for a KeyPackage record; the account signs it. +export const keyPackageEvent = (device, kp) => ({ + kind: KIND.KEY_PACKAGE, + pubkey: device.pubkey, + created_at: now(), + content: base64.encode(kp.message), + tags: [ + ["d", device.slot], + ["mls_protocol_version", "1.0"], + ["i", kp.ref], + ["mls_ciphersuite", "0x0001"], + ["mls_extensions", ...capabilities().extensions.map(hex4)], + ["mls_proposals", ...capabilities().proposals.map(hex4)], + ["app_components", ...SUPPORTED.map(hex4)], + // which app made it: lets a coinos wallet recognise another one + ["client", "coinos"], + ], +}); + +const one = (ev, name) => { + const hits = ev.tags.filter((t) => t[0] === name); + return hits.length === 1 && hits[0].length === 2 ? hits[0][1] : null; +}; +const idTag = (ev, name) => { + const hits = ev.tags.filter((t) => t[0] === name); + if (hits.length !== 1 || hits[0].length < 2) return null; + const vals = hits[0].slice(1); + return vals.every((v) => /^0x[0-9a-f]{4}$/.test(v)) && new Set(vals).size === vals.length ? vals.map((v) => parseInt(v, 16)) : null; +}; + +// Validate somebody's published KeyPackage. Returns the candidate or null. +export function readKeyPackageEvent(ev, at = now()) { + try { + if (ev.kind !== KIND.KEY_PACKAGE || !verifyEvent(ev)) return null; + const d = one(ev, "d"), i = one(ev, "i"); + if (!d || !/^[0-9a-f]{64}$/.test(d) || !i || one(ev, "mls_protocol_version") !== "1.0") return null; + const suites = idTag(ev, "mls_ciphersuite"), comps = idTag(ev, "app_components"); + if (!suites || !suites.includes(1) || !idTag(ev, "mls_extensions") || !idTag(ev, "mls_proposals")) return null; + if (!comps || !comps.includes(COMP.PROOF)) return null; + const kp = mls.parseKeyPackage(base64.decode(ev.content)); + if (kp.cipherSuite !== 1 || bytesToHex(kp.ref) !== i) return null; + if (bytesToHex(kp.leaf.credential.identity) !== ev.pubkey || !leafOk(kp.leaf)) return null; + const life = kp.leaf.lifetime; + if (!life || at < life.notBefore || at > life.notAfter || life.notAfter - life.notBefore > KP_LIFETIME + KP_SKEW) return null; + const caps = kp.leaf.capabilities; + if (!caps.extensions.includes(mls.EXT.APP_DATA_DICTIONARY) || !caps.proposals.includes(mls.PROPOSAL.APP_DATA_UPDATE)) return null; + let lastResort = false; + try { lastResort = dictOf(kp.extensions).some((e) => e.id === COMP.LAST_RESORT); } catch {} + return { + id: ev.id, pubkey: ev.pubkey, slot: d, ref: i, created_at: ev.created_at, lastResort, + keyPackage: kp.keyPackage, + 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] || "", + }; + } 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) { + 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; +} + +// Can this KeyPackage join a group in this state? +export function fits(state, cand) { + const v = groupView(state); + return v.required.every((id) => cand.components.includes(id) || id === COMP.APP_COMPONENTS); +} + +// ---- group state rules ---- + +const account = (m) => bytesToHex(m.identity); +export const roster = (state) => mls.members(state).map((m) => ({ leaf: m.index, pubkey: account(m) })); +export const accounts = (state) => [...new Set(roster(state).map((m) => m.pubkey))]; +const accountAt = (state, leaf) => { + const m = mls.members(state).find((x) => x.index === leaf); + return m ? account(m) : null; +}; + +// Every invariant a Marmot epoch must hold. Throws with the reason. +export function checkState(state) { + const v = groupView(state); + if (!v.required.includes(COMP.PROOF) || !v.required.includes(COMP.ADMINS)) throw new Error("group does not require proofs and an admin policy"); + for (const id of v.required) + if (id !== COMP.APP_COMPONENTS && !SUPPORTED.includes(id)) throw new Error("group requires component " + hex4(id)); + if (comp(state.extensions, COMP.PROOF)) throw new Error("proof component in group state"); + const members = mls.members(state); + for (const m of members) if (!leafOk(m.leaf)) throw new Error("member without a valid account proof"); + if (!v.admins.length) throw new Error("group has no admin"); + const have = new Set(members.map(account)); + for (const a of v.admins) if (!have.has(a)) throw new Error("admin without a member leaf"); + return v; +} + +// Authorize a processed commit against its parent state, then hold the +// resulting epoch to the invariants. Returns the edge convergence orders by. +function checkCommit(parent, res, digest) { + const committer = accountAt(parent, res.sender); + if (!committer) throw new Error("commit from a non-member"); + const pv = groupView(parent); + const props = res.proposals || []; + const kinds = props.map((p) => p.proposal.type); + for (const p of props) + if (p.proposal.type === mls.PROPOSAL.SELF_REMOVE && pv.admins.includes(accountAt(parent, p.sender))) + throw new Error("an admin cannot self-remove"); + // one operation per component per commit; "last one wins" is not defined + const touched = props.filter((p) => p.proposal.type === mls.PROPOSAL.APP_DATA_UPDATE).map((p) => p.proposal.componentId); + if (new Set(touched).size !== touched.length) throw new Error("two updates to one component"); + const selfUpdate = !props.length; + const selfRemoveOnly = props.length > 0 && kinds.every((k) => k === mls.PROPOSAL.SELF_REMOVE); + const privileged = !(selfUpdate || selfRemoveOnly); + if (privileged && !pv.admins.includes(committer)) throw new Error("commit by a non-admin"); + if (!res.removedSelf) { + checkState(res.state); + // a leaf may rotate its keys, never its account + if (accountAt(res.state, res.sender) !== committer) throw new Error("committer changed identity"); + } + return { digest, committer, privileged }; +} + +// privileged before ordinary, then the lower committer, then the lower digest +const beats = (a, b) => + a.privileged !== b.privileged ? a.privileged + : a.committer !== b.committer ? a.committer < b.committer + : a.digest < b.digest; + +// ---- nostr event shapes ---- + +const keyMemo = new WeakMap(); +const groupKey = (state) => { + let k = keyMemo.get(state); + if (!k) keyMemo.set(state, (k = mls.exportSecret(state, "marmot", utf8("group-event"), 32))); + return k; +}; + +// kind 445: one MLS message under the epoch's exporter key, signed by a +// throwaway key, addressed by the state's routing id. +function seal(state, bytes, expiration) { + const nonce = randomBytes(12); + const ct = chacha20poly1305(groupKey(state), nonce).encrypt(bytes); + const tags = [["h", groupView(state).routing.id]]; + if (expiration) tags.push(["expiration", String(expiration)]); + return finalizeEvent({ kind: KIND.GROUP, created_at: now(), tags, content: base64.encode(concatBytes(nonce, ct)) }, generateSecretKey()); +} + +function open(state, payload) { + try { return chacha20poly1305(groupKey(state), payload.subarray(0, 12)).decrypt(payload.subarray(12)); } catch { return null; } +} + +// kind 444, unsigned: the Welcome for one invitee, to be gift-wrapped. +export function welcomeRumor(pubkey, welcome, keyPackageEventId, relays) { + const r = { kind: KIND.WELCOME, pubkey, created_at: now(), content: base64.encode(welcome), tags: [["relays", ...relays], ["e", keyPackageEventId]] }; + r.id = getEventHash(r); + return r; +} + +export function readWelcome(rumor) { + if (rumor.kind !== KIND.WELCOME || rumor.sig) throw new Error("not a welcome"); + const e = one(rumor, "e"); + const rel = rumor.tags.filter((t) => t[0] === "relays"); + if (!e || !/^[0-9a-f]{64}$/.test(e) || rel.length !== 1 || rel[0].length < 2) throw new Error("malformed welcome"); + const relays = rel[0].slice(1); + if (!relays.every(relayOk) || new Set(relays).size !== relays.length) throw new Error("malformed welcome relays"); + return { bytes: base64.decode(rumor.content), keyPackageEventId: e, relays }; +} + +// The app payload inside MLS: a nostr event with an id and no signature. +export function appEvent(pubkey, kind, content, tags = [], created_at = now()) { + const e = { pubkey, created_at, kind, tags, content }; + return { id: getEventHash(e), ...e }; +} + +const APP_KEYS = ["content", "created_at", "id", "kind", "pubkey", "tags"]; +function readAppEvent(bytes, sender) { + let e; + try { e = JSON.parse(text(bytes)); } catch { return null; } + if (!e || typeof e !== "object" || Object.keys(e).sort().join() !== APP_KEYS.join()) return null; + if (typeof e.content !== "string" || !Number.isInteger(e.created_at) || !Number.isInteger(e.kind) || !Array.isArray(e.tags)) return null; + if (!e.tags.every((t) => Array.isArray(t) && t.every((x) => typeof x === "string"))) return null; + // MLS authenticated the leaf; the event must name that leaf's account + if (e.pubkey !== sender) return null; + try { if (getEventHash(e) !== e.id) return null; } catch { return null; } + return e; +} + +// ---- the group engine ---- +// +// A group record is plain data, persisted whole: +// tip the canonical MLS state +// past the epochs behind it (newest first, at most five): late app +// messages decrypt there, and a competing commit forks from there +// via how the tip was reached { digest, committer, privileged } +// pending a commit of ours that is published but not yet acknowledged — +// it becomes the tip only when a relay takes it +// alt a peer's commit that lost to `pending`; applied if ours fails +// held kind-445 events no retained epoch could open yet +// seen ids already processed (outer event ids and MLS message ids) +// log delivered app events, oldest first +// from nothing older than this can concern us (when we joined) +// heard the newest relay timestamp processed: where to resume listening +// +// Convergence here is the spec's branch rule without the app-message witness +// boost: deeper branch first, then privileged, lower committer, lower digest. + +const SEEN_MAX = 2000, HELD_MAX = 100, LOG_MAX = 400; + +const record = (state, me, extra) => ({ + id: bytesToHex(state.groupId), + me, + tip: state, + past: [], + via: null, + pending: null, + alt: null, + held: [], + seen: [], + log: [], + routes: [], + removed: false, + leaving: false, + at: now(), + from: now(), + heard: 0, + ...extra, +}); + +// Where a subscription for this group should start. Ten minutes of slack +// covers relays that lag each other; a timestamp is only ever a fetch hint. +export const since = (g) => Math.max(0, (g.heard || g.from) - 600); + +// ids are remembered by their first 16 hex characters: plenty against +// accidents, and the record is rewritten whole on every message +const short = (id) => id.slice(0, 16); +const sawIt = (g, id) => g.seen.includes(short(id)); +function see(g, id) { + g.seen.push(short(id)); + if (g.seen.length > SEEN_MAX) g.seen.splice(0, g.seen.length - SEEN_MAX); +} + +// Every routing address this group still has to be heard on: the current +// one and those of the epochs we retain. +export function routes(g) { + const out = new Map(); + for (const s of [g.tip, ...(g.pending ? [g.pending.state] : []), ...g.past]) { + let r; + try { r = groupView(s).routing; } catch { continue; } + if (r && !out.has(r.id)) out.set(r.id, r.relays); + } + return [...out].map(([id, relays]) => ({ id, relays })); +} + +export const isAdmin = (g) => groupView(g.tip).admins.includes(g.me); +// A direct chat: two accounts and no name. Everything else is a group. +export function directPeer(g) { + const acc = accounts(g.tip); + if (acc.length !== 2 || !acc.includes(g.me) || groupView(g.tip).name) return null; + return acc.find((a) => a !== g.me); +} + +// Found a group: epoch 0 with only us, then one founding commit that adds +// the invitees (nobody else exists yet, so nothing has to be published). +// Returns the record and the Welcome to deliver to each invitee. +export function found(device, { name = "", about = "", relays, invitees = [], adminKeys = [] }) { + const reqComps = [COMP.ADMINS, COMP.ROUTING, COMP.PROOF, COMP.LIFECYCLE, ...(name || about ? [COMP.PROFILE] : [])]; + const entries = [ + { id: COMP.APP_COMPONENTS, data: idList.enc(reqComps) }, + { id: COMP.ADMINS, data: admins.enc([device.pubkey, ...adminKeys]) }, + { id: COMP.ROUTING, data: routing.enc({ id: bytesToHex(randomBytes(32)), relays }) }, + { id: COMP.LIFECYCLE, data: Uint8Array.of(0) }, + ...(name || about ? [{ id: COMP.PROFILE, data: profile.enc({ name, about }) }] : []), + ]; + const required = { + type: mls.EXT.REQUIRED_CAPABILITIES, + data: concatBytes(vec(u16(mls.EXT.APP_DATA_DICTIONARY)), vec(u16(mls.PROPOSAL.APP_DATA_UPDATE)), vec()), + }; + const state0 = mls.createGroup({ + groupId: randomBytes(16), + sig: device.sig, + identity: hexToBytes(device.pubkey), + capabilities: capabilities(), + leafExtensions: leafExtensions(device.proof), + extensions: [required, dictExt(entries)], + lifetime: lifetime(), + }); + const g = record(state0, device.pubkey); + if (!invitees.length) { checkState(state0); return { g, welcome: null }; } + for (const c of invitees) if (!fits(state0, c)) throw new Error("invitee cannot join this group"); + const r = mls.createCommit(state0, { proposals: invitees.map((c) => mls.proposeAdd(c.keyPackage)) }); + checkState(r.state); + g.tip = r.state; + g.past = [state0]; + g.via = { digest: bytesToHex(sha256(r.message)), committer: device.pubkey, privileged: true }; + return { g, welcome: r.welcome }; +} + +// Join from a Welcome addressed to us. `kps` are this device's KeyPackage +// records; nothing is consumed unless every check passes. +export function join(device, rumor, kps) { + const w = readWelcome(rumor); + let state = null, used = null, err = null; + const first = kps.filter((k) => k.eventId === w.keyPackageEventId); + for (const k of [...first, ...kps.filter((x) => !first.includes(x))]) { + try { + state = mls.joinWelcome(w.bytes, { keyPackage: k.keyPackage, secrets: k.secrets, sig: device.sig }); + used = k; + break; + } catch (e) { err = e; } + } + if (!state) throw err || new Error("welcome is not for this device"); + const v = checkState(state); + if (!v.routing) throw new Error("group has no nostr routing"); + if (v.disbanded) throw new Error("group was disbanded"); + // the only authority a first-time joiner can check: the inviter is an + // admin of the state it handed us, and it is who the gift wrap said + const inviter = accountAt(state, state.joinedVia); + if (!inviter || !v.admins.includes(inviter)) throw new Error("invited by a non-admin"); + if (inviter !== rumor.pubkey) throw new Error("welcome author mismatch"); + // the group may have moved on while the Welcome sat in the inbox: listen from when it was written + return { g: record(state, device.pubkey, { inviter, fresh: true, from: Math.min(now(), rumor.created_at) - 3600 }), used }; +} + +function advance(g, state, edge) { + g.past.unshift(g.tip); + g.past.length = Math.min(g.past.length, MAX_REWIND); + g.tip = state; + g.via = edge; +} + +// Membership and settings changes between two epochs, as kind-1210-shaped +// rows for the transcript (synthesized locally from signed state). +function changes(before, after, actor, at) { + const out = []; + const row = (type, data) => { + out.push(appEvent(actor, KIND.SYSTEM, JSON.stringify({ v: 1, system_type: type, text: type.replace(/_/g, " "), data: { actor, ...data } }), [["system", type]], at)); + }; + const a = accounts(before), b = accounts(after); + for (const pk of b) if (!a.includes(pk)) row("member_added", { subject: pk }); + for (const pk of a) if (!b.includes(pk)) row(pk === actor ? "member_left" : "member_removed", { subject: pk }); + const va = groupView(before), vb = groupView(after); + if (vb.disbanded && !va.disbanded) return [appEvent(actor, KIND.SYSTEM, JSON.stringify({ v: 1, system_type: "group_disbanded", text: "Group disbanded", data: { actor } }), [["system", "group_disbanded"]], at)]; + for (const pk of vb.admins) if (!va.admins.includes(pk) && a.includes(pk)) row("admin_added", { subject: pk }); + for (const pk of va.admins) if (!vb.admins.includes(pk) && b.includes(pk)) row("admin_removed", { subject: pk }); + if (va.name !== vb.name) row("group_renamed", { name: vb.name }); + return out; +} + +function deliver(g, out, ev, epoch) { + if (g.log.some((m) => m.id === ev.id)) return; + ev = { ...ev, epoch }; + g.log.push(ev); + if (g.log.length > LOG_MAX) g.log.splice(0, g.log.length - LOG_MAX); + out.push(ev); +} + +function applyCommit(g, out, parent, res, edge, at) { + const sys = res.removedSelf ? [] : changes(parent, res.state, edge.committer, at); + if (res.removedSelf) { + g.removed = true; + out.push({ removed: true }); + return; + } + advance(g, res.state, edge); + if (groupView(g.tip).disbanded) g.removed = true; + for (const ev of sys) deliver(g, out, ev, g.tip.epoch); + out.push({ epoch: g.tip.epoch }); +} + +// One kind-445 event in. Returns what the app needs to hear about: +// delivered app events, { epoch } on a state change, { removed }, { withdrawn: [ids] }, +// { selfRemove: leaf } when a peer asks to leave. Mutates the record. +export function receive(g, ev) { + const out = []; + if (g.removed) return out; + if (sawIt(g, ev.id)) return out; + if (g.pending && g.pending.ev.id === ev.id) { confirm(g, out); return out; } + let mark = progress(g); + if (take(g, ev, out)) g.heard = Math.max(g.heard || 0, Math.min(ev.created_at, now())); + // new state (an epoch, a cached proposal) is what makes held input usable: go round again + while (g.held.length && !g.removed && progress(g) !== mark) { + mark = progress(g); + for (const h of g.held.splice(0)) take(g, h, out); + } + return out; +} + +// changes whenever input that was unusable may have become usable +const progress = (g) => g.tip.epoch + ":" + (g.via ? g.via.digest : "") + ":" + (g.tip.pending || []).length + ":" + (g.pending ? 1 : 0); + +function hold(g, ev) { + if (g.held.some((h) => h.id === ev.id)) return; + g.held.push(ev); + if (g.held.length > HELD_MAX) g.held.shift(); +} +const unsee = (g, ...ids) => { ids = ids.map(short); g.seen = g.seen.filter((x) => !ids.includes(x)); }; + +// One event through the engine: processed, rejected for good, or held for later. +function take(g, ev, out) { + let payload; + try { payload = base64.decode(ev.content); } catch { see(g, ev.id); return true; } + if (payload.length < 28) { see(g, ev.id); return true; } + let src = null, bytes = null; + for (const s of [g.tip, ...(g.pending ? [g.pending.state] : []), ...g.past]) { + bytes = open(s, payload); + if (bytes) { src = s; break; } + } + if (!bytes) { hold(g, ev); return false; } + see(g, ev.id); + const digest = bytesToHex(sha256(bytes)); + if (sawIt(g, digest)) return true; + see(g, digest); + let peek; + try { peek = mls.decodeMessage(bytes); } catch { return true; } + + // somebody built on our unacknowledged commit: it reached the relays after all + if (g.pending && src === g.pending.state) { confirm(g, out); src = g.tip; } + + try { + if (peek.contentType === "application") { + if (g.tip.epoch - src.epoch > PAST_EPOCHS) return true; + const r = mls.processMessage(src, bytes); + swap(g, src, r.state); + const inner = readAppEvent(r.data, accountAt(src, r.sender)); + if (inner) deliver(g, out, inner, src.epoch); + } else if (peek.contentType === "proposal") { + if (src !== g.tip) return true; // proposals are epoch-bound: stale + const r = mls.processMessage(src, bytes); + g.tip = r.state; + if (r.proposal.type === mls.PROPOSAL.SELF_REMOVE) out.push({ selfRemove: r.sender, who: accountAt(src, r.sender) }); + } else if (peek.contentType === "commit") { + const r = mls.processMessage(src, bytes); + const edge = checkCommit(src, r, digest); + if (src === g.tip) { + if (g.pending) { + // a race from the same parent: ours is not canonical yet. Order them now; + // every member that sees both makes the same call. + if (beats(g.pending.edge, edge)) { g.alt = { ev, digest }; unsee(g, ev.id, digest); return true; } + g.pending = null; + out.push({ lost: true }); + } + applyCommit(g, out, src, r, edge, ev.created_at); + } else if (src === g.past[0] && g.via && beats(edge, g.via)) { + // a sibling of the commit we applied wins the tie-break: switch branch + const dropped = g.log.filter((m) => m.epoch === g.tip.epoch).map((m) => m.id); + g.log = g.log.filter((m) => m.epoch !== g.tip.epoch); + g.tip = g.past.shift(); + applyCommit(g, out, src, r, edge, ev.created_at); + if (dropped.length) out.push({ withdrawn: dropped }); + } + // anything else forks from further back than a one-commit branch can win + } + } catch (e) { + // a commit can overtake the proposal it references (relays do not order): + // keep it until the proposal shows up + if (e && (e.code === "missing-proposal") && src === g.tip) { + unsee(g, ev.id, digest); + hold(g, ev); + return false; + } + out.push({ invalid: String(e && e.message || e) }); + } + return true; +} + +function swap(g, old, next) { + if (g.tip === old) g.tip = next; + else { + const i = g.past.indexOf(old); + if (i >= 0) g.past[i] = next; + } +} + +// ---- our own output ---- + +// An app event out. The ratchet has moved once this returns: persist the +// record before the event leaves. +export function send(g, inner) { + const r = mls.encryptApplication(g.tip, utf8(JSON.stringify(inner))); + const v = groupView(g.tip); + const ev = seal(g.tip, r.message, v.retention ? inner.created_at + v.retention : 0); + g.tip = r.state; + see(g, ev.id); + see(g, bytesToHex(sha256(r.message))); + const out = []; + deliver(g, out, inner, g.tip.epoch); + return ev; +} + +// Stage a commit. It is NOT group state until a relay acknowledges the +// returned event: call confirm() then, or drop() if nobody would take it. +export function stage(g, { proposals = [] } = {}) { + if (g.pending) throw new Error("a change is already on its way"); + const r = mls.createCommit(g.tip, { proposals }); + const digest = bytesToHex(sha256(r.message)); + const edge = checkCommit(g.tip, { ...r, sender: g.tip.leafIndex, proposals: staged(g.tip, proposals), removedSelf: false }, digest); + g.pending = { state: r.state, ev: seal(g.tip, r.message), edge, welcome: r.welcome, at: now() }; + return g.pending; +} + +// what createCommit will have committed: the inline proposals plus the pending by-reference ones +const staged = (state, inline) => [ + ...inline.map((proposal) => ({ proposal, sender: state.leafIndex, byRef: false })), + ...(state.pending || []).map((p) => ({ proposal: p.proposal, sender: p.sender, byRef: true })), +]; + +export function confirm(g, out = []) { + const p = g.pending; + if (!p) return out; + g.pending = null; + g.alt = null; + const parent = g.tip; + see(g, p.ev.id); + see(g, p.edge.digest); + applyCommit(g, out, parent, { state: p.state, removedSelf: false }, p.edge, p.ev.created_at); + return out; +} + +// Our commit never made it out. If a peer's commit was waiting behind it, that one goes in. +export function drop(g) { + const out = []; + const alt = g.alt; + g.pending = null; + g.alt = null; + if (alt) return receive(g, alt.ev); + return out; +} + +// The proposals for common changes. +export const invite = (cands) => cands.map((c) => mls.proposeAdd(c.keyPackage)); +export function remove(g, pubkey) { + const leaves = roster(g.tip).filter((m) => m.pubkey === pubkey).map((m) => mls.proposeRemove(m.leaf)); + const v = groupView(g.tip); + // an admin's last leaf and its admin entry leave in the same commit + return v.admins.includes(pubkey) ? [...leaves, setAdmins(v.admins.filter((a) => a !== pubkey))] : leaves; +} +export const setAdmins = (list) => mls.proposeAppDataUpdate(COMP.ADMINS, admins.enc(list)); +export const setProfile = (name, about = "") => mls.proposeAppDataUpdate(COMP.PROFILE, profile.enc({ name, about })); + +// Leaving is a proposal somebody else commits. The record goes quiet at once. +export function leave(g) { + const r = mls.createProposal(g.tip, mls.proposeSelfRemove()); + const ev = seal(g.tip, r.message); + g.tip = r.state; + g.leaving = true; + see(g, ev.id); + see(g, bytesToHex(sha256(r.message))); + return ev; +} diff --git a/src/mls.js b/src/mls.js new file mode 100644 index 0000000..449036b --- /dev/null +++ b/src/mls.js @@ -0,0 +1,2178 @@ +// MLS (RFC 9420) — the group key agreement under Marmot / White Noise +// https://www.rfc-editor.org/rfc/rfc9420.html +// +// One ciphersuite (0x0001: X25519 / AES-128-GCM / SHA-256 / Ed25519), basic +// credentials, and the draft-ietf-mls-extensions-10 pieces Marmot needs: +// app_data_dictionary, app_data_update, app_ephemeral, self_remove. Protocol-pure +// and synchronous: no DOM, no app state, no storage. +// +// States are plain data (objects, arrays, numbers, strings, Uint8Array) and are +// never mutated: every call returns a new state, so the caller can keep old +// epochs and candidate branches around. Where ts-mls and OpenMLS disagree the +// code follows OpenMLS, because that is what the other clients run. +// +// Decoded but refused: reinit, external_init / external commits, external and +// new_member senders, reinit and branch PSKs. We never send Update proposals +// (a commit rotates our leaf) but apply other members'. + +import { ed25519, x25519 } from "@noble/curves/ed25519"; +import { sha256 } from "@noble/hashes/sha256"; +import { hmac } from "@noble/hashes/hmac"; +import { extract, expand } from "@noble/hashes/hkdf"; +import { bytesToHex, randomBytes } from "@noble/hashes/utils"; +import { gcm } from "@noble/ciphers/aes.js"; + +export const EXT = { + APPLICATION_ID: 1, + RATCHET_TREE: 2, + REQUIRED_CAPABILITIES: 3, + EXTERNAL_PUB: 4, + EXTERNAL_SENDERS: 5, + APP_DATA_DICTIONARY: 6, +}; + +export const PROPOSAL = { + ADD: 1, + UPDATE: 2, + REMOVE: 3, + PSK: 4, + REINIT: 5, + EXTERNAL_INIT: 6, + GROUP_CONTEXT_EXTENSIONS: 7, + APP_DATA_UPDATE: 8, + APP_EPHEMERAL: 9, + SELF_REMOVE: 10, +}; + +export const LEAF_SOURCE = { KEY_PACKAGE: 1, UPDATE: 2, COMMIT: 3 }; + +const WIRE = { PUBLIC: 1, PRIVATE: 2, WELCOME: 3, GROUP_INFO: 4, KEY_PACKAGE: 5 }; +const WIRE_NAME = [undefined, "public", "private", "welcome", "group_info", "key_package"]; +const CONTENT = { APPLICATION: 1, PROPOSAL: 2, COMMIT: 3 }; +const CONTENT_NAME = [undefined, "application", "proposal", "commit"]; +const SENDER = { MEMBER: 1, EXTERNAL: 2, NEW_MEMBER_PROPOSAL: 3, NEW_MEMBER_COMMIT: 4 }; + +const EMPTY = new Uint8Array(0); +const ZERO32 = new Uint8Array(32); +// how far a sender's ratchet may jump ahead, and how many skipped keys we keep per ratchet +const MAX_SKIP = 1000; + +const utf8 = (s) => new TextEncoder().encode(s); +const hex = bytesToHex; + +const fail = (msg, code) => { + let e = new Error("mls: " + msg); + if (code) e.code = code; + throw e; +}; + +const concat = (...arrs) => { + let out = new Uint8Array(arrs.reduce((n, a) => n + a.length, 0)); + let o = 0; + for (let a of arrs) (out.set(a, o), (o += a.length)); + return out; +}; + +const eq = (a, b) => { + if (a.length !== b.length) return false; + let d = 0; + for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i]; + return d === 0; +}; + +// ---- TLS presentation language, with RFC 9420 §2.1.2 variable-length vectors ---- + +export class Writer { + constructor() { + this.parts = []; + } + bytes(b) { + this.parts.push(b); + return this; + } + uint(n, size) { + if (!Number.isInteger(n) || n < 0 || n >= 2 ** (8 * size)) + fail(`uint${8 * size} out of range: ${n}`); + let b = new Uint8Array(size); + for (let i = size - 1; i >= 0; i--) ((b[i] = n % 256), (n = Math.floor(n / 256))); + return this.bytes(b); + } + u8(n) { + return this.uint(n, 1); + } + u16(n) { + return this.uint(n, 2); + } + u32(n) { + return this.uint(n, 4); + } + // numbers past 2^53 travel as decimal strings (see Reader.u64) + u64(n) { + let v = BigInt(n); + if (v < 0n || v >> 64n) fail("uint64 out of range: " + n); + return this.u32(Number(v >> 32n)).u32(Number(v & 0xffffffffn)); + } + varint(n) { + if (n < 64) return this.u8(n); + if (n < 16384) return this.u16(n | 0x4000); + if (n < 0x40000000) return this.u32(n + 0x80000000); + return fail("vector too long"); + } + vec(b) { + return this.varint(b.length).bytes(b); + } + list(items, fn) { + let w = new Writer(); + items.forEach((item, i) => fn(w, item, i)); + return this.vec(w.finish()); + } + opt(v, fn) { + if (v == null) return this.u8(0); + fn(this.u8(1), v); + return this; + } + finish() { + return concat(...this.parts); + } +} + +export class Reader { + constructor(buf, pos = 0, end = buf.length) { + this.buf = buf; + this.pos = pos; + this.end = end; + } + need(n) { + if (n < 0 || this.pos + n > this.end) fail("truncated input"); + let p = this.pos; + this.pos += n; + return p; + } + u8() { + return this.buf[this.need(1)]; + } + u16() { + let p = this.need(2); + return (this.buf[p] << 8) | this.buf[p + 1]; + } + u32() { + let p = this.need(4); + return ( + this.buf[p] * 0x1000000 + ((this.buf[p + 1] << 16) | (this.buf[p + 2] << 8) | this.buf[p + 3]) + ); + } + // a JS number while it is exact, a decimal string beyond that — never a BigInt, + // so decoded structures stay structured-clone friendly and still re-encode exactly + u64() { + let hi = this.u32(); + let lo = this.u32(); + return hi < 0x200000 ? hi * 0x100000000 + lo : ((BigInt(hi) << 32n) | BigInt(lo)).toString(); + } + // copies: a view would pin (and structured-clone) the whole message buffer + bytes(n) { + let p = this.need(n); + return this.buf.slice(p, p + n); + } + varint() { + let b = this.u8(); + let prefix = b >> 6; + if (prefix === 3) fail("invalid vector length"); + let n = b & 0x3f; + for (let i = (1 << prefix) - 1; i > 0; i--) n = n * 256 + this.u8(); + if ((prefix === 1 && n < 64) || (prefix === 2 && n < 16384)) + fail("vector length not in shortest form"); + return n; + } + vec() { + return this.bytes(this.varint()); + } + list(fn) { + let n = this.varint(); + let sub = new Reader(this.buf, this.need(n), this.pos); + let out = []; + while (sub.pos < sub.end) out.push(fn(sub, out.length)); + return out; + } + opt(fn) { + let b = this.u8(); + if (b > 1) fail("invalid optional"); + return b ? fn(this) : null; + } + done() { + if (this.pos !== this.end) fail("trailing bytes"); + } +} + +const enc = (fn, v, ...rest) => fn(new Writer(), v, ...rest).finish(); +const dec = (fn, bytes) => { + let r = new Reader(bytes); + let v = fn(r); + r.done(); + return v; +}; + +// ---- ciphersuite 0x0001 ---- + +const mac = (key, data) => hmac(sha256, key, data); + +const expandWithLabel = (secret, label, context, length) => + expand( + sha256, + secret, + new Writer() + .u16(length) + .vec(utf8("MLS 1.0 " + label)) + .vec(context) + .finish(), + length, + ); +const deriveSecret = (secret, label) => expandWithLabel(secret, label, EMPTY, 32); +const deriveTreeSecret = (secret, label, generation, length) => + expandWithLabel(secret, label, new Writer().u32(generation).finish(), length); +const refHash = (label, value) => sha256(new Writer().vec(utf8(label)).vec(value).finish()); + +const labeled = (label, content) => + new Writer() + .vec(utf8("MLS 1.0 " + label)) + .vec(content) + .finish(); +// Ed25519 private keys are the 32-byte seed; some libraries hand out seed || public +const signWithLabel = (priv, label, content) => + ed25519.sign(labeled(label, content), priv.subarray(0, 32)); +const verifyWithLabel = (pub, label, content, signature) => { + try { + return ed25519.verify(signature, labeled(label, content), pub); + } catch { + return false; + } +}; + +// HPKE (RFC 9180), base mode, DHKEM(X25519, HKDF-SHA256) / HKDF-SHA256 / AES-128-GCM +const KEM_SUITE = concat(utf8("KEM"), Uint8Array.of(0, 0x20)); +const HPKE_SUITE = concat(utf8("HPKE"), Uint8Array.of(0, 0x20, 0, 1, 0, 1)); +const HPKE_V1 = utf8("HPKE-v1"); + +const labeledExtract = (suite, salt, label, ikm) => + extract(sha256, concat(HPKE_V1, suite, utf8(label), ikm), salt); +const labeledExpand = (suite, prk, label, info, length) => + expand( + sha256, + prk, + concat(Uint8Array.of(length >> 8, length & 255), HPKE_V1, suite, utf8(label), info), + length, + ); + +const deriveKeyPair = (ikm) => { + let priv = labeledExpand( + KEM_SUITE, + labeledExtract(KEM_SUITE, EMPTY, "dkp_prk", ikm), + "sk", + EMPTY, + 32, + ); + return { priv, pub: x25519.getPublicKey(priv) }; +}; + +const hpkeKey = (dh, kemOutput, pub, info) => { + let eae = labeledExtract(KEM_SUITE, EMPTY, "eae_prk", dh); + let shared = labeledExpand(KEM_SUITE, eae, "shared_secret", concat(kemOutput, pub), 32); + let context = concat( + Uint8Array.of(0), + labeledExtract(HPKE_SUITE, EMPTY, "psk_id_hash", EMPTY), + labeledExtract(HPKE_SUITE, EMPTY, "info_hash", info), + ); + let secret = labeledExtract(HPKE_SUITE, shared, "secret", EMPTY); + return { + key: labeledExpand(HPKE_SUITE, secret, "key", context, 16), + nonce: labeledExpand(HPKE_SUITE, secret, "base_nonce", context, 12), + }; +}; + +const hpkeSeal = (pub, info, aad, plaintext) => { + let eph = randomBytes(32); + let kemOutput = x25519.getPublicKey(eph); + let { key, nonce } = hpkeKey(x25519.getSharedSecret(eph, pub), kemOutput, pub, info); + return { kemOutput, ciphertext: gcm(key, nonce, aad).encrypt(plaintext) }; +}; + +const hpkeOpen = (priv, kemOutput, info, aad, ciphertext) => { + let { key, nonce } = hpkeKey( + x25519.getSharedSecret(priv, kemOutput), + kemOutput, + x25519.getPublicKey(priv), + info, + ); + return gcm(key, nonce, aad).decrypt(ciphertext); +}; + +const encryptWithLabel = (pub, label, context, plaintext) => + hpkeSeal(pub, labeled(label, context), EMPTY, plaintext); +const decryptWithLabel = (priv, label, context, kemOutput, ciphertext) => + hpkeOpen(priv, kemOutput, labeled(label, context), EMPTY, ciphertext); + +// ---- structures ---- + +const wU16s = (w, list) => w.list(list, (w, n) => w.u16(n)); +const rU16s = (r) => r.list((r) => r.u16()); + +// extensions stay { type, data }: unknown and GREASE types round-trip untouched +const wExts = (w, exts) => w.list(exts, (w, e) => w.u16(e.type).vec(e.data)); +const rExts = (r) => r.list((r) => ({ type: r.u16(), data: r.vec() })); + +const CAPS = ["versions", "ciphersuites", "extensions", "proposals", "credentials"]; +const wCaps = (w, c) => CAPS.reduce((w, k) => wU16s(w, c[k]), w); +const rCaps = (r) => Object.fromEntries(CAPS.map((k) => [k, rU16s(r)])); + +const wLeafBody = (w, l) => { + w.vec(l.encryptionKey).vec(l.signatureKey).u16(l.credential.type).vec(l.credential.identity); + wCaps(w, l.capabilities).u8(l.source); + if (l.source === LEAF_SOURCE.KEY_PACKAGE) w.u64(l.lifetime.notBefore).u64(l.lifetime.notAfter); + if (l.source === LEAF_SOURCE.COMMIT) w.vec(l.parentHash); + return wExts(w, l.extensions); +}; +const wLeaf = (w, l) => wLeafBody(w, l).vec(l.signature); +const rLeaf = (r) => { + // every credential is type + one vector on the wire (basic: identity, x509: the chain), + // so unknown kinds decode as opaque; only basic is accepted as a member + let l = { + encryptionKey: r.vec(), + signatureKey: r.vec(), + credential: { type: r.u16(), identity: r.vec() }, + }; + l.capabilities = rCaps(r); + l.source = r.u8(); + if (l.source === LEAF_SOURCE.KEY_PACKAGE) l.lifetime = { notBefore: r.u64(), notAfter: r.u64() }; + else if (l.source === LEAF_SOURCE.COMMIT) l.parentHash = r.vec(); + else if (l.source !== LEAF_SOURCE.UPDATE) fail("unknown leaf node source " + l.source); + l.extensions = rExts(r); + l.signature = r.vec(); + return l; +}; +const leafTBS = (l, groupId, leafIndex) => { + let w = wLeafBody(new Writer(), l); + if (l.source !== LEAF_SOURCE.KEY_PACKAGE) w.vec(groupId).u32(leafIndex); + return w.finish(); +}; +const signLeaf = (l, priv, groupId, leafIndex) => ({ + ...l, + signature: signWithLabel(priv, "LeafNodeTBS", leafTBS(l, groupId, leafIndex)), +}); + +const wKeyPackageBody = (w, k) => + wExts(wLeaf(w.u16(k.version).u16(k.cipherSuite).vec(k.initKey), k.leaf), k.extensions); +const wKeyPackage = (w, k) => wKeyPackageBody(w, k).vec(k.signature); +const rKeyPackage = (r) => ({ + version: r.u16(), + cipherSuite: r.u16(), + initKey: r.vec(), + leaf: rLeaf(r), + extensions: rExts(r), + signature: r.vec(), +}); +const keyPackageRef = (k) => refHash("MLS 1.0 KeyPackage Reference", enc(wKeyPackage, k)); + +const wParent = (w, p) => + w + .vec(p.encryptionKey) + .vec(p.parentHash) + .list(p.unmergedLeaves, (w, n) => w.u32(n)); +const rParent = (r) => ({ + encryptionKey: r.vec(), + parentHash: r.vec(), + unmergedLeaves: r.list((r) => r.u32()), +}); + +// a ratchet tree is an array over node indices: leaves at even slots, parents at +// odd ones, null for blank; always 2^k - 1 wide +const wTree = (w, tree) => { + let n = tree.length; + while (n && !tree[n - 1]) n--; + return w.list(tree.slice(0, n), (w, node, i) => + w.opt(node, (w, node) => (i % 2 ? wParent(w.u8(2), node) : wLeaf(w.u8(1), node))), + ); +}; +const rTree = (r) => { + let tree = r.list((r, i) => + r.opt((r) => { + if (r.u8() !== (i % 2 ? 2 : 1)) fail("ratchet tree: wrong node type at index " + i); + return i % 2 ? rParent(r) : rLeaf(r); + }), + ); + if (!tree.length || !tree[tree.length - 1]) fail("ratchet tree must end in a non-blank node"); + let width = 1; + while (width < tree.length) width = 2 * width + 1; + while (tree.length < width) tree.push(null); + return tree; +}; + +const wContext = (w, c) => + wExts( + w + .u16(c.version) + .u16(c.cipherSuite) + .vec(c.groupId) + .u64(c.epoch) + .vec(c.treeHash) + .vec(c.confirmedTranscriptHash), + c.extensions, + ); +const rContext = (r) => ({ + version: r.u16(), + cipherSuite: r.u16(), + groupId: r.vec(), + epoch: r.u64(), + treeHash: r.vec(), + confirmedTranscriptHash: r.vec(), + extensions: rExts(r), +}); + +const wPskId = (w, p) => { + w.u8(p.psktype); + if (p.psktype === 1) w.vec(p.pskId); + else w.u8(p.usage).vec(p.pskGroupId).u64(p.pskEpoch); + return w.vec(p.nonce); +}; +const rPskId = (r) => { + let p = { psktype: r.u8() }; + if (p.psktype === 1) p.pskId = r.vec(); + else if (p.psktype === 2) + Object.assign(p, { usage: r.u8(), pskGroupId: r.vec(), pskEpoch: r.u64() }); + else fail("unknown PSK type " + p.psktype); + p.nonce = r.vec(); + return p; +}; + +const wProposal = (w, p) => { + w.u16(p.type); + switch (p.type) { + case PROPOSAL.ADD: + return wKeyPackage(w, p.keyPackage); + case PROPOSAL.UPDATE: + return wLeaf(w, p.leaf); + case PROPOSAL.REMOVE: + return w.u32(p.removed); + case PROPOSAL.PSK: + return wPskId(w, p.psk); + case PROPOSAL.REINIT: + return wExts(w.vec(p.groupId).u16(p.version).u16(p.cipherSuite), p.extensions); + case PROPOSAL.EXTERNAL_INIT: + return w.vec(p.kemOutput); + case PROPOSAL.GROUP_CONTEXT_EXTENSIONS: + return wExts(w, p.extensions); + case PROPOSAL.APP_DATA_UPDATE: + w.u16(p.componentId).u8(p.op); + return p.op === 1 ? w.vec(p.data) : w; + case PROPOSAL.APP_EPHEMERAL: + return w.u16(p.componentId).vec(p.data); + case PROPOSAL.SELF_REMOVE: + return w; + default: + return w.vec(p.data); + } +}; +const rProposal = (r) => { + let type = r.u16(); + switch (type) { + case PROPOSAL.ADD: + return { type, keyPackage: rKeyPackage(r) }; + case PROPOSAL.UPDATE: + return { type, leaf: rLeaf(r) }; + case PROPOSAL.REMOVE: + return { type, removed: r.u32() }; + case PROPOSAL.PSK: + return { type, psk: rPskId(r) }; + case PROPOSAL.REINIT: + return { + type, + groupId: r.vec(), + version: r.u16(), + cipherSuite: r.u16(), + extensions: rExts(r), + }; + case PROPOSAL.EXTERNAL_INIT: + return { type, kemOutput: r.vec() }; + case PROPOSAL.GROUP_CONTEXT_EXTENSIONS: + return { type, extensions: rExts(r) }; + case PROPOSAL.APP_DATA_UPDATE: { + let p = { type, componentId: r.u16(), op: r.u8() }; + if (p.op === 1) p.data = r.vec(); + else if (p.op !== 2) fail("unknown app_data_update operation " + p.op); + return p; + } + case PROPOSAL.APP_EPHEMERAL: + return { type, componentId: r.u16(), data: r.vec() }; + case PROPOSAL.SELF_REMOVE: + return { type }; + default: + // unknown and GREASE proposal types carry one opaque vector (as OpenMLS frames them) + return { type, data: r.vec() }; + } +}; + +const wHpke = (w, c) => w.vec(c.kemOutput).vec(c.ciphertext); +const rHpke = (r) => ({ kemOutput: r.vec(), ciphertext: r.vec() }); + +const wPath = (w, p) => + wLeaf(w, p.leaf).list(p.nodes, (w, n) => w.vec(n.encryptionKey).list(n.secrets, wHpke)); +const rPath = (r) => ({ + leaf: rLeaf(r), + nodes: r.list((r) => ({ encryptionKey: r.vec(), secrets: r.list(rHpke) })), +}); + +const wCommit = (w, c) => + w + .list(c.proposals, (w, p) => (p.ref ? w.u8(2).vec(p.ref) : wProposal(w.u8(1), p.proposal))) + .opt(c.path, wPath); +const rCommit = (r) => ({ + proposals: r.list((r) => { + let t = r.u8(); + if (t === 1) return { proposal: rProposal(r) }; + if (t === 2) return { ref: r.vec() }; + return fail("unknown ProposalOrRef type " + t); + }), + path: r.opt(rPath), +}); + +const wSender = (w, s) => { + w.u8(s.type); + if (s.type === SENDER.MEMBER) w.u32(s.leafIndex); + if (s.type === SENDER.EXTERNAL) w.u32(s.senderIndex); + return w; +}; +const rSender = (r) => { + let type = r.u8(); + if (type === SENDER.MEMBER) return { type, leafIndex: r.u32() }; + if (type === SENDER.EXTERNAL) return { type, senderIndex: r.u32() }; + if (type === SENDER.NEW_MEMBER_PROPOSAL || type === SENDER.NEW_MEMBER_COMMIT) return { type }; + return fail("unknown sender type " + type); +}; + +const wBody = (w, c) => + c.contentType === CONTENT.APPLICATION + ? w.vec(c.data) + : c.contentType === CONTENT.PROPOSAL + ? wProposal(w, c.proposal) + : wCommit(w, c.commit); +const rBody = (r, contentType) => { + if (contentType === CONTENT.APPLICATION) return { data: r.vec() }; + if (contentType === CONTENT.PROPOSAL) return { proposal: rProposal(r) }; + if (contentType === CONTENT.COMMIT) return { commit: rCommit(r) }; + return fail("unknown content type " + contentType); +}; + +// FramedContent +const wContent = (w, c) => + wBody(wSender(w.vec(c.groupId).u64(c.epoch), c.sender).vec(c.aad).u8(c.contentType), c); +const rContent = (r) => { + let c = { + groupId: r.vec(), + epoch: r.u64(), + sender: rSender(r), + aad: r.vec(), + contentType: r.u8(), + }; + return Object.assign(c, rBody(r, c.contentType)); +}; + +const wAuth = (w, a, contentType) => + contentType === CONTENT.COMMIT ? w.vec(a.signature).vec(a.confirmationTag) : w.vec(a.signature); +const rAuth = (r, contentType) => + contentType === CONTENT.COMMIT + ? { signature: r.vec(), confirmationTag: r.vec() } + : { signature: r.vec() }; + +const wPublic = (w, m) => { + wAuth(wContent(w, m.content), m.auth, m.content.contentType); + return m.content.sender.type === SENDER.MEMBER ? w.vec(m.membershipTag) : w; +}; +const rPublic = (r) => { + let content = rContent(r); + let m = { content, auth: rAuth(r, content.contentType) }; + if (content.sender.type === SENDER.MEMBER) m.membershipTag = r.vec(); + return m; +}; + +const wPrivate = (w, m) => + w + .vec(m.groupId) + .u64(m.epoch) + .u8(m.contentType) + .vec(m.aad) + .vec(m.encryptedSenderData) + .vec(m.ciphertext); +const rPrivate = (r) => ({ + groupId: r.vec(), + epoch: r.u64(), + contentType: r.u8(), + aad: r.vec(), + encryptedSenderData: r.vec(), + ciphertext: r.vec(), +}); + +const wGroupInfoTBS = (w, g) => + wExts(wContext(w, g.context), g.extensions).vec(g.confirmationTag).u32(g.signer); +const wGroupInfo = (w, g) => wGroupInfoTBS(w, g).vec(g.signature); +const rGroupInfo = (r) => ({ + context: rContext(r), + extensions: rExts(r), + confirmationTag: r.vec(), + signer: r.u32(), + signature: r.vec(), +}); + +const wWelcome = (w, m) => + w + .u16(m.cipherSuite) + .list(m.secrets, (w, s) => wHpke(w.vec(s.ref), s)) + .vec(m.encryptedGroupInfo); +const rWelcome = (r) => ({ + cipherSuite: r.u16(), + secrets: r.list((r) => ({ ref: r.vec(), ...rHpke(r) })), + encryptedGroupInfo: r.vec(), +}); + +const wGroupSecrets = (w, g) => + w + .vec(g.joinerSecret) + .opt(g.pathSecret, (w, s) => w.vec(s)) + .list(g.psks, wPskId); +const rGroupSecrets = (r) => ({ + joinerSecret: r.vec(), + pathSecret: r.opt((r) => r.vec()), + psks: r.list(rPskId), +}); + +const BODY = [undefined, "public", "private", "welcome", "groupInfo", "keyPackage"]; +const W_BODY = [undefined, wPublic, wPrivate, wWelcome, wGroupInfo, wKeyPackage]; +const R_BODY = [undefined, rPublic, rPrivate, rWelcome, rGroupInfo, rKeyPackage]; + +const wMessage = (w, m) => W_BODY[m.wireFormat](w.u16(1).u16(m.wireFormat), m[BODY[m.wireFormat]]); +const rMessage = (r) => { + let version = r.u16(); + let wireFormat = r.u16(); + if (version !== 1) fail("unsupported protocol version " + version, "unsupported"); + if (!R_BODY[wireFormat]) fail("unknown wire format " + wireFormat); + return { wireFormat, [BODY[wireFormat]]: R_BODY[wireFormat](r) }; +}; + +// ---- extensions we read ---- + +const extOf = (exts, type) => exts.find((e) => e.type === type)?.data; + +const uniqueTypes = (exts, what) => { + if (new Set(exts.map((e) => e.type)).size !== exts.length) + fail("duplicate extension type in " + what); +}; + +const requiredCapabilities = (exts) => { + let data = extOf(exts, EXT.REQUIRED_CAPABILITIES); + return data + ? dec((r) => ({ extensions: rU16s(r), proposals: rU16s(r), credentials: rU16s(r) }), data) + : null; +}; + +// the RFC 9420 types every client supports without listing them in its capabilities +const supportsExtension = (caps, t) => (t >= 1 && t <= 5) || caps.extensions.includes(t); +const supportsProposal = (caps, t) => (t >= 1 && t <= 7) || caps.proposals.includes(t); +const meetsRequired = (caps, rc) => + !rc || + (rc.extensions.every((t) => supportsExtension(caps, t)) && + rc.proposals.every((t) => supportsProposal(caps, t)) && + rc.credentials.every((t) => caps.credentials.includes(t))); + +// app_data_dictionary: entries are { id, data }, sorted by id, one per component +export const encodeDict = (entries) => { + let sorted = [...entries].sort((a, b) => a.id - b.id); + if (sorted.some((e, i) => i && sorted[i - 1].id === e.id)) + fail("app_data_dictionary: duplicate component id"); + return new Writer().list(sorted, (w, e) => w.u16(e.id).vec(e.data)).finish(); +}; + +export const decodeDict = (bytes) => { + let entries = dec((r) => r.list((r) => ({ id: r.u16(), data: r.vec() })), bytes); + if (entries.some((e, i) => i && entries[i - 1].id >= e.id)) + fail("app_data_dictionary: entries not sorted and unique"); + return entries; +}; + +export const dictOf = (extensions) => { + let data = extOf(extensions, EXT.APP_DATA_DICTIONARY); + return data ? decodeDict(data) : []; +}; + +// ---- tree math (RFC 9420 appendix C, complete trees only) ---- + +const level = (x) => { + let k = 0; + while ((x >> k) & 1) k++; + return k; +}; +const left = (x) => x ^ (1 << (level(x) - 1)); +const right = (x) => x ^ (3 << (level(x) - 1)); +const parent = (x) => { + let k = level(x); + return (x | (1 << k)) & ~(1 << (k + 1)); +}; +const rootOf = (width) => width >> 1; +const sibling = (x) => { + let p = parent(x); + return x < p ? right(p) : left(p); +}; +const directPath = (x, width) => { + let path = []; + while (x !== rootOf(width)) path.push((x = parent(x))); + return path; +}; +const inSubtree = (top, x) => Math.abs(x - top) < 1 << level(top); + +const resolution = (tree, x) => { + let node = tree[x]; + if (node) return x % 2 ? [x, ...node.unmergedLeaves.map((l) => 2 * l)] : [x]; + return x % 2 ? [...resolution(tree, left(x)), ...resolution(tree, right(x))] : []; +}; + +// the direct path of leaf node x minus the parents whose copath child resolves to nobody +const filteredPath = (tree, x) => { + let out = []; + while (x !== rootOf(tree.length)) { + let copath = sibling(x); + let res = resolution(tree, copath); + x = parent(x); + if (res.length) out.push({ node: x, copath, res }); + } + return out; +}; + +// `without` (a Set of leaf indices) hashes the subtree as it was before those leaves joined +const treeHash = (tree, x, without) => { + if (x % 2 === 0) { + let leaf = without?.has(x / 2) ? null : tree[x]; + return sha256( + new Writer() + .u8(1) + .u32(x / 2) + .opt(leaf, wLeaf) + .finish(), + ); + } + let node = tree[x]; + if (node && without) + node = { ...node, unmergedLeaves: node.unmergedLeaves.filter((l) => !without.has(l)) }; + return sha256( + new Writer() + .u8(2) + .opt(node, wParent) + .vec(treeHash(tree, left(x), without)) + .vec(treeHash(tree, right(x), without)) + .finish(), + ); +}; +const rootHash = (tree) => treeHash(tree, rootOf(tree.length)); + +// the hash a child stores to pin parent node p, given the copath child s under p +const parentHashOf = (tree, p, s) => { + let node = tree[p]; + let without = node.unmergedLeaves.length ? new Set(node.unmergedLeaves) : undefined; + return sha256( + new Writer() + .vec(node.encryptionKey) + .vec(node.parentHash) + .vec(treeHash(tree, s, without)) + .finish(), + ); +}; + +// RFC 9420 §7.9.2: every non-blank parent must be vouched for by a node in the +// resolution of one of its children (one that was there when the parent was set) +const verifyParentHashes = (tree) => + tree.every((node, x) => { + if (!node || x % 2 === 0) return true; + let late = new Set(node.unmergedLeaves); + return [ + [left(x), right(x)], + [right(x), left(x)], + ].some(([child, other]) => { + let ph = parentHashOf(tree, x, other); + return resolution(tree, child).some((d) => { + if (d % 2 === 0 && late.has(d / 2)) return false; + let stored = d % 2 || tree[d].source === LEAF_SOURCE.COMMIT ? tree[d].parentHash : null; + return stored && eq(stored, ph); + }); + }); + }); + +// tree edits work on a fresh copy of the array and replace nodes, never touch them + +const blankPath = (tree, x) => { + for (let p of directPath(x, tree.length)) tree[p] = null; +}; + +const truncate = (tree) => { + let n = tree.length; + while (n > 1 && !tree[n - 1]) n--; + let width = 1; + while (width < n) width = 2 * width + 1; + tree.length = width; +}; + +const addLeaf = (tree, leaf) => { + let x = 0; + while (x < tree.length && tree[x]) x += 2; + if (x >= tree.length) for (let n = tree.length + 1; n > 0; n--) tree.push(null); + tree[x] = leaf; + // sorted, as OpenMLS keeps (and requires) them; ts-mls appends + for (let p of directPath(x, tree.length)) + if (tree[p]) + tree[p] = { + ...tree[p], + unmergedLeaves: [...tree[p].unmergedLeaves, x / 2].sort((a, b) => a - b), + }; + return x / 2; +}; + +// ---- validation ---- + +// RFC 9420 §7.3, the parts that need only the leaf and the group's extensions +const checkLeaf = (leaf, groupId, leafIndex, extensions, now) => { + if (leaf.encryptionKey.length !== 32 || leaf.signatureKey.length !== 32) + fail("leaf node key of the wrong size"); + if ( + !verifyWithLabel( + leaf.signatureKey, + "LeafNodeTBS", + leafTBS(leaf, groupId, leafIndex), + leaf.signature, + ) + ) + fail("invalid leaf node signature"); + if (leaf.credential.type !== 1) + fail("unsupported credential type " + leaf.credential.type, "unsupported"); + let caps = leaf.capabilities; + if (!caps.versions.includes(1) || !caps.ciphersuites.includes(1)) + fail("leaf node does not support this protocol version and ciphersuite"); + if (!caps.credentials.includes(leaf.credential.type)) + fail("leaf node credential type missing from its capabilities"); + uniqueTypes(leaf.extensions, "leaf node"); + if (!leaf.extensions.every((e) => supportsExtension(caps, e.type))) + fail("leaf node extension missing from its capabilities"); + if (!meetsRequired(caps, requiredCapabilities(extensions))) + fail("leaf node lacks the group's required capabilities"); + if (leaf.source === LEAF_SOURCE.KEY_PACKAGE) { + let from = BigInt(leaf.lifetime.notBefore); + let to = BigInt(leaf.lifetime.notAfter); + if (from > to) fail("leaf node lifetime ends before it starts"); + if (now != null && (from > BigInt(now) || to <= BigInt(now))) + fail("key package outside its lifetime", "lifetime"); + } +}; + +const checkKeyPackage = (kp) => { + if (kp.version !== 1 || kp.cipherSuite !== 1) + fail("key package for another version or ciphersuite", "unsupported"); + if (kp.leaf.source !== LEAF_SOURCE.KEY_PACKAGE) + fail("key package leaf node has the wrong source"); + if (kp.initKey.length !== 32 || eq(kp.initKey, kp.leaf.encryptionKey)) + fail("key package init key invalid"); + uniqueTypes(kp.extensions, "key package"); + if ( + !verifyWithLabel(kp.leaf.signatureKey, "KeyPackageTBS", enc(wKeyPackageBody, kp), kp.signature) + ) + fail("invalid key package signature"); +}; + +const checkTreeKeys = (tree) => { + let seen = new Set(); + tree.forEach((node, x) => { + if (!node) return; + let keys = x % 2 ? [node.encryptionKey] : [node.encryptionKey, node.signatureKey]; + for (let k of keys.map(hex)) { + if (seen.has(k)) fail("key appears twice in the ratchet tree"); + seen.add(k); + } + }); +}; + +const checkAllSupport = (tree, extensions) => { + let rc = requiredCapabilities(extensions); + tree.forEach((leaf, x) => { + if (!leaf || x % 2) return; + if ( + !meetsRequired(leaf.capabilities, rc) || + !extensions.every((e) => supportsExtension(leaf.capabilities, e.type)) + ) + fail("a member does not support the group context extensions"); + }); +}; + +// a tree somebody handed us (Welcome): RFC 9420 §12.4.3.1 +const checkTree = (tree, groupId, extensions, now) => { + tree.forEach((node, x) => { + if (!node) return; + if (x % 2 === 0) return checkLeaf(node, groupId, x / 2, extensions, now); + let last = -1; + for (let l of node.unmergedLeaves) { + if (l <= last) fail("unmerged leaves not sorted"); + last = l; + if (2 * l >= tree.length || !tree[2 * l] || !inSubtree(x, 2 * l)) + fail("unmerged leaf is not a member below its node"); + for (let p of directPath(2 * l, tree.length)) { + if (p === x) break; + if (tree[p] && !tree[p].unmergedLeaves.includes(l)) + fail("unmerged leaf missing from an intermediate node"); + } + } + }); + checkTreeKeys(tree); + if (!verifyParentHashes(tree)) fail("invalid parent hash in ratchet tree"); +}; + +// ---- key schedule ---- + +const epochKeys = (epochSecret) => { + let d = (label) => deriveSecret(epochSecret, label); + return { + encryption: d("encryption"), + keys: { + senderData: d("sender data"), + exporter: d("exporter"), + external: d("external"), + confirmation: d("confirm"), + membership: d("membership"), + resumption: d("resumption"), + authenticator: d("authentication"), + init: d("init"), + }, + }; +}; + +const joinerSecret = (init, commitSecret, context) => + expandWithLabel(extract(sha256, commitSecret, init), "joiner", context, 32); + +const fromJoiner = (joiner, pskSecret, context) => { + let member = extract(sha256, pskSecret, joiner); + return { + welcome: deriveSecret(member, "welcome"), + ...epochKeys(expandWithLabel(member, "epoch", context, 32)), + }; +}; + +const welcomeCipher = (welcome) => + gcm(expandWithLabel(welcome, "key", EMPTY, 16), expandWithLabel(welcome, "nonce", EMPTY, 12)); + +// RFC 9420 §8.4; `psks` is [{ id, psk }] +const pskSecret = (psks) => + psks.reduce((secret, { id, psk }, i) => { + let label = wPskId(new Writer(), id).u16(i).u16(psks.length).finish(); + let input = expandWithLabel(extract(sha256, psk, ZERO32), "derived psk", label, 32); + return extract(sha256, secret, input); + }, ZERO32); + +// PSKs come from the caller: { [hex(psk_id)]: bytes } for external ones, and +// { ["resumption:" + epoch]: state.keys.resumption } for earlier epochs of this +// group (we only hold the current epoch's ourselves) +const lookupPsks = (ids, known = {}, s) => { + if (new Set(ids.map((id) => hex(enc(wPskId, id)))).size !== ids.length) fail("duplicate PSK id"); + return pskSecret( + ids.map((id) => { + let psk = id.psktype === 1 ? known[hex(id.pskId)] : null; + if (id.psktype === 2) { + if (id.usage !== 1 || !s || !eq(id.pskGroupId, s.groupId)) + fail("reinit and branch PSKs are not supported", "unsupported"); + psk = id.pskEpoch === s.epoch ? s.keys.resumption : known["resumption:" + id.pskEpoch]; + } + if (!psk) + fail( + id.psktype === 1 ? "unknown external PSK" : "no resumption PSK for epoch " + id.pskEpoch, + "unsupported", + ); + return { id, psk }; + }), + ); +}; + +const interimHash = (confirmed, tag) => sha256(concat(confirmed, new Writer().vec(tag).finish())); +const confirmedHash = (interim, wire, content, signature) => + sha256(concat(interim, wContent(new Writer().u16(wire), content).vec(signature).finish())); + +// ---- secret tree (RFC 9420 §9) ---- +// +// { nodes: { nodeIndex: secret }, ratchets: { leafNodeIndex: { h, a } } }: node +// secrets are expanded only on the way down to a leaf that speaks, and deleted +// once used. A ratchet is { gen, secret, skip: { generation: { key, nonce } } }. + +const ratchetKey = (r) => ({ + key: deriveTreeSecret(r.secret, "key", r.gen, 16), + nonce: deriveTreeSecret(r.secret, "nonce", r.gen, 12), +}); +const ratchetNext = (r) => ({ + ...r, + gen: r.gen + 1, + secret: deriveTreeSecret(r.secret, "secret", r.gen, 32), +}); + +const leafRatchets = (st, width, x) => { + if (st.ratchets[x]) return st; + let nodes = { ...st.nodes }; + let path = [x, ...directPath(x, width)].reverse(); + let i = path.findIndex((n) => nodes[n]); + if (i < 0) fail("secret tree has no secret for leaf " + x / 2); + for (; path[i] !== x; i++) { + let n = path[i]; + nodes[left(n)] = expandWithLabel(nodes[n], "tree", utf8("left"), 32); + nodes[right(n)] = expandWithLabel(nodes[n], "tree", utf8("right"), 32); + delete nodes[n]; + } + let ratchet = (label) => ({ + gen: 0, + secret: expandWithLabel(nodes[x], label, EMPTY, 32), + skip: {}, + }); + let ratchets = { ...st.ratchets, [x]: { h: ratchet("handshake"), a: ratchet("application") } }; + delete nodes[x]; + return { nodes, ratchets }; +}; + +const setRatchet = (st, x, kind, r) => ({ + ...st, + ratchets: { ...st.ratchets, [x]: { ...st.ratchets[x], [kind]: r } }, +}); + +// the key for `generation`, and the ratchet after it has been taken +const seek = (r, generation) => { + if (generation < r.gen) { + let kn = r.skip[generation]; + if (!kn) fail("message key already used", "replay"); + let skip = { ...r.skip }; + delete skip[generation]; + return { kn, r: { ...r, skip } }; + } + if (generation - r.gen > MAX_SKIP) fail("message generation too far ahead"); + let skip = { ...r.skip }; + for (; r.gen < generation; r = ratchetNext(r)) skip[r.gen] = ratchetKey(r); + let gens = Object.keys(skip); + for (let g of gens.slice(0, Math.max(0, gens.length - MAX_SKIP))) delete skip[g]; + return { kn: ratchetKey(r), r: { ...ratchetNext(r), skip } }; +}; + +// ---- message framing ---- + +const contentTBS = (s, wire, content) => { + let w = wContent(new Writer().u16(1).u16(wire), content); + let t = content.sender.type; + if (t === SENDER.MEMBER || t === SENDER.NEW_MEMBER_COMMIT) wContext(w, s); + return w.finish(); +}; + +const signContent = (s, wire, content) => + signWithLabel(s.sigPriv, "FramedContentTBS", contentTBS(s, wire, content)); + +const memberLeaf = (s, leafIndex) => { + let leaf = 2 * leafIndex < s.tree.length ? s.tree[2 * leafIndex] : null; + return leaf || fail("sender is not a member of the group"); +}; + +const verifyContent = (s, wire, content, auth) => { + let leaf = memberLeaf(s, content.sender.leafIndex); + if ( + !verifyWithLabel( + leaf.signatureKey, + "FramedContentTBS", + contentTBS(s, wire, content), + auth.signature, + ) + ) + fail("invalid message signature"); +}; + +const membershipTag = (s, content, auth) => + mac( + s.keys.membership, + wAuth( + new Writer().bytes(contentTBS(s, WIRE.PUBLIC, content)), + auth, + content.contentType, + ).finish(), + ); + +const sealPublic = (s, content, auth) => + enc(wMessage, { + wireFormat: WIRE.PUBLIC, + public: { content, auth, membershipTag: membershipTag(s, content, auth) }, + }); + +const refuseSender = (sender) => { + if (sender.type === SENDER.EXTERNAL) fail("external senders are not supported", "unsupported"); + if (sender.type === SENDER.NEW_MEMBER_PROPOSAL) + fail("new_member proposals are not supported", "unsupported"); + if (sender.type === SENDER.NEW_MEMBER_COMMIT) + fail("external commits are not supported", "unsupported"); +}; + +const openPublic = (s, m) => { + refuseSender(m.content.sender); + if (m.content.contentType === CONTENT.APPLICATION) fail("application data must be encrypted"); + if (!eq(m.membershipTag, membershipTag(s, m.content, m.auth))) fail("invalid membership tag"); + verifyContent(s, WIRE.PUBLIC, m.content, m.auth); + return m; +}; + +const privateAAD = (m) => + new Writer().vec(m.groupId).u64(m.epoch).u8(m.contentType).vec(m.aad).finish(); +const senderDataCipher = (s, m) => { + let sample = m.ciphertext.subarray(0, 32); + let aad = new Writer().vec(m.groupId).u64(m.epoch).u8(m.contentType).finish(); + let secret = s.keys.senderData; + return gcm( + expandWithLabel(secret, "key", sample, 16), + expandWithLabel(secret, "nonce", sample, 12), + aad, + ); +}; +const guarded = (nonce, guard) => nonce.map((b, i) => (i < 4 ? b ^ guard[i] : b)); +const ratchetKind = (contentType) => (contentType === CONTENT.APPLICATION ? "a" : "h"); + +const sealPrivate = (s, content, auth, padding = 0) => { + let x = 2 * s.leafIndex; + let kind = ratchetKind(content.contentType); + let st = leafRatchets(s.secretTree, s.tree.length, x); + let r = st.ratchets[x][kind]; + let kn = ratchetKey(r); + let guard = randomBytes(4); + let m = { + groupId: content.groupId, + epoch: content.epoch, + contentType: content.contentType, + aad: content.aad, + }; + let body = wAuth(wBody(new Writer(), content), auth, content.contentType) + .bytes(new Uint8Array(padding)) + .finish(); + m.ciphertext = gcm(kn.key, guarded(kn.nonce, guard), privateAAD(m)).encrypt(body); + let senderData = new Writer().u32(s.leafIndex).u32(r.gen).bytes(guard).finish(); + m.encryptedSenderData = senderDataCipher(s, m).encrypt(senderData); + return { + message: enc(wMessage, { wireFormat: WIRE.PRIVATE, private: m }), + secretTree: setRatchet(st, x, kind, ratchetNext(r)), + }; +}; + +// the ratchet only moves in the returned secretTree, and only once the message has authenticated +const openPrivate = (s, m) => { + let sd; + try { + sd = new Reader(senderDataCipher(s, m).decrypt(m.encryptedSenderData)); + } catch { + fail("cannot decrypt sender data"); + } + let leafIndex = sd.u32(); + let generation = sd.u32(); + let guard = sd.bytes(4); + sd.done(); + memberLeaf(s, leafIndex); + if (leafIndex === s.leafIndex) fail("message was sent by this client", "own"); + let x = 2 * leafIndex; + let kind = ratchetKind(m.contentType); + let st = leafRatchets(s.secretTree, s.tree.length, x); + let { kn, r } = seek(st.ratchets[x][kind], generation); + let body; + try { + body = new Reader(gcm(kn.key, guarded(kn.nonce, guard), privateAAD(m)).decrypt(m.ciphertext)); + } catch { + fail("cannot decrypt message"); + } + let content = { + groupId: m.groupId, + epoch: m.epoch, + sender: { type: SENDER.MEMBER, leafIndex }, + aad: m.aad, + contentType: m.contentType, + ...rBody(body, m.contentType), + }; + let auth = rAuth(body, m.contentType); + if (body.buf.subarray(body.pos).some((b) => b)) fail("non-zero message padding"); + verifyContent(s, WIRE.PRIVATE, content, auth); + return { content, auth, secretTree: setRatchet(st, x, kind, r) }; +}; + +// ---- key packages ---- + +const DEFAULT_CAPS = { + versions: [1], + ciphersuites: [1], + extensions: [], + proposals: [], + credentials: [1], +}; + +const bytesOf = (v) => (typeof v === "string" ? utf8(v) : v); + +// an hour of slack behind us and twelve weeks ahead: the widest range OpenMLS accepts +const lifetimeOf = (l = {}) => { + let now = Math.floor(Date.now() / 1000); + return { notBefore: l.notBefore ?? now - 3600, notAfter: l.notAfter ?? now + 84 * 86400 }; +}; + +const newLeaf = ({ sig, identity, capabilities, leafExtensions = [], lifetime }, encryptionKey) => + signLeaf( + { + encryptionKey, + signatureKey: sig.pub, + credential: { type: 1, identity: bytesOf(identity) }, + capabilities: { ...DEFAULT_CAPS, ...capabilities }, + source: LEAF_SOURCE.KEY_PACKAGE, + lifetime: lifetimeOf(lifetime), + extensions: leafExtensions, + }, + sig.priv, + ); + +// { priv, pub }: an Ed25519 seed and its public key +export const generateSignatureKeyPair = () => { + let priv = randomBytes(32); + return { priv, pub: ed25519.getPublicKey(priv) }; +}; + +// ({ sig, identity, capabilities, leafExtensions, extensions, lifetime }) → +// { keyPackage, message, ref, secrets: { initPriv, encPriv } }. `keyPackage` is the bare +// struct, `message` the same thing framed as an MLSMessage; keep `secrets` until the Welcome. +export const generateKeyPackage = (opts) => { + let initPriv = randomBytes(32); + let encPriv = randomBytes(32); + let kp = { + version: 1, + cipherSuite: 1, + initKey: x25519.getPublicKey(initPriv), + leaf: newLeaf(opts, x25519.getPublicKey(encPriv)), + extensions: opts.extensions || [], + }; + kp.signature = signWithLabel(opts.sig.priv, "KeyPackageTBS", enc(wKeyPackageBody, kp)); + checkLeaf(kp.leaf, EMPTY, 0, []); + checkKeyPackage(kp); + return { + keyPackage: enc(wKeyPackage, kp), + message: enc(wMessage, { wireFormat: WIRE.KEY_PACKAGE, keyPackage: kp }), + ref: keyPackageRef(kp), + secrets: { initPriv, encPriv }, + }; +}; + +// bare KeyPackage or MLSMessage(mls_key_package): a bare one starts version 1, suite 1 +// where the framed one says version 1, wire format 5 +const readKeyPackage = (bytes) => + bytes[2] === 0 && bytes[3] === WIRE.KEY_PACKAGE + ? dec(rMessage, bytes).keyPackage || fail("not a key package") + : dec(rKeyPackage, bytes); + +// either framing; throws unless both signatures verify +export const parseKeyPackage = (bytes) => { + let kp = readKeyPackage(bytes); + checkKeyPackage(kp); + if (!verifyWithLabel(kp.leaf.signatureKey, "LeafNodeTBS", leafTBS(kp.leaf), kp.leaf.signature)) + fail("invalid leaf node signature"); + let { initKey, leaf, extensions, cipherSuite, version } = kp; + return { + keyPackage: enc(wKeyPackage, kp), + ref: keyPackageRef(kp), + initKey, + leaf, + extensions, + cipherSuite, + version, + }; +}; + +// a look at an MLSMessage without any group state: enough to route it +export const decodeMessage = (bytes) => { + let m = dec(rMessage, bytes); + let out = { wireFormat: WIRE_NAME[m.wireFormat] }; + if (m.public) { + let { groupId, epoch, sender, aad, contentType } = m.public.content; + Object.assign(out, { groupId, epoch, sender, aad, contentType: CONTENT_NAME[contentType] }); + } else if (m.private) { + let { groupId, epoch, aad, contentType } = m.private; + Object.assign(out, { + groupId, + epoch, + aad, + contentType: CONTENT_NAME[contentType] || fail("unknown content type"), + }); + } else if (m.welcome) { + Object.assign(out, { + cipherSuite: m.welcome.cipherSuite, + refs: m.welcome.secrets.map((s) => s.ref), + }); + } else if (m.groupInfo) { + let { groupId, epoch } = m.groupInfo.context; + Object.assign(out, { + groupId, + epoch, + extensions: m.groupInfo.extensions, + signer: m.groupInfo.signer, + }); + } else { + Object.assign(out, { + keyPackage: enc(wKeyPackage, m.keyPackage), + ref: keyPackageRef(m.keyPackage), + }); + } + return out; +}; + +// ---- groups ---- + +const secretTreeFor = (tree, encryption) => ({ + nodes: { [rootOf(tree.length)]: encryption }, + ratchets: {}, +}); + +// ({ groupId, sig, identity, capabilities, leafExtensions, extensions, lifetime }) → state +// at epoch 0 with one member; `extensions` are the GroupContext's +export const createGroup = (opts) => { + let { groupId, sig, extensions = [] } = opts; + let encPriv = randomBytes(32); + let leaf = newLeaf(opts, x25519.getPublicKey(encPriv)); + uniqueTypes(extensions, "group context"); + checkLeaf(leaf, groupId, 0, extensions); + checkAllSupport([leaf], extensions); + let tree = [leaf]; + let { keys, encryption } = epochKeys(randomBytes(32)); + let confirmationTag = mac(keys.confirmation, EMPTY); + return { + version: 1, + cipherSuite: 1, + groupId, + epoch: 0, + treeHash: rootHash(tree), + confirmedTranscriptHash: EMPTY, + extensions, + interimTranscriptHash: interimHash(EMPTY, confirmationTag), + confirmationTag, + tree, + leafIndex: 0, + sigPriv: sig.priv, + priv: { 0: encPriv }, + keys, + secretTree: secretTreeFor(tree, encryption), + pending: [], + }; +}; + +// node keys from a path secret upward: fp[from] is the node the secret belongs to +const derivePath = (tree, fp, from, secret) => { + let priv = {}; + for (let i = from; i < fp.length; i++) { + let kp = deriveKeyPair(deriveSecret(secret, "node")); + if (!eq(kp.pub, tree[fp[i].node].encryptionKey)) + fail("path secret does not match the node's public key"); + priv[fp[i].node] = kp.priv; + secret = deriveSecret(secret, "path"); + } + return { priv, commitSecret: secret }; +}; + +// The state a Welcome invites us into. `keyPackage` and `secrets` are what +// generateKeyPackage returned; `state.joinedVia` is the leaf that signed the GroupInfo. +// `ratchetTree` supplies the tree out of band when the GroupInfo has none, `now` +// (unix seconds) turns on the key package lifetime check for every leaf. +export const joinWelcome = (bytes, { keyPackage, secrets, sig, ratchetTree, psks, now } = {}) => { + let { welcome } = dec(rMessage, bytes); + if (!welcome) fail("not a Welcome message"); + if (welcome.cipherSuite !== 1) + fail("unsupported ciphersuite " + welcome.cipherSuite, "unsupported"); + let kp = readKeyPackage(keyPackage); + let ref = keyPackageRef(kp); + let mine = welcome.secrets.find((e) => eq(e.ref, ref)); + if (!mine) fail("Welcome is not addressed to this key package", "not-for-us"); + if ( + !eq(x25519.getPublicKey(secrets.encPriv), kp.leaf.encryptionKey) || + !eq(sig.pub, kp.leaf.signatureKey) + ) + fail("private keys do not match the key package"); + + let groupSecrets; + try { + let plain = decryptWithLabel( + secrets.initPriv, + "Welcome", + welcome.encryptedGroupInfo, + mine.kemOutput, + mine.ciphertext, + ); + groupSecrets = dec(rGroupSecrets, plain); + } catch (e) { + if (e.code) throw e; + fail("cannot decrypt group secrets"); + } + let member = extract(sha256, lookupPsks(groupSecrets.psks, psks), groupSecrets.joinerSecret); + let info; + try { + info = dec( + rGroupInfo, + welcomeCipher(deriveSecret(member, "welcome")).decrypt(welcome.encryptedGroupInfo), + ); + } catch { + fail("cannot decrypt group info"); + } + let ctx = info.context; + if (ctx.version !== 1 || ctx.cipherSuite !== 1) + fail("group uses another version or ciphersuite", "unsupported"); + if (typeof ctx.epoch !== "number") fail("epoch out of range"); + uniqueTypes(ctx.extensions, "group context"); + + // without the tree nothing in the GroupInfo can be checked, so it has to travel with it + let treeBytes = extOf(info.extensions, EXT.RATCHET_TREE) || ratchetTree; + if (!treeBytes) fail("Welcome carries no ratchet_tree extension"); + let tree = dec(rTree, treeBytes); + let signer = 2 * info.signer < tree.length ? tree[2 * info.signer] : null; + if (!signer) fail("GroupInfo signer is not in the tree"); + if ( + !verifyWithLabel(signer.signatureKey, "GroupInfoTBS", enc(wGroupInfoTBS, info), info.signature) + ) + fail("invalid GroupInfo signature"); + if (!eq(rootHash(tree), ctx.treeHash)) fail("ratchet tree does not match the group's tree hash"); + checkTree(tree, ctx.groupId, ctx.extensions, now); + + let own = enc(wLeaf, kp.leaf); + let x = tree.findIndex((node, i) => node && i % 2 === 0 && eq(enc(wLeaf, node), own)); + if (x < 0) fail("our leaf is not in the ratchet tree"); + let priv = { [x]: secrets.encPriv }; + if (groupSecrets.pathSecret) { + let fp = filteredPath(tree, 2 * info.signer); + let from = fp.findIndex((f) => inSubtree(f.node, x)); + if (from < 0) fail("no common ancestor with the GroupInfo signer"); + Object.assign(priv, derivePath(tree, fp, from, groupSecrets.pathSecret).priv); + } + + let { keys, encryption } = epochKeys(expandWithLabel(member, "epoch", enc(wContext, ctx), 32)); + if (!eq(info.confirmationTag, mac(keys.confirmation, ctx.confirmedTranscriptHash))) + fail("invalid confirmation tag"); + return { + ...ctx, + interimTranscriptHash: interimHash(ctx.confirmedTranscriptHash, info.confirmationTag), + confirmationTag: info.confirmationTag, + tree, + leafIndex: x / 2, + sigPriv: sig.priv, + priv, + keys, + secretTree: secretTreeFor(tree, encryption), + pending: [], + joinedVia: info.signer, + }; +}; + +// ---- proposals ---- + +export const proposeAdd = (keyPackageBytes) => ({ + type: PROPOSAL.ADD, + keyPackage: readKeyPackage(keyPackageBytes), +}); +export const proposeRemove = (leafIndex) => ({ type: PROPOSAL.REMOVE, removed: leafIndex }); +export const proposeGroupContextExtensions = (extensions) => ({ + type: PROPOSAL.GROUP_CONTEXT_EXTENSIONS, + extensions, +}); +export const proposeAppDataUpdate = (componentId, data) => + data == null + ? { type: PROPOSAL.APP_DATA_UPDATE, componentId, op: 2 } + : { type: PROPOSAL.APP_DATA_UPDATE, componentId, op: 1, data }; +export const proposeAppEphemeral = (componentId, data) => ({ + type: PROPOSAL.APP_EPHEMERAL, + componentId, + data, +}); +export const proposeSelfRemove = () => ({ type: PROPOSAL.SELF_REMOVE }); + +// the default meaning of app_data_update: the payload replaces the component's data. +// A caller's hook (id, current | undefined, updates[]) → bytes may say otherwise, +// or return undefined to refuse the commit. +const lastWins = (id, current, updates) => updates[updates.length - 1]; + +// draft-ietf-mls-extensions §4.7. As OpenMLS does it: start from the dictionary of +// the CURRENT context and re-add the extension at the END of the list — the +// position is part of the GroupContext every member hashes. +const applyAppData = (current, target, updates, hook = lastWins, strict) => { + let dict = dictOf(current); + for (let id of new Set(updates.map((u) => u.componentId))) { + let ups = updates.filter((u) => u.componentId === id); + let at = dict.findIndex((e) => e.id === id); + if (ups.some((u) => u.op === 2)) { + if (ups.length > 1) + fail("app_data_update: a remove cannot share a commit with other changes to the component"); + // The draft calls removing absent state invalid and we never send it, but OpenMLS + // only notices when the commit also carries the dictionary in a + // GroupContextExtensions; refusing more than it does would fork us off the group. + if (at < 0 && strict) fail("app_data_update: component " + id + " has no state to remove"); + if (at >= 0) dict.splice(at, 1); + continue; + } + let data = hook( + id, + at < 0 ? undefined : dict[at].data, + ups.map((u) => u.data), + ); + if (!(data instanceof Uint8Array)) fail("app_data_update rejected for component " + id); + if (at < 0) dict.push({ id, data }); + else dict[at] = { id, data }; + } + return [ + ...target.filter((e) => e.type !== EXT.APP_DATA_DICTIONARY), + { type: EXT.APP_DATA_DICTIONARY, data: encodeDict(dict) }, + ]; +}; + +const requiresAppDataUpdate = (extensions) => + !!requiredCapabilities(extensions)?.proposals.includes(PROPOSAL.APP_DATA_UPDATE); + +const PATH_REQUIRED = [ + PROPOSAL.UPDATE, + PROPOSAL.REMOVE, + PROPOSAL.EXTERNAL_INIT, + PROPOSAL.GROUP_CONTEXT_EXTENSIONS, + PROPOSAL.SELF_REMOVE, +]; + +// RFC 9420 §12.2 for a member's commit: validates `list` ([{ proposal, sender, byRef }]) +// and applies it to a copy of the tree +const applyProposals = (s, list, committer, { appData, psks, now, own } = {}) => { + let of = (type) => list.filter((e) => e.proposal.type === type); + let types = list.map((e) => e.proposal.type); + for (let t of types) { + if (t === PROPOSAL.REINIT) fail("reinit is not supported", "unsupported"); + if (t === PROPOSAL.EXTERNAL_INIT) fail("external_init is not valid in a member's commit"); + if (t < 1 || t > PROPOSAL.SELF_REMOVE) fail("unsupported proposal type " + t, "unsupported"); + } + + let removals = new Set(of(PROPOSAL.REMOVE).map((e) => e.proposal.removed)); + for (let t of new Set(types.filter((t) => t > PROPOSAL.GROUP_CONTEXT_EXTENSIONS))) + s.tree.forEach((leaf, x) => { + if (leaf && x % 2 === 0 && !removals.has(x / 2) && !supportsProposal(leaf.capabilities, t)) + fail("proposal type " + t + " is not supported by every member"); + }); + + let touched = new Set(); + let touch = (leafIndex) => { + if (2 * leafIndex >= s.tree.length || !s.tree[2 * leafIndex]) + fail("proposal targets a blank leaf"); + if (touched.has(leafIndex)) fail("several update/remove proposals for the same leaf"); + touched.add(leafIndex); + }; + for (let e of of(PROPOSAL.UPDATE)) { + if (!e.byRef || e.sender === committer) + fail("a committer cannot commit its own update proposal"); + touch(e.sender); + } + for (let e of of(PROPOSAL.REMOVE)) { + if (e.proposal.removed === committer) fail("a committer cannot remove itself"); + touch(e.proposal.removed); + } + for (let e of of(PROPOSAL.SELF_REMOVE)) { + // the leaver is the proposal's sender, so inline (sender = committer) can never be right + if (!e.byRef || e.sender === committer) + fail("self_remove must be committed by reference by another member"); + touch(e.sender); + } + + let gce = of(PROPOSAL.GROUP_CONTEXT_EXTENSIONS); + if (gce.length > 1) fail("several group_context_extensions proposals"); + let extensions = gce.length ? gce[0].proposal.extensions : s.extensions; + if (gce.length) { + uniqueTypes(extensions, "group context"); + // an OpenMLS rule, not an RFC one: whatever the context carries must be required of every member + let required = requiredCapabilities(extensions)?.extensions || []; + if (!extensions.every((e) => supportsExtension({ extensions: required }, e.type))) + fail("group context extension type missing from required_capabilities"); + // with app_data_update required, the dictionary only changes through app_data_update + if (requiresAppDataUpdate(s.extensions) || requiresAppDataUpdate(extensions)) { + let a = extOf(s.extensions, EXT.APP_DATA_DICTIONARY); + let b = extOf(extensions, EXT.APP_DATA_DICTIONARY); + if (!!a !== !!b || (a && !eq(a, b))) + fail( + "group_context_extensions cannot change app_data_dictionary while app_data_update is required", + ); + } + } + let firstAppData = types.indexOf(PROPOSAL.APP_DATA_UPDATE); + if (firstAppData >= 0 && types.lastIndexOf(PROPOSAL.GROUP_CONTEXT_EXTENSIONS) > firstAppData) + fail("app_data_update must come after group_context_extensions"); + + let tree = s.tree.slice(); + for (let e of of(PROPOSAL.UPDATE)) { + let leaf = e.proposal.leaf; + if (leaf.source !== LEAF_SOURCE.UPDATE) fail("update proposal leaf node has the wrong source"); + checkLeaf(leaf, s.groupId, e.sender, extensions); + if (eq(leaf.encryptionKey, tree[2 * e.sender].encryptionKey)) + fail("update proposal keeps the old encryption key"); + blankPath(tree, 2 * e.sender); + tree[2 * e.sender] = leaf; + } + let leaving = [ + ...of(PROPOSAL.REMOVE).map((e) => e.proposal.removed), + ...of(PROPOSAL.SELF_REMOVE).map((e) => e.sender), + ]; + for (let leafIndex of leaving) { + blankPath(tree, 2 * leafIndex); + tree[2 * leafIndex] = null; + } + truncate(tree); + let added = of(PROPOSAL.ADD).map((e) => { + let keyPackage = e.proposal.keyPackage; + checkKeyPackage(keyPackage); + checkLeaf(keyPackage.leaf, EMPTY, 0, extensions, now); + if (!s.extensions.every((ext) => supportsExtension(keyPackage.leaf.capabilities, ext.type))) + fail("added member does not support the group context extensions"); + return { leafIndex: addLeaf(tree, keyPackage.leaf), keyPackage }; + }); + if (gce.length) checkAllSupport(tree, extensions); + + let updates = of(PROPOSAL.APP_DATA_UPDATE).map((e) => e.proposal); + let carried = gce.length > 0 && !!extOf(extensions, EXT.APP_DATA_DICTIONARY); + if (updates.length) + extensions = applyAppData(s.extensions, extensions, updates, appData, own || carried); + + return { + tree, + extensions, + added, + removedSelf: leaving.includes(s.leafIndex), + pathRequired: !list.length || types.some((t) => PATH_REQUIRED.includes(t)), + pskSecret: lookupPsks( + of(PROPOSAL.PSK).map((e) => e.proposal.psk), + psks, + s, + ), + pskIds: of(PROPOSAL.PSK).map((e) => e.proposal.psk), + ephemeral: of(PROPOSAL.APP_EPHEMERAL).map((e) => ({ + componentId: e.proposal.componentId, + data: e.proposal.data, + sender: e.sender, + })), + }; +}; + +// ---- commits ---- + +// writes the new parent nodes of a commit into the tree; returns the parent hash the leaf must carry +const setPath = (tree, x, fp, keys) => { + blankPath(tree, x); + let parentHash = EMPTY; + for (let i = fp.length - 1; i >= 0; i--) { + tree[fp[i].node] = { encryptionKey: keys[i], parentHash, unmergedLeaves: [] }; + parentHash = parentHashOf(tree, fp[i].node, fp[i].copath); + } + return parentHash; +}; + +// who each path secret is encrypted to: the copath resolutions, minus the leaves +// this commit adds (they learn theirs from the Welcome) +const pathTargets = (fp, added, path) => { + let fresh = new Set(added.map((a) => 2 * a.leafIndex)); + let targets = fp.map((f) => f.res.filter((n) => !fresh.has(n))); + if ( + path.nodes.length !== fp.length || + path.nodes.some((n, i) => n.secrets.length !== targets[i].length) + ) + fail("update path does not fit the tree"); + return targets; +}; + +const openPathSecret = (priv, leafIndex, fp, targets, path, context) => { + let at = fp.findIndex((f) => inSubtree(f.node, 2 * leafIndex)); + let to = at < 0 ? -1 : targets[at].findIndex((n) => priv[n]); + if (to < 0) fail("no private key for the update path"); + try { + let { kemOutput, ciphertext } = path.nodes[at].secrets[to]; + return { + at, + secret: decryptWithLabel( + priv[targets[at][to]], + "UpdatePathNode", + context, + kemOutput, + ciphertext, + ), + }; + } catch { + return fail("cannot decrypt path secret"); + } +}; + +// private keys survive an epoch change only for nodes whose public key did +const carryKeys = (priv, before, after) => + Object.fromEntries( + Object.entries(priv).filter( + ([x]) => after[x] && before[x] && eq(after[x].encryptionKey, before[x].encryptionKey), + ), + ); + +// the state every member lands in once a commit is applied +const enterEpoch = ( + s, + wire, + content, + signature, + { tree, treeHash, extensions, priv, commitSecret, pskSecret }, +) => { + let confirmedTranscriptHash = confirmedHash(s.interimTranscriptHash, wire, content, signature); + let next = { + ...s, + epoch: s.epoch + 1, + treeHash, + confirmedTranscriptHash, + extensions, + tree, + priv, + pending: [], + }; + let context = enc(wContext, next); + let joiner = joinerSecret(s.keys.init, commitSecret, context); + let { welcome, keys, encryption } = fromJoiner(joiner, pskSecret, context); + next.keys = keys; + next.confirmationTag = mac(keys.confirmation, confirmedTranscriptHash); + next.interimTranscriptHash = interimHash(confirmedTranscriptHash, next.confirmationTag); + next.secretTree = secretTreeFor(tree, encryption); + return { state: next, joiner, welcome }; +}; + +const active = (s) => { + if (s.removed) fail("this client was removed from the group", "removed"); +}; + +const findPending = (s, ref) => s.pending.find((p) => eq(p.ref, ref)); + +// which cached proposals a commit of ours can carry: never our own update or +// self_remove, one proposal per leaf (leaving beats updating), one context change. +// Among several self_removes of one leaf Marmot picks the lowest message digest. +const committable = (s, inline) => { + let me = s.leafIndex; + let leafOf = (p) => + p.proposal.type === PROPOSAL.REMOVE + ? p.proposal.removed + : p.proposal.type === PROPOSAL.UPDATE || p.proposal.type === PROPOSAL.SELF_REMOVE + ? p.sender + : null; + let rank = (p) => + [PROPOSAL.UPDATE, PROPOSAL.REMOVE, PROPOSAL.SELF_REMOVE].indexOf(p.proposal.type); + let best = new Map(); + for (let p of inline) if (p.type === PROPOSAL.REMOVE) best.set(p.removed, null); + let hasContext = inline.some((p) => p.type === PROPOSAL.GROUP_CONTEXT_EXTENSIONS); + let signers = new Set( + inline.filter((p) => p.type === PROPOSAL.ADD).map((p) => hex(p.keyPackage.leaf.signatureKey)), + ); + let keep = []; + for (let p of [...s.pending].reverse()) { + let type = p.proposal.type; + let leaf = leafOf(p); + if (leaf != null) { + if (leaf === me || 2 * leaf >= s.tree.length || !s.tree[2 * leaf]) continue; + let cur = best.get(leaf); + if (cur === null) continue; + let better = + !cur || + rank(p) > rank(cur) || + (type === PROPOSAL.SELF_REMOVE && + cur.proposal.type === type && + hex(p.digest) < hex(cur.digest)); + if (better) best.set(leaf, p); + } else if (type === PROPOSAL.GROUP_CONTEXT_EXTENSIONS) { + if (!hasContext) keep.push(p); + hasContext = true; + } else if (type === PROPOSAL.ADD) { + let k = hex(p.proposal.keyPackage.leaf.signatureKey); + if (!signers.has(k)) keep.push(p); + signers.add(k); + } else keep.push(p); + } + for (let p of best.values()) if (p) keep.push(p); + return s.pending.filter((p) => keep.includes(p)); +}; + +// (state, { proposals, includePending, refs, leaf, appData, aad, path, psks, now }) → +// { message, welcome, state, addedRefs }. `proposals` go inline, cached ones by +// reference (all that can be committed, or exactly `refs`). `leaf` replaces our +// leaf's { capabilities, extensions }; `path: false` drops the update path when no +// proposal needs one. The input state is untouched: hold on to it until the commit +// is published, then switch to the returned one. One Welcome covers every add. +export const createCommit = (state, opts = {}) => { + active(state); + let { + proposals = [], + includePending = true, + refs, + leaf: patch, + aad = EMPTY, + path: wantPath = true, + } = opts; + let me = state.leafIndex; + let cached = refs + ? refs.map((ref) => findPending(state, ref) || fail("unknown proposal reference")) + : includePending + ? committable(state, proposals) + : []; + let list = [ + ...cached.map((p) => ({ proposal: p.proposal, sender: p.sender, byRef: true, ref: p.ref })), + ...proposals.map((proposal) => ({ proposal, sender: me, byRef: false })), + ]; + // app_data_update is applied last and must follow a GroupContextExtensions on the wire + let late = (e) => e.proposal.type === PROPOSAL.APP_DATA_UPDATE; + list = [...list.filter((e) => !late(e)), ...list.filter(late)]; + + let ap = applyProposals(state, list, me, { ...opts, own: true }); + let { tree, extensions, pskSecret } = ap; + let x = 2 * me; + let commitSecret = ZERO32; + let path = null; + let steps = []; + let priv; + if (wantPath || ap.pathRequired) { + let fp = filteredPath(tree, x); + let secret = randomBytes(32); + for (let f of fp) { + steps.push({ ...f, secret, ...deriveKeyPair(deriveSecret(secret, "node")) }); + secret = deriveSecret(secret, "path"); + } + commitSecret = secret; + let old = tree[x]; + let leafPriv = randomBytes(32); + let parentHash = setPath( + tree, + x, + fp, + steps.map((st) => st.pub), + ); + let { lifetime, signature, ...rest } = old; + let leaf = signLeaf( + { + ...rest, + encryptionKey: x25519.getPublicKey(leafPriv), + capabilities: patch?.capabilities + ? { ...DEFAULT_CAPS, ...patch.capabilities } + : old.capabilities, + extensions: patch?.extensions || old.extensions, + source: LEAF_SOURCE.COMMIT, + parentHash, + }, + state.sigPriv, + state.groupId, + me, + ); + checkLeaf(leaf, state.groupId, me, extensions); + tree[x] = leaf; + priv = { ...carryKeys(state.priv, state.tree, tree), [x]: leafPriv }; + for (let st of steps) priv[st.node] = st.priv; + // new members learn their path secret from the Welcome instead + let fresh = new Set(ap.added.map((a) => 2 * a.leafIndex)); + let context = enc(wContext, { + ...state, + epoch: state.epoch + 1, + treeHash: rootHash(tree), + extensions, + }); + path = { + leaf, + nodes: steps.map((st) => ({ + encryptionKey: st.pub, + secrets: st.res + .filter((n) => !fresh.has(n)) + .map((n) => + encryptWithLabel(tree[n].encryptionKey, "UpdatePathNode", context, st.secret), + ), + })), + }; + } else priv = carryKeys(state.priv, state.tree, tree); + checkTreeKeys(tree); + + let content = { + groupId: state.groupId, + epoch: state.epoch, + sender: { type: SENDER.MEMBER, leafIndex: me }, + aad, + contentType: CONTENT.COMMIT, + commit: { + proposals: list.map((e) => (e.byRef ? { ref: e.ref } : { proposal: e.proposal })), + path, + }, + }; + let signature = signContent(state, WIRE.PUBLIC, content); + let next = enterEpoch(state, WIRE.PUBLIC, content, signature, { + tree, + treeHash: rootHash(tree), + extensions, + priv, + commitSecret, + pskSecret, + }); + let message = sealPublic(state, content, { + signature, + confirmationTag: next.state.confirmationTag, + }); + + let welcome = null; + let addedRefs = ap.added.map((a) => keyPackageRef(a.keyPackage)); + if (ap.added.length) { + let info = { + context: next.state, + extensions: [{ type: EXT.RATCHET_TREE, data: enc(wTree, tree) }], + confirmationTag: next.state.confirmationTag, + signer: me, + }; + info.signature = signWithLabel(state.sigPriv, "GroupInfoTBS", enc(wGroupInfoTBS, info)); + let encryptedGroupInfo = welcomeCipher(next.welcome).encrypt(enc(wGroupInfo, info)); + let secrets = ap.added.map(({ leafIndex, keyPackage }, i) => { + let groupSecrets = enc(wGroupSecrets, { + joinerSecret: next.joiner, + pathSecret: steps.find((st) => inSubtree(st.node, 2 * leafIndex))?.secret, + psks: ap.pskIds, + }); + return { + ref: addedRefs[i], + ...encryptWithLabel(keyPackage.initKey, "Welcome", encryptedGroupInfo, groupSecrets), + }; + }); + welcome = enc(wMessage, { + wireFormat: WIRE.WELCOME, + welcome: { cipherSuite: 1, secrets, encryptedGroupInfo }, + }); + } + return { message, welcome, state: next.state, addedRefs }; +}; + +const proposalRef = (wire, content, auth) => + refHash( + "MLS 1.0 Proposal Reference", + wAuth(wContent(new Writer().u16(wire), content), auth, content.contentType).finish(), + ); + +const cacheProposal = (s, wire, content, auth, message) => { + let ref = proposalRef(wire, content, auth); + let entry = { + ref, + proposal: content.proposal, + sender: content.sender.leafIndex, + digest: sha256(message), + }; + return { ref, state: findPending(s, ref) ? s : { ...s, pending: [...s.pending, entry] } }; +}; + +// a standalone proposal as a PublicMessage, cached in the returned state like anyone else's +export const createProposal = (state, proposal, { aad = EMPTY } = {}) => { + active(state); + if (proposal.type === PROPOSAL.UPDATE) + fail("update proposals are not supported; commit instead", "unsupported"); + let content = { + groupId: state.groupId, + epoch: state.epoch, + sender: { type: SENDER.MEMBER, leafIndex: state.leafIndex }, + aad, + contentType: CONTENT.PROPOSAL, + proposal, + }; + let auth = { signature: signContent(state, WIRE.PUBLIC, content) }; + let message = sealPublic(state, content, auth); + return { message, ...cacheProposal(state, WIRE.PUBLIC, content, auth, message) }; +}; + +const processCommit = (s, wire, content, auth, opts) => { + let committer = content.sender.leafIndex; + // our own commit comes back from the relay; its state is the one createCommit returned + if (committer === s.leafIndex) fail("commit was created by this client", "own"); + let { commit } = content; + let list = commit.proposals.map((p) => { + if (!p.ref) return { proposal: p.proposal, sender: committer, byRef: false }; + let cached = + findPending(s, p.ref) || + fail("commit references a proposal we have not seen", "missing-proposal"); + return { proposal: cached.proposal, sender: cached.sender, byRef: true }; + }); + let ap = applyProposals(s, list, committer, opts); + let result = { + type: "commit", + sender: committer, + aad: content.aad, + proposals: list, + ephemeral: ap.ephemeral, + removedSelf: ap.removedSelf, + }; + // removed members get no path secret: the old state stays readable, the group moves on without us + if (ap.removedSelf) return { ...result, state: { ...s, removed: true, pending: [] } }; + + let { tree, extensions, pskSecret } = ap; + let { path } = commit; + if (!path && ap.pathRequired) fail("commit is missing its update path"); + let commitSecret = ZERO32; + let priv; + if (path) { + let x = 2 * committer; + if (path.leaf.source !== LEAF_SOURCE.COMMIT) fail("commit leaf node has the wrong source"); + checkLeaf(path.leaf, s.groupId, committer, extensions); + if (eq(path.leaf.encryptionKey, tree[x].encryptionKey)) + fail("commit keeps the committer's old encryption key"); + let fp = filteredPath(tree, x); + let targets = pathTargets(fp, ap.added, path); + let parentHash = setPath( + tree, + x, + fp, + path.nodes.map((n) => n.encryptionKey), + ); + if (!eq(parentHash, path.leaf.parentHash)) + fail("commit leaf node carries the wrong parent hash"); + tree[x] = path.leaf; + let context = enc(wContext, { ...s, epoch: s.epoch + 1, treeHash: rootHash(tree), extensions }); + let { at, secret } = openPathSecret(s.priv, s.leafIndex, fp, targets, path, context); + let derived = derivePath(tree, fp, at, secret); + commitSecret = derived.commitSecret; + priv = { ...carryKeys(s.priv, s.tree, tree), ...derived.priv }; + } else priv = carryKeys(s.priv, s.tree, tree); + checkTreeKeys(tree); + + let next = enterEpoch(s, wire, content, auth.signature, { + tree, + treeHash: rootHash(tree), + extensions, + priv, + commitSecret, + pskSecret, + }); + if (!eq(next.state.confirmationTag, auth.confirmationTag)) fail("invalid confirmation tag"); + return { ...result, state: next.state }; +}; + +// (state, bytes, { appData, psks, now }) → +// { type: "application", data, aad, sender, state } +// { type: "proposal", proposal, sender, ref, aad, state } +// { type: "commit", proposals: [{ proposal, sender, byRef }], ephemeral, removedSelf, sender, aad, state } +// Handshake may arrive as PublicMessage or PrivateMessage. Throws on anything +// invalid; e.code says why when the caller can act on it: "epoch" (try another +// retained state), "group", "own" (we sent it), "replay", "missing-proposal", +// "removed", "unsupported". When a commit removes us the returned state is the old +// one marked `removed`: it still reads its epoch but can no longer send or advance. +export const processMessage = (state, bytes, opts = {}) => { + let m = dec(rMessage, bytes); + let framed = m.public?.content || m.private; + if (!framed) fail("not a group message"); + if (!eq(framed.groupId, state.groupId)) fail("message is for another group", "group"); + if (framed.epoch !== state.epoch) + fail(`message is for epoch ${framed.epoch}, state is at ${state.epoch}`, "epoch"); + + let wire = m.wireFormat; + let s = state; + let content, auth; + if (m.public) ({ content, auth } = openPublic(state, m.public)); + else { + let opened = openPrivate(state, m.private); + ({ content, auth } = opened); + s = { ...state, secretTree: opened.secretTree }; + } + let sender = content.sender.leafIndex; + if (content.contentType === CONTENT.APPLICATION) + return { type: "application", data: content.data, aad: content.aad, sender, state: s }; + active(state); + if (content.contentType === CONTENT.PROPOSAL) + return { + type: "proposal", + proposal: content.proposal, + sender, + aad: content.aad, + ...cacheProposal(s, wire, content, auth, bytes), + }; + return processCommit(s, wire, content, auth, opts); +}; + +// application data as a PrivateMessage; the returned state has the ratchet moved on +export const encryptApplication = (state, data, aad = EMPTY) => { + active(state); + let content = { + groupId: state.groupId, + epoch: state.epoch, + sender: { type: SENDER.MEMBER, leafIndex: state.leafIndex }, + aad, + contentType: CONTENT.APPLICATION, + data, + }; + let { message, secretTree } = sealPrivate(state, content, { + signature: signContent(state, WIRE.PRIVATE, content), + }); + return { message, state: { ...state, secretTree } }; +}; + +// MLS-Exporter (RFC 9420 §8.5) +export const exportSecret = (state, label, context, length) => + expandWithLabel(deriveSecret(state.keys.exporter, label), "exported", sha256(context), length); + +export const epochAuthenticator = (state) => state.keys.authenticator; + +export const members = (state) => + state.tree.flatMap((leaf, x) => + leaf && x % 2 === 0 + ? [ + { + index: x / 2, + identity: leaf.credential.identity, + signatureKey: leaf.signatureKey, + encryptionKey: leaf.encryptionKey, + leaf, + }, + ] + : [], + ); + +// not API: what tools/mls-vectors-test.js needs to run the RFC 9420 interop vectors +export const internals = { + enc, + dec, + codec: { + message: [wMessage, rMessage], + tree: [wTree, rTree], + proposal: [wProposal, rProposal], + commit: [wCommit, rCommit], + path: [wPath, rPath], + groupSecrets: [wGroupSecrets, rGroupSecrets], + groupInfo: [wGroupInfo, rGroupInfo], + groupInfoTBS: [wGroupInfoTBS], + context: [wContext, rContext], + content: [wContent, rContent], + auth: [wAuth, rAuth], + leaf: [wLeaf, rLeaf], + pskId: [wPskId, rPskId], + }, + tree: { + level, + left, + right, + parent, + sibling, + rootOf, + directPath, + resolution, + filteredPath, + treeHash, + rootHash, + }, + ratchetTree: { verifyParentHashes, blankPath, truncate, addLeaf, checkTree, leafTBS }, + treekem: { setPath, derivePath, pathTargets, openPathSecret }, + crypto: { + expandWithLabel, + deriveSecret, + deriveTreeSecret, + refHash, + signWithLabel, + verifyWithLabel, + encryptWithLabel, + decryptWithLabel, + deriveKeyPair, + mac, + }, + schedule: { + epochKeys, + joinerSecret, + fromJoiner, + welcomeCipher, + pskSecret, + interimHash, + confirmedHash, + }, + secretTree: { leafRatchets, seek, secretTreeFor }, + framing: { sealPublic, openPublic, sealPrivate, openPrivate, signContent }, + keyPackageRef, +}; |
