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