@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.js
CHANGED
|
@@ -6934,7 +6934,7 @@ var ConversationCache = class _ConversationCache {
|
|
|
6934
6934
|
"SELECT id, project_path, project_id, last_activity FROM conversation_meta WHERE project_path IS NOT NULL"
|
|
6935
6935
|
),
|
|
6936
6936
|
hasOrphanProjectId: db.prepare(
|
|
6937
|
-
"SELECT 1 FROM conversation_meta WHERE project_id IS NULL AND project_path IS NOT NULL LIMIT 1"
|
|
6937
|
+
"SELECT 1 FROM conversation_meta WHERE project_id IS NULL AND project_path IS NOT NULL AND project_path != '' LIMIT 1"
|
|
6938
6938
|
),
|
|
6939
6939
|
popularProjects: db.prepare(
|
|
6940
6940
|
`SELECT project_path, project_name, COUNT(*) as cnt
|
|
@@ -7531,10 +7531,11 @@ var ConversationCache = class _ConversationCache {
|
|
|
7531
7531
|
continue;
|
|
7532
7532
|
}
|
|
7533
7533
|
const scannerMetaJson = JSON.stringify(m);
|
|
7534
|
+
const projectPath = m.projectPath?.trim() || null;
|
|
7534
7535
|
this.stmts.upsertFull.run({
|
|
7535
7536
|
id,
|
|
7536
7537
|
file_path: canonicalPath,
|
|
7537
|
-
project_path:
|
|
7538
|
+
project_path: projectPath,
|
|
7538
7539
|
project_name: m.projectName ?? null,
|
|
7539
7540
|
title: m.title ?? m.sessionName ?? m.projectName ?? null,
|
|
7540
7541
|
model: m.model ?? null,
|
|
@@ -11239,6 +11240,259 @@ var RuntimeStore = class _RuntimeStore {
|
|
|
11239
11240
|
}
|
|
11240
11241
|
};
|
|
11241
11242
|
|
|
11243
|
+
// src/e2ee/noise.ts
|
|
11244
|
+
import {
|
|
11245
|
+
createCipheriv,
|
|
11246
|
+
createDecipheriv,
|
|
11247
|
+
createHash as createHash4,
|
|
11248
|
+
createPrivateKey as createPrivateKey2,
|
|
11249
|
+
createPublicKey as createPublicKey2,
|
|
11250
|
+
diffieHellman,
|
|
11251
|
+
generateKeyPairSync as generateKeyPairSync2,
|
|
11252
|
+
hkdfSync
|
|
11253
|
+
} from "crypto";
|
|
11254
|
+
var NOISE_PROTOCOL_NAME = "Noise_IKpsk1_25519_ChaChaPoly_SHA256";
|
|
11255
|
+
var PAIR_PROLOGUE = Buffer.from("threadbase-e2ee/1 pair", "utf-8");
|
|
11256
|
+
var DHLEN = 32;
|
|
11257
|
+
var HASHLEN = 32;
|
|
11258
|
+
var TAGLEN = 16;
|
|
11259
|
+
var NOISE_MESSAGE_1_OVERHEAD = DHLEN + DHLEN + TAGLEN + TAGLEN;
|
|
11260
|
+
var NOISE_MESSAGE_2_OVERHEAD = DHLEN + TAGLEN;
|
|
11261
|
+
var NOISE_MAX_MESSAGE_BYTES = 4096;
|
|
11262
|
+
var NoiseError = class extends Error {
|
|
11263
|
+
constructor(message) {
|
|
11264
|
+
super(message);
|
|
11265
|
+
this.name = "NoiseError";
|
|
11266
|
+
}
|
|
11267
|
+
};
|
|
11268
|
+
function generateKeyPair() {
|
|
11269
|
+
const { publicKey, privateKey } = generateKeyPairSync2("x25519");
|
|
11270
|
+
return { publicKey, privateKey, publicKeyRaw: rawPublicKey(publicKey) };
|
|
11271
|
+
}
|
|
11272
|
+
function keyPairFrom(privateKey) {
|
|
11273
|
+
const publicKey = createPublicKey2(privateKey);
|
|
11274
|
+
return { publicKey, privateKey, publicKeyRaw: rawPublicKey(publicKey) };
|
|
11275
|
+
}
|
|
11276
|
+
function rawPublicKey(key) {
|
|
11277
|
+
const pub = key.type === "public" ? key : createPublicKey2(key);
|
|
11278
|
+
const jwk = pub.export({ format: "jwk" });
|
|
11279
|
+
if (jwk.crv !== "X25519" || typeof jwk.x !== "string") {
|
|
11280
|
+
throw new NoiseError("Not an X25519 public key");
|
|
11281
|
+
}
|
|
11282
|
+
return Buffer.from(jwk.x, "base64url");
|
|
11283
|
+
}
|
|
11284
|
+
function publicKeyFromRaw(raw) {
|
|
11285
|
+
if (raw.length !== DHLEN) {
|
|
11286
|
+
throw new NoiseError(`X25519 public key must be ${DHLEN} bytes, got ${raw.length}`);
|
|
11287
|
+
}
|
|
11288
|
+
try {
|
|
11289
|
+
return createPublicKey2({
|
|
11290
|
+
key: { kty: "OKP", crv: "X25519", x: raw.toString("base64url") },
|
|
11291
|
+
format: "jwk"
|
|
11292
|
+
});
|
|
11293
|
+
} catch {
|
|
11294
|
+
throw new NoiseError("Invalid X25519 public key");
|
|
11295
|
+
}
|
|
11296
|
+
}
|
|
11297
|
+
function pskFromPairToken(token) {
|
|
11298
|
+
return createHash4("sha256").update("threadbase-e2ee/1 psk", "utf-8").update(token, "utf-8").digest();
|
|
11299
|
+
}
|
|
11300
|
+
function chachaNonce(n) {
|
|
11301
|
+
const nonce = Buffer.alloc(12);
|
|
11302
|
+
nonce.writeBigUInt64LE(n, 4);
|
|
11303
|
+
return nonce;
|
|
11304
|
+
}
|
|
11305
|
+
var CipherState = class {
|
|
11306
|
+
k = null;
|
|
11307
|
+
n = 0n;
|
|
11308
|
+
initializeKey(key) {
|
|
11309
|
+
this.k = key;
|
|
11310
|
+
this.n = 0n;
|
|
11311
|
+
}
|
|
11312
|
+
hasKey() {
|
|
11313
|
+
return this.k !== null;
|
|
11314
|
+
}
|
|
11315
|
+
encryptWithAd(ad, plaintext) {
|
|
11316
|
+
if (!this.k) return plaintext;
|
|
11317
|
+
const cipher = createCipheriv("chacha20-poly1305", this.k, chachaNonce(this.n), {
|
|
11318
|
+
authTagLength: TAGLEN
|
|
11319
|
+
});
|
|
11320
|
+
cipher.setAAD(ad, { plaintextLength: plaintext.length });
|
|
11321
|
+
const out = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
|
11322
|
+
this.n += 1n;
|
|
11323
|
+
return out;
|
|
11324
|
+
}
|
|
11325
|
+
decryptWithAd(ad, ciphertext) {
|
|
11326
|
+
if (!this.k) return ciphertext;
|
|
11327
|
+
if (ciphertext.length < TAGLEN) throw new NoiseError("Ciphertext shorter than its tag");
|
|
11328
|
+
const body = ciphertext.subarray(0, ciphertext.length - TAGLEN);
|
|
11329
|
+
const tag = ciphertext.subarray(ciphertext.length - TAGLEN);
|
|
11330
|
+
const decipher = createDecipheriv("chacha20-poly1305", this.k, chachaNonce(this.n), {
|
|
11331
|
+
authTagLength: TAGLEN
|
|
11332
|
+
});
|
|
11333
|
+
decipher.setAAD(ad, { plaintextLength: body.length });
|
|
11334
|
+
decipher.setAuthTag(tag);
|
|
11335
|
+
let out;
|
|
11336
|
+
try {
|
|
11337
|
+
out = Buffer.concat([decipher.update(body), decipher.final()]);
|
|
11338
|
+
} catch {
|
|
11339
|
+
throw new NoiseError("Decryption failed");
|
|
11340
|
+
}
|
|
11341
|
+
this.n += 1n;
|
|
11342
|
+
return out;
|
|
11343
|
+
}
|
|
11344
|
+
};
|
|
11345
|
+
var SymmetricState = class {
|
|
11346
|
+
ck;
|
|
11347
|
+
h;
|
|
11348
|
+
cipher = new CipherState();
|
|
11349
|
+
constructor(protocolName) {
|
|
11350
|
+
const name = Buffer.from(protocolName, "utf-8");
|
|
11351
|
+
this.h = name.length <= HASHLEN ? Buffer.concat([name, Buffer.alloc(HASHLEN - name.length)]) : createHash4("sha256").update(name).digest();
|
|
11352
|
+
this.ck = Buffer.from(this.h);
|
|
11353
|
+
this.cipher.initializeKey(null);
|
|
11354
|
+
}
|
|
11355
|
+
/**
|
|
11356
|
+
* Noise's HKDF (spec §4.3) is RFC 5869 with `salt = chaining_key`,
|
|
11357
|
+
* `ikm = input_key_material` and an EMPTY info, so Node's `hkdfSync` is
|
|
11358
|
+
* exactly it rather than approximately it. Using the platform's HKDF removes
|
|
11359
|
+
* the HMAC chain that would otherwise be the easiest thing here to get
|
|
11360
|
+
* subtly wrong.
|
|
11361
|
+
*/
|
|
11362
|
+
hkdf(ikm, outputs) {
|
|
11363
|
+
const raw = Buffer.from(hkdfSync("sha256", ikm, this.ck, Buffer.alloc(0), HASHLEN * outputs));
|
|
11364
|
+
const out = [];
|
|
11365
|
+
for (let i = 0; i < outputs; i++) out.push(raw.subarray(i * HASHLEN, (i + 1) * HASHLEN));
|
|
11366
|
+
return out;
|
|
11367
|
+
}
|
|
11368
|
+
mixKey(ikm) {
|
|
11369
|
+
const [ck, tempK] = this.hkdf(ikm, 2);
|
|
11370
|
+
this.ck = ck;
|
|
11371
|
+
this.cipher.initializeKey(tempK);
|
|
11372
|
+
}
|
|
11373
|
+
mixHash(data) {
|
|
11374
|
+
this.h = createHash4("sha256").update(this.h).update(data).digest();
|
|
11375
|
+
}
|
|
11376
|
+
/** Spec §5.2. Used only by the `psk` token. */
|
|
11377
|
+
mixKeyAndHash(ikm) {
|
|
11378
|
+
const [ck, tempH, tempK] = this.hkdf(ikm, 3);
|
|
11379
|
+
this.ck = ck;
|
|
11380
|
+
this.mixHash(tempH);
|
|
11381
|
+
this.cipher.initializeKey(tempK);
|
|
11382
|
+
}
|
|
11383
|
+
encryptAndHash(plaintext) {
|
|
11384
|
+
const ciphertext = this.cipher.encryptWithAd(this.h, plaintext);
|
|
11385
|
+
this.mixHash(ciphertext);
|
|
11386
|
+
return ciphertext;
|
|
11387
|
+
}
|
|
11388
|
+
decryptAndHash(ciphertext) {
|
|
11389
|
+
const plaintext = this.cipher.decryptWithAd(this.h, ciphertext);
|
|
11390
|
+
this.mixHash(ciphertext);
|
|
11391
|
+
return plaintext;
|
|
11392
|
+
}
|
|
11393
|
+
/** The transcript hash. Both sides must arrive at the same value. */
|
|
11394
|
+
handshakeHash() {
|
|
11395
|
+
return Buffer.from(this.h);
|
|
11396
|
+
}
|
|
11397
|
+
/** Spec §5.2 `Split()`: the two directional transport keys. */
|
|
11398
|
+
split() {
|
|
11399
|
+
const [k1, k2] = this.hkdf(Buffer.alloc(0), 2);
|
|
11400
|
+
return { k1: Buffer.from(k1), k2: Buffer.from(k2) };
|
|
11401
|
+
}
|
|
11402
|
+
};
|
|
11403
|
+
function dh(privateKey, publicKey) {
|
|
11404
|
+
return diffieHellman({ privateKey, publicKey });
|
|
11405
|
+
}
|
|
11406
|
+
function readMessage1(args) {
|
|
11407
|
+
assertMessageSize(args.message1, NOISE_MESSAGE_1_OVERHEAD);
|
|
11408
|
+
const state = new SymmetricState(NOISE_PROTOCOL_NAME);
|
|
11409
|
+
state.mixHash(args.prologue ?? PAIR_PROLOGUE);
|
|
11410
|
+
state.mixHash(args.staticKeyPair.publicKeyRaw);
|
|
11411
|
+
const reRaw = args.message1.subarray(0, DHLEN);
|
|
11412
|
+
const re = publicKeyFromRaw(reRaw);
|
|
11413
|
+
state.mixHash(reRaw);
|
|
11414
|
+
state.mixKey(reRaw);
|
|
11415
|
+
state.mixKey(dh(args.staticKeyPair.privateKey, re));
|
|
11416
|
+
const encryptedStatic = args.message1.subarray(DHLEN, DHLEN + DHLEN + TAGLEN);
|
|
11417
|
+
const initiatorStaticPub = state.decryptAndHash(encryptedStatic);
|
|
11418
|
+
const rs = publicKeyFromRaw(initiatorStaticPub);
|
|
11419
|
+
state.mixKey(dh(args.staticKeyPair.privateKey, rs));
|
|
11420
|
+
state.mixKeyAndHash(args.psk);
|
|
11421
|
+
const payload = state.decryptAndHash(args.message1.subarray(DHLEN + DHLEN + TAGLEN));
|
|
11422
|
+
return {
|
|
11423
|
+
symmetric: state,
|
|
11424
|
+
initiatorStaticPub,
|
|
11425
|
+
payload,
|
|
11426
|
+
initiatorEphemeral: re,
|
|
11427
|
+
initiatorStatic: rs,
|
|
11428
|
+
staticKeyPair: args.staticKeyPair
|
|
11429
|
+
};
|
|
11430
|
+
}
|
|
11431
|
+
function writeMessage2(state, responsePayload, ephemeral) {
|
|
11432
|
+
const e = ephemeral ?? generateKeyPair();
|
|
11433
|
+
state.symmetric.mixHash(e.publicKeyRaw);
|
|
11434
|
+
state.symmetric.mixKey(e.publicKeyRaw);
|
|
11435
|
+
state.symmetric.mixKey(dh(e.privateKey, state.initiatorEphemeral));
|
|
11436
|
+
state.symmetric.mixKey(dh(e.privateKey, state.initiatorStatic));
|
|
11437
|
+
const encryptedPayload = state.symmetric.encryptAndHash(responsePayload);
|
|
11438
|
+
return {
|
|
11439
|
+
message2: Buffer.concat([e.publicKeyRaw, encryptedPayload]),
|
|
11440
|
+
keys: finish(state.symmetric)
|
|
11441
|
+
};
|
|
11442
|
+
}
|
|
11443
|
+
function finish(state) {
|
|
11444
|
+
const { k1, k2 } = state.split();
|
|
11445
|
+
return { handshakeHash: state.handshakeHash(), clientToServer: k1, serverToClient: k2 };
|
|
11446
|
+
}
|
|
11447
|
+
function assertMessageSize(message, minimum) {
|
|
11448
|
+
if (message.length < minimum) {
|
|
11449
|
+
throw new NoiseError(`Handshake message too short: ${message.length} < ${minimum}`);
|
|
11450
|
+
}
|
|
11451
|
+
if (message.length > NOISE_MAX_MESSAGE_BYTES) {
|
|
11452
|
+
throw new NoiseError(`Handshake message too large: ${message.length}`);
|
|
11453
|
+
}
|
|
11454
|
+
}
|
|
11455
|
+
|
|
11456
|
+
// src/e2ee/pair-request.ts
|
|
11457
|
+
var E2EE_EXCHANGE_VERSION = 1;
|
|
11458
|
+
var MAX_BASE64_CHARS = Math.ceil(NOISE_MAX_MESSAGE_BYTES * 4 / 3) + 4;
|
|
11459
|
+
var E2eeRequestError = class extends Error {
|
|
11460
|
+
/** Stable code for the client, so a version mismatch is not a parse failure. */
|
|
11461
|
+
code;
|
|
11462
|
+
constructor(code, message) {
|
|
11463
|
+
super(message);
|
|
11464
|
+
this.name = "E2eeRequestError";
|
|
11465
|
+
this.code = code;
|
|
11466
|
+
}
|
|
11467
|
+
};
|
|
11468
|
+
function parseE2eeRequest(raw) {
|
|
11469
|
+
if (raw === void 0 || raw === null) return null;
|
|
11470
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
11471
|
+
throw new E2eeRequestError("E2EE_MALFORMED", "e2ee must be an object");
|
|
11472
|
+
}
|
|
11473
|
+
const { v, noise } = raw;
|
|
11474
|
+
if (typeof v !== "number" || !Number.isInteger(v)) {
|
|
11475
|
+
throw new E2eeRequestError("E2EE_MALFORMED", "e2ee.v must be an integer");
|
|
11476
|
+
}
|
|
11477
|
+
if (v !== E2EE_EXCHANGE_VERSION) {
|
|
11478
|
+
throw new E2eeRequestError(
|
|
11479
|
+
"E2EE_VERSION_UNSUPPORTED",
|
|
11480
|
+
`e2ee.v ${v} is not supported; this server speaks ${E2EE_EXCHANGE_VERSION}`
|
|
11481
|
+
);
|
|
11482
|
+
}
|
|
11483
|
+
if (typeof noise !== "string") {
|
|
11484
|
+
throw new E2eeRequestError("E2EE_MALFORMED", "e2ee.noise must be a base64 string");
|
|
11485
|
+
}
|
|
11486
|
+
if (noise.length > MAX_BASE64_CHARS) {
|
|
11487
|
+
throw new E2eeRequestError("E2EE_MALFORMED", "e2ee.noise is too large");
|
|
11488
|
+
}
|
|
11489
|
+
const message1 = Buffer.from(noise, "base64");
|
|
11490
|
+
if (message1.length === 0) {
|
|
11491
|
+
throw new E2eeRequestError("E2EE_MALFORMED", "e2ee.noise is not valid base64");
|
|
11492
|
+
}
|
|
11493
|
+
return { version: v, message1 };
|
|
11494
|
+
}
|
|
11495
|
+
|
|
11242
11496
|
// src/external-tails.ts
|
|
11243
11497
|
import { statSync as statSync6 } from "fs";
|
|
11244
11498
|
var EXTERNAL_TAIL_RECENCY_MS = RESUME_BUSY_WINDOW_MS;
|
|
@@ -11739,7 +11993,7 @@ function isScannedSnapshotStale(snapshotTimestamp, fileMtimeMs) {
|
|
|
11739
11993
|
|
|
11740
11994
|
// src/scanner-manager.ts
|
|
11741
11995
|
var REFRESH_TTL_MS = 2e3;
|
|
11742
|
-
var ScannerManager = class {
|
|
11996
|
+
var ScannerManager = class _ScannerManager {
|
|
11743
11997
|
constructor(deps) {
|
|
11744
11998
|
this.deps = deps;
|
|
11745
11999
|
this.persistenceDisabled = deps.persistenceDisabled;
|
|
@@ -11760,6 +12014,12 @@ var ScannerManager = class {
|
|
|
11760
12014
|
allScanners = /* @__PURE__ */ new Set();
|
|
11761
12015
|
stalePaths = /* @__PURE__ */ new Set();
|
|
11762
12016
|
reconcileInFlight = null;
|
|
12017
|
+
lastAutoFullReconcileAt = 0;
|
|
12018
|
+
// reconcileMode() returns "full" on every poll while a permanent drift
|
|
12019
|
+
// condition holds (e.g. an orphan row) — without a cooldown each poll
|
|
12020
|
+
// re-walks the whole corpus back-to-back. refresh=1 and per-file
|
|
12021
|
+
// reconciles go through other paths and are unaffected.
|
|
12022
|
+
static AUTO_FULL_RECONCILE_COOLDOWN_MS = 6e4;
|
|
11763
12023
|
refreshInFlight = /* @__PURE__ */ new Map();
|
|
11764
12024
|
log = getLogger("server");
|
|
11765
12025
|
/**
|
|
@@ -12076,6 +12336,13 @@ var ScannerManager = class {
|
|
|
12076
12336
|
// in-flight cache write before shutting the DB.
|
|
12077
12337
|
startBackgroundReconcile(mode = "full") {
|
|
12078
12338
|
if (this.reconcileInFlight) return;
|
|
12339
|
+
if (mode === "full") {
|
|
12340
|
+
const now = Date.now();
|
|
12341
|
+
if (now - this.lastAutoFullReconcileAt < _ScannerManager.AUTO_FULL_RECONCILE_COOLDOWN_MS) {
|
|
12342
|
+
return;
|
|
12343
|
+
}
|
|
12344
|
+
this.lastAutoFullReconcileAt = now;
|
|
12345
|
+
}
|
|
12079
12346
|
const paths = mode === "files" ? this.takeStaleFiles() : [];
|
|
12080
12347
|
const task = (paths.length > 0 ? this.reconcileStaleFilesFromDisk(paths) : this.reconcileFromDisk()).finally(() => {
|
|
12081
12348
|
this.reconcileInFlight = null;
|
|
@@ -12613,7 +12880,7 @@ function createApiDeps(deps) {
|
|
|
12613
12880
|
}
|
|
12614
12881
|
|
|
12615
12882
|
// src/services/cache-integrity/cacheIntegrityMonitor.ts
|
|
12616
|
-
import { createHash as
|
|
12883
|
+
import { createHash as createHash5 } from "crypto";
|
|
12617
12884
|
import { existsSync as existsSync12 } from "fs";
|
|
12618
12885
|
|
|
12619
12886
|
// src/services/cache-integrity/alertStore.ts
|
|
@@ -12678,7 +12945,7 @@ function envInt(name, fallback) {
|
|
|
12678
12945
|
}
|
|
12679
12946
|
function fingerprintOf(ids) {
|
|
12680
12947
|
const sorted = [...ids].sort();
|
|
12681
|
-
return `sha256:${
|
|
12948
|
+
return `sha256:${createHash5("sha256").update(sorted.join("\n")).digest("hex")}`;
|
|
12682
12949
|
}
|
|
12683
12950
|
var CacheIntegrityMonitor = class {
|
|
12684
12951
|
constructor(cache, wsHub, log10, cacheDir, rescan, runDuringReset) {
|
|
@@ -16312,6 +16579,14 @@ var StreamerServer = class {
|
|
|
16312
16579
|
json(res, 400, { error: "Missing token or clientPublicKey" });
|
|
16313
16580
|
return;
|
|
16314
16581
|
}
|
|
16582
|
+
let e2eeRequest;
|
|
16583
|
+
try {
|
|
16584
|
+
e2eeRequest = parseE2eeRequest(body?.e2ee);
|
|
16585
|
+
} catch (err) {
|
|
16586
|
+
const e = err;
|
|
16587
|
+
json(res, 400, { error: e.message, code: e.code });
|
|
16588
|
+
return;
|
|
16589
|
+
}
|
|
16315
16590
|
const precheck = this.pairTokens.wouldConsume(token);
|
|
16316
16591
|
if (!precheck.ok) {
|
|
16317
16592
|
if (precheck.reason === "used") {
|
|
@@ -16323,6 +16598,25 @@ var StreamerServer = class {
|
|
|
16323
16598
|
json(res, 401, { error: `Pair token ${precheck.reason}` });
|
|
16324
16599
|
return;
|
|
16325
16600
|
}
|
|
16601
|
+
let handshake = null;
|
|
16602
|
+
if (e2eeRequest) {
|
|
16603
|
+
try {
|
|
16604
|
+
handshake = readMessage1({
|
|
16605
|
+
staticKeyPair: keyPairFrom(loadOrCreateServerIdentity().privateKey),
|
|
16606
|
+
// The pair token binds this handshake to the scanned QR. Derivation
|
|
16607
|
+
// is specified in design.md §2.4 and pinned by a committed vector
|
|
16608
|
+
// that tb-mobile checks against independently.
|
|
16609
|
+
psk: pskFromPairToken(token),
|
|
16610
|
+
message1: e2eeRequest.message1
|
|
16611
|
+
});
|
|
16612
|
+
} catch {
|
|
16613
|
+
json(res, 400, {
|
|
16614
|
+
error: "E2EE handshake failed. Scan a fresh pairing code and try again.",
|
|
16615
|
+
code: "E2EE_HANDSHAKE_FAILED"
|
|
16616
|
+
});
|
|
16617
|
+
return;
|
|
16618
|
+
}
|
|
16619
|
+
}
|
|
16326
16620
|
let sealed;
|
|
16327
16621
|
try {
|
|
16328
16622
|
sealed = seal(this.apiKey, clientPublicKey);
|
|
@@ -16346,14 +16640,59 @@ var StreamerServer = class {
|
|
|
16346
16640
|
try {
|
|
16347
16641
|
const name = typeof body?.deviceName === "string" ? body.deviceName.slice(0, 100) : null;
|
|
16348
16642
|
const preset = body?.readOnly === true ? "read-only" : "full";
|
|
16349
|
-
device = this.devicesRepo?.register({
|
|
16643
|
+
device = this.devicesRepo?.register({
|
|
16644
|
+
publicKey: clientPublicKey,
|
|
16645
|
+
name,
|
|
16646
|
+
preset,
|
|
16647
|
+
// Recorded only when the handshake authenticated it. The static key
|
|
16648
|
+
// comes out of the transcript, never off the wire as a claim — that
|
|
16649
|
+
// is the difference between a device identified by a key it proved
|
|
16650
|
+
// it holds and one identified by a string it sent.
|
|
16651
|
+
//
|
|
16652
|
+
// Setting it also sets `e2ee_required`, so a device that has once
|
|
16653
|
+
// paired encrypted is pinned and never served plaintext again.
|
|
16654
|
+
...handshake && {
|
|
16655
|
+
e2eeStaticPub: handshake.initiatorStaticPub.toString("base64"),
|
|
16656
|
+
e2eeVersion: E2EE_EXCHANGE_VERSION
|
|
16657
|
+
}
|
|
16658
|
+
}) ?? null;
|
|
16350
16659
|
} catch (err) {
|
|
16351
16660
|
this.log.warn("[pair] device registration failed; pairing continues", {
|
|
16352
16661
|
event: "pair.device_register_failed",
|
|
16353
16662
|
err
|
|
16354
16663
|
});
|
|
16355
16664
|
}
|
|
16665
|
+
let e2eeResponse = null;
|
|
16666
|
+
if (handshake) {
|
|
16667
|
+
try {
|
|
16668
|
+
const { message2 } = writeMessage2(
|
|
16669
|
+
handshake,
|
|
16670
|
+
Buffer.from(
|
|
16671
|
+
JSON.stringify({
|
|
16672
|
+
v: E2EE_EXCHANGE_VERSION,
|
|
16673
|
+
deviceId: device?.deviceId ?? null,
|
|
16674
|
+
serverVersion: getVersion(),
|
|
16675
|
+
// Always true on this path: completing a handshake is what pins
|
|
16676
|
+
// the device, and design.md §6.3 says nothing a client sends
|
|
16677
|
+
// ever clears it.
|
|
16678
|
+
e2eeRequired: true
|
|
16679
|
+
}),
|
|
16680
|
+
"utf-8"
|
|
16681
|
+
)
|
|
16682
|
+
);
|
|
16683
|
+
e2eeResponse = { v: E2EE_EXCHANGE_VERSION, noise: message2.toString("base64") };
|
|
16684
|
+
} catch (err) {
|
|
16685
|
+
this.log.warn("[pair] E2EE response could not be written; pairing continues", {
|
|
16686
|
+
event: "pair.e2ee_response_failed",
|
|
16687
|
+
err
|
|
16688
|
+
});
|
|
16689
|
+
}
|
|
16690
|
+
}
|
|
16356
16691
|
json(res, 200, {
|
|
16692
|
+
// Unchanged and still sent on the E2EE path, deliberately. An older app
|
|
16693
|
+
// is the only thing that can read these and it cannot be force-updated
|
|
16694
|
+
// (docs/compatibility/tb-mobile.md); a new app ignores them and uses the
|
|
16695
|
+
// Noise result. The response grows a field, it never loses one.
|
|
16357
16696
|
ciphertext: sealed.ciphertext,
|
|
16358
16697
|
nonce: sealed.nonce,
|
|
16359
16698
|
ephemeralPublicKey: sealed.ephemeralPublicKey,
|
|
@@ -16376,7 +16715,11 @@ var StreamerServer = class {
|
|
|
16376
16715
|
deviceId: device.deviceId,
|
|
16377
16716
|
deviceToken: device.deviceToken,
|
|
16378
16717
|
capabilities: device.capabilities
|
|
16379
|
-
}
|
|
16718
|
+
},
|
|
16719
|
+
// Additive. Absent means this pairing is plaintext — either the client
|
|
16720
|
+
// never asked, or writing the reply failed — and a client that asked for
|
|
16721
|
+
// encryption must treat its absence as a refusal, not as consent.
|
|
16722
|
+
...e2eeResponse && { e2ee: e2eeResponse }
|
|
16380
16723
|
});
|
|
16381
16724
|
}
|
|
16382
16725
|
rotateApiKey() {
|