/** * Sealing a playlist blob, and opening one. * * `docs/playlists.md` §3.4 and §4.3. The node stores bytes it cannot read; this * is what makes them unreadable, and what makes them small enough to be worth * storing at all. * * The order is **compress → pad → seal**, and none of the three is optional: * * - *Compress*, because the payload is about as compressible as data gets — * eleven keys repeat on every track, the group id repeats across the whole * list, and path prefixes repeat per album. Three times is conservative for * this shape, and without it a realistic Favourites list does not fit under * any cap worth setting. * - *Pad*, because the ciphertext length otherwise tells the operator roughly * how many tracks this account has collected, and — once compression is in — * roughly how repetitive its paths are. 4 KB granularity, which is cheap and * is the only metadata this design leaks to a node that the node cannot * already see. * - *Seal*, last. Sealing first and compressing after would be compressing * ciphertext, which does not compress; padding outside the AEAD would be * padding nobody authenticated. * * `CompressionStream('deflate-raw')` is native in Chromium, Firefox 113+ and * Safari 16.4+, and therefore in the Electron build. No dependency: the one * compression helper this project has (`zipstream.js`) deliberately stores * rather than deflates, for a different reason — archives are already * compressed and this is text. * * No imports, and there must be none: the whole module is executed standalone * by `test_playlist_crypto.py` against node's own WebCrypto, and a crypto layer * that cannot be executed is a crypto layer nobody has checked. */ // The framing, ahead of the compressed bytes and inside the AEAD. One byte, so // a later change to the compression or the padding can be told from a blob // written before it rather than mis-parsed into nonsense. const FORMAT_V1 = 1; // The plaintext is padded up to a multiple of this before sealing. const PAD_TO = 4096; // AES-GCM. Ninety-six random bits, **never** a counter: two devices of one // account derive the same playlist key — that is the entire point — so a // counter would repeat, and a repeated nonce under one key is the one thing // GCM does not survive. Same reasoning already recorded for chat subkeys. const NONCE_BYTES = 12; /** * The playlist key, from the HKDF handle over the bundle key. * * A purpose-separated subkey rather than the bundle key reused with a different * AAD — the rule `groupbox.py` writes down for chunk keys, for the same reason. * v2 only: playlists are new, so there is no legacy blob and no v1 branch to * take by mistake. */ async function derivePlaylistKey(hkdfHandle) { return crypto.subtle.deriveKey( { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: new TextEncoder().encode('meshbay:playlists:v1'), }, hkdfHandle, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); } /** * What binds a sealed blob to its owner and its kind. * * `kind` is in here, not just `"playlists"`, so one playlist's body cannot be * served in place of another's — a substitution that a bare kind would have * allowed the moment there was more than one row. Mirrors * `groupbox.associated_data`. It cannot prevent rollback; §6.4 is what does. */ function associatedData(kind, userId) { return new TextEncoder().encode(`user_blob|${kind}|${userId}`); } async function _deflate(bytes) { const cs = new CompressionStream('deflate-raw'); const stream = new Blob([bytes]).stream().pipeThrough(cs); return new Uint8Array(await new Response(stream).arrayBuffer()); } async function _inflate(bytes) { const ds = new DecompressionStream('deflate-raw'); const stream = new Blob([bytes]).stream().pipeThrough(ds); return new Uint8Array(await new Response(stream).arrayBuffer()); } /** * One object as the bytes the node stores. * * Layout, all of it inside the AEAD except the nonce, which is prepended: * * nonce(12) || AES-GCM( fmt(1) || len(4, BE) || deflate(json) || zeros ) * * `len` is what lets the padding be stripped: the deflate stream would stop on * its own, but a trailing run of zeros is a valid thing for the next reader to * be confused by, and guessing is not a plan. */ async function seal(obj, kind, userId, key) { const json = new TextEncoder().encode(JSON.stringify(obj)); const body = await _deflate(json); const framed = new Uint8Array(5 + body.length); framed[0] = FORMAT_V1; new DataView(framed.buffer).setUint32(1, body.length, false); framed.set(body, 5); const padded = new Uint8Array(Math.ceil(framed.length / PAD_TO) * PAD_TO); padded.set(framed); const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES)); const ct = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: nonce, additionalData: associatedData(kind, userId) }, key, padded); const out = new Uint8Array(NONCE_BYTES + ct.byteLength); out.set(nonce); out.set(new Uint8Array(ct), NONCE_BYTES); return out; } /** * The object back, or a throw. * * Every failure here throws rather than returning null: a blob that does not * open is a wrong key, a tampered-with row or a kind served in place of * another, and none of those should be quietly indistinguishable from "this * account has no playlists yet" — which is what a `null` return would make them. */ async function open(bytes, kind, userId, key) { const buf = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); if (buf.length <= NONCE_BYTES) throw new Error('playlist blob is truncated'); const plain = new Uint8Array(await crypto.subtle.decrypt( { name: 'AES-GCM', iv: buf.slice(0, NONCE_BYTES), additionalData: associatedData(kind, userId) }, key, buf.slice(NONCE_BYTES))); if (plain[0] !== FORMAT_V1) { throw new Error(`playlist blob format ${plain[0]} is not readable here`); } const len = new DataView(plain.buffer, plain.byteOffset).getUint32(1, false); if (len > plain.length - 5) throw new Error('playlist blob length is wrong'); const json = await _inflate(plain.slice(5, 5 + len)); return JSON.parse(new TextDecoder().decode(json)); } export { FORMAT_V1, PAD_TO, NONCE_BYTES, derivePlaylistKey, associatedData, seal, open, };