diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-16 11:37:58 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-16 11:37:58 +0200 |
| commit | ad8a08c713dea0e46800ea0aa6bfcf185a69e70e (patch) | |
| tree | a1f9289b3a55e30853d22a95a5ec858cf04ab272 /packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js | |
| parent | cde57423e04812fe2c939fdf983e3b45a77fd82d (diff) | |
| download | meshbay-ad8a08c713dea0e46800ea0aa6bfcf185a69e70e.tar.gz | |
playlists: merge rules, sealing, and the key
playlist-merge.js and playlist-crypto.js have no imports and are run by
their tests, which is the only real evidence this feature can have.
Two revision counters per playlist, not one: a rename on one device and
a track added on another both write n+1, and a single counter makes two
edits that do not overlap collide.
One Argon2 run at sign-in, two handles. The AES handle is imported
non-extractably, so nothing can be derived from it — hence a second
import of the same bytes as HKDF rather than a derivation.
Measured: ~270 bytes a track, deflate worth 4.5x on realistic data, so
the 1 MB body cap holds about 17000 tracks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js | 155 |
1 files changed, 155 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js new file mode 100644 index 0000000..0f41d1e --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js @@ -0,0 +1,155 @@ +/** + * 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, +}; |