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