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