@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.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;
|
|
@@ -11435,7 +11688,7 @@ var PairTokenStore = class {
|
|
|
11435
11688
|
};
|
|
11436
11689
|
}
|
|
11437
11690
|
/**
|
|
11438
|
-
*
|
|
11691
|
+
* What `consume` would answer right now, WITHOUT spending the token.
|
|
11439
11692
|
*
|
|
11440
11693
|
* Exists so a caller can reject a bad token before doing any work, and still
|
|
11441
11694
|
* spend the token only once the work has succeeded. A pair token is
|
|
@@ -11444,10 +11697,14 @@ var PairTokenStore = class {
|
|
|
11444
11697
|
* indistinguishable from an attacker replaying a photographed code, which is
|
|
11445
11698
|
* the one signal `design.md` §2.6 designates as replay detection.
|
|
11446
11699
|
*
|
|
11447
|
-
*
|
|
11448
|
-
*
|
|
11700
|
+
* **Named for its relationship to `consume`, deliberately.** It reserves
|
|
11701
|
+
* nothing and takes no lock, so two callers can both be told `{ ok: true }`
|
|
11702
|
+
* for the same token. A name like `verify` would read as a gate that had
|
|
11703
|
+
* decided something, and inviting a caller to act on this alone is precisely
|
|
11704
|
+
* the misuse the ordering it enables exists to prevent. A reader who sees
|
|
11705
|
+
* `wouldConsume` asks where the `consume` is, which is the right question.
|
|
11449
11706
|
*/
|
|
11450
|
-
|
|
11707
|
+
wouldConsume(token) {
|
|
11451
11708
|
const result = this.check(token);
|
|
11452
11709
|
return result.ok ? { ok: true } : result;
|
|
11453
11710
|
}
|
|
@@ -12609,7 +12866,7 @@ function createApiDeps(deps) {
|
|
|
12609
12866
|
}
|
|
12610
12867
|
|
|
12611
12868
|
// src/services/cache-integrity/cacheIntegrityMonitor.ts
|
|
12612
|
-
import { createHash as
|
|
12869
|
+
import { createHash as createHash5 } from "crypto";
|
|
12613
12870
|
import { existsSync as existsSync12 } from "fs";
|
|
12614
12871
|
|
|
12615
12872
|
// src/services/cache-integrity/alertStore.ts
|
|
@@ -12674,7 +12931,7 @@ function envInt(name, fallback) {
|
|
|
12674
12931
|
}
|
|
12675
12932
|
function fingerprintOf(ids) {
|
|
12676
12933
|
const sorted = [...ids].sort();
|
|
12677
|
-
return `sha256:${
|
|
12934
|
+
return `sha256:${createHash5("sha256").update(sorted.join("\n")).digest("hex")}`;
|
|
12678
12935
|
}
|
|
12679
12936
|
var CacheIntegrityMonitor = class {
|
|
12680
12937
|
constructor(cache, wsHub, log10, cacheDir, rescan, runDuringReset) {
|
|
@@ -16308,11 +16565,44 @@ var StreamerServer = class {
|
|
|
16308
16565
|
json(res, 400, { error: "Missing token or clientPublicKey" });
|
|
16309
16566
|
return;
|
|
16310
16567
|
}
|
|
16311
|
-
|
|
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
|
+
}
|
|
16576
|
+
const precheck = this.pairTokens.wouldConsume(token);
|
|
16312
16577
|
if (!precheck.ok) {
|
|
16578
|
+
if (precheck.reason === "used") {
|
|
16579
|
+
this.log.warn(
|
|
16580
|
+
"[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.",
|
|
16581
|
+
{ event: "pair.token_replayed", ip }
|
|
16582
|
+
);
|
|
16583
|
+
}
|
|
16313
16584
|
json(res, 401, { error: `Pair token ${precheck.reason}` });
|
|
16314
16585
|
return;
|
|
16315
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
|
+
}
|
|
16316
16606
|
let sealed;
|
|
16317
16607
|
try {
|
|
16318
16608
|
sealed = seal(this.apiKey, clientPublicKey);
|
|
@@ -16336,14 +16626,59 @@ var StreamerServer = class {
|
|
|
16336
16626
|
try {
|
|
16337
16627
|
const name = typeof body?.deviceName === "string" ? body.deviceName.slice(0, 100) : null;
|
|
16338
16628
|
const preset = body?.readOnly === true ? "read-only" : "full";
|
|
16339
|
-
device = this.devicesRepo?.register({
|
|
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;
|
|
16340
16645
|
} catch (err) {
|
|
16341
16646
|
this.log.warn("[pair] device registration failed; pairing continues", {
|
|
16342
16647
|
event: "pair.device_register_failed",
|
|
16343
16648
|
err
|
|
16344
16649
|
});
|
|
16345
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
|
+
}
|
|
16346
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.
|
|
16347
16682
|
ciphertext: sealed.ciphertext,
|
|
16348
16683
|
nonce: sealed.nonce,
|
|
16349
16684
|
ephemeralPublicKey: sealed.ephemeralPublicKey,
|
|
@@ -16366,7 +16701,11 @@ var StreamerServer = class {
|
|
|
16366
16701
|
deviceId: device.deviceId,
|
|
16367
16702
|
deviceToken: device.deviceToken,
|
|
16368
16703
|
capabilities: device.capabilities
|
|
16369
|
-
}
|
|
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 }
|
|
16370
16709
|
});
|
|
16371
16710
|
}
|
|
16372
16711
|
rotateApiKey() {
|