@camstack/system 1.2.375 → 1.2.377

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