aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_playlist_crypto.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_playlist_crypto.py')
-rw-r--r--packages/meshbay-hub/tests/test_playlist_crypto.py255
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"]