vaultkeeper 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1048 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -10
- package/dist/index.d.ts +9 -10
- package/dist/index.js +1046 -41
- package/dist/index.js.map +1 -1
- package/dist/one-password-worker.js +27 -3
- package/dist/one-password-worker.js.map +1 -1
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import
|
|
2
|
-
import * as
|
|
3
|
-
import
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path3 from 'path';
|
|
3
|
+
import { join, dirname, resolve } from 'path';
|
|
4
4
|
import * as os4 from 'os';
|
|
5
|
-
import * as
|
|
6
|
-
import '
|
|
5
|
+
import * as crypto from 'crypto';
|
|
6
|
+
import { spawn } from 'child_process';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
7
8
|
import * as fs4 from 'fs';
|
|
9
|
+
import { existsSync, readFileSync } from 'fs';
|
|
8
10
|
import { CompactEncrypt, compactDecrypt } from 'jose';
|
|
9
11
|
|
|
10
12
|
// src/errors.ts
|
|
@@ -188,11 +190,6 @@ var RotationInProgressError = class extends VaultError {
|
|
|
188
190
|
}
|
|
189
191
|
};
|
|
190
192
|
|
|
191
|
-
// src/backend/types.ts
|
|
192
|
-
function isListableBackend(backend) {
|
|
193
|
-
return "list" in backend && typeof backend.list === "function";
|
|
194
|
-
}
|
|
195
|
-
|
|
196
193
|
// src/backend/registry.ts
|
|
197
194
|
var BackendRegistry = class {
|
|
198
195
|
static backends = /* @__PURE__ */ new Map();
|
|
@@ -208,10 +205,11 @@ var BackendRegistry = class {
|
|
|
208
205
|
/**
|
|
209
206
|
* Create a backend instance by type.
|
|
210
207
|
* @param type - Backend type identifier
|
|
208
|
+
* @param config - Optional backend configuration forwarded to the factory
|
|
211
209
|
* @returns A SecretBackend instance
|
|
212
210
|
* @throws {@link BackendUnavailableError} if the backend type is not registered
|
|
213
211
|
*/
|
|
214
|
-
static create(type) {
|
|
212
|
+
static create(type, config) {
|
|
215
213
|
const factory = this.backends.get(type);
|
|
216
214
|
if (factory === void 0) {
|
|
217
215
|
throw new BackendUnavailableError(
|
|
@@ -220,7 +218,7 @@ var BackendRegistry = class {
|
|
|
220
218
|
Array.from(this.backends.keys())
|
|
221
219
|
);
|
|
222
220
|
}
|
|
223
|
-
return factory();
|
|
221
|
+
return factory(config);
|
|
224
222
|
}
|
|
225
223
|
/**
|
|
226
224
|
* Get all registered backend type identifiers.
|
|
@@ -297,17 +295,158 @@ var BackendRegistry = class {
|
|
|
297
295
|
this.setups.clear();
|
|
298
296
|
}
|
|
299
297
|
};
|
|
298
|
+
var STORAGE_DIR_NAME = path3.join(".vaultkeeper", "file");
|
|
299
|
+
var KEY_FILE = ".key";
|
|
300
|
+
var GCM_IV_BYTES = 12;
|
|
301
|
+
var GCM_KEY_BYTES = 32;
|
|
302
|
+
var GCM_TAG_LENGTH = 128;
|
|
303
|
+
function getStorageDir() {
|
|
304
|
+
return path3.join(os4.homedir(), STORAGE_DIR_NAME);
|
|
305
|
+
}
|
|
306
|
+
function getEntryPath(storageDir, id) {
|
|
307
|
+
const safeId = Buffer.from(id, "utf8").toString("hex");
|
|
308
|
+
return path3.join(storageDir, `${safeId}.enc`);
|
|
309
|
+
}
|
|
310
|
+
async function ensureStorageDir(storageDir) {
|
|
311
|
+
try {
|
|
312
|
+
await fs.mkdir(storageDir, { recursive: true, mode: 448 });
|
|
313
|
+
} catch (err) {
|
|
314
|
+
if (err instanceof Error && "code" in err && err.code !== "EEXIST") {
|
|
315
|
+
throw new FilesystemError(
|
|
316
|
+
`Failed to create storage directory: ${storageDir}`,
|
|
317
|
+
storageDir,
|
|
318
|
+
"rwx"
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async function getOrCreateKey(storageDir) {
|
|
324
|
+
const keyPath = path3.join(storageDir, KEY_FILE);
|
|
325
|
+
try {
|
|
326
|
+
const data = await fs.readFile(keyPath);
|
|
327
|
+
return data;
|
|
328
|
+
} catch (err) {
|
|
329
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
330
|
+
const key = crypto.randomBytes(GCM_KEY_BYTES);
|
|
331
|
+
await fs.writeFile(keyPath, key, { mode: 384 });
|
|
332
|
+
return key;
|
|
333
|
+
}
|
|
334
|
+
throw err;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
function encryptGcm(key, plaintext) {
|
|
338
|
+
const iv = crypto.randomBytes(GCM_IV_BYTES);
|
|
339
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, {
|
|
340
|
+
authTagLength: GCM_TAG_LENGTH / 8
|
|
341
|
+
});
|
|
342
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
343
|
+
const authTag = cipher.getAuthTag();
|
|
344
|
+
return [iv.toString("base64"), authTag.toString("base64"), encrypted.toString("base64")].join(":");
|
|
345
|
+
}
|
|
346
|
+
function decryptGcm(key, encoded) {
|
|
347
|
+
const parts = encoded.split(":");
|
|
348
|
+
if (parts.length !== 3) {
|
|
349
|
+
throw new Error("Invalid encrypted file format: expected iv:authTag:ciphertext");
|
|
350
|
+
}
|
|
351
|
+
const [ivB64, authTagB64, ciphertextB64] = parts;
|
|
352
|
+
if (ivB64 === void 0 || authTagB64 === void 0 || ciphertextB64 === void 0) {
|
|
353
|
+
throw new Error("Invalid encrypted file format: missing part");
|
|
354
|
+
}
|
|
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
|
+
}
|
|
365
|
+
var FileBackend = class {
|
|
366
|
+
type = "file";
|
|
367
|
+
displayName = "Encrypted File Store";
|
|
368
|
+
async isAvailable() {
|
|
369
|
+
try {
|
|
370
|
+
const storageDir = getStorageDir();
|
|
371
|
+
await ensureStorageDir(storageDir);
|
|
372
|
+
return true;
|
|
373
|
+
} catch {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async store(id, secret) {
|
|
378
|
+
const storageDir = getStorageDir();
|
|
379
|
+
await ensureStorageDir(storageDir);
|
|
380
|
+
const key = await getOrCreateKey(storageDir);
|
|
381
|
+
const entryPath = getEntryPath(storageDir, id);
|
|
382
|
+
const encrypted = encryptGcm(key, secret);
|
|
383
|
+
await fs.writeFile(entryPath, encrypted, { mode: 384 });
|
|
384
|
+
}
|
|
385
|
+
async retrieve(id) {
|
|
386
|
+
const storageDir = getStorageDir();
|
|
387
|
+
const entryPath = getEntryPath(storageDir, id);
|
|
388
|
+
let encoded;
|
|
389
|
+
try {
|
|
390
|
+
encoded = await fs.readFile(entryPath, "utf8");
|
|
391
|
+
} catch (err) {
|
|
392
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
393
|
+
throw new SecretNotFoundError(`Secret not found in file store: ${id}`);
|
|
394
|
+
}
|
|
395
|
+
throw err;
|
|
396
|
+
}
|
|
397
|
+
const key = await getOrCreateKey(storageDir);
|
|
398
|
+
try {
|
|
399
|
+
return decryptGcm(key, encoded);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`Failed to decrypt secret: ${err instanceof Error ? err.message : String(err)}`
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async delete(id) {
|
|
407
|
+
const storageDir = getStorageDir();
|
|
408
|
+
const entryPath = getEntryPath(storageDir, id);
|
|
409
|
+
try {
|
|
410
|
+
await fs.unlink(entryPath);
|
|
411
|
+
} catch (err) {
|
|
412
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
413
|
+
throw new SecretNotFoundError(`Secret not found in file store: ${id}`);
|
|
414
|
+
}
|
|
415
|
+
throw err;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
async exists(id) {
|
|
419
|
+
const storageDir = getStorageDir();
|
|
420
|
+
const entryPath = getEntryPath(storageDir, id);
|
|
421
|
+
try {
|
|
422
|
+
await fs.access(entryPath);
|
|
423
|
+
return true;
|
|
424
|
+
} catch {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
async list() {
|
|
429
|
+
const storageDir = getStorageDir();
|
|
430
|
+
let entries;
|
|
431
|
+
try {
|
|
432
|
+
entries = await fs.readdir(storageDir);
|
|
433
|
+
} catch {
|
|
434
|
+
return [];
|
|
435
|
+
}
|
|
436
|
+
return entries.filter((f) => f.endsWith(".enc")).map((f) => Buffer.from(f.slice(0, -4), "hex").toString("utf8"));
|
|
437
|
+
}
|
|
438
|
+
};
|
|
300
439
|
async function execCommand(command, args, options) {
|
|
301
|
-
const result = await execCommandFull(command, args);
|
|
440
|
+
const result = await execCommandFull(command, args, options);
|
|
302
441
|
if (result.exitCode !== 0) {
|
|
303
442
|
throw new Error(`Command failed with exit code ${String(result.exitCode)}: ${result.stderr}`);
|
|
304
443
|
}
|
|
305
444
|
return result.stdout.trim();
|
|
306
445
|
}
|
|
307
446
|
function execCommandFull(command, args, options) {
|
|
308
|
-
return new Promise((
|
|
447
|
+
return new Promise((resolve2, reject) => {
|
|
309
448
|
const proc = spawn(command, args, {
|
|
310
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
449
|
+
stdio: [options?.stdin !== void 0 ? "pipe" : "ignore", "pipe", "pipe"]
|
|
311
450
|
});
|
|
312
451
|
let stdout = "";
|
|
313
452
|
let stderr = "";
|
|
@@ -317,25 +456,887 @@ function execCommandFull(command, args, options) {
|
|
|
317
456
|
proc.stderr?.on("data", (data) => {
|
|
318
457
|
stderr += data.toString();
|
|
319
458
|
});
|
|
459
|
+
if (options?.stdin !== void 0 && proc.stdin) {
|
|
460
|
+
proc.stdin.write(options.stdin);
|
|
461
|
+
proc.stdin.end();
|
|
462
|
+
}
|
|
463
|
+
if (options?.timeoutMs !== void 0) {
|
|
464
|
+
setTimeout(() => {
|
|
465
|
+
proc.kill("SIGTERM");
|
|
466
|
+
reject(new Error(`Command timed out after ${String(options.timeoutMs)}ms`));
|
|
467
|
+
}, options.timeoutMs);
|
|
468
|
+
}
|
|
320
469
|
proc.on("close", (code) => {
|
|
321
|
-
|
|
470
|
+
resolve2({ stdout, stderr, exitCode: code ?? 1 });
|
|
322
471
|
});
|
|
323
472
|
proc.on("error", (error) => {
|
|
324
473
|
reject(error);
|
|
325
474
|
});
|
|
326
475
|
});
|
|
327
476
|
}
|
|
328
|
-
|
|
329
|
-
|
|
477
|
+
|
|
478
|
+
// src/backend/keychain-backend.ts
|
|
479
|
+
var ACCOUNT = "vaultkeeper";
|
|
480
|
+
var SERVICE_PREFIX = "vaultkeeper:";
|
|
481
|
+
var KeychainBackend = class {
|
|
482
|
+
type = "keychain";
|
|
483
|
+
displayName = "macOS Keychain";
|
|
484
|
+
async isAvailable() {
|
|
485
|
+
if (process.platform !== "darwin") {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
const result = await execCommandFull("security", ["version"]);
|
|
490
|
+
return result.exitCode === 0;
|
|
491
|
+
} catch {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
async store(id, secret) {
|
|
496
|
+
const service = `${SERVICE_PREFIX}${id}`;
|
|
497
|
+
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
|
+
]);
|
|
505
|
+
await execCommand("security", [
|
|
506
|
+
"add-generic-password",
|
|
507
|
+
"-a",
|
|
508
|
+
ACCOUNT,
|
|
509
|
+
"-s",
|
|
510
|
+
service,
|
|
511
|
+
"-w",
|
|
512
|
+
encoded
|
|
513
|
+
]);
|
|
514
|
+
}
|
|
515
|
+
async retrieve(id) {
|
|
516
|
+
const service = `${SERVICE_PREFIX}${id}`;
|
|
517
|
+
const result = await execCommandFull("security", [
|
|
518
|
+
"find-generic-password",
|
|
519
|
+
"-a",
|
|
520
|
+
ACCOUNT,
|
|
521
|
+
"-s",
|
|
522
|
+
service,
|
|
523
|
+
"-w"
|
|
524
|
+
]);
|
|
525
|
+
if (result.exitCode !== 0) {
|
|
526
|
+
throw new SecretNotFoundError(`Secret not found in macOS Keychain: ${id}`);
|
|
527
|
+
}
|
|
528
|
+
const encoded = result.stdout.trim();
|
|
529
|
+
return Buffer.from(encoded, "base64").toString("utf8");
|
|
530
|
+
}
|
|
531
|
+
async delete(id) {
|
|
532
|
+
const service = `${SERVICE_PREFIX}${id}`;
|
|
533
|
+
const result = await execCommandFull("security", [
|
|
534
|
+
"delete-generic-password",
|
|
535
|
+
"-a",
|
|
536
|
+
ACCOUNT,
|
|
537
|
+
"-s",
|
|
538
|
+
service
|
|
539
|
+
]);
|
|
540
|
+
if (result.exitCode !== 0) {
|
|
541
|
+
throw new SecretNotFoundError(`Secret not found in macOS Keychain: ${id}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
async exists(id) {
|
|
545
|
+
const service = `${SERVICE_PREFIX}${id}`;
|
|
546
|
+
const result = await execCommandFull("security", [
|
|
547
|
+
"find-generic-password",
|
|
548
|
+
"-a",
|
|
549
|
+
ACCOUNT,
|
|
550
|
+
"-s",
|
|
551
|
+
service
|
|
552
|
+
]);
|
|
553
|
+
return result.exitCode === 0;
|
|
554
|
+
}
|
|
555
|
+
async list() {
|
|
556
|
+
const result = await execCommandFull("security", [
|
|
557
|
+
"dump-keychain"
|
|
558
|
+
]);
|
|
559
|
+
if (result.exitCode !== 0) {
|
|
560
|
+
return [];
|
|
561
|
+
}
|
|
562
|
+
const ids = [];
|
|
563
|
+
const servicePattern = /0x00000007 <blob>="vaultkeeper:([^"]+)"/g;
|
|
564
|
+
let match = servicePattern.exec(result.stdout);
|
|
565
|
+
while (match !== null) {
|
|
566
|
+
const id = match[1];
|
|
567
|
+
if (id !== void 0) {
|
|
568
|
+
ids.push(id);
|
|
569
|
+
}
|
|
570
|
+
match = servicePattern.exec(result.stdout);
|
|
571
|
+
}
|
|
572
|
+
return ids;
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
function getStoragePath() {
|
|
576
|
+
return path3.join(os4.homedir(), ".vaultkeeper", "dpapi");
|
|
577
|
+
}
|
|
578
|
+
function getEntryPath2(storageDir, id) {
|
|
579
|
+
const safeId = Buffer.from(id, "utf8").toString("hex");
|
|
580
|
+
return path3.join(storageDir, `${safeId}.enc`);
|
|
581
|
+
}
|
|
582
|
+
var DpapiBackend = class {
|
|
583
|
+
type = "dpapi";
|
|
584
|
+
displayName = "Windows DPAPI";
|
|
585
|
+
async isAvailable() {
|
|
586
|
+
if (process.platform !== "win32") {
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
const result = await execCommandFull("powershell", [
|
|
591
|
+
"-NoProfile",
|
|
592
|
+
"-Command",
|
|
593
|
+
"[System.Security.Cryptography.ProtectedData] | Out-Null; exit 0"
|
|
594
|
+
]);
|
|
595
|
+
return result.exitCode === 0;
|
|
596
|
+
} catch {
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
async store(id, secret) {
|
|
601
|
+
const storageDir = getStoragePath();
|
|
602
|
+
await fs.mkdir(storageDir, { recursive: true });
|
|
603
|
+
const entryPath = getEntryPath2(storageDir, id);
|
|
604
|
+
const script = [
|
|
605
|
+
"Add-Type -AssemblyName System.Security",
|
|
606
|
+
`$bytes = [System.Text.Encoding]::UTF8.GetBytes(${JSON.stringify(secret)})`,
|
|
607
|
+
"$entropy = $null",
|
|
608
|
+
"$scope = [System.Security.Cryptography.DataProtectionScope]::CurrentUser",
|
|
609
|
+
"$encrypted = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $entropy, $scope)",
|
|
610
|
+
`[System.IO.File]::WriteAllBytes(${JSON.stringify(entryPath)}, $encrypted)`
|
|
611
|
+
].join("; ");
|
|
612
|
+
await execCommand("powershell", ["-NoProfile", "-Command", script]);
|
|
613
|
+
}
|
|
614
|
+
async retrieve(id) {
|
|
615
|
+
const storageDir = getStoragePath();
|
|
616
|
+
const entryPath = getEntryPath2(storageDir, id);
|
|
617
|
+
try {
|
|
618
|
+
await fs.access(entryPath);
|
|
619
|
+
} catch {
|
|
620
|
+
throw new SecretNotFoundError(`Secret not found in Windows DPAPI store: ${id}`);
|
|
621
|
+
}
|
|
622
|
+
const script = [
|
|
623
|
+
"Add-Type -AssemblyName System.Security",
|
|
624
|
+
`$encrypted = [System.IO.File]::ReadAllBytes(${JSON.stringify(entryPath)})`,
|
|
625
|
+
"$entropy = $null",
|
|
626
|
+
"$scope = [System.Security.Cryptography.DataProtectionScope]::CurrentUser",
|
|
627
|
+
"$bytes = [System.Security.Cryptography.ProtectedData]::Unprotect($encrypted, $entropy, $scope)",
|
|
628
|
+
"Write-Output ([System.Text.Encoding]::UTF8.GetString($bytes))"
|
|
629
|
+
].join("; ");
|
|
630
|
+
return execCommand("powershell", ["-NoProfile", "-Command", script]);
|
|
631
|
+
}
|
|
632
|
+
async delete(id) {
|
|
633
|
+
const storageDir = getStoragePath();
|
|
634
|
+
const entryPath = getEntryPath2(storageDir, id);
|
|
635
|
+
try {
|
|
636
|
+
await fs.unlink(entryPath);
|
|
637
|
+
} catch (err) {
|
|
638
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
639
|
+
throw new SecretNotFoundError(`Secret not found in Windows DPAPI store: ${id}`);
|
|
640
|
+
}
|
|
641
|
+
throw err;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
async exists(id) {
|
|
645
|
+
const storageDir = getStoragePath();
|
|
646
|
+
const entryPath = getEntryPath2(storageDir, id);
|
|
647
|
+
try {
|
|
648
|
+
await fs.access(entryPath);
|
|
649
|
+
return true;
|
|
650
|
+
} catch {
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
async list() {
|
|
655
|
+
const storageDir = getStoragePath();
|
|
656
|
+
let entries;
|
|
657
|
+
try {
|
|
658
|
+
entries = await fs.readdir(storageDir);
|
|
659
|
+
} catch {
|
|
660
|
+
return [];
|
|
661
|
+
}
|
|
662
|
+
return entries.filter((f) => f.endsWith(".enc")).map((f) => Buffer.from(f.slice(0, -4), "hex").toString("utf8"));
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
// src/backend/secret-tool-backend.ts
|
|
667
|
+
var ATTRIBUTE_KEY = "vaultkeeper-id";
|
|
668
|
+
var LABEL_PREFIX = "vaultkeeper: ";
|
|
669
|
+
var SecretToolBackend = class {
|
|
670
|
+
type = "secret-tool";
|
|
671
|
+
displayName = "Linux Secret Service (secret-tool)";
|
|
672
|
+
async isAvailable() {
|
|
673
|
+
if (process.platform !== "linux") {
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
try {
|
|
677
|
+
const result = await execCommandFull("secret-tool", ["--version"]);
|
|
678
|
+
return result.exitCode === 0;
|
|
679
|
+
} catch {
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
async store(id, secret) {
|
|
684
|
+
const label = `${LABEL_PREFIX}${id}`;
|
|
685
|
+
await execCommand(
|
|
686
|
+
"secret-tool",
|
|
687
|
+
["store", "--label", label, ATTRIBUTE_KEY, id],
|
|
688
|
+
{ stdin: secret }
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
async retrieve(id) {
|
|
692
|
+
const result = await execCommandFull("secret-tool", ["lookup", ATTRIBUTE_KEY, id]);
|
|
693
|
+
if (result.exitCode !== 0 || result.stdout.trim() === "") {
|
|
694
|
+
throw new SecretNotFoundError(`Secret not found in Secret Service: ${id}`);
|
|
695
|
+
}
|
|
696
|
+
return result.stdout.trim();
|
|
697
|
+
}
|
|
698
|
+
async delete(id) {
|
|
699
|
+
const result = await execCommandFull("secret-tool", ["clear", ATTRIBUTE_KEY, id]);
|
|
700
|
+
if (result.exitCode !== 0) {
|
|
701
|
+
throw new SecretNotFoundError(`Secret not found in Secret Service: ${id}`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
async exists(id) {
|
|
705
|
+
const result = await execCommandFull("secret-tool", ["lookup", ATTRIBUTE_KEY, id]);
|
|
706
|
+
return result.exitCode === 0 && result.stdout.trim() !== "";
|
|
707
|
+
}
|
|
708
|
+
async list() {
|
|
709
|
+
const result = await execCommandFull("secret-tool", [
|
|
710
|
+
"search",
|
|
711
|
+
ATTRIBUTE_KEY,
|
|
712
|
+
""
|
|
713
|
+
]);
|
|
714
|
+
if (result.exitCode !== 0) {
|
|
715
|
+
return [];
|
|
716
|
+
}
|
|
717
|
+
const ids = [];
|
|
718
|
+
const attrPattern = new RegExp(`attribute\\.${ATTRIBUTE_KEY} = (.+)`, "g");
|
|
719
|
+
let match = attrPattern.exec(result.stdout);
|
|
720
|
+
while (match !== null) {
|
|
721
|
+
const id = match[1];
|
|
722
|
+
if (id !== void 0) {
|
|
723
|
+
ids.push(id);
|
|
724
|
+
}
|
|
725
|
+
match = attrPattern.exec(result.stdout);
|
|
726
|
+
}
|
|
727
|
+
return ids;
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
var INTEGRATION_NAME = "vaultkeeper";
|
|
731
|
+
var cachedVersion;
|
|
732
|
+
function getIntegrationVersion() {
|
|
733
|
+
if (cachedVersion !== void 0) return cachedVersion;
|
|
734
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
735
|
+
const candidates = [
|
|
736
|
+
resolve(dir, "..", "..", "package.json"),
|
|
737
|
+
resolve(dir, "..", "package.json")
|
|
738
|
+
];
|
|
739
|
+
for (const candidate of candidates) {
|
|
740
|
+
if (!existsSync(candidate)) continue;
|
|
741
|
+
const raw = JSON.parse(readFileSync(candidate, "utf8"));
|
|
742
|
+
if (raw !== null && typeof raw === "object" && "version" in raw && typeof raw.version === "string") {
|
|
743
|
+
cachedVersion = raw.version;
|
|
744
|
+
return cachedVersion;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
throw new Error(
|
|
748
|
+
`Could not read version from vaultkeeper package.json. Tried paths: ${candidates.join(", ")}`
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// 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
|
+
var SESSION_TIMEOUT_MS = 3e4;
|
|
757
|
+
function isWorkerSuccess(res) {
|
|
758
|
+
return "value" in res;
|
|
759
|
+
}
|
|
760
|
+
function isWorkerResponse(value) {
|
|
761
|
+
if (value === null || typeof value !== "object") return false;
|
|
762
|
+
if ("value" in value && typeof value.value === "string") return true;
|
|
763
|
+
if ("error" in value && typeof value.error === "string" && "code" in value && typeof value.code === "string")
|
|
764
|
+
return true;
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
var OnePasswordBackend = class {
|
|
768
|
+
type = "1password";
|
|
769
|
+
displayName = "1Password";
|
|
770
|
+
vaultId;
|
|
771
|
+
account;
|
|
772
|
+
serviceAccountToken;
|
|
773
|
+
accessMode;
|
|
774
|
+
sessionTimeoutMs;
|
|
775
|
+
/** In-flight or resolved client promise — prevents duplicate createClient calls. */
|
|
776
|
+
clientPromise;
|
|
777
|
+
constructor(options) {
|
|
778
|
+
if (options.accessMode === "per-access" && options.serviceAccountToken !== void 0) {
|
|
779
|
+
throw new Error(
|
|
780
|
+
"per-access mode requires desktop biometric authentication and cannot be used with a service account token"
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
if (options.account !== void 0 && options.serviceAccountToken !== void 0) {
|
|
784
|
+
throw new Error(
|
|
785
|
+
"account and serviceAccountToken are mutually exclusive \u2014 provide one or the other, not both"
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
this.vaultId = options.vault;
|
|
789
|
+
this.sessionTimeoutMs = options.sessionTimeoutMs ?? SESSION_TIMEOUT_MS;
|
|
790
|
+
if (options.account !== void 0) {
|
|
791
|
+
this.account = options.account;
|
|
792
|
+
}
|
|
793
|
+
if (options.serviceAccountToken !== void 0) {
|
|
794
|
+
this.serviceAccountToken = options.serviceAccountToken;
|
|
795
|
+
}
|
|
796
|
+
this.accessMode = options.accessMode ?? "session";
|
|
797
|
+
}
|
|
798
|
+
async isAvailable() {
|
|
799
|
+
const sdk = await this.tryLoadSdk();
|
|
800
|
+
return sdk !== null;
|
|
801
|
+
}
|
|
802
|
+
// ---- Session client management ----
|
|
803
|
+
/**
|
|
804
|
+
* Dynamically import the SDK. Returns `null` if the SDK is not installed or
|
|
805
|
+
* the native library cannot be loaded.
|
|
806
|
+
*/
|
|
807
|
+
async tryLoadSdk() {
|
|
808
|
+
try {
|
|
809
|
+
const sdk = await import('@1password/sdk');
|
|
810
|
+
return sdk;
|
|
811
|
+
} catch {
|
|
812
|
+
return null;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Acquire (or create) a cached SDK client.
|
|
817
|
+
* Wraps `createClient` with a configurable timeout (default 30 s) to handle
|
|
818
|
+
* the known beta SDK hang after session expiry.
|
|
819
|
+
*/
|
|
820
|
+
acquireClient() {
|
|
821
|
+
this.clientPromise ??= this.createClientInternal().catch((err) => {
|
|
822
|
+
this.clientPromise = void 0;
|
|
823
|
+
throw err;
|
|
824
|
+
});
|
|
825
|
+
return this.clientPromise;
|
|
826
|
+
}
|
|
827
|
+
async createClientInternal() {
|
|
828
|
+
const sdk = await this.tryLoadSdk();
|
|
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
|
+
}
|
|
836
|
+
const auth = this.buildAuth(sdk);
|
|
837
|
+
let timerId;
|
|
838
|
+
const timeoutPromise = new Promise((_resolve, reject) => {
|
|
839
|
+
timerId = setTimeout(() => {
|
|
840
|
+
reject(new BackendLockedError("1Password session timed out waiting for authentication", true));
|
|
841
|
+
}, this.sessionTimeoutMs);
|
|
842
|
+
});
|
|
843
|
+
try {
|
|
844
|
+
const client = await Promise.race([
|
|
845
|
+
sdk.createClient({
|
|
846
|
+
auth,
|
|
847
|
+
integrationName: INTEGRATION_NAME,
|
|
848
|
+
integrationVersion: getIntegrationVersion()
|
|
849
|
+
}),
|
|
850
|
+
timeoutPromise
|
|
851
|
+
]);
|
|
852
|
+
return client;
|
|
853
|
+
} catch (err) {
|
|
854
|
+
if (err instanceof BackendLockedError) {
|
|
855
|
+
throw err;
|
|
856
|
+
}
|
|
857
|
+
if (err instanceof sdk.DesktopSessionExpiredError) {
|
|
858
|
+
throw new BackendLockedError(
|
|
859
|
+
"1Password session has expired. Please unlock the app.",
|
|
860
|
+
true
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
throw new AuthorizationDeniedError(
|
|
864
|
+
`1Password authentication failed: ${String(err)}`
|
|
865
|
+
);
|
|
866
|
+
} finally {
|
|
867
|
+
if (timerId !== void 0) {
|
|
868
|
+
clearTimeout(timerId);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
buildAuth(sdk) {
|
|
873
|
+
if (this.serviceAccountToken !== void 0) {
|
|
874
|
+
return this.serviceAccountToken;
|
|
875
|
+
}
|
|
876
|
+
const accountName = this.account ?? "";
|
|
877
|
+
return new sdk.DesktopAuth(accountName);
|
|
878
|
+
}
|
|
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
|
+
// ---- SecretBackend / ListableBackend implementation ----
|
|
915
|
+
async store(id, secret) {
|
|
916
|
+
const { ItemCategory, ItemFieldType } = await this.requireSdk();
|
|
917
|
+
const client = await this.acquireClient();
|
|
918
|
+
const existing = await this.findItem(client, id);
|
|
919
|
+
if (existing !== void 0) {
|
|
920
|
+
const hasPasswordField = existing.fields.some((f) => f.title === PASSWORD_FIELD_TITLE);
|
|
921
|
+
const updatedFields = hasPasswordField ? existing.fields.map((f) => {
|
|
922
|
+
if (f.title === PASSWORD_FIELD_TITLE) {
|
|
923
|
+
return { ...f, value: secret };
|
|
924
|
+
}
|
|
925
|
+
return f;
|
|
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
|
+
}
|
|
952
|
+
}
|
|
953
|
+
async retrieve(id) {
|
|
954
|
+
if (this.accessMode === "per-access") {
|
|
955
|
+
return this.retrieveViaWorker(id);
|
|
956
|
+
}
|
|
957
|
+
return this.retrieveViaSession(id);
|
|
958
|
+
}
|
|
959
|
+
async retrieveViaSession(id) {
|
|
960
|
+
const client = await this.acquireClient();
|
|
961
|
+
const item = await this.findItem(client, id);
|
|
962
|
+
if (item === void 0) {
|
|
963
|
+
throw new SecretNotFoundError(`Secret not found in 1Password: ${id}`);
|
|
964
|
+
}
|
|
965
|
+
return this.extractSecret(item, id);
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Spawn the per-access worker script that triggers a fresh biometric prompt
|
|
969
|
+
* for each retrieval, then returns the secret from its stdout.
|
|
970
|
+
*/
|
|
971
|
+
retrieveViaWorker(id) {
|
|
972
|
+
return new Promise((resolve2, reject) => {
|
|
973
|
+
const workerPath = join(
|
|
974
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
975
|
+
"one-password-worker.js"
|
|
976
|
+
);
|
|
977
|
+
const accountArg = this.account ?? "";
|
|
978
|
+
const child = spawn(
|
|
979
|
+
process.execPath,
|
|
980
|
+
[workerPath, accountArg, this.vaultId, id],
|
|
981
|
+
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
982
|
+
);
|
|
983
|
+
const stdoutChunks = [];
|
|
984
|
+
const stderrChunks = [];
|
|
985
|
+
child.stdout.on("data", (chunk) => {
|
|
986
|
+
stdoutChunks.push(chunk);
|
|
987
|
+
});
|
|
988
|
+
child.stderr.on("data", (chunk) => {
|
|
989
|
+
stderrChunks.push(chunk);
|
|
990
|
+
});
|
|
991
|
+
child.on("close", (code) => {
|
|
992
|
+
const raw = Buffer.concat(stdoutChunks).toString("utf8").trim();
|
|
993
|
+
if (raw === "") {
|
|
994
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf8").trim();
|
|
995
|
+
const detail = stderr !== "" ? stderr : `exit code ${String(code)}`;
|
|
996
|
+
reject(new Error(`1Password per-access worker crashed for secret ${id}: ${detail}`));
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
let parsed;
|
|
1000
|
+
try {
|
|
1001
|
+
parsed = JSON.parse(raw);
|
|
1002
|
+
} catch {
|
|
1003
|
+
reject(new SecretNotFoundError(`Worker returned unparseable output for secret: ${id}`));
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
if (!isWorkerResponse(parsed)) {
|
|
1007
|
+
reject(new SecretNotFoundError(`Worker returned unexpected response shape for secret: ${id}`));
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (isWorkerSuccess(parsed)) {
|
|
1011
|
+
resolve2(parsed.value);
|
|
1012
|
+
} else {
|
|
1013
|
+
switch (parsed.code) {
|
|
1014
|
+
case "NOT_FOUND":
|
|
1015
|
+
reject(new SecretNotFoundError(`Secret not found in 1Password: ${id}`));
|
|
1016
|
+
break;
|
|
1017
|
+
case "AUTH_DENIED":
|
|
1018
|
+
reject(new AuthorizationDeniedError("1Password authentication was denied"));
|
|
1019
|
+
break;
|
|
1020
|
+
case "LOCKED":
|
|
1021
|
+
reject(new BackendLockedError("1Password is locked. Please unlock and retry.", true));
|
|
1022
|
+
break;
|
|
1023
|
+
default:
|
|
1024
|
+
reject(new SecretNotFoundError(`Worker failed for secret ${id}: ${parsed.error}`));
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
child.on("error", (err) => {
|
|
1029
|
+
reject(new Error(
|
|
1030
|
+
`Failed to spawn 1Password per-access worker at ${workerPath}: ${String(err)}`
|
|
1031
|
+
));
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
async delete(id) {
|
|
1036
|
+
const client = await this.acquireClient();
|
|
1037
|
+
const overview = await this.findItemOverview(client, id);
|
|
1038
|
+
if (overview === void 0) {
|
|
1039
|
+
throw new SecretNotFoundError(`Secret not found in 1Password: ${id}`);
|
|
1040
|
+
}
|
|
1041
|
+
await client.items.delete(this.vaultId, overview.id);
|
|
1042
|
+
}
|
|
1043
|
+
async exists(id) {
|
|
1044
|
+
const client = await this.acquireClient();
|
|
1045
|
+
const overview = await this.findItemOverview(client, id);
|
|
1046
|
+
return overview !== void 0;
|
|
1047
|
+
}
|
|
1048
|
+
async list() {
|
|
1049
|
+
const client = await this.acquireClient();
|
|
1050
|
+
const overviews = await client.items.list(this.vaultId);
|
|
1051
|
+
const ids = [];
|
|
1052
|
+
for (const overview of overviews) {
|
|
1053
|
+
if (overview.tags.includes(TAG)) {
|
|
1054
|
+
ids.push(overview.title);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
return ids;
|
|
1058
|
+
}
|
|
1059
|
+
// ---- Private helpers ----
|
|
1060
|
+
/** Load SDK and throw PluginNotFoundError if unavailable. */
|
|
1061
|
+
async requireSdk() {
|
|
1062
|
+
const sdk = await this.tryLoadSdk();
|
|
1063
|
+
if (sdk === null) {
|
|
1064
|
+
throw new PluginNotFoundError(
|
|
1065
|
+
"1Password SDK (@1password/sdk) is not available.",
|
|
1066
|
+
"@1password/sdk",
|
|
1067
|
+
SDK_INSTALL_URL
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
return sdk;
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
var YKMAN_INSTALL_URL = "https://developers.yubico.com/yubikey-manager/";
|
|
1074
|
+
var STORAGE_DIR_NAME2 = path3.join(".vaultkeeper", "yubikey");
|
|
1075
|
+
var METADATA_FILE = "metadata.json";
|
|
1076
|
+
var DEVICE_TIMEOUT_MS = 5e3;
|
|
1077
|
+
var GCM_IV_BYTES2 = 12;
|
|
1078
|
+
var GCM_KEY_BYTES2 = 32;
|
|
1079
|
+
var GCM_TAG_LENGTH_BITS = 128;
|
|
1080
|
+
var FORMAT_VERSION = "1";
|
|
1081
|
+
function getStorageDir2() {
|
|
1082
|
+
return path3.join(os4.homedir(), STORAGE_DIR_NAME2);
|
|
1083
|
+
}
|
|
1084
|
+
function getEntryPath3(storageDir, id) {
|
|
1085
|
+
const safeId = Buffer.from(id, "utf8").toString("hex");
|
|
1086
|
+
return path3.join(storageDir, `${safeId}.enc`);
|
|
1087
|
+
}
|
|
1088
|
+
function isStringRecord(value) {
|
|
1089
|
+
if (value === null || typeof value !== "object") {
|
|
1090
|
+
return false;
|
|
1091
|
+
}
|
|
1092
|
+
return Object.values(value).every((v) => typeof v === "string");
|
|
1093
|
+
}
|
|
1094
|
+
async function loadMetadata(storageDir) {
|
|
1095
|
+
const metaPath = path3.join(storageDir, METADATA_FILE);
|
|
1096
|
+
try {
|
|
1097
|
+
const raw = await fs.readFile(metaPath, "utf8");
|
|
1098
|
+
const parsed = JSON.parse(raw);
|
|
1099
|
+
if (parsed !== null && typeof parsed === "object" && "entries" in parsed && isStringRecord(parsed.entries)) {
|
|
1100
|
+
return { entries: parsed.entries };
|
|
1101
|
+
}
|
|
1102
|
+
return { entries: {} };
|
|
1103
|
+
} catch {
|
|
1104
|
+
return { entries: {} };
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
async function saveMetadata(storageDir, metadata) {
|
|
1108
|
+
const metaPath = path3.join(storageDir, METADATA_FILE);
|
|
1109
|
+
await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2), { mode: 384 });
|
|
1110
|
+
}
|
|
1111
|
+
var HMAC_RESPONSE_HEX_LENGTH = 40;
|
|
1112
|
+
var HMAC_RESPONSE_RE = /^[0-9a-fA-F]{40}$/;
|
|
1113
|
+
function deriveKey(hmacResponse, id) {
|
|
1114
|
+
const trimmed = hmacResponse.trim();
|
|
1115
|
+
if (!HMAC_RESPONSE_RE.test(trimmed)) {
|
|
1116
|
+
throw new Error(
|
|
1117
|
+
`Invalid YubiKey HMAC response: expected exactly ${String(HMAC_RESPONSE_HEX_LENGTH)} hex characters (20 bytes), got ${String(trimmed.length)} characters`
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
const ikm = Buffer.from(trimmed, "hex");
|
|
1121
|
+
const info = Buffer.from(`vaultkeeper-yubikey:${id}`, "utf8");
|
|
1122
|
+
const keyMaterial = crypto.hkdfSync("sha256", ikm, Buffer.alloc(0), info, GCM_KEY_BYTES2);
|
|
1123
|
+
return Buffer.from(keyMaterial);
|
|
1124
|
+
}
|
|
1125
|
+
function encryptGcm2(key, plaintext) {
|
|
1126
|
+
const iv = crypto.randomBytes(GCM_IV_BYTES2);
|
|
1127
|
+
let encrypted;
|
|
1128
|
+
let authTag;
|
|
1129
|
+
try {
|
|
1130
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, {
|
|
1131
|
+
authTagLength: GCM_TAG_LENGTH_BITS / 8
|
|
1132
|
+
});
|
|
1133
|
+
encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
1134
|
+
authTag = cipher.getAuthTag();
|
|
1135
|
+
return [
|
|
1136
|
+
FORMAT_VERSION,
|
|
1137
|
+
iv.toString("base64"),
|
|
1138
|
+
authTag.toString("base64"),
|
|
1139
|
+
encrypted.toString("base64")
|
|
1140
|
+
].join(":");
|
|
1141
|
+
} finally {
|
|
1142
|
+
key.fill(0);
|
|
1143
|
+
iv.fill(0);
|
|
1144
|
+
encrypted?.fill(0);
|
|
1145
|
+
authTag?.fill(0);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
function decryptGcm2(key, encoded) {
|
|
1149
|
+
const parts = encoded.split(":");
|
|
1150
|
+
const versionSegment = parts[0] ?? "";
|
|
1151
|
+
const parsedVersion = parseInt(versionSegment, 10);
|
|
1152
|
+
const isNumericVersion = String(parsedVersion) === versionSegment && !Number.isNaN(parsedVersion);
|
|
1153
|
+
if (!isNumericVersion) {
|
|
1154
|
+
key.fill(0);
|
|
1155
|
+
throw new Error(
|
|
1156
|
+
"Encrypted file uses a legacy format (AES-256-CBC). Delete the secret and re-store it to migrate to AES-256-GCM."
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
if (versionSegment !== FORMAT_VERSION) {
|
|
1160
|
+
key.fill(0);
|
|
1161
|
+
throw new Error(
|
|
1162
|
+
`Unsupported encrypted file version: ${versionSegment}. This vaultkeeper build only supports version ${FORMAT_VERSION}. Upgrade vaultkeeper to read this secret.`
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
if (parts.length !== 4) {
|
|
1166
|
+
key.fill(0);
|
|
1167
|
+
throw new Error(
|
|
1168
|
+
`Invalid encrypted file format: expected ${FORMAT_VERSION}:iv:authTag:ciphertext`
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
const [_version, ivB64, authTagB64, ciphertextB64] = parts;
|
|
1172
|
+
if (ivB64 === void 0 || authTagB64 === void 0 || ciphertextB64 === void 0) {
|
|
1173
|
+
key.fill(0);
|
|
1174
|
+
throw new Error("Invalid encrypted file format: missing part");
|
|
1175
|
+
}
|
|
1176
|
+
let decrypted;
|
|
1177
|
+
try {
|
|
1178
|
+
const iv = Buffer.from(ivB64, "base64");
|
|
1179
|
+
const authTag = Buffer.from(authTagB64, "base64");
|
|
1180
|
+
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
|
+
try {
|
|
1186
|
+
decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
throw new Error(
|
|
1189
|
+
`GCM authentication failed \u2014 ciphertext may be tampered: ${err instanceof Error ? err.message : String(err)}`
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
const plaintext = decrypted.toString("utf8");
|
|
1193
|
+
return plaintext;
|
|
1194
|
+
} finally {
|
|
1195
|
+
key.fill(0);
|
|
1196
|
+
decrypted?.fill(0);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
var YubikeyBackend = class {
|
|
1200
|
+
type = "yubikey";
|
|
1201
|
+
displayName = "YubiKey";
|
|
1202
|
+
async isAvailable() {
|
|
1203
|
+
try {
|
|
1204
|
+
const result = await execCommandFull("ykman", ["--version"]);
|
|
1205
|
+
if (result.exitCode !== 0) {
|
|
1206
|
+
return false;
|
|
1207
|
+
}
|
|
1208
|
+
const listResult = await execCommandFull("ykman", ["list"]);
|
|
1209
|
+
return listResult.exitCode === 0 && listResult.stdout.trim() !== "";
|
|
1210
|
+
} catch {
|
|
1211
|
+
return false;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
async requireDevice() {
|
|
1215
|
+
const available = await this.isAvailable();
|
|
1216
|
+
if (!available) {
|
|
1217
|
+
const hasYkman = await execCommandFull("ykman", ["--version"]).then(
|
|
1218
|
+
(r) => r.exitCode === 0,
|
|
1219
|
+
() => false
|
|
1220
|
+
);
|
|
1221
|
+
if (!hasYkman) {
|
|
1222
|
+
throw new PluginNotFoundError("ykman is not installed", "ykman", YKMAN_INSTALL_URL);
|
|
1223
|
+
}
|
|
1224
|
+
throw new DeviceNotPresentError("No YubiKey device detected", DEVICE_TIMEOUT_MS);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Perform the YubiKey HMAC-SHA1 challenge-response for `id` and return the
|
|
1229
|
+
* raw hex response string. Throws on device failure.
|
|
1230
|
+
*/
|
|
1231
|
+
async challengeResponse(id) {
|
|
1232
|
+
const challenge = Buffer.from(`vaultkeeper:${id}`, "utf8").toString("hex");
|
|
1233
|
+
const responseResult = await execCommandFull("ykman", ["otp", "calculate", "2", challenge]);
|
|
1234
|
+
if (responseResult.exitCode !== 0) {
|
|
1235
|
+
throw new Error(`YubiKey challenge-response failed: ${responseResult.stderr}`);
|
|
1236
|
+
}
|
|
1237
|
+
return responseResult.stdout.trim();
|
|
1238
|
+
}
|
|
1239
|
+
async store(id, secret) {
|
|
1240
|
+
await this.requireDevice();
|
|
1241
|
+
const storageDir = getStorageDir2();
|
|
1242
|
+
await fs.mkdir(storageDir, { recursive: true, mode: 448 });
|
|
1243
|
+
const hmacResponse = await this.challengeResponse(id);
|
|
1244
|
+
const key = deriveKey(hmacResponse, id);
|
|
1245
|
+
const encrypted = encryptGcm2(key, secret);
|
|
1246
|
+
const entryPath = getEntryPath3(storageDir, id);
|
|
1247
|
+
await fs.writeFile(entryPath, encrypted, { mode: 384 });
|
|
1248
|
+
const metadata = await loadMetadata(storageDir);
|
|
1249
|
+
metadata.entries[id] = entryPath;
|
|
1250
|
+
await saveMetadata(storageDir, metadata);
|
|
1251
|
+
}
|
|
1252
|
+
async retrieve(id) {
|
|
1253
|
+
await this.requireDevice();
|
|
1254
|
+
const storageDir = getStorageDir2();
|
|
1255
|
+
const entryPath = getEntryPath3(storageDir, id);
|
|
1256
|
+
try {
|
|
1257
|
+
await fs.access(entryPath);
|
|
1258
|
+
} catch {
|
|
1259
|
+
throw new SecretNotFoundError(`Secret not found in YubiKey store: ${id}`);
|
|
1260
|
+
}
|
|
1261
|
+
const encoded = await fs.readFile(entryPath, "utf8");
|
|
1262
|
+
const hmacResponse = await this.challengeResponse(id);
|
|
1263
|
+
const key = deriveKey(hmacResponse, id);
|
|
1264
|
+
return decryptGcm2(key, encoded);
|
|
1265
|
+
}
|
|
1266
|
+
async delete(id) {
|
|
1267
|
+
await this.requireDevice();
|
|
1268
|
+
const storageDir = getStorageDir2();
|
|
1269
|
+
const entryPath = getEntryPath3(storageDir, id);
|
|
1270
|
+
try {
|
|
1271
|
+
await fs.unlink(entryPath);
|
|
1272
|
+
} catch (err) {
|
|
1273
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
1274
|
+
throw new SecretNotFoundError(`Secret not found in YubiKey store: ${id}`);
|
|
1275
|
+
}
|
|
1276
|
+
throw err;
|
|
1277
|
+
}
|
|
1278
|
+
const metadata = await loadMetadata(storageDir);
|
|
1279
|
+
delete metadata.entries[id];
|
|
1280
|
+
await saveMetadata(storageDir, metadata);
|
|
1281
|
+
}
|
|
1282
|
+
async exists(id) {
|
|
1283
|
+
const storageDir = getStorageDir2();
|
|
1284
|
+
const entryPath = getEntryPath3(storageDir, id);
|
|
1285
|
+
try {
|
|
1286
|
+
await fs.access(entryPath);
|
|
1287
|
+
return true;
|
|
1288
|
+
} catch {
|
|
1289
|
+
return false;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
async list() {
|
|
1293
|
+
const storageDir = getStorageDir2();
|
|
1294
|
+
const metadata = await loadMetadata(storageDir);
|
|
1295
|
+
return Object.keys(metadata.entries);
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
|
|
1299
|
+
// src/backend/register-builtins.ts
|
|
1300
|
+
function registerBuiltinBackends() {
|
|
1301
|
+
BackendRegistry.register("file", () => new FileBackend());
|
|
1302
|
+
BackendRegistry.register("keychain", () => new KeychainBackend());
|
|
1303
|
+
BackendRegistry.register("dpapi", () => new DpapiBackend());
|
|
1304
|
+
BackendRegistry.register("secret-tool", () => new SecretToolBackend());
|
|
1305
|
+
BackendRegistry.register("1password", (config) => {
|
|
1306
|
+
const opts = config?.options;
|
|
1307
|
+
const vaultId = opts?.vault ?? "";
|
|
1308
|
+
const opOptions = {
|
|
1309
|
+
vault: vaultId,
|
|
1310
|
+
// 'session' is the safer default — 'per-access' re-prompts on every read.
|
|
1311
|
+
accessMode: opts?.accessMode === "per-access" ? "per-access" : "session"
|
|
1312
|
+
};
|
|
1313
|
+
const account = opts?.account;
|
|
1314
|
+
if (account !== void 0) {
|
|
1315
|
+
opOptions.account = account;
|
|
1316
|
+
}
|
|
1317
|
+
const serviceAccountToken = opts?.serviceAccountToken;
|
|
1318
|
+
if (serviceAccountToken !== void 0) {
|
|
1319
|
+
opOptions.serviceAccountToken = serviceAccountToken;
|
|
1320
|
+
}
|
|
1321
|
+
return new OnePasswordBackend(opOptions);
|
|
1322
|
+
});
|
|
1323
|
+
BackendRegistry.register("yubikey", () => new YubikeyBackend());
|
|
1324
|
+
}
|
|
1325
|
+
registerBuiltinBackends();
|
|
1326
|
+
|
|
1327
|
+
// src/backend/types.ts
|
|
1328
|
+
function isListableBackend(backend) {
|
|
1329
|
+
return "list" in backend && typeof backend.list === "function";
|
|
1330
|
+
}
|
|
330
1331
|
function hashExecutable(filePath) {
|
|
331
|
-
return new Promise((
|
|
332
|
-
const hash =
|
|
1332
|
+
return new Promise((resolve2, reject) => {
|
|
1333
|
+
const hash = crypto.createHash("sha256");
|
|
333
1334
|
const stream = fs4.createReadStream(filePath);
|
|
334
1335
|
stream.on("data", (chunk) => {
|
|
335
1336
|
hash.update(chunk);
|
|
336
1337
|
});
|
|
337
1338
|
stream.on("end", () => {
|
|
338
|
-
|
|
1339
|
+
resolve2(hash.digest("hex"));
|
|
339
1340
|
});
|
|
340
1341
|
stream.on("error", (err) => {
|
|
341
1342
|
reject(err);
|
|
@@ -358,10 +1359,10 @@ function isTrustManifestEntry(value) {
|
|
|
358
1359
|
return true;
|
|
359
1360
|
}
|
|
360
1361
|
async function loadManifest(configDir) {
|
|
361
|
-
const manifestPath =
|
|
1362
|
+
const manifestPath = path3.join(configDir, MANIFEST_FILENAME);
|
|
362
1363
|
let rawText;
|
|
363
1364
|
try {
|
|
364
|
-
rawText = await
|
|
1365
|
+
rawText = await fs.readFile(manifestPath, "utf8");
|
|
365
1366
|
} catch (err) {
|
|
366
1367
|
if (typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT") {
|
|
367
1368
|
return /* @__PURE__ */ new Map();
|
|
@@ -381,14 +1382,14 @@ async function loadManifest(configDir) {
|
|
|
381
1382
|
return manifest;
|
|
382
1383
|
}
|
|
383
1384
|
async function saveManifest(configDir, manifest) {
|
|
384
|
-
await
|
|
1385
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
385
1386
|
const entries = {};
|
|
386
1387
|
for (const [namespace, entry] of manifest) {
|
|
387
1388
|
entries[namespace] = entry;
|
|
388
1389
|
}
|
|
389
1390
|
const raw = { version: 1, entries };
|
|
390
|
-
const manifestPath =
|
|
391
|
-
await
|
|
1391
|
+
const manifestPath = path3.join(configDir, MANIFEST_FILENAME);
|
|
1392
|
+
await fs.writeFile(manifestPath, JSON.stringify(raw, null, 2), "utf8");
|
|
392
1393
|
}
|
|
393
1394
|
function addTrustedHash(manifest, namespace, hash) {
|
|
394
1395
|
const next = new Map(manifest);
|
|
@@ -499,14 +1500,18 @@ function validateCapabilityToken(token) {
|
|
|
499
1500
|
return claims;
|
|
500
1501
|
}
|
|
501
1502
|
function getDefaultConfigDir() {
|
|
1503
|
+
const envOverride = process.env.VAULTKEEPER_CONFIG_DIR;
|
|
1504
|
+
if (envOverride !== void 0 && envOverride !== "") {
|
|
1505
|
+
return envOverride;
|
|
1506
|
+
}
|
|
502
1507
|
if (process.platform === "win32") {
|
|
503
1508
|
const appData = process.env.APPDATA;
|
|
504
1509
|
if (appData !== void 0) {
|
|
505
|
-
return
|
|
1510
|
+
return path3.join(appData, "vaultkeeper");
|
|
506
1511
|
}
|
|
507
|
-
return
|
|
1512
|
+
return path3.join(os4.homedir(), "AppData", "Roaming", "vaultkeeper");
|
|
508
1513
|
}
|
|
509
|
-
return
|
|
1514
|
+
return path3.join(os4.homedir(), ".config", "vaultkeeper");
|
|
510
1515
|
}
|
|
511
1516
|
function defaultConfig() {
|
|
512
1517
|
return {
|
|
@@ -622,10 +1627,10 @@ function validateConfig(config) {
|
|
|
622
1627
|
}
|
|
623
1628
|
async function loadConfig(configDir) {
|
|
624
1629
|
const dir = configDir ?? getDefaultConfigDir();
|
|
625
|
-
const configPath =
|
|
1630
|
+
const configPath = path3.join(dir, "config.json");
|
|
626
1631
|
let raw;
|
|
627
1632
|
try {
|
|
628
|
-
raw = await
|
|
1633
|
+
raw = await fs.readFile(configPath, "utf-8");
|
|
629
1634
|
} catch {
|
|
630
1635
|
return defaultConfig();
|
|
631
1636
|
}
|
|
@@ -644,10 +1649,10 @@ var KeyManager = class {
|
|
|
644
1649
|
#rotating = false;
|
|
645
1650
|
/** Generate a new 32-byte key with a timestamp-based id. */
|
|
646
1651
|
generateKey() {
|
|
647
|
-
const randomSuffix =
|
|
1652
|
+
const randomSuffix = crypto.randomBytes(4).toString("hex");
|
|
648
1653
|
return {
|
|
649
1654
|
id: `k-${String(Date.now())}-${randomSuffix}`,
|
|
650
|
-
key: new Uint8Array(
|
|
1655
|
+
key: new Uint8Array(crypto.randomBytes(32)),
|
|
651
1656
|
createdAt: /* @__PURE__ */ new Date()
|
|
652
1657
|
};
|
|
653
1658
|
}
|
|
@@ -949,7 +1954,7 @@ function replaceInRecord2(record, secret) {
|
|
|
949
1954
|
function delegatedExec(secret, request) {
|
|
950
1955
|
const args = (request.args ?? []).map((arg) => replacePlaceholder2(arg, secret));
|
|
951
1956
|
const env = request.env !== void 0 ? replaceInRecord2(request.env, secret) : void 0;
|
|
952
|
-
return new Promise((
|
|
1957
|
+
return new Promise((resolve2, reject) => {
|
|
953
1958
|
const spawnOptions = {
|
|
954
1959
|
stdio: ["ignore", "pipe", "pipe"]
|
|
955
1960
|
};
|
|
@@ -969,7 +1974,7 @@ function delegatedExec(secret, request) {
|
|
|
969
1974
|
stderr += data.toString();
|
|
970
1975
|
});
|
|
971
1976
|
proc.on("close", (code) => {
|
|
972
|
-
|
|
1977
|
+
resolve2({ stdout, stderr, exitCode: code ?? 1 });
|
|
973
1978
|
});
|
|
974
1979
|
proc.on("error", (error) => {
|
|
975
1980
|
reject(error);
|
|
@@ -1083,10 +2088,10 @@ function resolveAlgorithmForKey(key, override) {
|
|
|
1083
2088
|
|
|
1084
2089
|
// src/access/delegated-sign.ts
|
|
1085
2090
|
function delegatedSign(secretPem, request) {
|
|
1086
|
-
const key =
|
|
2091
|
+
const key = crypto.createPrivateKey(secretPem);
|
|
1087
2092
|
const { signAlg, label } = resolveAlgorithmForKey(key, request.algorithm);
|
|
1088
2093
|
const data = Buffer.isBuffer(request.data) ? request.data : Buffer.from(request.data);
|
|
1089
|
-
const signature =
|
|
2094
|
+
const signature = crypto.sign(signAlg, data, key);
|
|
1090
2095
|
return {
|
|
1091
2096
|
signature: signature.toString("base64"),
|
|
1092
2097
|
algorithm: label
|
|
@@ -1095,7 +2100,7 @@ function delegatedSign(secretPem, request) {
|
|
|
1095
2100
|
function delegatedVerify(request) {
|
|
1096
2101
|
let key;
|
|
1097
2102
|
try {
|
|
1098
|
-
key =
|
|
2103
|
+
key = crypto.createPublicKey(request.publicKey);
|
|
1099
2104
|
} catch {
|
|
1100
2105
|
return false;
|
|
1101
2106
|
}
|
|
@@ -1106,7 +2111,7 @@ function delegatedVerify(request) {
|
|
|
1106
2111
|
const sig = Buffer.from(request.signature, "base64");
|
|
1107
2112
|
try {
|
|
1108
2113
|
const data = Buffer.isBuffer(request.data) ? request.data : Buffer.from(request.data);
|
|
1109
|
-
return
|
|
2114
|
+
return crypto.verify(signAlg, data, key, sig);
|
|
1110
2115
|
} catch {
|
|
1111
2116
|
return false;
|
|
1112
2117
|
}
|
|
@@ -1364,7 +2369,7 @@ var VaultKeeper = class _VaultKeeper {
|
|
|
1364
2369
|
}
|
|
1365
2370
|
const now = Math.floor(Date.now() / 1e3);
|
|
1366
2371
|
const claims = {
|
|
1367
|
-
jti:
|
|
2372
|
+
jti: crypto.randomUUID(),
|
|
1368
2373
|
exp: now + ttlMinutes * 60,
|
|
1369
2374
|
iat: now,
|
|
1370
2375
|
sub: secretName,
|
|
@@ -1590,7 +2595,7 @@ var VaultKeeper = class _VaultKeeper {
|
|
|
1590
2595
|
[]
|
|
1591
2596
|
);
|
|
1592
2597
|
}
|
|
1593
|
-
return BackendRegistry.create(firstEnabled.type);
|
|
2598
|
+
return BackendRegistry.create(firstEnabled.type, firstEnabled);
|
|
1594
2599
|
}
|
|
1595
2600
|
#requireBackend() {
|
|
1596
2601
|
if (this.#backend === void 0) {
|