llm-relay 0.44.0 → 0.45.1
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/README.md +1 -0
- package/dist/authEnv.d.ts +20 -12
- package/dist/authEnv.js +76 -36
- package/dist/authEnv.js.map +1 -1
- package/dist/candidates.d.ts +2 -0
- package/dist/candidates.js +2 -1
- package/dist/candidates.js.map +1 -1
- package/dist/circuit-breaker.d.ts +2 -0
- package/dist/circuit-breaker.js +17 -0
- package/dist/circuit-breaker.js.map +1 -1
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +193 -5
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +4 -3
- package/dist/config.js +87 -7
- package/dist/config.js.map +1 -1
- package/dist/cooldown-clear.d.ts +2 -0
- package/dist/cooldown-clear.js +20 -4
- package/dist/cooldown-clear.js.map +1 -1
- package/dist/credential-fleet.d.ts +5 -4
- package/dist/credential-fleet.js +9 -9
- package/dist/credential-fleet.js.map +1 -1
- package/dist/dotenv.d.ts +2 -0
- package/dist/dotenv.js +9 -0
- package/dist/dotenv.js.map +1 -1
- package/dist/key-checker.d.ts +1 -0
- package/dist/key-checker.js +3 -2
- package/dist/key-checker.js.map +1 -1
- package/dist/key-import.d.ts +7 -0
- package/dist/key-import.js +52 -0
- package/dist/key-import.js.map +1 -1
- package/dist/keys-cli.d.ts +48 -0
- package/dist/keys-cli.js +693 -0
- package/dist/keys-cli.js.map +1 -0
- package/dist/keystore.d.ts +130 -0
- package/dist/keystore.js +1195 -0
- package/dist/keystore.js.map +1 -0
- package/dist/os-keyring.d.ts +88 -0
- package/dist/os-keyring.js +476 -0
- package/dist/os-keyring.js.map +1 -0
- package/dist/registry.d.ts +2 -1
- package/dist/registry.js +1 -0
- package/dist/registry.js.map +1 -1
- package/dist/resolved-attempt.d.ts +2 -1
- package/dist/resolved-attempt.js +3 -2
- package/dist/resolved-attempt.js.map +1 -1
- package/dist/routes/admin.js +8 -1
- package/dist/routes/admin.js.map +1 -1
- package/dist/secret-file-acl.d.ts +47 -12
- package/dist/secret-file-acl.js +151 -16
- package/dist/secret-file-acl.js.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/target-facts.d.ts +5 -0
- package/dist/target-facts.js +10 -3
- package/dist/target-facts.js.map +1 -1
- package/package.json +1 -1
package/dist/keystore.js
ADDED
|
@@ -0,0 +1,1195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encrypted credential custody is single-writer by convention. Mutations are read-modify-write,
|
|
3
|
+
* no inter-process lock is built, and concurrent CLI writers therefore have last-write-wins
|
|
4
|
+
* semantics. An envName is globally unique within a store; malformed or duplicate rows are
|
|
5
|
+
* skipped in file order, so the first valid row deterministically wins read-side deduplication.
|
|
6
|
+
*/
|
|
7
|
+
import { chmodSync, closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
8
|
+
import { homedir, tmpdir } from "node:os";
|
|
9
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
10
|
+
import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual, } from "node:crypto";
|
|
11
|
+
import { parseCredentialId } from "./credential-id.js";
|
|
12
|
+
import { createPassphraseKek, createKek, derivePassphraseKek, unwrapKek, } from "./os-keyring.js";
|
|
13
|
+
import { restrictSecretDirectoryOnWindowsSync, restrictSecretFileOnWindowsSync, } from "./secret-file-acl.js";
|
|
14
|
+
const STORE_VERSION = 1;
|
|
15
|
+
const FILE_MODE = 0o600;
|
|
16
|
+
const DIRECTORY_MODE = 0o700;
|
|
17
|
+
const GCM_TAG_LENGTH = 16;
|
|
18
|
+
const GCM_IV_LENGTH = 12;
|
|
19
|
+
const FINGERPRINT_SALT_LENGTH = 32;
|
|
20
|
+
const ITEM_ID_LENGTH = 16;
|
|
21
|
+
const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
22
|
+
const PROVIDER_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
23
|
+
const FINGERPRINT_PATTERN = /^hmac:[0-9a-f]{8}$/;
|
|
24
|
+
const ITEM_ID_PATTERN = /^[0-9a-f]{32}$/;
|
|
25
|
+
const KEK_CHECK_PATTERN = /^[0-9a-f]{64}$/;
|
|
26
|
+
const KEK_CHECK_CONTEXT = "llm-relay-kek-v1";
|
|
27
|
+
const EXPORT_AAD = Buffer.from("llm-relay-keystore-export-v1", "utf8");
|
|
28
|
+
const EXPORT_SOURCE = "llm-relay-keystore";
|
|
29
|
+
const UNREADABLE_RETRY_MS = 30_000;
|
|
30
|
+
const UNLOCK_RETRY_MS = 60_000;
|
|
31
|
+
export class KeystoreEntryNotFoundError extends Error {
|
|
32
|
+
constructor() {
|
|
33
|
+
super("keystore entry not found");
|
|
34
|
+
this.name = "KeystoreEntryNotFoundError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export class KeystoreEntryExistsError extends Error {
|
|
38
|
+
constructor() {
|
|
39
|
+
super("keystore entry already exists");
|
|
40
|
+
this.name = "KeystoreEntryExistsError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export class KeystoreValidationError extends Error {
|
|
44
|
+
constructor(classification) {
|
|
45
|
+
super(`invalid keystore entry ${classification}`);
|
|
46
|
+
this.name = "KeystoreValidationError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export class KeystoreWriteError extends Error {
|
|
50
|
+
constructor(errno) {
|
|
51
|
+
super(`keystore write failed${errno === undefined ? "" : ` (${errno})`}`);
|
|
52
|
+
this.name = "KeystoreWriteError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export class KeystoreReadError extends Error {
|
|
56
|
+
constructor(errno) {
|
|
57
|
+
super(`keystore read failed${errno === undefined ? "" : ` (${errno})`}`);
|
|
58
|
+
this.name = "KeystoreReadError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export class KeystoreUnlockError extends Error {
|
|
62
|
+
constructor() {
|
|
63
|
+
super("keystore unlock failed");
|
|
64
|
+
this.name = "KeystoreUnlockError";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export class KeystoreExportError extends Error {
|
|
68
|
+
constructor(message = "encrypted keystore export is invalid or cannot be decrypted") {
|
|
69
|
+
super(message);
|
|
70
|
+
this.name = "KeystoreExportError";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export class KeystoreMutationRefusedError extends Error {
|
|
74
|
+
status;
|
|
75
|
+
droppedCount;
|
|
76
|
+
constructor(status, droppedCount = 0, errno) {
|
|
77
|
+
const count = droppedCount > 0 ? `; ${droppedCount} unreadable row${droppedCount === 1 ? "" : "s"}` : "";
|
|
78
|
+
const code = errno === undefined ? "" : `; ${errno}`;
|
|
79
|
+
super(`keystore mutation refused: ${status}${count}${code}`);
|
|
80
|
+
this.name = "KeystoreMutationRefusedError";
|
|
81
|
+
this.status = status;
|
|
82
|
+
this.droppedCount = droppedCount;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
let unlocked = null;
|
|
86
|
+
const unlockFailures = new Map();
|
|
87
|
+
// Production resolves one configured store path. Test/embedding path injection can grow these
|
|
88
|
+
// maps until lock()/process exit; cardinality eviction would violate unchanged-store zero-read and
|
|
89
|
+
// uninterrupted unlock-cooldown guarantees.
|
|
90
|
+
const storeLoads = new Map();
|
|
91
|
+
const resolutionWalkLoads = new WeakMap();
|
|
92
|
+
function unlockFailureKey(path, descriptor) {
|
|
93
|
+
return `${path}\0${descriptor}`;
|
|
94
|
+
}
|
|
95
|
+
function pruneUnlockFailures(path, descriptor) {
|
|
96
|
+
for (const [key, failure] of unlockFailures) {
|
|
97
|
+
if (failure.path === path && failure.descriptor !== descriptor)
|
|
98
|
+
unlockFailures.delete(key);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function rememberUnlockFailure(path, descriptor, status, opts) {
|
|
102
|
+
const key = unlockFailureKey(path, descriptor);
|
|
103
|
+
unlockFailures.delete(key);
|
|
104
|
+
unlockFailures.set(key, { path, descriptor, status, failedAt: memoTime(opts) });
|
|
105
|
+
}
|
|
106
|
+
export function createKeystoreResolutionWalk() {
|
|
107
|
+
const walk = Object.freeze({});
|
|
108
|
+
resolutionWalkLoads.set(walk, new Map());
|
|
109
|
+
return walk;
|
|
110
|
+
}
|
|
111
|
+
/** Resolve the live store path without ever letting tests touch the user's real store. */
|
|
112
|
+
export function resolveKeystorePath(opts = {}) {
|
|
113
|
+
if (opts.path !== undefined)
|
|
114
|
+
return resolve(opts.path);
|
|
115
|
+
if (process.env.VITEST !== undefined) {
|
|
116
|
+
const pool = process.env.VITEST_POOL_ID ?? "pool";
|
|
117
|
+
const worker = process.env.VITEST_WORKER_ID ?? "worker";
|
|
118
|
+
return resolve(join(tmpdir(), `llm-relay-test-keystore-${process.pid}-${pool}-${worker}`, "keystore.json"));
|
|
119
|
+
}
|
|
120
|
+
return resolve(join(homedir(), ".llm-relay", "keystore.json"));
|
|
121
|
+
}
|
|
122
|
+
function isObject(value) {
|
|
123
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
124
|
+
}
|
|
125
|
+
function hasExactKeys(value, required, optional = []) {
|
|
126
|
+
const permitted = new Set([...required, ...optional]);
|
|
127
|
+
return required.every((key) => Object.hasOwn(value, key))
|
|
128
|
+
&& Object.keys(value).every((key) => permitted.has(key));
|
|
129
|
+
}
|
|
130
|
+
function decodeBase64(value, expectedLength) {
|
|
131
|
+
if (typeof value !== "string")
|
|
132
|
+
return null;
|
|
133
|
+
try {
|
|
134
|
+
const decoded = Buffer.from(value, "base64");
|
|
135
|
+
if (decoded.toString("base64") !== value)
|
|
136
|
+
return null;
|
|
137
|
+
if (expectedLength !== undefined && decoded.length !== expectedLength)
|
|
138
|
+
return null;
|
|
139
|
+
return decoded;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function validTimestamp(value) {
|
|
146
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
147
|
+
}
|
|
148
|
+
function validNullableTimestamp(value) {
|
|
149
|
+
return value === null || validTimestamp(value);
|
|
150
|
+
}
|
|
151
|
+
function validKekDescriptor(value) {
|
|
152
|
+
if (!isObject(value) || typeof value.wrap !== "string")
|
|
153
|
+
return false;
|
|
154
|
+
switch (value.wrap) {
|
|
155
|
+
case "dpapi":
|
|
156
|
+
return hasExactKeys(value, ["wrap", "blob"])
|
|
157
|
+
&& (decodeBase64(value.blob)?.length ?? 0) > 0;
|
|
158
|
+
case "keychain":
|
|
159
|
+
case "libsecret":
|
|
160
|
+
return hasExactKeys(value, ["wrap"]);
|
|
161
|
+
case "passphrase": {
|
|
162
|
+
if (!hasExactKeys(value, ["wrap", "kdf"]) || !isObject(value.kdf))
|
|
163
|
+
return false;
|
|
164
|
+
return hasExactKeys(value.kdf, ["n", "r", "p", "salt"])
|
|
165
|
+
&& value.kdf.n === 16_384
|
|
166
|
+
&& value.kdf.r === 8
|
|
167
|
+
&& value.kdf.p === 1
|
|
168
|
+
&& decodeBase64(value.kdf.salt)?.length === 32;
|
|
169
|
+
}
|
|
170
|
+
default:
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function validStoredEntry(value) {
|
|
175
|
+
if (!isObject(value) || !hasExactKeys(value, [
|
|
176
|
+
"id",
|
|
177
|
+
"provider",
|
|
178
|
+
"envName",
|
|
179
|
+
"ct",
|
|
180
|
+
"iv",
|
|
181
|
+
"tag",
|
|
182
|
+
"fingerprint",
|
|
183
|
+
"addedAt",
|
|
184
|
+
"rotatedAt",
|
|
185
|
+
"expiresAt",
|
|
186
|
+
"revokedAt",
|
|
187
|
+
"disabled",
|
|
188
|
+
]))
|
|
189
|
+
return false;
|
|
190
|
+
const parsedId = typeof value.id === "string" ? parseCredentialId(value.id) : null;
|
|
191
|
+
return parsedId !== null
|
|
192
|
+
&& typeof value.provider === "string"
|
|
193
|
+
&& PROVIDER_PATTERN.test(value.provider)
|
|
194
|
+
&& parsedId.provider === value.provider
|
|
195
|
+
&& typeof value.envName === "string"
|
|
196
|
+
&& ENV_NAME_PATTERN.test(value.envName)
|
|
197
|
+
&& (decodeBase64(value.ct)?.length ?? 0) > 0
|
|
198
|
+
&& decodeBase64(value.iv, GCM_IV_LENGTH) !== null
|
|
199
|
+
&& decodeBase64(value.tag, GCM_TAG_LENGTH) !== null
|
|
200
|
+
&& typeof value.fingerprint === "string"
|
|
201
|
+
&& FINGERPRINT_PATTERN.test(value.fingerprint)
|
|
202
|
+
&& validTimestamp(value.addedAt)
|
|
203
|
+
&& validNullableTimestamp(value.rotatedAt)
|
|
204
|
+
&& validNullableTimestamp(value.expiresAt)
|
|
205
|
+
&& validNullableTimestamp(value.revokedAt)
|
|
206
|
+
&& typeof value.disabled === "boolean";
|
|
207
|
+
}
|
|
208
|
+
function parseStore(raw) {
|
|
209
|
+
if (!isObject(raw) || !hasExactKeys(raw, [
|
|
210
|
+
"version",
|
|
211
|
+
"kek",
|
|
212
|
+
"fpSalt",
|
|
213
|
+
"itemId",
|
|
214
|
+
"kekCheck",
|
|
215
|
+
"entries",
|
|
216
|
+
]))
|
|
217
|
+
return null;
|
|
218
|
+
if (raw.version !== STORE_VERSION || !validKekDescriptor(raw.kek))
|
|
219
|
+
return null;
|
|
220
|
+
if (decodeBase64(raw.fpSalt, FINGERPRINT_SALT_LENGTH) === null
|
|
221
|
+
|| typeof raw.itemId !== "string"
|
|
222
|
+
|| !ITEM_ID_PATTERN.test(raw.itemId)
|
|
223
|
+
|| typeof raw.kekCheck !== "string"
|
|
224
|
+
|| !KEK_CHECK_PATTERN.test(raw.kekCheck)
|
|
225
|
+
|| !Array.isArray(raw.entries))
|
|
226
|
+
return null;
|
|
227
|
+
const entries = [];
|
|
228
|
+
const seenIds = new Set();
|
|
229
|
+
const seenEnvNames = new Set();
|
|
230
|
+
let droppedCount = 0;
|
|
231
|
+
for (const candidate of raw.entries) {
|
|
232
|
+
if (!validStoredEntry(candidate)) {
|
|
233
|
+
droppedCount += 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (seenIds.has(candidate.id) || seenEnvNames.has(candidate.envName)) {
|
|
237
|
+
droppedCount += 1;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
seenIds.add(candidate.id);
|
|
241
|
+
seenEnvNames.add(candidate.envName);
|
|
242
|
+
entries.push(candidate);
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
droppedCount,
|
|
246
|
+
store: {
|
|
247
|
+
version: STORE_VERSION,
|
|
248
|
+
kek: raw.kek,
|
|
249
|
+
fpSalt: raw.fpSalt,
|
|
250
|
+
itemId: raw.itemId,
|
|
251
|
+
kekCheck: raw.kekCheck,
|
|
252
|
+
entries,
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function errorCode(error) {
|
|
257
|
+
if (!error || typeof error !== "object" || !("code" in error))
|
|
258
|
+
return undefined;
|
|
259
|
+
const code = String(error.code);
|
|
260
|
+
return /^[A-Z][A-Z0-9_]{0,31}$/u.test(code) ? code : undefined;
|
|
261
|
+
}
|
|
262
|
+
function memoTime(opts) {
|
|
263
|
+
return validTimestamp(opts.now) ? opts.now : Date.now();
|
|
264
|
+
}
|
|
265
|
+
function observeStore(path, opts) {
|
|
266
|
+
let pathEntryExists = false;
|
|
267
|
+
try {
|
|
268
|
+
let stat;
|
|
269
|
+
if (opts.statFile !== undefined) {
|
|
270
|
+
stat = opts.statFile(path);
|
|
271
|
+
pathEntryExists = true;
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
const pathStat = lstatSync(path);
|
|
275
|
+
pathEntryExists = true;
|
|
276
|
+
if (pathStat.isSymbolicLink()) {
|
|
277
|
+
const targetStat = statSync(path);
|
|
278
|
+
stat = {
|
|
279
|
+
mtimeMs: targetStat.mtimeMs,
|
|
280
|
+
size: targetStat.size,
|
|
281
|
+
ino: targetStat.ino,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
stat = {
|
|
286
|
+
mtimeMs: pathStat.mtimeMs,
|
|
287
|
+
size: pathStat.size,
|
|
288
|
+
ino: pathStat.ino,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (!Number.isFinite(stat.mtimeMs)
|
|
293
|
+
|| !Number.isFinite(stat.size)
|
|
294
|
+
|| !Number.isFinite(stat.ino)) {
|
|
295
|
+
return { kind: "error", token: "stat:EINVAL", errno: "EINVAL" };
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
kind: "present",
|
|
299
|
+
token: `stat:${stat.mtimeMs}:${stat.size}:${stat.ino}`,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
const errno = errorCode(error);
|
|
304
|
+
if (errno === "ENOENT" && !pathEntryExists) {
|
|
305
|
+
return { kind: "absent", token: "ENOENT" };
|
|
306
|
+
}
|
|
307
|
+
if (errno !== "ENOENT") {
|
|
308
|
+
return errno === undefined
|
|
309
|
+
? { kind: "error", token: "stat:UNKNOWN" }
|
|
310
|
+
: { kind: "error", token: `stat:${errno}`, errno };
|
|
311
|
+
}
|
|
312
|
+
// A dangling symlink has a directory entry even though following it reports ENOENT.
|
|
313
|
+
return { kind: "error", token: "stat:ENOENT:present", errno };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function reusableStoreLoad(memo, now) {
|
|
317
|
+
if (memo.result.status !== "unreadable")
|
|
318
|
+
return true;
|
|
319
|
+
const failedAt = memo.failedAt ?? now;
|
|
320
|
+
return now < failedAt || now - failedAt < UNREADABLE_RETRY_MS;
|
|
321
|
+
}
|
|
322
|
+
function rememberStoreLoad(path, memo) {
|
|
323
|
+
storeLoads.delete(path);
|
|
324
|
+
storeLoads.set(path, memo);
|
|
325
|
+
}
|
|
326
|
+
function walkLoads(opts) {
|
|
327
|
+
return opts.resolutionWalk === undefined
|
|
328
|
+
? undefined
|
|
329
|
+
: resolutionWalkLoads.get(opts.resolutionWalk);
|
|
330
|
+
}
|
|
331
|
+
function invalidateStoreLoad(path, opts) {
|
|
332
|
+
const normalizedPath = resolve(path);
|
|
333
|
+
storeLoads.delete(normalizedPath);
|
|
334
|
+
if (opts !== undefined)
|
|
335
|
+
walkLoads(opts)?.delete(normalizedPath);
|
|
336
|
+
}
|
|
337
|
+
function loadStore(path, opts = {}) {
|
|
338
|
+
const normalizedPath = resolve(path);
|
|
339
|
+
const now = memoTime(opts);
|
|
340
|
+
const scopedLoads = walkLoads(opts);
|
|
341
|
+
const scoped = scopedLoads?.get(normalizedPath);
|
|
342
|
+
if (scoped !== undefined)
|
|
343
|
+
return scoped.result;
|
|
344
|
+
const observation = observeStore(normalizedPath, opts);
|
|
345
|
+
const memoized = storeLoads.get(normalizedPath);
|
|
346
|
+
if (memoized !== undefined
|
|
347
|
+
&& memoized.token === observation.token
|
|
348
|
+
&& reusableStoreLoad(memoized, now)) {
|
|
349
|
+
scopedLoads?.set(normalizedPath, memoized);
|
|
350
|
+
return memoized.result;
|
|
351
|
+
}
|
|
352
|
+
let result;
|
|
353
|
+
if (observation.kind === "absent") {
|
|
354
|
+
result = { status: "fresh", droppedCount: 0 };
|
|
355
|
+
}
|
|
356
|
+
else if (observation.kind === "error") {
|
|
357
|
+
result = observation.errno === undefined
|
|
358
|
+
? { status: "unreadable", droppedCount: 0 }
|
|
359
|
+
: { status: "unreadable", droppedCount: 0, errno: observation.errno };
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
try {
|
|
363
|
+
const serialized = opts.readFile?.(normalizedPath) ?? readFileSync(normalizedPath, "utf8");
|
|
364
|
+
const parsed = parseStore(JSON.parse(serialized));
|
|
365
|
+
result = parsed === null
|
|
366
|
+
? { status: "unreadable", droppedCount: 0 }
|
|
367
|
+
: {
|
|
368
|
+
status: parsed.droppedCount > 0 ? "degraded" : "ok",
|
|
369
|
+
droppedCount: parsed.droppedCount,
|
|
370
|
+
store: parsed.store,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
// A present but malformed or transiently unreadable document is never equivalent to an
|
|
375
|
+
// absent, creatable store. The sanitized verdict is retried on token change or TTL expiry.
|
|
376
|
+
const errno = errorCode(error);
|
|
377
|
+
result = errno === undefined
|
|
378
|
+
? { status: "unreadable", droppedCount: 0 }
|
|
379
|
+
: { status: "unreadable", droppedCount: 0, errno };
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const remembered = result.status === "unreadable"
|
|
383
|
+
? { token: observation.token, result, failedAt: now }
|
|
384
|
+
: { token: observation.token, result };
|
|
385
|
+
rememberStoreLoad(normalizedPath, remembered);
|
|
386
|
+
scopedLoads?.set(normalizedPath, remembered);
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
function validateEntryIdentity(id, provider, envName) {
|
|
390
|
+
const parsedId = parseCredentialId(id);
|
|
391
|
+
if (parsedId === null)
|
|
392
|
+
throw new KeystoreValidationError("id");
|
|
393
|
+
if (!PROVIDER_PATTERN.test(provider))
|
|
394
|
+
throw new KeystoreValidationError("provider");
|
|
395
|
+
if (parsedId.provider !== provider)
|
|
396
|
+
throw new KeystoreValidationError("provider");
|
|
397
|
+
if (!ENV_NAME_PATTERN.test(envName))
|
|
398
|
+
throw new KeystoreValidationError("envName");
|
|
399
|
+
}
|
|
400
|
+
export function keyIsPresent(value) {
|
|
401
|
+
return (value ?? "").trim().length > 0;
|
|
402
|
+
}
|
|
403
|
+
function validateValue(value) {
|
|
404
|
+
if (typeof value !== "string" || !keyIsPresent(value)) {
|
|
405
|
+
throw new KeystoreValidationError("value");
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
function validateExpiresAt(value) {
|
|
409
|
+
if (value !== undefined && !validNullableTimestamp(value))
|
|
410
|
+
throw new KeystoreValidationError("timestamp");
|
|
411
|
+
}
|
|
412
|
+
function writeTime(opts) {
|
|
413
|
+
const now = opts.now ?? Date.now();
|
|
414
|
+
if (!validTimestamp(now))
|
|
415
|
+
throw new KeystoreValidationError("timestamp");
|
|
416
|
+
return now;
|
|
417
|
+
}
|
|
418
|
+
function fingerprint(value, kek, fpSalt) {
|
|
419
|
+
const salt = Buffer.from(fpSalt, "base64");
|
|
420
|
+
try {
|
|
421
|
+
return `hmac:${createHmac("sha256", kek).update(salt).update(value).digest("hex").slice(0, 8)}`;
|
|
422
|
+
}
|
|
423
|
+
finally {
|
|
424
|
+
salt.fill(0);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
function aad(provider, entryId, envName) {
|
|
428
|
+
return Buffer.from(`${STORE_VERSION}|${provider}|${entryId}|${envName}`, "utf8");
|
|
429
|
+
}
|
|
430
|
+
function encryptEntryValue(value, provider, entryId, envName, kek, fpSalt) {
|
|
431
|
+
const plaintext = Buffer.from(value, "utf8");
|
|
432
|
+
const iv = randomBytes(GCM_IV_LENGTH);
|
|
433
|
+
try {
|
|
434
|
+
const cipher = createCipheriv("aes-256-gcm", kek, iv, { authTagLength: GCM_TAG_LENGTH });
|
|
435
|
+
cipher.setAAD(aad(provider, entryId, envName));
|
|
436
|
+
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
437
|
+
const tag = cipher.getAuthTag();
|
|
438
|
+
return {
|
|
439
|
+
ct: ct.toString("base64"),
|
|
440
|
+
iv: iv.toString("base64"),
|
|
441
|
+
tag: tag.toString("base64"),
|
|
442
|
+
fingerprint: fingerprint(plaintext, kek, fpSalt),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
plaintext.fill(0);
|
|
447
|
+
iv.fill(0);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function decryptEntryBytes(entry, kek, fpSalt) {
|
|
451
|
+
const ct = decodeBase64(entry.ct);
|
|
452
|
+
const iv = decodeBase64(entry.iv, GCM_IV_LENGTH);
|
|
453
|
+
const tag = decodeBase64(entry.tag, GCM_TAG_LENGTH);
|
|
454
|
+
if (ct === null || iv === null || tag === null)
|
|
455
|
+
return null;
|
|
456
|
+
try {
|
|
457
|
+
const decipher = createDecipheriv("aes-256-gcm", kek, iv, { authTagLength: GCM_TAG_LENGTH });
|
|
458
|
+
decipher.setAAD(aad(entry.provider, entry.id, entry.envName));
|
|
459
|
+
decipher.setAuthTag(tag);
|
|
460
|
+
const plaintext = Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
461
|
+
const expected = fingerprint(plaintext, kek, fpSalt);
|
|
462
|
+
if (!timingSafeEqual(Buffer.from(expected), Buffer.from(entry.fingerprint))) {
|
|
463
|
+
plaintext.fill(0);
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
return plaintext;
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
// Authentication failures are a corrupt row, not a process-level custody failure.
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
finally {
|
|
473
|
+
ct.fill(0);
|
|
474
|
+
iv.fill(0);
|
|
475
|
+
tag.fill(0);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function decryptEntryValue(entry, kek, fpSalt) {
|
|
479
|
+
const plaintext = decryptEntryBytes(entry, kek, fpSalt);
|
|
480
|
+
if (plaintext === null)
|
|
481
|
+
return null;
|
|
482
|
+
try {
|
|
483
|
+
return plaintext.toString("utf8");
|
|
484
|
+
}
|
|
485
|
+
finally {
|
|
486
|
+
plaintext.fill(0);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function entryAuthenticates(entry, kek, fpSalt) {
|
|
490
|
+
const plaintext = decryptEntryBytes(entry, kek, fpSalt);
|
|
491
|
+
if (plaintext === null)
|
|
492
|
+
return false;
|
|
493
|
+
plaintext.fill(0);
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
function clearUnlocked() {
|
|
497
|
+
if (unlocked !== null)
|
|
498
|
+
unlocked.kek.fill(0);
|
|
499
|
+
unlocked = null;
|
|
500
|
+
}
|
|
501
|
+
function keyringItemId(itemId) {
|
|
502
|
+
return `keystore-${itemId}`;
|
|
503
|
+
}
|
|
504
|
+
function calculateKekCheck(kek) {
|
|
505
|
+
return createHmac("sha256", kek).update(KEK_CHECK_CONTEXT, "utf8").digest("hex");
|
|
506
|
+
}
|
|
507
|
+
function validKekCheck(kek, expected) {
|
|
508
|
+
const actual = Buffer.from(calculateKekCheck(kek), "hex");
|
|
509
|
+
const persisted = Buffer.from(expected, "hex");
|
|
510
|
+
try {
|
|
511
|
+
return actual.length === persisted.length && timingSafeEqual(actual, persisted);
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
actual.fill(0);
|
|
515
|
+
persisted.fill(0);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function descriptorIdentity(descriptor, keyId) {
|
|
519
|
+
switch (descriptor.wrap) {
|
|
520
|
+
case "dpapi":
|
|
521
|
+
return `${keyId}|dpapi|${descriptor.blob ?? ""}`;
|
|
522
|
+
case "passphrase":
|
|
523
|
+
return `${keyId}|passphrase|${descriptor.kdf?.n ?? ""}|${descriptor.kdf?.r ?? ""}|${descriptor.kdf?.p ?? ""}|${descriptor.kdf?.salt ?? ""}`;
|
|
524
|
+
case "keychain":
|
|
525
|
+
case "libsecret":
|
|
526
|
+
return `${keyId}|${descriptor.wrap}`;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
function cacheKek(path, identity, kek) {
|
|
530
|
+
if (kek.length !== 32) {
|
|
531
|
+
kek.fill(0);
|
|
532
|
+
throw new KeystoreUnlockError();
|
|
533
|
+
}
|
|
534
|
+
clearUnlocked();
|
|
535
|
+
unlockFailures.delete(unlockFailureKey(path, identity));
|
|
536
|
+
unlocked = { path, descriptor: identity, kek };
|
|
537
|
+
return kek;
|
|
538
|
+
}
|
|
539
|
+
function recoverStoreKek(store, path, opts) {
|
|
540
|
+
const keyId = keyringItemId(store.itemId);
|
|
541
|
+
const identity = descriptorIdentity(store.kek, keyId);
|
|
542
|
+
pruneUnlockFailures(path, identity);
|
|
543
|
+
if (unlocked?.path === path && unlocked.descriptor === identity) {
|
|
544
|
+
return { kek: unlocked.kek, identity, cached: true };
|
|
545
|
+
}
|
|
546
|
+
const failureKey = unlockFailureKey(path, identity);
|
|
547
|
+
const failure = unlockFailures.get(failureKey);
|
|
548
|
+
if (failure !== undefined) {
|
|
549
|
+
const now = memoTime(opts);
|
|
550
|
+
if (now < failure.failedAt || now - failure.failedAt < UNLOCK_RETRY_MS) {
|
|
551
|
+
throw new KeystoreUnlockError();
|
|
552
|
+
}
|
|
553
|
+
unlockFailures.delete(failureKey);
|
|
554
|
+
}
|
|
555
|
+
// A changed path or wrapper identifies a different custody cell. Do not retain the old KEK if
|
|
556
|
+
// recovery of the replacement fails.
|
|
557
|
+
clearUnlocked();
|
|
558
|
+
try {
|
|
559
|
+
const kek = unwrapKek(store.kek, { ...opts, keyId });
|
|
560
|
+
if (kek.length !== 32) {
|
|
561
|
+
kek.fill(0);
|
|
562
|
+
throw new KeystoreUnlockError();
|
|
563
|
+
}
|
|
564
|
+
return { kek, identity, cached: false };
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
// Lazy request-path custody applies a cooldown to sanitized failures. Later resolutions avoid
|
|
568
|
+
// respawning the OS keyring until the bounded retry window expires.
|
|
569
|
+
rememberUnlockFailure(path, identity, "locked", opts);
|
|
570
|
+
throw new KeystoreUnlockError();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
function discardRecoveredKek(recovered) {
|
|
574
|
+
if (recovered.cached)
|
|
575
|
+
clearUnlocked();
|
|
576
|
+
else
|
|
577
|
+
recovered.kek.fill(0);
|
|
578
|
+
}
|
|
579
|
+
function recoverVerifiedStoreKek(store, path, opts) {
|
|
580
|
+
const recovered = recoverStoreKek(store, path, opts);
|
|
581
|
+
if (!validKekCheck(recovered.kek, store.kekCheck)) {
|
|
582
|
+
discardRecoveredKek(recovered);
|
|
583
|
+
rememberUnlockFailure(path, recovered.identity, "locked", opts);
|
|
584
|
+
throw new KeystoreUnlockError();
|
|
585
|
+
}
|
|
586
|
+
unlockFailures.delete(unlockFailureKey(path, recovered.identity));
|
|
587
|
+
return recovered;
|
|
588
|
+
}
|
|
589
|
+
function cryptographicallyUnreadableCount(store, kek) {
|
|
590
|
+
return store.entries.filter((entry) => !entryAuthenticates(entry, kek, store.fpSalt)).length;
|
|
591
|
+
}
|
|
592
|
+
function unlockStoreForWrite(store, path, opts) {
|
|
593
|
+
const recovered = recoverVerifiedStoreKek(store, path, opts);
|
|
594
|
+
const unreadableRows = cryptographicallyUnreadableCount(store, recovered.kek);
|
|
595
|
+
if (unreadableRows > 0) {
|
|
596
|
+
discardRecoveredKek(recovered);
|
|
597
|
+
throw new KeystoreMutationRefusedError("degraded", unreadableRows);
|
|
598
|
+
}
|
|
599
|
+
if (recovered.cached)
|
|
600
|
+
return recovered.kek;
|
|
601
|
+
return cacheKek(path, recovered.identity, recovered.kek);
|
|
602
|
+
}
|
|
603
|
+
function refuseCryptographicDegradationWhenUnlockable(store, path, opts) {
|
|
604
|
+
let recovered;
|
|
605
|
+
try {
|
|
606
|
+
recovered = recoverVerifiedStoreKek(store, path, opts);
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// Lifecycle cleanup must remain possible when custody cannot be unwrapped.
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
const unreadableRows = cryptographicallyUnreadableCount(store, recovered.kek);
|
|
614
|
+
if (unreadableRows > 0) {
|
|
615
|
+
throw new KeystoreMutationRefusedError("degraded", unreadableRows);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
finally {
|
|
619
|
+
if (!recovered.cached)
|
|
620
|
+
recovered.kek.fill(0);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
function createStore(path, opts) {
|
|
624
|
+
const fpSalt = randomBytes(FINGERPRINT_SALT_LENGTH).toString("base64");
|
|
625
|
+
const itemId = randomBytes(ITEM_ID_LENGTH).toString("hex");
|
|
626
|
+
const keyId = keyringItemId(itemId);
|
|
627
|
+
const created = createKek({ ...opts, keyId });
|
|
628
|
+
const kek = cacheKek(path, descriptorIdentity(created.descriptor, keyId), created.kek);
|
|
629
|
+
return {
|
|
630
|
+
store: {
|
|
631
|
+
version: STORE_VERSION,
|
|
632
|
+
kek: created.descriptor,
|
|
633
|
+
fpSalt,
|
|
634
|
+
itemId,
|
|
635
|
+
kekCheck: calculateKekCheck(kek),
|
|
636
|
+
entries: [],
|
|
637
|
+
},
|
|
638
|
+
kek,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
function hardeningPlatform(opts) {
|
|
642
|
+
return opts.acl?.platform ?? process.platform;
|
|
643
|
+
}
|
|
644
|
+
function hardenDirectory(path, opts) {
|
|
645
|
+
if (hardeningPlatform(opts) === "win32") {
|
|
646
|
+
restrictSecretDirectoryOnWindowsSync(path, opts.acl);
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
chmodSync(path, DIRECTORY_MODE);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
function hardenFile(path, opts) {
|
|
653
|
+
if (hardeningPlatform(opts) === "win32") {
|
|
654
|
+
restrictSecretFileOnWindowsSync(path, opts.acl);
|
|
655
|
+
}
|
|
656
|
+
else {
|
|
657
|
+
chmodSync(path, FILE_MODE);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
function persistStore(path, store, opts) {
|
|
661
|
+
const directory = dirname(path);
|
|
662
|
+
const temporaryPath = join(directory, `.${basename(path)}.${process.pid}.${randomBytes(12).toString("hex")}.tmp`);
|
|
663
|
+
let descriptor;
|
|
664
|
+
try {
|
|
665
|
+
const createdDirectory = mkdirSync(directory, { recursive: true, mode: DIRECTORY_MODE });
|
|
666
|
+
if (createdDirectory !== undefined)
|
|
667
|
+
hardenDirectory(directory, opts);
|
|
668
|
+
descriptor = openSync(temporaryPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, FILE_MODE);
|
|
669
|
+
writeFileSync(descriptor, `${JSON.stringify(store, null, 2)}\n`, "utf8");
|
|
670
|
+
fsyncSync(descriptor);
|
|
671
|
+
closeSync(descriptor);
|
|
672
|
+
descriptor = undefined;
|
|
673
|
+
hardenFile(temporaryPath, opts);
|
|
674
|
+
renameSync(temporaryPath, path);
|
|
675
|
+
invalidateStoreLoad(path, opts);
|
|
676
|
+
hardenFile(path, opts);
|
|
677
|
+
}
|
|
678
|
+
catch (error) {
|
|
679
|
+
throw new KeystoreWriteError(errorCode(error));
|
|
680
|
+
}
|
|
681
|
+
finally {
|
|
682
|
+
if (descriptor !== undefined) {
|
|
683
|
+
try {
|
|
684
|
+
closeSync(descriptor);
|
|
685
|
+
}
|
|
686
|
+
catch { /* preserve the normalized write failure */ }
|
|
687
|
+
}
|
|
688
|
+
try {
|
|
689
|
+
unlinkSync(temporaryPath);
|
|
690
|
+
}
|
|
691
|
+
catch { /* renamed successfully or best-effort cleanup */ }
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
function descriptorOf(entry) {
|
|
695
|
+
return {
|
|
696
|
+
id: entry.id,
|
|
697
|
+
provider: entry.provider,
|
|
698
|
+
envName: entry.envName,
|
|
699
|
+
fingerprint: entry.fingerprint,
|
|
700
|
+
addedAt: entry.addedAt,
|
|
701
|
+
rotatedAt: entry.rotatedAt,
|
|
702
|
+
expiresAt: entry.expiresAt,
|
|
703
|
+
revokedAt: entry.revokedAt,
|
|
704
|
+
disabled: entry.disabled,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
function cloneStoreForMutation(store) {
|
|
708
|
+
return {
|
|
709
|
+
...store,
|
|
710
|
+
entries: store.entries.map((entry) => ({ ...entry })),
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
function refuseMutation(loaded) {
|
|
714
|
+
const status = loaded.status === "degraded" ? "degraded" : "unreadable";
|
|
715
|
+
const errno = loaded.status === "unreadable" ? loaded.errno : undefined;
|
|
716
|
+
throw new KeystoreMutationRefusedError(status, loaded.droppedCount, errno);
|
|
717
|
+
}
|
|
718
|
+
function requireStoreForMutation(path, opts) {
|
|
719
|
+
const loaded = loadStore(path, opts);
|
|
720
|
+
if (loaded.status === "fresh")
|
|
721
|
+
throw new KeystoreEntryNotFoundError();
|
|
722
|
+
if (loaded.status === "unreadable" || loaded.status === "degraded")
|
|
723
|
+
refuseMutation(loaded);
|
|
724
|
+
return cloneStoreForMutation(loaded.store);
|
|
725
|
+
}
|
|
726
|
+
function findEntry(store, entryId) {
|
|
727
|
+
const entry = store.entries.find((candidate) => candidate.id === entryId);
|
|
728
|
+
if (entry === undefined)
|
|
729
|
+
throw new KeystoreEntryNotFoundError();
|
|
730
|
+
return entry;
|
|
731
|
+
}
|
|
732
|
+
export function lookupByEnvName(envName, optsOrProvider = {}, providerOrOpts) {
|
|
733
|
+
const opts = typeof optsOrProvider === "string"
|
|
734
|
+
? (typeof providerOrOpts === "object" && providerOrOpts !== null ? providerOrOpts : {})
|
|
735
|
+
: optsOrProvider;
|
|
736
|
+
if (!ENV_NAME_PATTERN.test(envName))
|
|
737
|
+
return null;
|
|
738
|
+
const found = lookupByEnvNames([envName], opts);
|
|
739
|
+
if (found === null)
|
|
740
|
+
return null;
|
|
741
|
+
return { value: found.value, entryId: found.entryId, provider: found.provider };
|
|
742
|
+
}
|
|
743
|
+
export function lookupByEnvNames(envNames, opts = {}) {
|
|
744
|
+
const candidates = envNames.filter((envName) => ENV_NAME_PATTERN.test(envName));
|
|
745
|
+
if (candidates.length === 0)
|
|
746
|
+
return null;
|
|
747
|
+
let recovered = null;
|
|
748
|
+
let retained = false;
|
|
749
|
+
try {
|
|
750
|
+
const path = resolveKeystorePath(opts);
|
|
751
|
+
const loaded = loadStore(path, opts);
|
|
752
|
+
if (loaded.status === "fresh" || loaded.status === "unreadable")
|
|
753
|
+
return null;
|
|
754
|
+
const store = loaded.store;
|
|
755
|
+
const now = opts.now ?? Date.now();
|
|
756
|
+
if (!validTimestamp(now))
|
|
757
|
+
return null;
|
|
758
|
+
for (const envName of candidates) {
|
|
759
|
+
for (const entry of store.entries) {
|
|
760
|
+
if (entry.envName !== envName)
|
|
761
|
+
continue;
|
|
762
|
+
if (entry.disabled || entry.revokedAt !== null)
|
|
763
|
+
continue;
|
|
764
|
+
if (entry.expiresAt !== null && entry.expiresAt <= now)
|
|
765
|
+
continue;
|
|
766
|
+
recovered ??= recoverVerifiedStoreKek(store, path, opts);
|
|
767
|
+
if (!recovered.cached && !retained) {
|
|
768
|
+
cacheKek(path, recovered.identity, recovered.kek);
|
|
769
|
+
retained = true;
|
|
770
|
+
}
|
|
771
|
+
const value = decryptEntryValue(entry, recovered.kek, store.fpSalt);
|
|
772
|
+
if (value !== null && keyIsPresent(value)) {
|
|
773
|
+
return {
|
|
774
|
+
envName,
|
|
775
|
+
value,
|
|
776
|
+
entryId: entry.id,
|
|
777
|
+
provider: entry.provider,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
// Resolver reads are fail-closed: custody failure degrades the provider, never process boot.
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
finally {
|
|
789
|
+
if (recovered !== null && !recovered.cached && !retained)
|
|
790
|
+
recovered.kek.fill(0);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
export function listEntries(opts = {}) {
|
|
794
|
+
const path = resolveKeystorePath(opts);
|
|
795
|
+
const loaded = loadStore(path, opts);
|
|
796
|
+
if (loaded.status === "unreadable")
|
|
797
|
+
throw new KeystoreReadError(loaded.errno);
|
|
798
|
+
if (loaded.status === "fresh")
|
|
799
|
+
return [];
|
|
800
|
+
return loaded.store.entries.map(descriptorOf);
|
|
801
|
+
}
|
|
802
|
+
/** Report resolver-readable custody state without throwing or exposing key material. */
|
|
803
|
+
export function keystoreStatus(opts = {}) {
|
|
804
|
+
try {
|
|
805
|
+
const path = resolveKeystorePath(opts);
|
|
806
|
+
const loaded = loadStore(path, opts);
|
|
807
|
+
if (loaded.status === "fresh") {
|
|
808
|
+
return { status: "absent", undecryptableCount: 0, droppedCount: 0 };
|
|
809
|
+
}
|
|
810
|
+
if (loaded.status === "unreadable") {
|
|
811
|
+
return {
|
|
812
|
+
status: "unreadable",
|
|
813
|
+
undecryptableCount: 0,
|
|
814
|
+
droppedCount: loaded.droppedCount,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
const store = loaded.store;
|
|
818
|
+
let recovered = null;
|
|
819
|
+
let retained = false;
|
|
820
|
+
try {
|
|
821
|
+
recovered = recoverVerifiedStoreKek(store, path, opts);
|
|
822
|
+
if (!recovered.cached) {
|
|
823
|
+
cacheKek(path, recovered.identity, recovered.kek);
|
|
824
|
+
retained = true;
|
|
825
|
+
}
|
|
826
|
+
const cryptographicallyUnreadable = cryptographicallyUnreadableCount(store, recovered.kek);
|
|
827
|
+
const droppedCount = loaded.droppedCount + cryptographicallyUnreadable;
|
|
828
|
+
return {
|
|
829
|
+
status: droppedCount > 0 ? "degraded" : "ok",
|
|
830
|
+
undecryptableCount: cryptographicallyUnreadable,
|
|
831
|
+
droppedCount,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
catch {
|
|
835
|
+
return {
|
|
836
|
+
status: "locked",
|
|
837
|
+
undecryptableCount: 0,
|
|
838
|
+
droppedCount: loaded.droppedCount,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
finally {
|
|
842
|
+
if (recovered !== null && !recovered.cached && !retained)
|
|
843
|
+
recovered.kek.fill(0);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
return { status: "unreadable", undecryptableCount: 0, droppedCount: 0 };
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
export function addEntry(input, opts = {}) {
|
|
851
|
+
validateEntryIdentity(input.id, input.provider, input.envName);
|
|
852
|
+
validateValue(input.value);
|
|
853
|
+
validateExpiresAt(input.expiresAt);
|
|
854
|
+
const now = writeTime(opts);
|
|
855
|
+
const path = resolveKeystorePath(opts);
|
|
856
|
+
const loaded = loadStore(path, opts);
|
|
857
|
+
if (loaded.status === "unreadable" || loaded.status === "degraded")
|
|
858
|
+
refuseMutation(loaded);
|
|
859
|
+
const existing = loaded.status === "fresh" ? null : cloneStoreForMutation(loaded.store);
|
|
860
|
+
if (existing?.entries.some((entry) => entry.id === input.id || entry.envName === input.envName)) {
|
|
861
|
+
throw new KeystoreEntryExistsError();
|
|
862
|
+
}
|
|
863
|
+
const initialized = existing === null ? createStore(path, opts) : {
|
|
864
|
+
store: existing,
|
|
865
|
+
kek: unlockStoreForWrite(existing, path, opts),
|
|
866
|
+
};
|
|
867
|
+
const entry = {
|
|
868
|
+
id: input.id,
|
|
869
|
+
provider: input.provider,
|
|
870
|
+
envName: input.envName,
|
|
871
|
+
...encryptEntryValue(input.value, input.provider, input.id, input.envName, initialized.kek, initialized.store.fpSalt),
|
|
872
|
+
addedAt: now,
|
|
873
|
+
rotatedAt: null,
|
|
874
|
+
expiresAt: input.expiresAt ?? null,
|
|
875
|
+
revokedAt: null,
|
|
876
|
+
disabled: false,
|
|
877
|
+
};
|
|
878
|
+
initialized.store.entries.push(entry);
|
|
879
|
+
persistStore(path, initialized.store, opts);
|
|
880
|
+
return descriptorOf(entry);
|
|
881
|
+
}
|
|
882
|
+
/** Rotate installs a live replacement key; it deliberately un-revokes a revoked entry. */
|
|
883
|
+
export function rotateEntry(entryId, value, opts = {}) {
|
|
884
|
+
if (parseCredentialId(entryId) === null)
|
|
885
|
+
throw new KeystoreValidationError("id");
|
|
886
|
+
validateValue(value);
|
|
887
|
+
validateExpiresAt(opts.expiresAt);
|
|
888
|
+
const now = writeTime(opts);
|
|
889
|
+
const path = resolveKeystorePath(opts);
|
|
890
|
+
const store = requireStoreForMutation(path, opts);
|
|
891
|
+
const entry = findEntry(store, entryId);
|
|
892
|
+
Object.assign(entry, encryptEntryValue(value, entry.provider, entry.id, entry.envName, unlockStoreForWrite(store, path, opts), store.fpSalt));
|
|
893
|
+
entry.rotatedAt = now;
|
|
894
|
+
entry.revokedAt = null;
|
|
895
|
+
if (opts.expiresAt !== undefined)
|
|
896
|
+
entry.expiresAt = opts.expiresAt;
|
|
897
|
+
persistStore(path, store, opts);
|
|
898
|
+
return descriptorOf(entry);
|
|
899
|
+
}
|
|
900
|
+
export function revokeEntry(entryId, opts = {}) {
|
|
901
|
+
if (parseCredentialId(entryId) === null)
|
|
902
|
+
throw new KeystoreValidationError("id");
|
|
903
|
+
const now = writeTime(opts);
|
|
904
|
+
const path = resolveKeystorePath(opts);
|
|
905
|
+
const store = requireStoreForMutation(path, opts);
|
|
906
|
+
const entry = findEntry(store, entryId);
|
|
907
|
+
refuseCryptographicDegradationWhenUnlockable(store, path, opts);
|
|
908
|
+
// Lifecycle metadata stays outside v1 AAD so cleanup remains possible when KEK unwrap fails.
|
|
909
|
+
// When custody is available we authenticate every row before rewriting; the deliberate trade
|
|
910
|
+
// on an unwrappable store is that a file writer can change descriptor/lifecycle metadata and
|
|
911
|
+
// availability, but cannot retarget authenticated provider/id/envName identity or recover
|
|
912
|
+
// plaintext. addedAt, rotatedAt, expiresAt, revokedAt, and disabled remain outside AAD;
|
|
913
|
+
// envName is inside it.
|
|
914
|
+
entry.revokedAt = now;
|
|
915
|
+
persistStore(path, store, opts);
|
|
916
|
+
return descriptorOf(entry);
|
|
917
|
+
}
|
|
918
|
+
export function removeEntry(entryId, opts = {}) {
|
|
919
|
+
if (parseCredentialId(entryId) === null)
|
|
920
|
+
throw new KeystoreValidationError("id");
|
|
921
|
+
const path = resolveKeystorePath(opts);
|
|
922
|
+
const store = requireStoreForMutation(path, opts);
|
|
923
|
+
const index = store.entries.findIndex((entry) => entry.id === entryId);
|
|
924
|
+
if (index < 0)
|
|
925
|
+
throw new KeystoreEntryNotFoundError();
|
|
926
|
+
refuseCryptographicDegradationWhenUnlockable(store, path, opts);
|
|
927
|
+
store.entries.splice(index, 1);
|
|
928
|
+
persistStore(path, store, opts);
|
|
929
|
+
return true;
|
|
930
|
+
}
|
|
931
|
+
export function setDisabled(entryId, disabled, opts = {}) {
|
|
932
|
+
if (parseCredentialId(entryId) === null)
|
|
933
|
+
throw new KeystoreValidationError("id");
|
|
934
|
+
if (typeof disabled !== "boolean")
|
|
935
|
+
throw new KeystoreValidationError("value");
|
|
936
|
+
const path = resolveKeystorePath(opts);
|
|
937
|
+
const store = requireStoreForMutation(path, opts);
|
|
938
|
+
const entry = findEntry(store, entryId);
|
|
939
|
+
refuseCryptographicDegradationWhenUnlockable(store, path, opts);
|
|
940
|
+
entry.disabled = disabled;
|
|
941
|
+
persistStore(path, store, opts);
|
|
942
|
+
return descriptorOf(entry);
|
|
943
|
+
}
|
|
944
|
+
/** Read only the wrapping mode; passphrase bytes and KEK recovery are deliberately not involved. */
|
|
945
|
+
export function keystoreWrapMode(opts = {}) {
|
|
946
|
+
const path = resolveKeystorePath(opts);
|
|
947
|
+
const loaded = loadStore(path, opts);
|
|
948
|
+
if (loaded.status === "fresh")
|
|
949
|
+
return null;
|
|
950
|
+
if (loaded.status === "unreadable")
|
|
951
|
+
throw new KeystoreReadError(loaded.errno);
|
|
952
|
+
return loaded.store.kek.wrap;
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Explicitly verify a passphrase store against its persisted KEK verifier.
|
|
956
|
+
* Other wrap modes are a metadata-only no-op.
|
|
957
|
+
*/
|
|
958
|
+
export function verifyKeystoreUnlock(opts = {}) {
|
|
959
|
+
const path = resolveKeystorePath(opts);
|
|
960
|
+
const loaded = loadStore(path, opts);
|
|
961
|
+
if (loaded.status === "fresh")
|
|
962
|
+
return null;
|
|
963
|
+
if (loaded.status === "unreadable")
|
|
964
|
+
throw new KeystoreReadError(loaded.errno);
|
|
965
|
+
const mode = loaded.store.kek.wrap;
|
|
966
|
+
if (mode !== "passphrase")
|
|
967
|
+
return mode;
|
|
968
|
+
// An explicit operator retry starts a fresh verifier attempt. Otherwise the resolver's
|
|
969
|
+
// bounded wrong-passphrase cooldown could reject a corrected passphrase in this process.
|
|
970
|
+
lock({ path });
|
|
971
|
+
const recovered = recoverVerifiedStoreKek(loaded.store, path, opts);
|
|
972
|
+
try {
|
|
973
|
+
return mode;
|
|
974
|
+
}
|
|
975
|
+
finally {
|
|
976
|
+
if (!recovered.cached)
|
|
977
|
+
recovered.kek.fill(0);
|
|
978
|
+
lock({ path });
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
function parseExportEnvelope(text) {
|
|
982
|
+
let parsed;
|
|
983
|
+
try {
|
|
984
|
+
parsed = JSON.parse(text);
|
|
985
|
+
}
|
|
986
|
+
catch {
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
if (!isObject(parsed) || !hasExactKeys(parsed, [
|
|
990
|
+
"version", "source", "kdf", "cipher", "iv", "tag", "ciphertext",
|
|
991
|
+
]))
|
|
992
|
+
return null;
|
|
993
|
+
if (parsed.version !== 1 ||
|
|
994
|
+
parsed.source !== EXPORT_SOURCE ||
|
|
995
|
+
parsed.cipher !== "aes-256-gcm" ||
|
|
996
|
+
!isObject(parsed.kdf) ||
|
|
997
|
+
!hasExactKeys(parsed.kdf, ["n", "r", "p", "salt"]) ||
|
|
998
|
+
parsed.kdf.n !== 16_384 ||
|
|
999
|
+
parsed.kdf.r !== 8 ||
|
|
1000
|
+
parsed.kdf.p !== 1 ||
|
|
1001
|
+
typeof parsed.kdf.salt !== "string" ||
|
|
1002
|
+
decodeBase64(parsed.kdf.salt, 32) === null ||
|
|
1003
|
+
typeof parsed.iv !== "string" ||
|
|
1004
|
+
decodeBase64(parsed.iv, GCM_IV_LENGTH) === null ||
|
|
1005
|
+
typeof parsed.tag !== "string" ||
|
|
1006
|
+
decodeBase64(parsed.tag, GCM_TAG_LENGTH) === null ||
|
|
1007
|
+
typeof parsed.ciphertext !== "string" ||
|
|
1008
|
+
(decodeBase64(parsed.ciphertext)?.length ?? 0) === 0)
|
|
1009
|
+
return null;
|
|
1010
|
+
return parsed;
|
|
1011
|
+
}
|
|
1012
|
+
function validExportEntry(value) {
|
|
1013
|
+
if (!isObject(value) || !hasExactKeys(value, [
|
|
1014
|
+
"id", "provider", "envName", "fingerprint", "addedAt", "rotatedAt", "expiresAt",
|
|
1015
|
+
"revokedAt", "disabled", "value",
|
|
1016
|
+
]))
|
|
1017
|
+
return false;
|
|
1018
|
+
const parsedId = typeof value.id === "string" ? parseCredentialId(value.id) : null;
|
|
1019
|
+
return parsedId !== null &&
|
|
1020
|
+
typeof value.provider === "string" && PROVIDER_PATTERN.test(value.provider) &&
|
|
1021
|
+
parsedId.provider === value.provider &&
|
|
1022
|
+
typeof value.envName === "string" && ENV_NAME_PATTERN.test(value.envName) &&
|
|
1023
|
+
typeof value.fingerprint === "string" && FINGERPRINT_PATTERN.test(value.fingerprint) &&
|
|
1024
|
+
validTimestamp(value.addedAt) &&
|
|
1025
|
+
validNullableTimestamp(value.rotatedAt) &&
|
|
1026
|
+
validNullableTimestamp(value.expiresAt) &&
|
|
1027
|
+
validNullableTimestamp(value.revokedAt) &&
|
|
1028
|
+
typeof value.disabled === "boolean" &&
|
|
1029
|
+
typeof value.value === "string" && keyIsPresent(value.value);
|
|
1030
|
+
}
|
|
1031
|
+
function parseExportPayload(value) {
|
|
1032
|
+
if (!isObject(value) || !hasExactKeys(value, ["version", "entries"]) ||
|
|
1033
|
+
value.version !== 1 || !Array.isArray(value.entries))
|
|
1034
|
+
return null;
|
|
1035
|
+
const entries = [];
|
|
1036
|
+
const ids = new Set();
|
|
1037
|
+
const envNames = new Set();
|
|
1038
|
+
for (const candidate of value.entries) {
|
|
1039
|
+
if (!validExportEntry(candidate) || ids.has(candidate.id) || envNames.has(candidate.envName)) {
|
|
1040
|
+
return null;
|
|
1041
|
+
}
|
|
1042
|
+
ids.add(candidate.id);
|
|
1043
|
+
envNames.add(candidate.envName);
|
|
1044
|
+
entries.push(candidate);
|
|
1045
|
+
}
|
|
1046
|
+
return { version: 1, entries };
|
|
1047
|
+
}
|
|
1048
|
+
/** True only for this CLI's closed, versioned encrypted-export envelope. */
|
|
1049
|
+
export function isEncryptedKeystoreExport(text) {
|
|
1050
|
+
return parseExportEnvelope(text) !== null;
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Decrypt every usable row and immediately re-encrypt the whole logical payload under an
|
|
1054
|
+
* export-time passphrase. A plaintext export API deliberately does not exist.
|
|
1055
|
+
*/
|
|
1056
|
+
export function createEncryptedKeystoreExport(exportPassphrase, opts = {}) {
|
|
1057
|
+
const path = resolveKeystorePath(opts);
|
|
1058
|
+
const loaded = loadStore(path, opts);
|
|
1059
|
+
if (loaded.status === "fresh")
|
|
1060
|
+
throw new KeystoreReadError("ENOENT");
|
|
1061
|
+
if (loaded.status === "unreadable")
|
|
1062
|
+
throw new KeystoreReadError(loaded.errno);
|
|
1063
|
+
if (loaded.status === "degraded") {
|
|
1064
|
+
throw new KeystoreMutationRefusedError("degraded", loaded.droppedCount);
|
|
1065
|
+
}
|
|
1066
|
+
// Derive the export key before decrypting any row, so KDF failure cannot strand an owned
|
|
1067
|
+
// plaintext payload buffer. Every Buffer below enters the outer cleanup scope immediately.
|
|
1068
|
+
const created = createPassphraseKek(exportPassphrase);
|
|
1069
|
+
let iv;
|
|
1070
|
+
let plaintext;
|
|
1071
|
+
let ciphertext;
|
|
1072
|
+
let tag;
|
|
1073
|
+
try {
|
|
1074
|
+
iv = randomBytes(GCM_IV_LENGTH);
|
|
1075
|
+
const recovered = recoverVerifiedStoreKek(loaded.store, path, opts);
|
|
1076
|
+
const entries = [];
|
|
1077
|
+
try {
|
|
1078
|
+
for (const entry of loaded.store.entries) {
|
|
1079
|
+
const value = decryptEntryValue(entry, recovered.kek, loaded.store.fpSalt);
|
|
1080
|
+
if (value === null)
|
|
1081
|
+
throw new KeystoreMutationRefusedError("degraded", 1);
|
|
1082
|
+
entries.push({ ...descriptorOf(entry), value });
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
finally {
|
|
1086
|
+
if (!recovered.cached)
|
|
1087
|
+
recovered.kek.fill(0);
|
|
1088
|
+
}
|
|
1089
|
+
const payload = { version: 1, entries };
|
|
1090
|
+
plaintext = Buffer.from(JSON.stringify(payload), "utf8");
|
|
1091
|
+
const cipher = createCipheriv("aes-256-gcm", created.kek, iv);
|
|
1092
|
+
cipher.setAAD(EXPORT_AAD);
|
|
1093
|
+
ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
1094
|
+
tag = cipher.getAuthTag();
|
|
1095
|
+
const kdf = created.descriptor.kdf;
|
|
1096
|
+
if (created.descriptor.wrap !== "passphrase" || kdf === undefined) {
|
|
1097
|
+
throw new KeystoreExportError();
|
|
1098
|
+
}
|
|
1099
|
+
const envelope = {
|
|
1100
|
+
version: 1,
|
|
1101
|
+
source: EXPORT_SOURCE,
|
|
1102
|
+
kdf,
|
|
1103
|
+
cipher: "aes-256-gcm",
|
|
1104
|
+
iv: iv.toString("base64"),
|
|
1105
|
+
tag: tag.toString("base64"),
|
|
1106
|
+
ciphertext: ciphertext.toString("base64"),
|
|
1107
|
+
};
|
|
1108
|
+
return `${JSON.stringify(envelope, null, 2)}\n`;
|
|
1109
|
+
}
|
|
1110
|
+
finally {
|
|
1111
|
+
tag?.fill(0);
|
|
1112
|
+
ciphertext?.fill(0);
|
|
1113
|
+
plaintext?.fill(0);
|
|
1114
|
+
iv?.fill(0);
|
|
1115
|
+
created.kek.fill(0);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
/** Decrypt an encrypted export for immediate insertion into another keystore. */
|
|
1119
|
+
export function decryptEncryptedKeystoreExport(text, passphrase) {
|
|
1120
|
+
const envelope = parseExportEnvelope(text);
|
|
1121
|
+
if (envelope === null)
|
|
1122
|
+
throw new KeystoreExportError();
|
|
1123
|
+
let key;
|
|
1124
|
+
let iv;
|
|
1125
|
+
let tag;
|
|
1126
|
+
let ciphertext;
|
|
1127
|
+
let plaintext;
|
|
1128
|
+
try {
|
|
1129
|
+
key = derivePassphraseKek(passphrase, envelope.kdf);
|
|
1130
|
+
iv = Buffer.from(envelope.iv, "base64");
|
|
1131
|
+
tag = Buffer.from(envelope.tag, "base64");
|
|
1132
|
+
ciphertext = Buffer.from(envelope.ciphertext, "base64");
|
|
1133
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
1134
|
+
decipher.setAAD(EXPORT_AAD);
|
|
1135
|
+
decipher.setAuthTag(tag);
|
|
1136
|
+
plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
1137
|
+
const parsed = parseExportPayload(JSON.parse(plaintext.toString("utf8")));
|
|
1138
|
+
if (parsed === null)
|
|
1139
|
+
throw new KeystoreExportError();
|
|
1140
|
+
return parsed.entries;
|
|
1141
|
+
}
|
|
1142
|
+
catch (error) {
|
|
1143
|
+
if (error instanceof KeystoreExportError)
|
|
1144
|
+
throw error;
|
|
1145
|
+
throw new KeystoreExportError();
|
|
1146
|
+
}
|
|
1147
|
+
finally {
|
|
1148
|
+
key?.fill(0);
|
|
1149
|
+
iv?.fill(0);
|
|
1150
|
+
tag?.fill(0);
|
|
1151
|
+
ciphertext?.fill(0);
|
|
1152
|
+
plaintext?.fill(0);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
/** Restore one authenticated export row, re-encrypting under the destination store's KEK. */
|
|
1156
|
+
export function restoreEntryFromExport(input, opts = {}) {
|
|
1157
|
+
if (!validExportEntry(input))
|
|
1158
|
+
throw new KeystoreExportError();
|
|
1159
|
+
const path = resolveKeystorePath(opts);
|
|
1160
|
+
const loaded = loadStore(path, opts);
|
|
1161
|
+
if (loaded.status === "unreadable" || loaded.status === "degraded")
|
|
1162
|
+
refuseMutation(loaded);
|
|
1163
|
+
const existing = loaded.status === "fresh" ? null : cloneStoreForMutation(loaded.store);
|
|
1164
|
+
if (existing?.entries.some((entry) => entry.id === input.id || entry.envName === input.envName)) {
|
|
1165
|
+
throw new KeystoreEntryExistsError();
|
|
1166
|
+
}
|
|
1167
|
+
const initialized = existing === null
|
|
1168
|
+
? createStore(path, opts)
|
|
1169
|
+
: { store: existing, kek: unlockStoreForWrite(existing, path, opts) };
|
|
1170
|
+
const entry = {
|
|
1171
|
+
id: input.id,
|
|
1172
|
+
provider: input.provider,
|
|
1173
|
+
envName: input.envName,
|
|
1174
|
+
...encryptEntryValue(input.value, input.provider, input.id, input.envName, initialized.kek, initialized.store.fpSalt),
|
|
1175
|
+
addedAt: input.addedAt,
|
|
1176
|
+
rotatedAt: input.rotatedAt,
|
|
1177
|
+
expiresAt: input.expiresAt,
|
|
1178
|
+
revokedAt: input.revokedAt,
|
|
1179
|
+
disabled: input.disabled,
|
|
1180
|
+
};
|
|
1181
|
+
initialized.store.entries.push(entry);
|
|
1182
|
+
persistStore(path, initialized.store, opts);
|
|
1183
|
+
return descriptorOf(entry);
|
|
1184
|
+
}
|
|
1185
|
+
/** Zeroize a process-held KEK and end the matching unlock-failure cooldown epoch. */
|
|
1186
|
+
export function lock(opts = {}) {
|
|
1187
|
+
const path = opts.path === undefined ? undefined : resolveKeystorePath(opts);
|
|
1188
|
+
if (unlocked !== null && (path === undefined || unlocked.path === path))
|
|
1189
|
+
clearUnlocked();
|
|
1190
|
+
for (const [key, failure] of unlockFailures) {
|
|
1191
|
+
if (path === undefined || failure.path === path)
|
|
1192
|
+
unlockFailures.delete(key);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
//# sourceMappingURL=keystore.js.map
|