@camstack/system 1.2.381 → 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.
Files changed (114) hide show
  1. package/dist/addon-runner.js +4 -2
  2. package/dist/addon-runner.mjs +4 -2
  3. package/dist/addon-utils.js +1 -1
  4. package/dist/addon-utils.mjs +1 -1
  5. package/dist/auth/admin-token-manager.d.ts +75 -0
  6. package/dist/auth/api-key-manager.d.ts +1 -14
  7. package/dist/auth/auth-audit-log.d.ts +59 -0
  8. package/dist/auth/auth-manager.d.ts +28 -16
  9. package/dist/auth/cidr.d.ts +22 -0
  10. package/dist/auth/login-session-manager.d.ts +158 -0
  11. package/dist/auth/user-management-internal.d.ts +69 -0
  12. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  13. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  14. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  15. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  16. package/dist/builtins/alerts/alerts.addon.js +1 -1
  17. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  18. package/dist/builtins/autotrack/index.js +1 -1
  19. package/dist/builtins/autotrack/index.mjs +1 -1
  20. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.d.ts +14 -0
  21. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +291 -3631
  22. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +284 -3617
  23. package/dist/builtins/backup-orchestrator/encryption-key-store.d.ts +36 -0
  24. package/dist/builtins/backup-orchestrator/manifest-store.d.ts +19 -1
  25. package/dist/builtins/backup-orchestrator/restore-archive.d.ts +24 -0
  26. package/dist/builtins/calendars/calendars.addon.js +1 -1
  27. package/dist/builtins/calendars/calendars.addon.mjs +1 -1
  28. package/dist/builtins/camera-grid/index.js +1 -1
  29. package/dist/builtins/camera-grid/index.mjs +1 -1
  30. package/dist/builtins/composer/composer.addon.js +2 -2
  31. package/dist/builtins/composer/composer.addon.mjs +2 -2
  32. package/dist/builtins/console-logging/index.js +1 -1
  33. package/dist/builtins/console-logging/index.mjs +1 -1
  34. package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
  35. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  36. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  37. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  38. package/dist/builtins/hub-forwarder/index.js +1 -1
  39. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  40. package/dist/builtins/keychain/index.js +1108 -733
  41. package/dist/builtins/keychain/index.mjs +1108 -733
  42. package/dist/builtins/keychain/keychain-reset-trigger.d.ts +25 -0
  43. package/dist/builtins/keychain/keychain-service.d.ts +36 -3
  44. package/dist/builtins/keychain/keychain-store.d.ts +24 -1
  45. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  46. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  47. package/dist/builtins/local-auth/local-auth.addon.d.ts +16 -0
  48. package/dist/builtins/local-auth/local-auth.addon.js +1021 -118
  49. package/dist/builtins/local-auth/local-auth.addon.mjs +1034 -141
  50. package/dist/builtins/local-network/local-network.addon.js +3 -3
  51. package/dist/builtins/local-network/local-network.addon.mjs +3 -3
  52. package/dist/builtins/loki-logging/index.js +97 -29
  53. package/dist/builtins/loki-logging/index.mjs +97 -29
  54. package/dist/builtins/loki-logging/loki-logging.addon.d.ts +7 -23
  55. package/dist/builtins/loki-logging/retire-enabled-flag.d.ts +23 -0
  56. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  57. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  58. package/dist/builtins/platform-probe/index.js +1 -1
  59. package/dist/builtins/platform-probe/index.mjs +1 -1
  60. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  61. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  62. package/dist/builtins/snapshot/index.js +1 -1
  63. package/dist/builtins/snapshot/index.mjs +1 -1
  64. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  65. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  66. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  67. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  68. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +2 -2
  69. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +2 -2
  70. package/dist/builtins/system-backup/backup-encryption.d.ts +57 -0
  71. package/dist/builtins/system-backup/system-backup.service.d.ts +16 -1
  72. package/dist/builtins/system-config/cluster-secret-keeper.d.ts +7 -0
  73. package/dist/builtins/system-config/system-config.addon.d.ts +13 -3
  74. package/dist/builtins/system-config/system-config.addon.js +324 -38
  75. package/dist/builtins/system-config/system-config.addon.mjs +312 -39
  76. package/dist/builtins/winston-logging/index.js +1 -1
  77. package/dist/builtins/winston-logging/index.mjs +1 -1
  78. package/dist/{child-cap-dispatch-hf-Gce_4.js → child-cap-dispatch-BrMdKHfp.js} +6 -3
  79. package/dist/{child-cap-dispatch-BTe4eKep.mjs → child-cap-dispatch-WsYPNvnK.mjs} +10 -7
  80. package/dist/{cluster-secret-BUiG8DCo.js → cluster-secret-BEPLh3LL.js} +1 -1
  81. package/dist/{cluster-secret-DgJTZ4qY.mjs → cluster-secret-DhPdZ0VA.mjs} +1 -1
  82. package/dist/{composition-sources-4SmsP0O4.js → composition-sources-C6ZynCZ2.js} +1 -1
  83. package/dist/{composition-sources-CgOQVIwa.mjs → composition-sources-XqRX51db.mjs} +1 -1
  84. package/dist/{dist-BC0GnGeg.mjs → dist-CP5TTjwd.mjs} +1710 -158
  85. package/dist/{dist-BugrtARh.js → dist-tnAO12ak.js} +1721 -157
  86. package/dist/{event-loop-stall-monitor-CnQ-K6Gn.js → event-loop-stall-monitor-9kR0sAPS.js} +15 -15
  87. package/dist/{event-loop-stall-monitor-GgpnfAC0.mjs → event-loop-stall-monitor-WkssIXjq.mjs} +15 -15
  88. package/dist/{hub-cap-forward-CH6_VWVO.mjs → hub-cap-forward-CDiN1p98.mjs} +18 -2
  89. package/dist/{hub-cap-forward-COCmYaup.js → hub-cap-forward-W8oEm4ME.js} +23 -1
  90. package/dist/index.d.ts +16 -1
  91. package/dist/index.js +3341 -79
  92. package/dist/index.mjs +3567 -354
  93. package/dist/{jwt-signing-key-CsuVKA1W.mjs → jwt-signing-key-CAbozwvF.mjs} +1 -1
  94. package/dist/{jwt-signing-key-CjoZcol8.js → jwt-signing-key-Dgf8sIXY.js} +1 -1
  95. package/dist/kernel/index.d.ts +12 -0
  96. package/dist/kernel/moleculer/broker-factory.d.ts +8 -1
  97. package/dist/kernel/moleculer/child-addon-call-dispatch.d.ts +1 -0
  98. package/dist/kernel/moleculer/hub-service.d.ts +9 -0
  99. package/dist/kernel/moleculer/mesh-access-middleware.d.ts +28 -0
  100. package/dist/kernel/moleculer/mesh-auth-mode.d.ts +64 -0
  101. package/dist/kernel/moleculer/mesh-authenticator.d.ts +71 -0
  102. package/dist/kernel/moleculer/mesh-auto-enforce.d.ts +26 -0
  103. package/dist/kernel/moleculer/mesh-join-client.d.ts +51 -0
  104. package/dist/kernel/moleculer/mesh-join-proof.d.ts +39 -0
  105. package/dist/kernel/system-settings-policy.d.ts +4 -3
  106. package/dist/kernel/transport/hub-cap-forward.d.ts +14 -3
  107. package/dist/kernel/transport/index.d.ts +1 -1
  108. package/dist/restore-archive-BV39nY46.mjs +4045 -0
  109. package/dist/restore-archive-CbvrfPuB.js +4139 -0
  110. package/dist/{retired-settings-keys-DCuetwOt.js → retired-settings-keys-4bHpL-0m.js} +1 -1
  111. package/dist/{retired-settings-keys-MCVhxPkJ.mjs → retired-settings-keys-CzpIkwS-.mjs} +1 -1
  112. package/dist/{tls-CJHogwTl.mjs → tls-4cRH24Id.mjs} +3 -3
  113. package/package.json +1 -1
  114. 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-BugrtARh.js");
7
- const require_jwt_signing_key = require("../../jwt-signing-key-CjoZcol8.js");
8
- const require_cluster_secret = require("../../cluster-secret-BUiG8DCo.js");
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-store.ts
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
- * @durable class=ring owner=keychain
259
- * write="one row per write / delete / rotation / reveal / recovery download"
260
- * retention="90 days and at most 20 000 rows, pruned at boot and daily"
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
- var KEYCHAIN_AUDIT_COLLECTION = "keychain:audit";
263
- var AUDIT_RETENTION_MS = 2160 * 60 * 6e4;
264
- var AUDIT_MAX_ROWS = 2e4;
265
- var SECRET_COLUMNS = [
266
- {
267
- name: "ref",
268
- type: "TEXT",
269
- primaryKey: true,
270
- notNull: true
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 toKeyRow(id, data) {
429
- const state = str(data, "state");
430
- return {
431
- wrapId: str(data, "wrapId") || id,
432
- keyId: str(data, "keyId"),
433
- wrappedDek: str(data, "wrappedDek"),
434
- kekFingerprint: str(data, "kekFingerprint"),
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
- var AUDIT_ACTIONS = new Set([
442
- "put",
443
- "delete",
444
- "reveal",
445
- "rotate-master-key",
446
- "purge-retired-keys",
447
- "download-recovery-key",
448
- "delete-orphans",
449
- "reset",
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 toAuditRow(id, data) {
456
- const action = str(data, "action");
457
- if (!isAuditAction(action)) return null;
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
- id: str(data, "id") || id,
460
- at: num(data, "at"),
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.audit("put", actor, ref, `version=${version}`);
892
- this.deps.events.emit({
893
- ref,
894
- owner,
895
- version,
896
- change: "put"
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 resolve(caller, refs) {
904
- const out = {};
905
- const owned = [];
906
- for (const ref of new Set(refs)) {
907
- const parts = require_dist.parseSecretRef(ref);
908
- if (parts === null || !this.owns(caller, parts.owner)) {
909
- out[ref] = { kind: "forbidden" };
910
- continue;
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
- out[ref] = {
951
- kind: "value",
952
- value,
953
- version: row.version
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
- async touchLastRead(rows, at) {
961
- try {
962
- for (const row of rows) await this.deps.repo.putSecret({
963
- ...row,
964
- lastReadAt: at
965
- });
966
- } catch (err) {
967
- this.deps.logger.warn("keychain lastReadAt not recorded", { error: errMsg(err) });
968
- }
969
- }
970
- async delete(caller, refs) {
971
- const owners = /* @__PURE__ */ new Map();
972
- for (const ref of new Set(refs)) owners.set(ref, this.ownerOf(ref, caller));
973
- if (owners.size === 0) return 0;
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 listMeta(caller) {
990
- return (await this.deps.repo.listSecrets()).filter((row) => this.owns(caller, row.owner)).map(toMeta);
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 status() {
993
- const wraps = await this.deps.repo.listKeys().catch(() => []);
994
- const secrets = await this.deps.repo.listSecrets().catch(() => []);
995
- const unlocked = this.unlock.state === "ready" ? this.unlock : null;
996
- const configured = this.configuredSource ?? {
997
- kind: "default-file",
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 listSecrets() {
1014
- const rows = await this.deps.repo.listSecrets();
1015
- const usage = await this.deps.usage(rows).catch(() => /* @__PURE__ */ new Map());
1016
- return rows.map((row) => ({
1017
- ...toMeta(row),
1018
- usage: usage.get(row.ref) ?? "unknown"
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
- * The operator replaces a system-held secret no form edits (D797) — the
1023
- * session signing key. Stored under owner `system`, audited as the user.
1024
- */
1025
- async setSystemSecret(userId, name, value) {
1026
- const ref = SYSTEM_HELD_SECRET_REFS[name];
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 listAudit(limit) {
1030
- return this.deps.repo.listAudit(limit);
1427
+ async deleteKey(wrapId) {
1428
+ await this.door.delete({
1429
+ collection: KEYCHAIN_KEYS_COLLECTION,
1430
+ key: wrapId
1431
+ });
1031
1432
  }
1032
- async reveal(userId, ref) {
1033
- const unlocked = this.requireReady();
1034
- const row = (await this.deps.repo.getSecrets([ref])).get(ref);
1035
- if (row === void 0) throw new Error(`keychain: no value stored at ${ref}`);
1036
- const value = decryptValue(unlocked.dek, ref, row.ciphertext);
1037
- if (value === null) throw new Error(`keychain: the value at ${ref} did not authenticate`);
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 deleteOrphans(userId, refs) {
1045
- const rows = [...(await this.deps.repo.getSecrets(refs)).values()];
1046
- const usage = await this.deps.usage(rows);
1047
- const deleted = [];
1048
- const kept = [];
1049
- for (const row of rows) {
1050
- const u = usage.get(row.ref) ?? "unknown";
1051
- if (u === "orphan") deleted.push(row.ref);
1052
- else kept.push({
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
- await this.audit("delete-orphans", `user:${userId}`, null, `deleted=${deleted.length}`);
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
- async downloadRecoveryKey(userId) {
1076
- const unlocked = this.requireReady();
1077
- const t = this.now();
1078
- await this.deps.repo.putKey({
1079
- ...unlocked.wrap,
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
- this.unlock = {
1083
- ...unlocked,
1084
- wrap: {
1085
- ...unlocked.wrap,
1086
- recoveryDownloadedAt: t
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
- await this.audit("download-recovery-key", `user:${userId}`, null, `source=${unlocked.source.kind}`);
1090
- return {
1091
- key: encodeKeyMaterial(unlocked.kek),
1092
- fingerprint: shortFingerprint(unlocked.fingerprint),
1093
- keySource: unlocked.source
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
- /** See `keychain-admin.rotateMasterKey`. */
1097
- async rotateMasterKey(userId) {
1098
- const unlocked = this.requireReady();
1099
- const primary = (await this.deps.candidates()).find((c) => c.primary);
1100
- if (primary === void 0) throw new Error("keychain: no primary key source is configured");
1101
- let newKek;
1102
- if (unlocked.rotationPending && primary.source.kind !== unlocked.source.kind) {
1103
- const loaded = await primary.load();
1104
- if (loaded.kind !== "loaded") throw new Error(`keychain: the configured ${primary.source.kind} key could not be loaded`);
1105
- newKek = loaded.key;
1106
- } else if (primary.source.kind === "default-file") {
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.unlock = {
1131
- state: "ready",
1132
- dek: unlocked.dek,
1133
- keyId: unlocked.keyId,
1134
- kek: newKek,
1135
- fingerprint,
1136
- source: primary.source,
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
- /** Delete retired wraps, and the default file's previous copies with them. */
1148
- async purgeRetiredKeys(userId) {
1149
- this.requireReady();
1150
- const retired = (await this.deps.repo.listKeys()).filter((w) => w.state === "retired");
1151
- for (const wrap of retired) await this.deps.repo.deleteKey(wrap.wrapId);
1152
- const dir = node_path.dirname(defaultKeyFilePath(this.deps.dataDir));
1153
- try {
1154
- 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 });
1155
- } catch {}
1156
- await this.audit("purge-retired-keys", `user:${userId}`, null, `purged=${retired.length}`);
1157
- return retired.length;
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
- /** Lost-key recovery — see `keychain-admin.resetKeychain`. */
1160
- async resetKeychain(userId) {
1161
- const t = this.now();
1162
- const archiveName = `keychain:secrets-orphaned-${t}`;
1163
- const archived = await this.deps.repo.archiveAllSecrets(archiveName);
1164
- for (const wrap of await this.deps.repo.listKeys()) await this.deps.repo.deleteKey(wrap.wrapId);
1165
- const primary = (await this.deps.candidates()).find((c) => c.primary) ?? null;
1166
- if (primary?.source.kind === "default-file") {
1167
- if ((await primary.load()).kind !== "absent") await writeDefaultKeyFile(this.deps.dataDir, generateKey(), {
1168
- replace: true,
1169
- nowMs: t
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
- async audit(action, actor, ref, detail) {
1180
- try {
1181
- await this.deps.repo.appendAudit({
1182
- id: (0, node_crypto.randomUUID)(),
1183
- at: this.now(),
1184
- action,
1185
- actor,
1186
- ref,
1187
- detail
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
- } catch (err) {
1190
- this.deps.logger.warn("keychain audit row not written", {
1191
- action,
1192
- ref,
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
- /** Daily ring prune, driven by the addon's timer. */
1198
- async pruneAudit() {
1199
- await this.deps.repo.pruneAudit(this.now());
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 admin; the four
1361
- * that change or hand out material take the session's user for the audit.
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
  }