aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 11:37:58 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 11:37:58 +0200
commitad8a08c713dea0e46800ea0aa6bfcf185a69e70e (patch)
treea1f9289b3a55e30853d22a95a5ec858cf04ab272 /packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js
parentcde57423e04812fe2c939fdf983e3b45a77fd82d (diff)
downloadmeshbay-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.js155
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,
+};