@threadbase-sh/streamer 1.58.3 → 1.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -11239,6 +11239,259 @@ var RuntimeStore = class _RuntimeStore {
11239
11239
  }
11240
11240
  };
11241
11241
 
11242
+ // src/e2ee/noise.ts
11243
+ import {
11244
+ createCipheriv,
11245
+ createDecipheriv,
11246
+ createHash as createHash4,
11247
+ createPrivateKey as createPrivateKey2,
11248
+ createPublicKey as createPublicKey2,
11249
+ diffieHellman,
11250
+ generateKeyPairSync as generateKeyPairSync2,
11251
+ hkdfSync
11252
+ } from "crypto";
11253
+ var NOISE_PROTOCOL_NAME = "Noise_IKpsk1_25519_ChaChaPoly_SHA256";
11254
+ var PAIR_PROLOGUE = Buffer.from("threadbase-e2ee/1 pair", "utf-8");
11255
+ var DHLEN = 32;
11256
+ var HASHLEN = 32;
11257
+ var TAGLEN = 16;
11258
+ var NOISE_MESSAGE_1_OVERHEAD = DHLEN + DHLEN + TAGLEN + TAGLEN;
11259
+ var NOISE_MESSAGE_2_OVERHEAD = DHLEN + TAGLEN;
11260
+ var NOISE_MAX_MESSAGE_BYTES = 4096;
11261
+ var NoiseError = class extends Error {
11262
+ constructor(message) {
11263
+ super(message);
11264
+ this.name = "NoiseError";
11265
+ }
11266
+ };
11267
+ function generateKeyPair() {
11268
+ const { publicKey, privateKey } = generateKeyPairSync2("x25519");
11269
+ return { publicKey, privateKey, publicKeyRaw: rawPublicKey(publicKey) };
11270
+ }
11271
+ function keyPairFrom(privateKey) {
11272
+ const publicKey = createPublicKey2(privateKey);
11273
+ return { publicKey, privateKey, publicKeyRaw: rawPublicKey(publicKey) };
11274
+ }
11275
+ function rawPublicKey(key) {
11276
+ const pub = key.type === "public" ? key : createPublicKey2(key);
11277
+ const jwk = pub.export({ format: "jwk" });
11278
+ if (jwk.crv !== "X25519" || typeof jwk.x !== "string") {
11279
+ throw new NoiseError("Not an X25519 public key");
11280
+ }
11281
+ return Buffer.from(jwk.x, "base64url");
11282
+ }
11283
+ function publicKeyFromRaw(raw) {
11284
+ if (raw.length !== DHLEN) {
11285
+ throw new NoiseError(`X25519 public key must be ${DHLEN} bytes, got ${raw.length}`);
11286
+ }
11287
+ try {
11288
+ return createPublicKey2({
11289
+ key: { kty: "OKP", crv: "X25519", x: raw.toString("base64url") },
11290
+ format: "jwk"
11291
+ });
11292
+ } catch {
11293
+ throw new NoiseError("Invalid X25519 public key");
11294
+ }
11295
+ }
11296
+ function pskFromPairToken(token) {
11297
+ return createHash4("sha256").update("threadbase-e2ee/1 psk", "utf-8").update(token, "utf-8").digest();
11298
+ }
11299
+ function chachaNonce(n) {
11300
+ const nonce = Buffer.alloc(12);
11301
+ nonce.writeBigUInt64LE(n, 4);
11302
+ return nonce;
11303
+ }
11304
+ var CipherState = class {
11305
+ k = null;
11306
+ n = 0n;
11307
+ initializeKey(key) {
11308
+ this.k = key;
11309
+ this.n = 0n;
11310
+ }
11311
+ hasKey() {
11312
+ return this.k !== null;
11313
+ }
11314
+ encryptWithAd(ad, plaintext) {
11315
+ if (!this.k) return plaintext;
11316
+ const cipher = createCipheriv("chacha20-poly1305", this.k, chachaNonce(this.n), {
11317
+ authTagLength: TAGLEN
11318
+ });
11319
+ cipher.setAAD(ad, { plaintextLength: plaintext.length });
11320
+ const out = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
11321
+ this.n += 1n;
11322
+ return out;
11323
+ }
11324
+ decryptWithAd(ad, ciphertext) {
11325
+ if (!this.k) return ciphertext;
11326
+ if (ciphertext.length < TAGLEN) throw new NoiseError("Ciphertext shorter than its tag");
11327
+ const body = ciphertext.subarray(0, ciphertext.length - TAGLEN);
11328
+ const tag = ciphertext.subarray(ciphertext.length - TAGLEN);
11329
+ const decipher = createDecipheriv("chacha20-poly1305", this.k, chachaNonce(this.n), {
11330
+ authTagLength: TAGLEN
11331
+ });
11332
+ decipher.setAAD(ad, { plaintextLength: body.length });
11333
+ decipher.setAuthTag(tag);
11334
+ let out;
11335
+ try {
11336
+ out = Buffer.concat([decipher.update(body), decipher.final()]);
11337
+ } catch {
11338
+ throw new NoiseError("Decryption failed");
11339
+ }
11340
+ this.n += 1n;
11341
+ return out;
11342
+ }
11343
+ };
11344
+ var SymmetricState = class {
11345
+ ck;
11346
+ h;
11347
+ cipher = new CipherState();
11348
+ constructor(protocolName) {
11349
+ const name = Buffer.from(protocolName, "utf-8");
11350
+ this.h = name.length <= HASHLEN ? Buffer.concat([name, Buffer.alloc(HASHLEN - name.length)]) : createHash4("sha256").update(name).digest();
11351
+ this.ck = Buffer.from(this.h);
11352
+ this.cipher.initializeKey(null);
11353
+ }
11354
+ /**
11355
+ * Noise's HKDF (spec §4.3) is RFC 5869 with `salt = chaining_key`,
11356
+ * `ikm = input_key_material` and an EMPTY info, so Node's `hkdfSync` is
11357
+ * exactly it rather than approximately it. Using the platform's HKDF removes
11358
+ * the HMAC chain that would otherwise be the easiest thing here to get
11359
+ * subtly wrong.
11360
+ */
11361
+ hkdf(ikm, outputs) {
11362
+ const raw = Buffer.from(hkdfSync("sha256", ikm, this.ck, Buffer.alloc(0), HASHLEN * outputs));
11363
+ const out = [];
11364
+ for (let i = 0; i < outputs; i++) out.push(raw.subarray(i * HASHLEN, (i + 1) * HASHLEN));
11365
+ return out;
11366
+ }
11367
+ mixKey(ikm) {
11368
+ const [ck, tempK] = this.hkdf(ikm, 2);
11369
+ this.ck = ck;
11370
+ this.cipher.initializeKey(tempK);
11371
+ }
11372
+ mixHash(data) {
11373
+ this.h = createHash4("sha256").update(this.h).update(data).digest();
11374
+ }
11375
+ /** Spec §5.2. Used only by the `psk` token. */
11376
+ mixKeyAndHash(ikm) {
11377
+ const [ck, tempH, tempK] = this.hkdf(ikm, 3);
11378
+ this.ck = ck;
11379
+ this.mixHash(tempH);
11380
+ this.cipher.initializeKey(tempK);
11381
+ }
11382
+ encryptAndHash(plaintext) {
11383
+ const ciphertext = this.cipher.encryptWithAd(this.h, plaintext);
11384
+ this.mixHash(ciphertext);
11385
+ return ciphertext;
11386
+ }
11387
+ decryptAndHash(ciphertext) {
11388
+ const plaintext = this.cipher.decryptWithAd(this.h, ciphertext);
11389
+ this.mixHash(ciphertext);
11390
+ return plaintext;
11391
+ }
11392
+ /** The transcript hash. Both sides must arrive at the same value. */
11393
+ handshakeHash() {
11394
+ return Buffer.from(this.h);
11395
+ }
11396
+ /** Spec §5.2 `Split()`: the two directional transport keys. */
11397
+ split() {
11398
+ const [k1, k2] = this.hkdf(Buffer.alloc(0), 2);
11399
+ return { k1: Buffer.from(k1), k2: Buffer.from(k2) };
11400
+ }
11401
+ };
11402
+ function dh(privateKey, publicKey) {
11403
+ return diffieHellman({ privateKey, publicKey });
11404
+ }
11405
+ function readMessage1(args) {
11406
+ assertMessageSize(args.message1, NOISE_MESSAGE_1_OVERHEAD);
11407
+ const state = new SymmetricState(NOISE_PROTOCOL_NAME);
11408
+ state.mixHash(args.prologue ?? PAIR_PROLOGUE);
11409
+ state.mixHash(args.staticKeyPair.publicKeyRaw);
11410
+ const reRaw = args.message1.subarray(0, DHLEN);
11411
+ const re = publicKeyFromRaw(reRaw);
11412
+ state.mixHash(reRaw);
11413
+ state.mixKey(reRaw);
11414
+ state.mixKey(dh(args.staticKeyPair.privateKey, re));
11415
+ const encryptedStatic = args.message1.subarray(DHLEN, DHLEN + DHLEN + TAGLEN);
11416
+ const initiatorStaticPub = state.decryptAndHash(encryptedStatic);
11417
+ const rs = publicKeyFromRaw(initiatorStaticPub);
11418
+ state.mixKey(dh(args.staticKeyPair.privateKey, rs));
11419
+ state.mixKeyAndHash(args.psk);
11420
+ const payload = state.decryptAndHash(args.message1.subarray(DHLEN + DHLEN + TAGLEN));
11421
+ return {
11422
+ symmetric: state,
11423
+ initiatorStaticPub,
11424
+ payload,
11425
+ initiatorEphemeral: re,
11426
+ initiatorStatic: rs,
11427
+ staticKeyPair: args.staticKeyPair
11428
+ };
11429
+ }
11430
+ function writeMessage2(state, responsePayload, ephemeral) {
11431
+ const e = ephemeral ?? generateKeyPair();
11432
+ state.symmetric.mixHash(e.publicKeyRaw);
11433
+ state.symmetric.mixKey(e.publicKeyRaw);
11434
+ state.symmetric.mixKey(dh(e.privateKey, state.initiatorEphemeral));
11435
+ state.symmetric.mixKey(dh(e.privateKey, state.initiatorStatic));
11436
+ const encryptedPayload = state.symmetric.encryptAndHash(responsePayload);
11437
+ return {
11438
+ message2: Buffer.concat([e.publicKeyRaw, encryptedPayload]),
11439
+ keys: finish(state.symmetric)
11440
+ };
11441
+ }
11442
+ function finish(state) {
11443
+ const { k1, k2 } = state.split();
11444
+ return { handshakeHash: state.handshakeHash(), clientToServer: k1, serverToClient: k2 };
11445
+ }
11446
+ function assertMessageSize(message, minimum) {
11447
+ if (message.length < minimum) {
11448
+ throw new NoiseError(`Handshake message too short: ${message.length} < ${minimum}`);
11449
+ }
11450
+ if (message.length > NOISE_MAX_MESSAGE_BYTES) {
11451
+ throw new NoiseError(`Handshake message too large: ${message.length}`);
11452
+ }
11453
+ }
11454
+
11455
+ // src/e2ee/pair-request.ts
11456
+ var E2EE_EXCHANGE_VERSION = 1;
11457
+ var MAX_BASE64_CHARS = Math.ceil(NOISE_MAX_MESSAGE_BYTES * 4 / 3) + 4;
11458
+ var E2eeRequestError = class extends Error {
11459
+ /** Stable code for the client, so a version mismatch is not a parse failure. */
11460
+ code;
11461
+ constructor(code, message) {
11462
+ super(message);
11463
+ this.name = "E2eeRequestError";
11464
+ this.code = code;
11465
+ }
11466
+ };
11467
+ function parseE2eeRequest(raw) {
11468
+ if (raw === void 0 || raw === null) return null;
11469
+ if (typeof raw !== "object" || Array.isArray(raw)) {
11470
+ throw new E2eeRequestError("E2EE_MALFORMED", "e2ee must be an object");
11471
+ }
11472
+ const { v, noise } = raw;
11473
+ if (typeof v !== "number" || !Number.isInteger(v)) {
11474
+ throw new E2eeRequestError("E2EE_MALFORMED", "e2ee.v must be an integer");
11475
+ }
11476
+ if (v !== E2EE_EXCHANGE_VERSION) {
11477
+ throw new E2eeRequestError(
11478
+ "E2EE_VERSION_UNSUPPORTED",
11479
+ `e2ee.v ${v} is not supported; this server speaks ${E2EE_EXCHANGE_VERSION}`
11480
+ );
11481
+ }
11482
+ if (typeof noise !== "string") {
11483
+ throw new E2eeRequestError("E2EE_MALFORMED", "e2ee.noise must be a base64 string");
11484
+ }
11485
+ if (noise.length > MAX_BASE64_CHARS) {
11486
+ throw new E2eeRequestError("E2EE_MALFORMED", "e2ee.noise is too large");
11487
+ }
11488
+ const message1 = Buffer.from(noise, "base64");
11489
+ if (message1.length === 0) {
11490
+ throw new E2eeRequestError("E2EE_MALFORMED", "e2ee.noise is not valid base64");
11491
+ }
11492
+ return { version: v, message1 };
11493
+ }
11494
+
11242
11495
  // src/external-tails.ts
11243
11496
  import { statSync as statSync6 } from "fs";
11244
11497
  var EXTERNAL_TAIL_RECENCY_MS = RESUME_BUSY_WINDOW_MS;
@@ -12613,7 +12866,7 @@ function createApiDeps(deps) {
12613
12866
  }
12614
12867
 
12615
12868
  // src/services/cache-integrity/cacheIntegrityMonitor.ts
12616
- import { createHash as createHash4 } from "crypto";
12869
+ import { createHash as createHash5 } from "crypto";
12617
12870
  import { existsSync as existsSync12 } from "fs";
12618
12871
 
12619
12872
  // src/services/cache-integrity/alertStore.ts
@@ -12678,7 +12931,7 @@ function envInt(name, fallback) {
12678
12931
  }
12679
12932
  function fingerprintOf(ids) {
12680
12933
  const sorted = [...ids].sort();
12681
- return `sha256:${createHash4("sha256").update(sorted.join("\n")).digest("hex")}`;
12934
+ return `sha256:${createHash5("sha256").update(sorted.join("\n")).digest("hex")}`;
12682
12935
  }
12683
12936
  var CacheIntegrityMonitor = class {
12684
12937
  constructor(cache, wsHub, log10, cacheDir, rescan, runDuringReset) {
@@ -16312,6 +16565,14 @@ var StreamerServer = class {
16312
16565
  json(res, 400, { error: "Missing token or clientPublicKey" });
16313
16566
  return;
16314
16567
  }
16568
+ let e2eeRequest;
16569
+ try {
16570
+ e2eeRequest = parseE2eeRequest(body?.e2ee);
16571
+ } catch (err) {
16572
+ const e = err;
16573
+ json(res, 400, { error: e.message, code: e.code });
16574
+ return;
16575
+ }
16315
16576
  const precheck = this.pairTokens.wouldConsume(token);
16316
16577
  if (!precheck.ok) {
16317
16578
  if (precheck.reason === "used") {
@@ -16323,6 +16584,25 @@ var StreamerServer = class {
16323
16584
  json(res, 401, { error: `Pair token ${precheck.reason}` });
16324
16585
  return;
16325
16586
  }
16587
+ let handshake = null;
16588
+ if (e2eeRequest) {
16589
+ try {
16590
+ handshake = readMessage1({
16591
+ staticKeyPair: keyPairFrom(loadOrCreateServerIdentity().privateKey),
16592
+ // The pair token binds this handshake to the scanned QR. Derivation
16593
+ // is specified in design.md §2.4 and pinned by a committed vector
16594
+ // that tb-mobile checks against independently.
16595
+ psk: pskFromPairToken(token),
16596
+ message1: e2eeRequest.message1
16597
+ });
16598
+ } catch {
16599
+ json(res, 400, {
16600
+ error: "E2EE handshake failed. Scan a fresh pairing code and try again.",
16601
+ code: "E2EE_HANDSHAKE_FAILED"
16602
+ });
16603
+ return;
16604
+ }
16605
+ }
16326
16606
  let sealed;
16327
16607
  try {
16328
16608
  sealed = seal(this.apiKey, clientPublicKey);
@@ -16346,14 +16626,59 @@ var StreamerServer = class {
16346
16626
  try {
16347
16627
  const name = typeof body?.deviceName === "string" ? body.deviceName.slice(0, 100) : null;
16348
16628
  const preset = body?.readOnly === true ? "read-only" : "full";
16349
- device = this.devicesRepo?.register({ publicKey: clientPublicKey, name, preset }) ?? null;
16629
+ device = this.devicesRepo?.register({
16630
+ publicKey: clientPublicKey,
16631
+ name,
16632
+ preset,
16633
+ // Recorded only when the handshake authenticated it. The static key
16634
+ // comes out of the transcript, never off the wire as a claim — that
16635
+ // is the difference between a device identified by a key it proved
16636
+ // it holds and one identified by a string it sent.
16637
+ //
16638
+ // Setting it also sets `e2ee_required`, so a device that has once
16639
+ // paired encrypted is pinned and never served plaintext again.
16640
+ ...handshake && {
16641
+ e2eeStaticPub: handshake.initiatorStaticPub.toString("base64"),
16642
+ e2eeVersion: E2EE_EXCHANGE_VERSION
16643
+ }
16644
+ }) ?? null;
16350
16645
  } catch (err) {
16351
16646
  this.log.warn("[pair] device registration failed; pairing continues", {
16352
16647
  event: "pair.device_register_failed",
16353
16648
  err
16354
16649
  });
16355
16650
  }
16651
+ let e2eeResponse = null;
16652
+ if (handshake) {
16653
+ try {
16654
+ const { message2 } = writeMessage2(
16655
+ handshake,
16656
+ Buffer.from(
16657
+ JSON.stringify({
16658
+ v: E2EE_EXCHANGE_VERSION,
16659
+ deviceId: device?.deviceId ?? null,
16660
+ serverVersion: getVersion(),
16661
+ // Always true on this path: completing a handshake is what pins
16662
+ // the device, and design.md §6.3 says nothing a client sends
16663
+ // ever clears it.
16664
+ e2eeRequired: true
16665
+ }),
16666
+ "utf-8"
16667
+ )
16668
+ );
16669
+ e2eeResponse = { v: E2EE_EXCHANGE_VERSION, noise: message2.toString("base64") };
16670
+ } catch (err) {
16671
+ this.log.warn("[pair] E2EE response could not be written; pairing continues", {
16672
+ event: "pair.e2ee_response_failed",
16673
+ err
16674
+ });
16675
+ }
16676
+ }
16356
16677
  json(res, 200, {
16678
+ // Unchanged and still sent on the E2EE path, deliberately. An older app
16679
+ // is the only thing that can read these and it cannot be force-updated
16680
+ // (docs/compatibility/tb-mobile.md); a new app ignores them and uses the
16681
+ // Noise result. The response grows a field, it never loses one.
16357
16682
  ciphertext: sealed.ciphertext,
16358
16683
  nonce: sealed.nonce,
16359
16684
  ephemeralPublicKey: sealed.ephemeralPublicKey,
@@ -16376,7 +16701,11 @@ var StreamerServer = class {
16376
16701
  deviceId: device.deviceId,
16377
16702
  deviceToken: device.deviceToken,
16378
16703
  capabilities: device.capabilities
16379
- }
16704
+ },
16705
+ // Additive. Absent means this pairing is plaintext — either the client
16706
+ // never asked, or writing the reply failed — and a client that asked for
16707
+ // encryption must treat its absence as a refusal, not as consent.
16708
+ ...e2eeResponse && { e2ee: e2eeResponse }
16380
16709
  });
16381
16710
  }
16382
16711
  rotateApiKey() {