@camstack/system 1.2.376 → 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.
- package/dist/addon-runner.js +9 -4
- package/dist/addon-runner.mjs +10 -5
- package/dist/addon-utils.js +1 -1
- package/dist/addon-utils.mjs +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/autotrack/index.js +1 -1
- package/dist/builtins/autotrack/index.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/calendars/calendars.addon.js +1 -1
- package/dist/builtins/calendars/calendars.addon.mjs +1 -1
- package/dist/builtins/camera-grid/index.js +1 -1
- package/dist/builtins/camera-grid/index.mjs +1 -1
- package/dist/builtins/composer/composer.addon.js +2 -2
- package/dist/builtins/composer/composer.addon.mjs +2 -2
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
- package/dist/builtins/device-manager/device-config-secrets.d.ts +21 -0
- package/dist/builtins/device-manager/device-manager.addon.js +85 -6
- package/dist/builtins/device-manager/device-manager.addon.mjs +85 -6
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/keychain/index.d.ts +1 -0
- package/dist/builtins/keychain/index.js +1416 -0
- package/dist/builtins/keychain/index.mjs +1409 -0
- package/dist/builtins/keychain/key-sources.d.ts +37 -0
- package/dist/builtins/keychain/keychain-crypto.d.ts +28 -0
- package/dist/builtins/keychain/keychain-service.d.ts +103 -0
- package/dist/builtins/keychain/keychain-store.d.ts +82 -0
- package/dist/builtins/keychain/keychain-usage.d.ts +6 -0
- package/dist/builtins/keychain/keychain.addon.d.ts +11 -0
- package/dist/builtins/keychain/platform-key-source.d.ts +24 -0
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +19 -2
- package/dist/builtins/local-auth/local-auth.addon.mjs +19 -2
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
- package/dist/builtins/storage-orchestrator/location-store.d.ts +6 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.d.ts +15 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +74 -14
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +74 -14
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{child-cap-dispatch-8qMgog1O.mjs → child-cap-dispatch-CgmOo0yl.mjs} +170 -27
- package/dist/{child-cap-dispatch-BWtaSmWf.js → child-cap-dispatch-DlDSoqm1.js} +198 -25
- package/dist/{composition-sources-DW7CgMsg.js → composition-sources-BNyXPSs_.js} +1 -1
- package/dist/{composition-sources-zxQPPtP_.mjs → composition-sources-D39bl0nP.mjs} +1 -1
- package/dist/{dist-D732NygW.mjs → dist-BWNoS-1S.mjs} +2195 -105
- package/dist/{dist-BcUWQGr9.js → dist-D_a9mUiM.js} +2260 -104
- package/dist/{event-loop-stall-monitor-8W4yWNVW.mjs → event-loop-stall-monitor-ClPhfx7Y.mjs} +1 -1
- package/dist/{event-loop-stall-monitor-QX4RjNe7.js → event-loop-stall-monitor-DuOn8H1n.js} +1 -1
- package/dist/{hub-cap-forward-BdT320dq.mjs → hub-cap-forward-CH6_VWVO.mjs} +13 -4
- package/dist/{hub-cap-forward-DmHNjbB0.js → hub-cap-forward-COCmYaup.js} +13 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +20 -6
- package/dist/index.mjs +13 -7
- package/dist/kernel/config-schema.d.ts +4 -0
- package/dist/kernel/moleculer/child-addon-call-dispatch.d.ts +6 -0
- package/dist/kernel/transport/cap-route-resolver.d.ts +19 -0
- package/dist/kernel/transport/hub-cap-forward.d.ts +22 -1
- package/dist/kernel/transport/index.d.ts +2 -2
- package/dist/kernel/transport/local-child-registry.d.ts +4 -3
- package/dist/logging/log-redactor.d.ts +9 -0
- package/dist/platform-key-source-BWlq0jjn.mjs +23 -0
- package/dist/platform-key-source-Di7HrlGR.js +40 -0
- package/dist/{retired-settings-keys-MaESpO18.js → retired-settings-keys-CDyOGAje.js} +1 -1
- package/dist/{retired-settings-keys-CxPOg7BF.mjs → retired-settings-keys-iGcmzwLV.mjs} +1 -1
- 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 };
|