@threadbase-sh/streamer 1.58.3 → 1.59.1
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/cli.cjs +373 -39
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +345 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +350 -7
- package/dist/index.js.map +1 -1
- package/dist/migrations/016_normalize_blank_project_path.sql +7 -0
- package/package.json +1 -1
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
|
|
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");
|
|
@@ -6979,7 +6979,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
6979
6979
|
"SELECT id, project_path, project_id, last_activity FROM conversation_meta WHERE project_path IS NOT NULL"
|
|
6980
6980
|
),
|
|
6981
6981
|
hasOrphanProjectId: db.prepare(
|
|
6982
|
-
"SELECT 1 FROM conversation_meta WHERE project_id IS NULL AND project_path IS NOT NULL LIMIT 1"
|
|
6982
|
+
"SELECT 1 FROM conversation_meta WHERE project_id IS NULL AND project_path IS NOT NULL AND project_path != '' LIMIT 1"
|
|
6983
6983
|
),
|
|
6984
6984
|
popularProjects: db.prepare(
|
|
6985
6985
|
`SELECT project_path, project_name, COUNT(*) as cnt
|
|
@@ -7576,10 +7576,11 @@ var ConversationCache = class _ConversationCache {
|
|
|
7576
7576
|
continue;
|
|
7577
7577
|
}
|
|
7578
7578
|
const scannerMetaJson = JSON.stringify(m);
|
|
7579
|
+
const projectPath = m.projectPath?.trim() || null;
|
|
7579
7580
|
this.stmts.upsertFull.run({
|
|
7580
7581
|
id,
|
|
7581
7582
|
file_path: canonicalPath,
|
|
7582
|
-
project_path:
|
|
7583
|
+
project_path: projectPath,
|
|
7583
7584
|
project_name: m.projectName ?? null,
|
|
7584
7585
|
title: m.title ?? m.sessionName ?? m.projectName ?? null,
|
|
7585
7586
|
model: m.model ?? null,
|
|
@@ -11284,6 +11285,250 @@ var RuntimeStore = class _RuntimeStore {
|
|
|
11284
11285
|
}
|
|
11285
11286
|
};
|
|
11286
11287
|
|
|
11288
|
+
// src/e2ee/noise.ts
|
|
11289
|
+
var import_crypto10 = require("crypto");
|
|
11290
|
+
var NOISE_PROTOCOL_NAME = "Noise_IKpsk1_25519_ChaChaPoly_SHA256";
|
|
11291
|
+
var PAIR_PROLOGUE = Buffer.from("threadbase-e2ee/1 pair", "utf-8");
|
|
11292
|
+
var DHLEN = 32;
|
|
11293
|
+
var HASHLEN = 32;
|
|
11294
|
+
var TAGLEN = 16;
|
|
11295
|
+
var NOISE_MESSAGE_1_OVERHEAD = DHLEN + DHLEN + TAGLEN + TAGLEN;
|
|
11296
|
+
var NOISE_MESSAGE_2_OVERHEAD = DHLEN + TAGLEN;
|
|
11297
|
+
var NOISE_MAX_MESSAGE_BYTES = 4096;
|
|
11298
|
+
var NoiseError = class extends Error {
|
|
11299
|
+
constructor(message) {
|
|
11300
|
+
super(message);
|
|
11301
|
+
this.name = "NoiseError";
|
|
11302
|
+
}
|
|
11303
|
+
};
|
|
11304
|
+
function generateKeyPair() {
|
|
11305
|
+
const { publicKey, privateKey } = (0, import_crypto10.generateKeyPairSync)("x25519");
|
|
11306
|
+
return { publicKey, privateKey, publicKeyRaw: rawPublicKey(publicKey) };
|
|
11307
|
+
}
|
|
11308
|
+
function keyPairFrom(privateKey) {
|
|
11309
|
+
const publicKey = (0, import_crypto10.createPublicKey)(privateKey);
|
|
11310
|
+
return { publicKey, privateKey, publicKeyRaw: rawPublicKey(publicKey) };
|
|
11311
|
+
}
|
|
11312
|
+
function rawPublicKey(key) {
|
|
11313
|
+
const pub = key.type === "public" ? key : (0, import_crypto10.createPublicKey)(key);
|
|
11314
|
+
const jwk = pub.export({ format: "jwk" });
|
|
11315
|
+
if (jwk.crv !== "X25519" || typeof jwk.x !== "string") {
|
|
11316
|
+
throw new NoiseError("Not an X25519 public key");
|
|
11317
|
+
}
|
|
11318
|
+
return Buffer.from(jwk.x, "base64url");
|
|
11319
|
+
}
|
|
11320
|
+
function publicKeyFromRaw(raw) {
|
|
11321
|
+
if (raw.length !== DHLEN) {
|
|
11322
|
+
throw new NoiseError(`X25519 public key must be ${DHLEN} bytes, got ${raw.length}`);
|
|
11323
|
+
}
|
|
11324
|
+
try {
|
|
11325
|
+
return (0, import_crypto10.createPublicKey)({
|
|
11326
|
+
key: { kty: "OKP", crv: "X25519", x: raw.toString("base64url") },
|
|
11327
|
+
format: "jwk"
|
|
11328
|
+
});
|
|
11329
|
+
} catch {
|
|
11330
|
+
throw new NoiseError("Invalid X25519 public key");
|
|
11331
|
+
}
|
|
11332
|
+
}
|
|
11333
|
+
function pskFromPairToken(token) {
|
|
11334
|
+
return (0, import_crypto10.createHash)("sha256").update("threadbase-e2ee/1 psk", "utf-8").update(token, "utf-8").digest();
|
|
11335
|
+
}
|
|
11336
|
+
function chachaNonce(n) {
|
|
11337
|
+
const nonce = Buffer.alloc(12);
|
|
11338
|
+
nonce.writeBigUInt64LE(n, 4);
|
|
11339
|
+
return nonce;
|
|
11340
|
+
}
|
|
11341
|
+
var CipherState = class {
|
|
11342
|
+
k = null;
|
|
11343
|
+
n = 0n;
|
|
11344
|
+
initializeKey(key) {
|
|
11345
|
+
this.k = key;
|
|
11346
|
+
this.n = 0n;
|
|
11347
|
+
}
|
|
11348
|
+
hasKey() {
|
|
11349
|
+
return this.k !== null;
|
|
11350
|
+
}
|
|
11351
|
+
encryptWithAd(ad, plaintext) {
|
|
11352
|
+
if (!this.k) return plaintext;
|
|
11353
|
+
const cipher = (0, import_crypto10.createCipheriv)("chacha20-poly1305", this.k, chachaNonce(this.n), {
|
|
11354
|
+
authTagLength: TAGLEN
|
|
11355
|
+
});
|
|
11356
|
+
cipher.setAAD(ad, { plaintextLength: plaintext.length });
|
|
11357
|
+
const out = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
|
11358
|
+
this.n += 1n;
|
|
11359
|
+
return out;
|
|
11360
|
+
}
|
|
11361
|
+
decryptWithAd(ad, ciphertext) {
|
|
11362
|
+
if (!this.k) return ciphertext;
|
|
11363
|
+
if (ciphertext.length < TAGLEN) throw new NoiseError("Ciphertext shorter than its tag");
|
|
11364
|
+
const body = ciphertext.subarray(0, ciphertext.length - TAGLEN);
|
|
11365
|
+
const tag = ciphertext.subarray(ciphertext.length - TAGLEN);
|
|
11366
|
+
const decipher = (0, import_crypto10.createDecipheriv)("chacha20-poly1305", this.k, chachaNonce(this.n), {
|
|
11367
|
+
authTagLength: TAGLEN
|
|
11368
|
+
});
|
|
11369
|
+
decipher.setAAD(ad, { plaintextLength: body.length });
|
|
11370
|
+
decipher.setAuthTag(tag);
|
|
11371
|
+
let out;
|
|
11372
|
+
try {
|
|
11373
|
+
out = Buffer.concat([decipher.update(body), decipher.final()]);
|
|
11374
|
+
} catch {
|
|
11375
|
+
throw new NoiseError("Decryption failed");
|
|
11376
|
+
}
|
|
11377
|
+
this.n += 1n;
|
|
11378
|
+
return out;
|
|
11379
|
+
}
|
|
11380
|
+
};
|
|
11381
|
+
var SymmetricState = class {
|
|
11382
|
+
ck;
|
|
11383
|
+
h;
|
|
11384
|
+
cipher = new CipherState();
|
|
11385
|
+
constructor(protocolName) {
|
|
11386
|
+
const name = Buffer.from(protocolName, "utf-8");
|
|
11387
|
+
this.h = name.length <= HASHLEN ? Buffer.concat([name, Buffer.alloc(HASHLEN - name.length)]) : (0, import_crypto10.createHash)("sha256").update(name).digest();
|
|
11388
|
+
this.ck = Buffer.from(this.h);
|
|
11389
|
+
this.cipher.initializeKey(null);
|
|
11390
|
+
}
|
|
11391
|
+
/**
|
|
11392
|
+
* Noise's HKDF (spec §4.3) is RFC 5869 with `salt = chaining_key`,
|
|
11393
|
+
* `ikm = input_key_material` and an EMPTY info, so Node's `hkdfSync` is
|
|
11394
|
+
* exactly it rather than approximately it. Using the platform's HKDF removes
|
|
11395
|
+
* the HMAC chain that would otherwise be the easiest thing here to get
|
|
11396
|
+
* subtly wrong.
|
|
11397
|
+
*/
|
|
11398
|
+
hkdf(ikm, outputs) {
|
|
11399
|
+
const raw = Buffer.from((0, import_crypto10.hkdfSync)("sha256", ikm, this.ck, Buffer.alloc(0), HASHLEN * outputs));
|
|
11400
|
+
const out = [];
|
|
11401
|
+
for (let i = 0; i < outputs; i++) out.push(raw.subarray(i * HASHLEN, (i + 1) * HASHLEN));
|
|
11402
|
+
return out;
|
|
11403
|
+
}
|
|
11404
|
+
mixKey(ikm) {
|
|
11405
|
+
const [ck, tempK] = this.hkdf(ikm, 2);
|
|
11406
|
+
this.ck = ck;
|
|
11407
|
+
this.cipher.initializeKey(tempK);
|
|
11408
|
+
}
|
|
11409
|
+
mixHash(data) {
|
|
11410
|
+
this.h = (0, import_crypto10.createHash)("sha256").update(this.h).update(data).digest();
|
|
11411
|
+
}
|
|
11412
|
+
/** Spec §5.2. Used only by the `psk` token. */
|
|
11413
|
+
mixKeyAndHash(ikm) {
|
|
11414
|
+
const [ck, tempH, tempK] = this.hkdf(ikm, 3);
|
|
11415
|
+
this.ck = ck;
|
|
11416
|
+
this.mixHash(tempH);
|
|
11417
|
+
this.cipher.initializeKey(tempK);
|
|
11418
|
+
}
|
|
11419
|
+
encryptAndHash(plaintext) {
|
|
11420
|
+
const ciphertext = this.cipher.encryptWithAd(this.h, plaintext);
|
|
11421
|
+
this.mixHash(ciphertext);
|
|
11422
|
+
return ciphertext;
|
|
11423
|
+
}
|
|
11424
|
+
decryptAndHash(ciphertext) {
|
|
11425
|
+
const plaintext = this.cipher.decryptWithAd(this.h, ciphertext);
|
|
11426
|
+
this.mixHash(ciphertext);
|
|
11427
|
+
return plaintext;
|
|
11428
|
+
}
|
|
11429
|
+
/** The transcript hash. Both sides must arrive at the same value. */
|
|
11430
|
+
handshakeHash() {
|
|
11431
|
+
return Buffer.from(this.h);
|
|
11432
|
+
}
|
|
11433
|
+
/** Spec §5.2 `Split()`: the two directional transport keys. */
|
|
11434
|
+
split() {
|
|
11435
|
+
const [k1, k2] = this.hkdf(Buffer.alloc(0), 2);
|
|
11436
|
+
return { k1: Buffer.from(k1), k2: Buffer.from(k2) };
|
|
11437
|
+
}
|
|
11438
|
+
};
|
|
11439
|
+
function dh(privateKey, publicKey) {
|
|
11440
|
+
return (0, import_crypto10.diffieHellman)({ privateKey, publicKey });
|
|
11441
|
+
}
|
|
11442
|
+
function readMessage1(args) {
|
|
11443
|
+
assertMessageSize(args.message1, NOISE_MESSAGE_1_OVERHEAD);
|
|
11444
|
+
const state = new SymmetricState(NOISE_PROTOCOL_NAME);
|
|
11445
|
+
state.mixHash(args.prologue ?? PAIR_PROLOGUE);
|
|
11446
|
+
state.mixHash(args.staticKeyPair.publicKeyRaw);
|
|
11447
|
+
const reRaw = args.message1.subarray(0, DHLEN);
|
|
11448
|
+
const re = publicKeyFromRaw(reRaw);
|
|
11449
|
+
state.mixHash(reRaw);
|
|
11450
|
+
state.mixKey(reRaw);
|
|
11451
|
+
state.mixKey(dh(args.staticKeyPair.privateKey, re));
|
|
11452
|
+
const encryptedStatic = args.message1.subarray(DHLEN, DHLEN + DHLEN + TAGLEN);
|
|
11453
|
+
const initiatorStaticPub = state.decryptAndHash(encryptedStatic);
|
|
11454
|
+
const rs = publicKeyFromRaw(initiatorStaticPub);
|
|
11455
|
+
state.mixKey(dh(args.staticKeyPair.privateKey, rs));
|
|
11456
|
+
state.mixKeyAndHash(args.psk);
|
|
11457
|
+
const payload = state.decryptAndHash(args.message1.subarray(DHLEN + DHLEN + TAGLEN));
|
|
11458
|
+
return {
|
|
11459
|
+
symmetric: state,
|
|
11460
|
+
initiatorStaticPub,
|
|
11461
|
+
payload,
|
|
11462
|
+
initiatorEphemeral: re,
|
|
11463
|
+
initiatorStatic: rs,
|
|
11464
|
+
staticKeyPair: args.staticKeyPair
|
|
11465
|
+
};
|
|
11466
|
+
}
|
|
11467
|
+
function writeMessage2(state, responsePayload, ephemeral) {
|
|
11468
|
+
const e = ephemeral ?? generateKeyPair();
|
|
11469
|
+
state.symmetric.mixHash(e.publicKeyRaw);
|
|
11470
|
+
state.symmetric.mixKey(e.publicKeyRaw);
|
|
11471
|
+
state.symmetric.mixKey(dh(e.privateKey, state.initiatorEphemeral));
|
|
11472
|
+
state.symmetric.mixKey(dh(e.privateKey, state.initiatorStatic));
|
|
11473
|
+
const encryptedPayload = state.symmetric.encryptAndHash(responsePayload);
|
|
11474
|
+
return {
|
|
11475
|
+
message2: Buffer.concat([e.publicKeyRaw, encryptedPayload]),
|
|
11476
|
+
keys: finish(state.symmetric)
|
|
11477
|
+
};
|
|
11478
|
+
}
|
|
11479
|
+
function finish(state) {
|
|
11480
|
+
const { k1, k2 } = state.split();
|
|
11481
|
+
return { handshakeHash: state.handshakeHash(), clientToServer: k1, serverToClient: k2 };
|
|
11482
|
+
}
|
|
11483
|
+
function assertMessageSize(message, minimum) {
|
|
11484
|
+
if (message.length < minimum) {
|
|
11485
|
+
throw new NoiseError(`Handshake message too short: ${message.length} < ${minimum}`);
|
|
11486
|
+
}
|
|
11487
|
+
if (message.length > NOISE_MAX_MESSAGE_BYTES) {
|
|
11488
|
+
throw new NoiseError(`Handshake message too large: ${message.length}`);
|
|
11489
|
+
}
|
|
11490
|
+
}
|
|
11491
|
+
|
|
11492
|
+
// src/e2ee/pair-request.ts
|
|
11493
|
+
var E2EE_EXCHANGE_VERSION = 1;
|
|
11494
|
+
var MAX_BASE64_CHARS = Math.ceil(NOISE_MAX_MESSAGE_BYTES * 4 / 3) + 4;
|
|
11495
|
+
var E2eeRequestError = class extends Error {
|
|
11496
|
+
/** Stable code for the client, so a version mismatch is not a parse failure. */
|
|
11497
|
+
code;
|
|
11498
|
+
constructor(code, message) {
|
|
11499
|
+
super(message);
|
|
11500
|
+
this.name = "E2eeRequestError";
|
|
11501
|
+
this.code = code;
|
|
11502
|
+
}
|
|
11503
|
+
};
|
|
11504
|
+
function parseE2eeRequest(raw) {
|
|
11505
|
+
if (raw === void 0 || raw === null) return null;
|
|
11506
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
11507
|
+
throw new E2eeRequestError("E2EE_MALFORMED", "e2ee must be an object");
|
|
11508
|
+
}
|
|
11509
|
+
const { v, noise } = raw;
|
|
11510
|
+
if (typeof v !== "number" || !Number.isInteger(v)) {
|
|
11511
|
+
throw new E2eeRequestError("E2EE_MALFORMED", "e2ee.v must be an integer");
|
|
11512
|
+
}
|
|
11513
|
+
if (v !== E2EE_EXCHANGE_VERSION) {
|
|
11514
|
+
throw new E2eeRequestError(
|
|
11515
|
+
"E2EE_VERSION_UNSUPPORTED",
|
|
11516
|
+
`e2ee.v ${v} is not supported; this server speaks ${E2EE_EXCHANGE_VERSION}`
|
|
11517
|
+
);
|
|
11518
|
+
}
|
|
11519
|
+
if (typeof noise !== "string") {
|
|
11520
|
+
throw new E2eeRequestError("E2EE_MALFORMED", "e2ee.noise must be a base64 string");
|
|
11521
|
+
}
|
|
11522
|
+
if (noise.length > MAX_BASE64_CHARS) {
|
|
11523
|
+
throw new E2eeRequestError("E2EE_MALFORMED", "e2ee.noise is too large");
|
|
11524
|
+
}
|
|
11525
|
+
const message1 = Buffer.from(noise, "base64");
|
|
11526
|
+
if (message1.length === 0) {
|
|
11527
|
+
throw new E2eeRequestError("E2EE_MALFORMED", "e2ee.noise is not valid base64");
|
|
11528
|
+
}
|
|
11529
|
+
return { version: v, message1 };
|
|
11530
|
+
}
|
|
11531
|
+
|
|
11287
11532
|
// src/external-tails.ts
|
|
11288
11533
|
var import_fs16 = require("fs");
|
|
11289
11534
|
var EXTERNAL_TAIL_RECENCY_MS = RESUME_BUSY_WINDOW_MS;
|
|
@@ -11455,7 +11700,7 @@ var ExternalTailManager = class {
|
|
|
11455
11700
|
};
|
|
11456
11701
|
|
|
11457
11702
|
// src/pair-store.ts
|
|
11458
|
-
var
|
|
11703
|
+
var import_crypto11 = require("crypto");
|
|
11459
11704
|
var DEFAULT_TTL_SECONDS = 180;
|
|
11460
11705
|
var SWEEP_INTERVAL_MS = 6e4;
|
|
11461
11706
|
var PairTokenStore = class {
|
|
@@ -11470,7 +11715,7 @@ var PairTokenStore = class {
|
|
|
11470
11715
|
}
|
|
11471
11716
|
}
|
|
11472
11717
|
mint() {
|
|
11473
|
-
const token = `pt_${(0,
|
|
11718
|
+
const token = `pt_${(0, import_crypto11.randomBytes)(16).toString("hex")}`;
|
|
11474
11719
|
const expiresAt = Date.now() + this.ttlMs;
|
|
11475
11720
|
this.current = { token, expiresAt, used: false };
|
|
11476
11721
|
return {
|
|
@@ -11782,7 +12027,7 @@ function isScannedSnapshotStale(snapshotTimestamp, fileMtimeMs) {
|
|
|
11782
12027
|
|
|
11783
12028
|
// src/scanner-manager.ts
|
|
11784
12029
|
var REFRESH_TTL_MS = 2e3;
|
|
11785
|
-
var ScannerManager = class {
|
|
12030
|
+
var ScannerManager = class _ScannerManager {
|
|
11786
12031
|
constructor(deps) {
|
|
11787
12032
|
this.deps = deps;
|
|
11788
12033
|
this.persistenceDisabled = deps.persistenceDisabled;
|
|
@@ -11803,6 +12048,12 @@ var ScannerManager = class {
|
|
|
11803
12048
|
allScanners = /* @__PURE__ */ new Set();
|
|
11804
12049
|
stalePaths = /* @__PURE__ */ new Set();
|
|
11805
12050
|
reconcileInFlight = null;
|
|
12051
|
+
lastAutoFullReconcileAt = 0;
|
|
12052
|
+
// reconcileMode() returns "full" on every poll while a permanent drift
|
|
12053
|
+
// condition holds (e.g. an orphan row) — without a cooldown each poll
|
|
12054
|
+
// re-walks the whole corpus back-to-back. refresh=1 and per-file
|
|
12055
|
+
// reconciles go through other paths and are unaffected.
|
|
12056
|
+
static AUTO_FULL_RECONCILE_COOLDOWN_MS = 6e4;
|
|
11806
12057
|
refreshInFlight = /* @__PURE__ */ new Map();
|
|
11807
12058
|
log = getLogger("server");
|
|
11808
12059
|
/**
|
|
@@ -12119,6 +12370,13 @@ var ScannerManager = class {
|
|
|
12119
12370
|
// in-flight cache write before shutting the DB.
|
|
12120
12371
|
startBackgroundReconcile(mode = "full") {
|
|
12121
12372
|
if (this.reconcileInFlight) return;
|
|
12373
|
+
if (mode === "full") {
|
|
12374
|
+
const now = Date.now();
|
|
12375
|
+
if (now - this.lastAutoFullReconcileAt < _ScannerManager.AUTO_FULL_RECONCILE_COOLDOWN_MS) {
|
|
12376
|
+
return;
|
|
12377
|
+
}
|
|
12378
|
+
this.lastAutoFullReconcileAt = now;
|
|
12379
|
+
}
|
|
12122
12380
|
const paths = mode === "files" ? this.takeStaleFiles() : [];
|
|
12123
12381
|
const task = (paths.length > 0 ? this.reconcileStaleFilesFromDisk(paths) : this.reconcileFromDisk()).finally(() => {
|
|
12124
12382
|
this.reconcileInFlight = null;
|
|
@@ -12656,7 +12914,7 @@ function createApiDeps(deps) {
|
|
|
12656
12914
|
}
|
|
12657
12915
|
|
|
12658
12916
|
// src/services/cache-integrity/cacheIntegrityMonitor.ts
|
|
12659
|
-
var
|
|
12917
|
+
var import_crypto12 = require("crypto");
|
|
12660
12918
|
var import_fs23 = require("fs");
|
|
12661
12919
|
|
|
12662
12920
|
// src/services/cache-integrity/alertStore.ts
|
|
@@ -12721,7 +12979,7 @@ function envInt(name, fallback) {
|
|
|
12721
12979
|
}
|
|
12722
12980
|
function fingerprintOf(ids) {
|
|
12723
12981
|
const sorted = [...ids].sort();
|
|
12724
|
-
return `sha256:${(0,
|
|
12982
|
+
return `sha256:${(0, import_crypto12.createHash)("sha256").update(sorted.join("\n")).digest("hex")}`;
|
|
12725
12983
|
}
|
|
12726
12984
|
var CacheIntegrityMonitor = class {
|
|
12727
12985
|
constructor(cache, wsHub, log10, cacheDir, rescan, runDuringReset) {
|
|
@@ -15227,7 +15485,7 @@ var StreamerServer = class {
|
|
|
15227
15485
|
runtimeStore = null;
|
|
15228
15486
|
// Identifies this streamer run. A registry row carrying a different id is a
|
|
15229
15487
|
// session that outlived the process that started it.
|
|
15230
|
-
streamerInstanceId = (0,
|
|
15488
|
+
streamerInstanceId = (0, import_crypto13.randomUUID)();
|
|
15231
15489
|
cacheMetadataRepo = null;
|
|
15232
15490
|
// Push registration + delivery state (C7). Null when the cache DB failed to
|
|
15233
15491
|
// open — registration then degrades to a no-op rather than 500ing.
|
|
@@ -16355,6 +16613,14 @@ var StreamerServer = class {
|
|
|
16355
16613
|
json(res, 400, { error: "Missing token or clientPublicKey" });
|
|
16356
16614
|
return;
|
|
16357
16615
|
}
|
|
16616
|
+
let e2eeRequest;
|
|
16617
|
+
try {
|
|
16618
|
+
e2eeRequest = parseE2eeRequest(body?.e2ee);
|
|
16619
|
+
} catch (err) {
|
|
16620
|
+
const e = err;
|
|
16621
|
+
json(res, 400, { error: e.message, code: e.code });
|
|
16622
|
+
return;
|
|
16623
|
+
}
|
|
16358
16624
|
const precheck = this.pairTokens.wouldConsume(token);
|
|
16359
16625
|
if (!precheck.ok) {
|
|
16360
16626
|
if (precheck.reason === "used") {
|
|
@@ -16366,6 +16632,25 @@ var StreamerServer = class {
|
|
|
16366
16632
|
json(res, 401, { error: `Pair token ${precheck.reason}` });
|
|
16367
16633
|
return;
|
|
16368
16634
|
}
|
|
16635
|
+
let handshake = null;
|
|
16636
|
+
if (e2eeRequest) {
|
|
16637
|
+
try {
|
|
16638
|
+
handshake = readMessage1({
|
|
16639
|
+
staticKeyPair: keyPairFrom(loadOrCreateServerIdentity().privateKey),
|
|
16640
|
+
// The pair token binds this handshake to the scanned QR. Derivation
|
|
16641
|
+
// is specified in design.md §2.4 and pinned by a committed vector
|
|
16642
|
+
// that tb-mobile checks against independently.
|
|
16643
|
+
psk: pskFromPairToken(token),
|
|
16644
|
+
message1: e2eeRequest.message1
|
|
16645
|
+
});
|
|
16646
|
+
} catch {
|
|
16647
|
+
json(res, 400, {
|
|
16648
|
+
error: "E2EE handshake failed. Scan a fresh pairing code and try again.",
|
|
16649
|
+
code: "E2EE_HANDSHAKE_FAILED"
|
|
16650
|
+
});
|
|
16651
|
+
return;
|
|
16652
|
+
}
|
|
16653
|
+
}
|
|
16369
16654
|
let sealed;
|
|
16370
16655
|
try {
|
|
16371
16656
|
sealed = seal(this.apiKey, clientPublicKey);
|
|
@@ -16389,14 +16674,59 @@ var StreamerServer = class {
|
|
|
16389
16674
|
try {
|
|
16390
16675
|
const name = typeof body?.deviceName === "string" ? body.deviceName.slice(0, 100) : null;
|
|
16391
16676
|
const preset = body?.readOnly === true ? "read-only" : "full";
|
|
16392
|
-
device = this.devicesRepo?.register({
|
|
16677
|
+
device = this.devicesRepo?.register({
|
|
16678
|
+
publicKey: clientPublicKey,
|
|
16679
|
+
name,
|
|
16680
|
+
preset,
|
|
16681
|
+
// Recorded only when the handshake authenticated it. The static key
|
|
16682
|
+
// comes out of the transcript, never off the wire as a claim — that
|
|
16683
|
+
// is the difference between a device identified by a key it proved
|
|
16684
|
+
// it holds and one identified by a string it sent.
|
|
16685
|
+
//
|
|
16686
|
+
// Setting it also sets `e2ee_required`, so a device that has once
|
|
16687
|
+
// paired encrypted is pinned and never served plaintext again.
|
|
16688
|
+
...handshake && {
|
|
16689
|
+
e2eeStaticPub: handshake.initiatorStaticPub.toString("base64"),
|
|
16690
|
+
e2eeVersion: E2EE_EXCHANGE_VERSION
|
|
16691
|
+
}
|
|
16692
|
+
}) ?? null;
|
|
16393
16693
|
} catch (err) {
|
|
16394
16694
|
this.log.warn("[pair] device registration failed; pairing continues", {
|
|
16395
16695
|
event: "pair.device_register_failed",
|
|
16396
16696
|
err
|
|
16397
16697
|
});
|
|
16398
16698
|
}
|
|
16699
|
+
let e2eeResponse = null;
|
|
16700
|
+
if (handshake) {
|
|
16701
|
+
try {
|
|
16702
|
+
const { message2 } = writeMessage2(
|
|
16703
|
+
handshake,
|
|
16704
|
+
Buffer.from(
|
|
16705
|
+
JSON.stringify({
|
|
16706
|
+
v: E2EE_EXCHANGE_VERSION,
|
|
16707
|
+
deviceId: device?.deviceId ?? null,
|
|
16708
|
+
serverVersion: getVersion(),
|
|
16709
|
+
// Always true on this path: completing a handshake is what pins
|
|
16710
|
+
// the device, and design.md §6.3 says nothing a client sends
|
|
16711
|
+
// ever clears it.
|
|
16712
|
+
e2eeRequired: true
|
|
16713
|
+
}),
|
|
16714
|
+
"utf-8"
|
|
16715
|
+
)
|
|
16716
|
+
);
|
|
16717
|
+
e2eeResponse = { v: E2EE_EXCHANGE_VERSION, noise: message2.toString("base64") };
|
|
16718
|
+
} catch (err) {
|
|
16719
|
+
this.log.warn("[pair] E2EE response could not be written; pairing continues", {
|
|
16720
|
+
event: "pair.e2ee_response_failed",
|
|
16721
|
+
err
|
|
16722
|
+
});
|
|
16723
|
+
}
|
|
16724
|
+
}
|
|
16399
16725
|
json(res, 200, {
|
|
16726
|
+
// Unchanged and still sent on the E2EE path, deliberately. An older app
|
|
16727
|
+
// is the only thing that can read these and it cannot be force-updated
|
|
16728
|
+
// (docs/compatibility/tb-mobile.md); a new app ignores them and uses the
|
|
16729
|
+
// Noise result. The response grows a field, it never loses one.
|
|
16400
16730
|
ciphertext: sealed.ciphertext,
|
|
16401
16731
|
nonce: sealed.nonce,
|
|
16402
16732
|
ephemeralPublicKey: sealed.ephemeralPublicKey,
|
|
@@ -16419,7 +16749,11 @@ var StreamerServer = class {
|
|
|
16419
16749
|
deviceId: device.deviceId,
|
|
16420
16750
|
deviceToken: device.deviceToken,
|
|
16421
16751
|
capabilities: device.capabilities
|
|
16422
|
-
}
|
|
16752
|
+
},
|
|
16753
|
+
// Additive. Absent means this pairing is plaintext — either the client
|
|
16754
|
+
// never asked, or writing the reply failed — and a client that asked for
|
|
16755
|
+
// encryption must treat its absence as a refusal, not as consent.
|
|
16756
|
+
...e2eeResponse && { e2ee: e2eeResponse }
|
|
16423
16757
|
});
|
|
16424
16758
|
}
|
|
16425
16759
|
rotateApiKey() {
|