diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_playlist_crypto.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_playlist_crypto.py | 255 |
1 files changed, 255 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_playlist_crypto.py b/packages/meshbay-hub/tests/test_playlist_crypto.py new file mode 100644 index 0000000..4ff3080 --- /dev/null +++ b/packages/meshbay-hub/tests/test_playlist_crypto.py @@ -0,0 +1,255 @@ +""" +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"] |