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