@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.cjs CHANGED
@@ -4123,7 +4123,7 @@ async function readGitBranch(dir) {
4123
4123
  // src/server.ts
4124
4124
  var import_node_ws = require("@hono/node-ws");
4125
4125
  var import_client = require("@temporalio/client");
4126
- var import_crypto12 = require("crypto");
4126
+ var import_crypto13 = require("crypto");
4127
4127
  var import_events = require("events");
4128
4128
  var import_fs28 = require("fs");
4129
4129
  var import_promises7 = require("fs/promises");
@@ -11284,6 +11284,250 @@ var RuntimeStore = class _RuntimeStore {
11284
11284
  }
11285
11285
  };
11286
11286
 
11287
+ // src/e2ee/noise.ts
11288
+ var import_crypto10 = require("crypto");
11289
+ var NOISE_PROTOCOL_NAME = "Noise_IKpsk1_25519_ChaChaPoly_SHA256";
11290
+ var PAIR_PROLOGUE = Buffer.from("threadbase-e2ee/1 pair", "utf-8");
11291
+ var DHLEN = 32;
11292
+ var HASHLEN = 32;
11293
+ var TAGLEN = 16;
11294
+ var NOISE_MESSAGE_1_OVERHEAD = DHLEN + DHLEN + TAGLEN + TAGLEN;
11295
+ var NOISE_MESSAGE_2_OVERHEAD = DHLEN + TAGLEN;
11296
+ var NOISE_MAX_MESSAGE_BYTES = 4096;
11297
+ var NoiseError = class extends Error {
11298
+ constructor(message) {
11299
+ super(message);
11300
+ this.name = "NoiseError";
11301
+ }
11302
+ };
11303
+ function generateKeyPair() {
11304
+ const { publicKey, privateKey } = (0, import_crypto10.generateKeyPairSync)("x25519");
11305
+ return { publicKey, privateKey, publicKeyRaw: rawPublicKey(publicKey) };
11306
+ }
11307
+ function keyPairFrom(privateKey) {
11308
+ const publicKey = (0, import_crypto10.createPublicKey)(privateKey);
11309
+ return { publicKey, privateKey, publicKeyRaw: rawPublicKey(publicKey) };
11310
+ }
11311
+ function rawPublicKey(key) {
11312
+ const pub = key.type === "public" ? key : (0, import_crypto10.createPublicKey)(key);
11313
+ const jwk = pub.export({ format: "jwk" });
11314
+ if (jwk.crv !== "X25519" || typeof jwk.x !== "string") {
11315
+ throw new NoiseError("Not an X25519 public key");
11316
+ }
11317
+ return Buffer.from(jwk.x, "base64url");
11318
+ }
11319
+ function publicKeyFromRaw(raw) {
11320
+ if (raw.length !== DHLEN) {
11321
+ throw new NoiseError(`X25519 public key must be ${DHLEN} bytes, got ${raw.length}`);
11322
+ }
11323
+ try {
11324
+ return (0, import_crypto10.createPublicKey)({
11325
+ key: { kty: "OKP", crv: "X25519", x: raw.toString("base64url") },
11326
+ format: "jwk"
11327
+ });
11328
+ } catch {
11329
+ throw new NoiseError("Invalid X25519 public key");
11330
+ }
11331
+ }
11332
+ function pskFromPairToken(token) {
11333
+ return (0, import_crypto10.createHash)("sha256").update("threadbase-e2ee/1 psk", "utf-8").update(token, "utf-8").digest();
11334
+ }
11335
+ function chachaNonce(n) {
11336
+ const nonce = Buffer.alloc(12);
11337
+ nonce.writeBigUInt64LE(n, 4);
11338
+ return nonce;
11339
+ }
11340
+ var CipherState = class {
11341
+ k = null;
11342
+ n = 0n;
11343
+ initializeKey(key) {
11344
+ this.k = key;
11345
+ this.n = 0n;
11346
+ }
11347
+ hasKey() {
11348
+ return this.k !== null;
11349
+ }
11350
+ encryptWithAd(ad, plaintext) {
11351
+ if (!this.k) return plaintext;
11352
+ const cipher = (0, import_crypto10.createCipheriv)("chacha20-poly1305", this.k, chachaNonce(this.n), {
11353
+ authTagLength: TAGLEN
11354
+ });
11355
+ cipher.setAAD(ad, { plaintextLength: plaintext.length });
11356
+ const out = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
11357
+ this.n += 1n;
11358
+ return out;
11359
+ }
11360
+ decryptWithAd(ad, ciphertext) {
11361
+ if (!this.k) return ciphertext;
11362
+ if (ciphertext.length < TAGLEN) throw new NoiseError("Ciphertext shorter than its tag");
11363
+ const body = ciphertext.subarray(0, ciphertext.length - TAGLEN);
11364
+ const tag = ciphertext.subarray(ciphertext.length - TAGLEN);
11365
+ const decipher = (0, import_crypto10.createDecipheriv)("chacha20-poly1305", this.k, chachaNonce(this.n), {
11366
+ authTagLength: TAGLEN
11367
+ });
11368
+ decipher.setAAD(ad, { plaintextLength: body.length });
11369
+ decipher.setAuthTag(tag);
11370
+ let out;
11371
+ try {
11372
+ out = Buffer.concat([decipher.update(body), decipher.final()]);
11373
+ } catch {
11374
+ throw new NoiseError("Decryption failed");
11375
+ }
11376
+ this.n += 1n;
11377
+ return out;
11378
+ }
11379
+ };
11380
+ var SymmetricState = class {
11381
+ ck;
11382
+ h;
11383
+ cipher = new CipherState();
11384
+ constructor(protocolName) {
11385
+ const name = Buffer.from(protocolName, "utf-8");
11386
+ this.h = name.length <= HASHLEN ? Buffer.concat([name, Buffer.alloc(HASHLEN - name.length)]) : (0, import_crypto10.createHash)("sha256").update(name).digest();
11387
+ this.ck = Buffer.from(this.h);
11388
+ this.cipher.initializeKey(null);
11389
+ }
11390
+ /**
11391
+ * Noise's HKDF (spec §4.3) is RFC 5869 with `salt = chaining_key`,
11392
+ * `ikm = input_key_material` and an EMPTY info, so Node's `hkdfSync` is
11393
+ * exactly it rather than approximately it. Using the platform's HKDF removes
11394
+ * the HMAC chain that would otherwise be the easiest thing here to get
11395
+ * subtly wrong.
11396
+ */
11397
+ hkdf(ikm, outputs) {
11398
+ const raw = Buffer.from((0, import_crypto10.hkdfSync)("sha256", ikm, this.ck, Buffer.alloc(0), HASHLEN * outputs));
11399
+ const out = [];
11400
+ for (let i = 0; i < outputs; i++) out.push(raw.subarray(i * HASHLEN, (i + 1) * HASHLEN));
11401
+ return out;
11402
+ }
11403
+ mixKey(ikm) {
11404
+ const [ck, tempK] = this.hkdf(ikm, 2);
11405
+ this.ck = ck;
11406
+ this.cipher.initializeKey(tempK);
11407
+ }
11408
+ mixHash(data) {
11409
+ this.h = (0, import_crypto10.createHash)("sha256").update(this.h).update(data).digest();
11410
+ }
11411
+ /** Spec §5.2. Used only by the `psk` token. */
11412
+ mixKeyAndHash(ikm) {
11413
+ const [ck, tempH, tempK] = this.hkdf(ikm, 3);
11414
+ this.ck = ck;
11415
+ this.mixHash(tempH);
11416
+ this.cipher.initializeKey(tempK);
11417
+ }
11418
+ encryptAndHash(plaintext) {
11419
+ const ciphertext = this.cipher.encryptWithAd(this.h, plaintext);
11420
+ this.mixHash(ciphertext);
11421
+ return ciphertext;
11422
+ }
11423
+ decryptAndHash(ciphertext) {
11424
+ const plaintext = this.cipher.decryptWithAd(this.h, ciphertext);
11425
+ this.mixHash(ciphertext);
11426
+ return plaintext;
11427
+ }
11428
+ /** The transcript hash. Both sides must arrive at the same value. */
11429
+ handshakeHash() {
11430
+ return Buffer.from(this.h);
11431
+ }
11432
+ /** Spec §5.2 `Split()`: the two directional transport keys. */
11433
+ split() {
11434
+ const [k1, k2] = this.hkdf(Buffer.alloc(0), 2);
11435
+ return { k1: Buffer.from(k1), k2: Buffer.from(k2) };
11436
+ }
11437
+ };
11438
+ function dh(privateKey, publicKey) {
11439
+ return (0, import_crypto10.diffieHellman)({ privateKey, publicKey });
11440
+ }
11441
+ function readMessage1(args) {
11442
+ assertMessageSize(args.message1, NOISE_MESSAGE_1_OVERHEAD);
11443
+ const state = new SymmetricState(NOISE_PROTOCOL_NAME);
11444
+ state.mixHash(args.prologue ?? PAIR_PROLOGUE);
11445
+ state.mixHash(args.staticKeyPair.publicKeyRaw);
11446
+ const reRaw = args.message1.subarray(0, DHLEN);
11447
+ const re = publicKeyFromRaw(reRaw);
11448
+ state.mixHash(reRaw);
11449
+ state.mixKey(reRaw);
11450
+ state.mixKey(dh(args.staticKeyPair.privateKey, re));
11451
+ const encryptedStatic = args.message1.subarray(DHLEN, DHLEN + DHLEN + TAGLEN);
11452
+ const initiatorStaticPub = state.decryptAndHash(encryptedStatic);
11453
+ const rs = publicKeyFromRaw(initiatorStaticPub);
11454
+ state.mixKey(dh(args.staticKeyPair.privateKey, rs));
11455
+ state.mixKeyAndHash(args.psk);
11456
+ const payload = state.decryptAndHash(args.message1.subarray(DHLEN + DHLEN + TAGLEN));
11457
+ return {
11458
+ symmetric: state,
11459
+ initiatorStaticPub,
11460
+ payload,
11461
+ initiatorEphemeral: re,
11462
+ initiatorStatic: rs,
11463
+ staticKeyPair: args.staticKeyPair
11464
+ };
11465
+ }
11466
+ function writeMessage2(state, responsePayload, ephemeral) {
11467
+ const e = ephemeral ?? generateKeyPair();
11468
+ state.symmetric.mixHash(e.publicKeyRaw);
11469
+ state.symmetric.mixKey(e.publicKeyRaw);
11470
+ state.symmetric.mixKey(dh(e.privateKey, state.initiatorEphemeral));
11471
+ state.symmetric.mixKey(dh(e.privateKey, state.initiatorStatic));
11472
+ const encryptedPayload = state.symmetric.encryptAndHash(responsePayload);
11473
+ return {
11474
+ message2: Buffer.concat([e.publicKeyRaw, encryptedPayload]),
11475
+ keys: finish(state.symmetric)
11476
+ };
11477
+ }
11478
+ function finish(state) {
11479
+ const { k1, k2 } = state.split();
11480
+ return { handshakeHash: state.handshakeHash(), clientToServer: k1, serverToClient: k2 };
11481
+ }
11482
+ function assertMessageSize(message, minimum) {
11483
+ if (message.length < minimum) {
11484
+ throw new NoiseError(`Handshake message too short: ${message.length} < ${minimum}`);
11485
+ }
11486
+ if (message.length > NOISE_MAX_MESSAGE_BYTES) {
11487
+ throw new NoiseError(`Handshake message too large: ${message.length}`);
11488
+ }
11489
+ }
11490
+
11491
+ // src/e2ee/pair-request.ts
11492
+ var E2EE_EXCHANGE_VERSION = 1;
11493
+ var MAX_BASE64_CHARS = Math.ceil(NOISE_MAX_MESSAGE_BYTES * 4 / 3) + 4;
11494
+ var E2eeRequestError = class extends Error {
11495
+ /** Stable code for the client, so a version mismatch is not a parse failure. */
11496
+ code;
11497
+ constructor(code, message) {
11498
+ super(message);
11499
+ this.name = "E2eeRequestError";
11500
+ this.code = code;
11501
+ }
11502
+ };
11503
+ function parseE2eeRequest(raw) {
11504
+ if (raw === void 0 || raw === null) return null;
11505
+ if (typeof raw !== "object" || Array.isArray(raw)) {
11506
+ throw new E2eeRequestError("E2EE_MALFORMED", "e2ee must be an object");
11507
+ }
11508
+ const { v, noise } = raw;
11509
+ if (typeof v !== "number" || !Number.isInteger(v)) {
11510
+ throw new E2eeRequestError("E2EE_MALFORMED", "e2ee.v must be an integer");
11511
+ }
11512
+ if (v !== E2EE_EXCHANGE_VERSION) {
11513
+ throw new E2eeRequestError(
11514
+ "E2EE_VERSION_UNSUPPORTED",
11515
+ `e2ee.v ${v} is not supported; this server speaks ${E2EE_EXCHANGE_VERSION}`
11516
+ );
11517
+ }
11518
+ if (typeof noise !== "string") {
11519
+ throw new E2eeRequestError("E2EE_MALFORMED", "e2ee.noise must be a base64 string");
11520
+ }
11521
+ if (noise.length > MAX_BASE64_CHARS) {
11522
+ throw new E2eeRequestError("E2EE_MALFORMED", "e2ee.noise is too large");
11523
+ }
11524
+ const message1 = Buffer.from(noise, "base64");
11525
+ if (message1.length === 0) {
11526
+ throw new E2eeRequestError("E2EE_MALFORMED", "e2ee.noise is not valid base64");
11527
+ }
11528
+ return { version: v, message1 };
11529
+ }
11530
+
11287
11531
  // src/external-tails.ts
11288
11532
  var import_fs16 = require("fs");
11289
11533
  var EXTERNAL_TAIL_RECENCY_MS = RESUME_BUSY_WINDOW_MS;
@@ -11455,7 +11699,7 @@ var ExternalTailManager = class {
11455
11699
  };
11456
11700
 
11457
11701
  // src/pair-store.ts
11458
- var import_crypto10 = require("crypto");
11702
+ var import_crypto11 = require("crypto");
11459
11703
  var DEFAULT_TTL_SECONDS = 180;
11460
11704
  var SWEEP_INTERVAL_MS = 6e4;
11461
11705
  var PairTokenStore = class {
@@ -11470,7 +11714,7 @@ var PairTokenStore = class {
11470
11714
  }
11471
11715
  }
11472
11716
  mint() {
11473
- const token = `pt_${(0, import_crypto10.randomBytes)(16).toString("hex")}`;
11717
+ const token = `pt_${(0, import_crypto11.randomBytes)(16).toString("hex")}`;
11474
11718
  const expiresAt = Date.now() + this.ttlMs;
11475
11719
  this.current = { token, expiresAt, used: false };
11476
11720
  return {
@@ -12656,7 +12900,7 @@ function createApiDeps(deps) {
12656
12900
  }
12657
12901
 
12658
12902
  // src/services/cache-integrity/cacheIntegrityMonitor.ts
12659
- var import_crypto11 = require("crypto");
12903
+ var import_crypto12 = require("crypto");
12660
12904
  var import_fs23 = require("fs");
12661
12905
 
12662
12906
  // src/services/cache-integrity/alertStore.ts
@@ -12721,7 +12965,7 @@ function envInt(name, fallback) {
12721
12965
  }
12722
12966
  function fingerprintOf(ids) {
12723
12967
  const sorted = [...ids].sort();
12724
- return `sha256:${(0, import_crypto11.createHash)("sha256").update(sorted.join("\n")).digest("hex")}`;
12968
+ return `sha256:${(0, import_crypto12.createHash)("sha256").update(sorted.join("\n")).digest("hex")}`;
12725
12969
  }
12726
12970
  var CacheIntegrityMonitor = class {
12727
12971
  constructor(cache, wsHub, log10, cacheDir, rescan, runDuringReset) {
@@ -15227,7 +15471,7 @@ var StreamerServer = class {
15227
15471
  runtimeStore = null;
15228
15472
  // Identifies this streamer run. A registry row carrying a different id is a
15229
15473
  // session that outlived the process that started it.
15230
- streamerInstanceId = (0, import_crypto12.randomUUID)();
15474
+ streamerInstanceId = (0, import_crypto13.randomUUID)();
15231
15475
  cacheMetadataRepo = null;
15232
15476
  // Push registration + delivery state (C7). Null when the cache DB failed to
15233
15477
  // open — registration then degrades to a no-op rather than 500ing.
@@ -16355,6 +16599,14 @@ var StreamerServer = class {
16355
16599
  json(res, 400, { error: "Missing token or clientPublicKey" });
16356
16600
  return;
16357
16601
  }
16602
+ let e2eeRequest;
16603
+ try {
16604
+ e2eeRequest = parseE2eeRequest(body?.e2ee);
16605
+ } catch (err) {
16606
+ const e = err;
16607
+ json(res, 400, { error: e.message, code: e.code });
16608
+ return;
16609
+ }
16358
16610
  const precheck = this.pairTokens.wouldConsume(token);
16359
16611
  if (!precheck.ok) {
16360
16612
  if (precheck.reason === "used") {
@@ -16366,6 +16618,25 @@ var StreamerServer = class {
16366
16618
  json(res, 401, { error: `Pair token ${precheck.reason}` });
16367
16619
  return;
16368
16620
  }
16621
+ let handshake = null;
16622
+ if (e2eeRequest) {
16623
+ try {
16624
+ handshake = readMessage1({
16625
+ staticKeyPair: keyPairFrom(loadOrCreateServerIdentity().privateKey),
16626
+ // The pair token binds this handshake to the scanned QR. Derivation
16627
+ // is specified in design.md §2.4 and pinned by a committed vector
16628
+ // that tb-mobile checks against independently.
16629
+ psk: pskFromPairToken(token),
16630
+ message1: e2eeRequest.message1
16631
+ });
16632
+ } catch {
16633
+ json(res, 400, {
16634
+ error: "E2EE handshake failed. Scan a fresh pairing code and try again.",
16635
+ code: "E2EE_HANDSHAKE_FAILED"
16636
+ });
16637
+ return;
16638
+ }
16639
+ }
16369
16640
  let sealed;
16370
16641
  try {
16371
16642
  sealed = seal(this.apiKey, clientPublicKey);
@@ -16389,14 +16660,59 @@ var StreamerServer = class {
16389
16660
  try {
16390
16661
  const name = typeof body?.deviceName === "string" ? body.deviceName.slice(0, 100) : null;
16391
16662
  const preset = body?.readOnly === true ? "read-only" : "full";
16392
- device = this.devicesRepo?.register({ publicKey: clientPublicKey, name, preset }) ?? null;
16663
+ device = this.devicesRepo?.register({
16664
+ publicKey: clientPublicKey,
16665
+ name,
16666
+ preset,
16667
+ // Recorded only when the handshake authenticated it. The static key
16668
+ // comes out of the transcript, never off the wire as a claim — that
16669
+ // is the difference between a device identified by a key it proved
16670
+ // it holds and one identified by a string it sent.
16671
+ //
16672
+ // Setting it also sets `e2ee_required`, so a device that has once
16673
+ // paired encrypted is pinned and never served plaintext again.
16674
+ ...handshake && {
16675
+ e2eeStaticPub: handshake.initiatorStaticPub.toString("base64"),
16676
+ e2eeVersion: E2EE_EXCHANGE_VERSION
16677
+ }
16678
+ }) ?? null;
16393
16679
  } catch (err) {
16394
16680
  this.log.warn("[pair] device registration failed; pairing continues", {
16395
16681
  event: "pair.device_register_failed",
16396
16682
  err
16397
16683
  });
16398
16684
  }
16685
+ let e2eeResponse = null;
16686
+ if (handshake) {
16687
+ try {
16688
+ const { message2 } = writeMessage2(
16689
+ handshake,
16690
+ Buffer.from(
16691
+ JSON.stringify({
16692
+ v: E2EE_EXCHANGE_VERSION,
16693
+ deviceId: device?.deviceId ?? null,
16694
+ serverVersion: getVersion(),
16695
+ // Always true on this path: completing a handshake is what pins
16696
+ // the device, and design.md §6.3 says nothing a client sends
16697
+ // ever clears it.
16698
+ e2eeRequired: true
16699
+ }),
16700
+ "utf-8"
16701
+ )
16702
+ );
16703
+ e2eeResponse = { v: E2EE_EXCHANGE_VERSION, noise: message2.toString("base64") };
16704
+ } catch (err) {
16705
+ this.log.warn("[pair] E2EE response could not be written; pairing continues", {
16706
+ event: "pair.e2ee_response_failed",
16707
+ err
16708
+ });
16709
+ }
16710
+ }
16399
16711
  json(res, 200, {
16712
+ // Unchanged and still sent on the E2EE path, deliberately. An older app
16713
+ // is the only thing that can read these and it cannot be force-updated
16714
+ // (docs/compatibility/tb-mobile.md); a new app ignores them and uses the
16715
+ // Noise result. The response grows a field, it never loses one.
16400
16716
  ciphertext: sealed.ciphertext,
16401
16717
  nonce: sealed.nonce,
16402
16718
  ephemeralPublicKey: sealed.ephemeralPublicKey,
@@ -16419,7 +16735,11 @@ var StreamerServer = class {
16419
16735
  deviceId: device.deviceId,
16420
16736
  deviceToken: device.deviceToken,
16421
16737
  capabilities: device.capabilities
16422
- }
16738
+ },
16739
+ // Additive. Absent means this pairing is plaintext — either the client
16740
+ // never asked, or writing the reply failed — and a client that asked for
16741
+ // encryption must treat its absence as a refusal, not as consent.
16742
+ ...e2eeResponse && { e2ee: e2eeResponse }
16423
16743
  });
16424
16744
  }
16425
16745
  rotateApiKey() {