@camstack/system 1.2.380 → 1.2.382
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/addon-runner.js +4 -2
- package/dist/addon-runner.mjs +4 -2
- package/dist/addon-utils.js +1 -1
- package/dist/addon-utils.mjs +1 -1
- package/dist/auth/admin-token-manager.d.ts +75 -0
- package/dist/auth/api-key-manager.d.ts +1 -14
- package/dist/auth/auth-audit-log.d.ts +59 -0
- package/dist/auth/auth-manager.d.ts +28 -16
- package/dist/auth/cidr.d.ts +22 -0
- package/dist/auth/login-session-manager.d.ts +158 -0
- package/dist/auth/user-management-internal.d.ts +69 -0
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/autotrack/index.js +1 -1
- package/dist/builtins/autotrack/index.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.d.ts +14 -0
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +291 -3631
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +284 -3617
- package/dist/builtins/backup-orchestrator/encryption-key-store.d.ts +36 -0
- package/dist/builtins/backup-orchestrator/manifest-store.d.ts +19 -1
- package/dist/builtins/backup-orchestrator/restore-archive.d.ts +24 -0
- package/dist/builtins/calendars/calendars.addon.js +1 -1
- package/dist/builtins/calendars/calendars.addon.mjs +1 -1
- package/dist/builtins/camera-grid/index.js +1 -1
- package/dist/builtins/camera-grid/index.mjs +1 -1
- package/dist/builtins/composer/composer.addon.js +2 -2
- package/dist/builtins/composer/composer.addon.mjs +2 -2
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
- package/dist/builtins/device-manager/device-aggregation.d.ts +12 -0
- package/dist/builtins/device-manager/device-form-secrets.d.ts +25 -0
- package/dist/builtins/device-manager/device-manager.addon.d.ts +2 -0
- package/dist/builtins/device-manager/device-manager.addon.js +165 -26
- package/dist/builtins/device-manager/device-manager.addon.mjs +165 -26
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/keychain/index.js +1108 -733
- package/dist/builtins/keychain/index.mjs +1108 -733
- package/dist/builtins/keychain/keychain-reset-trigger.d.ts +25 -0
- package/dist/builtins/keychain/keychain-service.d.ts +36 -3
- package/dist/builtins/keychain/keychain-store.d.ts +24 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.d.ts +16 -0
- package/dist/builtins/local-auth/local-auth.addon.js +1021 -118
- package/dist/builtins/local-auth/local-auth.addon.mjs +1034 -141
- package/dist/builtins/local-network/local-network.addon.js +3 -3
- package/dist/builtins/local-network/local-network.addon.mjs +3 -3
- package/dist/builtins/loki-logging/index.js +97 -29
- package/dist/builtins/loki-logging/index.mjs +97 -29
- package/dist/builtins/loki-logging/loki-logging.addon.d.ts +7 -23
- package/dist/builtins/loki-logging/retire-enabled-flag.d.ts +23 -0
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +2 -2
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +2 -2
- package/dist/builtins/system-backup/backup-encryption.d.ts +57 -0
- package/dist/builtins/system-backup/system-backup.service.d.ts +16 -1
- package/dist/builtins/system-config/cluster-secret-keeper.d.ts +7 -0
- package/dist/builtins/system-config/system-config.addon.d.ts +13 -3
- package/dist/builtins/system-config/system-config.addon.js +324 -38
- package/dist/builtins/system-config/system-config.addon.mjs +312 -39
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{child-cap-dispatch-CZeRUgYF.js → child-cap-dispatch-BrMdKHfp.js} +6 -3
- package/dist/{child-cap-dispatch-CpjohToR.mjs → child-cap-dispatch-WsYPNvnK.mjs} +10 -7
- package/dist/{cluster-secret-Dg0OOOSr.js → cluster-secret-BEPLh3LL.js} +1 -1
- package/dist/{cluster-secret-BDue-uJj.mjs → cluster-secret-DhPdZ0VA.mjs} +1 -1
- package/dist/{composition-sources-CHvLMrFI.js → composition-sources-C6ZynCZ2.js} +1 -1
- package/dist/{composition-sources-DT_uJwI3.mjs → composition-sources-XqRX51db.mjs} +1 -1
- package/dist/{dist-B9oFw2jh.mjs → dist-CP5TTjwd.mjs} +1713 -157
- package/dist/{dist-Cc6KOdY-.js → dist-tnAO12ak.js} +1754 -156
- package/dist/{event-loop-stall-monitor-mk6WGxir.js → event-loop-stall-monitor-9kR0sAPS.js} +15 -15
- package/dist/{event-loop-stall-monitor-anUH2IE1.mjs → event-loop-stall-monitor-WkssIXjq.mjs} +15 -15
- package/dist/{hub-cap-forward-CH6_VWVO.mjs → hub-cap-forward-CDiN1p98.mjs} +18 -2
- package/dist/{hub-cap-forward-COCmYaup.js → hub-cap-forward-W8oEm4ME.js} +23 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.js +3341 -79
- package/dist/index.mjs +3567 -354
- package/dist/{jwt-signing-key-Qhde8r82.mjs → jwt-signing-key-CAbozwvF.mjs} +1 -1
- package/dist/{jwt-signing-key-C2rE3wH6.js → jwt-signing-key-Dgf8sIXY.js} +1 -1
- package/dist/kernel/index.d.ts +12 -0
- package/dist/kernel/moleculer/broker-factory.d.ts +8 -1
- package/dist/kernel/moleculer/child-addon-call-dispatch.d.ts +1 -0
- package/dist/kernel/moleculer/hub-service.d.ts +9 -0
- package/dist/kernel/moleculer/mesh-access-middleware.d.ts +28 -0
- package/dist/kernel/moleculer/mesh-auth-mode.d.ts +64 -0
- package/dist/kernel/moleculer/mesh-authenticator.d.ts +71 -0
- package/dist/kernel/moleculer/mesh-auto-enforce.d.ts +26 -0
- package/dist/kernel/moleculer/mesh-join-client.d.ts +51 -0
- package/dist/kernel/moleculer/mesh-join-proof.d.ts +39 -0
- package/dist/kernel/system-settings-policy.d.ts +4 -3
- package/dist/kernel/transport/hub-cap-forward.d.ts +14 -3
- package/dist/kernel/transport/index.d.ts +1 -1
- package/dist/restore-archive-BV39nY46.mjs +4045 -0
- package/dist/restore-archive-CbvrfPuB.js +4139 -0
- package/dist/{retired-settings-keys-YOOKLA3v.js → retired-settings-keys-4bHpL-0m.js} +1 -1
- package/dist/{retired-settings-keys-DlZ3VV6x.mjs → retired-settings-keys-CzpIkwS-.mjs} +1 -1
- package/dist/{tls-CJHogwTl.mjs → tls-4cRH24Id.mjs} +3 -3
- package/package.json +1 -1
- package/dist/{tls-CCMX7MjX.js → tls-CljF057D.js} +1 -1
|
@@ -3,9 +3,9 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
7
|
-
const require_jwt_signing_key = require("../../jwt-signing-key-
|
|
8
|
-
const require_cluster_secret = require("../../cluster-secret-
|
|
6
|
+
const require_dist = require("../../dist-tnAO12ak.js");
|
|
7
|
+
const require_jwt_signing_key = require("../../jwt-signing-key-Dgf8sIXY.js");
|
|
8
|
+
const require_cluster_secret = require("../../cluster-secret-BEPLh3LL.js");
|
|
9
9
|
const require_platform_key_source = require("../../platform-key-source-Di7HrlGR.js");
|
|
10
10
|
let node_crypto = require("node:crypto");
|
|
11
11
|
let node_path = require("node:path");
|
|
@@ -237,406 +237,120 @@ async function writeKeyFileAt(file, key, opts) {
|
|
|
237
237
|
return movedAside;
|
|
238
238
|
}
|
|
239
239
|
//#endregion
|
|
240
|
-
//#region src/builtins/keychain/keychain-
|
|
241
|
-
/**
|
|
242
|
-
* @durable class=config owner=keychain
|
|
243
|
-
* write="`keychain.put` upserts one row per ref; `delete` removes it"
|
|
244
|
-
* retention="none — a row lives until its owner deletes the ref or the
|
|
245
|
-
* operator deletes it as an orphan. Losing the table loses every secret
|
|
246
|
-
* value; the settings blobs then point at refs that resolve `missing`."
|
|
247
|
-
*/
|
|
248
|
-
var KEYCHAIN_SECRETS_COLLECTION = "keychain:secrets";
|
|
249
|
-
/**
|
|
250
|
-
* @durable class=config owner=keychain
|
|
251
|
-
* write="first boot writes the wrapped data key; `rotateMasterKey` adds a
|
|
252
|
-
* wrap and retires the previous one"
|
|
253
|
-
* retention="retired wraps until `purgeRetiredKeys`. Losing the table with
|
|
254
|
-
* the secrets intact makes every value undecryptable."
|
|
255
|
-
*/
|
|
256
|
-
var KEYCHAIN_KEYS_COLLECTION = "keychain:keys";
|
|
240
|
+
//#region src/builtins/keychain/keychain-reset-trigger.ts
|
|
257
241
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
242
|
+
* The one-shot `RESET_KEYCHAIN` trigger file — the way back into a hub whose
|
|
243
|
+
* master key is lost (operator decision 2026-10-09; plan §2).
|
|
244
|
+
*
|
|
245
|
+
* ## Why a file
|
|
246
|
+
*
|
|
247
|
+
* A locked keychain cannot be reset from System → Secrets: the session signing
|
|
248
|
+
* key lives IN the keychain (D797), so no login and no bearer token verifies,
|
|
249
|
+
* and every admin credential is a signing-key JWT. Whoever can write the data
|
|
250
|
+
* directory can already read the default master key, so a file there is the
|
|
251
|
+
* same trust boundary as the key itself — and it needs nothing but host access
|
|
252
|
+
* (works in a stopped container's volume, no extra binary). The pattern is
|
|
253
|
+
* `RESET_ADMIN_PASSWORD`'s (`local-auth/admin-account-boot.ts`).
|
|
254
|
+
*
|
|
255
|
+
* ## Behaviour
|
|
256
|
+
*
|
|
257
|
+
* Checked once at keychain boot, right after the unlock:
|
|
258
|
+
*
|
|
259
|
+
* - absent → nothing;
|
|
260
|
+
* - the keychain is UNLOCKED → ignored and deleted, with a warning. A working
|
|
261
|
+
* store is never destroyed by a file someone forgot;
|
|
262
|
+
* - locked for a key reason (`wrong-key`, `key-missing`, `key-file-unreadable`)
|
|
263
|
+
* → `KeychainService.resetAs('trigger-file')`: values and wrapped keys
|
|
264
|
+
* archived, a new master key and data key, the system-held markers cleared,
|
|
265
|
+
* the file deleted, and an error-level banner naming what to re-enter;
|
|
266
|
+
* - a reset that cannot end unlocked (a configured key file that is missing)
|
|
267
|
+
* is refused BEFORE anything is archived; the file is still deleted (one
|
|
268
|
+
* shot — fix the key source and create it again);
|
|
269
|
+
* - locked because the STORE cannot be read → left armed: nothing could be
|
|
270
|
+
* reset, and the next boot that reaches the store decides.
|
|
271
|
+
*
|
|
272
|
+
* NO environment variable arms it. A flag left in a container template would
|
|
273
|
+
* re-arm on every boot; a file the hub deletes cannot.
|
|
261
274
|
*/
|
|
262
|
-
|
|
263
|
-
var
|
|
264
|
-
|
|
265
|
-
var
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
{
|
|
273
|
-
name: "owner",
|
|
274
|
-
type: "TEXT",
|
|
275
|
-
notNull: true
|
|
276
|
-
},
|
|
277
|
-
{
|
|
278
|
-
name: "scope",
|
|
279
|
-
type: "TEXT",
|
|
280
|
-
notNull: true
|
|
281
|
-
},
|
|
282
|
-
{
|
|
283
|
-
name: "fieldPath",
|
|
284
|
-
type: "TEXT",
|
|
285
|
-
notNull: true
|
|
286
|
-
},
|
|
287
|
-
{
|
|
288
|
-
name: "ciphertext",
|
|
289
|
-
type: "TEXT",
|
|
290
|
-
notNull: true
|
|
291
|
-
},
|
|
292
|
-
{
|
|
293
|
-
name: "keyId",
|
|
294
|
-
type: "TEXT",
|
|
295
|
-
notNull: true
|
|
296
|
-
},
|
|
297
|
-
{
|
|
298
|
-
name: "version",
|
|
299
|
-
type: "INTEGER",
|
|
300
|
-
notNull: true
|
|
301
|
-
},
|
|
302
|
-
{
|
|
303
|
-
name: "createdAt",
|
|
304
|
-
type: "INTEGER",
|
|
305
|
-
notNull: true
|
|
306
|
-
},
|
|
307
|
-
{
|
|
308
|
-
name: "updatedAt",
|
|
309
|
-
type: "INTEGER",
|
|
310
|
-
notNull: true
|
|
311
|
-
},
|
|
312
|
-
{
|
|
313
|
-
name: "updatedBy",
|
|
314
|
-
type: "TEXT",
|
|
315
|
-
notNull: true
|
|
316
|
-
},
|
|
317
|
-
{
|
|
318
|
-
name: "lastReadAt",
|
|
319
|
-
type: "INTEGER"
|
|
320
|
-
}
|
|
321
|
-
];
|
|
322
|
-
var KEY_COLUMNS = [
|
|
323
|
-
{
|
|
324
|
-
name: "wrapId",
|
|
325
|
-
type: "TEXT",
|
|
326
|
-
primaryKey: true,
|
|
327
|
-
notNull: true
|
|
328
|
-
},
|
|
329
|
-
{
|
|
330
|
-
name: "keyId",
|
|
331
|
-
type: "TEXT",
|
|
332
|
-
notNull: true
|
|
333
|
-
},
|
|
334
|
-
{
|
|
335
|
-
name: "wrappedDek",
|
|
336
|
-
type: "TEXT",
|
|
337
|
-
notNull: true
|
|
338
|
-
},
|
|
339
|
-
{
|
|
340
|
-
name: "kekFingerprint",
|
|
341
|
-
type: "TEXT",
|
|
342
|
-
notNull: true
|
|
343
|
-
},
|
|
344
|
-
{
|
|
345
|
-
name: "kekSource",
|
|
346
|
-
type: "TEXT",
|
|
347
|
-
notNull: true
|
|
348
|
-
},
|
|
349
|
-
{
|
|
350
|
-
name: "createdAt",
|
|
351
|
-
type: "INTEGER",
|
|
352
|
-
notNull: true
|
|
353
|
-
},
|
|
354
|
-
{
|
|
355
|
-
name: "state",
|
|
356
|
-
type: "TEXT",
|
|
357
|
-
notNull: true
|
|
358
|
-
},
|
|
359
|
-
{
|
|
360
|
-
name: "recoveryDownloadedAt",
|
|
361
|
-
type: "INTEGER"
|
|
362
|
-
}
|
|
363
|
-
];
|
|
364
|
-
var AUDIT_COLUMNS = [
|
|
365
|
-
{
|
|
366
|
-
name: "id",
|
|
367
|
-
type: "TEXT",
|
|
368
|
-
primaryKey: true,
|
|
369
|
-
notNull: true
|
|
370
|
-
},
|
|
371
|
-
{
|
|
372
|
-
name: "at",
|
|
373
|
-
type: "INTEGER",
|
|
374
|
-
notNull: true
|
|
375
|
-
},
|
|
376
|
-
{
|
|
377
|
-
name: "action",
|
|
378
|
-
type: "TEXT",
|
|
379
|
-
notNull: true
|
|
380
|
-
},
|
|
381
|
-
{
|
|
382
|
-
name: "actor",
|
|
383
|
-
type: "TEXT",
|
|
384
|
-
notNull: true
|
|
385
|
-
},
|
|
386
|
-
{
|
|
387
|
-
name: "ref",
|
|
388
|
-
type: "TEXT"
|
|
389
|
-
},
|
|
390
|
-
{
|
|
391
|
-
name: "detail",
|
|
392
|
-
type: "TEXT"
|
|
393
|
-
}
|
|
394
|
-
];
|
|
395
|
-
function str(data, key) {
|
|
396
|
-
const v = data[key];
|
|
397
|
-
return typeof v === "string" ? v : "";
|
|
398
|
-
}
|
|
399
|
-
function num(data, key) {
|
|
400
|
-
const v = data[key];
|
|
401
|
-
return typeof v === "number" ? v : Number(v ?? 0);
|
|
402
|
-
}
|
|
403
|
-
function numOrNull(data, key) {
|
|
404
|
-
const v = data[key];
|
|
405
|
-
if (v === null || v === void 0) return null;
|
|
406
|
-
const n = typeof v === "number" ? v : Number(v);
|
|
407
|
-
return Number.isFinite(n) ? n : null;
|
|
408
|
-
}
|
|
409
|
-
function strOrNull(data, key) {
|
|
410
|
-
const v = data[key];
|
|
411
|
-
return typeof v === "string" ? v : null;
|
|
412
|
-
}
|
|
413
|
-
function toSecretRow(id, data) {
|
|
414
|
-
return {
|
|
415
|
-
ref: str(data, "ref") || id,
|
|
416
|
-
owner: str(data, "owner"),
|
|
417
|
-
scope: str(data, "scope"),
|
|
418
|
-
fieldPath: str(data, "fieldPath"),
|
|
419
|
-
ciphertext: str(data, "ciphertext"),
|
|
420
|
-
keyId: str(data, "keyId"),
|
|
421
|
-
version: num(data, "version"),
|
|
422
|
-
createdAt: num(data, "createdAt"),
|
|
423
|
-
updatedAt: num(data, "updatedAt"),
|
|
424
|
-
updatedBy: str(data, "updatedBy"),
|
|
425
|
-
lastReadAt: numOrNull(data, "lastReadAt")
|
|
426
|
-
};
|
|
275
|
+
/** The trigger's name, inside `<dataDir>/.keychain/`. */
|
|
276
|
+
var KEYCHAIN_RESET_TRIGGER = "RESET_KEYCHAIN";
|
|
277
|
+
/** Lock reasons a reset can cure — the master key, not the store. */
|
|
278
|
+
var RESETTABLE_REASONS = new Set([
|
|
279
|
+
"wrong-key",
|
|
280
|
+
"key-missing",
|
|
281
|
+
"key-file-unreadable"
|
|
282
|
+
]);
|
|
283
|
+
function keychainResetTriggerPath(dataDir) {
|
|
284
|
+
return node_path.join(dataDir, ".keychain", KEYCHAIN_RESET_TRIGGER);
|
|
427
285
|
}
|
|
428
|
-
function
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
kekSource: str(data, "kekSource"),
|
|
436
|
-
createdAt: num(data, "createdAt"),
|
|
437
|
-
state: state === "retired" ? "retired" : "active",
|
|
438
|
-
recoveryDownloadedAt: numOrNull(data, "recoveryDownloadedAt")
|
|
439
|
-
};
|
|
286
|
+
async function triggerExists(file) {
|
|
287
|
+
try {
|
|
288
|
+
await node_fs.promises.access(file);
|
|
289
|
+
return true;
|
|
290
|
+
} catch {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
440
293
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
"initialize"
|
|
451
|
-
]);
|
|
452
|
-
function isAuditAction(value) {
|
|
453
|
-
return AUDIT_ACTIONS.has(value);
|
|
294
|
+
async function consume(file, logger) {
|
|
295
|
+
try {
|
|
296
|
+
await node_fs.promises.rm(file, { force: true });
|
|
297
|
+
} catch (err) {
|
|
298
|
+
logger.error("Could not delete the RESET_KEYCHAIN trigger — delete it by hand, or the next boot with a locked keychain resets it again", {
|
|
299
|
+
trigger: file,
|
|
300
|
+
error: err instanceof Error ? err.message : String(err)
|
|
301
|
+
});
|
|
302
|
+
}
|
|
454
303
|
}
|
|
455
|
-
function
|
|
456
|
-
const
|
|
457
|
-
|
|
304
|
+
async function applyKeychainResetTrigger(input) {
|
|
305
|
+
const { service, logger } = input;
|
|
306
|
+
const file = keychainResetTriggerPath(input.dataDir);
|
|
307
|
+
if (!await triggerExists(file)) return { kind: "absent" };
|
|
308
|
+
const state = service.getState();
|
|
309
|
+
if (state.state === "ready") {
|
|
310
|
+
logger.warn("RESET_KEYCHAIN ignored: the keychain is unlocked, so nothing is lost and nothing is reset. The trigger file was deleted.", { trigger: file });
|
|
311
|
+
await consume(file, logger);
|
|
312
|
+
return { kind: "ignored-unlocked" };
|
|
313
|
+
}
|
|
314
|
+
if (!RESETTABLE_REASONS.has(state.reason)) {
|
|
315
|
+
logger.error("RESET_KEYCHAIN not acted on yet: the keychain is locked because its store cannot be read, not because of the master key. The trigger stays until a boot can reach the store.", {
|
|
316
|
+
trigger: file,
|
|
317
|
+
reason: state.reason
|
|
318
|
+
});
|
|
319
|
+
return {
|
|
320
|
+
kind: "deferred",
|
|
321
|
+
reason: state.reason
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
let record;
|
|
325
|
+
try {
|
|
326
|
+
record = await service.resetAs(require_dist.KEYCHAIN_RESET_TRIGGER_ACTOR);
|
|
327
|
+
} catch (err) {
|
|
328
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
329
|
+
logger.error(`RESET_KEYCHAIN refused — nothing was archived, the keychain is still locked: ${error}. The trigger file was deleted; fix the key source and create it again.`, {
|
|
330
|
+
trigger: file,
|
|
331
|
+
reason: state.reason
|
|
332
|
+
});
|
|
333
|
+
await consume(file, logger);
|
|
334
|
+
return {
|
|
335
|
+
kind: "refused",
|
|
336
|
+
error
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
await consume(file, logger);
|
|
340
|
+
const toReenter = ((await service.resetReport().catch(() => null))?.pendingReentry ?? []).map((m) => `${m.owner}:${m.scope}:${m.fieldPath}`);
|
|
341
|
+
logger.error("*** KEYCHAIN RESET by the RESET_KEYCHAIN trigger file *** Every stored secret was archived (unreadable without the lost master key) and a new master key and data key were created. Every secret field now reads \"not set\" — re-enter them (System → Secrets lists them). The session signing key is regenerated: everyone signs in again. The cluster secret was cleared: set it again in System → Cluster if agents used one.", {
|
|
342
|
+
lockedReason: state.reason,
|
|
343
|
+
archived: record.archived,
|
|
344
|
+
archivedCollection: record.archivedCollection,
|
|
345
|
+
archivedKeysCollection: record.archivedKeysCollection,
|
|
346
|
+
clearedSettingsKeys: record.clearedSettingsKeys,
|
|
347
|
+
toReenter
|
|
348
|
+
});
|
|
458
349
|
return {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
action,
|
|
462
|
-
actor: str(data, "actor"),
|
|
463
|
-
ref: strOrNull(data, "ref"),
|
|
464
|
-
detail: strOrNull(data, "detail")
|
|
350
|
+
kind: "reset",
|
|
351
|
+
record
|
|
465
352
|
};
|
|
466
353
|
}
|
|
467
|
-
/** Rows one listing page asks for — the table is small, but a read names its bound. */
|
|
468
|
-
var PAGE = 1e3;
|
|
469
|
-
var KeychainRepository = class {
|
|
470
|
-
door;
|
|
471
|
-
constructor(door) {
|
|
472
|
-
this.door = door;
|
|
473
|
-
}
|
|
474
|
-
async init() {
|
|
475
|
-
await this.door.declareCollection({
|
|
476
|
-
collection: KEYCHAIN_SECRETS_COLLECTION,
|
|
477
|
-
columns: SECRET_COLUMNS,
|
|
478
|
-
indexes: [{
|
|
479
|
-
name: "idx_keychain_secrets_owner",
|
|
480
|
-
columns: ["owner"]
|
|
481
|
-
}]
|
|
482
|
-
});
|
|
483
|
-
await this.door.declareCollection({
|
|
484
|
-
collection: KEYCHAIN_KEYS_COLLECTION,
|
|
485
|
-
columns: KEY_COLUMNS
|
|
486
|
-
});
|
|
487
|
-
await this.door.declareCollection({
|
|
488
|
-
collection: KEYCHAIN_AUDIT_COLLECTION,
|
|
489
|
-
columns: AUDIT_COLUMNS,
|
|
490
|
-
indexes: [{
|
|
491
|
-
name: "idx_keychain_audit_at",
|
|
492
|
-
columns: ["at"]
|
|
493
|
-
}]
|
|
494
|
-
});
|
|
495
|
-
}
|
|
496
|
-
async getSecrets(refs) {
|
|
497
|
-
const out = /* @__PURE__ */ new Map();
|
|
498
|
-
if (refs.length === 0) return out;
|
|
499
|
-
const rows = await this.door.query({
|
|
500
|
-
collection: KEYCHAIN_SECRETS_COLLECTION,
|
|
501
|
-
filter: {
|
|
502
|
-
whereIn: { ref: [...refs] },
|
|
503
|
-
limit: refs.length
|
|
504
|
-
}
|
|
505
|
-
});
|
|
506
|
-
for (const row of rows) {
|
|
507
|
-
const parsed = toSecretRow(row.id, row.data);
|
|
508
|
-
out.set(parsed.ref, parsed);
|
|
509
|
-
}
|
|
510
|
-
return out;
|
|
511
|
-
}
|
|
512
|
-
/** Every row, paged by primary key. */
|
|
513
|
-
async listSecrets() {
|
|
514
|
-
const out = [];
|
|
515
|
-
for (let offset = 0;; offset += PAGE) {
|
|
516
|
-
const rows = await this.door.query({
|
|
517
|
-
collection: KEYCHAIN_SECRETS_COLLECTION,
|
|
518
|
-
filter: {
|
|
519
|
-
orderBy: {
|
|
520
|
-
field: "ref",
|
|
521
|
-
direction: "asc"
|
|
522
|
-
},
|
|
523
|
-
limit: PAGE,
|
|
524
|
-
offset
|
|
525
|
-
}
|
|
526
|
-
});
|
|
527
|
-
for (const row of rows) out.push(toSecretRow(row.id, row.data));
|
|
528
|
-
if (rows.length < PAGE) return out;
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
async putSecret(row) {
|
|
532
|
-
await this.door.set({
|
|
533
|
-
collection: KEYCHAIN_SECRETS_COLLECTION,
|
|
534
|
-
key: row.ref,
|
|
535
|
-
value: row
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
async deleteSecrets(refs) {
|
|
539
|
-
if (refs.length === 0) return 0;
|
|
540
|
-
return (await this.door.deleteWhere({
|
|
541
|
-
collection: KEYCHAIN_SECRETS_COLLECTION,
|
|
542
|
-
filter: { whereIn: { ref: [...refs] } }
|
|
543
|
-
})).deleted;
|
|
544
|
-
}
|
|
545
|
-
async listKeys() {
|
|
546
|
-
return (await this.door.query({
|
|
547
|
-
collection: KEYCHAIN_KEYS_COLLECTION,
|
|
548
|
-
filter: {
|
|
549
|
-
orderBy: {
|
|
550
|
-
field: "createdAt",
|
|
551
|
-
direction: "asc"
|
|
552
|
-
},
|
|
553
|
-
limit: PAGE
|
|
554
|
-
}
|
|
555
|
-
})).map((row) => toKeyRow(row.id, row.data));
|
|
556
|
-
}
|
|
557
|
-
async putKey(row) {
|
|
558
|
-
await this.door.set({
|
|
559
|
-
collection: KEYCHAIN_KEYS_COLLECTION,
|
|
560
|
-
key: row.wrapId,
|
|
561
|
-
value: row
|
|
562
|
-
});
|
|
563
|
-
}
|
|
564
|
-
async deleteKey(wrapId) {
|
|
565
|
-
await this.door.delete({
|
|
566
|
-
collection: KEYCHAIN_KEYS_COLLECTION,
|
|
567
|
-
key: wrapId
|
|
568
|
-
});
|
|
569
|
-
}
|
|
570
|
-
async appendAudit(row) {
|
|
571
|
-
await this.door.set({
|
|
572
|
-
collection: KEYCHAIN_AUDIT_COLLECTION,
|
|
573
|
-
key: row.id,
|
|
574
|
-
value: row
|
|
575
|
-
});
|
|
576
|
-
}
|
|
577
|
-
async listAudit(limit) {
|
|
578
|
-
const rows = await this.door.query({
|
|
579
|
-
collection: KEYCHAIN_AUDIT_COLLECTION,
|
|
580
|
-
filter: {
|
|
581
|
-
orderBy: {
|
|
582
|
-
field: "at",
|
|
583
|
-
direction: "desc"
|
|
584
|
-
},
|
|
585
|
-
limit
|
|
586
|
-
}
|
|
587
|
-
});
|
|
588
|
-
const out = [];
|
|
589
|
-
for (const row of rows) {
|
|
590
|
-
const parsed = toAuditRow(row.id, row.data);
|
|
591
|
-
if (parsed !== null) out.push(parsed);
|
|
592
|
-
}
|
|
593
|
-
return out;
|
|
594
|
-
}
|
|
595
|
-
/** Drop audit rows older than the retention window, then beyond the row bound. */
|
|
596
|
-
async pruneAudit(nowMs) {
|
|
597
|
-
const aged = await this.door.deleteWhere({
|
|
598
|
-
collection: KEYCHAIN_AUDIT_COLLECTION,
|
|
599
|
-
filter: { whereBetween: { at: [0, nowMs - AUDIT_RETENTION_MS] } }
|
|
600
|
-
});
|
|
601
|
-
const cutoff = (await this.door.query({
|
|
602
|
-
collection: KEYCHAIN_AUDIT_COLLECTION,
|
|
603
|
-
filter: {
|
|
604
|
-
orderBy: {
|
|
605
|
-
field: "at",
|
|
606
|
-
direction: "desc"
|
|
607
|
-
},
|
|
608
|
-
limit: 1,
|
|
609
|
-
offset: AUDIT_MAX_ROWS
|
|
610
|
-
}
|
|
611
|
-
}))[0]?.data["at"];
|
|
612
|
-
if (typeof cutoff !== "number") return aged.deleted;
|
|
613
|
-
const bounded = await this.door.deleteWhere({
|
|
614
|
-
collection: KEYCHAIN_AUDIT_COLLECTION,
|
|
615
|
-
filter: { whereBetween: { at: [0, cutoff] } }
|
|
616
|
-
});
|
|
617
|
-
return aged.deleted + bounded.deleted;
|
|
618
|
-
}
|
|
619
|
-
/**
|
|
620
|
-
* Lost-key recovery: copy every secret row into `archive`, then empty the
|
|
621
|
-
* live table. The archive is ciphertext under a key nobody has — it exists
|
|
622
|
-
* so a key that turns up later can still recover it.
|
|
623
|
-
*/
|
|
624
|
-
async archiveAllSecrets(archive) {
|
|
625
|
-
const rows = await this.listSecrets();
|
|
626
|
-
if (rows.length === 0) return 0;
|
|
627
|
-
await this.door.declareCollection({
|
|
628
|
-
collection: archive,
|
|
629
|
-
columns: SECRET_COLUMNS
|
|
630
|
-
});
|
|
631
|
-
for (const row of rows) await this.door.set({
|
|
632
|
-
collection: archive,
|
|
633
|
-
key: row.ref,
|
|
634
|
-
value: row
|
|
635
|
-
});
|
|
636
|
-
await this.deleteSecrets(rows.map((r) => r.ref));
|
|
637
|
-
return rows.length;
|
|
638
|
-
}
|
|
639
|
-
};
|
|
640
354
|
//#endregion
|
|
641
355
|
//#region src/builtins/keychain/system-held-secrets.ts
|
|
642
356
|
/** The refs `keychain-admin.setSystemSecret` may write, by name. */
|
|
@@ -653,6 +367,13 @@ var KeychainLockedError = class extends Error {
|
|
|
653
367
|
this.name = "KeychainLockedError";
|
|
654
368
|
}
|
|
655
369
|
};
|
|
370
|
+
/** A reset that would archive the values and still end locked — refused before it starts. */
|
|
371
|
+
var KeychainResetRefusedError = class extends Error {
|
|
372
|
+
constructor(reason) {
|
|
373
|
+
super(`keychain reset refused: ${reason}`);
|
|
374
|
+
this.name = "KeychainResetRefusedError";
|
|
375
|
+
}
|
|
376
|
+
};
|
|
656
377
|
var KeychainForbiddenError = class extends Error {
|
|
657
378
|
ref;
|
|
658
379
|
constructor(ref, caller) {
|
|
@@ -855,370 +576,1011 @@ var KeychainService = class {
|
|
|
855
576
|
if (parts === null || !this.owns(caller, parts.owner)) throw new KeychainForbiddenError(ref, caller);
|
|
856
577
|
return parts.owner;
|
|
857
578
|
}
|
|
858
|
-
/**
|
|
859
|
-
* @param actor who the audit names — the owner addon by default; the
|
|
860
|
-
* operator (`user:<id>`) for {@link setSystemSecret}.
|
|
861
|
-
*/
|
|
862
|
-
async put(caller, ref, value, actor = actorOfAddon(caller)) {
|
|
863
|
-
const owner = this.ownerOf(ref, caller);
|
|
864
|
-
const unlocked = this.requireReady();
|
|
865
|
-
if (value.length === 0) throw new Error("keychain.put: an empty value is not stored — delete the ref");
|
|
866
|
-
const parts = require_dist.parseSecretRef(ref);
|
|
867
|
-
if (parts === null) throw new KeychainForbiddenError(ref, caller);
|
|
868
|
-
const existing = (await this.deps.repo.getSecrets([ref])).get(ref);
|
|
869
|
-
if (existing !== void 0) {
|
|
870
|
-
const current = decryptValue(unlocked.dek, ref, existing.ciphertext);
|
|
871
|
-
if (current !== null && constantTimeEquals(current, value)) return {
|
|
872
|
-
ref,
|
|
873
|
-
version: existing.version
|
|
874
|
-
};
|
|
875
|
-
}
|
|
876
|
-
const t = this.now();
|
|
877
|
-
const version = (existing?.version ?? 0) + 1;
|
|
878
|
-
await this.deps.repo.putSecret({
|
|
879
|
-
ref,
|
|
880
|
-
owner,
|
|
881
|
-
scope: require_dist.formatSecretScope(parts.scope),
|
|
882
|
-
fieldPath: parts.fieldPath,
|
|
883
|
-
ciphertext: encryptValue(unlocked.dek, unlocked.keyId, ref, value),
|
|
884
|
-
keyId: unlocked.keyId,
|
|
885
|
-
version,
|
|
886
|
-
createdAt: existing?.createdAt ?? t,
|
|
887
|
-
updatedAt: t,
|
|
888
|
-
updatedBy: actor,
|
|
889
|
-
lastReadAt: existing?.lastReadAt ?? null
|
|
579
|
+
/**
|
|
580
|
+
* @param actor who the audit names — the owner addon by default; the
|
|
581
|
+
* operator (`user:<id>`) for {@link setSystemSecret}.
|
|
582
|
+
*/
|
|
583
|
+
async put(caller, ref, value, actor = actorOfAddon(caller)) {
|
|
584
|
+
const owner = this.ownerOf(ref, caller);
|
|
585
|
+
const unlocked = this.requireReady();
|
|
586
|
+
if (value.length === 0) throw new Error("keychain.put: an empty value is not stored — delete the ref");
|
|
587
|
+
const parts = require_dist.parseSecretRef(ref);
|
|
588
|
+
if (parts === null) throw new KeychainForbiddenError(ref, caller);
|
|
589
|
+
const existing = (await this.deps.repo.getSecrets([ref])).get(ref);
|
|
590
|
+
if (existing !== void 0) {
|
|
591
|
+
const current = decryptValue(unlocked.dek, ref, existing.ciphertext);
|
|
592
|
+
if (current !== null && constantTimeEquals(current, value)) return {
|
|
593
|
+
ref,
|
|
594
|
+
version: existing.version
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
const t = this.now();
|
|
598
|
+
const version = (existing?.version ?? 0) + 1;
|
|
599
|
+
await this.deps.repo.putSecret({
|
|
600
|
+
ref,
|
|
601
|
+
owner,
|
|
602
|
+
scope: require_dist.formatSecretScope(parts.scope),
|
|
603
|
+
fieldPath: parts.fieldPath,
|
|
604
|
+
ciphertext: encryptValue(unlocked.dek, unlocked.keyId, ref, value),
|
|
605
|
+
keyId: unlocked.keyId,
|
|
606
|
+
version,
|
|
607
|
+
createdAt: existing?.createdAt ?? t,
|
|
608
|
+
updatedAt: t,
|
|
609
|
+
updatedBy: actor,
|
|
610
|
+
lastReadAt: existing?.lastReadAt ?? null
|
|
611
|
+
});
|
|
612
|
+
await this.audit("put", actor, ref, `version=${version}`);
|
|
613
|
+
this.deps.events.emit({
|
|
614
|
+
ref,
|
|
615
|
+
owner,
|
|
616
|
+
version,
|
|
617
|
+
change: "put"
|
|
618
|
+
});
|
|
619
|
+
return {
|
|
620
|
+
ref,
|
|
621
|
+
version
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
async resolve(caller, refs) {
|
|
625
|
+
const out = {};
|
|
626
|
+
const owned = [];
|
|
627
|
+
for (const ref of new Set(refs)) {
|
|
628
|
+
const parts = require_dist.parseSecretRef(ref);
|
|
629
|
+
if (parts === null || !this.owns(caller, parts.owner)) {
|
|
630
|
+
out[ref] = { kind: "forbidden" };
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
owned.push(ref);
|
|
634
|
+
}
|
|
635
|
+
if (owned.length === 0) return out;
|
|
636
|
+
if (this.unlock.state !== "ready") {
|
|
637
|
+
for (const ref of owned) out[ref] = {
|
|
638
|
+
kind: "unavailable",
|
|
639
|
+
reason: this.unlock.reason
|
|
640
|
+
};
|
|
641
|
+
return out;
|
|
642
|
+
}
|
|
643
|
+
const unlocked = this.unlock;
|
|
644
|
+
let rows;
|
|
645
|
+
try {
|
|
646
|
+
rows = await this.deps.repo.getSecrets(owned);
|
|
647
|
+
} catch {
|
|
648
|
+
for (const ref of owned) out[ref] = {
|
|
649
|
+
kind: "unavailable",
|
|
650
|
+
reason: "store-unavailable"
|
|
651
|
+
};
|
|
652
|
+
return out;
|
|
653
|
+
}
|
|
654
|
+
const t = this.now();
|
|
655
|
+
const stale = [];
|
|
656
|
+
for (const ref of owned) {
|
|
657
|
+
const row = rows.get(ref);
|
|
658
|
+
if (row === void 0) {
|
|
659
|
+
out[ref] = { kind: "missing" };
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
const value = decryptValue(unlocked.dek, ref, row.ciphertext);
|
|
663
|
+
if (value === null) {
|
|
664
|
+
out[ref] = {
|
|
665
|
+
kind: "unavailable",
|
|
666
|
+
reason: "decrypt-failed"
|
|
667
|
+
};
|
|
668
|
+
this.deps.logger.error("keychain value did not authenticate", { ref });
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
out[ref] = {
|
|
672
|
+
kind: "value",
|
|
673
|
+
value,
|
|
674
|
+
version: row.version
|
|
675
|
+
};
|
|
676
|
+
if (row.lastReadAt === null || t - row.lastReadAt >= 36e5) stale.push(row);
|
|
677
|
+
}
|
|
678
|
+
if (stale.length > 0) this.touchLastRead(stale, t);
|
|
679
|
+
return out;
|
|
680
|
+
}
|
|
681
|
+
async touchLastRead(rows, at) {
|
|
682
|
+
try {
|
|
683
|
+
for (const row of rows) await this.deps.repo.putSecret({
|
|
684
|
+
...row,
|
|
685
|
+
lastReadAt: at
|
|
686
|
+
});
|
|
687
|
+
} catch (err) {
|
|
688
|
+
this.deps.logger.warn("keychain lastReadAt not recorded", { error: errMsg(err) });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
async delete(caller, refs) {
|
|
692
|
+
const owners = /* @__PURE__ */ new Map();
|
|
693
|
+
for (const ref of new Set(refs)) owners.set(ref, this.ownerOf(ref, caller));
|
|
694
|
+
if (owners.size === 0) return 0;
|
|
695
|
+
const existing = await this.deps.repo.getSecrets([...owners.keys()]);
|
|
696
|
+
const present = [...owners.keys()].filter((ref) => existing.has(ref));
|
|
697
|
+
if (present.length === 0) return 0;
|
|
698
|
+
const deleted = await this.deps.repo.deleteSecrets(present);
|
|
699
|
+
for (const ref of present) {
|
|
700
|
+
await this.audit("delete", actorOfAddon(caller), ref, null);
|
|
701
|
+
this.deps.events.emit({
|
|
702
|
+
ref,
|
|
703
|
+
owner: owners.get(ref) ?? "",
|
|
704
|
+
version: null,
|
|
705
|
+
change: "delete"
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
return deleted;
|
|
709
|
+
}
|
|
710
|
+
async listMeta(caller) {
|
|
711
|
+
return (await this.deps.repo.listSecrets()).filter((row) => this.owns(caller, row.owner)).map(toMeta);
|
|
712
|
+
}
|
|
713
|
+
async status() {
|
|
714
|
+
const wraps = await this.deps.repo.listKeys().catch(() => []);
|
|
715
|
+
const secrets = await this.deps.repo.listSecrets().catch(() => []);
|
|
716
|
+
const unlocked = this.unlock.state === "ready" ? this.unlock : null;
|
|
717
|
+
const configured = this.configuredSource ?? {
|
|
718
|
+
kind: "default-file",
|
|
719
|
+
path: defaultKeyFilePath(this.deps.dataDir),
|
|
720
|
+
sharesBackupWithDatabase: true
|
|
721
|
+
};
|
|
722
|
+
return {
|
|
723
|
+
keychain: this.getState(),
|
|
724
|
+
keySource: unlocked?.source ?? null,
|
|
725
|
+
configuredSource: configured,
|
|
726
|
+
fingerprint: unlocked !== null ? shortFingerprint(unlocked.fingerprint) : null,
|
|
727
|
+
activeKeyId: unlocked?.keyId ?? null,
|
|
728
|
+
rotationPending: unlocked?.rotationPending ?? false,
|
|
729
|
+
retiredWraps: wraps.filter((w) => w.state === "retired").length,
|
|
730
|
+
recoveryKeyDownloadedAt: unlocked !== null ? wraps.find((w) => w.wrapId === unlocked.wrap.wrapId)?.recoveryDownloadedAt ?? null : null,
|
|
731
|
+
secretCount: secrets.length,
|
|
732
|
+
lastReset: await this.deps.repo.latestReset().catch(() => null)
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
async listSecrets() {
|
|
736
|
+
const rows = await this.deps.repo.listSecrets();
|
|
737
|
+
const usage = await this.deps.usage(rows).catch(() => /* @__PURE__ */ new Map());
|
|
738
|
+
return rows.map((row) => ({
|
|
739
|
+
...toMeta(row),
|
|
740
|
+
usage: usage.get(row.ref) ?? "unknown"
|
|
741
|
+
}));
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* The operator replaces a system-held secret no form edits (D797) — the
|
|
745
|
+
* session signing key. Stored under owner `system`, audited as the user.
|
|
746
|
+
*/
|
|
747
|
+
async setSystemSecret(userId, name, value) {
|
|
748
|
+
const ref = SYSTEM_HELD_SECRET_REFS[name];
|
|
749
|
+
return this.put(require_jwt_signing_key.SYSTEM_SECRET_OWNER, ref, value, `user:${userId}`);
|
|
750
|
+
}
|
|
751
|
+
async listAudit(limit) {
|
|
752
|
+
return this.deps.repo.listAudit(limit);
|
|
753
|
+
}
|
|
754
|
+
async reveal(userId, ref) {
|
|
755
|
+
const unlocked = this.requireReady();
|
|
756
|
+
const row = (await this.deps.repo.getSecrets([ref])).get(ref);
|
|
757
|
+
if (row === void 0) throw new Error(`keychain: no value stored at ${ref}`);
|
|
758
|
+
const value = decryptValue(unlocked.dek, ref, row.ciphertext);
|
|
759
|
+
if (value === null) throw new Error(`keychain: the value at ${ref} did not authenticate`);
|
|
760
|
+
await this.audit("reveal", `user:${userId}`, ref, null);
|
|
761
|
+
return {
|
|
762
|
+
value,
|
|
763
|
+
version: row.version
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
async deleteOrphans(userId, refs) {
|
|
767
|
+
const rows = [...(await this.deps.repo.getSecrets(refs)).values()];
|
|
768
|
+
const usage = await this.deps.usage(rows);
|
|
769
|
+
const deleted = [];
|
|
770
|
+
const kept = [];
|
|
771
|
+
for (const row of rows) {
|
|
772
|
+
const u = usage.get(row.ref) ?? "unknown";
|
|
773
|
+
if (u === "orphan") deleted.push(row.ref);
|
|
774
|
+
else kept.push({
|
|
775
|
+
ref: row.ref,
|
|
776
|
+
usage: u
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
if (deleted.length > 0) {
|
|
780
|
+
await this.deps.repo.deleteSecrets(deleted);
|
|
781
|
+
for (const ref of deleted) {
|
|
782
|
+
const owner = require_dist.parseSecretRef(ref)?.owner ?? "";
|
|
783
|
+
this.deps.events.emit({
|
|
784
|
+
ref,
|
|
785
|
+
owner,
|
|
786
|
+
version: null,
|
|
787
|
+
change: "delete"
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
await this.audit("delete-orphans", `user:${userId}`, null, `deleted=${deleted.length}`);
|
|
791
|
+
}
|
|
792
|
+
return {
|
|
793
|
+
deleted,
|
|
794
|
+
kept
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
async downloadRecoveryKey(userId) {
|
|
798
|
+
const unlocked = this.requireReady();
|
|
799
|
+
const t = this.now();
|
|
800
|
+
await this.deps.repo.putKey({
|
|
801
|
+
...unlocked.wrap,
|
|
802
|
+
recoveryDownloadedAt: t
|
|
803
|
+
});
|
|
804
|
+
this.unlock = {
|
|
805
|
+
...unlocked,
|
|
806
|
+
wrap: {
|
|
807
|
+
...unlocked.wrap,
|
|
808
|
+
recoveryDownloadedAt: t
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
await this.audit("download-recovery-key", `user:${userId}`, null, `source=${unlocked.source.kind}`);
|
|
812
|
+
return {
|
|
813
|
+
key: encodeKeyMaterial(unlocked.kek),
|
|
814
|
+
fingerprint: shortFingerprint(unlocked.fingerprint),
|
|
815
|
+
keySource: unlocked.source
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
/** See `keychain-admin.rotateMasterKey`. */
|
|
819
|
+
async rotateMasterKey(userId) {
|
|
820
|
+
const unlocked = this.requireReady();
|
|
821
|
+
const primary = (await this.deps.candidates()).find((c) => c.primary);
|
|
822
|
+
if (primary === void 0) throw new Error("keychain: no primary key source is configured");
|
|
823
|
+
let newKek;
|
|
824
|
+
if (unlocked.rotationPending && primary.source.kind !== unlocked.source.kind) {
|
|
825
|
+
const loaded = await primary.load();
|
|
826
|
+
if (loaded.kind !== "loaded") throw new Error(`keychain: the configured ${primary.source.kind} key could not be loaded`);
|
|
827
|
+
newKek = loaded.key;
|
|
828
|
+
} else if (primary.source.kind === "default-file") {
|
|
829
|
+
newKek = generateKey();
|
|
830
|
+
await writeDefaultKeyFile(this.deps.dataDir, newKek, {
|
|
831
|
+
replace: true,
|
|
832
|
+
nowMs: this.now()
|
|
833
|
+
});
|
|
834
|
+
} else throw new Error(`keychain: the ${primary.source.kind} key is already in use. Put the new key at its source, set the old one as keychain.previousKeyFile, restart, then rotate.`);
|
|
835
|
+
const fingerprint = kekFingerprint(newKek);
|
|
836
|
+
if (constantTimeEquals(fingerprint, unlocked.fingerprint)) throw new Error("keychain: the new master key is the one already in use");
|
|
837
|
+
const wrap = {
|
|
838
|
+
wrapId: (0, node_crypto.randomUUID)(),
|
|
839
|
+
keyId: unlocked.keyId,
|
|
840
|
+
wrappedDek: wrapDek(newKek, unlocked.dek, unlocked.keyId),
|
|
841
|
+
kekFingerprint: fingerprint,
|
|
842
|
+
kekSource: primary.source.kind,
|
|
843
|
+
createdAt: this.now(),
|
|
844
|
+
state: "active",
|
|
845
|
+
recoveryDownloadedAt: null
|
|
846
|
+
};
|
|
847
|
+
await this.deps.repo.putKey(wrap);
|
|
848
|
+
for (const old of await this.deps.repo.listKeys()) if (old.wrapId !== wrap.wrapId && old.state === "active") await this.deps.repo.putKey({
|
|
849
|
+
...old,
|
|
850
|
+
state: "retired"
|
|
851
|
+
});
|
|
852
|
+
this.unlock = {
|
|
853
|
+
state: "ready",
|
|
854
|
+
dek: unlocked.dek,
|
|
855
|
+
keyId: unlocked.keyId,
|
|
856
|
+
kek: newKek,
|
|
857
|
+
fingerprint,
|
|
858
|
+
source: primary.source,
|
|
859
|
+
wrap,
|
|
860
|
+
rotationPending: false
|
|
861
|
+
};
|
|
862
|
+
this.configuredSource = primary.source;
|
|
863
|
+
await this.audit("rotate-master-key", `user:${userId}`, null, `source=${primary.source.kind}`);
|
|
864
|
+
return {
|
|
865
|
+
fingerprint: shortFingerprint(fingerprint),
|
|
866
|
+
keySource: primary.source
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
/** Delete retired wraps, and the default file's previous copies with them. */
|
|
870
|
+
async purgeRetiredKeys(userId) {
|
|
871
|
+
this.requireReady();
|
|
872
|
+
const retired = (await this.deps.repo.listKeys()).filter((w) => w.state === "retired");
|
|
873
|
+
for (const wrap of retired) await this.deps.repo.deleteKey(wrap.wrapId);
|
|
874
|
+
const dir = node_path.dirname(defaultKeyFilePath(this.deps.dataDir));
|
|
875
|
+
try {
|
|
876
|
+
for (const name of await node_fs.promises.readdir(dir)) if (name.startsWith("master.key.prev-")) await node_fs.promises.rm(node_path.join(dir, name), { force: true });
|
|
877
|
+
} catch {}
|
|
878
|
+
await this.audit("purge-retired-keys", `user:${userId}`, null, `purged=${retired.length}`);
|
|
879
|
+
return retired.length;
|
|
880
|
+
}
|
|
881
|
+
/** Lost-key recovery from System → Secrets — see `keychain-admin.resetKeychain`. */
|
|
882
|
+
async resetKeychain(userId) {
|
|
883
|
+
const record = await this.resetAs(`user:${userId}`);
|
|
884
|
+
return {
|
|
885
|
+
archivedCollection: record.archivedCollection,
|
|
886
|
+
archived: record.archived
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Start over with a new master key and a new data key.
|
|
891
|
+
*
|
|
892
|
+
* 1. Refuse BEFORE touching anything when the configured primary source
|
|
893
|
+
* cannot give a key (a configured file that is missing or unreadable, a
|
|
894
|
+
* desktop key that did not arrive): archiving the values and then staying
|
|
895
|
+
* locked would lose them for nothing. The default file is always usable —
|
|
896
|
+
* the reset writes a new one (the old one is kept as `master.key.prev-*`).
|
|
897
|
+
* 2. Archive every value AND every wrapped data key — the archive stays
|
|
898
|
+
* openable if the lost master key turns up.
|
|
899
|
+
* 3. New data key under the new master key ({@link firstBoot}).
|
|
900
|
+
* 4. Clear the system-held markers (the cluster secret's): a marker pointing
|
|
901
|
+
* at an archived value would refuse every agent forever (D797). The
|
|
902
|
+
* session signing key has no marker; it now resolves `missing`, and
|
|
903
|
+
* `local-auth` generates a new one (everyone signs in again).
|
|
904
|
+
* 5. One `delete` event per archived ref, so every keeper reloads.
|
|
905
|
+
* 6. A reset record for the Secrets page, and an audit row.
|
|
906
|
+
*/
|
|
907
|
+
async resetAs(actor) {
|
|
908
|
+
const t = this.now();
|
|
909
|
+
const lockedReason = this.unlock.state === "locked" ? this.unlock.reason : null;
|
|
910
|
+
const primary = (await this.deps.candidates()).find((c) => c.primary) ?? null;
|
|
911
|
+
await this.assertResettable(primary);
|
|
912
|
+
const archiveName = `keychain:secrets-orphaned-${t}`;
|
|
913
|
+
const keysArchiveName = `keychain:keys-orphaned-${t}`;
|
|
914
|
+
const archivedRows = await this.deps.repo.listSecrets();
|
|
915
|
+
const archivedKeys = await this.deps.repo.archiveAllKeys(keysArchiveName);
|
|
916
|
+
const archived = await this.deps.repo.archiveAllSecrets(archiveName);
|
|
917
|
+
for (const wrap of await this.deps.repo.listKeys()) await this.deps.repo.deleteKey(wrap.wrapId);
|
|
918
|
+
if (primary?.source.kind === "default-file") {
|
|
919
|
+
if ((await primary.load()).kind !== "absent") await writeDefaultKeyFile(this.deps.dataDir, generateKey(), {
|
|
920
|
+
replace: true,
|
|
921
|
+
nowMs: t
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
this.unlock = await this.firstBoot(primary);
|
|
925
|
+
const clearedSettingsKeys = await this.deps.repo.clearSystemSettingsKeys(SYSTEM_SECRET_SETTINGS_KEYS);
|
|
926
|
+
for (const row of archivedRows) this.deps.events.emit({
|
|
927
|
+
ref: row.ref,
|
|
928
|
+
owner: row.owner,
|
|
929
|
+
version: null,
|
|
930
|
+
change: "delete"
|
|
931
|
+
});
|
|
932
|
+
const record = {
|
|
933
|
+
id: (0, node_crypto.randomUUID)(),
|
|
934
|
+
at: t,
|
|
935
|
+
actor,
|
|
936
|
+
lockedReason,
|
|
937
|
+
archived,
|
|
938
|
+
archivedCollection: archived > 0 ? archiveName : null,
|
|
939
|
+
archivedKeysCollection: archivedKeys > 0 ? keysArchiveName : null,
|
|
940
|
+
clearedSettingsKeys,
|
|
941
|
+
acknowledgedAt: null
|
|
942
|
+
};
|
|
943
|
+
await this.deps.repo.putReset(record);
|
|
944
|
+
await this.audit("reset", actor, null, `archived=${archived} lockedReason=${lockedReason ?? "none"} cleared=${clearedSettingsKeys.join(",") || "none"}`);
|
|
945
|
+
return record;
|
|
946
|
+
}
|
|
947
|
+
/** Throws {@link KeychainResetRefusedError} when no new master key could be had. */
|
|
948
|
+
async assertResettable(primary) {
|
|
949
|
+
if (primary === null) throw new KeychainResetRefusedError("no key source is configured");
|
|
950
|
+
if (primary.source.kind === "default-file") return;
|
|
951
|
+
const loaded = await primary.load();
|
|
952
|
+
if (loaded.kind === "loaded") return;
|
|
953
|
+
const where = primary.source.path ?? primary.source.kind;
|
|
954
|
+
throw new KeychainResetRefusedError(loaded.kind === "absent" ? `the configured master key ${where} does not exist — put a new 32-byte key there (or remove keychain.keyFile to use the default file) first` : `the configured master key ${where} cannot be read (${loaded.error})`);
|
|
955
|
+
}
|
|
956
|
+
/** What the last reset left to re-enter (see `keychain-admin.resetReport`). */
|
|
957
|
+
async resetReport() {
|
|
958
|
+
const reset = await this.deps.repo.latestReset();
|
|
959
|
+
if (reset === null || reset.archivedCollection === null) return {
|
|
960
|
+
reset,
|
|
961
|
+
pendingReentry: []
|
|
962
|
+
};
|
|
963
|
+
const archived = await this.deps.repo.listArchivedSecrets(reset.archivedCollection);
|
|
964
|
+
const live = await this.deps.repo.getSecrets(archived.map((r) => r.ref));
|
|
965
|
+
return {
|
|
966
|
+
reset,
|
|
967
|
+
pendingReentry: archived.filter((r) => !live.has(r.ref) && !PROCESS_HELD_SECRET_REFS.has(r.ref)).map(toMeta)
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
async acknowledgeReset(userId) {
|
|
971
|
+
const reset = await this.deps.repo.latestReset();
|
|
972
|
+
if (reset === null || reset.acknowledgedAt !== null) return { acknowledged: false };
|
|
973
|
+
await this.deps.repo.putReset({
|
|
974
|
+
...reset,
|
|
975
|
+
acknowledgedAt: this.now()
|
|
976
|
+
});
|
|
977
|
+
await this.audit("acknowledge-reset", `user:${userId}`, null, `reset=${reset.id}`);
|
|
978
|
+
return { acknowledged: true };
|
|
979
|
+
}
|
|
980
|
+
async audit(action, actor, ref, detail) {
|
|
981
|
+
try {
|
|
982
|
+
await this.deps.repo.appendAudit({
|
|
983
|
+
id: (0, node_crypto.randomUUID)(),
|
|
984
|
+
at: this.now(),
|
|
985
|
+
action,
|
|
986
|
+
actor,
|
|
987
|
+
ref,
|
|
988
|
+
detail
|
|
989
|
+
});
|
|
990
|
+
} catch (err) {
|
|
991
|
+
this.deps.logger.warn("keychain audit row not written", {
|
|
992
|
+
action,
|
|
993
|
+
ref,
|
|
994
|
+
error: errMsg(err)
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
/** Daily ring prune, driven by the addon's timer. */
|
|
999
|
+
async pruneAudit() {
|
|
1000
|
+
await this.deps.repo.pruneAudit(this.now());
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
function toMeta(row) {
|
|
1004
|
+
return {
|
|
1005
|
+
ref: row.ref,
|
|
1006
|
+
owner: row.owner,
|
|
1007
|
+
scope: row.scope,
|
|
1008
|
+
fieldPath: row.fieldPath,
|
|
1009
|
+
version: row.version,
|
|
1010
|
+
createdAt: row.createdAt,
|
|
1011
|
+
updatedAt: row.updatedAt,
|
|
1012
|
+
updatedBy: row.updatedBy,
|
|
1013
|
+
lastReadAt: row.lastReadAt
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
function actorOfAddon(caller) {
|
|
1017
|
+
return caller === null ? "system" : `addon:${require_dist.normalizeSecretOwner(caller)}`;
|
|
1018
|
+
}
|
|
1019
|
+
function errMsg(err) {
|
|
1020
|
+
return err instanceof Error ? err.message : String(err);
|
|
1021
|
+
}
|
|
1022
|
+
require_dist.EventCategory.KeychainChanged;
|
|
1023
|
+
//#endregion
|
|
1024
|
+
//#region src/builtins/keychain/keychain-store.ts
|
|
1025
|
+
/**
|
|
1026
|
+
* @durable class=config owner=keychain
|
|
1027
|
+
* write="`keychain.put` upserts one row per ref; `delete` removes it"
|
|
1028
|
+
* retention="none — a row lives until its owner deletes the ref or the
|
|
1029
|
+
* operator deletes it as an orphan. Losing the table loses every secret
|
|
1030
|
+
* value; the settings blobs then point at refs that resolve `missing`."
|
|
1031
|
+
*/
|
|
1032
|
+
var KEYCHAIN_SECRETS_COLLECTION = "keychain:secrets";
|
|
1033
|
+
/**
|
|
1034
|
+
* @durable class=config owner=keychain
|
|
1035
|
+
* write="first boot writes the wrapped data key; `rotateMasterKey` adds a
|
|
1036
|
+
* wrap and retires the previous one"
|
|
1037
|
+
* retention="retired wraps until `purgeRetiredKeys`. Losing the table with
|
|
1038
|
+
* the secrets intact makes every value undecryptable."
|
|
1039
|
+
*/
|
|
1040
|
+
var KEYCHAIN_KEYS_COLLECTION = "keychain:keys";
|
|
1041
|
+
/**
|
|
1042
|
+
* @durable class=ring owner=keychain
|
|
1043
|
+
* write="one row per write / delete / rotation / reveal / recovery download"
|
|
1044
|
+
* retention="90 days and at most 20 000 rows, pruned at boot and daily"
|
|
1045
|
+
*/
|
|
1046
|
+
var KEYCHAIN_AUDIT_COLLECTION = "keychain:audit";
|
|
1047
|
+
/**
|
|
1048
|
+
* @durable class=config owner=keychain
|
|
1049
|
+
* write="one row per keychain reset (System → Secrets, or the RESET_KEYCHAIN
|
|
1050
|
+
* trigger file); `acknowledgeReset` stamps `acknowledgedAt`"
|
|
1051
|
+
* retention="none — a handful of rows over a hub's life; they name the
|
|
1052
|
+
* archive tables a reset left behind"
|
|
1053
|
+
*/
|
|
1054
|
+
var KEYCHAIN_RESETS_COLLECTION = "keychain:resets";
|
|
1055
|
+
var AUDIT_RETENTION_MS = 2160 * 60 * 6e4;
|
|
1056
|
+
var AUDIT_MAX_ROWS = 2e4;
|
|
1057
|
+
var SECRET_COLUMNS = [
|
|
1058
|
+
{
|
|
1059
|
+
name: "ref",
|
|
1060
|
+
type: "TEXT",
|
|
1061
|
+
primaryKey: true,
|
|
1062
|
+
notNull: true
|
|
1063
|
+
},
|
|
1064
|
+
{
|
|
1065
|
+
name: "owner",
|
|
1066
|
+
type: "TEXT",
|
|
1067
|
+
notNull: true
|
|
1068
|
+
},
|
|
1069
|
+
{
|
|
1070
|
+
name: "scope",
|
|
1071
|
+
type: "TEXT",
|
|
1072
|
+
notNull: true
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
name: "fieldPath",
|
|
1076
|
+
type: "TEXT",
|
|
1077
|
+
notNull: true
|
|
1078
|
+
},
|
|
1079
|
+
{
|
|
1080
|
+
name: "ciphertext",
|
|
1081
|
+
type: "TEXT",
|
|
1082
|
+
notNull: true
|
|
1083
|
+
},
|
|
1084
|
+
{
|
|
1085
|
+
name: "keyId",
|
|
1086
|
+
type: "TEXT",
|
|
1087
|
+
notNull: true
|
|
1088
|
+
},
|
|
1089
|
+
{
|
|
1090
|
+
name: "version",
|
|
1091
|
+
type: "INTEGER",
|
|
1092
|
+
notNull: true
|
|
1093
|
+
},
|
|
1094
|
+
{
|
|
1095
|
+
name: "createdAt",
|
|
1096
|
+
type: "INTEGER",
|
|
1097
|
+
notNull: true
|
|
1098
|
+
},
|
|
1099
|
+
{
|
|
1100
|
+
name: "updatedAt",
|
|
1101
|
+
type: "INTEGER",
|
|
1102
|
+
notNull: true
|
|
1103
|
+
},
|
|
1104
|
+
{
|
|
1105
|
+
name: "updatedBy",
|
|
1106
|
+
type: "TEXT",
|
|
1107
|
+
notNull: true
|
|
1108
|
+
},
|
|
1109
|
+
{
|
|
1110
|
+
name: "lastReadAt",
|
|
1111
|
+
type: "INTEGER"
|
|
1112
|
+
}
|
|
1113
|
+
];
|
|
1114
|
+
var KEY_COLUMNS = [
|
|
1115
|
+
{
|
|
1116
|
+
name: "wrapId",
|
|
1117
|
+
type: "TEXT",
|
|
1118
|
+
primaryKey: true,
|
|
1119
|
+
notNull: true
|
|
1120
|
+
},
|
|
1121
|
+
{
|
|
1122
|
+
name: "keyId",
|
|
1123
|
+
type: "TEXT",
|
|
1124
|
+
notNull: true
|
|
1125
|
+
},
|
|
1126
|
+
{
|
|
1127
|
+
name: "wrappedDek",
|
|
1128
|
+
type: "TEXT",
|
|
1129
|
+
notNull: true
|
|
1130
|
+
},
|
|
1131
|
+
{
|
|
1132
|
+
name: "kekFingerprint",
|
|
1133
|
+
type: "TEXT",
|
|
1134
|
+
notNull: true
|
|
1135
|
+
},
|
|
1136
|
+
{
|
|
1137
|
+
name: "kekSource",
|
|
1138
|
+
type: "TEXT",
|
|
1139
|
+
notNull: true
|
|
1140
|
+
},
|
|
1141
|
+
{
|
|
1142
|
+
name: "createdAt",
|
|
1143
|
+
type: "INTEGER",
|
|
1144
|
+
notNull: true
|
|
1145
|
+
},
|
|
1146
|
+
{
|
|
1147
|
+
name: "state",
|
|
1148
|
+
type: "TEXT",
|
|
1149
|
+
notNull: true
|
|
1150
|
+
},
|
|
1151
|
+
{
|
|
1152
|
+
name: "recoveryDownloadedAt",
|
|
1153
|
+
type: "INTEGER"
|
|
1154
|
+
}
|
|
1155
|
+
];
|
|
1156
|
+
var RESET_COLUMNS = [
|
|
1157
|
+
{
|
|
1158
|
+
name: "id",
|
|
1159
|
+
type: "TEXT",
|
|
1160
|
+
primaryKey: true,
|
|
1161
|
+
notNull: true
|
|
1162
|
+
},
|
|
1163
|
+
{
|
|
1164
|
+
name: "at",
|
|
1165
|
+
type: "INTEGER",
|
|
1166
|
+
notNull: true
|
|
1167
|
+
},
|
|
1168
|
+
{
|
|
1169
|
+
name: "actor",
|
|
1170
|
+
type: "TEXT",
|
|
1171
|
+
notNull: true
|
|
1172
|
+
},
|
|
1173
|
+
{
|
|
1174
|
+
name: "lockedReason",
|
|
1175
|
+
type: "TEXT"
|
|
1176
|
+
},
|
|
1177
|
+
{
|
|
1178
|
+
name: "archived",
|
|
1179
|
+
type: "INTEGER",
|
|
1180
|
+
notNull: true
|
|
1181
|
+
},
|
|
1182
|
+
{
|
|
1183
|
+
name: "archivedCollection",
|
|
1184
|
+
type: "TEXT"
|
|
1185
|
+
},
|
|
1186
|
+
{
|
|
1187
|
+
name: "archivedKeysCollection",
|
|
1188
|
+
type: "TEXT"
|
|
1189
|
+
},
|
|
1190
|
+
{
|
|
1191
|
+
name: "clearedSettingsKeys",
|
|
1192
|
+
type: "TEXT",
|
|
1193
|
+
notNull: true
|
|
1194
|
+
},
|
|
1195
|
+
{
|
|
1196
|
+
name: "acknowledgedAt",
|
|
1197
|
+
type: "INTEGER"
|
|
1198
|
+
}
|
|
1199
|
+
];
|
|
1200
|
+
var AUDIT_COLUMNS = [
|
|
1201
|
+
{
|
|
1202
|
+
name: "id",
|
|
1203
|
+
type: "TEXT",
|
|
1204
|
+
primaryKey: true,
|
|
1205
|
+
notNull: true
|
|
1206
|
+
},
|
|
1207
|
+
{
|
|
1208
|
+
name: "at",
|
|
1209
|
+
type: "INTEGER",
|
|
1210
|
+
notNull: true
|
|
1211
|
+
},
|
|
1212
|
+
{
|
|
1213
|
+
name: "action",
|
|
1214
|
+
type: "TEXT",
|
|
1215
|
+
notNull: true
|
|
1216
|
+
},
|
|
1217
|
+
{
|
|
1218
|
+
name: "actor",
|
|
1219
|
+
type: "TEXT",
|
|
1220
|
+
notNull: true
|
|
1221
|
+
},
|
|
1222
|
+
{
|
|
1223
|
+
name: "ref",
|
|
1224
|
+
type: "TEXT"
|
|
1225
|
+
},
|
|
1226
|
+
{
|
|
1227
|
+
name: "detail",
|
|
1228
|
+
type: "TEXT"
|
|
1229
|
+
}
|
|
1230
|
+
];
|
|
1231
|
+
function str(data, key) {
|
|
1232
|
+
const v = data[key];
|
|
1233
|
+
return typeof v === "string" ? v : "";
|
|
1234
|
+
}
|
|
1235
|
+
function num(data, key) {
|
|
1236
|
+
const v = data[key];
|
|
1237
|
+
return typeof v === "number" ? v : Number(v ?? 0);
|
|
1238
|
+
}
|
|
1239
|
+
function numOrNull(data, key) {
|
|
1240
|
+
const v = data[key];
|
|
1241
|
+
if (v === null || v === void 0) return null;
|
|
1242
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
1243
|
+
return Number.isFinite(n) ? n : null;
|
|
1244
|
+
}
|
|
1245
|
+
function strOrNull(data, key) {
|
|
1246
|
+
const v = data[key];
|
|
1247
|
+
return typeof v === "string" ? v : null;
|
|
1248
|
+
}
|
|
1249
|
+
function toSecretRow(id, data) {
|
|
1250
|
+
return {
|
|
1251
|
+
ref: str(data, "ref") || id,
|
|
1252
|
+
owner: str(data, "owner"),
|
|
1253
|
+
scope: str(data, "scope"),
|
|
1254
|
+
fieldPath: str(data, "fieldPath"),
|
|
1255
|
+
ciphertext: str(data, "ciphertext"),
|
|
1256
|
+
keyId: str(data, "keyId"),
|
|
1257
|
+
version: num(data, "version"),
|
|
1258
|
+
createdAt: num(data, "createdAt"),
|
|
1259
|
+
updatedAt: num(data, "updatedAt"),
|
|
1260
|
+
updatedBy: str(data, "updatedBy"),
|
|
1261
|
+
lastReadAt: numOrNull(data, "lastReadAt")
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
function toKeyRow(id, data) {
|
|
1265
|
+
const state = str(data, "state");
|
|
1266
|
+
return {
|
|
1267
|
+
wrapId: str(data, "wrapId") || id,
|
|
1268
|
+
keyId: str(data, "keyId"),
|
|
1269
|
+
wrappedDek: str(data, "wrappedDek"),
|
|
1270
|
+
kekFingerprint: str(data, "kekFingerprint"),
|
|
1271
|
+
kekSource: str(data, "kekSource"),
|
|
1272
|
+
createdAt: num(data, "createdAt"),
|
|
1273
|
+
state: state === "retired" ? "retired" : "active",
|
|
1274
|
+
recoveryDownloadedAt: numOrNull(data, "recoveryDownloadedAt")
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
var AUDIT_ACTIONS = new Set([
|
|
1278
|
+
"put",
|
|
1279
|
+
"delete",
|
|
1280
|
+
"reveal",
|
|
1281
|
+
"rotate-master-key",
|
|
1282
|
+
"purge-retired-keys",
|
|
1283
|
+
"download-recovery-key",
|
|
1284
|
+
"delete-orphans",
|
|
1285
|
+
"reset",
|
|
1286
|
+
"acknowledge-reset",
|
|
1287
|
+
"initialize"
|
|
1288
|
+
]);
|
|
1289
|
+
function isAuditAction(value) {
|
|
1290
|
+
return AUDIT_ACTIONS.has(value);
|
|
1291
|
+
}
|
|
1292
|
+
function toAuditRow(id, data) {
|
|
1293
|
+
const action = str(data, "action");
|
|
1294
|
+
if (!isAuditAction(action)) return null;
|
|
1295
|
+
return {
|
|
1296
|
+
id: str(data, "id") || id,
|
|
1297
|
+
at: num(data, "at"),
|
|
1298
|
+
action,
|
|
1299
|
+
actor: str(data, "actor"),
|
|
1300
|
+
ref: strOrNull(data, "ref"),
|
|
1301
|
+
detail: strOrNull(data, "detail")
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
function toResetRecord(id, data) {
|
|
1305
|
+
const cleared = data["clearedSettingsKeys"];
|
|
1306
|
+
let keys = [];
|
|
1307
|
+
if (typeof cleared === "string") try {
|
|
1308
|
+
const parsed = JSON.parse(cleared);
|
|
1309
|
+
if (Array.isArray(parsed)) keys = parsed.filter((k) => typeof k === "string");
|
|
1310
|
+
} catch {
|
|
1311
|
+
keys = [];
|
|
1312
|
+
}
|
|
1313
|
+
else if (Array.isArray(cleared)) keys = cleared.filter((k) => typeof k === "string");
|
|
1314
|
+
return {
|
|
1315
|
+
id: str(data, "id") || id,
|
|
1316
|
+
at: num(data, "at"),
|
|
1317
|
+
actor: str(data, "actor"),
|
|
1318
|
+
lockedReason: strOrNull(data, "lockedReason"),
|
|
1319
|
+
archived: num(data, "archived"),
|
|
1320
|
+
archivedCollection: strOrNull(data, "archivedCollection"),
|
|
1321
|
+
archivedKeysCollection: strOrNull(data, "archivedKeysCollection"),
|
|
1322
|
+
clearedSettingsKeys: keys,
|
|
1323
|
+
acknowledgedAt: numOrNull(data, "acknowledgedAt")
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
/** Rows one listing page asks for — the table is small, but a read names its bound. */
|
|
1327
|
+
var PAGE = 1e3;
|
|
1328
|
+
var KeychainRepository = class {
|
|
1329
|
+
door;
|
|
1330
|
+
constructor(door) {
|
|
1331
|
+
this.door = door;
|
|
1332
|
+
}
|
|
1333
|
+
async init() {
|
|
1334
|
+
await this.door.declareCollection({
|
|
1335
|
+
collection: KEYCHAIN_SECRETS_COLLECTION,
|
|
1336
|
+
columns: SECRET_COLUMNS,
|
|
1337
|
+
indexes: [{
|
|
1338
|
+
name: "idx_keychain_secrets_owner",
|
|
1339
|
+
columns: ["owner"]
|
|
1340
|
+
}]
|
|
890
1341
|
});
|
|
891
|
-
await this.
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1342
|
+
await this.door.declareCollection({
|
|
1343
|
+
collection: KEYCHAIN_KEYS_COLLECTION,
|
|
1344
|
+
columns: KEY_COLUMNS
|
|
1345
|
+
});
|
|
1346
|
+
await this.door.declareCollection({
|
|
1347
|
+
collection: KEYCHAIN_RESETS_COLLECTION,
|
|
1348
|
+
columns: RESET_COLUMNS
|
|
1349
|
+
});
|
|
1350
|
+
await this.door.declareCollection({
|
|
1351
|
+
collection: KEYCHAIN_AUDIT_COLLECTION,
|
|
1352
|
+
columns: AUDIT_COLUMNS,
|
|
1353
|
+
indexes: [{
|
|
1354
|
+
name: "idx_keychain_audit_at",
|
|
1355
|
+
columns: ["at"]
|
|
1356
|
+
}]
|
|
897
1357
|
});
|
|
898
|
-
return {
|
|
899
|
-
ref,
|
|
900
|
-
version
|
|
901
|
-
};
|
|
902
1358
|
}
|
|
903
|
-
async
|
|
904
|
-
const out =
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
}
|
|
912
|
-
owned.push(ref);
|
|
913
|
-
}
|
|
914
|
-
if (owned.length === 0) return out;
|
|
915
|
-
if (this.unlock.state !== "ready") {
|
|
916
|
-
for (const ref of owned) out[ref] = {
|
|
917
|
-
kind: "unavailable",
|
|
918
|
-
reason: this.unlock.reason
|
|
919
|
-
};
|
|
920
|
-
return out;
|
|
921
|
-
}
|
|
922
|
-
const unlocked = this.unlock;
|
|
923
|
-
let rows;
|
|
924
|
-
try {
|
|
925
|
-
rows = await this.deps.repo.getSecrets(owned);
|
|
926
|
-
} catch {
|
|
927
|
-
for (const ref of owned) out[ref] = {
|
|
928
|
-
kind: "unavailable",
|
|
929
|
-
reason: "store-unavailable"
|
|
930
|
-
};
|
|
931
|
-
return out;
|
|
932
|
-
}
|
|
933
|
-
const t = this.now();
|
|
934
|
-
const stale = [];
|
|
935
|
-
for (const ref of owned) {
|
|
936
|
-
const row = rows.get(ref);
|
|
937
|
-
if (row === void 0) {
|
|
938
|
-
out[ref] = { kind: "missing" };
|
|
939
|
-
continue;
|
|
940
|
-
}
|
|
941
|
-
const value = decryptValue(unlocked.dek, ref, row.ciphertext);
|
|
942
|
-
if (value === null) {
|
|
943
|
-
out[ref] = {
|
|
944
|
-
kind: "unavailable",
|
|
945
|
-
reason: "decrypt-failed"
|
|
946
|
-
};
|
|
947
|
-
this.deps.logger.error("keychain value did not authenticate", { ref });
|
|
948
|
-
continue;
|
|
1359
|
+
async getSecrets(refs) {
|
|
1360
|
+
const out = /* @__PURE__ */ new Map();
|
|
1361
|
+
if (refs.length === 0) return out;
|
|
1362
|
+
const rows = await this.door.query({
|
|
1363
|
+
collection: KEYCHAIN_SECRETS_COLLECTION,
|
|
1364
|
+
filter: {
|
|
1365
|
+
whereIn: { ref: [...refs] },
|
|
1366
|
+
limit: refs.length
|
|
949
1367
|
}
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
};
|
|
955
|
-
if (row.lastReadAt === null || t - row.lastReadAt >= 36e5) stale.push(row);
|
|
1368
|
+
});
|
|
1369
|
+
for (const row of rows) {
|
|
1370
|
+
const parsed = toSecretRow(row.id, row.data);
|
|
1371
|
+
out.set(parsed.ref, parsed);
|
|
956
1372
|
}
|
|
957
|
-
if (stale.length > 0) this.touchLastRead(stale, t);
|
|
958
1373
|
return out;
|
|
959
1374
|
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
const existing = await this.deps.repo.getSecrets([...owners.keys()]);
|
|
975
|
-
const present = [...owners.keys()].filter((ref) => existing.has(ref));
|
|
976
|
-
if (present.length === 0) return 0;
|
|
977
|
-
const deleted = await this.deps.repo.deleteSecrets(present);
|
|
978
|
-
for (const ref of present) {
|
|
979
|
-
await this.audit("delete", actorOfAddon(caller), ref, null);
|
|
980
|
-
this.deps.events.emit({
|
|
981
|
-
ref,
|
|
982
|
-
owner: owners.get(ref) ?? "",
|
|
983
|
-
version: null,
|
|
984
|
-
change: "delete"
|
|
1375
|
+
/** Every row, paged by primary key. */
|
|
1376
|
+
async listSecrets() {
|
|
1377
|
+
const out = [];
|
|
1378
|
+
for (let offset = 0;; offset += PAGE) {
|
|
1379
|
+
const rows = await this.door.query({
|
|
1380
|
+
collection: KEYCHAIN_SECRETS_COLLECTION,
|
|
1381
|
+
filter: {
|
|
1382
|
+
orderBy: {
|
|
1383
|
+
field: "ref",
|
|
1384
|
+
direction: "asc"
|
|
1385
|
+
},
|
|
1386
|
+
limit: PAGE,
|
|
1387
|
+
offset
|
|
1388
|
+
}
|
|
985
1389
|
});
|
|
1390
|
+
for (const row of rows) out.push(toSecretRow(row.id, row.data));
|
|
1391
|
+
if (rows.length < PAGE) return out;
|
|
986
1392
|
}
|
|
987
|
-
return deleted;
|
|
988
1393
|
}
|
|
989
|
-
async
|
|
990
|
-
|
|
1394
|
+
async putSecret(row) {
|
|
1395
|
+
await this.door.set({
|
|
1396
|
+
collection: KEYCHAIN_SECRETS_COLLECTION,
|
|
1397
|
+
key: row.ref,
|
|
1398
|
+
value: row
|
|
1399
|
+
});
|
|
991
1400
|
}
|
|
992
|
-
async
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
path: defaultKeyFilePath(this.deps.dataDir),
|
|
999
|
-
sharesBackupWithDatabase: true
|
|
1000
|
-
};
|
|
1001
|
-
return {
|
|
1002
|
-
keychain: this.getState(),
|
|
1003
|
-
keySource: unlocked?.source ?? null,
|
|
1004
|
-
configuredSource: configured,
|
|
1005
|
-
fingerprint: unlocked !== null ? shortFingerprint(unlocked.fingerprint) : null,
|
|
1006
|
-
activeKeyId: unlocked?.keyId ?? null,
|
|
1007
|
-
rotationPending: unlocked?.rotationPending ?? false,
|
|
1008
|
-
retiredWraps: wraps.filter((w) => w.state === "retired").length,
|
|
1009
|
-
recoveryKeyDownloadedAt: unlocked !== null ? wraps.find((w) => w.wrapId === unlocked.wrap.wrapId)?.recoveryDownloadedAt ?? null : null,
|
|
1010
|
-
secretCount: secrets.length
|
|
1011
|
-
};
|
|
1401
|
+
async deleteSecrets(refs) {
|
|
1402
|
+
if (refs.length === 0) return 0;
|
|
1403
|
+
return (await this.door.deleteWhere({
|
|
1404
|
+
collection: KEYCHAIN_SECRETS_COLLECTION,
|
|
1405
|
+
filter: { whereIn: { ref: [...refs] } }
|
|
1406
|
+
})).deleted;
|
|
1012
1407
|
}
|
|
1013
|
-
async
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1408
|
+
async listKeys() {
|
|
1409
|
+
return (await this.door.query({
|
|
1410
|
+
collection: KEYCHAIN_KEYS_COLLECTION,
|
|
1411
|
+
filter: {
|
|
1412
|
+
orderBy: {
|
|
1413
|
+
field: "createdAt",
|
|
1414
|
+
direction: "asc"
|
|
1415
|
+
},
|
|
1416
|
+
limit: PAGE
|
|
1417
|
+
}
|
|
1418
|
+
})).map((row) => toKeyRow(row.id, row.data));
|
|
1020
1419
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
return this.put(require_jwt_signing_key.SYSTEM_SECRET_OWNER, ref, value, `user:${userId}`);
|
|
1420
|
+
async putKey(row) {
|
|
1421
|
+
await this.door.set({
|
|
1422
|
+
collection: KEYCHAIN_KEYS_COLLECTION,
|
|
1423
|
+
key: row.wrapId,
|
|
1424
|
+
value: row
|
|
1425
|
+
});
|
|
1028
1426
|
}
|
|
1029
|
-
async
|
|
1030
|
-
|
|
1427
|
+
async deleteKey(wrapId) {
|
|
1428
|
+
await this.door.delete({
|
|
1429
|
+
collection: KEYCHAIN_KEYS_COLLECTION,
|
|
1430
|
+
key: wrapId
|
|
1431
|
+
});
|
|
1031
1432
|
}
|
|
1032
|
-
async
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
await this.audit("reveal", `user:${userId}`, ref, null);
|
|
1039
|
-
return {
|
|
1040
|
-
value,
|
|
1041
|
-
version: row.version
|
|
1042
|
-
};
|
|
1433
|
+
async appendAudit(row) {
|
|
1434
|
+
await this.door.set({
|
|
1435
|
+
collection: KEYCHAIN_AUDIT_COLLECTION,
|
|
1436
|
+
key: row.id,
|
|
1437
|
+
value: row
|
|
1438
|
+
});
|
|
1043
1439
|
}
|
|
1044
|
-
async
|
|
1045
|
-
const rows =
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
ref: row.ref,
|
|
1054
|
-
usage: u
|
|
1055
|
-
});
|
|
1056
|
-
}
|
|
1057
|
-
if (deleted.length > 0) {
|
|
1058
|
-
await this.deps.repo.deleteSecrets(deleted);
|
|
1059
|
-
for (const ref of deleted) {
|
|
1060
|
-
const owner = require_dist.parseSecretRef(ref)?.owner ?? "";
|
|
1061
|
-
this.deps.events.emit({
|
|
1062
|
-
ref,
|
|
1063
|
-
owner,
|
|
1064
|
-
version: null,
|
|
1065
|
-
change: "delete"
|
|
1066
|
-
});
|
|
1440
|
+
async listAudit(limit) {
|
|
1441
|
+
const rows = await this.door.query({
|
|
1442
|
+
collection: KEYCHAIN_AUDIT_COLLECTION,
|
|
1443
|
+
filter: {
|
|
1444
|
+
orderBy: {
|
|
1445
|
+
field: "at",
|
|
1446
|
+
direction: "desc"
|
|
1447
|
+
},
|
|
1448
|
+
limit
|
|
1067
1449
|
}
|
|
1068
|
-
|
|
1450
|
+
});
|
|
1451
|
+
const out = [];
|
|
1452
|
+
for (const row of rows) {
|
|
1453
|
+
const parsed = toAuditRow(row.id, row.data);
|
|
1454
|
+
if (parsed !== null) out.push(parsed);
|
|
1069
1455
|
}
|
|
1070
|
-
return
|
|
1071
|
-
deleted,
|
|
1072
|
-
kept
|
|
1073
|
-
};
|
|
1456
|
+
return out;
|
|
1074
1457
|
}
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
const
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
recoveryDownloadedAt: t
|
|
1458
|
+
/** Drop audit rows older than the retention window, then beyond the row bound. */
|
|
1459
|
+
async pruneAudit(nowMs) {
|
|
1460
|
+
const aged = await this.door.deleteWhere({
|
|
1461
|
+
collection: KEYCHAIN_AUDIT_COLLECTION,
|
|
1462
|
+
filter: { whereBetween: { at: [0, nowMs - AUDIT_RETENTION_MS] } }
|
|
1081
1463
|
});
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1464
|
+
const cutoff = (await this.door.query({
|
|
1465
|
+
collection: KEYCHAIN_AUDIT_COLLECTION,
|
|
1466
|
+
filter: {
|
|
1467
|
+
orderBy: {
|
|
1468
|
+
field: "at",
|
|
1469
|
+
direction: "desc"
|
|
1470
|
+
},
|
|
1471
|
+
limit: 1,
|
|
1472
|
+
offset: AUDIT_MAX_ROWS
|
|
1087
1473
|
}
|
|
1088
|
-
};
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1474
|
+
}))[0]?.data["at"];
|
|
1475
|
+
if (typeof cutoff !== "number") return aged.deleted;
|
|
1476
|
+
const bounded = await this.door.deleteWhere({
|
|
1477
|
+
collection: KEYCHAIN_AUDIT_COLLECTION,
|
|
1478
|
+
filter: { whereBetween: { at: [0, cutoff] } }
|
|
1479
|
+
});
|
|
1480
|
+
return aged.deleted + bounded.deleted;
|
|
1095
1481
|
}
|
|
1096
|
-
/**
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
newKek = generateKey();
|
|
1108
|
-
await writeDefaultKeyFile(this.deps.dataDir, newKek, {
|
|
1109
|
-
replace: true,
|
|
1110
|
-
nowMs: this.now()
|
|
1111
|
-
});
|
|
1112
|
-
} else throw new Error(`keychain: the ${primary.source.kind} key is already in use. Put the new key at its source, set the old one as keychain.previousKeyFile, restart, then rotate.`);
|
|
1113
|
-
const fingerprint = kekFingerprint(newKek);
|
|
1114
|
-
if (constantTimeEquals(fingerprint, unlocked.fingerprint)) throw new Error("keychain: the new master key is the one already in use");
|
|
1115
|
-
const wrap = {
|
|
1116
|
-
wrapId: (0, node_crypto.randomUUID)(),
|
|
1117
|
-
keyId: unlocked.keyId,
|
|
1118
|
-
wrappedDek: wrapDek(newKek, unlocked.dek, unlocked.keyId),
|
|
1119
|
-
kekFingerprint: fingerprint,
|
|
1120
|
-
kekSource: primary.source.kind,
|
|
1121
|
-
createdAt: this.now(),
|
|
1122
|
-
state: "active",
|
|
1123
|
-
recoveryDownloadedAt: null
|
|
1124
|
-
};
|
|
1125
|
-
await this.deps.repo.putKey(wrap);
|
|
1126
|
-
for (const old of await this.deps.repo.listKeys()) if (old.wrapId !== wrap.wrapId && old.state === "active") await this.deps.repo.putKey({
|
|
1127
|
-
...old,
|
|
1128
|
-
state: "retired"
|
|
1482
|
+
/**
|
|
1483
|
+
* Lost-key recovery: copy every secret row into `archive`, then empty the
|
|
1484
|
+
* live table. The archive is ciphertext under a key nobody has — it exists
|
|
1485
|
+
* so a key that turns up later can still recover it.
|
|
1486
|
+
*/
|
|
1487
|
+
async archiveAllSecrets(archive) {
|
|
1488
|
+
const rows = await this.listSecrets();
|
|
1489
|
+
if (rows.length === 0) return 0;
|
|
1490
|
+
await this.door.declareCollection({
|
|
1491
|
+
collection: archive,
|
|
1492
|
+
columns: SECRET_COLUMNS
|
|
1129
1493
|
});
|
|
1130
|
-
this.
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
wrap,
|
|
1138
|
-
rotationPending: false
|
|
1139
|
-
};
|
|
1140
|
-
this.configuredSource = primary.source;
|
|
1141
|
-
await this.audit("rotate-master-key", `user:${userId}`, null, `source=${primary.source.kind}`);
|
|
1142
|
-
return {
|
|
1143
|
-
fingerprint: shortFingerprint(fingerprint),
|
|
1144
|
-
keySource: primary.source
|
|
1145
|
-
};
|
|
1494
|
+
for (const row of rows) await this.door.set({
|
|
1495
|
+
collection: archive,
|
|
1496
|
+
key: row.ref,
|
|
1497
|
+
value: row
|
|
1498
|
+
});
|
|
1499
|
+
await this.deleteSecrets(rows.map((r) => r.ref));
|
|
1500
|
+
return rows.length;
|
|
1146
1501
|
}
|
|
1147
|
-
/**
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1502
|
+
/**
|
|
1503
|
+
* Copy every wrapped data key into `archive` (the companion of
|
|
1504
|
+
* {@link archiveAllSecrets}): without the wrap, an archived value could
|
|
1505
|
+
* never be opened again even if the lost master key turned up.
|
|
1506
|
+
*/
|
|
1507
|
+
async archiveAllKeys(archive) {
|
|
1508
|
+
const rows = await this.listKeys();
|
|
1509
|
+
if (rows.length === 0) return 0;
|
|
1510
|
+
await this.door.declareCollection({
|
|
1511
|
+
collection: archive,
|
|
1512
|
+
columns: KEY_COLUMNS
|
|
1513
|
+
});
|
|
1514
|
+
for (const row of rows) await this.door.set({
|
|
1515
|
+
collection: archive,
|
|
1516
|
+
key: row.wrapId,
|
|
1517
|
+
value: row
|
|
1518
|
+
});
|
|
1519
|
+
return rows.length;
|
|
1158
1520
|
}
|
|
1159
|
-
/**
|
|
1160
|
-
async
|
|
1161
|
-
const
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1521
|
+
/** Every secret row of an archive table (ciphertext — never decrypted here). */
|
|
1522
|
+
async listArchivedSecrets(archive) {
|
|
1523
|
+
const out = [];
|
|
1524
|
+
for (let offset = 0;; offset += PAGE) {
|
|
1525
|
+
const rows = await this.door.query({
|
|
1526
|
+
collection: archive,
|
|
1527
|
+
filter: {
|
|
1528
|
+
orderBy: {
|
|
1529
|
+
field: "ref",
|
|
1530
|
+
direction: "asc"
|
|
1531
|
+
},
|
|
1532
|
+
limit: PAGE,
|
|
1533
|
+
offset
|
|
1534
|
+
}
|
|
1170
1535
|
});
|
|
1536
|
+
for (const row of rows) out.push(toSecretRow(row.id, row.data));
|
|
1537
|
+
if (rows.length < PAGE) return out;
|
|
1171
1538
|
}
|
|
1172
|
-
this.unlock = await this.firstBoot(primary);
|
|
1173
|
-
await this.audit("reset", `user:${userId}`, null, `archived=${archived}`);
|
|
1174
|
-
return {
|
|
1175
|
-
archivedCollection: archived > 0 ? archiveName : null,
|
|
1176
|
-
archived
|
|
1177
|
-
};
|
|
1178
1539
|
}
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1540
|
+
/**
|
|
1541
|
+
* Delete the `system-settings` rows among `keys` that exist; returns the
|
|
1542
|
+
* keys it deleted. A read that fails throws — never "nothing was there".
|
|
1543
|
+
*/
|
|
1544
|
+
async clearSystemSettingsKeys(keys) {
|
|
1545
|
+
const cleared = [];
|
|
1546
|
+
for (const key of keys) {
|
|
1547
|
+
const current = await this.door.get({
|
|
1548
|
+
collection: "system-settings",
|
|
1549
|
+
key
|
|
1188
1550
|
});
|
|
1189
|
-
|
|
1190
|
-
this.
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
error: errMsg(err)
|
|
1551
|
+
if (current === void 0 || current === null) continue;
|
|
1552
|
+
await this.door.delete({
|
|
1553
|
+
collection: "system-settings",
|
|
1554
|
+
key
|
|
1194
1555
|
});
|
|
1556
|
+
cleared.push(key);
|
|
1195
1557
|
}
|
|
1558
|
+
return cleared;
|
|
1196
1559
|
}
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1560
|
+
async putReset(record) {
|
|
1561
|
+
await this.door.set({
|
|
1562
|
+
collection: KEYCHAIN_RESETS_COLLECTION,
|
|
1563
|
+
key: record.id,
|
|
1564
|
+
value: {
|
|
1565
|
+
...record,
|
|
1566
|
+
clearedSettingsKeys: JSON.stringify(record.clearedSettingsKeys)
|
|
1567
|
+
}
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
async latestReset() {
|
|
1571
|
+
const row = (await this.door.query({
|
|
1572
|
+
collection: KEYCHAIN_RESETS_COLLECTION,
|
|
1573
|
+
filter: {
|
|
1574
|
+
orderBy: {
|
|
1575
|
+
field: "at",
|
|
1576
|
+
direction: "desc"
|
|
1577
|
+
},
|
|
1578
|
+
limit: 1
|
|
1579
|
+
}
|
|
1580
|
+
}))[0];
|
|
1581
|
+
return row === void 0 ? null : toResetRecord(row.id, row.data);
|
|
1200
1582
|
}
|
|
1201
1583
|
};
|
|
1202
|
-
function toMeta(row) {
|
|
1203
|
-
return {
|
|
1204
|
-
ref: row.ref,
|
|
1205
|
-
owner: row.owner,
|
|
1206
|
-
scope: row.scope,
|
|
1207
|
-
fieldPath: row.fieldPath,
|
|
1208
|
-
version: row.version,
|
|
1209
|
-
createdAt: row.createdAt,
|
|
1210
|
-
updatedAt: row.updatedAt,
|
|
1211
|
-
updatedBy: row.updatedBy,
|
|
1212
|
-
lastReadAt: row.lastReadAt
|
|
1213
|
-
};
|
|
1214
|
-
}
|
|
1215
|
-
function actorOfAddon(caller) {
|
|
1216
|
-
return caller === null ? "system" : `addon:${require_dist.normalizeSecretOwner(caller)}`;
|
|
1217
|
-
}
|
|
1218
|
-
function errMsg(err) {
|
|
1219
|
-
return err instanceof Error ? err.message : String(err);
|
|
1220
|
-
}
|
|
1221
|
-
require_dist.EventCategory.KeychainChanged;
|
|
1222
1584
|
//#endregion
|
|
1223
1585
|
//#region src/builtins/keychain/keychain-usage.ts
|
|
1224
1586
|
/**
|
|
@@ -1357,8 +1719,13 @@ function createKeychainUsageCheck(door) {
|
|
|
1357
1719
|
* listMeta / getState, owner-checked against the caller the transport
|
|
1358
1720
|
* stamped (`callerAddonId`, never in the input schema);
|
|
1359
1721
|
* - `keychain-admin`: System → Secrets — status, list, audit, reveal,
|
|
1360
|
-
* orphans, rotation, recovery key, reset. Every method
|
|
1361
|
-
* that change or hand out material take the session's user
|
|
1722
|
+
* orphans, rotation, recovery key, reset (and its report). Every method
|
|
1723
|
+
* admin; those that change or hand out material take the session's user
|
|
1724
|
+
* for the audit.
|
|
1725
|
+
*
|
|
1726
|
+
* At boot, right after the unlock, the one-shot `<dataDir>/.keychain/RESET_KEYCHAIN`
|
|
1727
|
+
* trigger file is honoured (see `keychain-reset-trigger.ts`) — the only way
|
|
1728
|
+
* back into a hub whose master key is lost.
|
|
1362
1729
|
*
|
|
1363
1730
|
* It does not run on agents: an agent's Phase 1 drops `keychain` from its infra
|
|
1364
1731
|
* list (`server/backend/src/agent/agent-infra-capabilities.ts`), and an agent
|
|
@@ -1401,6 +1768,11 @@ var KeychainAddon = class extends require_dist.BaseAddon {
|
|
|
1401
1768
|
};
|
|
1402
1769
|
const dataDir = ctx.nodeDataDir;
|
|
1403
1770
|
const runnerIdOf = ctx.kernel.runnerIdForAddon ?? ((addonId) => addonId);
|
|
1771
|
+
const serviceLogger = {
|
|
1772
|
+
info: (message, meta) => ctx.logger.info(message, meta ? { meta } : void 0),
|
|
1773
|
+
warn: (message, meta) => ctx.logger.warn(message, meta ? { meta } : void 0),
|
|
1774
|
+
error: (message, meta) => ctx.logger.error(message, meta ? { meta } : void 0)
|
|
1775
|
+
};
|
|
1404
1776
|
const service = new KeychainService({
|
|
1405
1777
|
repo: new KeychainRepository(door),
|
|
1406
1778
|
candidates: async () => {
|
|
@@ -1422,15 +1794,16 @@ var KeychainAddon = class extends require_dist.BaseAddon {
|
|
|
1422
1794
|
data: payload
|
|
1423
1795
|
});
|
|
1424
1796
|
} },
|
|
1425
|
-
logger:
|
|
1426
|
-
info: (message, meta) => ctx.logger.info(message, meta ? { meta } : void 0),
|
|
1427
|
-
warn: (message, meta) => ctx.logger.warn(message, meta ? { meta } : void 0),
|
|
1428
|
-
error: (message, meta) => ctx.logger.error(message, meta ? { meta } : void 0)
|
|
1429
|
-
},
|
|
1797
|
+
logger: serviceLogger,
|
|
1430
1798
|
runnerIdOf,
|
|
1431
1799
|
usage: createKeychainUsageCheck(door)
|
|
1432
1800
|
});
|
|
1433
1801
|
await service.init();
|
|
1802
|
+
await applyKeychainResetTrigger({
|
|
1803
|
+
service,
|
|
1804
|
+
dataDir,
|
|
1805
|
+
logger: serviceLogger
|
|
1806
|
+
});
|
|
1434
1807
|
this.pruneTimer = setInterval(() => {
|
|
1435
1808
|
service.pruneAudit().catch((err) => {
|
|
1436
1809
|
ctx.logger.warn("keychain audit prune failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
@@ -1458,7 +1831,9 @@ var KeychainAddon = class extends require_dist.BaseAddon {
|
|
|
1458
1831
|
rotateMasterKey: (input) => service.rotateMasterKey(input.caller.userId),
|
|
1459
1832
|
purgeRetiredKeys: async (input) => ({ purged: await service.purgeRetiredKeys(input.caller.userId) }),
|
|
1460
1833
|
downloadRecoveryKey: (input) => service.downloadRecoveryKey(input.caller.userId),
|
|
1461
|
-
resetKeychain: (input) => service.resetKeychain(input.caller.userId)
|
|
1834
|
+
resetKeychain: (input) => service.resetKeychain(input.caller.userId),
|
|
1835
|
+
resetReport: () => service.resetReport(),
|
|
1836
|
+
acknowledgeReset: (input) => service.acknowledgeReset(input.caller.userId)
|
|
1462
1837
|
}
|
|
1463
1838
|
}];
|
|
1464
1839
|
}
|