aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_playlist_crypto.py
blob: 4ff30807074eadf1dc721f9f3c0953a10ff5c233 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
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"]