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