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