vaultkeeper 0.6.0 → 0.7.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 +611 -0
- package/dist/index.cjs +2621 -715
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1368 -139
- package/dist/index.d.ts +1368 -139
- package/dist/index.js +2593 -710
- package/dist/index.js.map +1 -1
- package/dist/one-password-worker.js +249 -39
- package/dist/one-password-worker.js.map +1 -1
- package/package.json +27 -6
package/dist/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import * as
|
|
2
|
-
import * as
|
|
1
|
+
import * as fs3 from 'fs/promises';
|
|
2
|
+
import * as path2 from 'path';
|
|
3
3
|
import { join, dirname, resolve } from 'path';
|
|
4
|
-
import * as
|
|
5
|
-
import * as
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import * as crypto2 from 'crypto';
|
|
6
6
|
import { spawn } from 'child_process';
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
|
-
import * as
|
|
8
|
+
import * as fs6 from 'fs';
|
|
9
9
|
import { existsSync, readFileSync } from 'fs';
|
|
10
|
-
import { CompactEncrypt, compactDecrypt } from 'jose';
|
|
10
|
+
import { CompactEncrypt, importSPKI, flattenedVerify, compactDecrypt } from 'jose';
|
|
11
|
+
import { Buffer as Buffer$1 } from 'buffer';
|
|
11
12
|
|
|
12
13
|
// src/errors.ts
|
|
13
14
|
var VaultError = class extends Error {
|
|
@@ -46,6 +47,42 @@ var AuthorizationDeniedError = class extends VaultError {
|
|
|
46
47
|
this.name = "AuthorizationDeniedError";
|
|
47
48
|
}
|
|
48
49
|
};
|
|
50
|
+
var NotCapableError = class extends VaultError {
|
|
51
|
+
/** The `type` identifier of the active backend that lacked the capability. */
|
|
52
|
+
backendType;
|
|
53
|
+
/**
|
|
54
|
+
* The machine-readable capability key that was required but not advertised
|
|
55
|
+
* (e.g. `'presencePerUse'`).
|
|
56
|
+
*/
|
|
57
|
+
capability;
|
|
58
|
+
constructor(message, backendType, capability) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = "NotCapableError";
|
|
61
|
+
this.backendType = backendType;
|
|
62
|
+
this.capability = capability;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
var PresenceDeclinedError = class extends VaultError {
|
|
66
|
+
/** The `type` identifier of the backend that requested the presence action. */
|
|
67
|
+
backendType;
|
|
68
|
+
constructor(message, backendType) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.name = "PresenceDeclinedError";
|
|
71
|
+
this.backendType = backendType;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
var PresenceTimeoutError = class extends VaultError {
|
|
75
|
+
/** The `type` identifier of the backend that requested the presence action. */
|
|
76
|
+
backendType;
|
|
77
|
+
/** How long (in milliseconds) the operation waited for the presence action. */
|
|
78
|
+
timeoutMs;
|
|
79
|
+
constructor(message, backendType, timeoutMs) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.name = "PresenceTimeoutError";
|
|
82
|
+
this.backendType = backendType;
|
|
83
|
+
this.timeoutMs = timeoutMs;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
49
86
|
var BackendUnavailableError = class extends VaultError {
|
|
50
87
|
/**
|
|
51
88
|
* Machine-readable reason code describing why the backend is unavailable
|
|
@@ -139,6 +176,23 @@ var IdentityMismatchError = class extends VaultError {
|
|
|
139
176
|
this.currentHash = currentHash;
|
|
140
177
|
}
|
|
141
178
|
};
|
|
179
|
+
var ExecutableTrustRequiredError = class extends VaultError {
|
|
180
|
+
/**
|
|
181
|
+
* Machine-readable discriminator for why the trust choice was rejected.
|
|
182
|
+
* `'missing-choice'` means neither `executablePath` nor `skipTrust: true`
|
|
183
|
+
* was provided, so no trust decision was expressed. `'conflicting-choice'`
|
|
184
|
+
* means both `executablePath` and `skipTrust: true` were provided, which
|
|
185
|
+
* are mutually exclusive intents. `'legacy-dev-sentinel'` means
|
|
186
|
+
* `executablePath` was the retired literal `'dev'` opt-out sentinel, which is
|
|
187
|
+
* no longer supported and must be replaced with `skipTrust: true`.
|
|
188
|
+
*/
|
|
189
|
+
reason;
|
|
190
|
+
constructor(message, reason) {
|
|
191
|
+
super(message);
|
|
192
|
+
this.name = "ExecutableTrustRequiredError";
|
|
193
|
+
this.reason = reason;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
142
196
|
var ExecError = class extends VaultError {
|
|
143
197
|
/**
|
|
144
198
|
* The command that failed to execute.
|
|
@@ -178,6 +232,139 @@ var InvalidAlgorithmError = class extends VaultError {
|
|
|
178
232
|
this.allowed = allowed;
|
|
179
233
|
}
|
|
180
234
|
};
|
|
235
|
+
var InvalidKeyMaterialError = class extends VaultError {
|
|
236
|
+
constructor(message) {
|
|
237
|
+
super(message);
|
|
238
|
+
this.name = "InvalidKeyMaterialError";
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
var SigningKeyNotFoundError = class extends VaultError {
|
|
242
|
+
/**
|
|
243
|
+
* The signing-key name that was requested (the caller-facing `--name`, not
|
|
244
|
+
* the internal namespaced identifier).
|
|
245
|
+
*/
|
|
246
|
+
keyName;
|
|
247
|
+
constructor(message, keyName) {
|
|
248
|
+
super(message);
|
|
249
|
+
this.name = "SigningKeyNotFoundError";
|
|
250
|
+
this.keyName = keyName;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
var SigningKeyAlreadyExistsError = class extends VaultError {
|
|
254
|
+
/**
|
|
255
|
+
* The signing-key name that already exists (the caller-facing `--name`, not
|
|
256
|
+
* the internal namespaced identifier).
|
|
257
|
+
*/
|
|
258
|
+
keyName;
|
|
259
|
+
constructor(message, keyName) {
|
|
260
|
+
super(message);
|
|
261
|
+
this.name = "SigningKeyAlreadyExistsError";
|
|
262
|
+
this.keyName = keyName;
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
var SigningNotSupportedError = class extends VaultError {
|
|
266
|
+
/** The type identifier of the active backend that cannot sign. */
|
|
267
|
+
backendType;
|
|
268
|
+
/**
|
|
269
|
+
* The **built-in** backend type identifiers known to implement the signing
|
|
270
|
+
* contract (currently just the `file` backend). This is deliberately a
|
|
271
|
+
* static list of built-ins, not a live capability survey: a consumer that
|
|
272
|
+
* registers its own {@link SigningBackend} is not enumerated here, because
|
|
273
|
+
* discovering that would require instantiating every registered backend —
|
|
274
|
+
* a side effect that must not happen on an error path. A caller can still
|
|
275
|
+
* point a user at a working built-in, and custom backends may implement the
|
|
276
|
+
* contract independently.
|
|
277
|
+
*/
|
|
278
|
+
builtInSigningBackends;
|
|
279
|
+
constructor(message, backendType, builtInSigningBackends) {
|
|
280
|
+
super(message);
|
|
281
|
+
this.name = "SigningNotSupportedError";
|
|
282
|
+
this.backendType = backendType;
|
|
283
|
+
this.builtInSigningBackends = builtInSigningBackends;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
var DecryptionError = class extends VaultError {
|
|
287
|
+
/**
|
|
288
|
+
* The path of the encrypted entry that failed to decrypt.
|
|
289
|
+
*/
|
|
290
|
+
path;
|
|
291
|
+
constructor(message, path9) {
|
|
292
|
+
super(message);
|
|
293
|
+
this.name = "DecryptionError";
|
|
294
|
+
this.path = path9;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
var FetchError = class extends VaultError {
|
|
298
|
+
/**
|
|
299
|
+
* The unresolved URL template that fetch failed to request, with
|
|
300
|
+
* `{{secret}}`/`{{secret:name}}` placeholders left intact. The
|
|
301
|
+
* placeholder-resolved URL is deliberately never stored here, so an
|
|
302
|
+
* injected secret is never exposed.
|
|
303
|
+
*/
|
|
304
|
+
url;
|
|
305
|
+
constructor(message, url) {
|
|
306
|
+
super(message);
|
|
307
|
+
this.name = "FetchError";
|
|
308
|
+
this.url = url;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
var ConfigValidationError = class extends VaultError {
|
|
312
|
+
/**
|
|
313
|
+
* The dotted/bracketed path to the offending config field (e.g.
|
|
314
|
+
* `'backends[0].path'`).
|
|
315
|
+
*/
|
|
316
|
+
field;
|
|
317
|
+
/**
|
|
318
|
+
* The path of the config file that failed validation, when the error
|
|
319
|
+
* originated from loading a file on disk (via `loadConfig`) rather than
|
|
320
|
+
* from validating an in-memory value directly. This is `configDir` joined
|
|
321
|
+
* with `config.json` exactly as provided to `loadConfig` — it is not
|
|
322
|
+
* guaranteed to be absolute (`loadConfig` does not resolve a relative
|
|
323
|
+
* `configDir`).
|
|
324
|
+
*/
|
|
325
|
+
configFilePath;
|
|
326
|
+
constructor(message, field, configFilePath) {
|
|
327
|
+
super(message);
|
|
328
|
+
this.name = "ConfigValidationError";
|
|
329
|
+
this.field = field;
|
|
330
|
+
this.configFilePath = configFilePath;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
var UnknownBackendTypeError = class extends ConfigValidationError {
|
|
334
|
+
/** The unregistered backend type named in the config. */
|
|
335
|
+
backendType;
|
|
336
|
+
/**
|
|
337
|
+
* The backend type identifiers that were registered when validation ran —
|
|
338
|
+
* the valid options to offer in remediation.
|
|
339
|
+
*/
|
|
340
|
+
knownTypes;
|
|
341
|
+
constructor(message, field, backendType, knownTypes, configFilePath) {
|
|
342
|
+
super(message, field, configFilePath);
|
|
343
|
+
this.name = "UnknownBackendTypeError";
|
|
344
|
+
this.backendType = backendType;
|
|
345
|
+
this.knownTypes = knownTypes;
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
var ConfigParseError = class extends VaultError {
|
|
349
|
+
/**
|
|
350
|
+
* The path of the config file that failed to parse. This is `configDir`
|
|
351
|
+
* joined with `config.json` exactly as provided to `loadConfig` — it is
|
|
352
|
+
* not guaranteed to be absolute (`loadConfig` does not resolve a relative
|
|
353
|
+
* `configDir`).
|
|
354
|
+
*/
|
|
355
|
+
path;
|
|
356
|
+
/**
|
|
357
|
+
* A human-readable parse location (e.g. `'line 3, column 12'`), when one
|
|
358
|
+
* could be derived from the underlying `SyntaxError`.
|
|
359
|
+
*/
|
|
360
|
+
location;
|
|
361
|
+
constructor(message, path9, location) {
|
|
362
|
+
super(message);
|
|
363
|
+
this.name = "ConfigParseError";
|
|
364
|
+
this.path = path9;
|
|
365
|
+
this.location = location;
|
|
366
|
+
}
|
|
367
|
+
};
|
|
181
368
|
var SetupError = class extends VaultError {
|
|
182
369
|
/**
|
|
183
370
|
* The name of the dependency that caused the setup failure.
|
|
@@ -191,24 +378,73 @@ var SetupError = class extends VaultError {
|
|
|
191
378
|
};
|
|
192
379
|
var FilesystemError = class extends VaultError {
|
|
193
380
|
/**
|
|
194
|
-
* The
|
|
381
|
+
* The path of the file or directory that caused the error, as provided by
|
|
382
|
+
* the caller. Not guaranteed to be absolute — e.g. `loadConfig` throws this
|
|
383
|
+
* with `configDir` joined with `config.json` exactly as given, without
|
|
384
|
+
* resolving a relative `configDir`.
|
|
195
385
|
*/
|
|
196
386
|
path;
|
|
197
387
|
/**
|
|
198
|
-
* The
|
|
199
|
-
*
|
|
388
|
+
* The file operation or access mode that was being attempted when the
|
|
389
|
+
* failure occurred, for example 'read', 'write', 'delete', or 'rwx' for a
|
|
390
|
+
* directory create/access check. Despite the field name, this does not
|
|
391
|
+
* imply the failure was itself a permission problem — it names the
|
|
392
|
+
* attempted operation regardless of the underlying errno, which may be a
|
|
393
|
+
* non-permission code such as ENOSPC or EISDIR.
|
|
200
394
|
*/
|
|
201
395
|
permission;
|
|
202
|
-
|
|
396
|
+
/**
|
|
397
|
+
* The Node.js errno code from the underlying filesystem failure, for
|
|
398
|
+
* example EACCES, EPERM, ENOSPC, or EISDIR. Undefined when the error was
|
|
399
|
+
* constructed without an underlying cause, or when that cause did not
|
|
400
|
+
* expose a string errno code. Prefer this over parsing the message text,
|
|
401
|
+
* which is not a contractual format.
|
|
402
|
+
*/
|
|
403
|
+
code;
|
|
404
|
+
/**
|
|
405
|
+
* @param message - Human-readable description of the failure.
|
|
406
|
+
* @param filePath - The path of the file or directory that caused the error.
|
|
407
|
+
* @param permission - The file operation or access mode being attempted,
|
|
408
|
+
* for example 'read', 'write', 'delete', or 'rwx'. See the `permission`
|
|
409
|
+
* property for why this need not indicate an actual permission problem.
|
|
410
|
+
* @param cause - The underlying error that was caught, if any. Recorded as
|
|
411
|
+
* the standard `Error.cause` and used to populate `code` when it exposes a
|
|
412
|
+
* string errno code.
|
|
413
|
+
*/
|
|
414
|
+
constructor(message, filePath, permission, cause) {
|
|
203
415
|
super(message);
|
|
204
416
|
this.name = "FilesystemError";
|
|
205
417
|
this.path = filePath;
|
|
206
418
|
this.permission = permission;
|
|
419
|
+
this.code = hasErrnoCode(cause) ? cause.code : void 0;
|
|
420
|
+
if (cause !== void 0) {
|
|
421
|
+
Object.defineProperty(this, "cause", {
|
|
422
|
+
value: cause,
|
|
423
|
+
writable: true,
|
|
424
|
+
enumerable: false,
|
|
425
|
+
configurable: true
|
|
426
|
+
});
|
|
427
|
+
}
|
|
207
428
|
}
|
|
208
429
|
};
|
|
430
|
+
function hasErrnoCode(err) {
|
|
431
|
+
return err instanceof Error && "code" in err && typeof err.code === "string";
|
|
432
|
+
}
|
|
433
|
+
function toFilesystemError(err, resourceLabel, filePath, permission) {
|
|
434
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
435
|
+
return new FilesystemError(
|
|
436
|
+
`Failed to ${permission} ${resourceLabel} at ${filePath}: ${detail}`,
|
|
437
|
+
filePath,
|
|
438
|
+
permission,
|
|
439
|
+
err
|
|
440
|
+
);
|
|
441
|
+
}
|
|
209
442
|
var RotationInProgressError = class extends VaultError {
|
|
210
443
|
constructor(message) {
|
|
211
|
-
|
|
444
|
+
const trimmed = message.trim();
|
|
445
|
+
const nextSteps = "Either wait for the current grace period to elapse before rotating again, or run 'vaultkeeper revoke-key' (or call revokeKey()) to invalidate the previous key immediately and clear the grace period.";
|
|
446
|
+
const prefix = trimmed.length === 0 ? "" : `${trimmed}${/[.!?]$/.test(trimmed) ? "" : "."} `;
|
|
447
|
+
super(`${prefix}${nextSteps}`);
|
|
212
448
|
this.name = "RotationInProgressError";
|
|
213
449
|
}
|
|
214
450
|
};
|
|
@@ -229,10 +465,12 @@ var BackendRegistry = class {
|
|
|
229
465
|
* Create a backend instance by type.
|
|
230
466
|
* @param type - Backend type identifier
|
|
231
467
|
* @param config - Optional backend configuration forwarded to the factory
|
|
468
|
+
* @param configDir - Optional resolved config directory forwarded to the
|
|
469
|
+
* factory, so file-based backends can default their storage under it
|
|
232
470
|
* @returns A SecretBackend instance
|
|
233
471
|
* @throws {@link BackendUnavailableError} if the backend type is not registered
|
|
234
472
|
*/
|
|
235
|
-
static create(type, config) {
|
|
473
|
+
static create(type, config, configDir) {
|
|
236
474
|
const factory = this.backends.get(type);
|
|
237
475
|
if (factory === void 0) {
|
|
238
476
|
throw new BackendUnavailableError(
|
|
@@ -241,7 +479,7 @@ var BackendRegistry = class {
|
|
|
241
479
|
Array.from(this.backends.keys())
|
|
242
480
|
);
|
|
243
481
|
}
|
|
244
|
-
return factory(config);
|
|
482
|
+
return factory(config, configDir);
|
|
245
483
|
}
|
|
246
484
|
/**
|
|
247
485
|
* Get all registered backend type identifiers.
|
|
@@ -318,161 +556,643 @@ var BackendRegistry = class {
|
|
|
318
556
|
this.setups.clear();
|
|
319
557
|
}
|
|
320
558
|
};
|
|
321
|
-
var STORAGE_DIR_NAME = path3.join(".vaultkeeper", "file");
|
|
322
|
-
var KEY_FILE = ".key";
|
|
323
559
|
var GCM_IV_BYTES = 12;
|
|
324
560
|
var GCM_KEY_BYTES = 32;
|
|
325
|
-
var
|
|
326
|
-
function
|
|
327
|
-
|
|
561
|
+
var GCM_TAG_LENGTH_BITS = 128;
|
|
562
|
+
function encryptGcm(key, plaintext) {
|
|
563
|
+
const iv = crypto2.randomBytes(GCM_IV_BYTES);
|
|
564
|
+
const cipher = crypto2.createCipheriv("aes-256-gcm", key, iv, {
|
|
565
|
+
authTagLength: GCM_TAG_LENGTH_BITS / 8
|
|
566
|
+
});
|
|
567
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
568
|
+
const authTag = cipher.getAuthTag();
|
|
569
|
+
return [iv.toString("base64"), authTag.toString("base64"), encrypted.toString("base64")].join(":");
|
|
328
570
|
}
|
|
329
|
-
function
|
|
330
|
-
const
|
|
331
|
-
|
|
571
|
+
function decryptGcm(key, encoded, path9 = "") {
|
|
572
|
+
const parts = encoded.split(":");
|
|
573
|
+
if (parts.length !== 3) {
|
|
574
|
+
throw new DecryptionError("Invalid encrypted envelope: expected iv:authTag:ciphertext", path9);
|
|
575
|
+
}
|
|
576
|
+
const [ivB64, authTagB64, ciphertextB64] = parts;
|
|
577
|
+
if (ivB64 === void 0 || authTagB64 === void 0 || ciphertextB64 === void 0) {
|
|
578
|
+
throw new DecryptionError("Invalid encrypted envelope: missing part", path9);
|
|
579
|
+
}
|
|
580
|
+
const iv = Buffer.from(ivB64, "base64");
|
|
581
|
+
const authTag = Buffer.from(authTagB64, "base64");
|
|
582
|
+
const ciphertext = Buffer.from(ciphertextB64, "base64");
|
|
583
|
+
try {
|
|
584
|
+
const decipher = crypto2.createDecipheriv("aes-256-gcm", key, iv, {
|
|
585
|
+
authTagLength: GCM_TAG_LENGTH_BITS / 8
|
|
586
|
+
});
|
|
587
|
+
decipher.setAuthTag(authTag);
|
|
588
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
589
|
+
return decrypted.toString("utf8");
|
|
590
|
+
} catch (err) {
|
|
591
|
+
throw new DecryptionError(
|
|
592
|
+
`Failed to decrypt envelope: ${err instanceof Error ? err.message : String(err)}`,
|
|
593
|
+
path9
|
|
594
|
+
);
|
|
595
|
+
}
|
|
332
596
|
}
|
|
333
|
-
async function
|
|
597
|
+
async function getOrCreateWrapKey(keyPath) {
|
|
598
|
+
let existing;
|
|
599
|
+
try {
|
|
600
|
+
existing = await fs3.readFile(keyPath);
|
|
601
|
+
} catch (err) {
|
|
602
|
+
if (!(err instanceof Error && "code" in err && err.code === "ENOENT")) {
|
|
603
|
+
throw toFilesystemError(err, "wrapping key file", keyPath, "read");
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (existing?.byteLength === GCM_KEY_BYTES) {
|
|
607
|
+
return existing;
|
|
608
|
+
}
|
|
609
|
+
const key = crypto2.randomBytes(GCM_KEY_BYTES);
|
|
334
610
|
try {
|
|
335
|
-
await
|
|
611
|
+
await fs3.writeFile(keyPath, key, { mode: 384 });
|
|
336
612
|
} catch (err) {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
613
|
+
throw toFilesystemError(err, "wrapping key file", keyPath, "write");
|
|
614
|
+
}
|
|
615
|
+
return key;
|
|
616
|
+
}
|
|
617
|
+
var CONFIG_REMEDIATION_HINT = `Fix the file \u2014 either install @vaultkeeper/cli and run 'vaultkeeper config init --force' to overwrite it with a valid config, or repair/replace it programmatically via this library (pass an explicit \`config\` or \`configDir\`, or write a valid config.json yourself).`;
|
|
618
|
+
function hasErrorCode(err, code) {
|
|
619
|
+
return err instanceof Error && "code" in err && err.code === code;
|
|
620
|
+
}
|
|
621
|
+
function describeError(err) {
|
|
622
|
+
return err instanceof Error ? err.message : String(err);
|
|
623
|
+
}
|
|
624
|
+
function describeJsonSyntaxLocation(err, raw) {
|
|
625
|
+
const message = describeError(err);
|
|
626
|
+
const lineColMatch = /line (\d+) column (\d+)/.exec(message);
|
|
627
|
+
if (lineColMatch) {
|
|
628
|
+
return `line ${lineColMatch[1] ?? ""}, column ${lineColMatch[2] ?? ""}`;
|
|
629
|
+
}
|
|
630
|
+
const positionMatch = /position (\d+)/.exec(message);
|
|
631
|
+
if (positionMatch) {
|
|
632
|
+
const posStr = positionMatch[1];
|
|
633
|
+
const pos = posStr !== void 0 ? Number(posStr) : NaN;
|
|
634
|
+
if (!Number.isNaN(pos) && pos >= 0 && pos <= raw.length) {
|
|
635
|
+
const upToPos = raw.slice(0, pos);
|
|
636
|
+
const line = upToPos.split("\n").length;
|
|
637
|
+
const column = pos - upToPos.lastIndexOf("\n");
|
|
638
|
+
return `line ${String(line)}, column ${String(column)}`;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return void 0;
|
|
642
|
+
}
|
|
643
|
+
function getPlatformDefaultConfigDir() {
|
|
644
|
+
if (process.platform === "win32") {
|
|
645
|
+
const appData = process.env.APPDATA;
|
|
646
|
+
if (appData !== void 0) {
|
|
647
|
+
return path2.join(appData, "vaultkeeper");
|
|
648
|
+
}
|
|
649
|
+
return path2.join(os.homedir(), "AppData", "Roaming", "vaultkeeper");
|
|
650
|
+
}
|
|
651
|
+
return path2.join(os.homedir(), ".config", "vaultkeeper");
|
|
652
|
+
}
|
|
653
|
+
function getDefaultConfigDir() {
|
|
654
|
+
const envOverride = process.env.VAULTKEEPER_CONFIG_DIR;
|
|
655
|
+
if (envOverride !== void 0 && envOverride !== "") {
|
|
656
|
+
return envOverride;
|
|
657
|
+
}
|
|
658
|
+
return getPlatformDefaultConfigDir();
|
|
659
|
+
}
|
|
660
|
+
function defaultBackendType() {
|
|
661
|
+
return "file";
|
|
662
|
+
}
|
|
663
|
+
function platformNativeBackendType() {
|
|
664
|
+
if (process.platform === "darwin") {
|
|
665
|
+
return "keychain";
|
|
666
|
+
}
|
|
667
|
+
if (process.platform === "win32") {
|
|
668
|
+
return "dpapi";
|
|
669
|
+
}
|
|
670
|
+
if (process.platform === "linux") {
|
|
671
|
+
return "secret-tool";
|
|
672
|
+
}
|
|
673
|
+
return "file";
|
|
674
|
+
}
|
|
675
|
+
function defaultConfig() {
|
|
676
|
+
return {
|
|
677
|
+
version: 1,
|
|
678
|
+
backends: [{ type: defaultBackendType(), enabled: true }],
|
|
679
|
+
keyRotation: { gracePeriodDays: 7 },
|
|
680
|
+
defaults: { ttlMinutes: 60, trustTier: 3 }
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
function isObject(value) {
|
|
684
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
685
|
+
}
|
|
686
|
+
function validateBackendEntry(entry, index) {
|
|
687
|
+
const base = `backends[${String(index)}]`;
|
|
688
|
+
if (!isObject(entry)) {
|
|
689
|
+
throw new ConfigValidationError(`${base} must be an object`, base);
|
|
690
|
+
}
|
|
691
|
+
if (typeof entry.type !== "string" || entry.type.trim() === "") {
|
|
692
|
+
throw new ConfigValidationError(`${base}.type must be a non-empty string`, `${base}.type`);
|
|
693
|
+
}
|
|
694
|
+
const knownTypes = BackendRegistry.getTypes();
|
|
695
|
+
if (knownTypes.length > 0 && !knownTypes.includes(entry.type)) {
|
|
696
|
+
throw new UnknownBackendTypeError(
|
|
697
|
+
`${base}.type is an unknown backend type: "${entry.type}". Available types: ${knownTypes.join(", ")}`,
|
|
698
|
+
`${base}.type`,
|
|
699
|
+
entry.type,
|
|
700
|
+
knownTypes
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
if (typeof entry.enabled !== "boolean") {
|
|
704
|
+
throw new ConfigValidationError(`${base}.enabled must be a boolean`, `${base}.enabled`);
|
|
705
|
+
}
|
|
706
|
+
const result = {
|
|
707
|
+
type: entry.type,
|
|
708
|
+
enabled: entry.enabled
|
|
709
|
+
};
|
|
710
|
+
if (entry.plugin !== void 0) {
|
|
711
|
+
if (typeof entry.plugin !== "boolean") {
|
|
712
|
+
throw new ConfigValidationError(`${base}.plugin must be a boolean`, `${base}.plugin`);
|
|
713
|
+
}
|
|
714
|
+
result.plugin = entry.plugin;
|
|
715
|
+
}
|
|
716
|
+
if (entry.path !== void 0) {
|
|
717
|
+
if (typeof entry.path !== "string") {
|
|
718
|
+
throw new ConfigValidationError(`${base}.path must be a string`, `${base}.path`);
|
|
719
|
+
}
|
|
720
|
+
if (entry.path.trim() === "") {
|
|
721
|
+
throw new ConfigValidationError(
|
|
722
|
+
`${base}.path must not be empty or whitespace-only`,
|
|
723
|
+
`${base}.path`
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
result.path = entry.path;
|
|
727
|
+
}
|
|
728
|
+
if (entry.options !== void 0) {
|
|
729
|
+
if (!isObject(entry.options)) {
|
|
730
|
+
throw new ConfigValidationError(`${base}.options must be an object`, `${base}.options`);
|
|
731
|
+
}
|
|
732
|
+
const opts = {};
|
|
733
|
+
for (const [k, v] of Object.entries(entry.options)) {
|
|
734
|
+
if (typeof v !== "string") {
|
|
735
|
+
const quotedKey = JSON.stringify(k);
|
|
736
|
+
throw new ConfigValidationError(
|
|
737
|
+
`${base}.options[${quotedKey}] must be a string`,
|
|
738
|
+
`${base}.options[${quotedKey}]`
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
opts[k] = v;
|
|
742
|
+
}
|
|
743
|
+
result.options = opts;
|
|
744
|
+
}
|
|
745
|
+
return result;
|
|
746
|
+
}
|
|
747
|
+
function validateConfig(config) {
|
|
748
|
+
if (!isObject(config)) {
|
|
749
|
+
throw new ConfigValidationError("Config must be an object", "config");
|
|
750
|
+
}
|
|
751
|
+
if (typeof config.version !== "number" || config.version !== 1) {
|
|
752
|
+
throw new ConfigValidationError("Config version must be 1", "version");
|
|
753
|
+
}
|
|
754
|
+
if (!Array.isArray(config.backends) || config.backends.length === 0) {
|
|
755
|
+
throw new ConfigValidationError("Config must have at least one backend", "backends");
|
|
756
|
+
}
|
|
757
|
+
const backends = config.backends.map(
|
|
758
|
+
(entry, i) => validateBackendEntry(entry, i)
|
|
759
|
+
);
|
|
760
|
+
if (!isObject(config.keyRotation)) {
|
|
761
|
+
throw new ConfigValidationError("Config keyRotation must be an object", "keyRotation");
|
|
762
|
+
}
|
|
763
|
+
if (typeof config.keyRotation.gracePeriodDays !== "number" || config.keyRotation.gracePeriodDays <= 0) {
|
|
764
|
+
throw new ConfigValidationError(
|
|
765
|
+
"Config keyRotation.gracePeriodDays must be a positive number",
|
|
766
|
+
"keyRotation.gracePeriodDays"
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
if (!isObject(config.defaults)) {
|
|
770
|
+
throw new ConfigValidationError("Config defaults must be an object", "defaults");
|
|
771
|
+
}
|
|
772
|
+
if (typeof config.defaults.ttlMinutes !== "number" || config.defaults.ttlMinutes <= 0) {
|
|
773
|
+
throw new ConfigValidationError(
|
|
774
|
+
"Config defaults.ttlMinutes must be a positive number",
|
|
775
|
+
"defaults.ttlMinutes"
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
let tier = config.defaults.trustTier;
|
|
779
|
+
if (typeof tier === "string") {
|
|
780
|
+
const parsed = Number(tier);
|
|
781
|
+
if (!Number.isNaN(parsed)) {
|
|
782
|
+
tier = parsed;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (tier !== 1 && tier !== 2 && tier !== 3) {
|
|
786
|
+
throw new ConfigValidationError(
|
|
787
|
+
"Config defaults.trustTier must be 1, 2, or 3",
|
|
788
|
+
"defaults.trustTier"
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
const result = {
|
|
792
|
+
version: 1,
|
|
793
|
+
backends,
|
|
794
|
+
keyRotation: {
|
|
795
|
+
gracePeriodDays: config.keyRotation.gracePeriodDays
|
|
796
|
+
},
|
|
797
|
+
defaults: {
|
|
798
|
+
ttlMinutes: config.defaults.ttlMinutes,
|
|
799
|
+
trustTier: tier
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
if (config.developmentMode !== void 0) {
|
|
803
|
+
if (!isObject(config.developmentMode)) {
|
|
804
|
+
throw new ConfigValidationError("Config developmentMode must be an object", "developmentMode");
|
|
805
|
+
}
|
|
806
|
+
if (!Array.isArray(config.developmentMode.executables)) {
|
|
807
|
+
throw new ConfigValidationError(
|
|
808
|
+
"Config developmentMode.executables must be an array",
|
|
809
|
+
"developmentMode.executables"
|
|
342
810
|
);
|
|
343
811
|
}
|
|
812
|
+
const executables = [];
|
|
813
|
+
for (const [i, exe] of Array.from(config.developmentMode.executables).entries()) {
|
|
814
|
+
if (typeof exe !== "string") {
|
|
815
|
+
throw new ConfigValidationError(
|
|
816
|
+
`Config developmentMode.executables[${String(i)}] must be a string`,
|
|
817
|
+
`developmentMode.executables[${String(i)}]`
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
executables.push(exe);
|
|
821
|
+
}
|
|
822
|
+
result.developmentMode = { executables };
|
|
344
823
|
}
|
|
824
|
+
return result;
|
|
345
825
|
}
|
|
346
|
-
async function
|
|
347
|
-
const
|
|
826
|
+
async function loadConfig(configDir) {
|
|
827
|
+
const dir = configDir ?? getDefaultConfigDir();
|
|
828
|
+
const configPath = path2.join(dir, "config.json");
|
|
829
|
+
let raw;
|
|
348
830
|
try {
|
|
349
|
-
|
|
350
|
-
return data;
|
|
831
|
+
raw = await fs3.readFile(configPath, "utf-8");
|
|
351
832
|
} catch (err) {
|
|
352
|
-
if (err
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
833
|
+
if (hasErrorCode(err, "ENOENT")) {
|
|
834
|
+
return defaultConfig();
|
|
835
|
+
}
|
|
836
|
+
throw new FilesystemError(
|
|
837
|
+
`Cannot read config file at ${configPath}: ${describeError(err)}. ${CONFIG_REMEDIATION_HINT}`,
|
|
838
|
+
configPath,
|
|
839
|
+
"read",
|
|
840
|
+
err
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
let parsed;
|
|
844
|
+
try {
|
|
845
|
+
parsed = JSON.parse(raw);
|
|
846
|
+
} catch (err) {
|
|
847
|
+
const location = describeJsonSyntaxLocation(err, raw);
|
|
848
|
+
const locationSuffix = location !== void 0 ? ` at ${location}` : "";
|
|
849
|
+
throw new ConfigParseError(
|
|
850
|
+
`Failed to parse config file at ${configPath}${locationSuffix}: ${describeError(err)}. ` + CONFIG_REMEDIATION_HINT,
|
|
851
|
+
configPath,
|
|
852
|
+
location
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
try {
|
|
856
|
+
return validateConfig(parsed);
|
|
857
|
+
} catch (err) {
|
|
858
|
+
if (err instanceof UnknownBackendTypeError) {
|
|
859
|
+
throw new UnknownBackendTypeError(
|
|
860
|
+
`Invalid config at ${configPath}: ${err.message}. ${CONFIG_REMEDIATION_HINT}`,
|
|
861
|
+
err.field,
|
|
862
|
+
err.backendType,
|
|
863
|
+
err.knownTypes,
|
|
864
|
+
configPath
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
if (err instanceof ConfigValidationError) {
|
|
868
|
+
throw new ConfigValidationError(
|
|
869
|
+
`Invalid config at ${configPath}: ${err.message}. ${CONFIG_REMEDIATION_HINT}`,
|
|
870
|
+
err.field,
|
|
871
|
+
configPath
|
|
872
|
+
);
|
|
356
873
|
}
|
|
357
874
|
throw err;
|
|
358
875
|
}
|
|
359
876
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
877
|
+
|
|
878
|
+
// src/backend/file-backend.ts
|
|
879
|
+
var STORAGE_DIR_NAME = "file";
|
|
880
|
+
var KEY_FILE = ".key";
|
|
881
|
+
var SIGNING_DIR_NAME = "signing-keys";
|
|
882
|
+
var SIGNING_KEY_PREFIX = "signing-key:";
|
|
883
|
+
var SUPPORTED_SIGNING_ALGORITHMS = ["EdDSA"];
|
|
884
|
+
function computeKid(spkiDer) {
|
|
885
|
+
return crypto2.createHash("sha256").update(spkiDer).digest("base64url");
|
|
368
886
|
}
|
|
369
|
-
function
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
887
|
+
function displayKeyName(id) {
|
|
888
|
+
return id.startsWith(SIGNING_KEY_PREFIX) ? id.slice(SIGNING_KEY_PREFIX.length) : id;
|
|
889
|
+
}
|
|
890
|
+
function legacyStorageDir() {
|
|
891
|
+
return path2.join(os.homedir(), ".vaultkeeper", "file");
|
|
892
|
+
}
|
|
893
|
+
function resolveStorageDir(configuredPath, configDir) {
|
|
894
|
+
if (configuredPath !== void 0) {
|
|
895
|
+
return configuredPath;
|
|
373
896
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
897
|
+
return path2.join(configDir ?? getDefaultConfigDir(), STORAGE_DIR_NAME);
|
|
898
|
+
}
|
|
899
|
+
function getEntryPath(storageDir, id) {
|
|
900
|
+
const safeId = Buffer.from(id, "utf8").toString("hex");
|
|
901
|
+
return path2.join(storageDir, `${safeId}.enc`);
|
|
902
|
+
}
|
|
903
|
+
async function ensureStorageDir(storageDir) {
|
|
904
|
+
try {
|
|
905
|
+
await fs3.mkdir(storageDir, { recursive: true, mode: 448 });
|
|
906
|
+
} catch (err) {
|
|
907
|
+
throw new FilesystemError(
|
|
908
|
+
`Failed to create storage directory: ${storageDir}`,
|
|
909
|
+
storageDir,
|
|
910
|
+
"rwx",
|
|
911
|
+
err
|
|
912
|
+
);
|
|
377
913
|
}
|
|
378
|
-
const iv = Buffer.from(ivB64, "base64");
|
|
379
|
-
const authTag = Buffer.from(authTagB64, "base64");
|
|
380
|
-
const ciphertext = Buffer.from(ciphertextB64, "base64");
|
|
381
|
-
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv, {
|
|
382
|
-
authTagLength: GCM_TAG_LENGTH / 8
|
|
383
|
-
});
|
|
384
|
-
decipher.setAuthTag(authTag);
|
|
385
|
-
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
386
|
-
return decrypted.toString("utf8");
|
|
387
914
|
}
|
|
388
|
-
|
|
915
|
+
async function getOrCreateKey(storageDir) {
|
|
916
|
+
return getOrCreateWrapKey(path2.join(storageDir, KEY_FILE));
|
|
917
|
+
}
|
|
918
|
+
var FileBackend = class _FileBackend {
|
|
389
919
|
type = "file";
|
|
390
920
|
displayName = "Encrypted File Store";
|
|
921
|
+
#storageDir;
|
|
922
|
+
/** Directory holding encrypted signing-key private material. */
|
|
923
|
+
#signingDir;
|
|
924
|
+
/**
|
|
925
|
+
* Pre-#99 default storage directory, consulted as a read-only fallback
|
|
926
|
+
* when `storageDir` was not explicitly configured. `undefined` when an
|
|
927
|
+
* explicit `storageDir` was given — an explicit path never falls back.
|
|
928
|
+
*/
|
|
929
|
+
#legacyStorageDir;
|
|
930
|
+
/**
|
|
931
|
+
* @param storageDir - Directory in which encrypted secrets are stored.
|
|
932
|
+
* Sourced from `BackendConfig.path`. Defaults to `<configDir>/file`.
|
|
933
|
+
* @param configDir - Resolved config directory, used to compute the
|
|
934
|
+
* default `storageDir` when one is not explicitly provided. Ignored when
|
|
935
|
+
* `storageDir` is given. Defaults to `getDefaultConfigDir()`.
|
|
936
|
+
*/
|
|
937
|
+
constructor(storageDir, configDir) {
|
|
938
|
+
this.#storageDir = resolveStorageDir(storageDir, configDir);
|
|
939
|
+
this.#legacyStorageDir = storageDir === void 0 ? legacyStorageDir() : void 0;
|
|
940
|
+
this.#signingDir = path2.join(this.#storageDir, SIGNING_DIR_NAME);
|
|
941
|
+
}
|
|
391
942
|
async isAvailable() {
|
|
392
943
|
try {
|
|
393
|
-
|
|
394
|
-
await ensureStorageDir(storageDir);
|
|
944
|
+
await ensureStorageDir(this.#storageDir);
|
|
395
945
|
return true;
|
|
396
946
|
} catch {
|
|
397
|
-
return false;
|
|
947
|
+
return false;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
async store(id, secret) {
|
|
951
|
+
const storageDir = this.#storageDir;
|
|
952
|
+
await ensureStorageDir(storageDir);
|
|
953
|
+
const key = await getOrCreateKey(storageDir);
|
|
954
|
+
const entryPath = getEntryPath(storageDir, id);
|
|
955
|
+
const encrypted = encryptGcm(key, secret);
|
|
956
|
+
try {
|
|
957
|
+
await fs3.writeFile(entryPath, encrypted, { mode: 384 });
|
|
958
|
+
} catch (err) {
|
|
959
|
+
throw toFilesystemError(err, "secret file", entryPath, "write");
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Attempt to read and decrypt the entry for `id` from `storageDir`.
|
|
964
|
+
* Returns `undefined` (rather than throwing) when the entry does not
|
|
965
|
+
* exist in `storageDir`, so callers can probe a fallback location.
|
|
966
|
+
*/
|
|
967
|
+
async #tryRetrieveFrom(storageDir, id) {
|
|
968
|
+
const entryPath = getEntryPath(storageDir, id);
|
|
969
|
+
let encoded;
|
|
970
|
+
try {
|
|
971
|
+
encoded = await fs3.readFile(entryPath, "utf8");
|
|
972
|
+
} catch (err) {
|
|
973
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
974
|
+
return void 0;
|
|
975
|
+
}
|
|
976
|
+
throw toFilesystemError(err, "secret file", entryPath, "read");
|
|
977
|
+
}
|
|
978
|
+
const key = await getOrCreateKey(storageDir);
|
|
979
|
+
try {
|
|
980
|
+
return decryptGcm(key, encoded, entryPath);
|
|
981
|
+
} catch (err) {
|
|
982
|
+
throw new DecryptionError(
|
|
983
|
+
`Failed to decrypt secret: ${err instanceof Error ? err.message : String(err)}`,
|
|
984
|
+
entryPath
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
async retrieve(id) {
|
|
989
|
+
const fromPrimary = await this.#tryRetrieveFrom(this.#storageDir, id);
|
|
990
|
+
if (fromPrimary !== void 0) {
|
|
991
|
+
return fromPrimary;
|
|
992
|
+
}
|
|
993
|
+
if (this.#legacyStorageDir !== void 0) {
|
|
994
|
+
const fromLegacy = await this.#tryRetrieveFrom(this.#legacyStorageDir, id);
|
|
995
|
+
if (fromLegacy !== void 0) {
|
|
996
|
+
return fromLegacy;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
throw new SecretNotFoundError(`Secret not found in file store: ${id}`);
|
|
1000
|
+
}
|
|
1001
|
+
async delete(id) {
|
|
1002
|
+
const entryPath = getEntryPath(this.#storageDir, id);
|
|
1003
|
+
try {
|
|
1004
|
+
await fs3.unlink(entryPath);
|
|
1005
|
+
return;
|
|
1006
|
+
} catch (err) {
|
|
1007
|
+
if (!(err instanceof Error && "code" in err && err.code === "ENOENT")) {
|
|
1008
|
+
throw toFilesystemError(err, "secret file", entryPath, "delete");
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
if (this.#legacyStorageDir !== void 0) {
|
|
1012
|
+
const legacyEntryPath = getEntryPath(this.#legacyStorageDir, id);
|
|
1013
|
+
try {
|
|
1014
|
+
await fs3.unlink(legacyEntryPath);
|
|
1015
|
+
return;
|
|
1016
|
+
} catch (err) {
|
|
1017
|
+
if (!(err instanceof Error && "code" in err && err.code === "ENOENT")) {
|
|
1018
|
+
throw toFilesystemError(err, "secret file", legacyEntryPath, "delete");
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
throw new SecretNotFoundError(`Secret not found in file store: ${id}`);
|
|
1023
|
+
}
|
|
1024
|
+
async exists(id) {
|
|
1025
|
+
if (await _FileBackend.#entryExists(this.#storageDir, id)) {
|
|
1026
|
+
return true;
|
|
1027
|
+
}
|
|
1028
|
+
if (this.#legacyStorageDir !== void 0) {
|
|
1029
|
+
return _FileBackend.#entryExists(this.#legacyStorageDir, id);
|
|
1030
|
+
}
|
|
1031
|
+
return false;
|
|
1032
|
+
}
|
|
1033
|
+
static async #entryExists(storageDir, id) {
|
|
1034
|
+
try {
|
|
1035
|
+
await fs3.access(getEntryPath(storageDir, id));
|
|
1036
|
+
return true;
|
|
1037
|
+
} catch {
|
|
1038
|
+
return false;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
async list() {
|
|
1042
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1043
|
+
for (const storageDir of [this.#storageDir, this.#legacyStorageDir].filter(
|
|
1044
|
+
(dir) => dir !== void 0
|
|
1045
|
+
)) {
|
|
1046
|
+
for (const id of await _FileBackend.#listEntries(storageDir)) {
|
|
1047
|
+
ids.add(id);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return Array.from(ids);
|
|
1051
|
+
}
|
|
1052
|
+
static async #listEntries(storageDir) {
|
|
1053
|
+
let entries;
|
|
1054
|
+
try {
|
|
1055
|
+
entries = await fs3.readdir(storageDir);
|
|
1056
|
+
} catch {
|
|
1057
|
+
return [];
|
|
398
1058
|
}
|
|
1059
|
+
return entries.filter((f) => f.endsWith(".enc")).map((f) => Buffer.from(f.slice(0, -4), "hex").toString("utf8"));
|
|
399
1060
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
const encrypted = encryptGcm(key, secret);
|
|
406
|
-
await fs.writeFile(entryPath, encrypted, { mode: 384 });
|
|
1061
|
+
// --- Signing contract (SigningBackend) ---
|
|
1062
|
+
/** On-disk path of the encrypted private key for a signing-key id. */
|
|
1063
|
+
#signingKeyPath(id) {
|
|
1064
|
+
const safeId = Buffer.from(id, "utf8").toString("hex");
|
|
1065
|
+
return path2.join(this.#signingDir, `${safeId}.pem.enc`);
|
|
407
1066
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
1067
|
+
/**
|
|
1068
|
+
* Load and decrypt the PKCS#8 private key PEM for `id`, or throw
|
|
1069
|
+
* {@link SigningKeyNotFoundError} when no signing key exists under `id`.
|
|
1070
|
+
*/
|
|
1071
|
+
async #loadSigningKeyPem(id) {
|
|
1072
|
+
const keyPath = this.#signingKeyPath(id);
|
|
411
1073
|
let encoded;
|
|
412
1074
|
try {
|
|
413
|
-
encoded = await
|
|
1075
|
+
encoded = await fs3.readFile(keyPath, "utf8");
|
|
414
1076
|
} catch (err) {
|
|
415
1077
|
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
416
|
-
throw new
|
|
1078
|
+
throw new SigningKeyNotFoundError(
|
|
1079
|
+
`Signing key not found: ${displayKeyName(id)}`,
|
|
1080
|
+
displayKeyName(id)
|
|
1081
|
+
);
|
|
417
1082
|
}
|
|
418
|
-
throw err;
|
|
1083
|
+
throw toFilesystemError(err, "signing key", keyPath, "read");
|
|
419
1084
|
}
|
|
420
|
-
const
|
|
1085
|
+
const wrapKey = await getOrCreateKey(this.#storageDir);
|
|
421
1086
|
try {
|
|
422
|
-
return decryptGcm(
|
|
1087
|
+
return decryptGcm(wrapKey, encoded);
|
|
423
1088
|
} catch (err) {
|
|
424
|
-
throw new
|
|
425
|
-
`Failed to decrypt
|
|
1089
|
+
throw new DecryptionError(
|
|
1090
|
+
`Failed to decrypt signing key: ${err instanceof Error ? err.message : String(err)}`,
|
|
1091
|
+
keyPath
|
|
426
1092
|
);
|
|
427
1093
|
}
|
|
428
1094
|
}
|
|
429
|
-
async
|
|
430
|
-
|
|
431
|
-
|
|
1095
|
+
async generateSigningKey(id, algorithm) {
|
|
1096
|
+
if (!SUPPORTED_SIGNING_ALGORITHMS.includes(algorithm)) {
|
|
1097
|
+
throw new InvalidAlgorithmError(
|
|
1098
|
+
`Unsupported signing algorithm '${algorithm}'. Supported: ${SUPPORTED_SIGNING_ALGORITHMS.join(", ")}.`,
|
|
1099
|
+
algorithm,
|
|
1100
|
+
[...SUPPORTED_SIGNING_ALGORITHMS]
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
await ensureStorageDir(this.#storageDir);
|
|
432
1104
|
try {
|
|
433
|
-
await
|
|
1105
|
+
await fs3.mkdir(this.#signingDir, { recursive: true, mode: 448 });
|
|
434
1106
|
} catch (err) {
|
|
435
|
-
if (err instanceof Error && "code" in err && err.code
|
|
436
|
-
throw
|
|
1107
|
+
if (err instanceof Error && "code" in err && err.code !== "EEXIST") {
|
|
1108
|
+
throw toFilesystemError(err, "signing-key directory", this.#signingDir, "create");
|
|
437
1109
|
}
|
|
438
|
-
throw err;
|
|
439
1110
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const storageDir = getStorageDir();
|
|
443
|
-
const entryPath = getEntryPath(storageDir, id);
|
|
1111
|
+
const keyPath = this.#signingKeyPath(id);
|
|
1112
|
+
let keyExists = false;
|
|
444
1113
|
try {
|
|
445
|
-
await
|
|
446
|
-
|
|
447
|
-
} catch {
|
|
448
|
-
|
|
1114
|
+
await fs3.access(keyPath);
|
|
1115
|
+
keyExists = true;
|
|
1116
|
+
} catch (err) {
|
|
1117
|
+
if (!(err instanceof Error && "code" in err && err.code === "ENOENT")) {
|
|
1118
|
+
throw toFilesystemError(err, "signing key", keyPath, "read");
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
if (keyExists) {
|
|
1122
|
+
throw new SigningKeyAlreadyExistsError(
|
|
1123
|
+
`Signing key already exists: ${displayKeyName(id)}`,
|
|
1124
|
+
displayKeyName(id)
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
const { privateKey } = crypto2.generateKeyPairSync("ed25519");
|
|
1128
|
+
const pkcs8Pem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
1129
|
+
const wrapKey = await getOrCreateKey(this.#storageDir);
|
|
1130
|
+
const encrypted = encryptGcm(wrapKey, pkcs8Pem);
|
|
1131
|
+
try {
|
|
1132
|
+
await fs3.writeFile(keyPath, encrypted, { mode: 384, flag: "wx" });
|
|
1133
|
+
} catch (err) {
|
|
1134
|
+
if (err instanceof Error && "code" in err && err.code === "EEXIST") {
|
|
1135
|
+
throw new SigningKeyAlreadyExistsError(
|
|
1136
|
+
`Signing key already exists: ${displayKeyName(id)}`,
|
|
1137
|
+
displayKeyName(id)
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
throw toFilesystemError(err, "signing key", keyPath, "write");
|
|
449
1141
|
}
|
|
450
1142
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
1143
|
+
/**
|
|
1144
|
+
* Load, decrypt, and parse the private key for `id` into a `KeyObject`.
|
|
1145
|
+
*
|
|
1146
|
+
* A parse failure means the decrypted-at-rest material is corrupt or tampered
|
|
1147
|
+
* (it decrypted cleanly but is not a valid PKCS#8 private key). It is
|
|
1148
|
+
* translated into a typed {@link InvalidKeyMaterialError} — never allowed to
|
|
1149
|
+
* surface as a raw Node crypto exception — and the message never echoes any
|
|
1150
|
+
* part of the key material.
|
|
1151
|
+
*/
|
|
1152
|
+
async #loadSigningKeyObject(id) {
|
|
1153
|
+
const pkcs8Pem = await this.#loadSigningKeyPem(id);
|
|
454
1154
|
try {
|
|
455
|
-
|
|
1155
|
+
return crypto2.createPrivateKey(pkcs8Pem);
|
|
456
1156
|
} catch {
|
|
457
|
-
|
|
1157
|
+
throw new InvalidKeyMaterialError(
|
|
1158
|
+
`The stored signing key for "${displayKeyName(id)}" is not valid private key material (it may be corrupt or tampered).`
|
|
1159
|
+
);
|
|
458
1160
|
}
|
|
459
|
-
|
|
1161
|
+
}
|
|
1162
|
+
async getPublicKey(id) {
|
|
1163
|
+
const privateKey = await this.#loadSigningKeyObject(id);
|
|
1164
|
+
const publicKey = crypto2.createPublicKey(privateKey);
|
|
1165
|
+
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
1166
|
+
const spkiDer = publicKey.export({ type: "spki", format: "der" });
|
|
1167
|
+
return {
|
|
1168
|
+
publicKeyPem,
|
|
1169
|
+
algorithm: "EdDSA",
|
|
1170
|
+
kid: computeKid(spkiDer)
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
async signWithKey(id, data) {
|
|
1174
|
+
const privateKey = await this.#loadSigningKeyObject(id);
|
|
1175
|
+
return crypto2.sign(null, data, privateKey);
|
|
460
1176
|
}
|
|
461
1177
|
};
|
|
462
1178
|
async function execCommand(command, args, options) {
|
|
463
1179
|
const result = await execCommandFull(command, args, options);
|
|
464
1180
|
if (result.exitCode !== 0) {
|
|
465
|
-
throw new
|
|
1181
|
+
throw new ExecError(
|
|
1182
|
+
`Command failed with exit code ${String(result.exitCode)}: ${result.stderr}`,
|
|
1183
|
+
command
|
|
1184
|
+
);
|
|
466
1185
|
}
|
|
467
1186
|
return result.stdout.trim();
|
|
468
1187
|
}
|
|
469
1188
|
function execCommandFull(command, args, options) {
|
|
470
|
-
return new Promise((
|
|
1189
|
+
return new Promise((resolve3, reject) => {
|
|
471
1190
|
const proc = spawn(command, args, {
|
|
472
1191
|
stdio: [options?.stdin !== void 0 ? "pipe" : "ignore", "pipe", "pipe"]
|
|
473
1192
|
});
|
|
474
1193
|
let stdout = "";
|
|
475
1194
|
let stderr = "";
|
|
1195
|
+
let timeoutHandle;
|
|
476
1196
|
proc.stdout?.on("data", (data) => {
|
|
477
1197
|
stdout += data.toString();
|
|
478
1198
|
});
|
|
@@ -484,15 +1204,22 @@ function execCommandFull(command, args, options) {
|
|
|
484
1204
|
proc.stdin.end();
|
|
485
1205
|
}
|
|
486
1206
|
if (options?.timeoutMs !== void 0) {
|
|
487
|
-
setTimeout(() => {
|
|
1207
|
+
timeoutHandle = setTimeout(() => {
|
|
1208
|
+
timeoutHandle = void 0;
|
|
488
1209
|
proc.kill("SIGTERM");
|
|
489
|
-
reject(new
|
|
1210
|
+
reject(new ExecError(`Command timed out after ${String(options.timeoutMs)}ms`, command));
|
|
490
1211
|
}, options.timeoutMs);
|
|
491
1212
|
}
|
|
492
1213
|
proc.on("close", (code) => {
|
|
493
|
-
|
|
1214
|
+
if (timeoutHandle !== void 0) {
|
|
1215
|
+
clearTimeout(timeoutHandle);
|
|
1216
|
+
}
|
|
1217
|
+
resolve3({ stdout, stderr, exitCode: code ?? 1 });
|
|
494
1218
|
});
|
|
495
1219
|
proc.on("error", (error) => {
|
|
1220
|
+
if (timeoutHandle !== void 0) {
|
|
1221
|
+
clearTimeout(timeoutHandle);
|
|
1222
|
+
}
|
|
496
1223
|
if ("code" in error && error.code === "ENOENT") {
|
|
497
1224
|
reject(
|
|
498
1225
|
new PluginNotFoundError(
|
|
@@ -502,7 +1229,7 @@ function execCommandFull(command, args, options) {
|
|
|
502
1229
|
)
|
|
503
1230
|
);
|
|
504
1231
|
} else {
|
|
505
|
-
reject(error);
|
|
1232
|
+
reject(new ExecError(`Failed to spawn '${command}': ${error.message}`, command));
|
|
506
1233
|
}
|
|
507
1234
|
});
|
|
508
1235
|
});
|
|
@@ -528,13 +1255,7 @@ var KeychainBackend = class {
|
|
|
528
1255
|
async store(id, secret) {
|
|
529
1256
|
const service = `${SERVICE_PREFIX}${id}`;
|
|
530
1257
|
const encoded = Buffer.from(secret, "utf8").toString("base64");
|
|
531
|
-
await execCommandFull("security", [
|
|
532
|
-
"delete-generic-password",
|
|
533
|
-
"-a",
|
|
534
|
-
ACCOUNT,
|
|
535
|
-
"-s",
|
|
536
|
-
service
|
|
537
|
-
]);
|
|
1258
|
+
await execCommandFull("security", ["delete-generic-password", "-a", ACCOUNT, "-s", service]);
|
|
538
1259
|
await execCommand("security", [
|
|
539
1260
|
"add-generic-password",
|
|
540
1261
|
"-a",
|
|
@@ -586,9 +1307,7 @@ var KeychainBackend = class {
|
|
|
586
1307
|
return result.exitCode === 0;
|
|
587
1308
|
}
|
|
588
1309
|
async list() {
|
|
589
|
-
const result = await execCommandFull("security", [
|
|
590
|
-
"dump-keychain"
|
|
591
|
-
]);
|
|
1310
|
+
const result = await execCommandFull("security", ["dump-keychain"]);
|
|
592
1311
|
if (result.exitCode !== 0) {
|
|
593
1312
|
return [];
|
|
594
1313
|
}
|
|
@@ -605,16 +1324,27 @@ var KeychainBackend = class {
|
|
|
605
1324
|
return ids;
|
|
606
1325
|
}
|
|
607
1326
|
};
|
|
608
|
-
function
|
|
609
|
-
|
|
1327
|
+
function resolveStorageDir2(configuredPath) {
|
|
1328
|
+
if (configuredPath !== void 0) {
|
|
1329
|
+
return configuredPath;
|
|
1330
|
+
}
|
|
1331
|
+
return path2.join(os.homedir(), ".vaultkeeper", "dpapi");
|
|
610
1332
|
}
|
|
611
1333
|
function getEntryPath2(storageDir, id) {
|
|
612
1334
|
const safeId = Buffer.from(id, "utf8").toString("hex");
|
|
613
|
-
return
|
|
1335
|
+
return path2.join(storageDir, `${safeId}.enc`);
|
|
614
1336
|
}
|
|
615
1337
|
var DpapiBackend = class {
|
|
616
1338
|
type = "dpapi";
|
|
617
1339
|
displayName = "Windows DPAPI";
|
|
1340
|
+
#storageDir;
|
|
1341
|
+
/**
|
|
1342
|
+
* @param storageDir - Directory in which encrypted blobs are stored.
|
|
1343
|
+
* Sourced from `BackendConfig.path`. Defaults to `$HOME/.vaultkeeper/dpapi`.
|
|
1344
|
+
*/
|
|
1345
|
+
constructor(storageDir) {
|
|
1346
|
+
this.#storageDir = resolveStorageDir2(storageDir);
|
|
1347
|
+
}
|
|
618
1348
|
async isAvailable() {
|
|
619
1349
|
if (process.platform !== "win32") {
|
|
620
1350
|
return false;
|
|
@@ -631,24 +1361,30 @@ var DpapiBackend = class {
|
|
|
631
1361
|
}
|
|
632
1362
|
}
|
|
633
1363
|
async store(id, secret) {
|
|
634
|
-
const storageDir =
|
|
635
|
-
await
|
|
1364
|
+
const storageDir = this.#storageDir;
|
|
1365
|
+
await fs3.mkdir(storageDir, { recursive: true });
|
|
636
1366
|
const entryPath = getEntryPath2(storageDir, id);
|
|
637
1367
|
const script = [
|
|
638
1368
|
"Add-Type -AssemblyName System.Security",
|
|
639
|
-
|
|
1369
|
+
"$b64 = [Console]::In.ReadToEnd()",
|
|
1370
|
+
"$bytes = [System.Convert]::FromBase64String($b64.Trim())",
|
|
640
1371
|
"$entropy = $null",
|
|
641
1372
|
"$scope = [System.Security.Cryptography.DataProtectionScope]::CurrentUser",
|
|
642
1373
|
"$encrypted = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $entropy, $scope)",
|
|
643
1374
|
`[System.IO.File]::WriteAllBytes(${JSON.stringify(entryPath)}, $encrypted)`
|
|
644
1375
|
].join("; ");
|
|
645
|
-
|
|
1376
|
+
const secretBuf = Buffer.from(secret, "utf8");
|
|
1377
|
+
const stdinPayload = secretBuf.toString("base64");
|
|
1378
|
+
secretBuf.fill(0);
|
|
1379
|
+
await execCommand("powershell", ["-NoProfile", "-Command", script], {
|
|
1380
|
+
stdin: stdinPayload
|
|
1381
|
+
});
|
|
646
1382
|
}
|
|
647
1383
|
async retrieve(id) {
|
|
648
|
-
const storageDir =
|
|
1384
|
+
const storageDir = this.#storageDir;
|
|
649
1385
|
const entryPath = getEntryPath2(storageDir, id);
|
|
650
1386
|
try {
|
|
651
|
-
await
|
|
1387
|
+
await fs3.access(entryPath);
|
|
652
1388
|
} catch {
|
|
653
1389
|
throw new SecretNotFoundError(`Secret not found in Windows DPAPI store: ${id}`);
|
|
654
1390
|
}
|
|
@@ -663,32 +1399,32 @@ var DpapiBackend = class {
|
|
|
663
1399
|
return execCommand("powershell", ["-NoProfile", "-Command", script]);
|
|
664
1400
|
}
|
|
665
1401
|
async delete(id) {
|
|
666
|
-
const storageDir =
|
|
1402
|
+
const storageDir = this.#storageDir;
|
|
667
1403
|
const entryPath = getEntryPath2(storageDir, id);
|
|
668
1404
|
try {
|
|
669
|
-
await
|
|
1405
|
+
await fs3.unlink(entryPath);
|
|
670
1406
|
} catch (err) {
|
|
671
1407
|
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
672
1408
|
throw new SecretNotFoundError(`Secret not found in Windows DPAPI store: ${id}`);
|
|
673
1409
|
}
|
|
674
|
-
throw err;
|
|
1410
|
+
throw toFilesystemError(err, "secret file", entryPath, "delete");
|
|
675
1411
|
}
|
|
676
1412
|
}
|
|
677
1413
|
async exists(id) {
|
|
678
|
-
const storageDir =
|
|
1414
|
+
const storageDir = this.#storageDir;
|
|
679
1415
|
const entryPath = getEntryPath2(storageDir, id);
|
|
680
1416
|
try {
|
|
681
|
-
await
|
|
1417
|
+
await fs3.access(entryPath);
|
|
682
1418
|
return true;
|
|
683
1419
|
} catch {
|
|
684
1420
|
return false;
|
|
685
1421
|
}
|
|
686
1422
|
}
|
|
687
1423
|
async list() {
|
|
688
|
-
const storageDir =
|
|
1424
|
+
const storageDir = this.#storageDir;
|
|
689
1425
|
let entries;
|
|
690
1426
|
try {
|
|
691
|
-
entries = await
|
|
1427
|
+
entries = await fs3.readdir(storageDir);
|
|
692
1428
|
} catch {
|
|
693
1429
|
return [];
|
|
694
1430
|
}
|
|
@@ -715,11 +1451,9 @@ var SecretToolBackend = class {
|
|
|
715
1451
|
}
|
|
716
1452
|
async store(id, secret) {
|
|
717
1453
|
const label = `${LABEL_PREFIX}${id}`;
|
|
718
|
-
await execCommand(
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
{ stdin: secret }
|
|
722
|
-
);
|
|
1454
|
+
await execCommand("secret-tool", ["store", "--label", label, ATTRIBUTE_KEY, id], {
|
|
1455
|
+
stdin: secret
|
|
1456
|
+
});
|
|
723
1457
|
}
|
|
724
1458
|
async retrieve(id) {
|
|
725
1459
|
const result = await execCommandFull("secret-tool", ["lookup", ATTRIBUTE_KEY, id]);
|
|
@@ -739,11 +1473,7 @@ var SecretToolBackend = class {
|
|
|
739
1473
|
return result.exitCode === 0 && result.stdout.trim() !== "";
|
|
740
1474
|
}
|
|
741
1475
|
async list() {
|
|
742
|
-
const result = await execCommandFull("secret-tool", [
|
|
743
|
-
"search",
|
|
744
|
-
ATTRIBUTE_KEY,
|
|
745
|
-
""
|
|
746
|
-
]);
|
|
1476
|
+
const result = await execCommandFull("secret-tool", ["search", ATTRIBUTE_KEY, ""]);
|
|
747
1477
|
if (result.exitCode !== 0) {
|
|
748
1478
|
return [];
|
|
749
1479
|
}
|
|
@@ -760,15 +1490,95 @@ var SecretToolBackend = class {
|
|
|
760
1490
|
return ids;
|
|
761
1491
|
}
|
|
762
1492
|
};
|
|
1493
|
+
|
|
1494
|
+
// src/backend/one-password-item-ops.ts
|
|
1495
|
+
var TAG = "vaultkeeper";
|
|
1496
|
+
var PASSWORD_FIELD_TITLE = "password";
|
|
1497
|
+
async function findItemOverviewByTitle(client, vaultId, title) {
|
|
1498
|
+
const overviews = await client.items.list(vaultId);
|
|
1499
|
+
for (const overview of overviews) {
|
|
1500
|
+
if (overview.title === title && overview.tags.includes(TAG)) {
|
|
1501
|
+
return overview;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
return void 0;
|
|
1505
|
+
}
|
|
1506
|
+
async function findItemByTitle(client, vaultId, title) {
|
|
1507
|
+
const overview = await findItemOverviewByTitle(client, vaultId, title);
|
|
1508
|
+
if (overview === void 0) return void 0;
|
|
1509
|
+
return client.items.get(vaultId, overview.id);
|
|
1510
|
+
}
|
|
1511
|
+
function extractPasswordField(item) {
|
|
1512
|
+
for (const field of item.fields) {
|
|
1513
|
+
if (field.title === PASSWORD_FIELD_TITLE) {
|
|
1514
|
+
return field.value;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
return void 0;
|
|
1518
|
+
}
|
|
1519
|
+
async function storeSecretItem(client, vaultId, title, secret, passwordCategory, concealedFieldType) {
|
|
1520
|
+
const existing = await findItemByTitle(client, vaultId, title);
|
|
1521
|
+
if (existing !== void 0) {
|
|
1522
|
+
const hasPasswordField = existing.fields.some((f) => f.title === PASSWORD_FIELD_TITLE);
|
|
1523
|
+
const updatedFields = hasPasswordField ? existing.fields.map((f) => f.title === PASSWORD_FIELD_TITLE ? { ...f, value: secret } : f) : [
|
|
1524
|
+
...existing.fields,
|
|
1525
|
+
{
|
|
1526
|
+
id: "password",
|
|
1527
|
+
title: PASSWORD_FIELD_TITLE,
|
|
1528
|
+
fieldType: concealedFieldType,
|
|
1529
|
+
value: secret
|
|
1530
|
+
}
|
|
1531
|
+
];
|
|
1532
|
+
await client.items.put({ ...existing, fields: updatedFields });
|
|
1533
|
+
} else {
|
|
1534
|
+
await client.items.create({
|
|
1535
|
+
category: passwordCategory,
|
|
1536
|
+
vaultId,
|
|
1537
|
+
title,
|
|
1538
|
+
tags: [TAG],
|
|
1539
|
+
fields: [
|
|
1540
|
+
{
|
|
1541
|
+
id: "password",
|
|
1542
|
+
title: PASSWORD_FIELD_TITLE,
|
|
1543
|
+
fieldType: concealedFieldType,
|
|
1544
|
+
value: secret
|
|
1545
|
+
}
|
|
1546
|
+
]
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
async function deleteSecretItem(client, vaultId, title) {
|
|
1551
|
+
const overview = await findItemOverviewByTitle(client, vaultId, title);
|
|
1552
|
+
if (overview === void 0) return false;
|
|
1553
|
+
await client.items.delete(vaultId, overview.id);
|
|
1554
|
+
return true;
|
|
1555
|
+
}
|
|
763
1556
|
var INTEGRATION_NAME = "vaultkeeper";
|
|
1557
|
+
var SDK_PACKAGE = "@1password/sdk";
|
|
1558
|
+
var SDK_INSTALL_URL = "https://developer.1password.com/docs/sdks/";
|
|
1559
|
+
var SDK_NOT_INSTALLED_MESSAGE = `1Password SDK (${SDK_PACKAGE}) is not installed. Install it to use the 1Password backend.`;
|
|
1560
|
+
var PRESENCE_WRITE_TIMEOUT_MS = 3e4;
|
|
1561
|
+
function isModuleNotFoundError(error) {
|
|
1562
|
+
const hasNotFoundCode = (value) => {
|
|
1563
|
+
if (value === null || typeof value !== "object" || !("code" in value)) {
|
|
1564
|
+
return false;
|
|
1565
|
+
}
|
|
1566
|
+
const { code } = value;
|
|
1567
|
+
return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
|
|
1568
|
+
};
|
|
1569
|
+
if (hasNotFoundCode(error)) {
|
|
1570
|
+
return true;
|
|
1571
|
+
}
|
|
1572
|
+
if (error !== null && typeof error === "object" && "cause" in error) {
|
|
1573
|
+
return hasNotFoundCode(error.cause);
|
|
1574
|
+
}
|
|
1575
|
+
return false;
|
|
1576
|
+
}
|
|
764
1577
|
var cachedVersion;
|
|
765
1578
|
function getIntegrationVersion() {
|
|
766
1579
|
if (cachedVersion !== void 0) return cachedVersion;
|
|
767
1580
|
const dir = dirname(fileURLToPath(import.meta.url));
|
|
768
|
-
const candidates = [
|
|
769
|
-
resolve(dir, "..", "..", "package.json"),
|
|
770
|
-
resolve(dir, "..", "package.json")
|
|
771
|
-
];
|
|
1581
|
+
const candidates = [resolve(dir, "..", "..", "package.json"), resolve(dir, "..", "package.json")];
|
|
772
1582
|
for (const candidate of candidates) {
|
|
773
1583
|
if (!existsSync(candidate)) continue;
|
|
774
1584
|
const raw = JSON.parse(readFileSync(candidate, "utf8"));
|
|
@@ -777,15 +1587,13 @@ function getIntegrationVersion() {
|
|
|
777
1587
|
return cachedVersion;
|
|
778
1588
|
}
|
|
779
1589
|
}
|
|
780
|
-
throw new
|
|
781
|
-
`Could not read version from vaultkeeper package.json. Tried paths: ${candidates.join(", ")}
|
|
1590
|
+
throw new SetupError(
|
|
1591
|
+
`Could not read version from vaultkeeper package.json. Tried paths: ${candidates.join(", ")}`,
|
|
1592
|
+
"vaultkeeper package.json"
|
|
782
1593
|
);
|
|
783
1594
|
}
|
|
784
1595
|
|
|
785
1596
|
// src/backend/one-password-backend.ts
|
|
786
|
-
var SDK_INSTALL_URL = "https://developer.1password.com/docs/sdks/";
|
|
787
|
-
var TAG = "vaultkeeper";
|
|
788
|
-
var PASSWORD_FIELD_TITLE = "password";
|
|
789
1597
|
var SESSION_TIMEOUT_MS = 3e4;
|
|
790
1598
|
function isWorkerSuccess(res) {
|
|
791
1599
|
return "value" in res;
|
|
@@ -797,6 +1605,17 @@ function isWorkerResponse(value) {
|
|
|
797
1605
|
return true;
|
|
798
1606
|
return false;
|
|
799
1607
|
}
|
|
1608
|
+
function isWorkerWriteSuccess(res) {
|
|
1609
|
+
const record = { ...res };
|
|
1610
|
+
return record.ok === true;
|
|
1611
|
+
}
|
|
1612
|
+
function isWorkerWriteResponse(value) {
|
|
1613
|
+
if (value === null || typeof value !== "object") return false;
|
|
1614
|
+
if ("ok" in value && value.ok === true) return true;
|
|
1615
|
+
if ("error" in value && typeof value.error === "string" && "code" in value && typeof value.code === "string")
|
|
1616
|
+
return true;
|
|
1617
|
+
return false;
|
|
1618
|
+
}
|
|
800
1619
|
var OnePasswordBackend = class {
|
|
801
1620
|
type = "1password";
|
|
802
1621
|
displayName = "1Password";
|
|
@@ -809,13 +1628,15 @@ var OnePasswordBackend = class {
|
|
|
809
1628
|
clientPromise;
|
|
810
1629
|
constructor(options) {
|
|
811
1630
|
if (options.accessMode === "per-access" && options.serviceAccountToken !== void 0) {
|
|
812
|
-
throw new
|
|
813
|
-
"per-access mode requires desktop biometric authentication and cannot be used with a service account token"
|
|
1631
|
+
throw new ConfigValidationError(
|
|
1632
|
+
"per-access mode requires desktop biometric authentication and cannot be used with a service account token",
|
|
1633
|
+
"options.accessMode"
|
|
814
1634
|
);
|
|
815
1635
|
}
|
|
816
1636
|
if (options.account !== void 0 && options.serviceAccountToken !== void 0) {
|
|
817
|
-
throw new
|
|
818
|
-
"account and serviceAccountToken are mutually exclusive \u2014 provide one or the other, not both"
|
|
1637
|
+
throw new ConfigValidationError(
|
|
1638
|
+
"account and serviceAccountToken are mutually exclusive \u2014 provide one or the other, not both",
|
|
1639
|
+
"options.serviceAccountToken"
|
|
819
1640
|
);
|
|
820
1641
|
}
|
|
821
1642
|
this.vaultId = options.vault;
|
|
@@ -832,10 +1653,50 @@ var OnePasswordBackend = class {
|
|
|
832
1653
|
const sdk = await this.tryLoadSdk();
|
|
833
1654
|
return sdk !== null;
|
|
834
1655
|
}
|
|
1656
|
+
/**
|
|
1657
|
+
* Report this instance's capabilities.
|
|
1658
|
+
*
|
|
1659
|
+
* @remarks
|
|
1660
|
+
* `presencePerUse` is `true` only in `per-access` mode, where every keyed
|
|
1661
|
+
* operation (`retrieve()`, `store()`, `delete()`) spawns a fresh worker
|
|
1662
|
+
* process that creates a new SDK client and triggers a per-operation
|
|
1663
|
+
* biometric approval that cannot be satisfied from the cached session
|
|
1664
|
+
* client. In the default `session` mode a single client is cached for all
|
|
1665
|
+
* operations, so operations ride one earlier unlock — that mode reports
|
|
1666
|
+
* `false`.
|
|
1667
|
+
*
|
|
1668
|
+
* **Operation coverage:** the per-access biometric path gates `retrieve()`,
|
|
1669
|
+
* `store()`, and `delete()` — every keyed operation reachable from
|
|
1670
|
+
* `presenceEnforcedOperations`. `exists()`/`list()` are read-only probes,
|
|
1671
|
+
* not keyed operations the presence contract covers, so they continue to
|
|
1672
|
+
* use the cached session client. This reports
|
|
1673
|
+
* `presenceEnforcedOperations: ['read', 'store', 'delete']` (issue #211
|
|
1674
|
+
* closed the earlier `store`/`delete` gap — see
|
|
1675
|
+
* {@link https://github.com/mike-north/vaultkeeper/issues/211}).
|
|
1676
|
+
*
|
|
1677
|
+
* **Truth-basis / cached-OS-unlock caveat:** even for a covered operation
|
|
1678
|
+
* the fresh action is "a fresh SDK client plus whatever the OS enforces at
|
|
1679
|
+
* that moment" — a "fresh process/SDK client" is **not** the same as a
|
|
1680
|
+
* guaranteed fresh hardware action. A per-access call can still ride a
|
|
1681
|
+
* cached OS-level Touch ID / Windows Hello unlock if the OS does not
|
|
1682
|
+
* re-prompt. The strongest per-use hardware guarantee comes from a touch
|
|
1683
|
+
* device (YubiKey / gpg smartcard).
|
|
1684
|
+
*/
|
|
1685
|
+
getCapabilities() {
|
|
1686
|
+
if (this.accessMode === "per-access") {
|
|
1687
|
+
return Promise.resolve({
|
|
1688
|
+
presencePerUse: true,
|
|
1689
|
+
presenceEnforcedOperations: ["read", "store", "delete"]
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
return Promise.resolve({ presencePerUse: false });
|
|
1693
|
+
}
|
|
835
1694
|
// ---- Session client management ----
|
|
836
1695
|
/**
|
|
837
1696
|
* Dynamically import the SDK. Returns `null` if the SDK is not installed or
|
|
838
|
-
* the native library cannot be loaded.
|
|
1697
|
+
* the native library cannot be loaded. Used by {@link isAvailable}, which
|
|
1698
|
+
* only needs a yes/no answer; call {@link loadSdkOrThrow} on paths that must
|
|
1699
|
+
* report *why* the SDK could not be loaded.
|
|
839
1700
|
*/
|
|
840
1701
|
async tryLoadSdk() {
|
|
841
1702
|
try {
|
|
@@ -845,6 +1706,23 @@ var OnePasswordBackend = class {
|
|
|
845
1706
|
return null;
|
|
846
1707
|
}
|
|
847
1708
|
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Dynamically import the SDK, throwing a typed {@link PluginNotFoundError}
|
|
1711
|
+
* only when the module cannot be resolved (the optional peer is not
|
|
1712
|
+
* installed). A present-but-broken SDK (native binding failure, init throw,
|
|
1713
|
+
* incompatible Node) surfaces its real error instead of a misleading
|
|
1714
|
+
* "not installed" message.
|
|
1715
|
+
*/
|
|
1716
|
+
async loadSdkOrThrow() {
|
|
1717
|
+
try {
|
|
1718
|
+
return await import('@1password/sdk');
|
|
1719
|
+
} catch (error) {
|
|
1720
|
+
if (isModuleNotFoundError(error)) {
|
|
1721
|
+
throw new PluginNotFoundError(SDK_NOT_INSTALLED_MESSAGE, SDK_PACKAGE, SDK_INSTALL_URL);
|
|
1722
|
+
}
|
|
1723
|
+
throw error;
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
848
1726
|
/**
|
|
849
1727
|
* Acquire (or create) a cached SDK client.
|
|
850
1728
|
* Wraps `createClient` with a configurable timeout (default 30 s) to handle
|
|
@@ -858,19 +1736,14 @@ var OnePasswordBackend = class {
|
|
|
858
1736
|
return this.clientPromise;
|
|
859
1737
|
}
|
|
860
1738
|
async createClientInternal() {
|
|
861
|
-
const sdk = await this.
|
|
862
|
-
if (sdk === null) {
|
|
863
|
-
throw new PluginNotFoundError(
|
|
864
|
-
"1Password SDK (@1password/sdk) is not available. Install it to use this backend.",
|
|
865
|
-
"@1password/sdk",
|
|
866
|
-
SDK_INSTALL_URL
|
|
867
|
-
);
|
|
868
|
-
}
|
|
1739
|
+
const sdk = await this.loadSdkOrThrow();
|
|
869
1740
|
const auth = this.buildAuth(sdk);
|
|
870
1741
|
let timerId;
|
|
871
1742
|
const timeoutPromise = new Promise((_resolve, reject) => {
|
|
872
1743
|
timerId = setTimeout(() => {
|
|
873
|
-
reject(
|
|
1744
|
+
reject(
|
|
1745
|
+
new BackendLockedError("1Password session timed out waiting for authentication", true)
|
|
1746
|
+
);
|
|
874
1747
|
}, this.sessionTimeoutMs);
|
|
875
1748
|
});
|
|
876
1749
|
try {
|
|
@@ -888,14 +1761,9 @@ var OnePasswordBackend = class {
|
|
|
888
1761
|
throw err;
|
|
889
1762
|
}
|
|
890
1763
|
if (err instanceof sdk.DesktopSessionExpiredError) {
|
|
891
|
-
throw new BackendLockedError(
|
|
892
|
-
"1Password session has expired. Please unlock the app.",
|
|
893
|
-
true
|
|
894
|
-
);
|
|
1764
|
+
throw new BackendLockedError("1Password session has expired. Please unlock the app.", true);
|
|
895
1765
|
}
|
|
896
|
-
throw new AuthorizationDeniedError(
|
|
897
|
-
`1Password authentication failed: ${String(err)}`
|
|
898
|
-
);
|
|
1766
|
+
throw new AuthorizationDeniedError(`1Password authentication failed: ${String(err)}`);
|
|
899
1767
|
} finally {
|
|
900
1768
|
if (timerId !== void 0) {
|
|
901
1769
|
clearTimeout(timerId);
|
|
@@ -909,79 +1777,21 @@ var OnePasswordBackend = class {
|
|
|
909
1777
|
const accountName = this.account ?? "";
|
|
910
1778
|
return new sdk.DesktopAuth(accountName);
|
|
911
1779
|
}
|
|
912
|
-
// ---- Helpers for item lookup by title ----
|
|
913
|
-
/**
|
|
914
|
-
* List all items in the vault tagged "vaultkeeper" and find one with the
|
|
915
|
-
* matching title (= secret ID). Returns `undefined` if not found.
|
|
916
|
-
*/
|
|
917
|
-
async findItemOverview(client, id) {
|
|
918
|
-
const overviews = await client.items.list(this.vaultId);
|
|
919
|
-
for (const overview of overviews) {
|
|
920
|
-
if (overview.title === id && overview.tags.includes(TAG)) {
|
|
921
|
-
return overview;
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
return void 0;
|
|
925
|
-
}
|
|
926
|
-
/**
|
|
927
|
-
* Fetch the full item for a given secret id. Returns `undefined` if not found.
|
|
928
|
-
*/
|
|
929
|
-
async findItem(client, id) {
|
|
930
|
-
const overview = await this.findItemOverview(client, id);
|
|
931
|
-
if (overview === void 0) return void 0;
|
|
932
|
-
return client.items.get(this.vaultId, overview.id);
|
|
933
|
-
}
|
|
934
|
-
/**
|
|
935
|
-
* Extract the concealed password field value from an item.
|
|
936
|
-
*/
|
|
937
|
-
extractSecret(item, id) {
|
|
938
|
-
for (const field of item.fields) {
|
|
939
|
-
if (field.title === PASSWORD_FIELD_TITLE) {
|
|
940
|
-
return field.value;
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
throw new SecretNotFoundError(
|
|
944
|
-
`Secret found in 1Password but missing password field: ${id}`
|
|
945
|
-
);
|
|
946
|
-
}
|
|
947
1780
|
// ---- SecretBackend / ListableBackend implementation ----
|
|
948
1781
|
async store(id, secret) {
|
|
1782
|
+
if (this.accessMode === "per-access") {
|
|
1783
|
+
return this.writeViaWorker("store", id, secret);
|
|
1784
|
+
}
|
|
949
1785
|
const { ItemCategory, ItemFieldType } = await this.requireSdk();
|
|
950
1786
|
const client = await this.acquireClient();
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
}) : [
|
|
960
|
-
...existing.fields,
|
|
961
|
-
{
|
|
962
|
-
id: "password",
|
|
963
|
-
title: PASSWORD_FIELD_TITLE,
|
|
964
|
-
fieldType: ItemFieldType.Concealed,
|
|
965
|
-
value: secret
|
|
966
|
-
}
|
|
967
|
-
];
|
|
968
|
-
await client.items.put({ ...existing, fields: updatedFields });
|
|
969
|
-
} else {
|
|
970
|
-
await client.items.create({
|
|
971
|
-
category: ItemCategory.Password,
|
|
972
|
-
vaultId: this.vaultId,
|
|
973
|
-
title: id,
|
|
974
|
-
tags: [TAG],
|
|
975
|
-
fields: [
|
|
976
|
-
{
|
|
977
|
-
id: "password",
|
|
978
|
-
title: PASSWORD_FIELD_TITLE,
|
|
979
|
-
fieldType: ItemFieldType.Concealed,
|
|
980
|
-
value: secret
|
|
981
|
-
}
|
|
982
|
-
]
|
|
983
|
-
});
|
|
984
|
-
}
|
|
1787
|
+
await storeSecretItem(
|
|
1788
|
+
client,
|
|
1789
|
+
this.vaultId,
|
|
1790
|
+
id,
|
|
1791
|
+
secret,
|
|
1792
|
+
ItemCategory.Password,
|
|
1793
|
+
ItemFieldType.Concealed
|
|
1794
|
+
);
|
|
985
1795
|
}
|
|
986
1796
|
async retrieve(id) {
|
|
987
1797
|
if (this.accessMode === "per-access") {
|
|
@@ -991,28 +1801,27 @@ var OnePasswordBackend = class {
|
|
|
991
1801
|
}
|
|
992
1802
|
async retrieveViaSession(id) {
|
|
993
1803
|
const client = await this.acquireClient();
|
|
994
|
-
const item = await
|
|
1804
|
+
const item = await findItemByTitle(client, this.vaultId, id);
|
|
995
1805
|
if (item === void 0) {
|
|
996
1806
|
throw new SecretNotFoundError(`Secret not found in 1Password: ${id}`);
|
|
997
1807
|
}
|
|
998
|
-
|
|
1808
|
+
const value = extractPasswordField(item);
|
|
1809
|
+
if (value === void 0) {
|
|
1810
|
+
throw new SecretNotFoundError(`Secret found in 1Password but missing password field: ${id}`);
|
|
1811
|
+
}
|
|
1812
|
+
return value;
|
|
999
1813
|
}
|
|
1000
1814
|
/**
|
|
1001
1815
|
* Spawn the per-access worker script that triggers a fresh biometric prompt
|
|
1002
1816
|
* for each retrieval, then returns the secret from its stdout.
|
|
1003
1817
|
*/
|
|
1004
1818
|
retrieveViaWorker(id) {
|
|
1005
|
-
return new Promise((
|
|
1006
|
-
const workerPath = join(
|
|
1007
|
-
dirname(fileURLToPath(import.meta.url)),
|
|
1008
|
-
"one-password-worker.js"
|
|
1009
|
-
);
|
|
1819
|
+
return new Promise((resolve3, reject) => {
|
|
1820
|
+
const workerPath = join(dirname(fileURLToPath(import.meta.url)), "one-password-worker.js");
|
|
1010
1821
|
const accountArg = this.account ?? "";
|
|
1011
|
-
const child = spawn(
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
1015
|
-
);
|
|
1822
|
+
const child = spawn(process.execPath, [workerPath, accountArg, this.vaultId, id], {
|
|
1823
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1824
|
+
});
|
|
1016
1825
|
const stdoutChunks = [];
|
|
1017
1826
|
const stderrChunks = [];
|
|
1018
1827
|
child.stdout.on("data", (chunk) => {
|
|
@@ -1021,12 +1830,19 @@ var OnePasswordBackend = class {
|
|
|
1021
1830
|
child.stderr.on("data", (chunk) => {
|
|
1022
1831
|
stderrChunks.push(chunk);
|
|
1023
1832
|
});
|
|
1024
|
-
child.on("close", (code) => {
|
|
1833
|
+
child.on("close", (code, signal) => {
|
|
1025
1834
|
const raw = Buffer.concat(stdoutChunks).toString("utf8").trim();
|
|
1026
1835
|
if (raw === "") {
|
|
1027
1836
|
const stderr = Buffer.concat(stderrChunks).toString("utf8").trim();
|
|
1028
|
-
const
|
|
1029
|
-
|
|
1837
|
+
const exitDescription = typeof signal === "string" ? `terminated by signal ${signal}` : `exit code ${String(code)}`;
|
|
1838
|
+
const detail = stderr !== "" ? stderr : exitDescription;
|
|
1839
|
+
reject(
|
|
1840
|
+
new BackendUnavailableError(
|
|
1841
|
+
`1Password per-access worker crashed for secret ${id}: ${detail}`,
|
|
1842
|
+
"worker-crashed",
|
|
1843
|
+
["1password"]
|
|
1844
|
+
)
|
|
1845
|
+
);
|
|
1030
1846
|
return;
|
|
1031
1847
|
}
|
|
1032
1848
|
let parsed;
|
|
@@ -1037,13 +1853,20 @@ var OnePasswordBackend = class {
|
|
|
1037
1853
|
return;
|
|
1038
1854
|
}
|
|
1039
1855
|
if (!isWorkerResponse(parsed)) {
|
|
1040
|
-
reject(
|
|
1856
|
+
reject(
|
|
1857
|
+
new SecretNotFoundError(`Worker returned unexpected response shape for secret: ${id}`)
|
|
1858
|
+
);
|
|
1041
1859
|
return;
|
|
1042
1860
|
}
|
|
1043
1861
|
if (isWorkerSuccess(parsed)) {
|
|
1044
|
-
|
|
1862
|
+
resolve3(parsed.value);
|
|
1045
1863
|
} else {
|
|
1046
1864
|
switch (parsed.code) {
|
|
1865
|
+
case "PLUGIN_NOT_FOUND":
|
|
1866
|
+
reject(
|
|
1867
|
+
new PluginNotFoundError(SDK_NOT_INSTALLED_MESSAGE, SDK_PACKAGE, SDK_INSTALL_URL)
|
|
1868
|
+
);
|
|
1869
|
+
break;
|
|
1047
1870
|
case "NOT_FOUND":
|
|
1048
1871
|
reject(new SecretNotFoundError(`Secret not found in 1Password: ${id}`));
|
|
1049
1872
|
break;
|
|
@@ -1053,29 +1876,175 @@ var OnePasswordBackend = class {
|
|
|
1053
1876
|
case "LOCKED":
|
|
1054
1877
|
reject(new BackendLockedError("1Password is locked. Please unlock and retry.", true));
|
|
1055
1878
|
break;
|
|
1879
|
+
case "INTERNAL":
|
|
1880
|
+
reject(
|
|
1881
|
+
new BackendUnavailableError(
|
|
1882
|
+
`1Password per-access worker failed for secret ${id}: ${parsed.error}`,
|
|
1883
|
+
"worker-internal-error",
|
|
1884
|
+
["1password"]
|
|
1885
|
+
)
|
|
1886
|
+
);
|
|
1887
|
+
break;
|
|
1056
1888
|
default:
|
|
1057
1889
|
reject(new SecretNotFoundError(`Worker failed for secret ${id}: ${parsed.error}`));
|
|
1058
1890
|
}
|
|
1059
1891
|
}
|
|
1060
1892
|
});
|
|
1061
1893
|
child.on("error", (err) => {
|
|
1062
|
-
reject(
|
|
1063
|
-
|
|
1064
|
-
|
|
1894
|
+
reject(
|
|
1895
|
+
new BackendUnavailableError(
|
|
1896
|
+
`Failed to spawn 1Password per-access worker at ${workerPath}: ${String(err)}`,
|
|
1897
|
+
"worker-spawn-failed",
|
|
1898
|
+
["1password"]
|
|
1899
|
+
)
|
|
1900
|
+
);
|
|
1901
|
+
});
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* Spawn the per-access worker script to perform a `store` or `delete`,
|
|
1906
|
+
* triggering a fresh biometric prompt for this single write (issue #211).
|
|
1907
|
+
*
|
|
1908
|
+
* @remarks
|
|
1909
|
+
* For `store`, `secret` is delivered to the worker over **stdin**, never
|
|
1910
|
+
* argv — it must never appear in a process listing, shell history, or log.
|
|
1911
|
+
* `delete` needs no payload, so no stdin is written and the worker's stdin
|
|
1912
|
+
* is left `'ignore'`d, mirroring the retrieve path's spawn options.
|
|
1913
|
+
*/
|
|
1914
|
+
writeViaWorker(op, id, secret) {
|
|
1915
|
+
return new Promise((resolve3, reject) => {
|
|
1916
|
+
const workerPath = join(dirname(fileURLToPath(import.meta.url)), "one-password-worker.js");
|
|
1917
|
+
const accountArg = this.account ?? "";
|
|
1918
|
+
const needsStdin = secret !== void 0;
|
|
1919
|
+
const child = spawn(process.execPath, [workerPath, accountArg, this.vaultId, id, op], {
|
|
1920
|
+
stdio: [needsStdin ? "pipe" : "ignore", "pipe", "pipe"]
|
|
1921
|
+
});
|
|
1922
|
+
if (needsStdin && child.stdin !== null) {
|
|
1923
|
+
child.stdin.on("error", () => {
|
|
1924
|
+
});
|
|
1925
|
+
child.stdin.write(secret, "utf8");
|
|
1926
|
+
child.stdin.end();
|
|
1927
|
+
}
|
|
1928
|
+
const stdoutChunks = [];
|
|
1929
|
+
const stderrChunks = [];
|
|
1930
|
+
child.stdout?.on("data", (chunk) => {
|
|
1931
|
+
stdoutChunks.push(chunk);
|
|
1932
|
+
});
|
|
1933
|
+
child.stderr?.on("data", (chunk) => {
|
|
1934
|
+
stderrChunks.push(chunk);
|
|
1935
|
+
});
|
|
1936
|
+
child.on("close", (code, signal) => {
|
|
1937
|
+
const raw = Buffer.concat(stdoutChunks).toString("utf8").trim();
|
|
1938
|
+
if (raw === "") {
|
|
1939
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf8").trim();
|
|
1940
|
+
const exitDescription = typeof signal === "string" ? `terminated by signal ${signal}` : `exit code ${String(code)}`;
|
|
1941
|
+
const detail = stderr !== "" ? stderr : exitDescription;
|
|
1942
|
+
reject(
|
|
1943
|
+
new BackendUnavailableError(
|
|
1944
|
+
`1Password per-access worker crashed during ${op} of secret ${id}: ${detail}`,
|
|
1945
|
+
"worker-crashed",
|
|
1946
|
+
["1password"]
|
|
1947
|
+
)
|
|
1948
|
+
);
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
let parsed;
|
|
1952
|
+
try {
|
|
1953
|
+
parsed = JSON.parse(raw);
|
|
1954
|
+
} catch {
|
|
1955
|
+
reject(
|
|
1956
|
+
new BackendUnavailableError(
|
|
1957
|
+
`Worker returned unparseable output during ${op} of secret ${id}`,
|
|
1958
|
+
"worker-internal-error",
|
|
1959
|
+
["1password"]
|
|
1960
|
+
)
|
|
1961
|
+
);
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
if (!isWorkerWriteResponse(parsed)) {
|
|
1965
|
+
reject(
|
|
1966
|
+
new BackendUnavailableError(
|
|
1967
|
+
`Worker returned unexpected response shape during ${op} of secret ${id}`,
|
|
1968
|
+
"worker-internal-error",
|
|
1969
|
+
["1password"]
|
|
1970
|
+
)
|
|
1971
|
+
);
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
if (isWorkerWriteSuccess(parsed)) {
|
|
1975
|
+
resolve3();
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
switch (parsed.code) {
|
|
1979
|
+
case "PLUGIN_NOT_FOUND":
|
|
1980
|
+
reject(new PluginNotFoundError(SDK_NOT_INSTALLED_MESSAGE, SDK_PACKAGE, SDK_INSTALL_URL));
|
|
1981
|
+
break;
|
|
1982
|
+
case "NOT_FOUND":
|
|
1983
|
+
reject(new SecretNotFoundError(`Secret not found in 1Password: ${id}`));
|
|
1984
|
+
break;
|
|
1985
|
+
case "LOCKED":
|
|
1986
|
+
reject(new BackendLockedError("1Password is locked. Please unlock and retry.", true));
|
|
1987
|
+
break;
|
|
1988
|
+
case "PRESENCE_DECLINED":
|
|
1989
|
+
reject(
|
|
1990
|
+
new PresenceDeclinedError(
|
|
1991
|
+
`1Password ${op} presence action was declined for secret ${id}`,
|
|
1992
|
+
"1password"
|
|
1993
|
+
)
|
|
1994
|
+
);
|
|
1995
|
+
break;
|
|
1996
|
+
case "PRESENCE_TIMEOUT":
|
|
1997
|
+
reject(
|
|
1998
|
+
new PresenceTimeoutError(
|
|
1999
|
+
`1Password ${op} presence action timed out for secret ${id}`,
|
|
2000
|
+
"1password",
|
|
2001
|
+
PRESENCE_WRITE_TIMEOUT_MS
|
|
2002
|
+
)
|
|
2003
|
+
);
|
|
2004
|
+
break;
|
|
2005
|
+
case "INTERNAL":
|
|
2006
|
+
reject(
|
|
2007
|
+
new BackendUnavailableError(
|
|
2008
|
+
`1Password per-access worker failed during ${op} of secret ${id}: ${parsed.error}`,
|
|
2009
|
+
"worker-internal-error",
|
|
2010
|
+
["1password"]
|
|
2011
|
+
)
|
|
2012
|
+
);
|
|
2013
|
+
break;
|
|
2014
|
+
default:
|
|
2015
|
+
reject(
|
|
2016
|
+
new BackendUnavailableError(
|
|
2017
|
+
`Worker failed during ${op} of secret ${id}: ${parsed.error}`,
|
|
2018
|
+
"worker-internal-error",
|
|
2019
|
+
["1password"]
|
|
2020
|
+
)
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
2023
|
+
});
|
|
2024
|
+
child.on("error", (err) => {
|
|
2025
|
+
reject(
|
|
2026
|
+
new BackendUnavailableError(
|
|
2027
|
+
`Failed to spawn 1Password per-access worker at ${workerPath}: ${String(err)}`,
|
|
2028
|
+
"worker-spawn-failed",
|
|
2029
|
+
["1password"]
|
|
2030
|
+
)
|
|
2031
|
+
);
|
|
1065
2032
|
});
|
|
1066
2033
|
});
|
|
1067
2034
|
}
|
|
1068
2035
|
async delete(id) {
|
|
2036
|
+
if (this.accessMode === "per-access") {
|
|
2037
|
+
return this.writeViaWorker("delete", id);
|
|
2038
|
+
}
|
|
1069
2039
|
const client = await this.acquireClient();
|
|
1070
|
-
const
|
|
1071
|
-
if (
|
|
2040
|
+
const deleted = await deleteSecretItem(client, this.vaultId, id);
|
|
2041
|
+
if (!deleted) {
|
|
1072
2042
|
throw new SecretNotFoundError(`Secret not found in 1Password: ${id}`);
|
|
1073
2043
|
}
|
|
1074
|
-
await client.items.delete(this.vaultId, overview.id);
|
|
1075
2044
|
}
|
|
1076
2045
|
async exists(id) {
|
|
1077
2046
|
const client = await this.acquireClient();
|
|
1078
|
-
const overview = await
|
|
2047
|
+
const overview = await findItemOverviewByTitle(client, this.vaultId, id);
|
|
1079
2048
|
return overview !== void 0;
|
|
1080
2049
|
}
|
|
1081
2050
|
async list() {
|
|
@@ -1090,33 +2059,31 @@ var OnePasswordBackend = class {
|
|
|
1090
2059
|
return ids;
|
|
1091
2060
|
}
|
|
1092
2061
|
// ---- Private helpers ----
|
|
1093
|
-
/**
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
"@1password/sdk",
|
|
1100
|
-
SDK_INSTALL_URL
|
|
1101
|
-
);
|
|
1102
|
-
}
|
|
1103
|
-
return sdk;
|
|
2062
|
+
/**
|
|
2063
|
+
* Load SDK, throwing a typed {@link PluginNotFoundError} when it is not
|
|
2064
|
+
* installed and surfacing the real error when it is present but broken.
|
|
2065
|
+
*/
|
|
2066
|
+
requireSdk() {
|
|
2067
|
+
return this.loadSdkOrThrow();
|
|
1104
2068
|
}
|
|
1105
2069
|
};
|
|
1106
2070
|
var YKMAN_INSTALL_URL = "https://developers.yubico.com/yubikey-manager/";
|
|
1107
|
-
var STORAGE_DIR_NAME2 =
|
|
2071
|
+
var STORAGE_DIR_NAME2 = path2.join(".vaultkeeper", "yubikey");
|
|
1108
2072
|
var METADATA_FILE = "metadata.json";
|
|
1109
2073
|
var DEVICE_TIMEOUT_MS = 5e3;
|
|
1110
2074
|
var GCM_IV_BYTES2 = 12;
|
|
1111
2075
|
var GCM_KEY_BYTES2 = 32;
|
|
1112
|
-
var
|
|
2076
|
+
var GCM_TAG_LENGTH_BITS2 = 128;
|
|
1113
2077
|
var FORMAT_VERSION = "1";
|
|
1114
|
-
function
|
|
1115
|
-
|
|
2078
|
+
function resolveStorageDir3(configuredPath) {
|
|
2079
|
+
if (configuredPath !== void 0) {
|
|
2080
|
+
return configuredPath;
|
|
2081
|
+
}
|
|
2082
|
+
return path2.join(os.homedir(), STORAGE_DIR_NAME2);
|
|
1116
2083
|
}
|
|
1117
2084
|
function getEntryPath3(storageDir, id) {
|
|
1118
2085
|
const safeId = Buffer.from(id, "utf8").toString("hex");
|
|
1119
|
-
return
|
|
2086
|
+
return path2.join(storageDir, `${safeId}.enc`);
|
|
1120
2087
|
}
|
|
1121
2088
|
function isStringRecord(value) {
|
|
1122
2089
|
if (value === null || typeof value !== "object") {
|
|
@@ -1125,9 +2092,9 @@ function isStringRecord(value) {
|
|
|
1125
2092
|
return Object.values(value).every((v) => typeof v === "string");
|
|
1126
2093
|
}
|
|
1127
2094
|
async function loadMetadata(storageDir) {
|
|
1128
|
-
const metaPath =
|
|
2095
|
+
const metaPath = path2.join(storageDir, METADATA_FILE);
|
|
1129
2096
|
try {
|
|
1130
|
-
const raw = await
|
|
2097
|
+
const raw = await fs3.readFile(metaPath, "utf8");
|
|
1131
2098
|
const parsed = JSON.parse(raw);
|
|
1132
2099
|
if (parsed !== null && typeof parsed === "object" && "entries" in parsed && isStringRecord(parsed.entries)) {
|
|
1133
2100
|
return { entries: parsed.entries };
|
|
@@ -1138,30 +2105,31 @@ async function loadMetadata(storageDir) {
|
|
|
1138
2105
|
}
|
|
1139
2106
|
}
|
|
1140
2107
|
async function saveMetadata(storageDir, metadata) {
|
|
1141
|
-
const metaPath =
|
|
1142
|
-
await
|
|
2108
|
+
const metaPath = path2.join(storageDir, METADATA_FILE);
|
|
2109
|
+
await fs3.writeFile(metaPath, JSON.stringify(metadata, null, 2), { mode: 384 });
|
|
1143
2110
|
}
|
|
1144
2111
|
var HMAC_RESPONSE_HEX_LENGTH = 40;
|
|
1145
2112
|
var HMAC_RESPONSE_RE = /^[0-9a-fA-F]{40}$/;
|
|
1146
2113
|
function deriveKey(hmacResponse, id) {
|
|
1147
2114
|
const trimmed = hmacResponse.trim();
|
|
1148
2115
|
if (!HMAC_RESPONSE_RE.test(trimmed)) {
|
|
1149
|
-
throw new
|
|
1150
|
-
`Invalid YubiKey HMAC response: expected exactly ${String(HMAC_RESPONSE_HEX_LENGTH)} hex characters (20 bytes), got ${String(trimmed.length)} characters
|
|
2116
|
+
throw new SetupError(
|
|
2117
|
+
`Invalid YubiKey HMAC response: expected exactly ${String(HMAC_RESPONSE_HEX_LENGTH)} hex characters (20 bytes), got ${String(trimmed.length)} characters`,
|
|
2118
|
+
"ykman"
|
|
1151
2119
|
);
|
|
1152
2120
|
}
|
|
1153
2121
|
const ikm = Buffer.from(trimmed, "hex");
|
|
1154
2122
|
const info = Buffer.from(`vaultkeeper-yubikey:${id}`, "utf8");
|
|
1155
|
-
const keyMaterial =
|
|
2123
|
+
const keyMaterial = crypto2.hkdfSync("sha256", ikm, Buffer.alloc(0), info, GCM_KEY_BYTES2);
|
|
1156
2124
|
return Buffer.from(keyMaterial);
|
|
1157
2125
|
}
|
|
1158
2126
|
function encryptGcm2(key, plaintext) {
|
|
1159
|
-
const iv =
|
|
2127
|
+
const iv = crypto2.randomBytes(GCM_IV_BYTES2);
|
|
1160
2128
|
let encrypted;
|
|
1161
2129
|
let authTag;
|
|
1162
2130
|
try {
|
|
1163
|
-
const cipher =
|
|
1164
|
-
authTagLength:
|
|
2131
|
+
const cipher = crypto2.createCipheriv("aes-256-gcm", key, iv, {
|
|
2132
|
+
authTagLength: GCM_TAG_LENGTH_BITS2 / 8
|
|
1165
2133
|
});
|
|
1166
2134
|
encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
1167
2135
|
authTag = cipher.getAuthTag();
|
|
@@ -1178,48 +2146,52 @@ function encryptGcm2(key, plaintext) {
|
|
|
1178
2146
|
authTag?.fill(0);
|
|
1179
2147
|
}
|
|
1180
2148
|
}
|
|
1181
|
-
function decryptGcm2(key, encoded) {
|
|
2149
|
+
function decryptGcm2(key, encoded, path9) {
|
|
1182
2150
|
const parts = encoded.split(":");
|
|
1183
2151
|
const versionSegment = parts[0] ?? "";
|
|
1184
2152
|
const parsedVersion = parseInt(versionSegment, 10);
|
|
1185
2153
|
const isNumericVersion = String(parsedVersion) === versionSegment && !Number.isNaN(parsedVersion);
|
|
1186
2154
|
if (!isNumericVersion) {
|
|
1187
2155
|
key.fill(0);
|
|
1188
|
-
throw new
|
|
1189
|
-
"Encrypted file uses a legacy format (AES-256-CBC). Delete the secret and re-store it to migrate to AES-256-GCM."
|
|
2156
|
+
throw new DecryptionError(
|
|
2157
|
+
"Encrypted file uses a legacy format (AES-256-CBC). Delete the secret and re-store it to migrate to AES-256-GCM.",
|
|
2158
|
+
path9
|
|
1190
2159
|
);
|
|
1191
2160
|
}
|
|
1192
2161
|
if (versionSegment !== FORMAT_VERSION) {
|
|
1193
2162
|
key.fill(0);
|
|
1194
|
-
throw new
|
|
1195
|
-
`Unsupported encrypted file version: ${versionSegment}. This vaultkeeper build only supports version ${FORMAT_VERSION}. Upgrade vaultkeeper to read this secret
|
|
2163
|
+
throw new DecryptionError(
|
|
2164
|
+
`Unsupported encrypted file version: ${versionSegment}. This vaultkeeper build only supports version ${FORMAT_VERSION}. Upgrade vaultkeeper to read this secret.`,
|
|
2165
|
+
path9
|
|
1196
2166
|
);
|
|
1197
2167
|
}
|
|
1198
2168
|
if (parts.length !== 4) {
|
|
1199
2169
|
key.fill(0);
|
|
1200
|
-
throw new
|
|
1201
|
-
`Invalid encrypted file format: expected ${FORMAT_VERSION}:iv:authTag:ciphertext
|
|
2170
|
+
throw new DecryptionError(
|
|
2171
|
+
`Invalid encrypted file format: expected ${FORMAT_VERSION}:iv:authTag:ciphertext`,
|
|
2172
|
+
path9
|
|
1202
2173
|
);
|
|
1203
2174
|
}
|
|
1204
2175
|
const [_version, ivB64, authTagB64, ciphertextB64] = parts;
|
|
1205
2176
|
if (ivB64 === void 0 || authTagB64 === void 0 || ciphertextB64 === void 0) {
|
|
1206
2177
|
key.fill(0);
|
|
1207
|
-
throw new
|
|
2178
|
+
throw new DecryptionError("Invalid encrypted file format: missing part", path9);
|
|
1208
2179
|
}
|
|
1209
2180
|
let decrypted;
|
|
1210
2181
|
try {
|
|
1211
2182
|
const iv = Buffer.from(ivB64, "base64");
|
|
1212
2183
|
const authTag = Buffer.from(authTagB64, "base64");
|
|
1213
2184
|
const ciphertext = Buffer.from(ciphertextB64, "base64");
|
|
1214
|
-
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv, {
|
|
1215
|
-
authTagLength: GCM_TAG_LENGTH_BITS / 8
|
|
1216
|
-
});
|
|
1217
|
-
decipher.setAuthTag(authTag);
|
|
1218
2185
|
try {
|
|
2186
|
+
const decipher = crypto2.createDecipheriv("aes-256-gcm", key, iv, {
|
|
2187
|
+
authTagLength: GCM_TAG_LENGTH_BITS2 / 8
|
|
2188
|
+
});
|
|
2189
|
+
decipher.setAuthTag(authTag);
|
|
1219
2190
|
decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
1220
2191
|
} catch (err) {
|
|
1221
|
-
throw new
|
|
1222
|
-
`GCM authentication failed \u2014 ciphertext may be tampered: ${err instanceof Error ? err.message : String(err)}
|
|
2192
|
+
throw new DecryptionError(
|
|
2193
|
+
`GCM authentication failed \u2014 ciphertext may be tampered or corrupt: ${err instanceof Error ? err.message : String(err)}`,
|
|
2194
|
+
path9
|
|
1223
2195
|
);
|
|
1224
2196
|
}
|
|
1225
2197
|
const plaintext = decrypted.toString("utf8");
|
|
@@ -1232,6 +2204,45 @@ function decryptGcm2(key, encoded) {
|
|
|
1232
2204
|
var YubikeyBackend = class {
|
|
1233
2205
|
type = "yubikey";
|
|
1234
2206
|
displayName = "YubiKey";
|
|
2207
|
+
#storageDir;
|
|
2208
|
+
/**
|
|
2209
|
+
* Whether the configured challenge-response slot enforces a touch for every
|
|
2210
|
+
* operation. When `true`, each `store`/`retrieve`/`delete` requires a fresh
|
|
2211
|
+
* physical tap (see {@link YubikeyBackend.getCapabilities}). Sourced from
|
|
2212
|
+
* configuration, not hardcoded by type — a slot without a touch policy
|
|
2213
|
+
* reports `false`.
|
|
2214
|
+
*/
|
|
2215
|
+
#requireTouch;
|
|
2216
|
+
/**
|
|
2217
|
+
* @param storageDir - Directory in which encrypted secrets and metadata are
|
|
2218
|
+
* stored. Sourced from `BackendConfig.path`. Defaults to
|
|
2219
|
+
* `$HOME/.vaultkeeper/yubikey`.
|
|
2220
|
+
* @param requireTouch - Whether the configured challenge-response slot
|
|
2221
|
+
* enforces a touch-per-operation policy. Defaults to `false`. Sourced from
|
|
2222
|
+
* the operator's configuration and must match the physical slot's policy
|
|
2223
|
+
* (verify with `ykman otp info`); it governs the value reported by
|
|
2224
|
+
* {@link YubikeyBackend.getCapabilities}.
|
|
2225
|
+
*/
|
|
2226
|
+
constructor(storageDir, requireTouch = false) {
|
|
2227
|
+
this.#storageDir = resolveStorageDir3(storageDir);
|
|
2228
|
+
this.#requireTouch = requireTouch;
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* Report this instance's capabilities.
|
|
2232
|
+
*
|
|
2233
|
+
* @remarks
|
|
2234
|
+
* `presencePerUse` is `true` only when the configured slot enforces a
|
|
2235
|
+
* touch-per-operation policy (`requireTouch`), in which case every
|
|
2236
|
+
* challenge-response — and therefore every `store`/`retrieve`/`delete` — forces
|
|
2237
|
+
* a fresh physical tap that cannot be satisfied from any cached state. A slot
|
|
2238
|
+
* without a touch policy reports `false`; the answer is never derived from the
|
|
2239
|
+
* backend `type` alone. Truth-basis: the reported value comes from the
|
|
2240
|
+
* operator-declared `touchPolicy` configuration; confirm it matches the
|
|
2241
|
+
* physical slot with `ykman otp info` (a manual verification).
|
|
2242
|
+
*/
|
|
2243
|
+
getCapabilities() {
|
|
2244
|
+
return Promise.resolve({ presencePerUse: this.#requireTouch });
|
|
2245
|
+
}
|
|
1235
2246
|
async isAvailable() {
|
|
1236
2247
|
try {
|
|
1237
2248
|
const result = await execCommandFull("ykman", ["--version"]);
|
|
@@ -1265,43 +2276,43 @@ var YubikeyBackend = class {
|
|
|
1265
2276
|
const challenge = Buffer.from(`vaultkeeper:${id}`, "utf8").toString("hex");
|
|
1266
2277
|
const responseResult = await execCommandFull("ykman", ["otp", "calculate", "2", challenge]);
|
|
1267
2278
|
if (responseResult.exitCode !== 0) {
|
|
1268
|
-
throw new
|
|
2279
|
+
throw new ExecError(`YubiKey challenge-response failed: ${responseResult.stderr}`, "ykman");
|
|
1269
2280
|
}
|
|
1270
2281
|
return responseResult.stdout.trim();
|
|
1271
2282
|
}
|
|
1272
2283
|
async store(id, secret) {
|
|
1273
2284
|
await this.requireDevice();
|
|
1274
|
-
const storageDir =
|
|
1275
|
-
await
|
|
2285
|
+
const storageDir = this.#storageDir;
|
|
2286
|
+
await fs3.mkdir(storageDir, { recursive: true, mode: 448 });
|
|
1276
2287
|
const hmacResponse = await this.challengeResponse(id);
|
|
1277
2288
|
const key = deriveKey(hmacResponse, id);
|
|
1278
2289
|
const encrypted = encryptGcm2(key, secret);
|
|
1279
2290
|
const entryPath = getEntryPath3(storageDir, id);
|
|
1280
|
-
await
|
|
2291
|
+
await fs3.writeFile(entryPath, encrypted, { mode: 384 });
|
|
1281
2292
|
const metadata = await loadMetadata(storageDir);
|
|
1282
2293
|
metadata.entries[id] = entryPath;
|
|
1283
2294
|
await saveMetadata(storageDir, metadata);
|
|
1284
2295
|
}
|
|
1285
2296
|
async retrieve(id) {
|
|
1286
2297
|
await this.requireDevice();
|
|
1287
|
-
const storageDir =
|
|
2298
|
+
const storageDir = this.#storageDir;
|
|
1288
2299
|
const entryPath = getEntryPath3(storageDir, id);
|
|
1289
2300
|
try {
|
|
1290
|
-
await
|
|
2301
|
+
await fs3.access(entryPath);
|
|
1291
2302
|
} catch {
|
|
1292
2303
|
throw new SecretNotFoundError(`Secret not found in YubiKey store: ${id}`);
|
|
1293
2304
|
}
|
|
1294
|
-
const encoded = await
|
|
2305
|
+
const encoded = await fs3.readFile(entryPath, "utf8");
|
|
1295
2306
|
const hmacResponse = await this.challengeResponse(id);
|
|
1296
2307
|
const key = deriveKey(hmacResponse, id);
|
|
1297
|
-
return decryptGcm2(key, encoded);
|
|
2308
|
+
return decryptGcm2(key, encoded, entryPath);
|
|
1298
2309
|
}
|
|
1299
2310
|
async delete(id) {
|
|
1300
2311
|
await this.requireDevice();
|
|
1301
|
-
const storageDir =
|
|
2312
|
+
const storageDir = this.#storageDir;
|
|
1302
2313
|
const entryPath = getEntryPath3(storageDir, id);
|
|
1303
2314
|
try {
|
|
1304
|
-
await
|
|
2315
|
+
await fs3.unlink(entryPath);
|
|
1305
2316
|
} catch (err) {
|
|
1306
2317
|
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
1307
2318
|
throw new SecretNotFoundError(`Secret not found in YubiKey store: ${id}`);
|
|
@@ -1313,17 +2324,17 @@ var YubikeyBackend = class {
|
|
|
1313
2324
|
await saveMetadata(storageDir, metadata);
|
|
1314
2325
|
}
|
|
1315
2326
|
async exists(id) {
|
|
1316
|
-
const storageDir =
|
|
2327
|
+
const storageDir = this.#storageDir;
|
|
1317
2328
|
const entryPath = getEntryPath3(storageDir, id);
|
|
1318
2329
|
try {
|
|
1319
|
-
await
|
|
2330
|
+
await fs3.access(entryPath);
|
|
1320
2331
|
return true;
|
|
1321
2332
|
} catch {
|
|
1322
2333
|
return false;
|
|
1323
2334
|
}
|
|
1324
2335
|
}
|
|
1325
2336
|
async list() {
|
|
1326
|
-
const storageDir =
|
|
2337
|
+
const storageDir = this.#storageDir;
|
|
1327
2338
|
const metadata = await loadMetadata(storageDir);
|
|
1328
2339
|
return Object.keys(metadata.entries);
|
|
1329
2340
|
}
|
|
@@ -1331,9 +2342,12 @@ var YubikeyBackend = class {
|
|
|
1331
2342
|
|
|
1332
2343
|
// src/backend/register-builtins.ts
|
|
1333
2344
|
function registerBuiltinBackends() {
|
|
1334
|
-
BackendRegistry.register(
|
|
2345
|
+
BackendRegistry.register(
|
|
2346
|
+
"file",
|
|
2347
|
+
(config, configDir) => new FileBackend(config?.path, configDir)
|
|
2348
|
+
);
|
|
1335
2349
|
BackendRegistry.register("keychain", () => new KeychainBackend());
|
|
1336
|
-
BackendRegistry.register("dpapi", () => new DpapiBackend());
|
|
2350
|
+
BackendRegistry.register("dpapi", (config) => new DpapiBackend(config?.path));
|
|
1337
2351
|
BackendRegistry.register("secret-tool", () => new SecretToolBackend());
|
|
1338
2352
|
BackendRegistry.register("1password", (config) => {
|
|
1339
2353
|
const opts = config?.options;
|
|
@@ -1353,7 +2367,17 @@ function registerBuiltinBackends() {
|
|
|
1353
2367
|
}
|
|
1354
2368
|
return new OnePasswordBackend(opOptions);
|
|
1355
2369
|
});
|
|
1356
|
-
BackendRegistry.register(
|
|
2370
|
+
BackendRegistry.register(
|
|
2371
|
+
"yubikey",
|
|
2372
|
+
(config) => (
|
|
2373
|
+
// `touchPolicy: 'required'` in the backend options declares that the
|
|
2374
|
+
// configured challenge-response slot enforces a touch per operation, which
|
|
2375
|
+
// is what makes the instance presence-per-use capable (see
|
|
2376
|
+
// YubikeyBackend.getCapabilities). Any other value (or absent) reports
|
|
2377
|
+
// false — presence is never assumed from the backend type alone.
|
|
2378
|
+
new YubikeyBackend(config?.path, config?.options?.touchPolicy === "required")
|
|
2379
|
+
)
|
|
2380
|
+
);
|
|
1357
2381
|
}
|
|
1358
2382
|
registerBuiltinBackends();
|
|
1359
2383
|
|
|
@@ -1361,18 +2385,37 @@ registerBuiltinBackends();
|
|
|
1361
2385
|
function isListableBackend(backend) {
|
|
1362
2386
|
return "list" in backend && typeof backend.list === "function";
|
|
1363
2387
|
}
|
|
2388
|
+
function isPresenceCapableBackend(backend) {
|
|
2389
|
+
return "getCapabilities" in backend && typeof backend.getCapabilities === "function";
|
|
2390
|
+
}
|
|
2391
|
+
async function getBackendCapabilities(backend) {
|
|
2392
|
+
if (isPresenceCapableBackend(backend)) {
|
|
2393
|
+
return backend.getCapabilities();
|
|
2394
|
+
}
|
|
2395
|
+
return { presencePerUse: false };
|
|
2396
|
+
}
|
|
2397
|
+
function isSigningBackend(backend) {
|
|
2398
|
+
return "generateSigningKey" in backend && typeof backend.generateSigningKey === "function" && "getPublicKey" in backend && typeof backend.getPublicKey === "function" && "signWithKey" in backend && typeof backend.signWithKey === "function";
|
|
2399
|
+
}
|
|
1364
2400
|
function hashExecutable(filePath) {
|
|
1365
|
-
return new Promise((
|
|
1366
|
-
const hash =
|
|
1367
|
-
const stream =
|
|
2401
|
+
return new Promise((resolve3, reject) => {
|
|
2402
|
+
const hash = crypto2.createHash("sha256");
|
|
2403
|
+
const stream = fs6.createReadStream(filePath);
|
|
1368
2404
|
stream.on("data", (chunk) => {
|
|
1369
2405
|
hash.update(chunk);
|
|
1370
2406
|
});
|
|
1371
2407
|
stream.on("end", () => {
|
|
1372
|
-
|
|
2408
|
+
resolve3(hash.digest("hex"));
|
|
1373
2409
|
});
|
|
1374
2410
|
stream.on("error", (err) => {
|
|
1375
|
-
reject(
|
|
2411
|
+
reject(
|
|
2412
|
+
new FilesystemError(
|
|
2413
|
+
`Cannot read executable at ${filePath}: ${err.message}`,
|
|
2414
|
+
filePath,
|
|
2415
|
+
"read",
|
|
2416
|
+
err
|
|
2417
|
+
)
|
|
2418
|
+
);
|
|
1376
2419
|
});
|
|
1377
2420
|
});
|
|
1378
2421
|
}
|
|
@@ -1380,7 +2423,8 @@ var MANIFEST_FILENAME = "trust-manifest.json";
|
|
|
1380
2423
|
function isRawManifest(value) {
|
|
1381
2424
|
if (typeof value !== "object" || value === null) return false;
|
|
1382
2425
|
if (!("version" in value) || typeof value.version !== "number") return false;
|
|
1383
|
-
if (!("entries" in value) || typeof value.entries !== "object" || value.entries === null)
|
|
2426
|
+
if (!("entries" in value) || typeof value.entries !== "object" || value.entries === null)
|
|
2427
|
+
return false;
|
|
1384
2428
|
return true;
|
|
1385
2429
|
}
|
|
1386
2430
|
function isTrustManifestEntry(value) {
|
|
@@ -1392,17 +2436,34 @@ function isTrustManifestEntry(value) {
|
|
|
1392
2436
|
return true;
|
|
1393
2437
|
}
|
|
1394
2438
|
async function loadManifest(configDir) {
|
|
1395
|
-
const manifestPath =
|
|
2439
|
+
const manifestPath = path2.join(configDir, MANIFEST_FILENAME);
|
|
1396
2440
|
let rawText;
|
|
1397
2441
|
try {
|
|
1398
|
-
rawText = await
|
|
2442
|
+
rawText = await fs3.readFile(manifestPath, "utf8");
|
|
1399
2443
|
} catch (err) {
|
|
1400
2444
|
if (typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT") {
|
|
1401
2445
|
return /* @__PURE__ */ new Map();
|
|
1402
2446
|
}
|
|
1403
|
-
|
|
2447
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2448
|
+
throw new FilesystemError(
|
|
2449
|
+
`Cannot read trust manifest at ${manifestPath}: ${detail}`,
|
|
2450
|
+
manifestPath,
|
|
2451
|
+
"read",
|
|
2452
|
+
err
|
|
2453
|
+
);
|
|
2454
|
+
}
|
|
2455
|
+
let parsed;
|
|
2456
|
+
try {
|
|
2457
|
+
parsed = JSON.parse(rawText);
|
|
2458
|
+
} catch (err) {
|
|
2459
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2460
|
+
throw new FilesystemError(
|
|
2461
|
+
`Trust manifest at ${manifestPath} is not valid JSON: ${detail}`,
|
|
2462
|
+
manifestPath,
|
|
2463
|
+
"read",
|
|
2464
|
+
err
|
|
2465
|
+
);
|
|
1404
2466
|
}
|
|
1405
|
-
const parsed = JSON.parse(rawText);
|
|
1406
2467
|
if (!isRawManifest(parsed)) {
|
|
1407
2468
|
return /* @__PURE__ */ new Map();
|
|
1408
2469
|
}
|
|
@@ -1415,14 +2476,28 @@ async function loadManifest(configDir) {
|
|
|
1415
2476
|
return manifest;
|
|
1416
2477
|
}
|
|
1417
2478
|
async function saveManifest(configDir, manifest) {
|
|
1418
|
-
|
|
2479
|
+
const manifestPath = path2.join(configDir, MANIFEST_FILENAME);
|
|
2480
|
+
try {
|
|
2481
|
+
await fs3.mkdir(configDir, { recursive: true });
|
|
2482
|
+
} catch (err) {
|
|
2483
|
+
throw toFilesystemError(err, "trust-manifest directory", configDir, "create");
|
|
2484
|
+
}
|
|
1419
2485
|
const entries = {};
|
|
1420
2486
|
for (const [namespace, entry] of manifest) {
|
|
1421
2487
|
entries[namespace] = entry;
|
|
1422
2488
|
}
|
|
1423
|
-
const raw = { version: 1, entries };
|
|
1424
|
-
|
|
1425
|
-
|
|
2489
|
+
const raw = { version: 1, entries };
|
|
2490
|
+
try {
|
|
2491
|
+
await fs3.writeFile(manifestPath, JSON.stringify(raw, null, 2), "utf8");
|
|
2492
|
+
} catch (err) {
|
|
2493
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2494
|
+
throw new FilesystemError(
|
|
2495
|
+
`Cannot write trust manifest at ${manifestPath}: ${detail}`,
|
|
2496
|
+
manifestPath,
|
|
2497
|
+
"write",
|
|
2498
|
+
err
|
|
2499
|
+
);
|
|
2500
|
+
}
|
|
1426
2501
|
}
|
|
1427
2502
|
function addTrustedHash(manifest, namespace, hash) {
|
|
1428
2503
|
const next = new Map(manifest);
|
|
@@ -1456,27 +2531,32 @@ async function trySigstore(execPath) {
|
|
|
1456
2531
|
return false;
|
|
1457
2532
|
}
|
|
1458
2533
|
}
|
|
1459
|
-
async function
|
|
2534
|
+
async function verifyTrustPending(execPath, options) {
|
|
2535
|
+
const configDir = options?.configDir ?? ".vaultkeeper";
|
|
1460
2536
|
if (execPath === "dev") {
|
|
1461
2537
|
return {
|
|
1462
2538
|
identity: { hash: "dev", trustTier: 3, verified: false },
|
|
1463
2539
|
tofuConflict: false,
|
|
1464
|
-
|
|
2540
|
+
approvedHashes: [],
|
|
2541
|
+
reason: "Dev mode \u2014 hash verification skipped",
|
|
2542
|
+
pendingWrite: void 0,
|
|
2543
|
+
configDir
|
|
1465
2544
|
};
|
|
1466
2545
|
}
|
|
1467
|
-
const configDir = options?.configDir ?? ".vaultkeeper";
|
|
1468
2546
|
const namespace = options?.namespace ?? execPath;
|
|
1469
2547
|
const currentHash = await hashExecutable(execPath);
|
|
1470
2548
|
const manifest = await loadManifest(configDir);
|
|
2549
|
+
const approvedHashes = manifest.get(namespace)?.hashes ?? [];
|
|
1471
2550
|
if (options?.skipSigstore !== true) {
|
|
1472
2551
|
const sigstoreVerified = await trySigstore(execPath);
|
|
1473
2552
|
if (sigstoreVerified) {
|
|
1474
|
-
const updated2 = addTrustedHash(manifest, namespace, currentHash);
|
|
1475
|
-
await saveManifest(configDir, updated2);
|
|
1476
2553
|
return {
|
|
1477
2554
|
identity: { hash: currentHash, trustTier: 1, verified: true },
|
|
1478
2555
|
tofuConflict: false,
|
|
1479
|
-
|
|
2556
|
+
approvedHashes,
|
|
2557
|
+
reason: "Sigstore bundle verified",
|
|
2558
|
+
pendingWrite: { namespace, hash: currentHash },
|
|
2559
|
+
configDir
|
|
1480
2560
|
};
|
|
1481
2561
|
}
|
|
1482
2562
|
}
|
|
@@ -1484,7 +2564,10 @@ async function verifyTrust(execPath, options) {
|
|
|
1484
2564
|
return {
|
|
1485
2565
|
identity: { hash: currentHash, trustTier: 2, verified: true },
|
|
1486
2566
|
tofuConflict: false,
|
|
1487
|
-
|
|
2567
|
+
approvedHashes,
|
|
2568
|
+
reason: "Hash found in trust manifest",
|
|
2569
|
+
pendingWrite: void 0,
|
|
2570
|
+
configDir
|
|
1488
2571
|
};
|
|
1489
2572
|
}
|
|
1490
2573
|
const existing = manifest.get(namespace);
|
|
@@ -1492,17 +2575,42 @@ async function verifyTrust(execPath, options) {
|
|
|
1492
2575
|
return {
|
|
1493
2576
|
identity: { hash: currentHash, trustTier: 3, verified: false },
|
|
1494
2577
|
tofuConflict: true,
|
|
1495
|
-
|
|
2578
|
+
approvedHashes,
|
|
2579
|
+
reason: `Hash changed from a previously approved value \u2014 re-approval required`,
|
|
2580
|
+
pendingWrite: void 0,
|
|
2581
|
+
configDir
|
|
1496
2582
|
};
|
|
1497
2583
|
}
|
|
1498
|
-
const updated = addTrustedHash(manifest, namespace, currentHash);
|
|
1499
|
-
await saveManifest(configDir, updated);
|
|
1500
2584
|
return {
|
|
1501
2585
|
identity: { hash: currentHash, trustTier: 3, verified: false },
|
|
1502
2586
|
tofuConflict: false,
|
|
1503
|
-
|
|
2587
|
+
approvedHashes,
|
|
2588
|
+
reason: "First encounter \u2014 hash staged for TOFU recording",
|
|
2589
|
+
pendingWrite: { namespace, hash: currentHash },
|
|
2590
|
+
configDir
|
|
1504
2591
|
};
|
|
1505
2592
|
}
|
|
2593
|
+
async function commitTrust(pending) {
|
|
2594
|
+
if (pending.pendingWrite === void 0) {
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
const { namespace, hash } = pending.pendingWrite;
|
|
2598
|
+
const current = await loadManifest(pending.configDir);
|
|
2599
|
+
const existing = current.get(namespace);
|
|
2600
|
+
if (existing?.hashes.includes(hash) === true) {
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
if (existing !== void 0 && existing.hashes.length > 0) {
|
|
2604
|
+
const previousHash = existing.hashes.at(-1) ?? hash;
|
|
2605
|
+
throw new IdentityMismatchError(
|
|
2606
|
+
"Executable hash changed \u2014 re-approval required",
|
|
2607
|
+
previousHash,
|
|
2608
|
+
hash
|
|
2609
|
+
);
|
|
2610
|
+
}
|
|
2611
|
+
const merged = addTrustedHash(current, namespace, hash);
|
|
2612
|
+
await saveManifest(pending.configDir, merged);
|
|
2613
|
+
}
|
|
1506
2614
|
|
|
1507
2615
|
// src/identity/session.ts
|
|
1508
2616
|
var CapabilityToken = class {
|
|
@@ -1525,6 +2633,11 @@ function createCapabilityToken(claims) {
|
|
|
1525
2633
|
claimsStore.set(token, claims);
|
|
1526
2634
|
return token;
|
|
1527
2635
|
}
|
|
2636
|
+
function createSigningCapabilityToken(claims) {
|
|
2637
|
+
const token = new CapabilityToken();
|
|
2638
|
+
claimsStore.set(token, claims);
|
|
2639
|
+
return token;
|
|
2640
|
+
}
|
|
1528
2641
|
function validateCapabilityToken(token) {
|
|
1529
2642
|
const claims = claimsStore.get(token);
|
|
1530
2643
|
if (claims === void 0) {
|
|
@@ -1532,166 +2645,22 @@ function validateCapabilityToken(token) {
|
|
|
1532
2645
|
}
|
|
1533
2646
|
return claims;
|
|
1534
2647
|
}
|
|
1535
|
-
function
|
|
1536
|
-
const
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
if (process.platform === "win32") {
|
|
1541
|
-
const appData = process.env.APPDATA;
|
|
1542
|
-
if (appData !== void 0) {
|
|
1543
|
-
return path3.join(appData, "vaultkeeper");
|
|
1544
|
-
}
|
|
1545
|
-
return path3.join(os4.homedir(), "AppData", "Roaming", "vaultkeeper");
|
|
1546
|
-
}
|
|
1547
|
-
return path3.join(os4.homedir(), ".config", "vaultkeeper");
|
|
1548
|
-
}
|
|
1549
|
-
function defaultConfig() {
|
|
1550
|
-
return {
|
|
1551
|
-
version: 1,
|
|
1552
|
-
backends: [{ type: "file", enabled: true }],
|
|
1553
|
-
keyRotation: { gracePeriodDays: 7 },
|
|
1554
|
-
defaults: { ttlMinutes: 60, trustTier: 3 }
|
|
1555
|
-
};
|
|
1556
|
-
}
|
|
1557
|
-
function isObject(value) {
|
|
1558
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1559
|
-
}
|
|
1560
|
-
function validateBackendEntry(entry, index) {
|
|
1561
|
-
if (!isObject(entry)) {
|
|
1562
|
-
throw new Error(`backends[${String(index)}] must be an object`);
|
|
1563
|
-
}
|
|
1564
|
-
if (typeof entry.type !== "string" || entry.type.trim() === "") {
|
|
1565
|
-
throw new Error(`backends[${String(index)}].type must be a non-empty string`);
|
|
1566
|
-
}
|
|
1567
|
-
if (typeof entry.enabled !== "boolean") {
|
|
1568
|
-
throw new Error(`backends[${String(index)}].enabled must be a boolean`);
|
|
1569
|
-
}
|
|
1570
|
-
const result = {
|
|
1571
|
-
type: entry.type,
|
|
1572
|
-
enabled: entry.enabled
|
|
1573
|
-
};
|
|
1574
|
-
if (entry.plugin !== void 0) {
|
|
1575
|
-
if (typeof entry.plugin !== "boolean") {
|
|
1576
|
-
throw new Error(`backends[${String(index)}].plugin must be a boolean`);
|
|
1577
|
-
}
|
|
1578
|
-
result.plugin = entry.plugin;
|
|
1579
|
-
}
|
|
1580
|
-
if (entry.path !== void 0) {
|
|
1581
|
-
if (typeof entry.path !== "string") {
|
|
1582
|
-
throw new Error(`backends[${String(index)}].path must be a string`);
|
|
1583
|
-
}
|
|
1584
|
-
result.path = entry.path;
|
|
1585
|
-
}
|
|
1586
|
-
if (entry.options !== void 0) {
|
|
1587
|
-
if (!isObject(entry.options)) {
|
|
1588
|
-
throw new Error(`backends[${String(index)}].options must be an object`);
|
|
1589
|
-
}
|
|
1590
|
-
const opts = {};
|
|
1591
|
-
for (const [k, v] of Object.entries(entry.options)) {
|
|
1592
|
-
if (typeof v !== "string") {
|
|
1593
|
-
throw new Error(
|
|
1594
|
-
`backends[${String(index)}].options["${k}"] must be a string`
|
|
1595
|
-
);
|
|
1596
|
-
}
|
|
1597
|
-
opts[k] = v;
|
|
1598
|
-
}
|
|
1599
|
-
result.options = opts;
|
|
1600
|
-
}
|
|
1601
|
-
return result;
|
|
1602
|
-
}
|
|
1603
|
-
function validateConfig(config) {
|
|
1604
|
-
if (!isObject(config)) {
|
|
1605
|
-
throw new Error("Config must be an object");
|
|
1606
|
-
}
|
|
1607
|
-
if (typeof config.version !== "number" || config.version !== 1) {
|
|
1608
|
-
throw new Error("Config version must be 1");
|
|
1609
|
-
}
|
|
1610
|
-
if (!Array.isArray(config.backends) || config.backends.length === 0) {
|
|
1611
|
-
throw new Error("Config must have at least one backend");
|
|
1612
|
-
}
|
|
1613
|
-
const backends = config.backends.map(
|
|
1614
|
-
(entry, i) => validateBackendEntry(entry, i)
|
|
1615
|
-
);
|
|
1616
|
-
if (!isObject(config.keyRotation)) {
|
|
1617
|
-
throw new Error("Config keyRotation must be an object");
|
|
1618
|
-
}
|
|
1619
|
-
if (typeof config.keyRotation.gracePeriodDays !== "number" || config.keyRotation.gracePeriodDays <= 0) {
|
|
1620
|
-
throw new Error("Config keyRotation.gracePeriodDays must be a positive number");
|
|
1621
|
-
}
|
|
1622
|
-
if (!isObject(config.defaults)) {
|
|
1623
|
-
throw new Error("Config defaults must be an object");
|
|
1624
|
-
}
|
|
1625
|
-
if (typeof config.defaults.ttlMinutes !== "number" || config.defaults.ttlMinutes <= 0) {
|
|
1626
|
-
throw new Error("Config defaults.ttlMinutes must be a positive number");
|
|
1627
|
-
}
|
|
1628
|
-
let tier = config.defaults.trustTier;
|
|
1629
|
-
if (typeof tier === "string") {
|
|
1630
|
-
const parsed = Number(tier);
|
|
1631
|
-
if (!Number.isNaN(parsed)) {
|
|
1632
|
-
tier = parsed;
|
|
1633
|
-
}
|
|
1634
|
-
}
|
|
1635
|
-
if (tier !== 1 && tier !== 2 && tier !== 3) {
|
|
1636
|
-
throw new Error("Config defaults.trustTier must be 1, 2, or 3");
|
|
1637
|
-
}
|
|
1638
|
-
const result = {
|
|
1639
|
-
version: 1,
|
|
1640
|
-
backends,
|
|
1641
|
-
keyRotation: {
|
|
1642
|
-
gracePeriodDays: config.keyRotation.gracePeriodDays
|
|
1643
|
-
},
|
|
1644
|
-
defaults: {
|
|
1645
|
-
ttlMinutes: config.defaults.ttlMinutes,
|
|
1646
|
-
trustTier: tier
|
|
1647
|
-
}
|
|
1648
|
-
};
|
|
1649
|
-
if (config.developmentMode !== void 0) {
|
|
1650
|
-
if (!isObject(config.developmentMode)) {
|
|
1651
|
-
throw new Error("Config developmentMode must be an object");
|
|
1652
|
-
}
|
|
1653
|
-
if (!Array.isArray(config.developmentMode.executables)) {
|
|
1654
|
-
throw new Error("Config developmentMode.executables must be an array");
|
|
1655
|
-
}
|
|
1656
|
-
const executables = [];
|
|
1657
|
-
for (const [i, exe] of Array.from(config.developmentMode.executables).entries()) {
|
|
1658
|
-
if (typeof exe !== "string") {
|
|
1659
|
-
throw new Error(`Config developmentMode.executables[${String(i)}] must be a string`);
|
|
1660
|
-
}
|
|
1661
|
-
executables.push(exe);
|
|
1662
|
-
}
|
|
1663
|
-
result.developmentMode = { executables };
|
|
1664
|
-
}
|
|
1665
|
-
return result;
|
|
1666
|
-
}
|
|
1667
|
-
async function loadConfig(configDir) {
|
|
1668
|
-
const dir = configDir ?? getDefaultConfigDir();
|
|
1669
|
-
const configPath = path3.join(dir, "config.json");
|
|
1670
|
-
let raw;
|
|
1671
|
-
try {
|
|
1672
|
-
raw = await fs.readFile(configPath, "utf-8");
|
|
1673
|
-
} catch {
|
|
1674
|
-
return defaultConfig();
|
|
1675
|
-
}
|
|
1676
|
-
let parsed;
|
|
1677
|
-
try {
|
|
1678
|
-
parsed = JSON.parse(raw);
|
|
1679
|
-
} catch {
|
|
1680
|
-
throw new Error(`Failed to parse config file at ${configPath}`);
|
|
1681
|
-
}
|
|
1682
|
-
return validateConfig(parsed);
|
|
2648
|
+
function isSigningClaims(claims) {
|
|
2649
|
+
const record = { ...claims };
|
|
2650
|
+
const kid = record.kid;
|
|
2651
|
+
const backendRef = record.backendRef;
|
|
2652
|
+
return record.keyType === "signing-key" && typeof kid === "string" && kid.length > 0 && typeof backendRef === "string" && backendRef.length > 0 && !("val" in record);
|
|
1683
2653
|
}
|
|
1684
2654
|
var KeyManager = class {
|
|
1685
2655
|
#state = void 0;
|
|
1686
2656
|
#gracePeriodTimer = void 0;
|
|
1687
2657
|
#gracePeriodExpiresAt = void 0;
|
|
1688
|
-
#rotating = false;
|
|
1689
2658
|
/** Generate a new 32-byte key with a timestamp-based id. */
|
|
1690
2659
|
generateKey() {
|
|
1691
|
-
const randomSuffix =
|
|
2660
|
+
const randomSuffix = crypto2.randomBytes(4).toString("hex");
|
|
1692
2661
|
return {
|
|
1693
2662
|
id: `k-${String(Date.now())}-${randomSuffix}`,
|
|
1694
|
-
key: new Uint8Array(
|
|
2663
|
+
key: new Uint8Array(crypto2.randomBytes(32)),
|
|
1695
2664
|
createdAt: /* @__PURE__ */ new Date()
|
|
1696
2665
|
};
|
|
1697
2666
|
}
|
|
@@ -1705,6 +2674,43 @@ var KeyManager = class {
|
|
|
1705
2674
|
}
|
|
1706
2675
|
return Promise.resolve();
|
|
1707
2676
|
}
|
|
2677
|
+
/**
|
|
2678
|
+
* Replace the in-memory state with a persisted snapshot.
|
|
2679
|
+
*
|
|
2680
|
+
* An expired grace period in the snapshot is dropped: the previous key is
|
|
2681
|
+
* discarded and no grace period is restored. When the grace period is still
|
|
2682
|
+
* active, a fresh timer is armed for the remaining duration so the previous
|
|
2683
|
+
* key is eventually freed in long-running processes; correctness never depends
|
|
2684
|
+
* on that timer (grace is always re-checked against the wall clock).
|
|
2685
|
+
* @internal
|
|
2686
|
+
*/
|
|
2687
|
+
hydrate(snapshot) {
|
|
2688
|
+
this.#clearGracePeriodTimer();
|
|
2689
|
+
if (snapshot.previous !== void 0 && snapshot.gracePeriodExpiresAt !== void 0 && Date.now() < snapshot.gracePeriodExpiresAt) {
|
|
2690
|
+
this.#state = { current: snapshot.current, previous: snapshot.previous };
|
|
2691
|
+
this.#gracePeriodExpiresAt = snapshot.gracePeriodExpiresAt;
|
|
2692
|
+
this.#armGracePeriodTimer(snapshot.gracePeriodExpiresAt - Date.now());
|
|
2693
|
+
} else {
|
|
2694
|
+
this.#state = { current: snapshot.current };
|
|
2695
|
+
this.#gracePeriodExpiresAt = void 0;
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
/**
|
|
2699
|
+
* Capture the current state as a serializable snapshot for persistence.
|
|
2700
|
+
* The previous key and grace expiry are included only while the grace period
|
|
2701
|
+
* is still active.
|
|
2702
|
+
* @internal
|
|
2703
|
+
*/
|
|
2704
|
+
snapshot() {
|
|
2705
|
+
const state = this.#requireState();
|
|
2706
|
+
const result = { current: state.current };
|
|
2707
|
+
const previous = this.getPreviousKey();
|
|
2708
|
+
if (previous !== void 0 && this.#gracePeriodExpiresAt !== void 0) {
|
|
2709
|
+
result.previous = previous;
|
|
2710
|
+
result.gracePeriodExpiresAt = this.#gracePeriodExpiresAt;
|
|
2711
|
+
}
|
|
2712
|
+
return result;
|
|
2713
|
+
}
|
|
1708
2714
|
/** Return the current (encryption) key. Throws if not initialized. */
|
|
1709
2715
|
getCurrentKey() {
|
|
1710
2716
|
const state = this.#requireState();
|
|
@@ -1716,6 +2722,9 @@ var KeyManager = class {
|
|
|
1716
2722
|
*/
|
|
1717
2723
|
getPreviousKey() {
|
|
1718
2724
|
const state = this.#requireState();
|
|
2725
|
+
if (!this.isInGracePeriod()) {
|
|
2726
|
+
return void 0;
|
|
2727
|
+
}
|
|
1719
2728
|
return state.previous;
|
|
1720
2729
|
}
|
|
1721
2730
|
/**
|
|
@@ -1728,7 +2737,7 @@ var KeyManager = class {
|
|
|
1728
2737
|
if (state.current.id === kid) {
|
|
1729
2738
|
return state.current;
|
|
1730
2739
|
}
|
|
1731
|
-
const
|
|
2740
|
+
const previous = this.getPreviousKey();
|
|
1732
2741
|
if (previous?.id === kid) {
|
|
1733
2742
|
return previous;
|
|
1734
2743
|
}
|
|
@@ -1739,29 +2748,19 @@ var KeyManager = class {
|
|
|
1739
2748
|
* becomes current. A grace-period timer is started; when it fires the
|
|
1740
2749
|
* previous key is cleared automatically.
|
|
1741
2750
|
*
|
|
1742
|
-
* @throws {RotationInProgressError} if a rotation is already underway.
|
|
2751
|
+
* @throws {RotationInProgressError} if a rotation is already underway (i.e. a
|
|
2752
|
+
* previous key is still within its grace period).
|
|
1743
2753
|
*/
|
|
1744
2754
|
rotateKey(gracePeriodMs) {
|
|
1745
|
-
|
|
2755
|
+
const state = this.#requireState();
|
|
2756
|
+
if (this.isInGracePeriod()) {
|
|
1746
2757
|
throw new RotationInProgressError("A key rotation is already in progress");
|
|
1747
2758
|
}
|
|
1748
|
-
const state = this.#requireState();
|
|
1749
|
-
this.#rotating = true;
|
|
1750
2759
|
this.#clearGracePeriodTimer();
|
|
1751
2760
|
const newKey = this.generateKey();
|
|
1752
2761
|
this.#state = { current: newKey, previous: state.current };
|
|
1753
2762
|
this.#gracePeriodExpiresAt = Date.now() + gracePeriodMs;
|
|
1754
|
-
this.#
|
|
1755
|
-
if (this.#state !== void 0) {
|
|
1756
|
-
this.#state = { current: this.#state.current };
|
|
1757
|
-
}
|
|
1758
|
-
this.#gracePeriodExpiresAt = void 0;
|
|
1759
|
-
this.#gracePeriodTimer = void 0;
|
|
1760
|
-
this.#rotating = false;
|
|
1761
|
-
}, gracePeriodMs);
|
|
1762
|
-
if (typeof this.#gracePeriodTimer.unref === "function") {
|
|
1763
|
-
this.#gracePeriodTimer.unref();
|
|
1764
|
-
}
|
|
2763
|
+
this.#armGracePeriodTimer(gracePeriodMs);
|
|
1765
2764
|
}
|
|
1766
2765
|
/**
|
|
1767
2766
|
* Emergency revocation: immediately clear the previous key and generate
|
|
@@ -1769,7 +2768,6 @@ var KeyManager = class {
|
|
|
1769
2768
|
*/
|
|
1770
2769
|
revokeKey() {
|
|
1771
2770
|
this.#clearGracePeriodTimer();
|
|
1772
|
-
this.#rotating = false;
|
|
1773
2771
|
this.#gracePeriodExpiresAt = void 0;
|
|
1774
2772
|
const newKey = this.generateKey();
|
|
1775
2773
|
this.#state = { current: newKey };
|
|
@@ -1789,13 +2787,22 @@ var KeyManager = class {
|
|
|
1789
2787
|
// ---------------------------------------------------------------------------
|
|
1790
2788
|
#requireState() {
|
|
1791
2789
|
if (this.#state === void 0) {
|
|
1792
|
-
throw new SetupError(
|
|
1793
|
-
"KeyManager has not been initialized \u2014 call init() first",
|
|
1794
|
-
"KeyManager"
|
|
1795
|
-
);
|
|
2790
|
+
throw new SetupError("KeyManager has not been initialized \u2014 call init() first", "KeyManager");
|
|
1796
2791
|
}
|
|
1797
2792
|
return this.#state;
|
|
1798
2793
|
}
|
|
2794
|
+
#armGracePeriodTimer(delayMs) {
|
|
2795
|
+
this.#gracePeriodTimer = setTimeout(() => {
|
|
2796
|
+
if (this.#state !== void 0) {
|
|
2797
|
+
this.#state = { current: this.#state.current };
|
|
2798
|
+
}
|
|
2799
|
+
this.#gracePeriodExpiresAt = void 0;
|
|
2800
|
+
this.#gracePeriodTimer = void 0;
|
|
2801
|
+
}, delayMs);
|
|
2802
|
+
if (typeof this.#gracePeriodTimer.unref === "function") {
|
|
2803
|
+
this.#gracePeriodTimer.unref();
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
1799
2806
|
#clearGracePeriodTimer() {
|
|
1800
2807
|
if (this.#gracePeriodTimer !== void 0) {
|
|
1801
2808
|
clearTimeout(this.#gracePeriodTimer);
|
|
@@ -1803,6 +2810,111 @@ var KeyManager = class {
|
|
|
1803
2810
|
}
|
|
1804
2811
|
}
|
|
1805
2812
|
};
|
|
2813
|
+
var KEY_STATE_FILE = "keys.enc";
|
|
2814
|
+
var KEY_WRAP_FILE = ".keys.wrap";
|
|
2815
|
+
function serializeKey(key) {
|
|
2816
|
+
return {
|
|
2817
|
+
id: key.id,
|
|
2818
|
+
key: Buffer.from(key.key).toString("base64"),
|
|
2819
|
+
createdAt: key.createdAt.toISOString()
|
|
2820
|
+
};
|
|
2821
|
+
}
|
|
2822
|
+
function isRawKeyMaterial(value) {
|
|
2823
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2824
|
+
if (!("id" in value) || typeof value.id !== "string" || value.id === "") return false;
|
|
2825
|
+
if (!("key" in value) || typeof value.key !== "string" || value.key === "") return false;
|
|
2826
|
+
if (!("createdAt" in value) || typeof value.createdAt !== "string") return false;
|
|
2827
|
+
return true;
|
|
2828
|
+
}
|
|
2829
|
+
function deserializeKey(raw) {
|
|
2830
|
+
const bytes = Buffer.from(raw.key, "base64");
|
|
2831
|
+
if (bytes.byteLength !== 32) return void 0;
|
|
2832
|
+
const createdAt = new Date(raw.createdAt);
|
|
2833
|
+
if (Number.isNaN(createdAt.getTime())) return void 0;
|
|
2834
|
+
return { id: raw.id, key: new Uint8Array(bytes), createdAt };
|
|
2835
|
+
}
|
|
2836
|
+
function isRawKeyState(value) {
|
|
2837
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2838
|
+
if (!("version" in value) || typeof value.version !== "number") return false;
|
|
2839
|
+
if (!("current" in value) || !isRawKeyMaterial(value.current)) return false;
|
|
2840
|
+
if ("previous" in value && value.previous !== void 0 && !isRawKeyMaterial(value.previous)) {
|
|
2841
|
+
return false;
|
|
2842
|
+
}
|
|
2843
|
+
if ("gracePeriodExpiresAt" in value && value.gracePeriodExpiresAt !== void 0 && typeof value.gracePeriodExpiresAt !== "number") {
|
|
2844
|
+
return false;
|
|
2845
|
+
}
|
|
2846
|
+
return true;
|
|
2847
|
+
}
|
|
2848
|
+
async function loadKeyState(configDir) {
|
|
2849
|
+
const statePath = path2.join(configDir, KEY_STATE_FILE);
|
|
2850
|
+
let envelope;
|
|
2851
|
+
try {
|
|
2852
|
+
envelope = await fs3.readFile(statePath, "utf8");
|
|
2853
|
+
} catch {
|
|
2854
|
+
return void 0;
|
|
2855
|
+
}
|
|
2856
|
+
const wrapKey = await getOrCreateWrapKey(path2.join(configDir, KEY_WRAP_FILE));
|
|
2857
|
+
let json;
|
|
2858
|
+
try {
|
|
2859
|
+
json = decryptGcm(wrapKey, envelope, statePath);
|
|
2860
|
+
} catch {
|
|
2861
|
+
return void 0;
|
|
2862
|
+
} finally {
|
|
2863
|
+
wrapKey.fill(0);
|
|
2864
|
+
}
|
|
2865
|
+
let parsed;
|
|
2866
|
+
try {
|
|
2867
|
+
parsed = JSON.parse(json);
|
|
2868
|
+
} catch {
|
|
2869
|
+
return void 0;
|
|
2870
|
+
}
|
|
2871
|
+
if (!isRawKeyState(parsed)) return void 0;
|
|
2872
|
+
const current = deserializeKey(parsed.current);
|
|
2873
|
+
if (current === void 0) return void 0;
|
|
2874
|
+
const snapshot = { current };
|
|
2875
|
+
if (parsed.previous !== void 0 && parsed.gracePeriodExpiresAt !== void 0) {
|
|
2876
|
+
const previous = deserializeKey(parsed.previous);
|
|
2877
|
+
if (previous !== void 0 && Date.now() < parsed.gracePeriodExpiresAt) {
|
|
2878
|
+
snapshot.previous = previous;
|
|
2879
|
+
snapshot.gracePeriodExpiresAt = parsed.gracePeriodExpiresAt;
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
return snapshot;
|
|
2883
|
+
}
|
|
2884
|
+
async function saveKeyState(configDir, snapshot) {
|
|
2885
|
+
try {
|
|
2886
|
+
await fs3.mkdir(configDir, { recursive: true, mode: 448 });
|
|
2887
|
+
} catch (err) {
|
|
2888
|
+
throw toFilesystemError(err, "config directory", configDir, "create");
|
|
2889
|
+
}
|
|
2890
|
+
const raw = {
|
|
2891
|
+
version: 1,
|
|
2892
|
+
current: serializeKey(snapshot.current)
|
|
2893
|
+
};
|
|
2894
|
+
if (snapshot.previous !== void 0 && snapshot.gracePeriodExpiresAt !== void 0) {
|
|
2895
|
+
raw.previous = serializeKey(snapshot.previous);
|
|
2896
|
+
raw.gracePeriodExpiresAt = snapshot.gracePeriodExpiresAt;
|
|
2897
|
+
}
|
|
2898
|
+
const wrapKey = await getOrCreateWrapKey(path2.join(configDir, KEY_WRAP_FILE));
|
|
2899
|
+
let envelope;
|
|
2900
|
+
try {
|
|
2901
|
+
envelope = encryptGcm(wrapKey, JSON.stringify(raw));
|
|
2902
|
+
} finally {
|
|
2903
|
+
wrapKey.fill(0);
|
|
2904
|
+
}
|
|
2905
|
+
const statePath = path2.join(configDir, KEY_STATE_FILE);
|
|
2906
|
+
const tmpPath = `${statePath}.${String(process.pid)}.tmp`;
|
|
2907
|
+
try {
|
|
2908
|
+
await fs3.writeFile(tmpPath, envelope, { encoding: "utf8", mode: 384 });
|
|
2909
|
+
} catch (err) {
|
|
2910
|
+
throw toFilesystemError(err, "key state file", tmpPath, "write");
|
|
2911
|
+
}
|
|
2912
|
+
try {
|
|
2913
|
+
await fs3.rename(tmpPath, statePath);
|
|
2914
|
+
} catch (err) {
|
|
2915
|
+
throw toFilesystemError(err, "key state file", statePath, "write");
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
1806
2918
|
var ALGORITHM = "dir";
|
|
1807
2919
|
var ENCRYPTION = "A256GCM";
|
|
1808
2920
|
async function createToken(key, claims, options) {
|
|
@@ -1819,11 +2931,16 @@ async function createToken(key, claims, options) {
|
|
|
1819
2931
|
function isObject2(value) {
|
|
1820
2932
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1821
2933
|
}
|
|
2934
|
+
function isLeasePresence(value) {
|
|
2935
|
+
if (!isObject2(value)) return false;
|
|
2936
|
+
const { op, at, method, backend } = value;
|
|
2937
|
+
return typeof op === "string" && typeof at === "number" && typeof method === "string" && typeof backend === "string";
|
|
2938
|
+
}
|
|
1822
2939
|
function parseVaultClaims(raw) {
|
|
1823
2940
|
if (!isObject2(raw)) {
|
|
1824
2941
|
return void 0;
|
|
1825
2942
|
}
|
|
1826
|
-
const { jti, exp, iat, sub, exe, use, tid, bkd, val, ref } = raw;
|
|
2943
|
+
const { jti, exp, iat, sub, exe, use, tid, bkd, val, ref, kty, kid, kgen, pres } = raw;
|
|
1827
2944
|
if (typeof jti !== "string") return void 0;
|
|
1828
2945
|
if (typeof exp !== "number") return void 0;
|
|
1829
2946
|
if (typeof iat !== "number") return void 0;
|
|
@@ -1831,10 +2948,29 @@ function parseVaultClaims(raw) {
|
|
|
1831
2948
|
if (typeof exe !== "string") return void 0;
|
|
1832
2949
|
if (use !== null && typeof use !== "number") return void 0;
|
|
1833
2950
|
if (tid !== 1 && tid !== 2 && tid !== 3) return void 0;
|
|
1834
|
-
if (typeof bkd !== "string") return void 0;
|
|
1835
|
-
if (typeof val !== "string") return void 0;
|
|
2951
|
+
if (bkd !== void 0 && typeof bkd !== "string") return void 0;
|
|
2952
|
+
if (val !== void 0 && typeof val !== "string") return void 0;
|
|
1836
2953
|
if (typeof ref !== "string") return void 0;
|
|
1837
|
-
|
|
2954
|
+
if (kty !== void 0 && kty !== "secret" && kty !== "signing-key") return void 0;
|
|
2955
|
+
if (kid !== void 0 && typeof kid !== "string") return void 0;
|
|
2956
|
+
if (kgen !== void 0 && typeof kgen !== "number") return void 0;
|
|
2957
|
+
if (pres !== void 0 && !isLeasePresence(pres)) return void 0;
|
|
2958
|
+
return {
|
|
2959
|
+
jti,
|
|
2960
|
+
exp,
|
|
2961
|
+
iat,
|
|
2962
|
+
sub,
|
|
2963
|
+
exe,
|
|
2964
|
+
use: use ?? null,
|
|
2965
|
+
tid,
|
|
2966
|
+
bkd,
|
|
2967
|
+
val,
|
|
2968
|
+
ref,
|
|
2969
|
+
kty,
|
|
2970
|
+
kid,
|
|
2971
|
+
kgen,
|
|
2972
|
+
pres
|
|
2973
|
+
};
|
|
1838
2974
|
}
|
|
1839
2975
|
async function decryptToken(key, jwe) {
|
|
1840
2976
|
let plaintext;
|
|
@@ -1915,15 +3051,44 @@ function validateClaims(claims, usedCount = 0) {
|
|
|
1915
3051
|
if (claims.exe.trim() === "") {
|
|
1916
3052
|
throw new VaultError("Invalid token: exe must not be empty");
|
|
1917
3053
|
}
|
|
1918
|
-
if (claims.bkd.trim() === "") {
|
|
1919
|
-
throw new VaultError("Invalid token: bkd must not be empty");
|
|
1920
|
-
}
|
|
1921
|
-
if (claims.val.trim() === "") {
|
|
1922
|
-
throw new VaultError("Invalid token: val must not be empty");
|
|
1923
|
-
}
|
|
1924
3054
|
if (claims.ref.trim() === "") {
|
|
1925
3055
|
throw new VaultError("Invalid token: ref must not be empty");
|
|
1926
3056
|
}
|
|
3057
|
+
switch (claims.kty) {
|
|
3058
|
+
case "signing-key": {
|
|
3059
|
+
if (claims.val !== void 0) {
|
|
3060
|
+
throw new VaultError("Invalid token: signing lease must not carry a val");
|
|
3061
|
+
}
|
|
3062
|
+
if (claims.kid === void 0 || claims.kid.trim() === "") {
|
|
3063
|
+
throw new VaultError("Invalid token: kid must not be empty");
|
|
3064
|
+
}
|
|
3065
|
+
if (claims.kgen === void 0) {
|
|
3066
|
+
throw new VaultError("Invalid token: kgen is required for a signing lease");
|
|
3067
|
+
}
|
|
3068
|
+
if (!Number.isSafeInteger(claims.kgen) || claims.kgen < 0) {
|
|
3069
|
+
throw new VaultError("Invalid token: kgen must be a non-negative integer");
|
|
3070
|
+
}
|
|
3071
|
+
break;
|
|
3072
|
+
}
|
|
3073
|
+
case "secret":
|
|
3074
|
+
case void 0: {
|
|
3075
|
+
if (claims.bkd === void 0 || claims.bkd.trim() === "") {
|
|
3076
|
+
throw new VaultError("Invalid token: bkd must not be empty");
|
|
3077
|
+
}
|
|
3078
|
+
if (claims.val === void 0 || claims.val.trim() === "") {
|
|
3079
|
+
throw new VaultError("Invalid token: val must not be empty");
|
|
3080
|
+
}
|
|
3081
|
+
if (claims.kid !== void 0 || claims.kgen !== void 0 || claims.pres !== void 0) {
|
|
3082
|
+
throw new VaultError(
|
|
3083
|
+
"Invalid token: secret claim must not carry signing-lease fields (kid/kgen/pres)"
|
|
3084
|
+
);
|
|
3085
|
+
}
|
|
3086
|
+
break;
|
|
3087
|
+
}
|
|
3088
|
+
default: {
|
|
3089
|
+
throw new VaultError(`Invalid token: unrecognized claim kind kty=${String(claims.kty)}`);
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
1927
3092
|
if (claims.iat > claims.exp) {
|
|
1928
3093
|
throw new VaultError("Invalid token: iat must not be after exp");
|
|
1929
3094
|
}
|
|
@@ -1951,22 +3116,50 @@ function validateClaims(claims, usedCount = 0) {
|
|
|
1951
3116
|
}
|
|
1952
3117
|
}
|
|
1953
3118
|
|
|
1954
|
-
// src/access/
|
|
3119
|
+
// src/access/placeholder.ts
|
|
1955
3120
|
var PLACEHOLDER = "{{secret}}";
|
|
1956
|
-
|
|
1957
|
-
|
|
3121
|
+
var NAMED_PLACEHOLDER_RE = /\{\{secret:([^}]+)\}\}/g;
|
|
3122
|
+
var ANY_PLACEHOLDER_RE = /\{\{secret(?::[^}]+)?\}\}/;
|
|
3123
|
+
function resolvePlaceholders(value, secrets) {
|
|
3124
|
+
if (typeof secrets === "string") {
|
|
3125
|
+
return value.replaceAll(PLACEHOLDER, secrets);
|
|
3126
|
+
}
|
|
3127
|
+
return value.replace(NAMED_PLACEHOLDER_RE, (_match, name) => {
|
|
3128
|
+
const secret = secrets[name];
|
|
3129
|
+
if (secret === void 0) {
|
|
3130
|
+
const available = Object.keys(secrets).join(", ");
|
|
3131
|
+
throw new VaultError(
|
|
3132
|
+
`Unknown secret name in placeholder: {{secret:${name}}}. Available names: ${available}`
|
|
3133
|
+
);
|
|
3134
|
+
}
|
|
3135
|
+
return secret;
|
|
3136
|
+
});
|
|
1958
3137
|
}
|
|
1959
|
-
function
|
|
3138
|
+
function resolvePlaceholdersInRecord(record, secrets) {
|
|
1960
3139
|
const result = {};
|
|
1961
3140
|
for (const [key, value] of Object.entries(record)) {
|
|
1962
|
-
result[key] =
|
|
3141
|
+
result[key] = resolvePlaceholders(value, secrets);
|
|
1963
3142
|
}
|
|
1964
3143
|
return result;
|
|
1965
3144
|
}
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
3145
|
+
|
|
3146
|
+
// src/access/delegated-fetch.ts
|
|
3147
|
+
function redactSecretMaterial(detail, resolvedUrl, urlTemplate, secretValues) {
|
|
3148
|
+
let out = detail;
|
|
3149
|
+
if (resolvedUrl !== urlTemplate) {
|
|
3150
|
+
out = out.split(resolvedUrl).join(urlTemplate);
|
|
3151
|
+
}
|
|
3152
|
+
for (const value of secretValues) {
|
|
3153
|
+
if (value.length > 0) {
|
|
3154
|
+
out = out.split(value).join("[REDACTED]");
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
return out;
|
|
3158
|
+
}
|
|
3159
|
+
async function delegatedFetch(secrets, request) {
|
|
3160
|
+
const url = resolvePlaceholders(request.url, secrets);
|
|
3161
|
+
const headers = request.headers !== void 0 ? resolvePlaceholdersInRecord(request.headers, secrets) : void 0;
|
|
3162
|
+
const body = request.body !== void 0 ? resolvePlaceholders(request.body, secrets) : void 0;
|
|
1970
3163
|
const init = {};
|
|
1971
3164
|
if (request.method !== void 0) {
|
|
1972
3165
|
init.method = request.method;
|
|
@@ -1977,29 +3170,57 @@ async function delegatedFetch(secret, request) {
|
|
|
1977
3170
|
if (body !== void 0) {
|
|
1978
3171
|
init.body = body;
|
|
1979
3172
|
}
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
3173
|
+
try {
|
|
3174
|
+
return await fetch(url, init);
|
|
3175
|
+
} catch (error) {
|
|
3176
|
+
const rawDetail = error instanceof Error ? error.message : String(error);
|
|
3177
|
+
const secretValues = typeof secrets === "string" ? [secrets] : Object.values(secrets);
|
|
3178
|
+
const detail = redactSecretMaterial(rawDetail, url, request.url, secretValues);
|
|
3179
|
+
throw new FetchError(`Fetch failed for ${request.url}: ${detail}`, request.url);
|
|
3180
|
+
}
|
|
1985
3181
|
}
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
3182
|
+
|
|
3183
|
+
// src/access/redact.ts
|
|
3184
|
+
var REDACTED = "[REDACTED]";
|
|
3185
|
+
function redactSecrets(text, secrets, replacement = REDACTED) {
|
|
3186
|
+
const ordered = [...new Set(secrets)].filter((secret) => secret.trim().length > 0).sort((a, b) => b.length - a.length);
|
|
3187
|
+
const literalReplacement = replacement.replaceAll("$", "$$$$");
|
|
3188
|
+
let result = text;
|
|
3189
|
+
for (const secret of ordered) {
|
|
3190
|
+
result = result.replaceAll(secret, literalReplacement);
|
|
1990
3191
|
}
|
|
1991
3192
|
return result;
|
|
1992
3193
|
}
|
|
1993
|
-
|
|
1994
|
-
|
|
3194
|
+
|
|
3195
|
+
// src/access/delegated-exec.ts
|
|
3196
|
+
async function delegatedExec(secrets, request) {
|
|
3197
|
+
if (ANY_PLACEHOLDER_RE.test(request.command)) {
|
|
1995
3198
|
throw new ExecError(
|
|
1996
|
-
`
|
|
3199
|
+
`Secret placeholders are not supported in the command field. Use env instead.`,
|
|
1997
3200
|
request.command
|
|
1998
3201
|
);
|
|
1999
3202
|
}
|
|
2000
|
-
const args =
|
|
2001
|
-
const
|
|
2002
|
-
|
|
3203
|
+
const args = request.args ?? [];
|
|
3204
|
+
for (const arg of args) {
|
|
3205
|
+
if (ANY_PLACEHOLDER_RE.test(arg)) {
|
|
3206
|
+
throw new ExecError(
|
|
3207
|
+
`Secret placeholders are not supported in the args field \u2014 process arguments are visible to other processes via ps. Use env instead.`,
|
|
3208
|
+
request.command
|
|
3209
|
+
);
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
const secretValues = typeof secrets === "string" ? [secrets] : Object.values(secrets);
|
|
3213
|
+
const shouldRedact = request.redact !== false;
|
|
3214
|
+
let env;
|
|
3215
|
+
try {
|
|
3216
|
+
env = request.env !== void 0 ? resolvePlaceholdersInRecord(request.env, secrets) : void 0;
|
|
3217
|
+
} catch (error) {
|
|
3218
|
+
if (error instanceof VaultError) {
|
|
3219
|
+
throw new ExecError(error.message, request.command);
|
|
3220
|
+
}
|
|
3221
|
+
throw error;
|
|
3222
|
+
}
|
|
3223
|
+
return new Promise((resolve3, reject) => {
|
|
2003
3224
|
const spawnOptions = {
|
|
2004
3225
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2005
3226
|
};
|
|
@@ -2019,7 +3240,11 @@ function delegatedExec(secret, request) {
|
|
|
2019
3240
|
stderr += data.toString();
|
|
2020
3241
|
});
|
|
2021
3242
|
proc.on("close", (code) => {
|
|
2022
|
-
|
|
3243
|
+
resolve3({
|
|
3244
|
+
stdout: shouldRedact ? redactSecrets(stdout, secretValues) : stdout,
|
|
3245
|
+
stderr: shouldRedact ? redactSecrets(stderr, secretValues) : stderr,
|
|
3246
|
+
exitCode: code ?? 1
|
|
3247
|
+
});
|
|
2023
3248
|
});
|
|
2024
3249
|
proc.on("error", (error) => {
|
|
2025
3250
|
const isEnoent = error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
@@ -2056,12 +3281,14 @@ function createSecretAccessor(secretValue) {
|
|
|
2056
3281
|
let consumed = false;
|
|
2057
3282
|
function readImpl(callback) {
|
|
2058
3283
|
if (consumed) {
|
|
2059
|
-
throw new AccessorConsumedError(
|
|
3284
|
+
throw new AccessorConsumedError(
|
|
3285
|
+
"SecretAccessor has already been consumed \u2014 call getSecret() again to obtain a new accessor"
|
|
3286
|
+
);
|
|
2060
3287
|
}
|
|
2061
3288
|
consumed = true;
|
|
2062
3289
|
const buf = Buffer.from(secretValue, "utf8");
|
|
2063
3290
|
try {
|
|
2064
|
-
callback(buf);
|
|
3291
|
+
return callback(buf);
|
|
2065
3292
|
} finally {
|
|
2066
3293
|
buf.fill(0);
|
|
2067
3294
|
}
|
|
@@ -2123,51 +3350,65 @@ function createSecretAccessor(secretValue) {
|
|
|
2123
3350
|
};
|
|
2124
3351
|
return new Proxy(target, handler);
|
|
2125
3352
|
}
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
function resolveAlgorithmForKey(key, override) {
|
|
2130
|
-
const keyType = key.asymmetricKeyType;
|
|
2131
|
-
if (keyType === "ed25519" || keyType === "ed448") {
|
|
2132
|
-
return { signAlg: null, label: keyType };
|
|
2133
|
-
}
|
|
2134
|
-
const alg = (override ?? "sha256").toLowerCase();
|
|
2135
|
-
if (!ALLOWED_ALGORITHMS.has(alg)) {
|
|
2136
|
-
throw new InvalidAlgorithmError(
|
|
2137
|
-
`Unsupported algorithm '${alg}'. Allowed: ${[...ALLOWED_ALGORITHMS].join(", ")}`,
|
|
2138
|
-
alg,
|
|
2139
|
-
[...ALLOWED_ALGORITHMS]
|
|
2140
|
-
);
|
|
2141
|
-
}
|
|
2142
|
-
return { signAlg: alg, label: alg };
|
|
3353
|
+
var JWS_ALG = "EdDSA";
|
|
3354
|
+
function toBytes(data) {
|
|
3355
|
+
return Buffer$1.isBuffer(data) ? data : Buffer$1.from(data, "utf8");
|
|
2143
3356
|
}
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
function delegatedSign(secretPem, request) {
|
|
2147
|
-
const key = crypto.createPrivateKey(secretPem);
|
|
2148
|
-
const { signAlg, label } = resolveAlgorithmForKey(key, request.algorithm);
|
|
2149
|
-
const data = Buffer.isBuffer(request.data) ? request.data : Buffer.from(request.data);
|
|
2150
|
-
const signature = crypto.sign(signAlg, data, key);
|
|
2151
|
-
return {
|
|
2152
|
-
signature: signature.toString("base64"),
|
|
2153
|
-
algorithm: label
|
|
2154
|
-
};
|
|
3357
|
+
function signingInput(protectedB64, payload) {
|
|
3358
|
+
return Buffer$1.concat([Buffer$1.from(`${protectedB64}.`, "ascii"), payload]);
|
|
2155
3359
|
}
|
|
2156
|
-
function
|
|
2157
|
-
|
|
3360
|
+
async function createDetachedJws(kid, payload, sign2) {
|
|
3361
|
+
const header = { alg: JWS_ALG, b64: false, crit: ["b64"], kid };
|
|
3362
|
+
const protectedB64 = Buffer$1.from(JSON.stringify(header), "utf8").toString("base64url");
|
|
3363
|
+
const signature = await sign2(signingInput(protectedB64, toBytes(payload)));
|
|
3364
|
+
return `${protectedB64}..${signature.toString("base64url")}`;
|
|
3365
|
+
}
|
|
3366
|
+
function hasExpectedHeader(header) {
|
|
3367
|
+
if (typeof header !== "object" || header === null) {
|
|
3368
|
+
return false;
|
|
3369
|
+
}
|
|
3370
|
+
const record = { ...header };
|
|
3371
|
+
const crit = record.crit;
|
|
3372
|
+
return record.alg === JWS_ALG && record.b64 === false && Array.isArray(crit) && crit.length === 1 && crit[0] === "b64";
|
|
3373
|
+
}
|
|
3374
|
+
async function verifyDetachedJws(request) {
|
|
3375
|
+
const parts = request.jws.trim().split(".");
|
|
3376
|
+
if (parts.length !== 3) {
|
|
3377
|
+
return false;
|
|
3378
|
+
}
|
|
3379
|
+
const [protectedB64, middle, signatureB64] = parts;
|
|
3380
|
+
if (protectedB64 === void 0 || signatureB64 === void 0 || middle !== "") {
|
|
3381
|
+
return false;
|
|
3382
|
+
}
|
|
3383
|
+
let header;
|
|
2158
3384
|
try {
|
|
2159
|
-
|
|
3385
|
+
header = JSON.parse(Buffer$1.from(protectedB64, "base64url").toString("utf8"));
|
|
2160
3386
|
} catch {
|
|
2161
3387
|
return false;
|
|
2162
3388
|
}
|
|
2163
|
-
if (
|
|
3389
|
+
if (!hasExpectedHeader(header)) {
|
|
2164
3390
|
return false;
|
|
2165
3391
|
}
|
|
2166
|
-
|
|
2167
|
-
|
|
3392
|
+
if (request.publicKey.includes("PRIVATE KEY")) {
|
|
3393
|
+
throw new InvalidKeyMaterialError(
|
|
3394
|
+
"A private key was supplied where an SPKI public key is required."
|
|
3395
|
+
);
|
|
3396
|
+
}
|
|
3397
|
+
let key;
|
|
3398
|
+
try {
|
|
3399
|
+
key = await importSPKI(request.publicKey, JWS_ALG);
|
|
3400
|
+
} catch {
|
|
3401
|
+
throw new InvalidKeyMaterialError(
|
|
3402
|
+
"The supplied public key is not an SPKI PEM EdDSA public key."
|
|
3403
|
+
);
|
|
3404
|
+
}
|
|
2168
3405
|
try {
|
|
2169
|
-
|
|
2170
|
-
|
|
3406
|
+
await flattenedVerify(
|
|
3407
|
+
{ protected: protectedB64, payload: toBytes(request.payload), signature: signatureB64 },
|
|
3408
|
+
key,
|
|
3409
|
+
{ algorithms: [JWS_ALG] }
|
|
3410
|
+
);
|
|
3411
|
+
return true;
|
|
2171
3412
|
} catch {
|
|
2172
3413
|
return false;
|
|
2173
3414
|
}
|
|
@@ -2226,10 +3467,7 @@ async function checkBash() {
|
|
|
2226
3467
|
async function checkPowershell() {
|
|
2227
3468
|
const name = "powershell";
|
|
2228
3469
|
try {
|
|
2229
|
-
const output = await execCommand("powershell", [
|
|
2230
|
-
"-Command",
|
|
2231
|
-
"$PSVersionTable.PSVersion"
|
|
2232
|
-
]);
|
|
3470
|
+
const output = await execCommand("powershell", ["-Command", "$PSVersionTable.PSVersion"]);
|
|
2233
3471
|
const version = output.trim();
|
|
2234
3472
|
return { name, status: "ok", version };
|
|
2235
3473
|
} catch {
|
|
@@ -2299,7 +3537,7 @@ function currentPlatform() {
|
|
|
2299
3537
|
if (p === "darwin" || p === "win32" || p === "linux") {
|
|
2300
3538
|
return p;
|
|
2301
3539
|
}
|
|
2302
|
-
throw new
|
|
3540
|
+
throw new SetupError(`Unsupported platform: ${p}`, "platform");
|
|
2303
3541
|
}
|
|
2304
3542
|
|
|
2305
3543
|
// src/doctor/runner.ts
|
|
@@ -2315,7 +3553,25 @@ async function runDoctor(options) {
|
|
|
2315
3553
|
nextSteps: ["Unsupported platform. vaultkeeper supports macOS, Linux, and Windows."]
|
|
2316
3554
|
};
|
|
2317
3555
|
}
|
|
2318
|
-
|
|
3556
|
+
let backends = options?.backends;
|
|
3557
|
+
let configCheck;
|
|
3558
|
+
if (backends === void 0 && options?.configDir !== void 0) {
|
|
3559
|
+
const configPath = path2.join(options.configDir, "config.json");
|
|
3560
|
+
try {
|
|
3561
|
+
const config = await loadConfig(options.configDir);
|
|
3562
|
+
backends = config.backends;
|
|
3563
|
+
configCheck = { name: "config", status: "ok", version: configPath, required: true };
|
|
3564
|
+
} catch (err) {
|
|
3565
|
+
configCheck = {
|
|
3566
|
+
name: "config",
|
|
3567
|
+
status: "invalid",
|
|
3568
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
3569
|
+
error: toPreflightConfigError(err, configPath),
|
|
3570
|
+
required: true
|
|
3571
|
+
};
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
const enabledTypes = enabledBackendTypes(backends);
|
|
2319
3575
|
const entries = buildCheckList(platform, enabledTypes);
|
|
2320
3576
|
const resolved = await Promise.all(
|
|
2321
3577
|
entries.map(async ({ check, required }) => {
|
|
@@ -2323,12 +3579,16 @@ async function runDoctor(options) {
|
|
|
2323
3579
|
return { required, result };
|
|
2324
3580
|
})
|
|
2325
3581
|
);
|
|
2326
|
-
const
|
|
3582
|
+
const configReady = configCheck === void 0 || configCheck.status === "ok";
|
|
3583
|
+
const ready = configReady && resolved.every(({ required, result }) => {
|
|
2327
3584
|
if (!required) return true;
|
|
2328
3585
|
return result.status === "ok";
|
|
2329
3586
|
});
|
|
2330
3587
|
const warnings = [];
|
|
2331
3588
|
const nextSteps = [];
|
|
3589
|
+
if (configCheck !== void 0 && configCheck.status !== "ok") {
|
|
3590
|
+
nextSteps.push(configCheck.reason ?? "Config file is invalid.");
|
|
3591
|
+
}
|
|
2332
3592
|
for (const { required, result } of resolved) {
|
|
2333
3593
|
const reasonSuffix = result.reason !== void 0 ? ` \u2014 ${result.reason}` : "";
|
|
2334
3594
|
if (result.status === "missing") {
|
|
@@ -2346,9 +3606,37 @@ async function runDoctor(options) {
|
|
|
2346
3606
|
}
|
|
2347
3607
|
}
|
|
2348
3608
|
}
|
|
2349
|
-
const checks =
|
|
3609
|
+
const checks = [
|
|
3610
|
+
...configCheck !== void 0 ? [configCheck] : [],
|
|
3611
|
+
...resolved.map(({ required, result }) => ({ ...result, required }))
|
|
3612
|
+
];
|
|
2350
3613
|
return { checks, ready, warnings, nextSteps };
|
|
2351
3614
|
}
|
|
3615
|
+
function toPreflightConfigError(err, configPath) {
|
|
3616
|
+
if (err instanceof ConfigParseError) {
|
|
3617
|
+
return { kind: "config-parse", configPath: err.path, location: err.location };
|
|
3618
|
+
}
|
|
3619
|
+
if (err instanceof UnknownBackendTypeError) {
|
|
3620
|
+
return {
|
|
3621
|
+
kind: "config-unknown-backend",
|
|
3622
|
+
configPath: err.configFilePath ?? configPath,
|
|
3623
|
+
field: err.field,
|
|
3624
|
+
backendType: err.backendType,
|
|
3625
|
+
knownBackendTypes: err.knownTypes
|
|
3626
|
+
};
|
|
3627
|
+
}
|
|
3628
|
+
if (err instanceof ConfigValidationError) {
|
|
3629
|
+
return {
|
|
3630
|
+
kind: "config-validation",
|
|
3631
|
+
configPath: err.configFilePath ?? configPath,
|
|
3632
|
+
field: err.field
|
|
3633
|
+
};
|
|
3634
|
+
}
|
|
3635
|
+
if (err instanceof FilesystemError) {
|
|
3636
|
+
return { kind: "config-read", configPath: err.path, code: err.code };
|
|
3637
|
+
}
|
|
3638
|
+
return void 0;
|
|
3639
|
+
}
|
|
2352
3640
|
function enabledBackendTypes(backends) {
|
|
2353
3641
|
if (backends === void 0) return null;
|
|
2354
3642
|
const types = /* @__PURE__ */ new Set();
|
|
@@ -2389,38 +3677,80 @@ function buildCheckList(platform, enabledTypes) {
|
|
|
2389
3677
|
}
|
|
2390
3678
|
|
|
2391
3679
|
// src/vault.ts
|
|
3680
|
+
function createDefaultInjectedBackendConfig() {
|
|
3681
|
+
return {
|
|
3682
|
+
version: 1,
|
|
3683
|
+
backends: [],
|
|
3684
|
+
keyRotation: { gracePeriodDays: 7 },
|
|
3685
|
+
defaults: { ttlMinutes: 60, trustTier: 3 }
|
|
3686
|
+
};
|
|
3687
|
+
}
|
|
3688
|
+
var BUILTIN_SIGNING_BACKENDS = ["file"];
|
|
3689
|
+
var PRESENCE_PER_USE_QUALIFYING_BACKENDS = "A qualifying backend forces a distinct, fresh human action per operation \u2014 e.g. a YubiKey slot with a touch-per-operation policy or a gpg smartcard with touch-to-sign (both cover every operation), or 1Password in per-access mode (which enforces presence for reads only today, not writes). Switch to (or reconfigure) such a backend, or drop the presence requirement.";
|
|
2392
3690
|
var usageCounts = /* @__PURE__ */ new Map();
|
|
2393
3691
|
var USAGE_MAP_MAX_SIZE = 1e4;
|
|
2394
3692
|
var VaultKeeper = class _VaultKeeper {
|
|
2395
3693
|
#config;
|
|
2396
3694
|
#keyManager;
|
|
2397
3695
|
#configDir;
|
|
3696
|
+
/**
|
|
3697
|
+
* Whether key material is persisted to (and reloaded from) `#configDir`.
|
|
3698
|
+
* Enabled only when the vault operates against a real on-disk config — i.e.
|
|
3699
|
+
* neither `config` nor `backend` was injected in-process (see
|
|
3700
|
+
* {@link VaultKeeper.init}). Injected-config/backend instances keep keys
|
|
3701
|
+
* purely in memory so tests and embedders stay hermetic.
|
|
3702
|
+
*/
|
|
3703
|
+
#persistKeys;
|
|
2398
3704
|
#backend;
|
|
2399
|
-
|
|
3705
|
+
/**
|
|
3706
|
+
* Whether {@link #backend} was injected via `init({ backend })` rather than
|
|
3707
|
+
* lazily resolved from `#config.backends`. Distinguishes the two so that
|
|
3708
|
+
* {@link activeBackendType} reports the injected instance directly instead of
|
|
3709
|
+
* consulting the (empty) config backend list. Stays correct after a lazy
|
|
3710
|
+
* resolution populates {@link #backend} in the config-driven path.
|
|
3711
|
+
*/
|
|
3712
|
+
#backendInjected;
|
|
3713
|
+
constructor(config, keyManager, configDir, persistKeys, backend) {
|
|
2400
3714
|
this.#config = config;
|
|
2401
3715
|
this.#keyManager = keyManager;
|
|
2402
3716
|
this.#configDir = configDir;
|
|
3717
|
+
this.#persistKeys = persistKeys;
|
|
3718
|
+
this.#backend = backend;
|
|
3719
|
+
this.#backendInjected = backend !== void 0;
|
|
2403
3720
|
}
|
|
2404
3721
|
/**
|
|
2405
3722
|
* Initialize a new VaultKeeper instance.
|
|
2406
3723
|
* Runs doctor checks (unless skipped), loads config, and sets up the key manager.
|
|
3724
|
+
*
|
|
3725
|
+
* The configured secret backend is resolved lazily on first use, not during
|
|
3726
|
+
* `init()`. Trust-only operations (e.g. {@link VaultKeeper.approveExecutable},
|
|
3727
|
+
* {@link VaultKeeper.checkExecutableTrust}) therefore succeed even when the
|
|
3728
|
+
* configured backend or plugin is unavailable or unregistered; a
|
|
3729
|
+
* misconfigured backend surfaces only when a secret operation is invoked.
|
|
2407
3730
|
*/
|
|
2408
3731
|
static async init(options) {
|
|
2409
3732
|
const configDir = options?.configDir ?? getDefaultConfigDir();
|
|
2410
|
-
const config = options?.config ?? await loadConfig(configDir);
|
|
3733
|
+
const config = options?.config ?? (options?.backend !== void 0 ? createDefaultInjectedBackendConfig() : await loadConfig(configDir));
|
|
2411
3734
|
if (options?.skipDoctor !== true) {
|
|
2412
3735
|
const doctorResult = await runDoctor({ backends: config.backends });
|
|
2413
3736
|
if (!doctorResult.ready) {
|
|
2414
|
-
throw new VaultError(
|
|
2415
|
-
`System not ready: ${doctorResult.nextSteps.join("; ")}`
|
|
2416
|
-
);
|
|
3737
|
+
throw new VaultError(`System not ready: ${doctorResult.nextSteps.join("; ")}`);
|
|
2417
3738
|
}
|
|
2418
3739
|
}
|
|
3740
|
+
const persistKeys = options?.config === void 0 && options?.backend === void 0;
|
|
2419
3741
|
const keyManager = new KeyManager();
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
3742
|
+
if (persistKeys) {
|
|
3743
|
+
const loaded = await loadKeyState(configDir);
|
|
3744
|
+
if (loaded !== void 0) {
|
|
3745
|
+
keyManager.hydrate(loaded);
|
|
3746
|
+
} else {
|
|
3747
|
+
await keyManager.init();
|
|
3748
|
+
await saveKeyState(configDir, keyManager.snapshot());
|
|
3749
|
+
}
|
|
3750
|
+
} else {
|
|
3751
|
+
await keyManager.init();
|
|
3752
|
+
}
|
|
3753
|
+
return new _VaultKeeper(config, keyManager, configDir, persistKeys, options?.backend);
|
|
2424
3754
|
}
|
|
2425
3755
|
/**
|
|
2426
3756
|
* Run doctor checks without full initialization.
|
|
@@ -2434,6 +3764,75 @@ var VaultKeeper = class _VaultKeeper {
|
|
|
2434
3764
|
static async doctor(options) {
|
|
2435
3765
|
return runDoctor(options);
|
|
2436
3766
|
}
|
|
3767
|
+
/**
|
|
3768
|
+
* The type identifier of the active backend — the first enabled backend in
|
|
3769
|
+
* the resolved configuration (the one `store()`, `delete()`, and `setup()`
|
|
3770
|
+
* operate on).
|
|
3771
|
+
*
|
|
3772
|
+
* @remarks
|
|
3773
|
+
* This is a pure, side-effect-free view of the resolved configuration: it
|
|
3774
|
+
* reads the type of the first enabled backend without instantiating the
|
|
3775
|
+
* backend or requiring it to be registered or healthy. Reading it therefore
|
|
3776
|
+
* never throws for an unavailable or unregistered backend — unlike a secret
|
|
3777
|
+
* operation — so it is safe to call purely to introspect an instance.
|
|
3778
|
+
*
|
|
3779
|
+
* When a backend was injected via `init({ backend })`, config-based
|
|
3780
|
+
* resolution is bypassed entirely and this reports the injected instance's
|
|
3781
|
+
* declared `type` (see {@link SecretBackend}) — or the stable sentinel
|
|
3782
|
+
* `'custom'` if it declares an empty type. It never throws in the injected
|
|
3783
|
+
* path.
|
|
3784
|
+
*
|
|
3785
|
+
* Use it to confirm which backend an instance resolved to, especially when
|
|
3786
|
+
* no config file exists and the safe zero-config default applies (see
|
|
3787
|
+
* {@link defaultBackendType}). With no config this reads `file` on every
|
|
3788
|
+
* platform, so secret operations never silently target the real OS
|
|
3789
|
+
* credential store; opt into the native store (see
|
|
3790
|
+
* {@link platformNativeBackendType}) via explicit config to change this.
|
|
3791
|
+
*
|
|
3792
|
+
* @throws A {@link BackendUnavailableError} only when the configuration has
|
|
3793
|
+
* no enabled backend at all (a configuration error, not a backend fault).
|
|
3794
|
+
* This can only happen in the config-driven path; an injected backend never
|
|
3795
|
+
* throws here.
|
|
3796
|
+
*
|
|
3797
|
+
* @public
|
|
3798
|
+
*/
|
|
3799
|
+
get activeBackendType() {
|
|
3800
|
+
if (this.#backendInjected && this.#backend !== void 0) {
|
|
3801
|
+
return _VaultKeeper.#resolveBackendTypeHint(this.#backend);
|
|
3802
|
+
}
|
|
3803
|
+
const firstEnabled = this.#config.backends.find((b) => b.enabled);
|
|
3804
|
+
if (firstEnabled === void 0) {
|
|
3805
|
+
throw new BackendUnavailableError(
|
|
3806
|
+
"No enabled backends configured",
|
|
3807
|
+
"none-enabled",
|
|
3808
|
+
this.#config.backends.map((b) => b.type)
|
|
3809
|
+
);
|
|
3810
|
+
}
|
|
3811
|
+
return firstEnabled.type;
|
|
3812
|
+
}
|
|
3813
|
+
/**
|
|
3814
|
+
* Report the {@link BackendCapabilities} of the active backend's configured
|
|
3815
|
+
* instance — the same backend {@link VaultKeeper.store}, {@link VaultKeeper.setup},
|
|
3816
|
+
* and {@link VaultKeeper.sign} operate on.
|
|
3817
|
+
*
|
|
3818
|
+
* @remarks
|
|
3819
|
+
* Unlike the pure {@link VaultKeeper.activeBackendType} getter, this resolves
|
|
3820
|
+
* (instantiates) the backend, because capabilities are a property of the
|
|
3821
|
+
* configured instance, not of the type. The answer reflects configured/live
|
|
3822
|
+
* state — e.g. a YubiKey slot's touch policy or 1Password's access mode — and
|
|
3823
|
+
* a backend that does not implement {@link PresenceCapableBackend} reports the
|
|
3824
|
+
* safe default (`{ presencePerUse: false }`) via {@link getBackendCapabilities}.
|
|
3825
|
+
* Reading this never triggers a human-presence prompt.
|
|
3826
|
+
*
|
|
3827
|
+
* @returns The active backend instance's capabilities.
|
|
3828
|
+
* @throws A {@link BackendUnavailableError} if no backend is enabled or the
|
|
3829
|
+
* configured backend cannot be built.
|
|
3830
|
+
* @public
|
|
3831
|
+
*/
|
|
3832
|
+
async getActiveBackendCapabilities() {
|
|
3833
|
+
const backend = this.#requireBackend();
|
|
3834
|
+
return getBackendCapabilities(backend);
|
|
3835
|
+
}
|
|
2437
3836
|
/**
|
|
2438
3837
|
* Store a secret in the configured backend.
|
|
2439
3838
|
*
|
|
@@ -2441,13 +3840,23 @@ var VaultKeeper = class _VaultKeeper {
|
|
|
2441
3840
|
* `store()` method. If a secret with the same name already exists, it is
|
|
2442
3841
|
* overwritten.
|
|
2443
3842
|
*
|
|
2444
|
-
* @param name - Identifier for the secret.
|
|
3843
|
+
* @param name - Identifier for the secret. Must not contain `':'` — that
|
|
3844
|
+
* character is reserved for the internal `signing-key:` namespace, so a
|
|
3845
|
+
* secret name can never collide with a signing key.
|
|
2445
3846
|
* @param value - The secret value to store.
|
|
3847
|
+
* @param options - Optional {@link PresenceRequirementOptions}. When
|
|
3848
|
+
* `requirePresencePerUse` is set, the store is refused with a
|
|
3849
|
+
* {@link NotCapableError} before the backend is touched unless the active
|
|
3850
|
+
* backend forces a fresh per-use human action.
|
|
3851
|
+
* @throws {VaultError} If `name` is empty or contains `':'`.
|
|
3852
|
+
* @throws {@link NotCapableError} If `options.requirePresencePerUse` is set
|
|
3853
|
+
* and the active backend is not presence-per-use capable.
|
|
2446
3854
|
* @public
|
|
2447
3855
|
*/
|
|
2448
|
-
async store(name, value) {
|
|
2449
|
-
_VaultKeeper.#
|
|
3856
|
+
async store(name, value, options) {
|
|
3857
|
+
_VaultKeeper.#validateName(name, "secret");
|
|
2450
3858
|
const backend = this.#requireBackend();
|
|
3859
|
+
await this.#enforcePresenceRequirement(backend, "store", options?.requirePresencePerUse);
|
|
2451
3860
|
await backend.store(name, value);
|
|
2452
3861
|
}
|
|
2453
3862
|
/**
|
|
@@ -2457,48 +3866,101 @@ var VaultKeeper = class _VaultKeeper {
|
|
|
2457
3866
|
* `delete()` method.
|
|
2458
3867
|
*
|
|
2459
3868
|
* @param name - Identifier for the secret to delete.
|
|
3869
|
+
* @param options - Optional {@link PresenceRequirementOptions}. When
|
|
3870
|
+
* `requirePresencePerUse` is set, the delete is refused with a
|
|
3871
|
+
* {@link NotCapableError} before the backend is touched unless the active
|
|
3872
|
+
* backend forces a fresh per-use human action.
|
|
3873
|
+
* @throws {@link NotCapableError} If `options.requirePresencePerUse` is set
|
|
3874
|
+
* and the active backend is not presence-per-use capable.
|
|
2460
3875
|
* @public
|
|
2461
3876
|
*/
|
|
2462
|
-
async delete(name) {
|
|
2463
|
-
_VaultKeeper.#
|
|
3877
|
+
async delete(name, options) {
|
|
3878
|
+
_VaultKeeper.#validateName(name, "secret", false);
|
|
2464
3879
|
const backend = this.#requireBackend();
|
|
3880
|
+
await this.#enforcePresenceRequirement(backend, "delete", options?.requirePresencePerUse);
|
|
2465
3881
|
await backend.delete(name);
|
|
2466
3882
|
}
|
|
3883
|
+
/**
|
|
3884
|
+
* Check whether a secret exists in the active backend, without retrieving
|
|
3885
|
+
* its value, minting a token, or touching the TOFU trust manifest.
|
|
3886
|
+
*
|
|
3887
|
+
* @remarks
|
|
3888
|
+
* This is a lightweight precondition check intended to run before any
|
|
3889
|
+
* interactive or trust-gating logic (e.g. `exec`'s caller-approval prompt),
|
|
3890
|
+
* so a nonexistent secret is reported immediately and unambiguously instead
|
|
3891
|
+
* of being masked by an unrelated approval failure — see issue #69.
|
|
3892
|
+
*
|
|
3893
|
+
* @param name - Identifier for the secret.
|
|
3894
|
+
* @returns `true` if the secret exists, `false` otherwise.
|
|
3895
|
+
* @public
|
|
3896
|
+
*/
|
|
3897
|
+
async secretExists(name) {
|
|
3898
|
+
_VaultKeeper.#validateName(name, "secret", false);
|
|
3899
|
+
const backend = this.#requireBackend();
|
|
3900
|
+
return backend.exists(name);
|
|
3901
|
+
}
|
|
2467
3902
|
/**
|
|
2468
3903
|
* Read a stored secret from the backend and mint a JWE token that encapsulates it.
|
|
2469
3904
|
*
|
|
2470
|
-
* @
|
|
2471
|
-
*
|
|
3905
|
+
* @remarks
|
|
3906
|
+
* `setup()` requires an explicit executable-trust decision — it has no
|
|
3907
|
+
* default and never silently skips verification. Pass `executablePath` (the
|
|
3908
|
+
* calling executable's real path) to run trust-on-first-use verification, or
|
|
3909
|
+
* `skipTrust: true` to deliberately skip it in development. The {@link SetupOptions}
|
|
3910
|
+
* type enforces this choice at compile time (exactly one, and the options
|
|
3911
|
+
* argument is required); {@link ExecutableTrustRequiredError} is the runtime
|
|
3912
|
+
* backstop for untyped callers.
|
|
3913
|
+
*
|
|
3914
|
+
* **Seeing a compile error here?** A bare `vault.setup('NAME')` or
|
|
3915
|
+
* `vault.setup('NAME', {})` fails to typecheck (e.g. TS2554 "Expected 2
|
|
3916
|
+
* arguments, but got 1" or TS2345 "Argument … is not assignable") precisely
|
|
3917
|
+
* because the mandatory trust choice is missing. The fix is to add **exactly
|
|
3918
|
+
* one** of `executablePath: '<path>'` (verify the caller — production) or
|
|
3919
|
+
* `skipTrust: true` (skip verification — development only). Supplying both
|
|
3920
|
+
* fails to typecheck for the same reason.
|
|
3921
|
+
*
|
|
3922
|
+
* @example
|
|
3923
|
+
* ```ts
|
|
3924
|
+
* // Production: bind the token to a STABLE executable so a swapped binary is
|
|
3925
|
+
* // rejected. Point executablePath at a released binary, or process.execPath
|
|
3926
|
+
* // to trust the Node runtime. Do NOT use process.argv[1] for a compiled entry
|
|
3927
|
+
* // point — its hash changes on every rebuild, so the next setup() after a
|
|
3928
|
+
* // recompile throws IdentityMismatchError (use setDevelopmentMode or
|
|
3929
|
+
* // skipTrust for a frequently-rebuilt local caller).
|
|
3930
|
+
* const jwe = await vault.setup('MY_API_KEY', { executablePath: '/usr/local/bin/my-tool' })
|
|
3931
|
+
*
|
|
3932
|
+
* // Local development: skip verification so rebuilds don't reject the caller.
|
|
3933
|
+
* const devJwe = await vault.setup('MY_API_KEY', { skipTrust: true })
|
|
3934
|
+
* ```
|
|
3935
|
+
*
|
|
3936
|
+
* @param secretName - Identifier for the secret. Must not contain `':'` (the
|
|
3937
|
+
* reserved `signing-key:` namespace separator).
|
|
3938
|
+
* @param options - Setup options; must carry exactly one of `executablePath`
|
|
3939
|
+
* or `skipTrust: true`
|
|
2472
3940
|
* @returns Compact JWE string
|
|
3941
|
+
* @throws {VaultError} If `secretName` is empty or contains `':'`.
|
|
3942
|
+
* @throws {@link ExecutableTrustRequiredError} If neither `executablePath`
|
|
3943
|
+
* nor `skipTrust: true` is provided, if both are, or if `executablePath` is
|
|
3944
|
+
* the retired legacy `'dev'` opt-out sentinel (use `skipTrust: true`).
|
|
3945
|
+
* @throws {@link IdentityMismatchError} If `executablePath`'s current hash no
|
|
3946
|
+
* longer matches a previously approved value (TOFU conflict).
|
|
3947
|
+
* @throws {@link FilesystemError} If `executablePath` cannot be read or hashed
|
|
3948
|
+
* for verification, or the trust manifest cannot be read or written while
|
|
3949
|
+
* recording the executable.
|
|
2473
3950
|
*/
|
|
2474
3951
|
async setup(secretName, options) {
|
|
2475
|
-
_VaultKeeper.#
|
|
3952
|
+
_VaultKeeper.#validateName(secretName, "secret");
|
|
2476
3953
|
const backend = this.#requireBackend();
|
|
2477
|
-
const
|
|
2478
|
-
|
|
2479
|
-
const
|
|
2480
|
-
const
|
|
2481
|
-
const
|
|
3954
|
+
const { exeIdentity, commit: commitExecutableTrust } = await this.#resolveExecutableIdentity(options);
|
|
3955
|
+
await this.#enforcePresenceRequirement(backend, "read", options.requirePresencePerUse);
|
|
3956
|
+
const backendType = _VaultKeeper.#resolveBackendTypeHint(backend, options.backendType);
|
|
3957
|
+
const ttlMinutes = options.ttlMinutes ?? this.#config.defaults.ttlMinutes;
|
|
3958
|
+
const trustTier = options.trustTier ?? this.#config.defaults.trustTier;
|
|
3959
|
+
const useLimit = options.useLimit ?? null;
|
|
2482
3960
|
const secretValue = await backend.retrieve(secretName);
|
|
2483
|
-
let exeIdentity;
|
|
2484
|
-
if (executablePath === "dev" || this.#isDevModeExecutable(executablePath)) {
|
|
2485
|
-
exeIdentity = "dev";
|
|
2486
|
-
} else {
|
|
2487
|
-
const trustResult = await verifyTrust(executablePath, {
|
|
2488
|
-
configDir: this.#configDir
|
|
2489
|
-
});
|
|
2490
|
-
if (trustResult.tofuConflict) {
|
|
2491
|
-
throw new IdentityMismatchError(
|
|
2492
|
-
"Executable hash changed \u2014 re-approval required",
|
|
2493
|
-
"previously-approved",
|
|
2494
|
-
trustResult.identity.hash
|
|
2495
|
-
);
|
|
2496
|
-
}
|
|
2497
|
-
exeIdentity = trustResult.identity.hash;
|
|
2498
|
-
}
|
|
2499
3961
|
const now = Math.floor(Date.now() / 1e3);
|
|
2500
3962
|
const claims = {
|
|
2501
|
-
jti:
|
|
3963
|
+
jti: crypto2.randomUUID(),
|
|
2502
3964
|
exp: now + ttlMinutes * 60,
|
|
2503
3965
|
iat: now,
|
|
2504
3966
|
sub: secretName,
|
|
@@ -2510,7 +3972,9 @@ var VaultKeeper = class _VaultKeeper {
|
|
|
2510
3972
|
ref: secretName
|
|
2511
3973
|
};
|
|
2512
3974
|
const currentKey = this.#keyManager.getCurrentKey();
|
|
2513
|
-
|
|
3975
|
+
const token = createToken(currentKey.key, claims, { kid: currentKey.id });
|
|
3976
|
+
await commitExecutableTrust();
|
|
3977
|
+
return token;
|
|
2514
3978
|
}
|
|
2515
3979
|
/**
|
|
2516
3980
|
* Decrypt a JWE, validate claims, verify executable identity, and return
|
|
@@ -2549,46 +4013,71 @@ var VaultKeeper = class _VaultKeeper {
|
|
|
2549
4013
|
return { token, vaultResponse };
|
|
2550
4014
|
}
|
|
2551
4015
|
/**
|
|
2552
|
-
* Execute a delegated HTTP fetch, injecting
|
|
4016
|
+
* Execute a delegated HTTP fetch, injecting secrets from the token(s).
|
|
2553
4017
|
*
|
|
2554
|
-
*
|
|
2555
|
-
*
|
|
2556
|
-
* is executed. The raw secret is never exposed in the return value.
|
|
4018
|
+
* **Single token:** every `{{secret}}` placeholder in `request.url`,
|
|
4019
|
+
* `request.headers`, and `request.body` is replaced with the secret value.
|
|
2557
4020
|
*
|
|
2558
|
-
* @
|
|
2559
|
-
*
|
|
2560
|
-
*
|
|
4021
|
+
* **Token map ({@link SecretTokenMap}):** every `{{secret:name}}` placeholder
|
|
4022
|
+
* is replaced with the secret from the corresponding named token.
|
|
4023
|
+
*
|
|
4024
|
+
* The raw secret is never exposed in the return value.
|
|
4025
|
+
*
|
|
4026
|
+
* @param token - A single `CapabilityToken` or a `SecretTokenMap` mapping
|
|
4027
|
+
* names to tokens obtained from `authorize()`.
|
|
4028
|
+
* @param request - The fetch request template with placeholders.
|
|
2561
4029
|
* @returns The `Response` from the underlying `fetch()` call, together with
|
|
2562
4030
|
* the vault metadata (`vaultResponse`).
|
|
2563
|
-
* @throws {
|
|
2564
|
-
* instance.
|
|
4031
|
+
* @throws {AuthorizationDeniedError} If any token is invalid or was not
|
|
4032
|
+
* created by this vault instance.
|
|
4033
|
+
* @throws {VaultError} If a named placeholder references an unknown
|
|
4034
|
+
* secret name.
|
|
4035
|
+
* @throws {FetchError} If the URL is malformed or the underlying network
|
|
4036
|
+
* request fails.
|
|
2565
4037
|
*/
|
|
2566
4038
|
async fetch(token, request) {
|
|
2567
|
-
const
|
|
2568
|
-
const response = await delegatedFetch(
|
|
4039
|
+
const secrets = _VaultKeeper.#resolveSecrets(token);
|
|
4040
|
+
const response = await delegatedFetch(secrets, request);
|
|
2569
4041
|
return {
|
|
2570
4042
|
response,
|
|
2571
4043
|
vaultResponse: { keyStatus: "current" }
|
|
2572
4044
|
};
|
|
2573
4045
|
}
|
|
2574
4046
|
/**
|
|
2575
|
-
* Execute a delegated command, injecting
|
|
4047
|
+
* Execute a delegated command, injecting secrets from the token(s).
|
|
2576
4048
|
*
|
|
2577
|
-
*
|
|
2578
|
-
*
|
|
2579
|
-
* The raw secret is never exposed in the return value.
|
|
4049
|
+
* **Single token:** every `{{secret}}` placeholder in `request.env` values
|
|
4050
|
+
* is replaced with the secret value.
|
|
2580
4051
|
*
|
|
2581
|
-
* @
|
|
2582
|
-
*
|
|
2583
|
-
*
|
|
4052
|
+
* **Token map ({@link SecretTokenMap}):** every `{{secret:name}}` placeholder
|
|
4053
|
+
* is replaced with the secret from the corresponding named token.
|
|
4054
|
+
*
|
|
4055
|
+
* Secret placeholders are not supported in `request.command` or
|
|
4056
|
+
* `request.args` — process arguments are visible to other processes via
|
|
4057
|
+
* `ps` and often collected in logs and telemetry.
|
|
4058
|
+
*
|
|
4059
|
+
* The raw secret is never exposed in the return value: by default the
|
|
4060
|
+
* captured `stdout`/`stderr` is scrubbed of every injected secret value
|
|
4061
|
+
* (replaced with `[REDACTED]`), so a command that echoes the secret does not
|
|
4062
|
+
* leak it back. Pass `request.redact = false` to receive raw, unredacted
|
|
4063
|
+
* output — only when a caller genuinely needs it, since that forfeits the
|
|
4064
|
+
* guarantee.
|
|
4065
|
+
*
|
|
4066
|
+
* @param token - A single `CapabilityToken` or a `SecretTokenMap` mapping
|
|
4067
|
+
* names to tokens obtained from `authorize()`.
|
|
4068
|
+
* @param request - The exec request template with placeholders. Set
|
|
4069
|
+
* `redact: false` to opt out of output redaction.
|
|
2584
4070
|
* @returns The command result (`stdout`, `stderr`, `exitCode`) together with
|
|
2585
4071
|
* the vault metadata (`vaultResponse`).
|
|
2586
|
-
* @throws {
|
|
2587
|
-
* instance.
|
|
4072
|
+
* @throws {AuthorizationDeniedError} If any token is invalid or was not
|
|
4073
|
+
* created by this vault instance.
|
|
4074
|
+
* @throws {ExecError} If the command cannot be started (e.g. ENOENT),
|
|
4075
|
+
* a placeholder references an unknown secret name, or a secret
|
|
4076
|
+
* placeholder appears in the `command` or `args` field.
|
|
2588
4077
|
*/
|
|
2589
4078
|
async exec(token, request) {
|
|
2590
|
-
const
|
|
2591
|
-
const result = await delegatedExec(
|
|
4079
|
+
const secrets = _VaultKeeper.#resolveSecrets(token);
|
|
4080
|
+
const result = await delegatedExec(secrets, request);
|
|
2592
4081
|
return {
|
|
2593
4082
|
result,
|
|
2594
4083
|
vaultResponse: { keyStatus: "current" }
|
|
@@ -2603,58 +4092,228 @@ var VaultKeeper = class _VaultKeeper {
|
|
|
2603
4092
|
*
|
|
2604
4093
|
* @param token - A `CapabilityToken` obtained from `authorize()`.
|
|
2605
4094
|
* @returns A `SecretAccessor` that can be read exactly once.
|
|
2606
|
-
* @throws {
|
|
2607
|
-
* instance
|
|
4095
|
+
* @throws {AuthorizationDeniedError} If `token` is invalid or was not created
|
|
4096
|
+
* by this vault instance, or if it is a signing-key token — a signing key
|
|
4097
|
+
* carries no secret and cannot be read through `getSecret()` (use `sign()`).
|
|
2608
4098
|
*/
|
|
2609
4099
|
getSecret(token) {
|
|
2610
4100
|
const claims = validateCapabilityToken(token);
|
|
4101
|
+
if (isSigningClaims(claims)) {
|
|
4102
|
+
throw new AuthorizationDeniedError(
|
|
4103
|
+
"This capability token authorizes a signing key, not a secret \u2014 it cannot be read with getSecret(). Use sign() instead."
|
|
4104
|
+
);
|
|
4105
|
+
}
|
|
4106
|
+
if (claims.kty === "signing-key") {
|
|
4107
|
+
throw new AuthorizationDeniedError(
|
|
4108
|
+
"This capability token authorizes a signing key, not a secret \u2014 it cannot be read with getSecret(). Use sign() instead."
|
|
4109
|
+
);
|
|
4110
|
+
}
|
|
4111
|
+
if (claims.val === void 0) {
|
|
4112
|
+
throw new AuthorizationDeniedError(
|
|
4113
|
+
"This capability token does not carry a secret value \u2014 it is malformed or was not minted as a secret token."
|
|
4114
|
+
);
|
|
4115
|
+
}
|
|
2611
4116
|
return createSecretAccessor(claims.val);
|
|
2612
4117
|
}
|
|
2613
4118
|
/**
|
|
2614
|
-
*
|
|
4119
|
+
* Enroll a new signing keypair under `name` in the active backend.
|
|
2615
4120
|
*
|
|
2616
|
-
* The
|
|
2617
|
-
*
|
|
2618
|
-
*
|
|
2619
|
-
*
|
|
4121
|
+
* The keypair is generated and stored entirely backend-side (see
|
|
4122
|
+
* {@link SigningBackend}); the private key never enters vault claims, a
|
|
4123
|
+
* capability token, or the caller's process. Signing keys occupy a namespace
|
|
4124
|
+
* distinct from secrets, so a signing key and a secret can share a name
|
|
4125
|
+
* without colliding, and a signing key can never be read as a secret.
|
|
2620
4126
|
*
|
|
2621
|
-
* @param
|
|
2622
|
-
*
|
|
2623
|
-
* @
|
|
2624
|
-
*
|
|
2625
|
-
* @throws {
|
|
2626
|
-
*
|
|
2627
|
-
* @throws {
|
|
2628
|
-
*
|
|
2629
|
-
|
|
2630
|
-
|
|
4127
|
+
* @param name - Caller-facing signing key name. Must not contain `':'` (the
|
|
4128
|
+
* `signing-key:` namespace separator).
|
|
4129
|
+
* @param algorithm - The JOSE signing algorithm (currently only `'EdDSA'`).
|
|
4130
|
+
* @returns The public half of the newly enrolled key.
|
|
4131
|
+
* @throws {SigningNotSupportedError} If the active backend cannot sign.
|
|
4132
|
+
* @throws {InvalidAlgorithmError} If `algorithm` is not supported.
|
|
4133
|
+
* @throws {SigningKeyAlreadyExistsError} If a signing key already exists under `name`.
|
|
4134
|
+
* @throws {VaultError} If `name` is empty or contains `':'`.
|
|
4135
|
+
* @public
|
|
4136
|
+
*/
|
|
4137
|
+
async createSigningKey(name, algorithm) {
|
|
4138
|
+
_VaultKeeper.#validateName(name, "signing key");
|
|
4139
|
+
const backend = this.#requireSigningBackend();
|
|
4140
|
+
const id = _VaultKeeper.#signingKeyId(name);
|
|
4141
|
+
await backend.generateSigningKey(id, algorithm);
|
|
4142
|
+
return backend.getPublicKey(id);
|
|
4143
|
+
}
|
|
4144
|
+
/**
|
|
4145
|
+
* Export the SPKI PEM public key for the signing key named `name`.
|
|
4146
|
+
*
|
|
4147
|
+
* @param name - Caller-facing signing key name. Must not contain `':'`.
|
|
4148
|
+
* @returns The public key material (SPKI PEM, algorithm, kid).
|
|
4149
|
+
* @throws {SigningNotSupportedError} If the active backend cannot sign.
|
|
4150
|
+
* @throws {SigningKeyNotFoundError} If no signing key exists under `name`.
|
|
4151
|
+
* @throws {VaultError} If `name` is empty or contains `':'`.
|
|
4152
|
+
* @public
|
|
4153
|
+
*/
|
|
4154
|
+
async exportPublicKey(name) {
|
|
4155
|
+
_VaultKeeper.#validateName(name, "signing key");
|
|
4156
|
+
const backend = this.#requireSigningBackend();
|
|
4157
|
+
return backend.getPublicKey(_VaultKeeper.#signingKeyId(name));
|
|
4158
|
+
}
|
|
4159
|
+
/**
|
|
4160
|
+
* Mint a signing-key capability token for the key named `name`.
|
|
4161
|
+
*
|
|
4162
|
+
* The returned token carries only `{ kid, backendRef, keyType: 'signing-key' }`
|
|
4163
|
+
* — never any key material — and is accepted only by {@link VaultKeeper.sign}.
|
|
4164
|
+
* Passing it to `getSecret`/`fetch`/`exec` is rejected.
|
|
4165
|
+
*
|
|
4166
|
+
* @param name - Caller-facing signing key name. Must not contain `':'`.
|
|
4167
|
+
* @returns An opaque {@link CapabilityToken} usable with `sign()`.
|
|
4168
|
+
* @throws {SigningNotSupportedError} If the active backend cannot sign.
|
|
4169
|
+
* @throws {SigningKeyNotFoundError} If no signing key exists under `name`.
|
|
4170
|
+
* @throws {VaultError} If `name` is empty or contains `':'`.
|
|
4171
|
+
* @public
|
|
4172
|
+
*/
|
|
4173
|
+
async authorizeSigningKey(name) {
|
|
4174
|
+
_VaultKeeper.#validateName(name, "signing key");
|
|
4175
|
+
const backend = this.#requireSigningBackend();
|
|
4176
|
+
const id = _VaultKeeper.#signingKeyId(name);
|
|
4177
|
+
const pub = await backend.getPublicKey(id);
|
|
4178
|
+
return createSigningCapabilityToken({ keyType: "signing-key", kid: pub.kid, backendRef: id });
|
|
4179
|
+
}
|
|
4180
|
+
/**
|
|
4181
|
+
* Sign a caller-supplied payload with a signing-key capability token.
|
|
4182
|
+
*
|
|
4183
|
+
* The signature is produced backend-side via {@link SigningBackend.signWithKey}
|
|
4184
|
+
* — the private key never leaves the backend and never appears in the token,
|
|
4185
|
+
* the claims, or this process. The result is a detached-payload Compact JWS
|
|
4186
|
+
* (RFC 7515 §7.2.2 + RFC 7797 `b64:false`, `crit:["b64"]`, `alg:EdDSA`) that
|
|
4187
|
+
* any standards-compliant JOSE library can verify given the payload and the
|
|
4188
|
+
* public key.
|
|
4189
|
+
*
|
|
4190
|
+
* @param token - A signing-key `CapabilityToken` from {@link VaultKeeper.authorizeSigningKey}.
|
|
4191
|
+
* @param request - The payload to sign.
|
|
4192
|
+
* @param options - Optional {@link PresenceRequirementOptions}. When
|
|
4193
|
+
* `requirePresencePerUse` is set, the signature is refused with a
|
|
4194
|
+
* {@link NotCapableError} before the backend is touched unless the active
|
|
4195
|
+
* backend forces a fresh per-use human action. When capable, a fresh
|
|
4196
|
+
* backend `signWithKey` round-trip is performed for this call — no cached
|
|
4197
|
+
* key material can satisfy it (the private key never leaves the backend).
|
|
4198
|
+
* @returns The detached compact JWS and vault metadata.
|
|
4199
|
+
* @throws {AuthorizationDeniedError} If `token` is invalid or is not a
|
|
4200
|
+
* signing-key token (e.g. an ordinary secret token).
|
|
4201
|
+
* @throws {SigningNotSupportedError} If the active backend cannot sign.
|
|
4202
|
+
* @throws {SigningKeyNotFoundError} If the referenced key no longer exists.
|
|
4203
|
+
* @throws {@link NotCapableError} If `options.requirePresencePerUse` is set
|
|
4204
|
+
* and the active backend is not presence-per-use capable.
|
|
4205
|
+
* @public
|
|
4206
|
+
*/
|
|
4207
|
+
async sign(token, request, options) {
|
|
2631
4208
|
const claims = validateCapabilityToken(token);
|
|
2632
|
-
|
|
2633
|
-
|
|
4209
|
+
if (!isSigningClaims(claims)) {
|
|
4210
|
+
throw new AuthorizationDeniedError(
|
|
4211
|
+
"sign() requires a signing-key capability token from authorizeSigningKey() \u2014 an ordinary secret token cannot be used to sign."
|
|
4212
|
+
);
|
|
4213
|
+
}
|
|
4214
|
+
const backend = this.#requireSigningBackend();
|
|
4215
|
+
await this.#enforcePresenceRequirement(backend, "sign", options?.requirePresencePerUse);
|
|
4216
|
+
const jws = await createDetachedJws(
|
|
4217
|
+
claims.kid,
|
|
4218
|
+
request.payload,
|
|
4219
|
+
(data) => backend.signWithKey(claims.backendRef, data)
|
|
4220
|
+
);
|
|
2634
4221
|
return {
|
|
2635
|
-
result,
|
|
4222
|
+
result: { jws },
|
|
2636
4223
|
vaultResponse: { keyStatus: "current" }
|
|
2637
4224
|
};
|
|
2638
4225
|
}
|
|
2639
4226
|
/**
|
|
2640
|
-
* Verify a
|
|
2641
|
-
*
|
|
2642
|
-
* This is a static method — no VaultKeeper instance, secrets, or
|
|
2643
|
-
* capability tokens are required. It is safe to call from CI or any
|
|
2644
|
-
* context that has access to public key material.
|
|
4227
|
+
* Verify a detached-payload Compact JWS against a public key — fully offline.
|
|
2645
4228
|
*
|
|
2646
|
-
*
|
|
2647
|
-
*
|
|
4229
|
+
* This is a static, asynchronous method: no VaultKeeper instance, backend,
|
|
4230
|
+
* config, or capability token is required, so it is safe to call in CI or any
|
|
4231
|
+
* context holding only public material.
|
|
2648
4232
|
*
|
|
2649
|
-
*
|
|
2650
|
-
*
|
|
4233
|
+
* Returns `false` for a signature that does not verify — a tampered payload,
|
|
4234
|
+
* the wrong key, or a structurally malformed JWS. It throws
|
|
4235
|
+
* {@link InvalidKeyMaterialError} only when the public key itself is not
|
|
4236
|
+
* parseable (or a private key was supplied) — an operational fault distinct
|
|
4237
|
+
* from a bad signature.
|
|
2651
4238
|
*
|
|
2652
|
-
* @param request - The
|
|
2653
|
-
* algorithm override.
|
|
4239
|
+
* @param request - The detached payload, the JWS, and the SPKI PEM public key.
|
|
2654
4240
|
* @returns `true` if the signature is valid, `false` otherwise.
|
|
4241
|
+
* @throws {InvalidKeyMaterialError} If `request.publicKey` is not parseable
|
|
4242
|
+
* SPKI public key material.
|
|
4243
|
+
* @public
|
|
4244
|
+
*/
|
|
4245
|
+
static async verify(request) {
|
|
4246
|
+
return verifyDetachedJws(request);
|
|
4247
|
+
}
|
|
4248
|
+
/**
|
|
4249
|
+
* Resolve the active backend and assert it implements the signing contract.
|
|
4250
|
+
* @throws {SigningNotSupportedError} If the active backend cannot sign.
|
|
4251
|
+
*/
|
|
4252
|
+
#requireSigningBackend() {
|
|
4253
|
+
const backend = this.#requireBackend();
|
|
4254
|
+
if (!isSigningBackend(backend)) {
|
|
4255
|
+
throw new SigningNotSupportedError(
|
|
4256
|
+
`The active backend ('${backend.type}') does not implement the signing contract. The built-in '${BUILTIN_SIGNING_BACKENDS.join("', '")}' backend does, and a custom backend may implement SigningBackend.`,
|
|
4257
|
+
backend.type,
|
|
4258
|
+
[...BUILTIN_SIGNING_BACKENDS]
|
|
4259
|
+
);
|
|
4260
|
+
}
|
|
4261
|
+
return backend;
|
|
4262
|
+
}
|
|
4263
|
+
/**
|
|
4264
|
+
* Shared enforcement for a presence-per-use requirement, called at the top of
|
|
4265
|
+
* every backend-touching access path ({@link VaultKeeper.store},
|
|
4266
|
+
* {@link VaultKeeper.delete}, {@link VaultKeeper.setup}, {@link VaultKeeper.sign}).
|
|
4267
|
+
*
|
|
4268
|
+
* @remarks
|
|
4269
|
+
* This is the single, non-bypassable point of enforcement — deliberately not
|
|
4270
|
+
* duplicated per CLI command. When `require` is not `true` it is a no-op.
|
|
4271
|
+
* Otherwise it queries the backend's capabilities **fresh on every call**
|
|
4272
|
+
* (never cached across operations, so a prior satisfied call can never
|
|
4273
|
+
* satisfy a later one) and throws {@link NotCapableError} — before the caller
|
|
4274
|
+
* performs any credential/session/device operation — when the configured
|
|
4275
|
+
* instance does not advertise {@link BackendCapabilities.presencePerUse}, **or**
|
|
4276
|
+
* advertises it but does not force a fresh action for **this** `operation` (see
|
|
4277
|
+
* {@link BackendCapabilities.presenceEnforcedOperations}). The latter makes
|
|
4278
|
+
* enforcement operation-aware and fail-closed: e.g. 1Password `per-access`
|
|
4279
|
+
* forces presence for reads but not `store`/`delete`, so a flagged write is
|
|
4280
|
+
* refused here rather than silently passing through the cached session client.
|
|
4281
|
+
*
|
|
4282
|
+
* When the backend *is* capable for this operation, this returns and the
|
|
4283
|
+
* caller proceeds to the backend's ordinary operation, which by the meaning of
|
|
4284
|
+
* the capability forces a distinct fresh human action for this specific call.
|
|
4285
|
+
* The presence action itself (and any {@link PresenceDeclinedError}/
|
|
4286
|
+
* {@link PresenceTimeoutError}) therefore surfaces from that backend operation,
|
|
4287
|
+
* not from here — so two consecutive required-presence operations each drive
|
|
4288
|
+
* their own backend call and each demand their own fresh action.
|
|
4289
|
+
*
|
|
4290
|
+
* @throws {@link NotCapableError} If `require` is `true` and the backend either
|
|
4291
|
+
* is not presence-per-use capable or does not force presence for `operation`.
|
|
2655
4292
|
*/
|
|
2656
|
-
|
|
2657
|
-
|
|
4293
|
+
async #enforcePresenceRequirement(backend, operation, require2) {
|
|
4294
|
+
if (require2 !== true) {
|
|
4295
|
+
return;
|
|
4296
|
+
}
|
|
4297
|
+
const capabilities = await getBackendCapabilities(backend);
|
|
4298
|
+
if (!capabilities.presencePerUse) {
|
|
4299
|
+
throw new NotCapableError(
|
|
4300
|
+
`This operation required presence-per-use, but the active backend ('${backend.type}') cannot guarantee it. ${PRESENCE_PER_USE_QUALIFYING_BACKENDS}`,
|
|
4301
|
+
backend.type,
|
|
4302
|
+
"presencePerUse"
|
|
4303
|
+
);
|
|
4304
|
+
}
|
|
4305
|
+
const enforced = capabilities.presenceEnforcedOperations;
|
|
4306
|
+
if (enforced !== void 0 && !enforced.includes(operation)) {
|
|
4307
|
+
throw new NotCapableError(
|
|
4308
|
+
`This '${operation}' operation required presence-per-use, but the active backend ('${backend.type}') only enforces a fresh per-use action for [${enforced.join(", ")}] \u2014 not '${operation}'. ${PRESENCE_PER_USE_QUALIFYING_BACKENDS}`,
|
|
4309
|
+
backend.type,
|
|
4310
|
+
"presencePerUse"
|
|
4311
|
+
);
|
|
4312
|
+
}
|
|
4313
|
+
}
|
|
4314
|
+
/** Map a caller-facing signing key name to its namespaced backend id. */
|
|
4315
|
+
static #signingKeyId(name) {
|
|
4316
|
+
return `signing-key:${name}`;
|
|
2658
4317
|
}
|
|
2659
4318
|
/**
|
|
2660
4319
|
* Rotate the current encryption key.
|
|
@@ -2670,7 +4329,7 @@ var VaultKeeper = class _VaultKeeper {
|
|
|
2670
4329
|
async rotateKey() {
|
|
2671
4330
|
const gracePeriodMs = this.#config.keyRotation.gracePeriodDays * 24 * 60 * 60 * 1e3;
|
|
2672
4331
|
this.#keyManager.rotateKey(gracePeriodMs);
|
|
2673
|
-
await
|
|
4332
|
+
await this.#persistKeyState();
|
|
2674
4333
|
}
|
|
2675
4334
|
/**
|
|
2676
4335
|
* Emergency key revocation — invalidates the previous key immediately.
|
|
@@ -2681,7 +4340,17 @@ var VaultKeeper = class _VaultKeeper {
|
|
|
2681
4340
|
*/
|
|
2682
4341
|
async revokeKey() {
|
|
2683
4342
|
this.#keyManager.revokeKey();
|
|
2684
|
-
await
|
|
4343
|
+
await this.#persistKeyState();
|
|
4344
|
+
}
|
|
4345
|
+
/**
|
|
4346
|
+
* Persist the current key state to the config dir when persistence is
|
|
4347
|
+
* enabled. A no-op for injected-config/backend instances (in-memory keys).
|
|
4348
|
+
*/
|
|
4349
|
+
async #persistKeyState() {
|
|
4350
|
+
if (!this.#persistKeys) {
|
|
4351
|
+
return;
|
|
4352
|
+
}
|
|
4353
|
+
await saveKeyState(this.#configDir, this.#keyManager.snapshot());
|
|
2685
4354
|
}
|
|
2686
4355
|
/**
|
|
2687
4356
|
* Add or remove an executable from the development-mode whitelist.
|
|
@@ -2711,12 +4380,149 @@ var VaultKeeper = class _VaultKeeper {
|
|
|
2711
4380
|
}
|
|
2712
4381
|
await Promise.resolve();
|
|
2713
4382
|
}
|
|
4383
|
+
/**
|
|
4384
|
+
* Approve an executable for trust-on-first-use by recording its current
|
|
4385
|
+
* SHA-256 hash in the trust manifest.
|
|
4386
|
+
*
|
|
4387
|
+
* After approval, {@link VaultKeeper.setup} and
|
|
4388
|
+
* {@link VaultKeeper.checkExecutableTrust} recognize the executable (matched
|
|
4389
|
+
* by its resolved absolute path and content hash) as trusted, so callers can
|
|
4390
|
+
* skip an interactive approval prompt.
|
|
4391
|
+
*
|
|
4392
|
+
* The operation is idempotent: approving the same, unchanged executable more
|
|
4393
|
+
* than once leaves a single manifest entry.
|
|
4394
|
+
*
|
|
4395
|
+
* @param executablePath - Path to the executable to approve. Resolved to an
|
|
4396
|
+
* absolute path before hashing and recording.
|
|
4397
|
+
* @returns The recorded trust status (always `trusted: true`).
|
|
4398
|
+
* @throws {FilesystemError} If the executable does not exist or cannot be read.
|
|
4399
|
+
* @public
|
|
4400
|
+
*/
|
|
4401
|
+
async approveExecutable(executablePath) {
|
|
4402
|
+
const resolved = path2.resolve(executablePath);
|
|
4403
|
+
const hash = await hashExecutable(resolved);
|
|
4404
|
+
const manifest = await loadManifest(this.#configDir);
|
|
4405
|
+
const updated = addTrustedHash(manifest, resolved, hash);
|
|
4406
|
+
await saveManifest(this.#configDir, updated);
|
|
4407
|
+
return {
|
|
4408
|
+
trusted: true,
|
|
4409
|
+
hash,
|
|
4410
|
+
hashMismatch: false,
|
|
4411
|
+
approvedHashes: updated.get(resolved)?.hashes ?? [hash],
|
|
4412
|
+
reason: "Hash recorded in trust manifest"
|
|
4413
|
+
};
|
|
4414
|
+
}
|
|
4415
|
+
/**
|
|
4416
|
+
* Check whether an executable is trusted according to the trust manifest,
|
|
4417
|
+
* without modifying the manifest.
|
|
4418
|
+
*
|
|
4419
|
+
* This is a read-only probe. Unlike {@link VaultKeeper.setup}, it never
|
|
4420
|
+
* records a hash, so it can be used to decide whether an interactive approval
|
|
4421
|
+
* prompt is required before proceeding.
|
|
4422
|
+
*
|
|
4423
|
+
* @param executablePath - Path to the executable to check. Resolved to an
|
|
4424
|
+
* absolute path before hashing and lookup.
|
|
4425
|
+
* @returns The current trust status. `trusted` is `true` only when the
|
|
4426
|
+
* executable's current hash matches an approved manifest entry.
|
|
4427
|
+
* @throws {FilesystemError} If the executable does not exist or cannot be read.
|
|
4428
|
+
* @public
|
|
4429
|
+
*/
|
|
4430
|
+
async checkExecutableTrust(executablePath) {
|
|
4431
|
+
const resolved = path2.resolve(executablePath);
|
|
4432
|
+
const hash = await hashExecutable(resolved);
|
|
4433
|
+
const manifest = await loadManifest(this.#configDir);
|
|
4434
|
+
const approvedHashes = manifest.get(resolved)?.hashes ?? [];
|
|
4435
|
+
if (isTrusted(manifest, resolved, hash)) {
|
|
4436
|
+
return {
|
|
4437
|
+
trusted: true,
|
|
4438
|
+
hash,
|
|
4439
|
+
hashMismatch: false,
|
|
4440
|
+
approvedHashes,
|
|
4441
|
+
reason: "Hash found in trust manifest"
|
|
4442
|
+
};
|
|
4443
|
+
}
|
|
4444
|
+
const hashMismatch = approvedHashes.length > 0;
|
|
4445
|
+
return {
|
|
4446
|
+
trusted: false,
|
|
4447
|
+
hash,
|
|
4448
|
+
hashMismatch,
|
|
4449
|
+
approvedHashes,
|
|
4450
|
+
reason: hashMismatch ? "Executable hash changed from a previously approved value \u2014 re-approval required" : "Executable not yet approved"
|
|
4451
|
+
};
|
|
4452
|
+
}
|
|
2714
4453
|
// ---------------------------------------------------------------------------
|
|
2715
4454
|
// Private helpers
|
|
2716
4455
|
// ---------------------------------------------------------------------------
|
|
2717
|
-
static #
|
|
4456
|
+
static #resolveSecrets(token) {
|
|
4457
|
+
if (token instanceof CapabilityToken) {
|
|
4458
|
+
return _VaultKeeper.#requireSecretValue(token);
|
|
4459
|
+
}
|
|
4460
|
+
const result = {};
|
|
4461
|
+
for (const [name, t] of Object.entries(token)) {
|
|
4462
|
+
if (!(t instanceof CapabilityToken)) {
|
|
4463
|
+
throw new AuthorizationDeniedError(
|
|
4464
|
+
`Invalid capability token for secret "${name}" \u2014 expected a CapabilityToken from authorize()`
|
|
4465
|
+
);
|
|
4466
|
+
}
|
|
4467
|
+
result[name] = _VaultKeeper.#requireSecretValue(t);
|
|
4468
|
+
}
|
|
4469
|
+
return result;
|
|
4470
|
+
}
|
|
4471
|
+
/**
|
|
4472
|
+
* Resolve a token to its secret claims, rejecting a signing-key token.
|
|
4473
|
+
*
|
|
4474
|
+
* Defense in depth: a signing-key capability must never be injectable as a
|
|
4475
|
+
* secret through `fetch()`/`exec()`. This rejects both the in-memory
|
|
4476
|
+
* signing-key token (`isSigningClaims`) and a JWE-based signing-key lease
|
|
4477
|
+
* (`kty: 'signing-key'`) — the two capability shapes are discriminated
|
|
4478
|
+
* differently but neither ever carries a secret value.
|
|
4479
|
+
*/
|
|
4480
|
+
static #requireSecretClaims(token) {
|
|
4481
|
+
const claims = validateCapabilityToken(token);
|
|
4482
|
+
if (isSigningClaims(claims)) {
|
|
4483
|
+
throw new AuthorizationDeniedError(
|
|
4484
|
+
"This capability token authorizes a signing key, not a secret \u2014 it cannot be injected into fetch() or exec()."
|
|
4485
|
+
);
|
|
4486
|
+
}
|
|
4487
|
+
if (claims.kty === "signing-key") {
|
|
4488
|
+
throw new AuthorizationDeniedError(
|
|
4489
|
+
"This capability token authorizes a signing-key lease, not a secret \u2014 it cannot be injected into fetch() or exec()."
|
|
4490
|
+
);
|
|
4491
|
+
}
|
|
4492
|
+
return claims;
|
|
4493
|
+
}
|
|
4494
|
+
/** Resolve a token to its secret value, rejecting a claims shape with no `val`. */
|
|
4495
|
+
static #requireSecretValue(token) {
|
|
4496
|
+
const claims = _VaultKeeper.#requireSecretClaims(token);
|
|
4497
|
+
if (claims.val === void 0 || claims.val.trim() === "") {
|
|
4498
|
+
throw new AuthorizationDeniedError("This capability token does not authorize a secret value.");
|
|
4499
|
+
}
|
|
4500
|
+
return claims.val;
|
|
4501
|
+
}
|
|
4502
|
+
/**
|
|
4503
|
+
* Validate a caller-supplied resource name. `kind` names the resource in the
|
|
4504
|
+
* error so a signing-key caller is not told about a "secret".
|
|
4505
|
+
*
|
|
4506
|
+
* When `enforceReserved` is true (the default, used by name-creating/binding
|
|
4507
|
+
* paths — `store`/`setup` and every signing-key operation), the name may not
|
|
4508
|
+
* contain `':'`. The `signing-key:<name>` prefix is a reserved internal
|
|
4509
|
+
* namespace, so forbidding `':'` at creation time is what actually enforces
|
|
4510
|
+
* the documented guarantee that a secret and a signing key can never collide
|
|
4511
|
+
* under one name — the CLI's name pattern already forbids `':'`, and this
|
|
4512
|
+
* closes the same hole for direct library callers. Read/delete/existence
|
|
4513
|
+
* paths pass `false` so a legacy secret whose name contains `':'` (stored
|
|
4514
|
+
* before this rule, or seeded directly through a backend) stays reachable for
|
|
4515
|
+
* inspection and cleanup.
|
|
4516
|
+
*/
|
|
4517
|
+
static #validateName(name, kind, enforceReserved = true) {
|
|
4518
|
+
const noun = kind === "secret" ? "Secret" : "Signing key";
|
|
2718
4519
|
if (name.trim() === "") {
|
|
2719
|
-
throw new VaultError(
|
|
4520
|
+
throw new VaultError(`${noun} name must not be empty`);
|
|
4521
|
+
}
|
|
4522
|
+
if (enforceReserved && name.includes(":")) {
|
|
4523
|
+
throw new VaultError(
|
|
4524
|
+
`${noun} name must not contain ':'. The 'signing-key:' prefix is a reserved internal namespace, so a secret and a signing key can never collide under one name.`
|
|
4525
|
+
);
|
|
2720
4526
|
}
|
|
2721
4527
|
}
|
|
2722
4528
|
#resolveBackend() {
|
|
@@ -2730,18 +4536,32 @@ var VaultKeeper = class _VaultKeeper {
|
|
|
2730
4536
|
}
|
|
2731
4537
|
const firstEnabled = enabledBackends[0];
|
|
2732
4538
|
if (firstEnabled === void 0) {
|
|
2733
|
-
throw new BackendUnavailableError(
|
|
2734
|
-
"No enabled backends configured",
|
|
2735
|
-
"none-enabled",
|
|
2736
|
-
[]
|
|
2737
|
-
);
|
|
4539
|
+
throw new BackendUnavailableError("No enabled backends configured", "none-enabled", []);
|
|
2738
4540
|
}
|
|
2739
|
-
return BackendRegistry.create(firstEnabled.type, firstEnabled);
|
|
4541
|
+
return BackendRegistry.create(firstEnabled.type, firstEnabled, this.#configDir);
|
|
2740
4542
|
}
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
4543
|
+
/**
|
|
4544
|
+
* Resolve the backend-type hint used both for introspection
|
|
4545
|
+
* ({@link activeBackendType}) and for the `bkd` claim minted by
|
|
4546
|
+
* {@link setup}. A non-blank `override` wins (trimmed); an empty or
|
|
4547
|
+
* whitespace-only override is treated the same as no override, since
|
|
4548
|
+
* honoring it would mint a token with a blank `bkd` claim. Otherwise the
|
|
4549
|
+
* backend's declared `type` is used (trimmed), and a backend that declares
|
|
4550
|
+
* an empty or whitespace-only type — permitted for injected backends —
|
|
4551
|
+
* falls back to the stable `'custom'` sentinel. Centralizing this keeps
|
|
4552
|
+
* both paths in sync so no route ever mints a token with an empty `bkd`
|
|
4553
|
+
* claim (which {@link validateClaims} rejects, making the token unusable).
|
|
4554
|
+
*/
|
|
4555
|
+
static #resolveBackendTypeHint(backend, override) {
|
|
4556
|
+
const trimmedOverride = override?.trim();
|
|
4557
|
+
if (trimmedOverride !== void 0 && trimmedOverride !== "") {
|
|
4558
|
+
return trimmedOverride;
|
|
2744
4559
|
}
|
|
4560
|
+
const declared = backend.type.trim();
|
|
4561
|
+
return declared === "" ? "custom" : declared;
|
|
4562
|
+
}
|
|
4563
|
+
#requireBackend() {
|
|
4564
|
+
this.#backend ??= this.#resolveBackend();
|
|
2745
4565
|
return this.#backend;
|
|
2746
4566
|
}
|
|
2747
4567
|
#isDevModeExecutable(executablePath) {
|
|
@@ -2750,6 +4570,69 @@ var VaultKeeper = class _VaultKeeper {
|
|
|
2750
4570
|
}
|
|
2751
4571
|
return this.#config.developmentMode.executables.includes(executablePath);
|
|
2752
4572
|
}
|
|
4573
|
+
/**
|
|
4574
|
+
* Resolve the executable identity to embed in a minted token, enforcing that
|
|
4575
|
+
* the caller made an explicit trust decision.
|
|
4576
|
+
*
|
|
4577
|
+
* Returns the sentinel `'dev'` (no executable binding) when trust is
|
|
4578
|
+
* deliberately skipped or the path is on the development-mode allowlist;
|
|
4579
|
+
* otherwise runs TOFU *verification only* and returns the verified hash
|
|
4580
|
+
* together with a `commit` callback. Verification never writes to the trust
|
|
4581
|
+
* manifest by itself — `commit` stages that write, and the caller
|
|
4582
|
+
* ({@link VaultKeeper.setup}) must invoke it only after the operation trust
|
|
4583
|
+
* was gating has actually succeeded. This is the fail-fast/defer-write split
|
|
4584
|
+
* from issue #148: shape validation (missing/conflicting/legacy-sentinel)
|
|
4585
|
+
* still throws immediately here, before any backend read, but a
|
|
4586
|
+
* first-encounter or Sigstore hash is not durably recorded until `commit`
|
|
4587
|
+
* runs.
|
|
4588
|
+
*
|
|
4589
|
+
* @throws {ExecutableTrustRequiredError} If neither `executablePath` nor
|
|
4590
|
+
* `skipTrust: true` is provided, if both are, or if `executablePath` is the
|
|
4591
|
+
* retired legacy `'dev'` opt-out sentinel.
|
|
4592
|
+
* @throws {IdentityMismatchError} On a TOFU hash conflict. Conflicts never
|
|
4593
|
+
* stage a manifest write regardless of whether `commit` is later called.
|
|
4594
|
+
*/
|
|
4595
|
+
async #resolveExecutableIdentity(options) {
|
|
4596
|
+
const executablePath = options?.executablePath;
|
|
4597
|
+
const skipTrust = options?.skipTrust === true;
|
|
4598
|
+
const noCommit = () => Promise.resolve();
|
|
4599
|
+
if (skipTrust && executablePath !== void 0) {
|
|
4600
|
+
throw new ExecutableTrustRequiredError(
|
|
4601
|
+
"VaultKeeper.setup() received both options.executablePath and options.skipTrust: true, which are mutually exclusive. Pass options.executablePath to verify the calling executable, or options.skipTrust: true to skip verification (development only) \u2014 not both.",
|
|
4602
|
+
"conflicting-choice"
|
|
4603
|
+
);
|
|
4604
|
+
}
|
|
4605
|
+
if (skipTrust) {
|
|
4606
|
+
return { exeIdentity: "dev", commit: noCommit };
|
|
4607
|
+
}
|
|
4608
|
+
if (executablePath === void 0) {
|
|
4609
|
+
throw new ExecutableTrustRequiredError(
|
|
4610
|
+
"VaultKeeper.setup() requires an explicit executable-trust choice and no longer defaults to skipping verification. Either pass options.executablePath set to the calling executable's real path (runs trust-on-first-use verification), or set options.skipTrust: true to deliberately skip verification (development only).",
|
|
4611
|
+
"missing-choice"
|
|
4612
|
+
);
|
|
4613
|
+
}
|
|
4614
|
+
if (executablePath === "dev") {
|
|
4615
|
+
throw new ExecutableTrustRequiredError(
|
|
4616
|
+
"VaultKeeper.setup() no longer supports the legacy options.executablePath: 'dev' sentinel for skipping trust verification. Set options.skipTrust: true to deliberately skip verification (development only), or pass options.executablePath set to the calling executable's real path to verify it.",
|
|
4617
|
+
"legacy-dev-sentinel"
|
|
4618
|
+
);
|
|
4619
|
+
}
|
|
4620
|
+
if (this.#isDevModeExecutable(executablePath)) {
|
|
4621
|
+
return { exeIdentity: "dev", commit: noCommit };
|
|
4622
|
+
}
|
|
4623
|
+
const pending = await verifyTrustPending(path2.resolve(executablePath), {
|
|
4624
|
+
configDir: this.#configDir
|
|
4625
|
+
});
|
|
4626
|
+
if (pending.tofuConflict) {
|
|
4627
|
+
const previousHash = pending.approvedHashes.at(-1) ?? pending.identity.hash;
|
|
4628
|
+
throw new IdentityMismatchError(
|
|
4629
|
+
"Executable hash changed \u2014 re-approval required",
|
|
4630
|
+
previousHash,
|
|
4631
|
+
pending.identity.hash
|
|
4632
|
+
);
|
|
4633
|
+
}
|
|
4634
|
+
return { exeIdentity: pending.identity.hash, commit: () => commitTrust(pending) };
|
|
4635
|
+
}
|
|
2753
4636
|
async #decryptWithKeyResolution(jwe, kid) {
|
|
2754
4637
|
if (kid !== void 0) {
|
|
2755
4638
|
const key = this.#keyManager.findKeyById(kid);
|
|
@@ -2777,6 +4660,6 @@ var VaultKeeper = class _VaultKeeper {
|
|
|
2777
4660
|
}
|
|
2778
4661
|
};
|
|
2779
4662
|
|
|
2780
|
-
export { AccessorConsumedError, AuthorizationDeniedError, BackendLockedError, BackendRegistry, BackendUnavailableError, CapabilityToken, DeviceNotPresentError, ExecError, FilesystemError, IdentityMismatchError, InvalidAlgorithmError, InvalidTokenError, KeyRevokedError, KeyRotatedError, PluginNotFoundError, RotationInProgressError, SecretNotFoundError, SetupError, TokenExpiredError, TokenRevokedError, UsageLimitExceededError, VaultError, VaultKeeper, isListableBackend, runDoctor };
|
|
4663
|
+
export { AccessorConsumedError, AuthorizationDeniedError, BackendLockedError, BackendRegistry, BackendUnavailableError, CapabilityToken, ConfigParseError, ConfigValidationError, DecryptionError, DeviceNotPresentError, ExecError, ExecutableTrustRequiredError, FetchError, FilesystemError, IdentityMismatchError, InvalidAlgorithmError, InvalidKeyMaterialError, InvalidTokenError, KeyRevokedError, KeyRotatedError, NotCapableError, PluginNotFoundError, PresenceDeclinedError, PresenceTimeoutError, REDACTED, RotationInProgressError, SecretNotFoundError, SetupError, SigningKeyAlreadyExistsError, SigningKeyNotFoundError, SigningNotSupportedError, TokenExpiredError, TokenRevokedError, UnknownBackendTypeError, UsageLimitExceededError, VaultError, VaultKeeper, defaultBackendType, getBackendCapabilities, getDefaultConfigDir, getPlatformDefaultConfigDir, isListableBackend, isPresenceCapableBackend, isSigningBackend, loadConfig, platformNativeBackendType, redactSecrets, runDoctor };
|
|
2781
4664
|
//# sourceMappingURL=index.js.map
|
|
2782
4665
|
//# sourceMappingURL=index.js.map
|