vaultkeeper 0.6.0 → 0.7.0

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