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