""" Sealing a playlist, and the three properties that make it worth doing. `playlist-crypto.js` is executed here against node's own WebCrypto — the real AES-GCM, the real deflate — because a crypto layer that cannot be executed is a crypto layer nobody has checked. What is asserted is not "it round-trips". That is the easy part and it would still pass with the compression, the padding and the AAD all removed. What is asserted is that each of the three is actually doing its job: - **compression**, because without it a realistic Favourites list does not fit under any cap worth setting (docs/playlists.md §4.2); - **padding**, because the ciphertext length otherwise counts somebody's tracks for the operator; - **the AAD**, because it names the *kind*, and without that one playlist's body can be served in place of another's. """ import json import shutil import subprocess from pathlib import Path import pytest STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" SRC = STATIC / "playlist-crypto.js" pytestmark = pytest.mark.skipif( shutil.which("node") is None or not SRC.exists(), reason="node or the SPA sources are not available") PRELUDE = """ const KEY = await crypto.subtle.importKey( 'raw', new Uint8Array(32).fill(7), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); const OTHER = await crypto.subtle.importKey( 'raw', new Uint8Array(32).fill(9), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); // A playlist the shape people actually have. The repetition is real — a few // groups, albums of a dozen tracks, artists and paths that recur — but **every // `id` is a distinct random hex string**, because a blake3 hash is, and 64 // random hex characters per entry do not compress at all. // // The first version of this fixture repeated one id on every track and // measured deflate at 23x. That number was a property of the fixture, not of a // playlist, and sizing the caps on it would have sized them on nothing. const hex = (n) => Array.from( crypto.getRandomValues(new Uint8Array(n / 2)), (b) => b.toString(16).padStart(2, '0')).join(''); const GROUPS = Array.from({ length: 4 }, () => hex(32)); const bigBody = (n) => ({ v: 1, id: 'favorites', rev: 3, device: 'dev-a', tracks: Array.from({ length: n }, (_, i) => { const al = Math.floor(i / 12); return { id: hex(64), g: GROUPS[al % GROUPS.length], hv: 1, n: `${String((i % 12) + 1).padStart(2, '0')} - Un titre de morceau ${i}.flac`, s: 30000000 + i, p: `Quelque Artiste ${al % 40}/Un Album Assez Long ${al} (2019)`, t: `Un titre de morceau ${i}`, a: `Quelque Artiste ${al % 40}`, b: `Un Album Assez Long ${al}`, d: 180 + (i % 200), tn: (i % 12) + 1, }; }), }); """ def _run(tmp_path, body): src = SRC.read_text().replace("export {", "const _unused_export = {") script = tmp_path / "case.mjs" script.write_text(f"{src}\n{PRELUDE}\n{body}\n") out = subprocess.run(["node", str(script)], capture_output=True, text=True, timeout=60) assert out.returncode == 0, out.stderr return json.loads(out.stdout) def test_a_playlist_round_trips(tmp_path): out = _run(tmp_path, """ const body = bigBody(12); const sealed = await seal(body, 'playlist:favorites', 'u1', KEY); const back = await open(sealed, 'playlist:favorites', 'u1', KEY); console.log(JSON.stringify({ same: JSON.stringify(back) === JSON.stringify(body), tracks: back.tracks.length, title: back.tracks[3].t, })); """) assert out["same"] and out["tracks"] == 12 assert out["title"] == "Un titre de morceau 3" def test_compression_is_worth_the_factor_the_caps_assume(tmp_path): """The caps are sized on deflate being worth about three on this shape. If it stopped being applied — or the payload stopped being compressible — a realistic Favourites list would silently stop fitting.""" out = _run(tmp_path, """ const body = bigBody(2000); const raw = new TextEncoder().encode(JSON.stringify(body)).length; const sealed = (await seal(body, 'playlist:favorites', 'u1', KEY)).length; console.log(JSON.stringify({ raw, sealed, ratio: raw / sealed })); """) # Measured at about 4.5x on this shape (~270 bytes a track raw, ~60 sealed). # The caps in webrtc_server.py are sized on three, so this is the margin. assert out["ratio"] > 3, ( f"deflate is only worth {out['ratio']:.1f}x here; the caps in " f"webrtc_server.py assume about three") # 2000 tracks is a realistic Favourites list and must fit the 1 MB body cap # with room to spare — at this ratio the cap is reached around 17000. assert out["sealed"] < 1024 * 1024 / 4 def test_the_sealed_length_is_padded_and_so_counts_nothing(tmp_path): """Two playlists whose sizes differ by hundreds of tracks may share a ciphertext length; one that differs by one track always does. What the operator can read off the row is a 4 KB bucket, not a track count.""" out = _run(tmp_path, """ const lens = {}; for (const n of [1, 2, 10, 30]) { lens[n] = (await seal(bigBody(n), 'playlist:x', 'u1', KEY)).length; } console.log(JSON.stringify(lens)); """) lengths = {int(k): v for k, v in out.items()} # Every length is the padding granularity plus the nonce and the tag. for n, size in lengths.items(): assert (size - 12 - 16) % 4096 == 0, f"{n} tracks sealed to {size}" assert lengths[1] == lengths[2] == lengths[10], ( "small playlists must be indistinguishable by length") def test_the_same_playlist_sealed_twice_is_two_different_blobs(tmp_path): """A random nonce, never a counter: two devices of one account derive the *same* key, so a counter would repeat — and a repeated nonce under one key is the one thing GCM does not survive.""" out = _run(tmp_path, """ const body = bigBody(5); const a = await seal(body, 'playlist:x', 'u1', KEY); const b = await seal(body, 'playlist:x', 'u1', KEY); const nonceA = Array.from(a.slice(0, 12)).join(','); const nonceB = Array.from(b.slice(0, 12)).join(','); console.log(JSON.stringify({ sameNonce: nonceA === nonceB, sameBytes: Array.from(a).join(',') === Array.from(b).join(','), allZero: nonceA === new Array(12).fill(0).join(','), })); """) assert out["sameNonce"] is False assert out["sameBytes"] is False assert out["allZero"] is False def test_the_plaintext_is_not_in_the_sealed_bytes(tmp_path): """The claim, checked rather than assumed.""" out = _run(tmp_path, """ const body = bigBody(20); const sealed = await seal(body, 'playlist:x', 'u1', KEY); const hay = new TextDecoder('latin1').decode(sealed); console.log(JSON.stringify({ leaks: ['Quelque Artiste', 'Un Album Assez Long', 'tracks', 'favorites'] .filter((s) => hay.includes(s)), })); """) assert out["leaks"] == [] def test_another_key_cannot_open_it(tmp_path): out = _run(tmp_path, """ const sealed = await seal(bigBody(3), 'playlist:x', 'u1', KEY); let opened = true; try { await open(sealed, 'playlist:x', 'u1', OTHER); } catch { opened = false; } console.log(JSON.stringify({ opened })); """) assert out["opened"] is False def test_one_playlists_body_cannot_be_served_as_another(tmp_path): """Why the AAD names the kind and not just "playlists". The moment there was more than one row, a bare kind let a node hand back the wrong body — authenticated, and wrong.""" out = _run(tmp_path, """ const sealed = await seal(bigBody(3), 'playlist:evening', 'u1', KEY); let asOther = true; try { await open(sealed, 'playlist:drive', 'u1', KEY); } catch { asOther = false; } let asManifest = true; try { await open(sealed, 'playlists', 'u1', KEY); } catch { asManifest = false; } console.log(JSON.stringify({ asOther, asManifest })); """) assert out["asOther"] is False assert out["asManifest"] is False def test_another_account_is_named_in_the_aad_too(tmp_path): out = _run(tmp_path, """ const sealed = await seal(bigBody(3), 'playlist:x', 'alice', KEY); let opened = true; try { await open(sealed, 'playlist:x', 'bob', KEY); } catch { opened = false; } console.log(JSON.stringify({ opened })); """) assert out["opened"] is False def test_a_flipped_byte_is_refused_rather_than_returned(tmp_path): out = _run(tmp_path, """ const sealed = await seal(bigBody(3), 'playlist:x', 'u1', KEY); sealed[40] ^= 0xff; let opened = true; try { await open(sealed, 'playlist:x', 'u1', KEY); } catch { opened = false; } console.log(JSON.stringify({ opened })); """) assert out["opened"] is False def test_an_unreadable_blob_throws_rather_than_reading_as_empty(tmp_path): """A wrong key, a tampered row and a kind served in place of another must not be quietly indistinguishable from "this account has no playlists yet" — which is exactly what returning null would make them.""" out = _run(tmp_path, """ const cases = {}; for (const [name, bytes] of Object.entries({ empty: new Uint8Array(0), short: new Uint8Array(8), garbage: crypto.getRandomValues(new Uint8Array(200)), })) { try { await open(bytes, 'playlists', 'u1', KEY); cases[name] = 'returned'; } catch { cases[name] = 'threw'; } } console.log(JSON.stringify(cases)); """) assert out == {"empty": "threw", "short": "threw", "garbage": "threw"} def test_a_blob_from_a_future_format_is_refused_by_name(tmp_path): """The framing byte exists 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.""" out = _run(tmp_path, """ // A well-formed blob whose framing byte says a version this build does // not know: sealed correctly, so it is the framing check that refuses it. const padded = new Uint8Array(PAD_TO); padded[0] = 99; const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES)); const ct = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: nonce, additionalData: associatedData('playlists', 'u1') }, KEY, padded); const blob = new Uint8Array(NONCE_BYTES + ct.byteLength); blob.set(nonce); blob.set(new Uint8Array(ct), NONCE_BYTES); let why = null; try { await open(blob, 'playlists', 'u1', KEY); } catch (e) { why = e.message; } console.log(JSON.stringify({ why })); """) assert out["why"] and "format 99" in out["why"]