@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.
- package/dist/addon-runner.js +4 -2
- package/dist/addon-runner.mjs +4 -2
- package/dist/addon-utils.js +1 -1
- package/dist/addon-utils.mjs +1 -1
- package/dist/auth/admin-token-manager.d.ts +75 -0
- package/dist/auth/api-key-manager.d.ts +1 -14
- package/dist/auth/auth-audit-log.d.ts +59 -0
- package/dist/auth/auth-manager.d.ts +28 -16
- package/dist/auth/cidr.d.ts +22 -0
- package/dist/auth/login-session-manager.d.ts +158 -0
- package/dist/auth/user-management-internal.d.ts +69 -0
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/autotrack/index.js +1 -1
- package/dist/builtins/autotrack/index.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.d.ts +14 -0
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +291 -3631
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +284 -3617
- package/dist/builtins/backup-orchestrator/encryption-key-store.d.ts +36 -0
- package/dist/builtins/backup-orchestrator/manifest-store.d.ts +19 -1
- package/dist/builtins/backup-orchestrator/restore-archive.d.ts +24 -0
- package/dist/builtins/calendars/calendars.addon.js +1 -1
- package/dist/builtins/calendars/calendars.addon.mjs +1 -1
- package/dist/builtins/camera-grid/index.js +1 -1
- package/dist/builtins/camera-grid/index.mjs +1 -1
- package/dist/builtins/composer/composer.addon.js +2 -2
- package/dist/builtins/composer/composer.addon.mjs +2 -2
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
- package/dist/builtins/device-manager/device-manager.addon.js +2 -2
- package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/keychain/index.js +1108 -733
- package/dist/builtins/keychain/index.mjs +1108 -733
- package/dist/builtins/keychain/keychain-reset-trigger.d.ts +25 -0
- package/dist/builtins/keychain/keychain-service.d.ts +36 -3
- package/dist/builtins/keychain/keychain-store.d.ts +24 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.d.ts +16 -0
- package/dist/builtins/local-auth/local-auth.addon.js +1021 -118
- package/dist/builtins/local-auth/local-auth.addon.mjs +1034 -141
- package/dist/builtins/local-network/local-network.addon.js +3 -3
- package/dist/builtins/local-network/local-network.addon.mjs +3 -3
- package/dist/builtins/loki-logging/index.js +97 -29
- package/dist/builtins/loki-logging/index.mjs +97 -29
- package/dist/builtins/loki-logging/loki-logging.addon.d.ts +7 -23
- package/dist/builtins/loki-logging/retire-enabled-flag.d.ts +23 -0
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +2 -2
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +2 -2
- package/dist/builtins/system-backup/backup-encryption.d.ts +57 -0
- package/dist/builtins/system-backup/system-backup.service.d.ts +16 -1
- package/dist/builtins/system-config/cluster-secret-keeper.d.ts +7 -0
- package/dist/builtins/system-config/system-config.addon.d.ts +13 -3
- package/dist/builtins/system-config/system-config.addon.js +324 -38
- package/dist/builtins/system-config/system-config.addon.mjs +312 -39
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{child-cap-dispatch-hf-Gce_4.js → child-cap-dispatch-BrMdKHfp.js} +6 -3
- package/dist/{child-cap-dispatch-BTe4eKep.mjs → child-cap-dispatch-WsYPNvnK.mjs} +10 -7
- package/dist/{cluster-secret-BUiG8DCo.js → cluster-secret-BEPLh3LL.js} +1 -1
- package/dist/{cluster-secret-DgJTZ4qY.mjs → cluster-secret-DhPdZ0VA.mjs} +1 -1
- package/dist/{composition-sources-4SmsP0O4.js → composition-sources-C6ZynCZ2.js} +1 -1
- package/dist/{composition-sources-CgOQVIwa.mjs → composition-sources-XqRX51db.mjs} +1 -1
- package/dist/{dist-BC0GnGeg.mjs → dist-CP5TTjwd.mjs} +1710 -158
- package/dist/{dist-BugrtARh.js → dist-tnAO12ak.js} +1721 -157
- package/dist/{event-loop-stall-monitor-CnQ-K6Gn.js → event-loop-stall-monitor-9kR0sAPS.js} +15 -15
- package/dist/{event-loop-stall-monitor-GgpnfAC0.mjs → event-loop-stall-monitor-WkssIXjq.mjs} +15 -15
- package/dist/{hub-cap-forward-CH6_VWVO.mjs → hub-cap-forward-CDiN1p98.mjs} +18 -2
- package/dist/{hub-cap-forward-COCmYaup.js → hub-cap-forward-W8oEm4ME.js} +23 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.js +3341 -79
- package/dist/index.mjs +3567 -354
- package/dist/{jwt-signing-key-CsuVKA1W.mjs → jwt-signing-key-CAbozwvF.mjs} +1 -1
- package/dist/{jwt-signing-key-CjoZcol8.js → jwt-signing-key-Dgf8sIXY.js} +1 -1
- package/dist/kernel/index.d.ts +12 -0
- package/dist/kernel/moleculer/broker-factory.d.ts +8 -1
- package/dist/kernel/moleculer/child-addon-call-dispatch.d.ts +1 -0
- package/dist/kernel/moleculer/hub-service.d.ts +9 -0
- package/dist/kernel/moleculer/mesh-access-middleware.d.ts +28 -0
- package/dist/kernel/moleculer/mesh-auth-mode.d.ts +64 -0
- package/dist/kernel/moleculer/mesh-authenticator.d.ts +71 -0
- package/dist/kernel/moleculer/mesh-auto-enforce.d.ts +26 -0
- package/dist/kernel/moleculer/mesh-join-client.d.ts +51 -0
- package/dist/kernel/moleculer/mesh-join-proof.d.ts +39 -0
- package/dist/kernel/system-settings-policy.d.ts +4 -3
- package/dist/kernel/transport/hub-cap-forward.d.ts +14 -3
- package/dist/kernel/transport/index.d.ts +1 -1
- package/dist/restore-archive-BV39nY46.mjs +4045 -0
- package/dist/restore-archive-CbvrfPuB.js +4139 -0
- package/dist/{retired-settings-keys-DCuetwOt.js → retired-settings-keys-4bHpL-0m.js} +1 -1
- package/dist/{retired-settings-keys-MCVhxPkJ.mjs → retired-settings-keys-CzpIkwS-.mjs} +1 -1
- package/dist/{tls-CJHogwTl.mjs → tls-4cRH24Id.mjs} +3 -3
- package/package.json +1 -1
- package/dist/{tls-CCMX7MjX.js → tls-CljF057D.js} +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { r as SYSTEM_SECRET_OWNER, t as JWT_SIGNING_KEY_REF } from "../../jwt-signing-key-
|
|
3
|
-
import { i as CLUSTER_SECRET_SETTINGS_KEY } from "../../cluster-secret-
|
|
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-
|
|
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
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
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
|
-
|
|
257
|
-
var
|
|
258
|
-
|
|
259
|
-
var
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
450
|
-
const
|
|
451
|
-
|
|
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
|
-
|
|
454
|
-
|
|
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.
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
|
898
|
-
const out =
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
|
984
|
-
|
|
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
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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
|
|
1024
|
-
|
|
1421
|
+
async deleteKey(wrapId) {
|
|
1422
|
+
await this.door.delete({
|
|
1423
|
+
collection: KEYCHAIN_KEYS_COLLECTION,
|
|
1424
|
+
key: wrapId
|
|
1425
|
+
});
|
|
1025
1426
|
}
|
|
1026
|
-
async
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
|
1039
|
-
const rows =
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1070
|
-
|
|
1071
|
-
const
|
|
1072
|
-
|
|
1073
|
-
|
|
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
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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
|
-
/**
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
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.
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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
|
-
/**
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
-
/**
|
|
1154
|
-
async
|
|
1155
|
-
const
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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
|
-
|
|
1184
|
-
this.
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
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
|
|
1355
|
-
* that change or hand out material take the session's user
|
|
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
|
}
|