@camstack/system 1.2.376 → 1.2.378

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 (92) hide show
  1. package/dist/addon-runner.js +9 -4
  2. package/dist/addon-runner.mjs +10 -5
  3. package/dist/addon-utils.js +1 -1
  4. package/dist/addon-utils.mjs +1 -1
  5. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  9. package/dist/builtins/alerts/alerts.addon.js +1 -1
  10. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  11. package/dist/builtins/autotrack/index.js +1 -1
  12. package/dist/builtins/autotrack/index.mjs +1 -1
  13. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  14. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  15. package/dist/builtins/calendars/calendars.addon.js +1 -1
  16. package/dist/builtins/calendars/calendars.addon.mjs +1 -1
  17. package/dist/builtins/camera-grid/index.js +1 -1
  18. package/dist/builtins/camera-grid/index.mjs +1 -1
  19. package/dist/builtins/composer/composer.addon.js +2 -2
  20. package/dist/builtins/composer/composer.addon.mjs +2 -2
  21. package/dist/builtins/console-logging/index.js +1 -1
  22. package/dist/builtins/console-logging/index.mjs +1 -1
  23. package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
  24. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  25. package/dist/builtins/device-manager/device-config-secrets.d.ts +21 -0
  26. package/dist/builtins/device-manager/device-manager.addon.js +85 -6
  27. package/dist/builtins/device-manager/device-manager.addon.mjs +85 -6
  28. package/dist/builtins/hub-forwarder/index.js +1 -1
  29. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  30. package/dist/builtins/keychain/index.d.ts +1 -0
  31. package/dist/builtins/keychain/index.js +1442 -0
  32. package/dist/builtins/keychain/index.mjs +1435 -0
  33. package/dist/builtins/keychain/key-sources.d.ts +37 -0
  34. package/dist/builtins/keychain/keychain-crypto.d.ts +28 -0
  35. package/dist/builtins/keychain/keychain-service.d.ts +103 -0
  36. package/dist/builtins/keychain/keychain-store.d.ts +82 -0
  37. package/dist/builtins/keychain/keychain-usage.d.ts +6 -0
  38. package/dist/builtins/keychain/keychain.addon.d.ts +11 -0
  39. package/dist/builtins/keychain/platform-key-source.d.ts +24 -0
  40. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  41. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  42. package/dist/builtins/local-auth/local-auth.addon.js +19 -2
  43. package/dist/builtins/local-auth/local-auth.addon.mjs +19 -2
  44. package/dist/builtins/local-network/local-network.addon.js +1 -1
  45. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  46. package/dist/builtins/loki-logging/index.js +1 -1
  47. package/dist/builtins/loki-logging/index.mjs +1 -1
  48. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  49. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  50. package/dist/builtins/platform-probe/index.js +1 -1
  51. package/dist/builtins/platform-probe/index.mjs +1 -1
  52. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  53. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  54. package/dist/builtins/snapshot/index.js +1 -1
  55. package/dist/builtins/snapshot/index.mjs +1 -1
  56. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  57. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  58. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  59. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  60. package/dist/builtins/storage-orchestrator/location-store.d.ts +6 -0
  61. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.d.ts +15 -0
  62. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +74 -14
  63. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +74 -14
  64. package/dist/builtins/system-config/system-config.addon.js +1 -1
  65. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  66. package/dist/builtins/winston-logging/index.js +1 -1
  67. package/dist/builtins/winston-logging/index.mjs +1 -1
  68. package/dist/{child-cap-dispatch-BWtaSmWf.js → child-cap-dispatch-C9lmrWFT.js} +198 -25
  69. package/dist/{child-cap-dispatch-8qMgog1O.mjs → child-cap-dispatch-DpHovtQd.mjs} +170 -27
  70. package/dist/{composition-sources-zxQPPtP_.mjs → composition-sources-BLBd338N.mjs} +1 -1
  71. package/dist/{composition-sources-DW7CgMsg.js → composition-sources-CxhGdtdh.js} +1 -1
  72. package/dist/{dist-D732NygW.mjs → dist-Bvl5JgNt.mjs} +2336 -105
  73. package/dist/{dist-BcUWQGr9.js → dist-KxsOMQik.js} +2401 -104
  74. package/dist/{event-loop-stall-monitor-QX4RjNe7.js → event-loop-stall-monitor-CbSmCWkp.js} +8 -7
  75. package/dist/{event-loop-stall-monitor-8W4yWNVW.mjs → event-loop-stall-monitor-D8rqSWfW.mjs} +8 -7
  76. package/dist/{hub-cap-forward-BdT320dq.mjs → hub-cap-forward-CH6_VWVO.mjs} +13 -4
  77. package/dist/{hub-cap-forward-DmHNjbB0.js → hub-cap-forward-COCmYaup.js} +13 -4
  78. package/dist/index.d.ts +2 -0
  79. package/dist/index.js +20 -6
  80. package/dist/index.mjs +13 -7
  81. package/dist/kernel/config-schema.d.ts +4 -0
  82. package/dist/kernel/moleculer/child-addon-call-dispatch.d.ts +6 -0
  83. package/dist/kernel/transport/cap-route-resolver.d.ts +19 -0
  84. package/dist/kernel/transport/hub-cap-forward.d.ts +22 -1
  85. package/dist/kernel/transport/index.d.ts +2 -2
  86. package/dist/kernel/transport/local-child-registry.d.ts +4 -3
  87. package/dist/logging/log-redactor.d.ts +9 -0
  88. package/dist/platform-key-source-BWlq0jjn.mjs +23 -0
  89. package/dist/platform-key-source-Di7HrlGR.js +40 -0
  90. package/dist/{retired-settings-keys-CxPOg7BF.mjs → retired-settings-keys-BAcfvFtm.mjs} +1 -1
  91. package/dist/{retired-settings-keys-MaESpO18.js → retired-settings-keys-gTsZfpmP.js} +1 -1
  92. package/package.json +17 -1
@@ -0,0 +1,1435 @@
1
+ import { Er as formatSecretScope, Fr as parseSecretRef, Gr as EventCategory, Gt as keychainCapability, Mr as normalizeSecretOwner, Wt as keychainAdminCapability, lr as BaseAddon } from "../../dist-Bvl5JgNt.mjs";
2
+ import { n as getPlatformKeychainKey } from "../../platform-key-source-BWlq0jjn.mjs";
3
+ import { createCipheriv, createDecipheriv, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
4
+ import * as path$1 from "node:path";
5
+ import { promises } from "node:fs";
6
+ var IV_BYTES = 12;
7
+ var TAG_BYTES = 16;
8
+ var VALUE_PREFIX = "ck1";
9
+ var WRAP_PREFIX = "kw1";
10
+ var FINGERPRINT_LABEL = "camstack-keychain-v1";
11
+ /** 32 fresh random bytes — a master or a data key. */
12
+ function generateKey() {
13
+ return randomBytes(32);
14
+ }
15
+ /** A short random id for a data key (no dots — it is a segment of `ck1`). */
16
+ function generateKeyId() {
17
+ return randomBytes(8).toString("hex");
18
+ }
19
+ /** The full fingerprint of a master key. */
20
+ function kekFingerprint(kek) {
21
+ return createHmac("sha256", kek).update(FINGERPRINT_LABEL).digest("hex");
22
+ }
23
+ /** What the operator sees: the first 16 hex chars. */
24
+ function shortFingerprint(fingerprint) {
25
+ return fingerprint.slice(0, 16);
26
+ }
27
+ /** Constant-time string equality (lengths may differ). */
28
+ function constantTimeEquals(a, b) {
29
+ const ab = Buffer.from(a, "utf8");
30
+ const bb = Buffer.from(b, "utf8");
31
+ if (ab.length !== bb.length) return false;
32
+ return timingSafeEqual(ab, bb);
33
+ }
34
+ /**
35
+ * Key material from a key file: base64 (the format we write) or hex of
36
+ * exactly 32 bytes, surrounding whitespace ignored. Anything else is `null` —
37
+ * a file holding a password or a PEM is refused, never stretched into a key.
38
+ */
39
+ function decodeKeyMaterial(text) {
40
+ const trimmed = text.trim();
41
+ if (/^[0-9a-fA-F]{64}$/.test(trimmed)) return Buffer.from(trimmed, "hex");
42
+ if (!/^[A-Za-z0-9+/=_-]+$/.test(trimmed)) return null;
43
+ const buf = Buffer.from(trimmed, "base64");
44
+ return buf.length === 32 ? buf : null;
45
+ }
46
+ function encodeKeyMaterial(key) {
47
+ return key.toString("base64");
48
+ }
49
+ function seal(key, plaintext, aad) {
50
+ const iv = randomBytes(IV_BYTES);
51
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
52
+ cipher.setAAD(Buffer.from(aad, "utf8"));
53
+ const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
54
+ const tag = cipher.getAuthTag();
55
+ return [
56
+ iv.toString("base64"),
57
+ tag.toString("base64"),
58
+ ct.toString("base64")
59
+ ];
60
+ }
61
+ function open(key, ivB64, tagB64, ctB64, aad) {
62
+ try {
63
+ const iv = Buffer.from(ivB64, "base64");
64
+ const tag = Buffer.from(tagB64, "base64");
65
+ if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) return null;
66
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
67
+ decipher.setAAD(Buffer.from(aad, "utf8"));
68
+ decipher.setAuthTag(tag);
69
+ return Buffer.concat([decipher.update(Buffer.from(ctB64, "base64")), decipher.final()]);
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+ function dekAad(keyId) {
75
+ return `camstack-keychain-dek:${keyId}`;
76
+ }
77
+ /** Wrap a data key under a master key. */
78
+ function wrapDek(kek, dek, keyId) {
79
+ const [iv, tag, ct] = seal(kek, dek, dekAad(keyId));
80
+ return `${WRAP_PREFIX}.${iv}.${tag}.${ct}`;
81
+ }
82
+ /** Unwrap; `null` when the master key is not the one that wrapped it. */
83
+ function unwrapDek(kek, wrapped, keyId) {
84
+ const parts = wrapped.split(".");
85
+ if (parts.length !== 4 || parts[0] !== WRAP_PREFIX) return null;
86
+ const [, iv, tag, ct] = parts;
87
+ if (iv === void 0 || tag === void 0 || ct === void 0) return null;
88
+ const dek = open(kek, iv, tag, ct, dekAad(keyId));
89
+ return dek !== null && dek.length === 32 ? dek : null;
90
+ }
91
+ /** Encrypt one value for one ref. */
92
+ function encryptValue(dek, keyId, ref, value) {
93
+ const [iv, tag, ct] = seal(dek, Buffer.from(value, "utf8"), ref);
94
+ return `${VALUE_PREFIX}.${keyId}.${iv}.${tag}.${ct}`;
95
+ }
96
+ /** Decrypt; `null` on any failure — wrong key, wrong ref, tampered bytes. */
97
+ function decryptValue(dek, ref, envelope) {
98
+ const parts = envelope.split(".");
99
+ if (parts.length !== 5 || parts[0] !== VALUE_PREFIX) return null;
100
+ const [, , iv, tag, ct] = parts;
101
+ if (iv === void 0 || tag === void 0 || ct === void 0) return null;
102
+ const plain = open(dek, iv, tag, ct, ref);
103
+ return plain === null ? null : plain.toString("utf8");
104
+ }
105
+ //#endregion
106
+ //#region src/builtins/keychain/key-sources.ts
107
+ /**
108
+ * Where the master key comes from (D788). Configuration only — NO env var
109
+ * carries a key or a key path:
110
+ *
111
+ * 1. `keychain.keyFile` in `config.yaml` — a file outside the data volume
112
+ * (a read-only bind mount, a Docker secret). Recommended: a whole-volume
113
+ * backup then holds the ciphertext but not the key.
114
+ * 2. The desktop app's OS keychain (`safeStorage`), handed to the hub over an
115
+ * inherited pipe. Primary on a desktop hub with no key file configured.
116
+ * 3. `<dataDir>/.keychain/master.key` (0600, directory 0700), generated on
117
+ * first boot. The fallback: it protects CamStack's own backups and stray
118
+ * database copies (it is NOT in `DEFAULT_BACKUP_LOCATIONS`), but a tar of
119
+ * the whole data directory carries key and database together, and the
120
+ * Secrets page says so.
121
+ *
122
+ * `keychain.previousKeyFile` is consulted only to UNLOCK — so a move from one
123
+ * key to another is a restart plus `rotateMasterKey`, with no window where
124
+ * neither key works.
125
+ */
126
+ /** The default key file's location under the data root. */
127
+ function defaultKeyFilePath(dataDir) {
128
+ return path$1.join(dataDir, ".keychain", "master.key");
129
+ }
130
+ async function loadKeyFile(file) {
131
+ let text;
132
+ try {
133
+ text = await promises.readFile(file, "utf8");
134
+ } catch (err) {
135
+ if ((err !== null && typeof err === "object" ? Reflect.get(err, "code") : void 0) === "ENOENT") return { kind: "absent" };
136
+ return {
137
+ kind: "unreadable",
138
+ error: err instanceof Error ? err.message : String(err)
139
+ };
140
+ }
141
+ const key = decodeKeyMaterial(text);
142
+ return key === null ? {
143
+ kind: "unreadable",
144
+ error: "not 32 bytes of base64 or hex key material"
145
+ } : {
146
+ kind: "loaded",
147
+ key
148
+ };
149
+ }
150
+ function fileSource(kind, file, sharesBackupWithDatabase) {
151
+ return {
152
+ kind,
153
+ path: file,
154
+ sharesBackupWithDatabase
155
+ };
156
+ }
157
+ /** Every candidate, primary first. */
158
+ function buildKeySourceCandidates(opts) {
159
+ const out = [];
160
+ const defaultFile = defaultKeyFilePath(opts.dataDir);
161
+ const platform = getPlatformKeychainKey();
162
+ const keyFile = opts.config.keyFile?.trim();
163
+ const previousKeyFile = opts.config.previousKeyFile?.trim();
164
+ const primaryKind = keyFile !== void 0 && keyFile.length > 0 ? "config-file" : platform !== null ? "platform" : "default-file";
165
+ if (keyFile !== void 0 && keyFile.length > 0) out.push({
166
+ source: fileSource("config-file", keyFile, false),
167
+ primary: true,
168
+ load: () => loadKeyFile(keyFile)
169
+ });
170
+ if (previousKeyFile !== void 0 && previousKeyFile.length > 0) out.push({
171
+ source: fileSource("previous-config-file", previousKeyFile, false),
172
+ primary: false,
173
+ load: () => loadKeyFile(previousKeyFile)
174
+ });
175
+ if (platform !== null) out.push({
176
+ source: {
177
+ kind: "platform",
178
+ sharesBackupWithDatabase: false
179
+ },
180
+ primary: primaryKind === "platform",
181
+ load: async () => ({
182
+ kind: "loaded",
183
+ key: platform.key
184
+ })
185
+ });
186
+ out.push({
187
+ source: fileSource("default-file", defaultFile, true),
188
+ primary: primaryKind === "default-file",
189
+ load: () => loadKeyFile(defaultFile)
190
+ });
191
+ return out;
192
+ }
193
+ /**
194
+ * Write a master key to the default location: directory 0700, file 0600,
195
+ * atomically (temp + rename), never over an existing file unless `replace`.
196
+ * Returns the previous file's new name when one was moved aside.
197
+ */
198
+ async function writeDefaultKeyFile(dataDir, key, opts) {
199
+ const file = defaultKeyFilePath(dataDir);
200
+ try {
201
+ return await writeKeyFileAt(file, key, opts);
202
+ } catch (err) {
203
+ throw new Error(`could not write the master key at ${file}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
204
+ }
205
+ }
206
+ async function writeKeyFileAt(file, key, opts) {
207
+ const dir = path$1.dirname(file);
208
+ await promises.mkdir(dir, {
209
+ recursive: true,
210
+ mode: 448
211
+ });
212
+ await promises.chmod(dir, 448);
213
+ let movedAside = null;
214
+ try {
215
+ await promises.access(file);
216
+ if (!opts.replace) throw new Error(`refusing to overwrite the existing master key at ${file}`);
217
+ movedAside = `${file}.prev-${opts.nowMs}`;
218
+ await promises.copyFile(file, movedAside);
219
+ await promises.chmod(movedAside, 384);
220
+ } catch (err) {
221
+ if ((err !== null && typeof err === "object" ? Reflect.get(err, "code") : void 0) !== "ENOENT") throw err;
222
+ }
223
+ const tmp = `${file}.tmp-${process.pid}`;
224
+ await promises.writeFile(tmp, `${encodeKeyMaterial(key)}\n`, { mode: 384 });
225
+ await promises.rename(tmp, file);
226
+ await promises.chmod(file, 384);
227
+ const back = await loadKeyFile(file);
228
+ if (back.kind !== "loaded" || !back.key.equals(key)) throw new Error("the written key file does not read back as the generated key");
229
+ return movedAside;
230
+ }
231
+ //#endregion
232
+ //#region src/builtins/keychain/keychain-store.ts
233
+ /**
234
+ * @durable class=config owner=keychain
235
+ * write="`keychain.put` upserts one row per ref; `delete` removes it"
236
+ * retention="none — a row lives until its owner deletes the ref or the
237
+ * operator deletes it as an orphan. Losing the table loses every secret
238
+ * value; the settings blobs then point at refs that resolve `missing`."
239
+ */
240
+ var KEYCHAIN_SECRETS_COLLECTION = "keychain:secrets";
241
+ /**
242
+ * @durable class=config owner=keychain
243
+ * write="first boot writes the wrapped data key; `rotateMasterKey` adds a
244
+ * wrap and retires the previous one"
245
+ * retention="retired wraps until `purgeRetiredKeys`. Losing the table with
246
+ * the secrets intact makes every value undecryptable."
247
+ */
248
+ var KEYCHAIN_KEYS_COLLECTION = "keychain:keys";
249
+ /**
250
+ * @durable class=ring owner=keychain
251
+ * write="one row per write / delete / rotation / reveal / recovery download"
252
+ * retention="90 days and at most 20 000 rows, pruned at boot and daily"
253
+ */
254
+ var KEYCHAIN_AUDIT_COLLECTION = "keychain:audit";
255
+ var AUDIT_RETENTION_MS = 2160 * 60 * 6e4;
256
+ var AUDIT_MAX_ROWS = 2e4;
257
+ var SECRET_COLUMNS = [
258
+ {
259
+ name: "ref",
260
+ type: "TEXT",
261
+ primaryKey: true,
262
+ notNull: true
263
+ },
264
+ {
265
+ name: "owner",
266
+ type: "TEXT",
267
+ notNull: true
268
+ },
269
+ {
270
+ name: "scope",
271
+ type: "TEXT",
272
+ notNull: true
273
+ },
274
+ {
275
+ name: "fieldPath",
276
+ type: "TEXT",
277
+ notNull: true
278
+ },
279
+ {
280
+ name: "ciphertext",
281
+ type: "TEXT",
282
+ notNull: true
283
+ },
284
+ {
285
+ name: "keyId",
286
+ type: "TEXT",
287
+ notNull: true
288
+ },
289
+ {
290
+ name: "version",
291
+ type: "INTEGER",
292
+ notNull: true
293
+ },
294
+ {
295
+ name: "createdAt",
296
+ type: "INTEGER",
297
+ notNull: true
298
+ },
299
+ {
300
+ name: "updatedAt",
301
+ type: "INTEGER",
302
+ notNull: true
303
+ },
304
+ {
305
+ name: "updatedBy",
306
+ type: "TEXT",
307
+ notNull: true
308
+ },
309
+ {
310
+ name: "lastReadAt",
311
+ type: "INTEGER"
312
+ }
313
+ ];
314
+ var KEY_COLUMNS = [
315
+ {
316
+ name: "wrapId",
317
+ type: "TEXT",
318
+ primaryKey: true,
319
+ notNull: true
320
+ },
321
+ {
322
+ name: "keyId",
323
+ type: "TEXT",
324
+ notNull: true
325
+ },
326
+ {
327
+ name: "wrappedDek",
328
+ type: "TEXT",
329
+ notNull: true
330
+ },
331
+ {
332
+ name: "kekFingerprint",
333
+ type: "TEXT",
334
+ notNull: true
335
+ },
336
+ {
337
+ name: "kekSource",
338
+ type: "TEXT",
339
+ notNull: true
340
+ },
341
+ {
342
+ name: "createdAt",
343
+ type: "INTEGER",
344
+ notNull: true
345
+ },
346
+ {
347
+ name: "state",
348
+ type: "TEXT",
349
+ notNull: true
350
+ },
351
+ {
352
+ name: "recoveryDownloadedAt",
353
+ type: "INTEGER"
354
+ }
355
+ ];
356
+ var AUDIT_COLUMNS = [
357
+ {
358
+ name: "id",
359
+ type: "TEXT",
360
+ primaryKey: true,
361
+ notNull: true
362
+ },
363
+ {
364
+ name: "at",
365
+ type: "INTEGER",
366
+ notNull: true
367
+ },
368
+ {
369
+ name: "action",
370
+ type: "TEXT",
371
+ notNull: true
372
+ },
373
+ {
374
+ name: "actor",
375
+ type: "TEXT",
376
+ notNull: true
377
+ },
378
+ {
379
+ name: "ref",
380
+ type: "TEXT"
381
+ },
382
+ {
383
+ name: "detail",
384
+ type: "TEXT"
385
+ }
386
+ ];
387
+ function str(data, key) {
388
+ const v = data[key];
389
+ return typeof v === "string" ? v : "";
390
+ }
391
+ function num(data, key) {
392
+ const v = data[key];
393
+ return typeof v === "number" ? v : Number(v ?? 0);
394
+ }
395
+ function numOrNull(data, key) {
396
+ const v = data[key];
397
+ if (v === null || v === void 0) return null;
398
+ const n = typeof v === "number" ? v : Number(v);
399
+ return Number.isFinite(n) ? n : null;
400
+ }
401
+ function strOrNull(data, key) {
402
+ const v = data[key];
403
+ return typeof v === "string" ? v : null;
404
+ }
405
+ function toSecretRow(id, data) {
406
+ return {
407
+ ref: str(data, "ref") || id,
408
+ owner: str(data, "owner"),
409
+ scope: str(data, "scope"),
410
+ fieldPath: str(data, "fieldPath"),
411
+ ciphertext: str(data, "ciphertext"),
412
+ keyId: str(data, "keyId"),
413
+ version: num(data, "version"),
414
+ createdAt: num(data, "createdAt"),
415
+ updatedAt: num(data, "updatedAt"),
416
+ updatedBy: str(data, "updatedBy"),
417
+ lastReadAt: numOrNull(data, "lastReadAt")
418
+ };
419
+ }
420
+ function toKeyRow(id, data) {
421
+ const state = str(data, "state");
422
+ return {
423
+ wrapId: str(data, "wrapId") || id,
424
+ keyId: str(data, "keyId"),
425
+ wrappedDek: str(data, "wrappedDek"),
426
+ kekFingerprint: str(data, "kekFingerprint"),
427
+ kekSource: str(data, "kekSource"),
428
+ createdAt: num(data, "createdAt"),
429
+ state: state === "retired" ? "retired" : "active",
430
+ recoveryDownloadedAt: numOrNull(data, "recoveryDownloadedAt")
431
+ };
432
+ }
433
+ var AUDIT_ACTIONS = new Set([
434
+ "put",
435
+ "delete",
436
+ "reveal",
437
+ "rotate-master-key",
438
+ "purge-retired-keys",
439
+ "download-recovery-key",
440
+ "delete-orphans",
441
+ "reset",
442
+ "initialize"
443
+ ]);
444
+ function isAuditAction(value) {
445
+ return AUDIT_ACTIONS.has(value);
446
+ }
447
+ function toAuditRow(id, data) {
448
+ const action = str(data, "action");
449
+ if (!isAuditAction(action)) return null;
450
+ return {
451
+ id: str(data, "id") || id,
452
+ at: num(data, "at"),
453
+ action,
454
+ actor: str(data, "actor"),
455
+ ref: strOrNull(data, "ref"),
456
+ detail: strOrNull(data, "detail")
457
+ };
458
+ }
459
+ /** Rows one listing page asks for — the table is small, but a read names its bound. */
460
+ var PAGE = 1e3;
461
+ var KeychainRepository = class {
462
+ door;
463
+ constructor(door) {
464
+ this.door = door;
465
+ }
466
+ async init() {
467
+ await this.door.declareCollection({
468
+ collection: KEYCHAIN_SECRETS_COLLECTION,
469
+ columns: SECRET_COLUMNS,
470
+ indexes: [{
471
+ name: "idx_keychain_secrets_owner",
472
+ columns: ["owner"]
473
+ }]
474
+ });
475
+ await this.door.declareCollection({
476
+ collection: KEYCHAIN_KEYS_COLLECTION,
477
+ columns: KEY_COLUMNS
478
+ });
479
+ await this.door.declareCollection({
480
+ collection: KEYCHAIN_AUDIT_COLLECTION,
481
+ columns: AUDIT_COLUMNS,
482
+ indexes: [{
483
+ name: "idx_keychain_audit_at",
484
+ columns: ["at"]
485
+ }]
486
+ });
487
+ }
488
+ async getSecrets(refs) {
489
+ const out = /* @__PURE__ */ new Map();
490
+ if (refs.length === 0) return out;
491
+ const rows = await this.door.query({
492
+ collection: KEYCHAIN_SECRETS_COLLECTION,
493
+ filter: {
494
+ whereIn: { ref: [...refs] },
495
+ limit: refs.length
496
+ }
497
+ });
498
+ for (const row of rows) {
499
+ const parsed = toSecretRow(row.id, row.data);
500
+ out.set(parsed.ref, parsed);
501
+ }
502
+ return out;
503
+ }
504
+ /** Every row, paged by primary key. */
505
+ async listSecrets() {
506
+ const out = [];
507
+ for (let offset = 0;; offset += PAGE) {
508
+ const rows = await this.door.query({
509
+ collection: KEYCHAIN_SECRETS_COLLECTION,
510
+ filter: {
511
+ orderBy: {
512
+ field: "ref",
513
+ direction: "asc"
514
+ },
515
+ limit: PAGE,
516
+ offset
517
+ }
518
+ });
519
+ for (const row of rows) out.push(toSecretRow(row.id, row.data));
520
+ if (rows.length < PAGE) return out;
521
+ }
522
+ }
523
+ async putSecret(row) {
524
+ await this.door.set({
525
+ collection: KEYCHAIN_SECRETS_COLLECTION,
526
+ key: row.ref,
527
+ value: row
528
+ });
529
+ }
530
+ async deleteSecrets(refs) {
531
+ if (refs.length === 0) return 0;
532
+ return (await this.door.deleteWhere({
533
+ collection: KEYCHAIN_SECRETS_COLLECTION,
534
+ filter: { whereIn: { ref: [...refs] } }
535
+ })).deleted;
536
+ }
537
+ async listKeys() {
538
+ return (await this.door.query({
539
+ collection: KEYCHAIN_KEYS_COLLECTION,
540
+ filter: {
541
+ orderBy: {
542
+ field: "createdAt",
543
+ direction: "asc"
544
+ },
545
+ limit: PAGE
546
+ }
547
+ })).map((row) => toKeyRow(row.id, row.data));
548
+ }
549
+ async putKey(row) {
550
+ await this.door.set({
551
+ collection: KEYCHAIN_KEYS_COLLECTION,
552
+ key: row.wrapId,
553
+ value: row
554
+ });
555
+ }
556
+ async deleteKey(wrapId) {
557
+ await this.door.delete({
558
+ collection: KEYCHAIN_KEYS_COLLECTION,
559
+ key: wrapId
560
+ });
561
+ }
562
+ async appendAudit(row) {
563
+ await this.door.set({
564
+ collection: KEYCHAIN_AUDIT_COLLECTION,
565
+ key: row.id,
566
+ value: row
567
+ });
568
+ }
569
+ async listAudit(limit) {
570
+ const rows = await this.door.query({
571
+ collection: KEYCHAIN_AUDIT_COLLECTION,
572
+ filter: {
573
+ orderBy: {
574
+ field: "at",
575
+ direction: "desc"
576
+ },
577
+ limit
578
+ }
579
+ });
580
+ const out = [];
581
+ for (const row of rows) {
582
+ const parsed = toAuditRow(row.id, row.data);
583
+ if (parsed !== null) out.push(parsed);
584
+ }
585
+ return out;
586
+ }
587
+ /** Drop audit rows older than the retention window, then beyond the row bound. */
588
+ async pruneAudit(nowMs) {
589
+ const aged = await this.door.deleteWhere({
590
+ collection: KEYCHAIN_AUDIT_COLLECTION,
591
+ filter: { whereBetween: { at: [0, nowMs - AUDIT_RETENTION_MS] } }
592
+ });
593
+ const cutoff = (await this.door.query({
594
+ collection: KEYCHAIN_AUDIT_COLLECTION,
595
+ filter: {
596
+ orderBy: {
597
+ field: "at",
598
+ direction: "desc"
599
+ },
600
+ limit: 1,
601
+ offset: AUDIT_MAX_ROWS
602
+ }
603
+ }))[0]?.data["at"];
604
+ if (typeof cutoff !== "number") return aged.deleted;
605
+ const bounded = await this.door.deleteWhere({
606
+ collection: KEYCHAIN_AUDIT_COLLECTION,
607
+ filter: { whereBetween: { at: [0, cutoff] } }
608
+ });
609
+ return aged.deleted + bounded.deleted;
610
+ }
611
+ /**
612
+ * Lost-key recovery: copy every secret row into `archive`, then empty the
613
+ * live table. The archive is ciphertext under a key nobody has — it exists
614
+ * so a key that turns up later can still recover it.
615
+ */
616
+ async archiveAllSecrets(archive) {
617
+ const rows = await this.listSecrets();
618
+ if (rows.length === 0) return 0;
619
+ await this.door.declareCollection({
620
+ collection: archive,
621
+ columns: SECRET_COLUMNS
622
+ });
623
+ for (const row of rows) await this.door.set({
624
+ collection: archive,
625
+ key: row.ref,
626
+ value: row
627
+ });
628
+ await this.deleteSecrets(rows.map((r) => r.ref));
629
+ return rows.length;
630
+ }
631
+ };
632
+ var KeychainLockedError = class extends Error {
633
+ reason;
634
+ constructor(reason) {
635
+ super(`keychain is locked (${reason}) — no value can be stored or revealed`);
636
+ this.reason = reason;
637
+ this.name = "KeychainLockedError";
638
+ }
639
+ };
640
+ var KeychainForbiddenError = class extends Error {
641
+ ref;
642
+ constructor(ref, caller) {
643
+ super(`keychain: ${caller === null ? "an unidentified caller" : `"${caller}"`} does not own ${ref}`);
644
+ this.ref = ref;
645
+ this.name = "KeychainForbiddenError";
646
+ }
647
+ };
648
+ var NOT_INITIALIZED = {
649
+ state: "locked",
650
+ reason: "not-initialized"
651
+ };
652
+ var KeychainService = class {
653
+ deps;
654
+ unlock = NOT_INITIALIZED;
655
+ configuredSource = null;
656
+ now;
657
+ constructor(deps) {
658
+ this.deps = deps;
659
+ this.now = deps.now ?? Date.now;
660
+ }
661
+ /** Load the keys and unlock. Never throws for a key problem — it locks. */
662
+ async init() {
663
+ try {
664
+ await this.deps.repo.init();
665
+ } catch (err) {
666
+ this.unlock = {
667
+ state: "locked",
668
+ reason: "store-unavailable"
669
+ };
670
+ this.deps.logger.error("keychain tables could not be declared — locked", { error: errMsg(err) });
671
+ return this.getState();
672
+ }
673
+ this.unlock = await this.computeUnlock();
674
+ if (this.unlock.state === "ready") this.deps.logger.info("keychain unlocked", {
675
+ source: this.unlock.source.kind,
676
+ path: this.unlock.source.path,
677
+ fingerprint: shortFingerprint(this.unlock.fingerprint),
678
+ rotationPending: this.unlock.rotationPending
679
+ });
680
+ else this.deps.logger.error("keychain LOCKED — every secret resolves unavailable", {
681
+ reason: this.unlock.reason,
682
+ path: this.configuredSource?.path
683
+ });
684
+ try {
685
+ await this.deps.repo.pruneAudit(this.now());
686
+ } catch (err) {
687
+ this.deps.logger.warn("keychain audit prune failed", { error: errMsg(err) });
688
+ }
689
+ return this.getState();
690
+ }
691
+ async computeUnlock() {
692
+ const candidates = await this.deps.candidates();
693
+ const primary = candidates.find((c) => c.primary) ?? null;
694
+ this.configuredSource = primary?.source ?? null;
695
+ let wraps;
696
+ try {
697
+ wraps = await this.deps.repo.listKeys();
698
+ } catch {
699
+ return {
700
+ state: "locked",
701
+ reason: "store-unavailable"
702
+ };
703
+ }
704
+ if (wraps.length === 0) return this.firstBoot(primary);
705
+ let anyLoaded = false;
706
+ let primaryUnreadable = false;
707
+ for (const candidate of candidates) {
708
+ const loaded = await candidate.load();
709
+ if (loaded.kind === "unreadable") {
710
+ if (candidate.primary) primaryUnreadable = true;
711
+ this.deps.logger.warn("keychain key source unreadable", {
712
+ source: candidate.source.kind,
713
+ path: candidate.source.path,
714
+ error: loaded.error
715
+ });
716
+ continue;
717
+ }
718
+ if (loaded.kind !== "loaded") continue;
719
+ anyLoaded = true;
720
+ const fingerprint = kekFingerprint(loaded.key);
721
+ const ordered = [...wraps].toSorted((a, b) => a.state === b.state ? b.createdAt - a.createdAt : a.state === "active" ? -1 : 1);
722
+ for (const wrap of ordered) {
723
+ if (!constantTimeEquals(wrap.kekFingerprint, fingerprint)) continue;
724
+ const dek = unwrapDek(loaded.key, wrap.wrappedDek, wrap.keyId);
725
+ if (dek === null) continue;
726
+ return {
727
+ state: "ready",
728
+ dek,
729
+ keyId: wrap.keyId,
730
+ kek: loaded.key,
731
+ fingerprint,
732
+ source: candidate.source,
733
+ wrap,
734
+ rotationPending: !candidate.primary || wrap.state !== "active"
735
+ };
736
+ }
737
+ }
738
+ if (primaryUnreadable) return {
739
+ state: "locked",
740
+ reason: "key-file-unreadable"
741
+ };
742
+ return {
743
+ state: "locked",
744
+ reason: anyLoaded ? "wrong-key" : "key-missing"
745
+ };
746
+ }
747
+ /** No wrapped data key yet: create one — only when nothing could be lost. */
748
+ async firstBoot(primary) {
749
+ let existing;
750
+ try {
751
+ existing = await this.deps.repo.listSecrets();
752
+ } catch {
753
+ return {
754
+ state: "locked",
755
+ reason: "store-unavailable"
756
+ };
757
+ }
758
+ if (existing.length > 0) return {
759
+ state: "locked",
760
+ reason: "key-missing"
761
+ };
762
+ if (primary === null) return {
763
+ state: "locked",
764
+ reason: "key-missing"
765
+ };
766
+ const loaded = await primary.load();
767
+ let kek;
768
+ if (loaded.kind === "loaded") kek = loaded.key;
769
+ else if (loaded.kind === "unreadable") return {
770
+ state: "locked",
771
+ reason: "key-file-unreadable"
772
+ };
773
+ else if (primary.source.kind === "default-file") {
774
+ kek = generateKey();
775
+ try {
776
+ await writeDefaultKeyFile(this.deps.dataDir, kek, {
777
+ replace: false,
778
+ nowMs: this.now()
779
+ });
780
+ } catch (err) {
781
+ this.deps.logger.error("keychain master key could NOT be generated — no key stored", {
782
+ path: primary.source.path,
783
+ error: errMsg(err)
784
+ });
785
+ throw err;
786
+ }
787
+ this.deps.logger.info("keychain master key generated", { path: primary.source.path });
788
+ } else return {
789
+ state: "locked",
790
+ reason: "key-missing"
791
+ };
792
+ const dek = generateKey();
793
+ const keyId = generateKeyId();
794
+ const fingerprint = kekFingerprint(kek);
795
+ const wrap = {
796
+ wrapId: randomUUID(),
797
+ keyId,
798
+ wrappedDek: wrapDek(kek, dek, keyId),
799
+ kekFingerprint: fingerprint,
800
+ kekSource: primary.source.kind,
801
+ createdAt: this.now(),
802
+ state: "active",
803
+ recoveryDownloadedAt: null
804
+ };
805
+ await this.deps.repo.putKey(wrap);
806
+ await this.audit("initialize", "system", null, `source=${primary.source.kind}`);
807
+ return {
808
+ state: "ready",
809
+ dek,
810
+ keyId,
811
+ kek,
812
+ fingerprint,
813
+ source: primary.source,
814
+ wrap,
815
+ rotationPending: false
816
+ };
817
+ }
818
+ getState() {
819
+ return this.unlock.state === "ready" ? { state: "ready" } : {
820
+ state: "locked",
821
+ reason: this.unlock.reason
822
+ };
823
+ }
824
+ requireReady() {
825
+ if (this.unlock.state !== "ready") throw new KeychainLockedError(this.unlock.reason);
826
+ return this.unlock;
827
+ }
828
+ /** May `caller` touch values owned by `owner`? */
829
+ owns(caller, owner) {
830
+ if (caller === null) return false;
831
+ const c = normalizeSecretOwner(caller);
832
+ const o = normalizeSecretOwner(owner);
833
+ if (c.length === 0) return false;
834
+ if (c === o) return true;
835
+ return this.deps.runnerIdOf(o) === c;
836
+ }
837
+ ownerOf(ref, caller) {
838
+ const parts = parseSecretRef(ref);
839
+ if (parts === null || !this.owns(caller, parts.owner)) throw new KeychainForbiddenError(ref, caller);
840
+ return parts.owner;
841
+ }
842
+ async put(caller, ref, value) {
843
+ const owner = this.ownerOf(ref, caller);
844
+ const unlocked = this.requireReady();
845
+ if (value.length === 0) throw new Error("keychain.put: an empty value is not stored — delete the ref");
846
+ const parts = parseSecretRef(ref);
847
+ if (parts === null) throw new KeychainForbiddenError(ref, caller);
848
+ const existing = (await this.deps.repo.getSecrets([ref])).get(ref);
849
+ if (existing !== void 0) {
850
+ const current = decryptValue(unlocked.dek, ref, existing.ciphertext);
851
+ if (current !== null && constantTimeEquals(current, value)) return {
852
+ ref,
853
+ version: existing.version
854
+ };
855
+ }
856
+ const t = this.now();
857
+ const version = (existing?.version ?? 0) + 1;
858
+ await this.deps.repo.putSecret({
859
+ ref,
860
+ owner,
861
+ scope: formatSecretScope(parts.scope),
862
+ fieldPath: parts.fieldPath,
863
+ ciphertext: encryptValue(unlocked.dek, unlocked.keyId, ref, value),
864
+ keyId: unlocked.keyId,
865
+ version,
866
+ createdAt: existing?.createdAt ?? t,
867
+ updatedAt: t,
868
+ updatedBy: actorOfAddon(caller),
869
+ lastReadAt: existing?.lastReadAt ?? null
870
+ });
871
+ await this.audit("put", actorOfAddon(caller), ref, `version=${version}`);
872
+ this.deps.events.emit({
873
+ ref,
874
+ owner,
875
+ version,
876
+ change: "put"
877
+ });
878
+ return {
879
+ ref,
880
+ version
881
+ };
882
+ }
883
+ async resolve(caller, refs) {
884
+ const out = {};
885
+ const owned = [];
886
+ for (const ref of new Set(refs)) {
887
+ const parts = parseSecretRef(ref);
888
+ if (parts === null || !this.owns(caller, parts.owner)) {
889
+ out[ref] = { kind: "forbidden" };
890
+ continue;
891
+ }
892
+ owned.push(ref);
893
+ }
894
+ if (owned.length === 0) return out;
895
+ if (this.unlock.state !== "ready") {
896
+ for (const ref of owned) out[ref] = {
897
+ kind: "unavailable",
898
+ reason: this.unlock.reason
899
+ };
900
+ return out;
901
+ }
902
+ const unlocked = this.unlock;
903
+ let rows;
904
+ try {
905
+ rows = await this.deps.repo.getSecrets(owned);
906
+ } catch {
907
+ for (const ref of owned) out[ref] = {
908
+ kind: "unavailable",
909
+ reason: "store-unavailable"
910
+ };
911
+ return out;
912
+ }
913
+ const t = this.now();
914
+ const stale = [];
915
+ for (const ref of owned) {
916
+ const row = rows.get(ref);
917
+ if (row === void 0) {
918
+ out[ref] = { kind: "missing" };
919
+ continue;
920
+ }
921
+ const value = decryptValue(unlocked.dek, ref, row.ciphertext);
922
+ if (value === null) {
923
+ out[ref] = {
924
+ kind: "unavailable",
925
+ reason: "decrypt-failed"
926
+ };
927
+ this.deps.logger.error("keychain value did not authenticate", { ref });
928
+ continue;
929
+ }
930
+ out[ref] = {
931
+ kind: "value",
932
+ value,
933
+ version: row.version
934
+ };
935
+ if (row.lastReadAt === null || t - row.lastReadAt >= 36e5) stale.push(row);
936
+ }
937
+ if (stale.length > 0) this.touchLastRead(stale, t);
938
+ return out;
939
+ }
940
+ async touchLastRead(rows, at) {
941
+ try {
942
+ for (const row of rows) await this.deps.repo.putSecret({
943
+ ...row,
944
+ lastReadAt: at
945
+ });
946
+ } catch (err) {
947
+ this.deps.logger.warn("keychain lastReadAt not recorded", { error: errMsg(err) });
948
+ }
949
+ }
950
+ async delete(caller, refs) {
951
+ const owners = /* @__PURE__ */ new Map();
952
+ for (const ref of new Set(refs)) owners.set(ref, this.ownerOf(ref, caller));
953
+ if (owners.size === 0) return 0;
954
+ const existing = await this.deps.repo.getSecrets([...owners.keys()]);
955
+ const present = [...owners.keys()].filter((ref) => existing.has(ref));
956
+ if (present.length === 0) return 0;
957
+ const deleted = await this.deps.repo.deleteSecrets(present);
958
+ for (const ref of present) {
959
+ await this.audit("delete", actorOfAddon(caller), ref, null);
960
+ this.deps.events.emit({
961
+ ref,
962
+ owner: owners.get(ref) ?? "",
963
+ version: null,
964
+ change: "delete"
965
+ });
966
+ }
967
+ return deleted;
968
+ }
969
+ async listMeta(caller) {
970
+ return (await this.deps.repo.listSecrets()).filter((row) => this.owns(caller, row.owner)).map(toMeta);
971
+ }
972
+ async status() {
973
+ const wraps = await this.deps.repo.listKeys().catch(() => []);
974
+ const secrets = await this.deps.repo.listSecrets().catch(() => []);
975
+ const unlocked = this.unlock.state === "ready" ? this.unlock : null;
976
+ const configured = this.configuredSource ?? {
977
+ kind: "default-file",
978
+ path: defaultKeyFilePath(this.deps.dataDir),
979
+ sharesBackupWithDatabase: true
980
+ };
981
+ return {
982
+ keychain: this.getState(),
983
+ keySource: unlocked?.source ?? null,
984
+ configuredSource: configured,
985
+ fingerprint: unlocked !== null ? shortFingerprint(unlocked.fingerprint) : null,
986
+ activeKeyId: unlocked?.keyId ?? null,
987
+ rotationPending: unlocked?.rotationPending ?? false,
988
+ retiredWraps: wraps.filter((w) => w.state === "retired").length,
989
+ recoveryKeyDownloadedAt: unlocked !== null ? wraps.find((w) => w.wrapId === unlocked.wrap.wrapId)?.recoveryDownloadedAt ?? null : null,
990
+ secretCount: secrets.length
991
+ };
992
+ }
993
+ async listSecrets() {
994
+ const rows = await this.deps.repo.listSecrets();
995
+ const usage = await this.deps.usage(rows).catch(() => /* @__PURE__ */ new Map());
996
+ return rows.map((row) => ({
997
+ ...toMeta(row),
998
+ usage: usage.get(row.ref) ?? "unknown"
999
+ }));
1000
+ }
1001
+ async listAudit(limit) {
1002
+ return this.deps.repo.listAudit(limit);
1003
+ }
1004
+ async reveal(userId, ref) {
1005
+ const unlocked = this.requireReady();
1006
+ const row = (await this.deps.repo.getSecrets([ref])).get(ref);
1007
+ if (row === void 0) throw new Error(`keychain: no value stored at ${ref}`);
1008
+ const value = decryptValue(unlocked.dek, ref, row.ciphertext);
1009
+ if (value === null) throw new Error(`keychain: the value at ${ref} did not authenticate`);
1010
+ await this.audit("reveal", `user:${userId}`, ref, null);
1011
+ return {
1012
+ value,
1013
+ version: row.version
1014
+ };
1015
+ }
1016
+ async deleteOrphans(userId, refs) {
1017
+ const rows = [...(await this.deps.repo.getSecrets(refs)).values()];
1018
+ const usage = await this.deps.usage(rows);
1019
+ const deleted = [];
1020
+ const kept = [];
1021
+ for (const row of rows) {
1022
+ const u = usage.get(row.ref) ?? "unknown";
1023
+ if (u === "orphan") deleted.push(row.ref);
1024
+ else kept.push({
1025
+ ref: row.ref,
1026
+ usage: u
1027
+ });
1028
+ }
1029
+ if (deleted.length > 0) {
1030
+ await this.deps.repo.deleteSecrets(deleted);
1031
+ for (const ref of deleted) {
1032
+ const owner = parseSecretRef(ref)?.owner ?? "";
1033
+ this.deps.events.emit({
1034
+ ref,
1035
+ owner,
1036
+ version: null,
1037
+ change: "delete"
1038
+ });
1039
+ }
1040
+ await this.audit("delete-orphans", `user:${userId}`, null, `deleted=${deleted.length}`);
1041
+ }
1042
+ return {
1043
+ deleted,
1044
+ kept
1045
+ };
1046
+ }
1047
+ async downloadRecoveryKey(userId) {
1048
+ const unlocked = this.requireReady();
1049
+ const t = this.now();
1050
+ await this.deps.repo.putKey({
1051
+ ...unlocked.wrap,
1052
+ recoveryDownloadedAt: t
1053
+ });
1054
+ this.unlock = {
1055
+ ...unlocked,
1056
+ wrap: {
1057
+ ...unlocked.wrap,
1058
+ recoveryDownloadedAt: t
1059
+ }
1060
+ };
1061
+ await this.audit("download-recovery-key", `user:${userId}`, null, `source=${unlocked.source.kind}`);
1062
+ return {
1063
+ key: encodeKeyMaterial(unlocked.kek),
1064
+ fingerprint: shortFingerprint(unlocked.fingerprint),
1065
+ keySource: unlocked.source
1066
+ };
1067
+ }
1068
+ /** See `keychain-admin.rotateMasterKey`. */
1069
+ async rotateMasterKey(userId) {
1070
+ const unlocked = this.requireReady();
1071
+ const primary = (await this.deps.candidates()).find((c) => c.primary);
1072
+ if (primary === void 0) throw new Error("keychain: no primary key source is configured");
1073
+ let newKek;
1074
+ if (unlocked.rotationPending && primary.source.kind !== unlocked.source.kind) {
1075
+ const loaded = await primary.load();
1076
+ if (loaded.kind !== "loaded") throw new Error(`keychain: the configured ${primary.source.kind} key could not be loaded`);
1077
+ newKek = loaded.key;
1078
+ } else if (primary.source.kind === "default-file") {
1079
+ newKek = generateKey();
1080
+ await writeDefaultKeyFile(this.deps.dataDir, newKek, {
1081
+ replace: true,
1082
+ nowMs: this.now()
1083
+ });
1084
+ } 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.`);
1085
+ const fingerprint = kekFingerprint(newKek);
1086
+ if (constantTimeEquals(fingerprint, unlocked.fingerprint)) throw new Error("keychain: the new master key is the one already in use");
1087
+ const wrap = {
1088
+ wrapId: randomUUID(),
1089
+ keyId: unlocked.keyId,
1090
+ wrappedDek: wrapDek(newKek, unlocked.dek, unlocked.keyId),
1091
+ kekFingerprint: fingerprint,
1092
+ kekSource: primary.source.kind,
1093
+ createdAt: this.now(),
1094
+ state: "active",
1095
+ recoveryDownloadedAt: null
1096
+ };
1097
+ await this.deps.repo.putKey(wrap);
1098
+ for (const old of await this.deps.repo.listKeys()) if (old.wrapId !== wrap.wrapId && old.state === "active") await this.deps.repo.putKey({
1099
+ ...old,
1100
+ state: "retired"
1101
+ });
1102
+ this.unlock = {
1103
+ state: "ready",
1104
+ dek: unlocked.dek,
1105
+ keyId: unlocked.keyId,
1106
+ kek: newKek,
1107
+ fingerprint,
1108
+ source: primary.source,
1109
+ wrap,
1110
+ rotationPending: false
1111
+ };
1112
+ this.configuredSource = primary.source;
1113
+ await this.audit("rotate-master-key", `user:${userId}`, null, `source=${primary.source.kind}`);
1114
+ return {
1115
+ fingerprint: shortFingerprint(fingerprint),
1116
+ keySource: primary.source
1117
+ };
1118
+ }
1119
+ /** Delete retired wraps, and the default file's previous copies with them. */
1120
+ async purgeRetiredKeys(userId) {
1121
+ this.requireReady();
1122
+ const retired = (await this.deps.repo.listKeys()).filter((w) => w.state === "retired");
1123
+ for (const wrap of retired) await this.deps.repo.deleteKey(wrap.wrapId);
1124
+ const dir = path$1.dirname(defaultKeyFilePath(this.deps.dataDir));
1125
+ try {
1126
+ for (const name of await promises.readdir(dir)) if (name.startsWith("master.key.prev-")) await promises.rm(path$1.join(dir, name), { force: true });
1127
+ } catch {}
1128
+ await this.audit("purge-retired-keys", `user:${userId}`, null, `purged=${retired.length}`);
1129
+ return retired.length;
1130
+ }
1131
+ /** Lost-key recovery — see `keychain-admin.resetKeychain`. */
1132
+ async resetKeychain(userId) {
1133
+ const t = this.now();
1134
+ const archiveName = `keychain:secrets-orphaned-${t}`;
1135
+ const archived = await this.deps.repo.archiveAllSecrets(archiveName);
1136
+ for (const wrap of await this.deps.repo.listKeys()) await this.deps.repo.deleteKey(wrap.wrapId);
1137
+ const primary = (await this.deps.candidates()).find((c) => c.primary) ?? null;
1138
+ if (primary?.source.kind === "default-file") {
1139
+ if ((await primary.load()).kind !== "absent") await writeDefaultKeyFile(this.deps.dataDir, generateKey(), {
1140
+ replace: true,
1141
+ nowMs: t
1142
+ });
1143
+ }
1144
+ this.unlock = await this.firstBoot(primary);
1145
+ await this.audit("reset", `user:${userId}`, null, `archived=${archived}`);
1146
+ return {
1147
+ archivedCollection: archived > 0 ? archiveName : null,
1148
+ archived
1149
+ };
1150
+ }
1151
+ async audit(action, actor, ref, detail) {
1152
+ try {
1153
+ await this.deps.repo.appendAudit({
1154
+ id: randomUUID(),
1155
+ at: this.now(),
1156
+ action,
1157
+ actor,
1158
+ ref,
1159
+ detail
1160
+ });
1161
+ } catch (err) {
1162
+ this.deps.logger.warn("keychain audit row not written", {
1163
+ action,
1164
+ ref,
1165
+ error: errMsg(err)
1166
+ });
1167
+ }
1168
+ }
1169
+ /** Daily ring prune, driven by the addon's timer. */
1170
+ async pruneAudit() {
1171
+ await this.deps.repo.pruneAudit(this.now());
1172
+ }
1173
+ };
1174
+ function toMeta(row) {
1175
+ return {
1176
+ ref: row.ref,
1177
+ owner: row.owner,
1178
+ scope: row.scope,
1179
+ fieldPath: row.fieldPath,
1180
+ version: row.version,
1181
+ createdAt: row.createdAt,
1182
+ updatedAt: row.updatedAt,
1183
+ updatedBy: row.updatedBy,
1184
+ lastReadAt: row.lastReadAt
1185
+ };
1186
+ }
1187
+ function actorOfAddon(caller) {
1188
+ return caller === null ? "system" : `addon:${normalizeSecretOwner(caller)}`;
1189
+ }
1190
+ function errMsg(err) {
1191
+ return err instanceof Error ? err.message : String(err);
1192
+ }
1193
+ EventCategory.KeychainChanged;
1194
+ //#endregion
1195
+ //#region src/builtins/keychain/keychain-usage.ts
1196
+ /**
1197
+ * Is a stored value still pointed at? — the orphan check behind System →
1198
+ * Secrets.
1199
+ *
1200
+ * A ref says where its marker lives (owner + scope), so the check reads THAT
1201
+ * blob and looks for `{"$secret": ref}` anywhere in it. Three answers, and the
1202
+ * third is the important one: `unknown` when the blob could not be read, or
1203
+ * when the scope's store is not one this check knows how to read. A read that
1204
+ * did not answer is never "orphan" — and only an orphan can be deleted from
1205
+ * the page (D315: absence is claimed only from a query that answered).
1206
+ */
1207
+ /** Every `$secret` ref inside a value, at any depth. */
1208
+ function collectMarkerRefs(value, out = /* @__PURE__ */ new Set()) {
1209
+ if (Array.isArray(value)) {
1210
+ for (const item of value) collectMarkerRefs(item, out);
1211
+ return out;
1212
+ }
1213
+ if (value === null || typeof value !== "object") {
1214
+ if (typeof value === "string" && value.includes("\"$secret\"")) try {
1215
+ collectMarkerRefs(JSON.parse(value), out);
1216
+ } catch {}
1217
+ return out;
1218
+ }
1219
+ const ref = Reflect.get(value, "$secret");
1220
+ if (typeof ref === "string") out.add(ref);
1221
+ for (const child of Object.values(value)) collectMarkerRefs(child, out);
1222
+ return out;
1223
+ }
1224
+ /** Rows under an ASCII id prefix, one bounded page; `null` when the page filled. */
1225
+ async function prefixRows(door, collection, prefix) {
1226
+ const hi = `${prefix.slice(0, -1)}${String.fromCharCode(prefix.charCodeAt(prefix.length - 1) + 1)}`;
1227
+ const LIMIT = 2e3;
1228
+ const rows = await door.query({
1229
+ collection,
1230
+ filter: {
1231
+ whereBetween: { id: [prefix, hi] },
1232
+ limit: LIMIT
1233
+ }
1234
+ });
1235
+ if (rows.length >= LIMIT) return null;
1236
+ return rows.filter((r) => r.id.startsWith(prefix)).map((r) => r.data);
1237
+ }
1238
+ /** The blobs that may hold the marker of a ref in `scope`; `null` = cannot tell. */
1239
+ async function blobsFor(door, owner, scope) {
1240
+ switch (scope.kind) {
1241
+ case "global":
1242
+ case "node": {
1243
+ const forked = await door.get({
1244
+ namespace: owner,
1245
+ collection: "addon-settings",
1246
+ key: "root"
1247
+ });
1248
+ const inProcess = await prefixRows(door, "addon-settings", `${owner}.`);
1249
+ if (inProcess === null) return null;
1250
+ return [forked, ...inProcess];
1251
+ }
1252
+ case "device": {
1253
+ const forked = await door.get({
1254
+ namespace: owner,
1255
+ collection: "addon-devices",
1256
+ key: String(scope.deviceId)
1257
+ });
1258
+ const doorRows = await prefixRows(door, "addon-device-settings", `device-manager:${scope.deviceId}.`);
1259
+ if (doorRows === null) return null;
1260
+ return [forked, ...doorRows];
1261
+ }
1262
+ case "integration": return (await door.query({
1263
+ collection: "integration_settings",
1264
+ filter: {
1265
+ where: { integration_id: scope.integrationId },
1266
+ limit: 2e3
1267
+ }
1268
+ })).map((r) => r.data);
1269
+ case "row": return [await door.get({
1270
+ collection: scope.collection,
1271
+ key: scope.rowId
1272
+ })];
1273
+ }
1274
+ }
1275
+ /** Build the usage check the service uses. */
1276
+ function createKeychainUsageCheck(door) {
1277
+ return async (rows) => {
1278
+ const out = /* @__PURE__ */ new Map();
1279
+ const groups = /* @__PURE__ */ new Map();
1280
+ for (const row of rows) {
1281
+ const parts = parseSecretRef(row.ref);
1282
+ if (parts === null) {
1283
+ out.set(row.ref, "unknown");
1284
+ continue;
1285
+ }
1286
+ const key = `${parts.owner}|${row.scope}`;
1287
+ const group = groups.get(key) ?? {
1288
+ owner: parts.owner,
1289
+ scope: parts.scope,
1290
+ refs: []
1291
+ };
1292
+ group.refs.push(row.ref);
1293
+ groups.set(key, group);
1294
+ }
1295
+ for (const group of groups.values()) {
1296
+ let blobs;
1297
+ try {
1298
+ blobs = await blobsFor(door, group.owner, group.scope);
1299
+ } catch {
1300
+ blobs = null;
1301
+ }
1302
+ if (blobs === null) {
1303
+ for (const ref of group.refs) out.set(ref, "unknown");
1304
+ continue;
1305
+ }
1306
+ const seen = /* @__PURE__ */ new Set();
1307
+ for (const blob of blobs) collectMarkerRefs(blob, seen);
1308
+ for (const ref of group.refs) out.set(ref, seen.has(ref) ? "in-use" : "orphan");
1309
+ }
1310
+ return out;
1311
+ };
1312
+ }
1313
+ //#endregion
1314
+ //#region src/builtins/keychain/keychain.addon.ts
1315
+ /**
1316
+ * `keychain` — hub-only, in-process builtin that owns every secret value
1317
+ * (D787). Boots in the infrastructure pass, right after the settings door, so
1318
+ * a forked addon's first `resolveConfig` already finds it.
1319
+ *
1320
+ * Registers two caps from one service:
1321
+ * - `keychain` (internal): the owner's door — put / resolve / delete /
1322
+ * listMeta / getState, owner-checked against the caller the transport
1323
+ * stamped (`callerAddonId`, never in the input schema);
1324
+ * - `keychain-admin`: System → Secrets — status, list, audit, reveal,
1325
+ * orphans, rotation, recovery key, reset. Every method admin; the four
1326
+ * that change or hand out material take the session's user for the audit.
1327
+ *
1328
+ * It does not run on agents: an agent's Phase 1 drops `keychain` from its infra
1329
+ * list (`server/backend/src/agent/agent-infra-capabilities.ts`), and an agent
1330
+ * addon reaches it through `$hub-cap-fwd` like every other hub cap. Until
1331
+ * 2026-10-08 agents DID boot it — their store door forwards to the hub, so an
1332
+ * agent wrote the hub's `keychain:keys` row with a key file on its own disk.
1333
+ * Booting first, it left the hub locked `key-missing`.
1334
+ */
1335
+ var DAY_MS = 1440 * 6e4;
1336
+ /** The caller the transport stamped (or an in-process caller stated). */
1337
+ function callerOf(input) {
1338
+ if (input === null || typeof input !== "object") return null;
1339
+ const raw = Reflect.get(input, "callerAddonId");
1340
+ return typeof raw === "string" && raw.length > 0 ? raw : null;
1341
+ }
1342
+ function readKeyConfig(section) {
1343
+ const keyFile = section["keyFile"];
1344
+ const previousKeyFile = section["previousKeyFile"];
1345
+ return {
1346
+ keyFile: typeof keyFile === "string" && keyFile.length > 0 ? keyFile : void 0,
1347
+ previousKeyFile: typeof previousKeyFile === "string" && previousKeyFile.length > 0 ? previousKeyFile : void 0
1348
+ };
1349
+ }
1350
+ var KeychainAddon = class extends BaseAddon {
1351
+ id = "keychain";
1352
+ pruneTimer = null;
1353
+ constructor() {
1354
+ super({});
1355
+ }
1356
+ async onInitialize() {
1357
+ const ctx = this.ctx;
1358
+ const store = ctx.api.settingsStore;
1359
+ const door = {
1360
+ declareCollection: (input) => store.declareCollection.mutate(input),
1361
+ get: (input) => store.get.query(input),
1362
+ set: (input) => store.set.mutate(input),
1363
+ query: (input) => store.query.query(input),
1364
+ delete: (input) => store.delete.mutate(input),
1365
+ deleteWhere: (input) => store.deleteWhere.mutate(input)
1366
+ };
1367
+ const dataDir = ctx.nodeDataDir;
1368
+ const runnerIdOf = ctx.kernel.runnerIdForAddon ?? ((addonId) => addonId);
1369
+ const service = new KeychainService({
1370
+ repo: new KeychainRepository(door),
1371
+ candidates: async () => {
1372
+ return buildKeySourceCandidates({
1373
+ config: readKeyConfig(await ctx.settings?.getSection("keychain") ?? {}),
1374
+ dataDir
1375
+ });
1376
+ },
1377
+ dataDir,
1378
+ events: { emit: (payload) => {
1379
+ ctx.eventBus.emit({
1380
+ id: `keychain-${payload.ref}-${Date.now()}`,
1381
+ timestamp: /* @__PURE__ */ new Date(),
1382
+ source: {
1383
+ type: "addon",
1384
+ id: "keychain"
1385
+ },
1386
+ category: EventCategory.KeychainChanged,
1387
+ data: payload
1388
+ });
1389
+ } },
1390
+ logger: {
1391
+ info: (message, meta) => ctx.logger.info(message, meta ? { meta } : void 0),
1392
+ warn: (message, meta) => ctx.logger.warn(message, meta ? { meta } : void 0),
1393
+ error: (message, meta) => ctx.logger.error(message, meta ? { meta } : void 0)
1394
+ },
1395
+ runnerIdOf,
1396
+ usage: createKeychainUsageCheck(door)
1397
+ });
1398
+ await service.init();
1399
+ this.pruneTimer = setInterval(() => {
1400
+ service.pruneAudit().catch((err) => {
1401
+ ctx.logger.warn("keychain audit prune failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
1402
+ });
1403
+ }, DAY_MS);
1404
+ this.pruneTimer.unref?.();
1405
+ return [{
1406
+ capability: keychainCapability,
1407
+ provider: {
1408
+ put: (input) => service.put(callerOf(input), input.ref, input.value),
1409
+ resolve: (input) => service.resolve(callerOf(input), input.refs),
1410
+ delete: async (input) => ({ deleted: await service.delete(callerOf(input), input.refs) }),
1411
+ listMeta: (input) => service.listMeta(callerOf(input)),
1412
+ getState: async () => service.getState()
1413
+ }
1414
+ }, {
1415
+ capability: keychainAdminCapability,
1416
+ provider: {
1417
+ status: () => service.status(),
1418
+ listSecrets: () => service.listSecrets(),
1419
+ listAudit: (input) => service.listAudit(input.limit ?? 100),
1420
+ reveal: (input) => service.reveal(input.caller.userId, input.ref),
1421
+ deleteOrphans: (input) => service.deleteOrphans(input.caller.userId, input.refs),
1422
+ rotateMasterKey: (input) => service.rotateMasterKey(input.caller.userId),
1423
+ purgeRetiredKeys: async (input) => ({ purged: await service.purgeRetiredKeys(input.caller.userId) }),
1424
+ downloadRecoveryKey: (input) => service.downloadRecoveryKey(input.caller.userId),
1425
+ resetKeychain: (input) => service.resetKeychain(input.caller.userId)
1426
+ }
1427
+ }];
1428
+ }
1429
+ async onShutdown() {
1430
+ if (this.pruneTimer !== null) clearInterval(this.pruneTimer);
1431
+ this.pruneTimer = null;
1432
+ }
1433
+ };
1434
+ //#endregion
1435
+ export { KeychainAddon, KeychainAddon as default };