aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js604
-rw-r--r--src/i18n.js28
-rw-r--r--src/marmot-client.js370
-rw-r--r--src/marmot-store.js74
-rw-r--r--src/marmot.js886
-rw-r--r--src/mls.js2178
-rw-r--r--tools/marmot-chat-test.js205
-rw-r--r--tools/marmot-interop-test.js243
-rw-r--r--tools/mls-vectors-test.js1195
9 files changed, 5772 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,
+};
diff --git a/tools/marmot-chat-test.js b/tools/marmot-chat-test.js
new file mode 100644
index 0000000..acfedf5
--- /dev/null
+++ b/tools/marmot-chat-test.js
@@ -0,0 +1,205 @@
+// Group chats (White Noise) in the real UI, against the Rust reference client.
+//
+// bun tools/marmot-chat-test.js
+//
+// Same prerequisites as tools/marmot-interop-test.js (a local relay and a
+// running `wn` daemon); skips cleanly without them. Every relay socket the
+// page opens is pointed at the local relay, so nothing leaves the machine.
+//
+// Covers: becoming invitable (KeyPackage published at boot), a wn group
+// invitation → card → Join → messages both ways; "New message" to a White
+// Noise user founding a direct group whose replies land in the DM thread;
+// "New group" from the app.
+import puppeteer from 'puppeteer-core';
+import { spawnSync } from 'node:child_process';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { buildHtml } from '../build.js';
+import { seedPubkey } from '../src/nostr.js';
+import * as nip06 from 'nostr-tools/nip06';
+import { finalizeEvent } from 'nostr-tools/pure';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+// shots go somewhere every machine has
+const shot = (name) => join(tmpdir(), name);
+
+const RELAY = process.env.RELAY || 'ws://127.0.0.1:27777';
+const PORT = 5247;
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+
+function wn(args, account) {
+ const r = spawnSync('wn', ['--json', ...(account ? ['--account', account] : []), ...args], { encoding: 'utf8' });
+ const line = (r.stdout || '').trim().split('\n').filter(Boolean).at(-1) || '';
+ try { const j = JSON.parse(line); return j.ok ? j.result : { error: j.error }; } catch { return { error: { message: (r.stderr || 'no output').trim() } }; }
+}
+function relayQuery(filter) {
+ return new Promise((resolve) => {
+ const ws = new WebSocket(RELAY), out = [];
+ const done = () => { try { ws.close(); } catch {} resolve(out); };
+ ws.onopen = () => ws.send(JSON.stringify(['REQ', 'q', filter]));
+ ws.onmessage = (m) => { const d = JSON.parse(m.data); if (d[0] === 'EVENT') out.push(d[2]); if (d[0] === 'EOSE') done(); };
+ ws.onerror = done;
+ setTimeout(done, 3000);
+ });
+}
+const until = async (fn, ms = 20000) => { const end = Date.now() + ms; for (;;) { const v = await fn(); if (v) return v; if (Date.now() > end) return null; await sleep(500); } };
+
+if (wn(['whoami']).error) { console.log('SKIP: wn daemon not reachable'); process.exit(0); }
+if (!(await relayQuery({ kinds: [0], limit: 1 }))) { console.log('SKIP: no relay'); process.exit(0); }
+const alice = wn(['create-identity']).account_id, bob = wn(['create-identity']).account_id;
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: PORT, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.setViewport({ width: 1100, height: 850 });
+page.on('pageerror', (e) => { console.log(' page error:', e.message); ok = false; });
+// every relay is the local relay
+await page.evaluateOnNewDocument((relay) => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (url, protocols) { return new Real(/^wss?:/.test(url) ? relay : url, protocols); };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
+ localStorage.setItem('coinos-group-relays', JSON.stringify([relay]));
+ localStorage.setItem('btc-wallet-network', 'regtest');
+}, RELAY);
+
+const click = (sel, t) => page.evaluate((s, x) => { const e = [...document.querySelectorAll(s)].find((n) => n.textContent.trim().toLowerCase().includes(x.toLowerCase())); if (e) { e.click(); return true; } return false; }, sel, t);
+const setInput = (sel, v) => page.evaluate((s, val) => { const e = document.querySelector(s); if (!e) return false; e.value = val; e.dispatchEvent(new Event('input', { bubbles: true })); return true; }, sel, v);
+const bodyText = () => page.evaluate(() => document.body.innerText);
+const waitText = (x, ms = 20000) => until(async () => (await bodyText()).toLowerCase().includes(x.toLowerCase()), ms);
+const say = async (text) => {
+ await page.waitForSelector('#msg-draft', { timeout: 15000 });
+ await page.evaluate((v) => {
+ const e = document.querySelector('#msg-draft');
+ const ta = e.shadowRoot ? e.shadowRoot.querySelector('textarea') : (e.querySelector && e.querySelector('textarea')) || e;
+ ta.focus();
+ ta.value = v;
+ ta.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
+ }, text);
+ await sleep(150);
+ await page.keyboard.press('Enter');
+};
+const openChat = () => page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /message/i.test(b.getAttribute('aria-label') || ''))?.click());
+
+try {
+ const mnemonic = generateMnemonic(wordlist);
+ const me = seedPubkey(mnemonic);
+ // wn follows an account's relay lists to real URLs; ours must name the
+ // local relay before the wallet (which would publish its defaults) opens
+ {
+ const sk = nip06.privateKeyFromSeedWords(mnemonic);
+ const at = Math.floor(Date.now() / 1000);
+ for (const ev of [
+ finalizeEvent({ kind: 10002, created_at: at, tags: [['r', RELAY]], content: '' }, sk),
+ finalizeEvent({ kind: 10050, created_at: at, tags: [['relay', RELAY]], content: '' }, sk),
+ ]) await new Promise((res) => { const ws = new WebSocket(RELAY); ws.onopen = () => ws.send(JSON.stringify(['EVENT', ev])); ws.onmessage = () => { ws.close(); res(); }; ws.onerror = res; });
+ }
+ await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
+ await sleep(500);
+ await click('button', 'Create a new wallet');
+ await sleep(300);
+ await click('button', 'Have an existing seed');
+ await sleep(300);
+ await page.waitForSelector('textarea');
+ await page.type('textarea', mnemonic);
+ await click('button', 'Open wallet');
+ await waitText('receive', 15000);
+
+ console.log('\n[invitable without doing anything]');
+ const kp = await until(async () => (await relayQuery({ kinds: [30443], authors: [me] }))[0], 25000);
+ check('a KeyPackage is published after boot', !!kp);
+ const chk = wn(['keys', 'check', me]);
+ check('wn can use it', !chk.error && JSON.stringify(chk).includes('true'), JSON.stringify(chk).slice(0, 200));
+
+ console.log('\n[a White Noise group invites us]');
+ const made = wn(['groups', 'create', 'Trail crew', me], alice);
+ check('wn groups create', !made.error, JSON.stringify(made.error || ''));
+ const gid = made.group_id || made.group?.group_id;
+ await openChat();
+ check('invitation card shows', !!await waitText('invited you to Trail crew', 25000));
+ await click('button', 'Join');
+ check('the group opens', !!await waitText('2 members'));
+ wn(['messages', 'send', gid, 'hello from white noise'], alice);
+ check('their message arrives', !!await waitText('hello from white noise'));
+ await say('hello from coinos');
+ check('ours reaches wn', !!await until(() => JSON.stringify(wn(['messages', 'list', gid, '--limit', '20'], alice)).includes('hello from coinos')));
+ await page.screenshot({ path: shot('marmot-group.png') });
+
+ console.log('\n[a first message to a White Noise user founds a direct chat]');
+ await page.evaluate(() => document.querySelector('.chat-head button')?.click()); // back
+ await sleep(400);
+ await click('button', 'New message');
+ await sleep(300);
+ await setInput('input[placeholder*="npub" i]', bob);
+ await click('button', 'Open');
+ await page.waitForSelector('#msg-draft');
+ await sleep(2500); // their KeyPackage is looked up when the thread opens
+ await say('hi bob, direct');
+ const bobChat = await until(() => { const l = wn(['chats', 'list'], bob); const s = JSON.stringify(l); return s.includes('group_id') && !s.includes('"chats":[]') ? l : null; }, 25000);
+ check('bob has a chat', !!bobChat, JSON.stringify(wn(['groups', 'invites'], bob)).slice(0, 200));
+ const dmGid = JSON.stringify(bobChat || '').match(/"group_id":"([0-9a-f]+)"/)?.[1];
+ if (dmGid) {
+ wn(['groups', 'accept', dmGid], bob);
+ check('bob reads our first message', !!await until(() => JSON.stringify(wn(['messages', 'list', dmGid, '--limit', '20'], bob)).includes('hi bob, direct')));
+ wn(['messages', 'send', dmGid, 'hey, bob here'], bob);
+ check('his reply lands in the same thread', !!await waitText('hey, bob here'));
+ await say('second message');
+ check('a reply goes back the same way', !!await until(() => JSON.stringify(wn(['messages', 'list', dmGid, '--limit', '20'], bob)).includes('second message')));
+ }
+ await page.screenshot({ path: shot('marmot-dm.png') });
+
+ console.log('\n[New group from the app]');
+ await page.evaluate(() => document.querySelector('.chat-head button')?.click());
+ await sleep(400);
+ await click('button', 'New group');
+ await sleep(300);
+ await setInput('input[placeholder="Group name"]', 'Made in coinos');
+ for (const who of [alice, bob]) {
+ await setInput('input[placeholder^="Add people"]', who);
+ await page.focus('input[placeholder^="Add people"]');
+ await page.keyboard.press('Enter');
+ await sleep(300);
+ }
+ await until(async () => !(await bodyText()).includes('checking'), 15000);
+ await click('button', 'Create group');
+ check('the new group opens', !!await waitText('3 members', 25000));
+ const theirs = await until(() => { const s = JSON.stringify(wn(['chats', 'list'], alice)) + JSON.stringify(wn(['groups', 'invites'], alice)); return s.includes('Made in coinos'); }, 25000);
+ check('wn sees the group', !!theirs);
+ await say('welcome everyone');
+ const rows = [...(wn(['chats', 'list'], alice).chats || []), ...(wn(['groups', 'invites'], alice).invites || [])];
+ const ng = (rows.find((c) => JSON.stringify(c).includes('Made in coinos')) || {}).group_id;
+ if (ng) {
+ wn(['groups', 'accept', ng], alice);
+ check('wn reads the group message', !!await until(() => JSON.stringify(wn(['messages', 'list', ng, '--limit', '20'], alice)).includes('welcome everyone')));
+ wn(['messages', 'send', ng, 'thanks!'], alice);
+ check('and answers', !!await waitText('thanks!'));
+ } else check('found the group id on the wn side', false, JSON.stringify(wn(['chats', 'list'], alice)).slice(0, 300));
+ await page.screenshot({ path: shot('marmot-newgroup.png') });
+
+ console.log('\n[it is all still there after a reload]');
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 15000);
+ await openChat();
+ check('groups listed after reload', !!await waitText('Made in coinos') && !!await waitText('Trail crew'));
+ await page.screenshot({ path: shot('marmot-home.png') });
+
+ console.log('\n[someone without a KeyPackage still gets a NIP-17 DM]');
+ const stranger = seedPubkey(generateMnemonic(wordlist));
+ await click('button', 'New message');
+ await sleep(300);
+ await setInput('input[placeholder*="npub" i]', stranger);
+ await click('button', 'Open');
+ await say('plain old dm');
+ check('the message shows', !!await waitText('plain old dm'));
+ check('a gift wrap went to their inbox', !!await until(async () => (await relayQuery({ kinds: [1059], '#p': [stranger] })).length > 0, 15000));
+ check('no group was founded for it', (await relayQuery({ kinds: [1059], '#p': [stranger] })).length <= 1);
+} catch (e) { console.log(e); ok = false; }
+await browser.close();
+server.stop();
+console.log(ok ? '\nall passed' : '\nFAILED');
+process.exit(ok ? 0 : 1);
diff --git a/tools/marmot-interop-test.js b/tools/marmot-interop-test.js
new file mode 100644
index 0000000..17b3e12
--- /dev/null
+++ b/tools/marmot-interop-test.js
@@ -0,0 +1,243 @@
+// White Noise interop: src/marmot.js + src/mls.js against the Rust reference
+// (MDK's `wn` CLI + `wnd` daemon) over a local relay.
+//
+// bun run tools/marmot-interop-test.js
+//
+// Needs, and skips cleanly without:
+// - a relay on RELAY (default ws://127.0.0.1:27777; the MDK repo's
+// `just relay-up`, or any strfry)
+// - `wn` on PATH built from github.com/marmot-protocol/mdk
+// (cargo install --path crates/cli --locked --bins), with a daemon running:
+// export WN_HOME=… WN_SECRET_STORE=file WN_ALLOW_LOOPBACK_RELAYS=1
+// wn daemon start --discovery-relays $RELAY --default-account-relays $RELAY
+//
+// Covers both directions: wn invites us (Welcome → join → messages → our
+// self-update commit), and we found a group, invite wn accounts, rename,
+// add and remove members, and commit a wn member's SelfRemove.
+
+import { spawnSync } from 'node:child_process';
+import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
+import * as nip19 from 'nostr-tools/nip19';
+import { wrapDM, unwrapDM } from '../src/dm.js';
+import * as M from '../src/marmot.js';
+
+const RELAY = process.env.RELAY || 'ws://127.0.0.1:27777';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let failed = 0;
+const ok = (cond, label, extra) => {
+ if (cond) console.log(' ok ' + label);
+ else { failed++; console.log(' FAIL ' + label + (extra ? ' — ' + extra : '')); }
+};
+
+function wn(args, { account, input } = {}) {
+ const r = spawnSync('wn', ['--json', ...(account ? ['--account', account] : []), ...args], { encoding: 'utf8', input });
+ const line = (r.stdout || '').trim().split('\n').filter(Boolean).at(-1) || '';
+ try {
+ const j = JSON.parse(line);
+ if (!j.ok) return { error: j.error, raw: line };
+ return j.result;
+ } catch { return { error: { message: (r.stderr || r.stdout || 'no output').trim() } }; }
+}
+
+// ---- a tiny relay client ----
+let ws, subs = new Map(), acks = new Map(), sid = 0;
+function connect() {
+ return new Promise((resolve, reject) => {
+ ws = new WebSocket(RELAY);
+ ws.onopen = resolve;
+ ws.onerror = () => reject(new Error('no relay at ' + RELAY));
+ ws.onmessage = (m) => {
+ const d = JSON.parse(m.data);
+ if (d[0] === 'EVENT') subs.get(d[1])?.events.push(d[2]);
+ else if (d[0] === 'EOSE') subs.get(d[1])?.done();
+ else if (d[0] === 'OK') acks.get(d[1])?.(d[2] ? null : new Error(d[3] || 'rejected'));
+ };
+ });
+}
+function query(filter) {
+ return new Promise((resolve) => {
+ const id = 'q' + ++sid, events = [];
+ const done = () => { subs.delete(id); ws.send(JSON.stringify(['CLOSE', id])); resolve(events); };
+ subs.set(id, { events, done });
+ ws.send(JSON.stringify(['REQ', id, filter]));
+ setTimeout(done, 3000);
+ });
+}
+function publish(ev) {
+ return new Promise((resolve, reject) => {
+ acks.set(ev.id, (err) => { acks.delete(ev.id); err ? reject(err) : resolve(true); });
+ ws.send(JSON.stringify(['EVENT', ev]));
+ setTimeout(() => reject(new Error('publish timed out')), 4000);
+ });
+}
+
+// ---- our side: one account, one device ----
+const sk = generateSecretKey(), pk = getPublicKey(sk);
+const sign = async (e) => finalizeEvent(e, sk);
+const now = () => Math.floor(Date.now() / 1000);
+let device, kps = [];
+
+async function publishKeyPackage() {
+ const kp = M.newKeyPackage(device);
+ const ev = await sign(M.keyPackageEvent(device, kp));
+ await publish(ev);
+ kps.push({ ...kp, eventId: ev.id });
+ return ev;
+}
+
+const seenWraps = new Set();
+async function welcomes() {
+ const out = [];
+ for (const wrap of await query({ kinds: [1059], '#p': [pk] })) {
+ if (seenWraps.has(wrap.id)) continue;
+ seenWraps.add(wrap.id);
+ const got = await unwrapDM(wrap, sk);
+ if (got && got.rumor.kind === 444) out.push(got.rumor);
+ }
+ return out;
+}
+
+// pull everything on the group's routing addresses through the engine
+async function sync(g) {
+ const out = [];
+ for (const r of M.routes(g)) {
+ const evs = (await query({ kinds: [445], '#h': [r.id] })).sort((a, b) => a.created_at - b.created_at);
+ for (const ev of evs) out.push(...M.receive(g, ev));
+ }
+ if (process.env.DEBUG) for (const o of out) if (!o.id) console.log(' ·', JSON.stringify(o), 'epoch', g.tip.epoch, 'held', g.held.length);
+ return out;
+}
+const texts = (g) => g.log.filter((m) => m.kind === 9).map((m) => m.content);
+
+async function commit(g, proposals) {
+ const p = M.stage(g, { proposals });
+ await publish(p.ev);
+ M.confirm(g);
+ return p;
+}
+
+async function waitFor(fn, label, ms = 15000) {
+ const until = Date.now() + ms;
+ for (;;) {
+ const v = await fn();
+ if (v) return v;
+ if (Date.now() > until) { ok(false, label, 'timed out'); return null; }
+ await sleep(700);
+ }
+}
+
+async function main() {
+ try { await connect(); } catch (e) { console.log('SKIP: ' + e.message); return; }
+ const who = wn(['whoami']);
+ if (who.error) { console.log('SKIP: wn daemon not reachable (' + who.error.message + ')'); return; }
+
+ const mk = () => { const r = wn(['create-identity']); if (r.error) throw new Error('create-identity: ' + JSON.stringify(r.error)); return r.account_id; };
+ const alice = mk(), bob = mk();
+ console.log('wn accounts', alice.slice(0, 8), bob.slice(0, 8), '· us', pk.slice(0, 8));
+
+ // an account other clients can find: relay lists + a KeyPackage
+ await publish(await sign({ kind: 10002, created_at: now(), tags: [['r', RELAY]], content: '' }));
+ await publish(await sign({ kind: 10050, created_at: now(), tags: [['relay', RELAY]], content: '' }));
+ device = await M.newDevice(sign, pk);
+ const kpEv = await publishKeyPackage();
+ ok(!!M.readKeyPackageEvent(kpEv), 'our KeyPackage event validates locally');
+
+ console.log('\n1. wn reads our KeyPackage');
+ const check = wn(['keys', 'check', pk]);
+ ok(!check.error && JSON.stringify(check).includes('true'), 'wn keys check accepts it', JSON.stringify(check).slice(0, 300));
+
+ console.log('\n2. wn founds a group and invites us');
+ const made = wn(['groups', 'create', 'interop', pk], { account: alice });
+ ok(!made.error, 'wn groups create', JSON.stringify(made.error || ''));
+ const wnGroup = made.group_id || made.group?.group_id || made.group_id_hex;
+ const rumor = await waitFor(async () => (await welcomes())[0], 'Welcome arrives');
+ if (!rumor) return;
+ let g;
+ try {
+ const j = M.join(device, rumor, kps);
+ g = j.g;
+ ok(true, 'joined from wn Welcome (epoch ' + g.tip.epoch + ', ' + M.accounts(g.tip).length + ' accounts)');
+ ok(M.groupView(g.tip).name === 'interop', 'group name reads back', M.groupView(g.tip).name);
+ ok(g.inviter === alice, 'inviter is the wn account');
+ } catch (e) { ok(false, 'join from wn Welcome', e.stack); return; }
+ await publishKeyPackage(); // rotate the consumed package
+
+ console.log('\n3. messages both ways');
+ wn(['messages', 'send', wnGroup, 'hello from wn'], { account: alice });
+ await waitFor(async () => { await sync(g); return texts(g).includes('hello from wn'); }, 'wn → us application message');
+ ok(texts(g).includes('hello from wn'), 'we decrypt wn\'s message');
+ await publish(M.send(g, M.appEvent(pk, 9, 'hello from js')));
+ const seen = await waitFor(async () => JSON.stringify(wn(['messages', 'list', wnGroup, '--limit', '20'], { account: alice })).includes('hello from js'), 'us → wn application message');
+ ok(!!seen, 'wn decrypts our message');
+
+ console.log('\n4. our self-update commit, then traffic in the new epoch');
+ const e0 = g.tip.epoch;
+ await commit(g, []);
+ ok(g.tip.epoch === e0 + 1, 'self-update applied locally');
+ await sleep(1500);
+ wn(['messages', 'send', wnGroup, 'after your update'], { account: alice });
+ await waitFor(async () => { await sync(g); return texts(g).includes('after your update'); }, 'wn follows our commit');
+ ok(texts(g).includes('after your update'), 'wn accepted our commit (message in the new epoch decrypts)');
+
+ console.log('\n5. wn changes the group; we follow');
+ wn(['groups', 'rename', wnGroup, 'interop-renamed'], { account: alice });
+ await waitFor(async () => { await sync(g); return M.groupView(g.tip).name === 'interop-renamed'; }, 'rename commit from wn');
+ ok(M.groupView(g.tip).name === 'interop-renamed', 'we process wn\'s AppDataUpdate commit');
+ wn(['groups', 'add-members', wnGroup, bob], { account: alice });
+ await waitFor(async () => { await sync(g); return M.accounts(g.tip).includes(bob); }, 'add commit from wn');
+ ok(M.accounts(g.tip).includes(bob), 'we process wn\'s Add commit');
+ await publish(M.send(g, M.appEvent(pk, 9, 'welcome bob')));
+ ok(!!await waitFor(async () => JSON.stringify(wn(['messages', 'list', wnGroup, '--limit', '20'], { account: bob })).includes('welcome bob'), 'bob reads us'), 'a member wn added reads our message');
+
+ console.log('\n6. we found a group and invite a wn account');
+ const fetchKp = async (who) => M.pickKeyPackage((await query({ kinds: [30443], authors: [who] })).map((e) => M.readKeyPackageEvent(e)).filter(Boolean));
+ const aliceKp = await fetchKp(alice);
+ ok(!!aliceKp, 'wn KeyPackage validates in JS');
+ if (!aliceKp) return;
+ const f = M.found(device, { name: 'from-js', relays: [RELAY], invitees: [aliceKp] });
+ const mine = f.g;
+ await publish(await wrapDM(sk, alice, M.welcomeRumor(pk, f.welcome, aliceKp.id, [RELAY])));
+ const mineId = mine.id;
+ const listed = await waitFor(async () => JSON.stringify(wn(['chats', 'list'], { account: alice })).includes(mineId) || JSON.stringify(wn(['groups', 'invites'], { account: alice })).includes(mineId), 'wn joins our group');
+ ok(!!listed, 'wn processes our Welcome');
+ wn(['groups', 'accept', mineId], { account: alice });
+ await publish(M.send(mine, M.appEvent(pk, 9, 'first post')));
+ ok(!!await waitFor(async () => JSON.stringify(wn(['messages', 'list', mineId, '--limit', '20'], { account: alice })).includes('first post'), 'wn reads our group'), 'wn decrypts a message in our group');
+ wn(['messages', 'send', mineId, 'reply from wn'], { account: alice });
+ await waitFor(async () => { await sync(mine); return texts(mine).includes('reply from wn'); }, 'wn → our group');
+ ok(texts(mine).includes('reply from wn'), 'we decrypt wn in our group');
+
+ console.log('\n7. our commits in our group: add, rename, remove');
+ const bobKp = await fetchKp(bob);
+ const add = await commit(mine, M.invite([bobKp]));
+ await publish(await wrapDM(sk, bob, M.welcomeRumor(pk, add.welcome, bobKp.id, [RELAY])));
+ ok(!!await waitFor(async () => JSON.stringify(wn(['chats', 'list'], { account: bob })).includes(mineId) || JSON.stringify(wn(['groups', 'invites'], { account: bob })).includes(mineId), 'bob joins'), 'wn joins from our Add commit\'s Welcome');
+ wn(['groups', 'accept', mineId], { account: bob });
+ await commit(mine, [M.setProfile('renamed-by-js')]);
+ ok(!!await waitFor(async () => JSON.stringify(wn(['groups', 'show', mineId], { account: alice })).includes('renamed-by-js'), 'wn sees rename'), 'wn processes our AppDataUpdate commit');
+ wn(['messages', 'send', mineId, 'bob here'], { account: bob });
+ await waitFor(async () => { await sync(mine); return texts(mine).includes('bob here'); }, 'bob → us');
+ ok(texts(mine).includes('bob here'), 'three-member traffic after our commits');
+
+ console.log('\n8. a wn member leaves (SelfRemove); we commit it');
+ const left = wn(['groups', 'leave', mineId], { account: bob });
+ ok(!left.error, 'wn groups leave', JSON.stringify(left.error || ''));
+ const asked = await waitFor(async () => (await sync(mine)).find((o) => o.selfRemove !== undefined) || (mine.tip.pending || []).length > 0, 'SelfRemove proposal arrives');
+ if (asked) {
+ await commit(mine, []);
+ ok(!M.accounts(mine.tip).includes(bob), 'bob is out after our SelfRemove commit');
+ wn(['messages', 'send', mineId, 'two again'], { account: alice });
+ await waitFor(async () => { await sync(mine); return texts(mine).includes('two again'); }, 'alice follows the SelfRemove commit');
+ ok(texts(mine).includes('two again'), 'wn accepted our SelfRemove commit');
+ }
+
+ console.log('\n9. we remove a member');
+ await commit(mine, M.remove(mine, alice));
+ ok(M.accounts(mine.tip).length === 1, 'alice removed locally');
+ ok(!!await waitFor(async () => { const s = JSON.stringify(wn(['chats', 'list', '--include-archived'], { account: alice })); return /removed|left|inactive/i.test(s) || !s.includes(mineId); }, 'wn notices removal', 12000), 'wn realizes its removal');
+}
+
+main().then(() => {
+ console.log(failed ? `\n${failed} FAILED` : '\nall passed');
+ process.exit(failed ? 1 : 0);
+}).catch((e) => { console.error(e); process.exit(1); });
diff --git a/tools/mls-vectors-test.js b/tools/mls-vectors-test.js
new file mode 100644
index 0000000..fb4fca6
--- /dev/null
+++ b/tools/mls-vectors-test.js
@@ -0,0 +1,1195 @@
+// src/mls.js against the RFC 9420 interop vectors (ciphersuite 1), then
+// self-tests that drive a small group through the public API only.
+//
+// bun run tools/mls-vectors-test.js [vectorsDir]
+//
+// vectorsDir holds the mls-implementations JSON files. Without one they are
+// fetched once from github.com/mlswg/mls-implementations into the system
+// temp dir (~10 MB). Exits non-zero on any failure.
+
+import { readFileSync, existsSync, mkdirSync, writeFileSync } from "fs";
+import { join as joinPath } from "path";
+import { tmpdir } from "os";
+import { bytesToHex as hex, hexToBytes as unhex } from "@noble/hashes/utils";
+import { sha256 } from "@noble/hashes/sha256";
+import { ed25519, x25519 } from "@noble/curves/ed25519";
+import * as mls from "../src/mls.js";
+
+const VECTOR_FILES = [
+ "deserialization", "tree-math", "crypto-basics", "secret-tree", "message-protection", "key-schedule", "psk_secret",
+ "transcript-hashes", "welcome", "tree-operations", "tree-validation", "treekem", "messages",
+ "passive-client-welcome", "passive-client-handling-commit", "passive-client-random",
+].map((n) => n + ".json");
+const UPSTREAM = "https://raw.githubusercontent.com/mlswg/mls-implementations/main/test-vectors/";
+const dir = process.argv[2] || joinPath(tmpdir(), "mls-test-vectors");
+if (!process.argv[2]) {
+ mkdirSync(dir, { recursive: true });
+ for (const f of VECTOR_FILES) {
+ if (existsSync(joinPath(dir, f))) continue;
+ const res = await fetch(UPSTREAM + f);
+ if (!res.ok) throw new Error(`could not fetch ${f}: ${res.status}`);
+ writeFileSync(joinPath(dir, f), await res.text());
+ }
+}
+
+const { internals: I, EXT, PROPOSAL } = mls;
+const { enc, dec, codec } = I;
+const utf8 = (s) => new TextEncoder().encode(s);
+const EMPTY = new Uint8Array(0);
+const ZERO32 = new Uint8Array(32);
+
+const same = (a, b, what) => {
+ let x = a instanceof Uint8Array ? hex(a) : JSON.stringify(a);
+ let y =
+ b instanceof Uint8Array
+ ? hex(b)
+ : typeof b === "string" && a instanceof Uint8Array
+ ? b
+ : JSON.stringify(b);
+ if (x !== y) throw new Error(`${what}: ${String(x).slice(0, 80)} != ${String(y).slice(0, 80)}`);
+};
+const ok = (cond, what) => {
+ if (!cond) throw new Error(what);
+};
+const throws = (fn, what, code) => {
+ try {
+ fn();
+ } catch (e) {
+ if (code && e.code !== code)
+ throw new Error(`${what}: expected code ${code}, got ${e.code} (${e.message})`);
+ return e;
+ }
+ throw new Error(what + ": expected an error");
+};
+
+let failed = 0;
+const results = [];
+
+// runs fn over every case; a case may return a string to say why it was skipped
+const suite = (name, cases, fn) => {
+ let pass = 0;
+ let fail = 0;
+ let skips = {};
+ cases.forEach((c, i) => {
+ try {
+ let skipped = fn(c, i);
+ if (typeof skipped === "string") skips[skipped] = (skips[skipped] || 0) + 1;
+ else pass++;
+ } catch (e) {
+ fail++;
+ if (fail <= 3) console.log(` FAIL ${name} #${i}: ${e.message}`);
+ }
+ });
+ failed += fail;
+ let skipped = Object.entries(skips)
+ .map(([why, n]) => `${n} skipped (${why})`)
+ .join(", ");
+ results.push(
+ `${fail ? "FAIL" : "ok "} ${name}: ${pass}/${cases.length - Object.values(skips).reduce((a, b) => a + b, 0)} passed${skipped ? ", " + skipped : ""}`,
+ );
+ console.log(results[results.length - 1]);
+};
+
+const load = (file, all) => {
+ let path = joinPath(dir, file);
+ if (!existsSync(path)) return null;
+ let cases = JSON.parse(readFileSync(path, "utf8"));
+ return all ? cases : cases.filter((c) => c.cipher_suite === 1);
+};
+
+const vectors = (name, file, fn, all = false) => {
+ let cases = load(file, all);
+ if (!cases) {
+ failed++;
+ console.log(`FAIL ${name}: ${file} not found in ${dir}`);
+ return;
+ }
+ suite(name, cases, fn);
+};
+
+// ---- official vectors ----
+
+vectors(
+ "deserialization",
+ "deserialization.json",
+ (c) => same(new mls.Reader(unhex(c.vlbytes_header)).varint(), c.length, "length"),
+ true,
+);
+
+vectors(
+ "tree-math",
+ "tree-math.json",
+ (c) => {
+ let T = I.tree;
+ let width = c.n_nodes;
+ same(2 * c.n_leaves - 1, width, "n_nodes");
+ same(T.rootOf(width), c.root, "root");
+ for (let x = 0; x < width; x++) {
+ same(x % 2 ? T.left(x) : null, c.left[x], "left " + x);
+ same(x % 2 ? T.right(x) : null, c.right[x], "right " + x);
+ same(x === c.root ? null : T.parent(x), c.parent[x], "parent " + x);
+ same(x === c.root ? null : T.sibling(x), c.sibling[x], "sibling " + x);
+ }
+ },
+ true,
+);
+
+vectors("crypto-basics", "crypto-basics.json", (c) => {
+ let C = I.crypto;
+ let v = c.ref_hash;
+ same(C.refHash(v.label, unhex(v.value)), v.out, "ref_hash");
+ v = c.expand_with_label;
+ same(
+ C.expandWithLabel(unhex(v.secret), v.label, unhex(v.context), v.length),
+ v.out,
+ "expand_with_label",
+ );
+ v = c.derive_secret;
+ same(C.deriveSecret(unhex(v.secret), v.label), v.out, "derive_secret");
+ v = c.derive_tree_secret;
+ same(
+ C.deriveTreeSecret(unhex(v.secret), v.label, v.generation, v.length),
+ v.out,
+ "derive_tree_secret",
+ );
+ v = c.sign_with_label;
+ ok(
+ C.verifyWithLabel(unhex(v.pub), v.label, unhex(v.content), unhex(v.signature)),
+ "verify vector signature",
+ );
+ let sig = C.signWithLabel(unhex(v.priv), v.label, unhex(v.content));
+ ok(C.verifyWithLabel(unhex(v.pub), v.label, unhex(v.content), sig), "verify own signature");
+ ok(
+ !C.verifyWithLabel(unhex(v.pub), v.label + "x", unhex(v.content), sig),
+ "signature bound to label",
+ );
+ v = c.encrypt_with_label;
+ let args = [unhex(v.priv), v.label, unhex(v.context)];
+ same(
+ C.decryptWithLabel(...args, unhex(v.kem_output), unhex(v.ciphertext)),
+ v.plaintext,
+ "decrypt vector",
+ );
+ let own = C.encryptWithLabel(unhex(v.pub), v.label, unhex(v.context), unhex(v.plaintext));
+ same(C.decryptWithLabel(...args, own.kemOutput, own.ciphertext), v.plaintext, "decrypt own");
+});
+
+vectors("secret-tree", "secret-tree.json", (c) => {
+ let C = I.crypto;
+ let sd = c.sender_data;
+ let sample = unhex(sd.ciphertext).subarray(0, 32);
+ same(
+ C.expandWithLabel(unhex(sd.sender_data_secret), "key", sample, 16),
+ sd.key,
+ "sender data key",
+ );
+ same(
+ C.expandWithLabel(unhex(sd.sender_data_secret), "nonce", sample, 12),
+ sd.nonce,
+ "sender data nonce",
+ );
+ let width = 2 * c.leaves.length - 1;
+ let st = I.secretTree.secretTreeFor({ length: width }, unhex(c.encryption_secret));
+ c.leaves.forEach((gens, leaf) => {
+ st = I.secretTree.leafRatchets(st, width, 2 * leaf);
+ let { h, a } = st.ratchets[2 * leaf];
+ for (let g of gens) {
+ let app = I.secretTree.seek(a, g.generation);
+ let hs = I.secretTree.seek(h, g.generation);
+ same(app.kn.key, g.application_key, "application key");
+ same(app.kn.nonce, g.application_nonce, "application nonce");
+ same(hs.kn.key, g.handshake_key, "handshake key");
+ same(hs.kn.nonce, g.handshake_nonce, "handshake nonce");
+ a = app.r;
+ h = hs.r;
+ }
+ });
+ ok(Object.keys(st.nodes).length === 0, "all node secrets consumed");
+});
+
+// a two-leaf state with just enough in it for the framing layer
+const framingState = (c, leafIndex) => {
+ let leaf = (signatureKey) => ({ signatureKey, encryptionKey: EMPTY });
+ let tree = [leaf(ZERO32), null, leaf(unhex(c.signature_pub))];
+ return {
+ version: 1,
+ cipherSuite: 1,
+ groupId: unhex(c.group_id),
+ epoch: c.epoch,
+ treeHash: unhex(c.tree_hash),
+ confirmedTranscriptHash: unhex(c.confirmed_transcript_hash),
+ extensions: [],
+ tree,
+ leafIndex,
+ sigPriv: unhex(c.signature_priv),
+ keys: { senderData: unhex(c.sender_data_secret), membership: unhex(c.membership_key) },
+ secretTree: I.secretTree.secretTreeFor(tree, unhex(c.encryption_secret)),
+ };
+};
+
+vectors("message-protection", "message-protection.json", (c) => {
+ let F = I.framing;
+ let receiver = framingState(c, 0);
+ let sender = framingState(c, 1);
+ let message = (h) => dec(codec.message[1], unhex(h));
+ let body = { proposal: codec.proposal[0], commit: codec.commit[0] };
+ let check = (content, kind) => {
+ if (kind === "application") return same(content.data, c.application, "application data");
+ same(enc(body[kind], content[kind]), c[kind], kind + " content");
+ };
+ let contentOf = (kind, contentType) => {
+ let content = {
+ groupId: sender.groupId,
+ epoch: sender.epoch,
+ sender: { type: 1, leafIndex: 1 },
+ aad: utf8("aad"),
+ contentType,
+ };
+ if (kind === "application") content.data = unhex(c.application);
+ else content[kind] = dec(codec[kind][1], unhex(c[kind]));
+ return content;
+ };
+ [
+ ["application", 1],
+ ["proposal", 2],
+ ["commit", 3],
+ ].forEach(([kind, contentType]) => {
+ let authFor = (wire) => {
+ let content = contentOf(kind, contentType);
+ let auth = { signature: F.signContent(sender, wire, content) };
+ if (kind === "commit") auth.confirmationTag = ZERO32;
+ return { content, auth };
+ };
+ if (kind !== "application") {
+ check(F.openPublic(receiver, message(c[kind + "_pub"]).public).content, kind);
+ let { content, auth } = authFor(1);
+ check(
+ F.openPublic(receiver, message(hex(F.sealPublic(sender, content, auth))).public).content,
+ kind,
+ );
+ let bad = dec(codec.message[1], F.sealPublic(sender, content, auth));
+ bad.public.membershipTag[0] ^= 1;
+ throws(() => F.openPublic(receiver, bad.public), "tampered membership tag");
+ }
+ check(F.openPrivate(receiver, message(c[kind + "_priv"]).private).content, kind);
+ let { content, auth } = authFor(2);
+ let sealed = F.sealPrivate(sender, content, auth, 7);
+ let opened = F.openPrivate(receiver, message(hex(sealed.message)).private);
+ check(opened.content, kind);
+ same(opened.content.aad, utf8("aad"), "aad");
+ throws(
+ () =>
+ F.openPrivate(
+ { ...receiver, secretTree: opened.secretTree },
+ message(hex(sealed.message)).private,
+ ),
+ "replay",
+ "replay",
+ );
+ throws(() => F.openPrivate(sender, message(hex(sealed.message)).private), "own message", "own");
+ });
+ let content = contentOf("application", 1);
+ let auth = { signature: F.signContent(sender, 1, content) };
+ throws(
+ () => F.openPublic(receiver, dec(codec.message[1], F.sealPublic(sender, content, auth)).public),
+ "public application",
+ );
+});
+
+vectors("key-schedule", "key-schedule.json", (c) => {
+ let S = I.schedule;
+ let init = unhex(c.initial_init_secret);
+ c.epochs.forEach((e, epoch) => {
+ let ctx = {
+ version: 1,
+ cipherSuite: 1,
+ groupId: unhex(c.group_id),
+ epoch,
+ treeHash: unhex(e.tree_hash),
+ confirmedTranscriptHash: unhex(e.confirmed_transcript_hash),
+ extensions: [],
+ };
+ let context = enc(codec.context[0], ctx);
+ same(context, e.group_context, "group_context");
+ let joiner = S.joinerSecret(init, unhex(e.commit_secret), context);
+ same(joiner, e.joiner_secret, "joiner_secret");
+ let { welcome, keys, encryption } = S.fromJoiner(joiner, unhex(e.psk_secret), context);
+ same(welcome, e.welcome_secret, "welcome_secret");
+ same(encryption, e.encryption_secret, "encryption_secret");
+ same(keys.senderData, e.sender_data_secret, "sender_data_secret");
+ same(keys.exporter, e.exporter_secret, "exporter_secret");
+ same(keys.external, e.external_secret, "external_secret");
+ same(keys.confirmation, e.confirmation_key, "confirmation_key");
+ same(keys.membership, e.membership_key, "membership_key");
+ same(keys.resumption, e.resumption_psk, "resumption_psk");
+ same(keys.authenticator, e.epoch_authenticator, "epoch_authenticator");
+ same(keys.init, e.init_secret, "init_secret");
+ same(I.crypto.deriveKeyPair(keys.external).pub, e.external_pub, "external_pub");
+ let x = e.exporter;
+ same(mls.exportSecret({ keys }, x.label, unhex(x.context), x.length), x.secret, "exporter");
+ init = keys.init;
+ });
+});
+
+vectors("psk-secret", "psk_secret.json", (c) => {
+ let psks = c.psks.map((p) => ({
+ id: { psktype: 1, pskId: unhex(p.psk_id), nonce: unhex(p.psk_nonce) },
+ psk: unhex(p.psk),
+ }));
+ same(I.schedule.pskSecret(psks), c.psk_secret, "psk_secret");
+});
+
+vectors("transcript-hashes", "transcript-hashes.json", (c) => {
+ let { wire, content, auth } = dec((r) => {
+ let wire = r.u16();
+ let content = codec.content[1](r);
+ return { wire, content, auth: codec.auth[1](r, content.contentType) };
+ }, unhex(c.authenticated_content));
+ let confirmed = I.schedule.confirmedHash(
+ unhex(c.interim_transcript_hash_before),
+ wire,
+ content,
+ auth.signature,
+ );
+ same(confirmed, c.confirmed_transcript_hash_after, "confirmed_transcript_hash");
+ same(
+ I.crypto.mac(unhex(c.confirmation_key), confirmed),
+ auth.confirmationTag,
+ "confirmation_tag",
+ );
+ same(
+ I.schedule.interimHash(confirmed, auth.confirmationTag),
+ c.interim_transcript_hash_after,
+ "interim_transcript_hash",
+ );
+});
+
+vectors("welcome", "welcome.json", (c) => {
+ let { welcome } = dec(codec.message[1], unhex(c.welcome));
+ let kp = mls.parseKeyPackage(unhex(c.key_package));
+ let mine = welcome.secrets.find((s) => hex(s.ref) === hex(kp.ref));
+ ok(mine, "welcome addressed to the key package");
+ let secrets = dec(
+ codec.groupSecrets[1],
+ I.crypto.decryptWithLabel(
+ unhex(c.init_priv),
+ "Welcome",
+ welcome.encryptedGroupInfo,
+ mine.kemOutput,
+ mine.ciphertext,
+ ),
+ );
+ // the welcome secret does not depend on the context, so any will do to get at the GroupInfo
+ let cipher = I.schedule.welcomeCipher(
+ I.schedule.fromJoiner(secrets.joinerSecret, ZERO32, EMPTY).welcome,
+ );
+ let info = dec(codec.groupInfo[1], cipher.decrypt(welcome.encryptedGroupInfo));
+ ok(
+ I.crypto.verifyWithLabel(
+ unhex(c.signer_pub),
+ "GroupInfoTBS",
+ enc(codec.groupInfoTBS[0], info),
+ info.signature,
+ ),
+ "GroupInfo signature",
+ );
+ let { keys } = I.schedule.fromJoiner(
+ secrets.joinerSecret,
+ ZERO32,
+ enc(codec.context[0], info.context),
+ );
+ same(
+ I.crypto.mac(keys.confirmation, info.context.confirmedTranscriptHash),
+ info.confirmationTag,
+ "confirmation_tag",
+ );
+});
+
+vectors("tree-operations", "tree-operations.json", (c) => {
+ let R = I.ratchetTree;
+ let tree = dec(codec.tree[1], unhex(c.tree_before));
+ same(I.tree.rootHash(tree), c.tree_hash_before, "tree_hash_before");
+ let p = dec(codec.proposal[1], unhex(c.proposal));
+ if (p.type === PROPOSAL.ADD) R.addLeaf(tree, p.keyPackage.leaf);
+ else if (p.type === PROPOSAL.UPDATE) {
+ R.blankPath(tree, 2 * c.proposal_sender);
+ tree[2 * c.proposal_sender] = p.leaf;
+ } else if (p.type === PROPOSAL.REMOVE) {
+ R.blankPath(tree, 2 * p.removed);
+ tree[2 * p.removed] = null;
+ R.truncate(tree);
+ } else throw new Error("unexpected proposal type " + p.type);
+ same(enc(codec.tree[0], tree), c.tree_after, "tree_after");
+ same(I.tree.rootHash(tree), c.tree_hash_after, "tree_hash_after");
+});
+
+vectors("tree-validation", "tree-validation.json", (c) => {
+ let tree = dec(codec.tree[1], unhex(c.tree));
+ same(enc(codec.tree[0], tree), c.tree, "tree round trip");
+ c.tree_hashes.forEach((h, x) => same(I.tree.treeHash(tree, x), h, "tree hash " + x));
+ c.resolutions.forEach((r, x) => same(I.tree.resolution(tree, x), r, "resolution " + x));
+ ok(I.ratchetTree.verifyParentHashes(tree), "parent hashes");
+ // leaf signatures, capabilities, unmerged leaves, key uniqueness, parent hashes
+ I.ratchetTree.checkTree(tree, unhex(c.group_id), []);
+});
+
+vectors("treekem", "treekem.json", (c) => {
+ let K = I.treekem;
+ let T = I.tree;
+ let tree = dec(codec.tree[1], unhex(c.ratchet_tree));
+ let base = {
+ version: 1,
+ cipherSuite: 1,
+ groupId: unhex(c.group_id),
+ epoch: c.epoch,
+ confirmedTranscriptHash: unhex(c.confirmed_transcript_hash),
+ extensions: [],
+ };
+ // what each member knows: its leaf key plus the node keys its path secrets derive
+ let privs = {};
+ for (let lp of c.leaves_private) {
+ let priv = { [2 * lp.index]: unhex(lp.encryption_priv) };
+ same(
+ x25519.getPublicKey(priv[2 * lp.index]),
+ tree[2 * lp.index].encryptionKey,
+ "leaf key " + lp.index,
+ );
+ for (let ps of lp.path_secrets) {
+ let kp = I.crypto.deriveKeyPair(I.crypto.deriveSecret(unhex(ps.path_secret), "node"));
+ same(kp.pub, tree[ps.node].encryptionKey, "node key " + ps.node);
+ priv[ps.node] = kp.priv;
+ }
+ privs[lp.index] = priv;
+ }
+ let receive = (after, fp, path, context, leaf) => {
+ let { at, secret } = K.openPathSecret(
+ privs[leaf],
+ leaf,
+ fp,
+ K.pathTargets(fp, [], path),
+ path,
+ context,
+ );
+ return { secret, commitSecret: K.derivePath(after, fp, at, secret).commitSecret };
+ };
+ for (let up of c.update_paths) {
+ let path = dec(codec.path[1], unhex(up.update_path));
+ let x = 2 * up.sender;
+ let fp = T.filteredPath(tree, x);
+ let after = tree.slice();
+ same(
+ K.setPath(
+ after,
+ x,
+ fp,
+ path.nodes.map((n) => n.encryptionKey),
+ ),
+ path.leaf.parentHash,
+ "leaf parent hash",
+ );
+ after[x] = path.leaf;
+ let treeHash = T.rootHash(after);
+ same(treeHash, up.tree_hash_after, "tree_hash_after");
+ ok(I.ratchetTree.verifyParentHashes(after), "parent hashes after the path");
+ let context = enc(codec.context[0], { ...base, treeHash });
+
+ // a path of our own from the same sender, opened by everybody else
+ let secret = crypto.getRandomValues(new Uint8Array(32));
+ let own = { leaf: path.leaf, nodes: [] };
+ for (let f of fp) {
+ let kp = I.crypto.deriveKeyPair(I.crypto.deriveSecret(secret, "node"));
+ let secrets = f.res.map((n) =>
+ I.crypto.encryptWithLabel(tree[n].encryptionKey, "UpdatePathNode", context, secret),
+ );
+ own.nodes.push({ encryptionKey: kp.pub, secrets });
+ secret = I.crypto.deriveSecret(secret, "path");
+ }
+ let ownAfter = tree.slice();
+ K.setPath(
+ ownAfter,
+ x,
+ fp,
+ own.nodes.map((n) => n.encryptionKey),
+ );
+
+ for (let lp of c.leaves_private) {
+ if (lp.index === up.sender) {
+ same(up.path_secrets[lp.index], null, "no path secret for the sender");
+ continue;
+ }
+ let got = receive(after, fp, path, context, lp.index);
+ same(got.secret, up.path_secrets[lp.index], "path secret for leaf " + lp.index);
+ same(got.commitSecret, up.commit_secret, "commit secret for leaf " + lp.index);
+ same(
+ receive(ownAfter, fp, own, context, lp.index).commitSecret,
+ secret,
+ "own path commit secret",
+ );
+ }
+ }
+});
+
+const PROPOSAL_FIELDS = {
+ add_proposal: 1,
+ update_proposal: 2,
+ remove_proposal: 3,
+ pre_shared_key_proposal: 4,
+ re_init_proposal: 5,
+ external_init_proposal: 6,
+ group_context_extensions_proposal: 7,
+};
+const MESSAGE_FIELDS = [
+ "mls_welcome",
+ "mls_group_info",
+ "mls_key_package",
+ "public_message_application",
+ "public_message_proposal",
+ "public_message_commit",
+ "private_message",
+];
+
+vectors(
+ "messages",
+ "messages.json",
+ (c) => {
+ let trip = ([w, r], h, what) => same(enc(w, dec(r, unhex(h))), h, what);
+ for (let f of MESSAGE_FIELDS) trip(codec.message, c[f], f);
+ trip(codec.tree, c.ratchet_tree, "ratchet_tree");
+ trip(codec.groupSecrets, c.group_secrets, "group_secrets");
+ trip(codec.commit, c.commit, "commit");
+ // the vectors hold bare proposal bodies; ours carry their type
+ for (let [f, type] of Object.entries(PROPOSAL_FIELDS))
+ trip(codec.proposal, hex(new mls.Writer().u16(type).finish()) + c[f], f);
+ for (let f of MESSAGE_FIELDS) mls.decodeMessage(unhex(c[f]));
+ },
+ true,
+);
+
+const snapshot = (v) => JSON.stringify(v, (k, x) => (x instanceof Uint8Array ? "0x" + hex(x) : x));
+
+// processMessage, checking on the way that the input state is left alone
+const step = (state, bytes, opts) => {
+ let before = snapshot(state);
+ let out = mls.processMessage(state, bytes, opts);
+ ok(snapshot(state) === before, "processMessage mutated its input state");
+ return out;
+};
+
+const passive = (c) => {
+ let kp = mls.parseKeyPackage(unhex(c.key_package));
+ let sig = { priv: unhex(c.signature_priv), pub: ed25519.getPublicKey(unhex(c.signature_priv)) };
+ same(sig.pub, kp.leaf.signatureKey, "signature key");
+ same(x25519.getPublicKey(unhex(c.init_priv)), kp.initKey, "init key");
+ let psks = Object.fromEntries(c.external_psks.map((p) => [p.psk_id, unhex(p.psk)]));
+ try {
+ let state = mls.joinWelcome(unhex(c.welcome), {
+ keyPackage: unhex(c.key_package),
+ secrets: { initPriv: unhex(c.init_priv), encPriv: unhex(c.encryption_priv) },
+ sig,
+ ratchetTree: c.ratchet_tree ? unhex(c.ratchet_tree) : undefined,
+ psks,
+ });
+ same(
+ mls.epochAuthenticator(state),
+ c.initial_epoch_authenticator,
+ "initial epoch authenticator",
+ );
+ c.epochs.forEach((e, i) => {
+ for (let p of e.proposals) state = step(state, unhex(p), { psks }).state;
+ let out = step(state, unhex(e.commit), { psks });
+ ok(out.type === "commit" && !out.removedSelf, "commit applied");
+ psks["resumption:" + state.epoch] = state.keys.resumption;
+ state = structuredClone(out.state);
+ same(
+ mls.epochAuthenticator(state),
+ e.epoch_authenticator,
+ "epoch authenticator after commit " + i,
+ );
+ });
+ } catch (e) {
+ if (e.code === "unsupported")
+ return e.message.replace(/^mls: /, "").replace(/ [0-9a-f]{16,}$/, "");
+ throw e;
+ }
+};
+
+vectors("passive-client-welcome", "passive-client-welcome.json", passive);
+vectors("passive-client-handling-commit", "passive-client-handling-commit.json", passive);
+vectors("passive-client-random", "passive-client-random.json", passive);
+
+// ---- self-tests: a group driven through the public API ----
+
+const COMPONENT = 0x8001;
+const caps = {
+ extensions: [EXT.APP_DATA_DICTIONARY, 0xf2f0],
+ proposals: [8, 9, 10],
+ credentials: [1],
+};
+const requiredCapsFor = (extensions) =>
+ new mls.Writer()
+ .list(extensions, (w, n) => w.u16(n))
+ .list([8, 9, 10], (w, n) => w.u16(n))
+ .list([1], (w, n) => w.u16(n))
+ .finish();
+const groupExtensions = [
+ { type: EXT.REQUIRED_CAPABILITIES, data: requiredCapsFor([EXT.APP_DATA_DICTIONARY]) },
+ { type: EXT.APP_DATA_DICTIONARY, data: mls.encodeDict([{ id: 1, data: utf8("components") }]) },
+];
+
+const person = (name) => {
+ let sig = mls.generateSignatureKeyPair();
+ let kp = mls.generateKeyPackage({ sig, identity: utf8(name), capabilities: caps });
+ return { name, sig, kp };
+};
+
+const agree = (states, what) => {
+ let [first, ...rest] = states;
+ for (let s of rest) {
+ same(s.epoch, first.epoch, what + ": epoch");
+ same(s.treeHash, first.treeHash, what + ": treeHash");
+ same(
+ s.confirmedTranscriptHash,
+ first.confirmedTranscriptHash,
+ what + ": confirmedTranscriptHash",
+ );
+ same(s.extensions, first.extensions, what + ": extensions");
+ same(
+ mls.exportSecret(s, "marmot", utf8("ctx"), 32),
+ mls.exportSecret(first, "marmot", utf8("ctx"), 32),
+ what + ": exporter",
+ );
+ same(mls.epochAuthenticator(s), mls.epochAuthenticator(first), what + ": epoch authenticator");
+ same(
+ mls.members(s).map((m) => [m.index, hex(m.identity)]),
+ mls.members(first).map((m) => [m.index, hex(m.identity)]),
+ what + ": members",
+ );
+ }
+};
+
+const join = (p, welcome) =>
+ mls.joinWelcome(welcome, { keyPackage: p.kp.keyPackage, secrets: p.kp.secrets, sig: p.sig });
+
+// everybody in `others` applies a commit; returns their new states
+const apply = (others, message, opts) =>
+ others.map((s) => {
+ let out = step(s, message, opts);
+ ok(out.type === "commit", "commit processed");
+ return out;
+ });
+
+const tests = [];
+const test = (name, fn) => tests.push({ name, fn });
+
+test("key packages", () => {
+ let p = person("alice");
+ let parsed = mls.parseKeyPackage(p.kp.keyPackage);
+ same(mls.parseKeyPackage(p.kp.message).ref, p.kp.ref, "ref from either framing");
+ same(parsed.keyPackage, p.kp.keyPackage, "bytes");
+ same(parsed.leaf.credential.identity, utf8("alice"), "identity");
+ same(parsed.leaf.capabilities.proposals, [8, 9, 10], "capabilities");
+ ok(parsed.cipherSuite === 1 && parsed.version === 1, "suite and version");
+ let peek = mls.decodeMessage(p.kp.message);
+ ok(peek.wireFormat === "key_package" && hex(peek.ref) === hex(p.kp.ref), "decodeMessage");
+ let bad = p.kp.keyPackage.slice();
+ bad[bad.length - 1] ^= 1;
+ throws(() => mls.parseKeyPackage(bad), "tampered key package");
+ let w = new mls.Writer().vec(new Uint8Array(70)).finish();
+ same(new mls.Reader(w).vec().length, 70, "two-byte vector length");
+ throws(
+ () => new mls.Reader(Uint8Array.of(0x40, 0x05, 1, 2, 3, 4, 5)).vec(),
+ "non-minimal vector length",
+ );
+});
+
+test("dictionary codec", () => {
+ let bytes = mls.encodeDict([
+ { id: 9, data: utf8("b") },
+ { id: 2, data: utf8("a") },
+ ]);
+ same(
+ mls.decodeDict(bytes).map((e) => e.id),
+ [2, 9],
+ "sorted",
+ );
+ same(mls.dictOf([{ type: EXT.APP_DATA_DICTIONARY, data: bytes }])[1].data, utf8("b"), "dictOf");
+ same(mls.dictOf([]), [], "absent dictionary");
+ throws(
+ () =>
+ mls.encodeDict([
+ { id: 1, data: EMPTY },
+ { id: 1, data: EMPTY },
+ ]),
+ "duplicate ids",
+ );
+ let unsorted = new mls.Writer().list([9, 2], (w, id) => w.u16(id).vec(EMPTY)).finish();
+ throws(() => mls.decodeDict(unsorted), "unsorted dictionary");
+});
+
+test("unknown types round-trip", () => {
+ let { codec } = I;
+ let grease = { type: 0x0a0a, data: utf8("grease") };
+ same(dec(codec.proposal[1], enc(codec.proposal[0], grease)), grease, "GREASE proposal");
+ let alice = person("alice");
+ let exts = [{ type: 0xf2f0, data: utf8("x") }];
+ let kp = mls.generateKeyPackage({
+ sig: alice.sig,
+ identity: "alice",
+ capabilities: caps,
+ leafExtensions: exts,
+ extensions: [{ type: 0x1a1a, data: EMPTY }],
+ });
+ let parsed = mls.parseKeyPackage(kp.message);
+ same(parsed.leaf.extensions, exts, "leaf extension");
+ same(parsed.extensions, [{ type: 0x1a1a, data: EMPTY }], "key package extension");
+ throws(
+ () => mls.generateKeyPackage({ sig: alice.sig, identity: "a", leafExtensions: exts }),
+ "extension outside capabilities",
+ );
+});
+
+test("group lifecycle", () => {
+ let [alice, bob, carol, dave] = ["alice", "bob", "carol", "dave"].map(person);
+ let groupId = crypto.getRandomValues(new Uint8Array(32));
+ let a = mls.createGroup({
+ groupId,
+ sig: alice.sig,
+ identity: utf8("alice"),
+ capabilities: caps,
+ extensions: groupExtensions,
+ });
+ ok(a.epoch === 0 && a.leafIndex === 0 && mls.members(a).length === 1, "fresh group");
+
+ // add two members in one commit
+ let before = snapshot(a);
+ let c1 = mls.createCommit(a, {
+ proposals: [mls.proposeAdd(bob.kp.keyPackage), mls.proposeAdd(carol.kp.message)],
+ });
+ ok(snapshot(a) === before, "createCommit left its input alone");
+ ok(c1.welcome && c1.addedRefs.length === 2, "one welcome for both");
+ same(c1.addedRefs, [bob.kp.ref, carol.kp.ref], "added refs");
+ same(mls.decodeMessage(c1.welcome).refs, [bob.kp.ref, carol.kp.ref], "welcome refs");
+ let peek = mls.decodeMessage(c1.message);
+ ok(
+ peek.wireFormat === "public" && peek.contentType === "commit" && peek.epoch === 0,
+ "commit is a PublicMessage",
+ );
+ let a0 = a;
+ a = c1.state;
+ let b = join(bob, c1.welcome);
+ let c = join(carol, c1.welcome);
+ ok(b.joinedVia === 0 && c.joinedVia === 0, "joinedVia");
+ ok(b.leafIndex === 1 && c.leafIndex === 2, "leaf indices");
+ agree([a, b, c], "after adds");
+ throws(() => join(dave, c1.welcome), "welcome for somebody else", "not-for-us");
+ throws(() => step(a, c1.message), "stale commit", "epoch");
+ throws(() => step(a0, c1.message), "own commit", "own");
+
+ // application messages in every direction, out of order, replayed
+ let states = { a, b, c };
+ for (let from of ["a", "b", "c"]) {
+ let sent = [];
+ for (let i = 0; i < 4; i++) {
+ let out = mls.encryptApplication(states[from], utf8(`${from}${i}`), utf8("aad" + i));
+ states[from] = out.state;
+ sent.push(out.message);
+ }
+ ok(mls.decodeMessage(sent[0]).wireFormat === "private", "application is a PrivateMessage");
+ for (let to of ["a", "b", "c"]) {
+ if (to === from) {
+ throws(() => step(states[to], sent[0]), "own application message", "own");
+ continue;
+ }
+ for (let i of [2, 0, 3, 1]) {
+ let out = step(states[to], sent[i]);
+ ok(
+ out.type === "application" && out.sender === states[from].leafIndex,
+ "application sender",
+ );
+ same(out.data, utf8(`${from}${i}`), "application data");
+ same(out.aad, utf8("aad" + i), "application aad");
+ states[to] = out.state;
+ }
+ throws(() => step(states[to], sent[1]), "replay", "replay");
+ let bad = sent[0].slice();
+ bad[bad.length - 1] ^= 1;
+ throws(() => step(states[to], bad), "tampered ciphertext");
+ }
+ }
+ ({ a, b, c } = states);
+
+ // an update-only commit by a non-creator
+ let epoch1 = { a, b, c };
+ let late = mls.encryptApplication(a, utf8("late")).message;
+ let c2 = mls.createCommit(b);
+ ok(c2.welcome === null, "no welcome without adds");
+ b = c2.state;
+ [a, c] = apply([a, c], c2.message).map((o) => o.state);
+ agree([a, b, c], "after update");
+ ok(
+ hex(mls.members(a)[1].encryptionKey) !== hex(mls.members(epoch1.a)[1].encryptionKey),
+ "committer rotated its leaf key",
+ );
+
+ // old states still read their own epoch
+ same(step(epoch1.b, late).data, utf8("late"), "old epoch state decrypts");
+ throws(() => step(b, late), "old message on the new epoch", "epoch");
+
+ // a tampered commit is refused
+ let c3 = mls.createCommit(c, {
+ proposals: [mls.proposeAdd(dave.kp.keyPackage)],
+ aad: utf8("commit aad"),
+ });
+ let bad = c3.message.slice();
+ bad[bad.length - 40] ^= 1;
+ throws(() => step(a, bad), "tampered commit");
+ let outs = apply([a, b], c3.message);
+ same(outs[0].aad, utf8("commit aad"), "commit aad");
+ ok(
+ outs[0].proposals.length === 1 &&
+ outs[0].proposals[0].proposal.type === PROPOSAL.ADD &&
+ !outs[0].proposals[0].byRef,
+ "commit proposals",
+ );
+ [a, b] = outs.map((o) => o.state);
+ c = c3.state;
+ let d = join(dave, c3.welcome);
+ ok(d.joinedVia === 2 && d.leafIndex === 3, "dave joined via carol");
+ agree([a, b, c, d], "after adding dave");
+
+ // remove carol
+ let c4 = mls.createCommit(a, { proposals: [mls.proposeRemove(2)] });
+ a = c4.state;
+ [b, d] = apply([b, d], c4.message).map((o) => o.state);
+ let gone = step(c, c4.message);
+ ok(
+ gone.removedSelf && gone.state.removed && gone.state.epoch === c.epoch,
+ "carol learns she was removed",
+ );
+ throws(
+ () => mls.encryptApplication(gone.state, utf8("x")),
+ "removed member cannot send",
+ "removed",
+ );
+ same(
+ mls.members(a).map((m) => m.index),
+ [0, 1, 3],
+ "members after remove",
+ );
+ agree([a, b, d], "after remove");
+ let secret = mls.encryptApplication(a, utf8("after carol"));
+ a = secret.state;
+ throws(() => step(c, secret.message), "removed member cannot read", "epoch");
+ b = step(b, secret.message).state;
+ d = step(d, secret.message).state;
+
+ // app_data_update, inline: update, then remove
+ let c5 = mls.createCommit(b, { proposals: [mls.proposeAppDataUpdate(COMPONENT, utf8("v1"))] });
+ b = c5.state;
+ [a, d] = apply([a, d], c5.message).map((o) => o.state);
+ agree([a, b, d], "after app_data_update");
+ same(
+ mls.dictOf(a.extensions).map((e) => [e.id, hex(e.data)]),
+ [
+ [1, hex(utf8("components"))],
+ [COMPONENT, hex(utf8("v1"))],
+ ],
+ "dictionary after update",
+ );
+ same(
+ a.extensions[a.extensions.length - 1].type,
+ EXT.APP_DATA_DICTIONARY,
+ "dictionary moved to the end",
+ );
+
+ // the caller's hook decides what an update means, on both sides
+ let append = (id, current, updates) =>
+ updates.reduce((acc, u) => Uint8Array.of(...acc, ...u), current || EMPTY);
+ let c6 = mls.createCommit(d, {
+ proposals: [
+ mls.proposeAppDataUpdate(COMPONENT, utf8("+2")),
+ mls.proposeAppDataUpdate(COMPONENT, utf8("+3")),
+ ],
+ appData: append,
+ });
+ d = c6.state;
+ throws(() => step(a, c6.message, { appData: () => undefined }), "hook rejects the update");
+ throws(() => step(a, c6.message), "default rule disagrees with the committer's hook");
+ [a, b] = apply([a, b], c6.message, { appData: append }).map((o) => o.state);
+ agree([a, b, d], "after hooked app_data_update");
+ same(mls.dictOf(b.extensions)[1].data, utf8("v1+2+3"), "hook result");
+
+ throws(
+ () =>
+ mls.createCommit(a, {
+ proposals: [
+ mls.proposeAppDataUpdate(COMPONENT, utf8("x")),
+ mls.proposeAppDataUpdate(COMPONENT, null),
+ ],
+ }),
+ "update and remove of one component",
+ );
+ throws(
+ () => mls.createCommit(a, { proposals: [mls.proposeAppDataUpdate(0x7777, null)] }),
+ "remove of an absent component",
+ );
+ let c7 = mls.createCommit(a, { proposals: [mls.proposeAppDataUpdate(COMPONENT, null)] });
+ a = c7.state;
+ [b, d] = apply([b, d], c7.message).map((o) => o.state);
+ agree([a, b, d], "after app_data_update remove");
+ same(
+ mls.dictOf(d.extensions).map((e) => e.id),
+ [1],
+ "dictionary after remove",
+ );
+
+ // with app_data_update required, GroupContextExtensions may not touch the dictionary
+ let swapped = [groupExtensions[0], { type: EXT.APP_DATA_DICTIONARY, data: mls.encodeDict([]) }];
+ throws(
+ () => mls.createCommit(a, { proposals: [mls.proposeGroupContextExtensions(swapped)] }),
+ "dictionary change through GroupContextExtensions",
+ );
+ throws(
+ () =>
+ mls.createCommit(a, {
+ proposals: [
+ mls.proposeGroupContextExtensions([...a.extensions, { type: 0xf2f0, data: EMPTY }]),
+ ],
+ }),
+ "context extension that is not required",
+ );
+ let extended = [
+ { type: EXT.REQUIRED_CAPABILITIES, data: requiredCapsFor([EXT.APP_DATA_DICTIONARY, 0xf2f0]) },
+ { type: 0xf2f0, data: utf8("marker") },
+ a.extensions.find((e) => e.type === EXT.APP_DATA_DICTIONARY),
+ ];
+ let c8 = mls.createCommit(b, {
+ proposals: [
+ mls.proposeGroupContextExtensions(extended),
+ mls.proposeAppEphemeral(COMPONENT, utf8("once")),
+ ],
+ });
+ b = c8.state;
+ outs = apply([a, d], c8.message);
+ same(
+ outs[0].ephemeral,
+ [{ componentId: COMPONENT, data: utf8("once"), sender: 1 }],
+ "app_ephemeral surfaced",
+ );
+ [a, d] = outs.map((o) => o.state);
+ agree([a, b, d], "after GroupContextExtensions");
+ same(a.extensions, extended, "extensions replaced");
+
+ // standalone self_remove, committed by somebody else
+ throws(() => mls.createCommit(a, { proposals: [mls.proposeSelfRemove()] }), "inline self_remove");
+ let leave = mls.createProposal(d, mls.proposeSelfRemove());
+ d = leave.state;
+ peek = mls.decodeMessage(leave.message);
+ ok(
+ peek.wireFormat === "public" && peek.contentType === "proposal",
+ "proposal is a PublicMessage",
+ );
+ let seen = [a, b].map((s) => step(s, leave.message));
+ ok(
+ seen.every(
+ (o) => o.type === "proposal" && o.sender === 3 && o.proposal.type === PROPOSAL.SELF_REMOVE,
+ ),
+ "proposal processed",
+ );
+ same(seen[0].ref, leave.ref, "proposal ref");
+ [a, b] = seen.map((o) => o.state);
+ ok(step(a, leave.message).state.pending.length === 1, "duplicate proposal cached once");
+ // the leaver's own commit simply leaves its self_remove out
+ let own = mls.createCommit(d);
+ ok(mls.members(own.state).length === 3, "leaver cannot commit its own self_remove");
+ throws(() => mls.createCommit(d, { refs: [leave.ref] }), "leaver forcing its own self_remove");
+ let c9 = mls.createCommit(a);
+ a = c9.state;
+ outs = apply([b], c9.message);
+ ok(
+ outs[0].proposals[0].byRef && outs[0].proposals[0].sender === 3,
+ "self_remove committed by reference",
+ );
+ [b] = outs.map((o) => o.state);
+ gone = step(d, c9.message);
+ ok(gone.removedSelf, "dave learns he left");
+ same(
+ mls.members(a).map((m) => m.index),
+ [0, 1],
+ "members after self_remove",
+ );
+ ok(a.tree.length === 3, "tree truncated");
+ agree([a, b], "after self_remove");
+ // a commit that references a proposal we never saw
+ throws(() => step(epoch1.c, c2.message) && step(c, c9.message), "unknown epoch", "epoch");
+
+ // structuredClone survives a round trip and keeps working
+ let a2 = structuredClone(a);
+ let b2 = structuredClone(b);
+ ok(snapshot(a2) === snapshot(a), "clone is equal");
+ let m = mls.encryptApplication(a2, utf8("cloned"));
+ same(step(b2, m.message).data, utf8("cloned"), "clone decrypts");
+ let c10 = mls.createCommit(b2, { proposals: [mls.proposeAdd(person("erin").kp.keyPackage)] });
+ let a3 = step(m.state, c10.message).state;
+ agree([a3, c10.state], "clones advance");
+ ok(mls.members(a3).length === 3 && mls.members(a3)[2].index === 2, "freed leaf reused");
+});
+
+test("pending proposals and competing branches", () => {
+ let [alice, bob, carol] = ["alice", "bob", "carol"].map(person);
+ let a = mls.createGroup({
+ groupId: utf8("branches"),
+ sig: alice.sig,
+ identity: "alice",
+ capabilities: caps,
+ extensions: groupExtensions,
+ });
+ let c1 = mls.createCommit(a, { proposals: [mls.proposeAdd(bob.kp.keyPackage)] });
+ a = c1.state;
+ let b = join(bob, c1.welcome);
+
+ // proposals by reference: an add from bob, committed by alice
+ let prop = mls.createProposal(b, mls.proposeAdd(carol.kp.keyPackage), { aad: utf8("p") });
+ b = prop.state;
+ let got = step(a, prop.message);
+ same(got.aad, utf8("p"), "proposal aad");
+ let held = got.state;
+ let c2 = mls.createCommit(held);
+ ok(c2.welcome && c2.addedRefs.length === 1, "pending add committed");
+ // the same state can also commit without it: two candidate branches from one parent
+ let alt = mls.createCommit(held, { includePending: false });
+ ok(!alt.welcome && mls.members(alt.state).length === 2, "includePending: false");
+ ok(
+ hex(alt.state.confirmedTranscriptHash) !== hex(c2.state.confirmedTranscriptHash),
+ "branches differ",
+ );
+ let viaAlt = step(b, alt.message).state;
+ let viaC2 = step(b, c2.message);
+ ok(
+ viaC2.proposals[0].byRef && viaC2.proposals[0].sender === 1,
+ "by-reference proposal keeps its sender",
+ );
+ agree([alt.state, viaAlt], "branch without the add");
+ agree([c2.state, viaC2.state], "branch with the add");
+ let c = join(carol, c2.welcome);
+ agree([c2.state, c], "carol on the chosen branch");
+
+ // a commit whose proposal we never saw
+ throws(() => step(a, mls.createCommit(b).message.slice(0, 20)), "truncated message");
+ let fresh = step(a, mls.createCommit(b, { includePending: false }).message).state;
+ ok(fresh.epoch === a.epoch + 1, "bob's own commit applies to alice's un-proposed state");
+ throws(
+ () => step(a, mls.createCommit(b).message),
+ "commit referencing an unseen proposal",
+ "missing-proposal",
+ );
+
+ // a path-less commit (adds only) is accepted, and the joiner works without a path secret
+ let dave = person("dave");
+ let lean = mls.createCommit(c2.state, {
+ proposals: [mls.proposeAdd(dave.kp.keyPackage)],
+ path: false,
+ });
+ let d = join(dave, lean.welcome);
+ let b3 = step(viaC2.state, lean.message).state;
+ agree([lean.state, b3, d], "path-less add");
+ let next = mls.createCommit(d);
+ agree(
+ [next.state, step(lean.state, next.message).state, step(b3, next.message).state],
+ "commit by the path-less joiner",
+ );
+
+ // handshake messages may also arrive as PrivateMessage
+ let content = {
+ groupId: b3.groupId,
+ epoch: b3.epoch,
+ sender: { type: 1, leafIndex: b3.leafIndex },
+ aad: EMPTY,
+ contentType: 2,
+ proposal: mls.proposeRemove(3),
+ };
+ let sealed = I.framing.sealPrivate(b3, content, {
+ signature: I.framing.signContent(b3, 2, content),
+ });
+ let viaPrivate = step(lean.state, sealed.message);
+ ok(
+ viaPrivate.type === "proposal" && viaPrivate.proposal.removed === 3,
+ "private proposal accepted",
+ );
+ let removal = mls.createCommit(viaPrivate.state);
+ ok(mls.members(removal.state).length === 3, "privately proposed remove committed");
+
+ // members must support every non-default proposal type a commit uses
+ let plain = person("plain");
+ plain.kp = mls.generateKeyPackage({ sig: plain.sig, identity: "plain" });
+ let g = mls.createGroup({
+ groupId: utf8("plain"),
+ sig: alice.sig,
+ identity: "alice",
+ capabilities: caps,
+ });
+ let both = mls.createCommit(g, { proposals: [mls.proposeAdd(plain.kp.keyPackage)] });
+ throws(
+ () => mls.createCommit(both.state, { proposals: [mls.proposeAppEphemeral(1, EMPTY)] }),
+ "proposal type one member lacks",
+ );
+ throws(
+ () => mls.createCommit(a, { proposals: [mls.proposeAdd(plain.kp.keyPackage)] }),
+ "add without the required capabilities",
+ );
+ throws(
+ () => mls.createCommit(a, { proposals: [mls.proposeAdd(bob.kp.keyPackage)] }),
+ "add of a current member",
+ );
+ throws(
+ () => mls.createCommit(a, { proposals: [mls.proposeRemove(0)] }),
+ "committer removing itself",
+ );
+ throws(
+ () => mls.createCommit(a, { proposals: [mls.proposeRemove(9)] }),
+ "remove of a blank leaf",
+ );
+ throws(
+ () => mls.createCommit(a, { proposals: [{ type: 0x0a0a, data: EMPTY }] }),
+ "unknown proposal type",
+ "unsupported",
+ );
+ throws(
+ () =>
+ mls.createCommit(a, {
+ proposals: [
+ { type: PROPOSAL.REINIT, groupId: EMPTY, version: 1, cipherSuite: 1, extensions: [] },
+ ],
+ }),
+ "reinit",
+ "unsupported",
+ );
+ let expired = mls.generateKeyPackage({
+ sig: mls.generateSignatureKeyPair(),
+ identity: "old",
+ capabilities: caps,
+ lifetime: { notBefore: 1, notAfter: 2 },
+ });
+ mls.createCommit(a, { proposals: [mls.proposeAdd(expired.keyPackage)] });
+ throws(
+ () =>
+ mls.createCommit(a, {
+ proposals: [mls.proposeAdd(expired.keyPackage)],
+ now: Math.floor(Date.now() / 1000),
+ }),
+ "expired key package with the lifetime check on",
+ "lifetime",
+ );
+});
+
+suite("self-tests", tests, (t) => {
+ try {
+ t.fn();
+ } catch (e) {
+ throw new Error(`${t.name}: ${e.message}`);
+ }
+});
+
+console.log("\n" + results.join("\n"));
+console.log(failed ? `\n${failed} failure(s)` : "\nall passed");
+process.exit(failed ? 1 : 0);