summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js
blob: 0f41d1e4134cdce2bdcd2ef401d28327cbcdca55 (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
/**
 * 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,
};