@threadbase-sh/streamer 1.58.2 → 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/cli.cjs +371 -41
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +343 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +348 -9
- package/dist/index.js.map +1 -1
- 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");
|
|
@@ -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
|
|
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,
|
|
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 {
|
|
@@ -11480,7 +11724,7 @@ var PairTokenStore = class {
|
|
|
11480
11724
|
};
|
|
11481
11725
|
}
|
|
11482
11726
|
/**
|
|
11483
|
-
*
|
|
11727
|
+
* What `consume` would answer right now, WITHOUT spending the token.
|
|
11484
11728
|
*
|
|
11485
11729
|
* Exists so a caller can reject a bad token before doing any work, and still
|
|
11486
11730
|
* spend the token only once the work has succeeded. A pair token is
|
|
@@ -11489,10 +11733,14 @@ var PairTokenStore = class {
|
|
|
11489
11733
|
* indistinguishable from an attacker replaying a photographed code, which is
|
|
11490
11734
|
* the one signal `design.md` §2.6 designates as replay detection.
|
|
11491
11735
|
*
|
|
11492
|
-
*
|
|
11493
|
-
*
|
|
11736
|
+
* **Named for its relationship to `consume`, deliberately.** It reserves
|
|
11737
|
+
* nothing and takes no lock, so two callers can both be told `{ ok: true }`
|
|
11738
|
+
* for the same token. A name like `verify` would read as a gate that had
|
|
11739
|
+
* decided something, and inviting a caller to act on this alone is precisely
|
|
11740
|
+
* the misuse the ordering it enables exists to prevent. A reader who sees
|
|
11741
|
+
* `wouldConsume` asks where the `consume` is, which is the right question.
|
|
11494
11742
|
*/
|
|
11495
|
-
|
|
11743
|
+
wouldConsume(token) {
|
|
11496
11744
|
const result = this.check(token);
|
|
11497
11745
|
return result.ok ? { ok: true } : result;
|
|
11498
11746
|
}
|
|
@@ -12652,7 +12900,7 @@ function createApiDeps(deps) {
|
|
|
12652
12900
|
}
|
|
12653
12901
|
|
|
12654
12902
|
// src/services/cache-integrity/cacheIntegrityMonitor.ts
|
|
12655
|
-
var
|
|
12903
|
+
var import_crypto12 = require("crypto");
|
|
12656
12904
|
var import_fs23 = require("fs");
|
|
12657
12905
|
|
|
12658
12906
|
// src/services/cache-integrity/alertStore.ts
|
|
@@ -12717,7 +12965,7 @@ function envInt(name, fallback) {
|
|
|
12717
12965
|
}
|
|
12718
12966
|
function fingerprintOf(ids) {
|
|
12719
12967
|
const sorted = [...ids].sort();
|
|
12720
|
-
return `sha256:${(0,
|
|
12968
|
+
return `sha256:${(0, import_crypto12.createHash)("sha256").update(sorted.join("\n")).digest("hex")}`;
|
|
12721
12969
|
}
|
|
12722
12970
|
var CacheIntegrityMonitor = class {
|
|
12723
12971
|
constructor(cache, wsHub, log10, cacheDir, rescan, runDuringReset) {
|
|
@@ -15223,7 +15471,7 @@ var StreamerServer = class {
|
|
|
15223
15471
|
runtimeStore = null;
|
|
15224
15472
|
// Identifies this streamer run. A registry row carrying a different id is a
|
|
15225
15473
|
// session that outlived the process that started it.
|
|
15226
|
-
streamerInstanceId = (0,
|
|
15474
|
+
streamerInstanceId = (0, import_crypto13.randomUUID)();
|
|
15227
15475
|
cacheMetadataRepo = null;
|
|
15228
15476
|
// Push registration + delivery state (C7). Null when the cache DB failed to
|
|
15229
15477
|
// open — registration then degrades to a no-op rather than 500ing.
|
|
@@ -16351,11 +16599,44 @@ var StreamerServer = class {
|
|
|
16351
16599
|
json(res, 400, { error: "Missing token or clientPublicKey" });
|
|
16352
16600
|
return;
|
|
16353
16601
|
}
|
|
16354
|
-
|
|
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
|
+
}
|
|
16610
|
+
const precheck = this.pairTokens.wouldConsume(token);
|
|
16355
16611
|
if (!precheck.ok) {
|
|
16612
|
+
if (precheck.reason === "used") {
|
|
16613
|
+
this.log.warn(
|
|
16614
|
+
"[pair] a pair token was replayed. If you did not just pair a device, check the paired-devices list and revoke anything you do not recognise.",
|
|
16615
|
+
{ event: "pair.token_replayed", ip }
|
|
16616
|
+
);
|
|
16617
|
+
}
|
|
16356
16618
|
json(res, 401, { error: `Pair token ${precheck.reason}` });
|
|
16357
16619
|
return;
|
|
16358
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
|
+
}
|
|
16359
16640
|
let sealed;
|
|
16360
16641
|
try {
|
|
16361
16642
|
sealed = seal(this.apiKey, clientPublicKey);
|
|
@@ -16379,14 +16660,59 @@ var StreamerServer = class {
|
|
|
16379
16660
|
try {
|
|
16380
16661
|
const name = typeof body?.deviceName === "string" ? body.deviceName.slice(0, 100) : null;
|
|
16381
16662
|
const preset = body?.readOnly === true ? "read-only" : "full";
|
|
16382
|
-
device = this.devicesRepo?.register({
|
|
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;
|
|
16383
16679
|
} catch (err) {
|
|
16384
16680
|
this.log.warn("[pair] device registration failed; pairing continues", {
|
|
16385
16681
|
event: "pair.device_register_failed",
|
|
16386
16682
|
err
|
|
16387
16683
|
});
|
|
16388
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
|
+
}
|
|
16389
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.
|
|
16390
16716
|
ciphertext: sealed.ciphertext,
|
|
16391
16717
|
nonce: sealed.nonce,
|
|
16392
16718
|
ephemeralPublicKey: sealed.ephemeralPublicKey,
|
|
@@ -16409,7 +16735,11 @@ var StreamerServer = class {
|
|
|
16409
16735
|
deviceId: device.deviceId,
|
|
16410
16736
|
deviceToken: device.deviceToken,
|
|
16411
16737
|
capabilities: device.capabilities
|
|
16412
|
-
}
|
|
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 }
|
|
16413
16743
|
});
|
|
16414
16744
|
}
|
|
16415
16745
|
rotateApiKey() {
|