@threadbase-sh/streamer 1.57.0 → 1.58.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 +100 -16
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +100 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +100 -16
- package/dist/index.js.map +1 -1
- package/dist/runtime-migrations/004_add_device_e2ee.sql +47 -0
- package/package.json +1 -1
package/dist/index.d.cts
CHANGED
|
@@ -1204,6 +1204,11 @@ interface DeviceRow {
|
|
|
1204
1204
|
created_at: number;
|
|
1205
1205
|
last_seen_at: number | null;
|
|
1206
1206
|
revoked_at: number | null;
|
|
1207
|
+
/** Noise static public key, base64. Null on every row that predates E2EE. */
|
|
1208
|
+
e2ee_static_pub: string | null;
|
|
1209
|
+
/** The downgrade lock. 1 once this device has completed a handshake. */
|
|
1210
|
+
e2ee_required: number;
|
|
1211
|
+
e2ee_version: number | null;
|
|
1207
1212
|
}
|
|
1208
1213
|
/** A device as reported over the API. Deliberately carries no credential. */
|
|
1209
1214
|
interface DeviceView {
|
|
@@ -1213,6 +1218,16 @@ interface DeviceView {
|
|
|
1213
1218
|
createdAt: number;
|
|
1214
1219
|
lastSeenAt: number | null;
|
|
1215
1220
|
revokedAt: number | null;
|
|
1221
|
+
/**
|
|
1222
|
+
* Whether this device is pinned to encryption.
|
|
1223
|
+
*
|
|
1224
|
+
* A boolean, never the key and never a hash of it: `GET /api/devices` exists
|
|
1225
|
+
* so a user can spot a device they did not pair, and answering "is this one
|
|
1226
|
+
* encrypted" needs no key material to do it. Publishing the static key would
|
|
1227
|
+
* hand an attacker who reached this endpoint the value that identifies a
|
|
1228
|
+
* device.
|
|
1229
|
+
*/
|
|
1230
|
+
e2ee: boolean;
|
|
1216
1231
|
}
|
|
1217
1232
|
interface RegisteredDevice {
|
|
1218
1233
|
deviceId: string;
|
|
@@ -1229,6 +1244,8 @@ declare class DevicesRepository {
|
|
|
1229
1244
|
private touchStmt;
|
|
1230
1245
|
private deleteStmt;
|
|
1231
1246
|
private deleteRevokedStmt;
|
|
1247
|
+
private byE2eeStaticPubStmt;
|
|
1248
|
+
private repairStmt;
|
|
1232
1249
|
constructor(db: Database.Database);
|
|
1233
1250
|
/**
|
|
1234
1251
|
* Record a newly paired device and mint its token.
|
|
@@ -1241,7 +1258,16 @@ declare class DevicesRepository {
|
|
|
1241
1258
|
name?: string | null;
|
|
1242
1259
|
preset?: CapabilityPreset;
|
|
1243
1260
|
now?: number;
|
|
1261
|
+
/**
|
|
1262
|
+
* The device's Noise static public key, base64, when pairing completed a
|
|
1263
|
+
* handshake. Absent on a plaintext pairing, which stays exactly as it is
|
|
1264
|
+
* today: no key, no pin, no behaviour change.
|
|
1265
|
+
*/
|
|
1266
|
+
e2eeStaticPub?: string;
|
|
1267
|
+
e2eeVersion?: number;
|
|
1244
1268
|
}): RegisteredDevice;
|
|
1269
|
+
/** The device that owns a Noise static key, or null. */
|
|
1270
|
+
getByE2eeStaticPub(staticPub: string): DeviceRow | null;
|
|
1245
1271
|
/**
|
|
1246
1272
|
* Resolve a presented token to a device, or null.
|
|
1247
1273
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -1204,6 +1204,11 @@ interface DeviceRow {
|
|
|
1204
1204
|
created_at: number;
|
|
1205
1205
|
last_seen_at: number | null;
|
|
1206
1206
|
revoked_at: number | null;
|
|
1207
|
+
/** Noise static public key, base64. Null on every row that predates E2EE. */
|
|
1208
|
+
e2ee_static_pub: string | null;
|
|
1209
|
+
/** The downgrade lock. 1 once this device has completed a handshake. */
|
|
1210
|
+
e2ee_required: number;
|
|
1211
|
+
e2ee_version: number | null;
|
|
1207
1212
|
}
|
|
1208
1213
|
/** A device as reported over the API. Deliberately carries no credential. */
|
|
1209
1214
|
interface DeviceView {
|
|
@@ -1213,6 +1218,16 @@ interface DeviceView {
|
|
|
1213
1218
|
createdAt: number;
|
|
1214
1219
|
lastSeenAt: number | null;
|
|
1215
1220
|
revokedAt: number | null;
|
|
1221
|
+
/**
|
|
1222
|
+
* Whether this device is pinned to encryption.
|
|
1223
|
+
*
|
|
1224
|
+
* A boolean, never the key and never a hash of it: `GET /api/devices` exists
|
|
1225
|
+
* so a user can spot a device they did not pair, and answering "is this one
|
|
1226
|
+
* encrypted" needs no key material to do it. Publishing the static key would
|
|
1227
|
+
* hand an attacker who reached this endpoint the value that identifies a
|
|
1228
|
+
* device.
|
|
1229
|
+
*/
|
|
1230
|
+
e2ee: boolean;
|
|
1216
1231
|
}
|
|
1217
1232
|
interface RegisteredDevice {
|
|
1218
1233
|
deviceId: string;
|
|
@@ -1229,6 +1244,8 @@ declare class DevicesRepository {
|
|
|
1229
1244
|
private touchStmt;
|
|
1230
1245
|
private deleteStmt;
|
|
1231
1246
|
private deleteRevokedStmt;
|
|
1247
|
+
private byE2eeStaticPubStmt;
|
|
1248
|
+
private repairStmt;
|
|
1232
1249
|
constructor(db: Database.Database);
|
|
1233
1250
|
/**
|
|
1234
1251
|
* Record a newly paired device and mint its token.
|
|
@@ -1241,7 +1258,16 @@ declare class DevicesRepository {
|
|
|
1241
1258
|
name?: string | null;
|
|
1242
1259
|
preset?: CapabilityPreset;
|
|
1243
1260
|
now?: number;
|
|
1261
|
+
/**
|
|
1262
|
+
* The device's Noise static public key, base64, when pairing completed a
|
|
1263
|
+
* handshake. Absent on a plaintext pairing, which stays exactly as it is
|
|
1264
|
+
* today: no key, no pin, no behaviour change.
|
|
1265
|
+
*/
|
|
1266
|
+
e2eeStaticPub?: string;
|
|
1267
|
+
e2eeVersion?: number;
|
|
1244
1268
|
}): RegisteredDevice;
|
|
1269
|
+
/** The device that owns a Noise static key, or null. */
|
|
1270
|
+
getByE2eeStaticPub(staticPub: string): DeviceRow | null;
|
|
1245
1271
|
/**
|
|
1246
1272
|
* Resolve a presented token to a device, or null.
|
|
1247
1273
|
*
|
package/dist/index.js
CHANGED
|
@@ -1252,11 +1252,19 @@ var CodexPtyRunner = class {
|
|
|
1252
1252
|
onStatusChange;
|
|
1253
1253
|
onPhaseChange;
|
|
1254
1254
|
onReady;
|
|
1255
|
-
//
|
|
1256
|
-
//
|
|
1255
|
+
// Every Codex prompt the client can answer — startup gates (directory trust,
|
|
1256
|
+
// hooks review), command approvals, and the rate-limit model picker — is
|
|
1257
|
+
// broadcast through this one channel; null dismisses the card once the prompt
|
|
1258
|
+
// leaves the screen.
|
|
1259
|
+
//
|
|
1260
|
+
// Deliberately NOT onLiveQuestion/onLiveQuestionGone, which is Claude's
|
|
1261
|
+
// AskUserQuestion transport. Both channels land on the same mobile
|
|
1262
|
+
// QuestionCard, and the permission one is the correct fit for Codex: its menus
|
|
1263
|
+
// are answered by the option's real on-screen number (parseCodexNumberedOptions
|
|
1264
|
+
// emits `answerKeys: "2\r"`), which is exactly what `permissionIndices` carries
|
|
1265
|
+
// and what AskUserQuestion's down-arrow-count model cannot express. Wiring the
|
|
1266
|
+
// question channel as well would be a second path to the same card.
|
|
1257
1267
|
onPermissionChange;
|
|
1258
|
-
onLiveQuestion;
|
|
1259
|
-
onLiveQuestionGone;
|
|
1260
1268
|
onUserMessage;
|
|
1261
1269
|
log;
|
|
1262
1270
|
// Tracks sessions whose PTY has spawned but Codex hasn't yet reached its
|
|
@@ -1306,8 +1314,6 @@ var CodexPtyRunner = class {
|
|
|
1306
1314
|
this.onPhaseChange = options.onPhaseChange;
|
|
1307
1315
|
this.onReady = options.onReady;
|
|
1308
1316
|
this.onPermissionChange = options.onPermissionChange;
|
|
1309
|
-
this.onLiveQuestion = options.onLiveQuestion;
|
|
1310
|
-
this.onLiveQuestionGone = options.onLiveQuestionGone;
|
|
1311
1317
|
this.onUserMessage = options.onUserMessage;
|
|
1312
1318
|
this.log = options.logger ?? getLogger("codex-pty");
|
|
1313
1319
|
}
|
|
@@ -4135,7 +4141,12 @@ function toDeviceView(row) {
|
|
|
4135
4141
|
capabilities: parseCapabilities(row.capabilities),
|
|
4136
4142
|
createdAt: row.created_at,
|
|
4137
4143
|
lastSeenAt: row.last_seen_at,
|
|
4138
|
-
revokedAt: row.revoked_at
|
|
4144
|
+
revokedAt: row.revoked_at,
|
|
4145
|
+
// Reported from `e2ee_required` rather than from the key's presence. The
|
|
4146
|
+
// two agree today, but they answer different questions — the key is what a
|
|
4147
|
+
// handshake is checked against, `e2ee_required` is whether plaintext is
|
|
4148
|
+
// refused — and it is the second one a user is asking about.
|
|
4149
|
+
e2ee: row.e2ee_required === 1
|
|
4139
4150
|
};
|
|
4140
4151
|
}
|
|
4141
4152
|
var DevicesRepository = class {
|
|
@@ -4147,14 +4158,30 @@ var DevicesRepository = class {
|
|
|
4147
4158
|
touchStmt;
|
|
4148
4159
|
deleteStmt;
|
|
4149
4160
|
deleteRevokedStmt;
|
|
4161
|
+
byE2eeStaticPubStmt;
|
|
4162
|
+
repairStmt;
|
|
4150
4163
|
constructor(db) {
|
|
4151
4164
|
this.insertStmt = db.prepare(`
|
|
4152
4165
|
INSERT INTO devices (
|
|
4153
|
-
device_id, public_key, token_hash, name, capabilities, created_at
|
|
4166
|
+
device_id, public_key, token_hash, name, capabilities, created_at,
|
|
4167
|
+
e2ee_static_pub, e2ee_required, e2ee_version
|
|
4154
4168
|
) VALUES (
|
|
4155
|
-
@device_id, @public_key, @token_hash, @name, @capabilities, @created_at
|
|
4169
|
+
@device_id, @public_key, @token_hash, @name, @capabilities, @created_at,
|
|
4170
|
+
@e2ee_static_pub, @e2ee_required, @e2ee_version
|
|
4156
4171
|
)
|
|
4157
4172
|
`);
|
|
4173
|
+
this.byE2eeStaticPubStmt = db.prepare("SELECT * FROM devices WHERE e2ee_static_pub = ?");
|
|
4174
|
+
this.repairStmt = db.prepare(`
|
|
4175
|
+
UPDATE devices SET
|
|
4176
|
+
public_key = @public_key,
|
|
4177
|
+
token_hash = @token_hash,
|
|
4178
|
+
name = @name,
|
|
4179
|
+
capabilities = @capabilities,
|
|
4180
|
+
e2ee_required = 1,
|
|
4181
|
+
e2ee_version = @e2ee_version,
|
|
4182
|
+
revoked_at = NULL
|
|
4183
|
+
WHERE device_id = @device_id
|
|
4184
|
+
`);
|
|
4158
4185
|
this.byTokenHashStmt = db.prepare("SELECT * FROM devices WHERE token_hash = ?");
|
|
4159
4186
|
this.byIdStmt = db.prepare("SELECT * FROM devices WHERE device_id = ?");
|
|
4160
4187
|
this.listStmt = db.prepare("SELECT * FROM devices ORDER BY created_at DESC");
|
|
@@ -4170,19 +4197,42 @@ var DevicesRepository = class {
|
|
|
4170
4197
|
* moment it exists outside the client.
|
|
4171
4198
|
*/
|
|
4172
4199
|
register(args) {
|
|
4173
|
-
const deviceId = randomUUID3();
|
|
4174
4200
|
const deviceToken = generateDeviceToken();
|
|
4175
4201
|
const capabilities = capabilitiesForPreset(args.preset ?? "full");
|
|
4202
|
+
const now = args.now ?? Date.now();
|
|
4203
|
+
const existing = args.e2eeStaticPub ? this.byE2eeStaticPubStmt.get(args.e2eeStaticPub) : void 0;
|
|
4204
|
+
if (existing) {
|
|
4205
|
+
this.repairStmt.run({
|
|
4206
|
+
device_id: existing.device_id,
|
|
4207
|
+
public_key: args.publicKey,
|
|
4208
|
+
token_hash: hashDeviceToken(deviceToken),
|
|
4209
|
+
name: args.name ?? null,
|
|
4210
|
+
capabilities: JSON.stringify(capabilities),
|
|
4211
|
+
e2ee_version: args.e2eeVersion ?? null
|
|
4212
|
+
});
|
|
4213
|
+
return { deviceId: existing.device_id, deviceToken, capabilities };
|
|
4214
|
+
}
|
|
4215
|
+
const deviceId = randomUUID3();
|
|
4176
4216
|
this.insertStmt.run({
|
|
4177
4217
|
device_id: deviceId,
|
|
4178
4218
|
public_key: args.publicKey,
|
|
4179
4219
|
token_hash: hashDeviceToken(deviceToken),
|
|
4180
4220
|
name: args.name ?? null,
|
|
4181
4221
|
capabilities: JSON.stringify(capabilities),
|
|
4182
|
-
created_at:
|
|
4222
|
+
created_at: now,
|
|
4223
|
+
e2ee_static_pub: args.e2eeStaticPub ?? null,
|
|
4224
|
+
// The downgrade lock, set in the same write that records the key. Once
|
|
4225
|
+
// set, nothing a client can send clears it (design.md §6.3) — which is
|
|
4226
|
+
// what makes it a lock rather than a preference.
|
|
4227
|
+
e2ee_required: args.e2eeStaticPub ? 1 : 0,
|
|
4228
|
+
e2ee_version: args.e2eeVersion ?? null
|
|
4183
4229
|
});
|
|
4184
4230
|
return { deviceId, deviceToken, capabilities };
|
|
4185
4231
|
}
|
|
4232
|
+
/** The device that owns a Noise static key, or null. */
|
|
4233
|
+
getByE2eeStaticPub(staticPub) {
|
|
4234
|
+
return this.byE2eeStaticPubStmt.get(staticPub) ?? null;
|
|
4235
|
+
}
|
|
4186
4236
|
/**
|
|
4187
4237
|
* Resolve a presented token to a device, or null.
|
|
4188
4238
|
*
|
|
@@ -11302,7 +11352,37 @@ var PairTokenStore = class {
|
|
|
11302
11352
|
expiresInSeconds: Math.floor(this.ttlMs / 1e3)
|
|
11303
11353
|
};
|
|
11304
11354
|
}
|
|
11355
|
+
/**
|
|
11356
|
+
* Whether `consume` would succeed right now, WITHOUT spending the token.
|
|
11357
|
+
*
|
|
11358
|
+
* Exists so a caller can reject a bad token before doing any work, and still
|
|
11359
|
+
* spend the token only once the work has succeeded. A pair token is
|
|
11360
|
+
* single-use and lives 180 seconds, so spending it on a request that then
|
|
11361
|
+
* fails costs the user a whole new QR — and, worse, makes their retry
|
|
11362
|
+
* indistinguishable from an attacker replaying a photographed code, which is
|
|
11363
|
+
* the one signal `design.md` §2.6 designates as replay detection.
|
|
11364
|
+
*
|
|
11365
|
+
* Advisory, not a reservation: it takes no lock and holds nothing. The
|
|
11366
|
+
* authoritative answer is still `consume`'s.
|
|
11367
|
+
*/
|
|
11368
|
+
verify(token) {
|
|
11369
|
+
const result = this.check(token);
|
|
11370
|
+
return result.ok ? { ok: true } : result;
|
|
11371
|
+
}
|
|
11305
11372
|
consume(token) {
|
|
11373
|
+
const result = this.check(token);
|
|
11374
|
+
if (!result.ok) return result;
|
|
11375
|
+
result.record.used = true;
|
|
11376
|
+
return { ok: true };
|
|
11377
|
+
}
|
|
11378
|
+
/**
|
|
11379
|
+
* The shared predicate behind `verify` and `consume`.
|
|
11380
|
+
*
|
|
11381
|
+
* One implementation on purpose: two copies of "is this token usable" is two
|
|
11382
|
+
* places for the expiry or single-use rule to drift, and a drift in this
|
|
11383
|
+
* direction fails open.
|
|
11384
|
+
*/
|
|
11385
|
+
check(token) {
|
|
11306
11386
|
const record2 = this.current;
|
|
11307
11387
|
if (!record2 || record2.token !== token) return { ok: false, reason: "unknown" };
|
|
11308
11388
|
if (Date.now() > record2.expiresAt) {
|
|
@@ -11310,8 +11390,7 @@ var PairTokenStore = class {
|
|
|
11310
11390
|
return { ok: false, reason: "expired" };
|
|
11311
11391
|
}
|
|
11312
11392
|
if (record2.used) return { ok: false, reason: "used" };
|
|
11313
|
-
|
|
11314
|
-
return { ok: true };
|
|
11393
|
+
return { ok: true, record: record2 };
|
|
11315
11394
|
}
|
|
11316
11395
|
peek() {
|
|
11317
11396
|
return this.current;
|
|
@@ -16103,9 +16182,9 @@ var StreamerServer = class {
|
|
|
16103
16182
|
json(res, 400, { error: "Missing token or clientPublicKey" });
|
|
16104
16183
|
return;
|
|
16105
16184
|
}
|
|
16106
|
-
const
|
|
16107
|
-
if (!
|
|
16108
|
-
json(res, 401, { error: `Pair token ${
|
|
16185
|
+
const precheck = this.pairTokens.verify(token);
|
|
16186
|
+
if (!precheck.ok) {
|
|
16187
|
+
json(res, 401, { error: `Pair token ${precheck.reason}` });
|
|
16109
16188
|
return;
|
|
16110
16189
|
}
|
|
16111
16190
|
let sealed;
|
|
@@ -16116,6 +16195,11 @@ var StreamerServer = class {
|
|
|
16116
16195
|
json(res, 400, { error: message });
|
|
16117
16196
|
return;
|
|
16118
16197
|
}
|
|
16198
|
+
const result = this.pairTokens.consume(token);
|
|
16199
|
+
if (!result.ok) {
|
|
16200
|
+
json(res, 401, { error: `Pair token ${result.reason}` });
|
|
16201
|
+
return;
|
|
16202
|
+
}
|
|
16119
16203
|
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
16120
16204
|
this.log.info(`[pair] token exchanged from ${ip} at ${ts}`, {
|
|
16121
16205
|
event: "pair.token_exchanged",
|