The page checks the room's record, loads the e2ee build, leaves a KeyPackage, follows the room service's log (web/room/e2ee.js), seals each frame before it is signed and published, and opens each object through §6 before it is decoded; objects of an epoch not yet reached wait. It shows the room is end-to-end encrypted, its epoch and its roster; no watching and no seeking back in such a room. The session hands each object's property list to a sink. wai-live-web's roomDescriptor gives the recording mode the record is checked against. Co-Authored-By: Claude Opus 5.5 <[email protected]>
221 lines
9.4 KiB
JavaScript
221 lines
9.4 KiB
JavaScript
// The member of an end-to-end encrypted room (WAI extensions/confidentiality.md), in a page.
|
|
//
|
|
// The room service is the room's carrier of MLS messages (confidentiality §5.1): its log
|
|
// holds the clock's lifecycle records and every proposal, GroupInfo, commit and Welcome, in
|
|
// one order. The member reads it from its first entry. It checks every lifecycle record as
|
|
// the clock's (its signature, its room, its place in the clock's chain) before the room's
|
|
// MLS model takes it, and hands every MLS message to the wasm member
|
|
// (joule_moq_web_e2ee's E2eeMember), which checks each as §5 requires. When the room calls
|
|
// on it, it creates the group or commits (§5.4, §5.5), and applies what it made only once
|
|
// the log returns it in its order. The member's MLS state and every key it derives live in
|
|
// the wasm member's memory alone; nothing of them is written to storage or sent anywhere.
|
|
|
|
const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
const unhex = (h) => new Uint8Array((h.match(/../g) || []).map((x) => parseInt(x, 16)));
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
// An epoch's keys are deleted this long after the next epoch opens (§5.8): objects of an
|
|
// epoch still in flight open meanwhile, and nothing of it opens after.
|
|
export const FORGET_AFTER_MS = 15000;
|
|
// A post whose answer was lost is dropped after this, if the log has not returned it.
|
|
const STAGED_MAX_MS = 15000;
|
|
|
|
export class E2ee {
|
|
// o: { moq, wai, api, log, room, clock, seed, suite, role, grantOf, refreshGrants,
|
|
// onRecord, onChange }
|
|
constructor(o) {
|
|
this.moq = o.moq;
|
|
this.wai = o.wai;
|
|
this.api = o.api;
|
|
this.log = o.log;
|
|
this.room = o.room;
|
|
this.clock = o.clock;
|
|
this.role = o.role;
|
|
this.grantOf = o.grantOf;
|
|
this.refreshGrants = o.refreshGrants;
|
|
this.onRecord = o.onRecord;
|
|
this.onChange = o.onChange || (() => {});
|
|
this.suite = o.suite;
|
|
this.member = new o.moq.E2eeMember(unhex(o.room), unhex(o.clock), o.seed, o.suite);
|
|
this.at = 0; // the next entry of the log
|
|
this.chain = { seq: 0, prev: "00".repeat(32) }; // the clock's chain as the log carried it
|
|
this.stopped = null;
|
|
this.posting = false;
|
|
this.stagedAt = null;
|
|
this.stats = {
|
|
log: 0, events: 0, mls: {}, refused: {}, posts: { ok: 0, refused: 0 },
|
|
epochs: [], joined: null, removed: null, forgotten: [],
|
|
sealed: { audio: 0, video: 0 }, sealed_by_epoch: {}, unkeyed: { audio: 0, video: 0 },
|
|
};
|
|
}
|
|
|
|
// The KeyPackage the clock proposes this principal with (the keying draft §4.7), left
|
|
// with the room service before the page publishes.
|
|
async keyPackage() {
|
|
const kp = this.member.keyPackage();
|
|
const r = await this.api("/mls/key-package", { room: this.room, key_package: hex(kp) });
|
|
if (r.status !== 200) throw new Error(`the room kept no KeyPackage: ${r.status}${r.body && r.body.error ? ` (${r.body.error})` : ""}`);
|
|
}
|
|
|
|
stop(why) {
|
|
this.stopped = why;
|
|
this.log(`end-to-end encryption: ${why}`);
|
|
}
|
|
|
|
// Read the room's log to its end, again and again, while `alive()`.
|
|
async follow(alive) {
|
|
while (alive() && !this.stopped) {
|
|
let r;
|
|
try { r = await this.api(`/mls?room=${this.room}&from=${this.at}`); } catch { await sleep(1000); continue; }
|
|
if (r.status === 410) { this.stop("the room was replaced"); break; }
|
|
if (r.status !== 200 || !r.body) { await sleep(1000); continue; }
|
|
for (const e of r.body.entries) {
|
|
if (e.seq !== this.at) return this.stop(`the log skipped from ${this.at} to ${e.seq}`);
|
|
if (e.event) await this.onEvent(unhex(e.event)); else this.onMls(unhex(e.mls));
|
|
if (this.stopped) return;
|
|
this.at++;
|
|
this.stats.log++;
|
|
}
|
|
if (this.member.staged() && this.stagedAt != null && performance.now() - this.stagedAt > STAGED_MAX_MS) {
|
|
this.member.withdraw();
|
|
this.stagedAt = null;
|
|
this.log("a post the log never returned: dropped");
|
|
}
|
|
await this.act();
|
|
this.onChange();
|
|
}
|
|
}
|
|
|
|
// A lifecycle record on the log: the clock's, in its chain's order, then the room's
|
|
// model takes what it says of who holds keys (§5.4).
|
|
async onEvent(bytes) {
|
|
const v = JSON.parse(this.wai.eventRead(bytes));
|
|
if (!v.ok) return this.stop(`a lifecycle record on the log does not verify: ${v.reason}`);
|
|
if (v.principal !== this.clock || v.room !== this.room) return this.stop("a record on the log is not the room clock's");
|
|
if (v.seq !== this.chain.seq || v.prev !== this.chain.prev) return this.stop(`the clock's chain breaks on the log at ${v.seq}`);
|
|
this.chain = { seq: v.seq + 1, prev: v.digest };
|
|
this.stats.events++;
|
|
this.onRecord(bytes);
|
|
const p = v.payload || {};
|
|
let ev = null;
|
|
if (v.kind_name === "join") {
|
|
let g = this.grantOf(p.detail.grant);
|
|
if (!g) { await this.refreshGrants(); g = this.grantOf(p.detail.grant); }
|
|
if (!g) return this.stop(`a join under a grant the room does not list (${String(p.detail.grant).slice(0, 16)})`);
|
|
// A keyed grant may subscribe or may publish (§2).
|
|
ev = { event: "join", subject: p.subject, keyed: !!g.subscribe || g.publish !== 0 };
|
|
} else if (v.kind_name === "leave") {
|
|
ev = { event: "leave", subject: p.subject, reason: p.detail.reason };
|
|
} else if (v.kind_name === "grant_exchange") {
|
|
return this.stop("a grant exchange, which this page does not follow");
|
|
}
|
|
if (ev) this.member.event(JSON.stringify(ev));
|
|
}
|
|
|
|
// An MLS message on the log, checked and applied by the wasm member.
|
|
onMls(frame) {
|
|
const v = JSON.parse(this.member.receive(frame));
|
|
if (!v.ok) {
|
|
const why = v.reason || v.error;
|
|
this.stats.refused[why] = (this.stats.refused[why] || 0) + 1;
|
|
this.log(`an MLS message refused: ${why}${v.detail ? ` (${v.detail})` : ""}`);
|
|
return;
|
|
}
|
|
this.stats.mls[v.received] = (this.stats.mls[v.received] || 0) + 1;
|
|
if (v.received === "epoch" || v.received === "joined" || v.received === "created") {
|
|
this.stagedAt = null;
|
|
this.entered(v.epoch, v.received);
|
|
} else if (v.received === "removed") {
|
|
this.stats.removed = v.epoch;
|
|
this.log(`removed from the room's group by the commit that opens epoch ${v.epoch}: no later epoch opens here`);
|
|
}
|
|
}
|
|
|
|
entered(epoch, how) {
|
|
if (this.stats.joined == null) this.stats.joined = epoch;
|
|
const last = this.stats.epochs[this.stats.epochs.length - 1];
|
|
this.stats.epochs.push({ epoch, how, at: Math.round(performance.now()) });
|
|
this.log(`epoch ${epoch} (${how})`);
|
|
if (last) {
|
|
setTimeout(() => {
|
|
this.member.forget(BigInt(last.epoch));
|
|
this.stats.forgotten.push(last.epoch);
|
|
}, FORGET_AFTER_MS);
|
|
}
|
|
}
|
|
|
|
// Create the group, or commit, when the room calls on this member; the log's order
|
|
// decides whether it is applied.
|
|
async act() {
|
|
if (this.role !== "publisher" || this.stats.removed != null || this.posting) return;
|
|
const m = this.member;
|
|
let frames = null;
|
|
let what = "";
|
|
if (m.owesCreate()) {
|
|
frames = [m.stageCreate()];
|
|
what = "GroupInfo";
|
|
} else if (m.shouldCommit()) {
|
|
const c = m.stageCommit();
|
|
const w = c.welcome();
|
|
frames = [c.commit(), ...(w ? [w] : [])];
|
|
what = `commit opening epoch ${c.epoch}`;
|
|
c.free();
|
|
}
|
|
if (!frames) return;
|
|
this.posting = true;
|
|
this.stagedAt = performance.now();
|
|
try {
|
|
const r = await this.api("/mls/post", { room: this.room, frames: frames.map(hex) });
|
|
if (r.status === 200) {
|
|
this.stats.posts.ok++;
|
|
this.log(`posted the ${what}`);
|
|
} else {
|
|
this.stats.posts.refused++;
|
|
m.withdraw();
|
|
this.stagedAt = null;
|
|
this.log(`the ${what} was not carried: ${r.status}${r.body && r.body.error ? ` (${r.body.error})` : ""}`);
|
|
}
|
|
} catch (e) {
|
|
// The answer was lost: the log says whether it was carried.
|
|
this.log(`posting the ${what}: ${e.message || e}`);
|
|
} finally {
|
|
this.posting = false;
|
|
}
|
|
}
|
|
|
|
get joined() {
|
|
return this.member.epoch() !== undefined;
|
|
}
|
|
|
|
// Whether an object refused epoch_not_joined may yet open: a publisher that has not
|
|
// joined the group, and was not removed from it, holds it until its Welcome.
|
|
get awaitingWelcome() {
|
|
return this.role === "publisher" && !this.joined && this.stats.removed == null && !this.stopped;
|
|
}
|
|
|
|
// Seal an encoded frame's envelope as object 0 of group `group` of this member's track
|
|
// in namespace `ns` (§4.9), or null while it holds no epoch.
|
|
seal(ns, group, priority, envelope, digest, timeoutMs) {
|
|
if (!this.joined) return null;
|
|
const s = this.member.seal(JSON.stringify(ns), "0", BigInt(group), 0n, priority, envelope, digest, true, BigInt(timeoutMs));
|
|
const kid = s.kid;
|
|
const out = { props: s.properties(), payload: s.payload(), kid, epoch: Number(kid >> 24n) };
|
|
s.free();
|
|
this.stats.sealed_by_epoch[out.epoch] = (this.stats.sealed_by_epoch[out.epoch] || 0) + 1;
|
|
return out;
|
|
}
|
|
|
|
// Open an object through the receiving checks (§6): { ok, payload, epoch, leaf } or
|
|
// { ok: false, reason } or { ok: false, pending: <epoch> }.
|
|
open(ns, name, group, object, priority, props, payload) {
|
|
const o = this.member.open(JSON.stringify(ns), name, BigInt(group), BigInt(object), priority, props || new Uint8Array(0), payload);
|
|
const v = JSON.parse(o.json);
|
|
if (v.ok) v.payload = o.payload();
|
|
o.free();
|
|
return v;
|
|
}
|
|
|
|
state() {
|
|
return JSON.parse(this.member.state());
|
|
}
|
|
}
|