@ouro.bot/cli 0.1.0-alpha.556 → 0.1.0-alpha.558
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/changelog.json
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.558",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Rejected local vault unlock material is now cleared after Bitwarden proves it is wrong, so a stale Keychain/DPAPI/Secret Service/plaintext entry cannot keep retrying across fresh CLI processes and rate-limit the agent vault.",
|
|
8
|
+
"Legacy vault unlock entries are no longer copied to canonical coordinates during read. Ouro now canonicalizes them only after a successful vault login, preventing unvalidated local material from poisoning the primary unlock slot.",
|
|
9
|
+
"`@ouro.bot/cli` and the `ouro.bot` wrapper are version-synced for the invalid local unlock quarantine release."
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"version": "0.1.0-alpha.557",
|
|
14
|
+
"changes": [
|
|
15
|
+
"`ouro vault unlock` now validates the typed unlock secret against the agent vault before replacing this machine's saved local unlock material, so a failed unlock attempt cannot poison a previously working Keychain/DPAPI/Secret Service/plaintext entry.",
|
|
16
|
+
"The Bitwarden adapter now rebuilds stale per-agent local `bw` profiles when they point at the wrong vault account/server or reject a saved unlock during unlock, reducing restart-time false locked-vault failures while keeping wrong saved secrets explicit.",
|
|
17
|
+
"`@ouro.bot/cli` and the `ouro.bot` wrapper are version-synced for the vault unlock hardening release."
|
|
18
|
+
]
|
|
19
|
+
},
|
|
4
20
|
{
|
|
5
21
|
"version": "0.1.0-alpha.556",
|
|
6
22
|
"changes": [
|
|
@@ -1453,15 +1453,13 @@ async function executeVaultUnlock(command, deps) {
|
|
|
1453
1453
|
const progress = createHumanCommandProgress(deps, "vault unlock");
|
|
1454
1454
|
let store;
|
|
1455
1455
|
try {
|
|
1456
|
+
await runCommandProgressPhase(progress, "checking vault access", () => (0, credential_access_1.probeCredentialVaultAccess)(command.agent, unlockSecret, { homeDir: deps.homeDir }), () => "ok");
|
|
1456
1457
|
store = await runCommandProgressPhase(progress, "saving local unlock", () => (0, vault_unlock_1.storeVaultUnlockSecret)({
|
|
1457
1458
|
agentName: command.agent,
|
|
1458
1459
|
email: vault.email,
|
|
1459
1460
|
serverUrl: vault.serverUrl,
|
|
1460
1461
|
}, unlockSecret, { homeDir: deps.homeDir, store: command.store }), (saved) => saved.kind);
|
|
1461
|
-
|
|
1462
|
-
(0, credential_access_1.resetCredentialStore)();
|
|
1463
|
-
await (0, credential_access_1.getCredentialStore)(command.agent).get("__ouro_vault_probe__");
|
|
1464
|
-
}, () => "ok");
|
|
1462
|
+
(0, credential_access_1.resetCredentialStore)();
|
|
1465
1463
|
}
|
|
1466
1464
|
finally {
|
|
1467
1465
|
progress.end();
|
|
@@ -88,7 +88,9 @@ function isBwSessionUnavailableMessage(message) {
|
|
|
88
88
|
/local bitwarden session/i.test(message));
|
|
89
89
|
}
|
|
90
90
|
function isBwInvalidUnlockSecretMessage(message) {
|
|
91
|
-
return /invalid master password/i.test(message) ||
|
|
91
|
+
return (/invalid master password/i.test(message) ||
|
|
92
|
+
/saved vault unlock secret/i.test(message) ||
|
|
93
|
+
/username or password is incorrect/i.test(message));
|
|
92
94
|
}
|
|
93
95
|
function isBwTimeoutError(err) {
|
|
94
96
|
const timeoutErr = err;
|
|
@@ -135,6 +137,13 @@ function isBwConfigLogoutRequired(err) {
|
|
|
135
137
|
function isBwAlreadyLoggedInError(err) {
|
|
136
138
|
return err.message.toLowerCase().includes("already logged in");
|
|
137
139
|
}
|
|
140
|
+
function isBwLoggedOutOrUnauthenticatedError(err) {
|
|
141
|
+
const message = err.message.toLowerCase();
|
|
142
|
+
return (message.includes("not logged in") ||
|
|
143
|
+
message.includes("not authenticated") ||
|
|
144
|
+
message.includes("unauthenticated") ||
|
|
145
|
+
message.includes("local bitwarden session because it is locked, missing, or expired"));
|
|
146
|
+
}
|
|
138
147
|
function shouldUseStructuredItemLookup(domain) {
|
|
139
148
|
return domain.includes("/");
|
|
140
149
|
}
|
|
@@ -392,7 +401,10 @@ class BitwardenCredentialStore {
|
|
|
392
401
|
email;
|
|
393
402
|
masterPassword;
|
|
394
403
|
appDataDir;
|
|
404
|
+
onInvalidUnlockSecret;
|
|
405
|
+
onLoginSuccess;
|
|
395
406
|
sessionToken = null;
|
|
407
|
+
terminalLoginError = null;
|
|
396
408
|
bwBinaryPath = "bw";
|
|
397
409
|
structuredItemCache = null;
|
|
398
410
|
constructor(serverUrl, email, masterPassword, options = {}) {
|
|
@@ -400,6 +412,8 @@ class BitwardenCredentialStore {
|
|
|
400
412
|
this.email = email;
|
|
401
413
|
this.masterPassword = masterPassword;
|
|
402
414
|
this.appDataDir = options.appDataDir;
|
|
415
|
+
this.onInvalidUnlockSecret = options.onInvalidUnlockSecret;
|
|
416
|
+
this.onLoginSuccess = options.onLoginSuccess;
|
|
403
417
|
}
|
|
404
418
|
isReady() {
|
|
405
419
|
return true;
|
|
@@ -410,12 +424,36 @@ class BitwardenCredentialStore {
|
|
|
410
424
|
execBwWithPasswordEnv(args) {
|
|
411
425
|
return execBw([...args, "--passwordenv", BW_PASSWORD_ENV], undefined, this.appDataDir, undefined, this.bwBinaryPath, { [BW_PASSWORD_ENV]: this.masterPassword });
|
|
412
426
|
}
|
|
427
|
+
async notifyInvalidUnlockSecret(error) {
|
|
428
|
+
if (!this.onInvalidUnlockSecret)
|
|
429
|
+
return;
|
|
430
|
+
try {
|
|
431
|
+
await this.onInvalidUnlockSecret(error);
|
|
432
|
+
}
|
|
433
|
+
catch (callbackError) {
|
|
434
|
+
(0, runtime_1.emitNervesEvent)({
|
|
435
|
+
level: "warn",
|
|
436
|
+
event: "repertoire.bw_invalid_unlock_cleanup_failed",
|
|
437
|
+
component: "repertoire",
|
|
438
|
+
message: "failed to clean up rejected local vault unlock material",
|
|
439
|
+
meta: {
|
|
440
|
+
email: this.email,
|
|
441
|
+
serverUrl: this.serverUrl,
|
|
442
|
+
error: callbackError instanceof Error ? callbackError.message : String(callbackError),
|
|
443
|
+
originalError: error.message,
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
413
448
|
/**
|
|
414
449
|
* Ensure the bw CLI is authenticated and unlocked.
|
|
415
450
|
* Handles three states: logged out → login, locked → unlock, already unlocked → no-op.
|
|
416
451
|
* Retries transient failures (network/timeout) up to MAX_RETRIES with exponential backoff.
|
|
417
452
|
*/
|
|
418
453
|
async login() {
|
|
454
|
+
if (this.terminalLoginError) {
|
|
455
|
+
throw this.terminalLoginError;
|
|
456
|
+
}
|
|
419
457
|
// Ensure bw CLI is installed before any bw commands
|
|
420
458
|
this.bwBinaryPath = await (0, bw_installer_1.ensureBwCli)();
|
|
421
459
|
if (this.appDataDir) {
|
|
@@ -425,6 +463,7 @@ class BitwardenCredentialStore {
|
|
|
425
463
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
426
464
|
try {
|
|
427
465
|
await this.loginAttempt();
|
|
466
|
+
await this.onLoginSuccess?.();
|
|
428
467
|
return;
|
|
429
468
|
}
|
|
430
469
|
catch (err) {
|
|
@@ -432,6 +471,10 @@ class BitwardenCredentialStore {
|
|
|
432
471
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
433
472
|
// Don't retry non-transient errors (auth failures, bw not installed)
|
|
434
473
|
if (!isTransientError(lastError)) {
|
|
474
|
+
this.terminalLoginError = lastError;
|
|
475
|
+
if (isBwInvalidUnlockSecretMessage(lastError.message)) {
|
|
476
|
+
await this.notifyInvalidUnlockSecret(lastError);
|
|
477
|
+
}
|
|
435
478
|
throw lastError;
|
|
436
479
|
}
|
|
437
480
|
// Don't retry after final attempt
|
|
@@ -447,22 +490,19 @@ class BitwardenCredentialStore {
|
|
|
447
490
|
await delay(backoffMs);
|
|
448
491
|
}
|
|
449
492
|
}
|
|
493
|
+
this.terminalLoginError = lastError;
|
|
494
|
+
/* v8 ignore next -- invalid unlock errors are non-transient and exit through the non-retry path above @preserve */
|
|
495
|
+
if (isBwInvalidUnlockSecretMessage(lastError.message)) {
|
|
496
|
+
await this.notifyInvalidUnlockSecret(lastError);
|
|
497
|
+
}
|
|
450
498
|
throw lastError;
|
|
451
499
|
}
|
|
452
500
|
/** Single login attempt — called by login() retry loop. */
|
|
453
501
|
async loginAttempt() {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
status = JSON.parse(raw);
|
|
459
|
-
}
|
|
460
|
-
catch (err) {
|
|
461
|
-
// If bw CLI is not installed or a transient error, propagate it for retry
|
|
462
|
-
if (err instanceof Error && (isBwNotInstalled(err) || isTransientError(err))) {
|
|
463
|
-
throw err;
|
|
464
|
-
}
|
|
465
|
-
// CLI not configured or broken — proceed with full setup
|
|
502
|
+
let status = await this.readStatus();
|
|
503
|
+
if (this.shouldRebuildLocalProfile(status)) {
|
|
504
|
+
await this.rebuildLocalProfile("local bw profile did not match requested vault", status);
|
|
505
|
+
status = { status: "unlocked", serverUrl: this.serverUrl, userEmail: this.email };
|
|
466
506
|
}
|
|
467
507
|
// Configure server URL if needed (only works when logged out)
|
|
468
508
|
if (status.status === "unauthenticated" || !status.serverUrl) {
|
|
@@ -471,43 +511,28 @@ class BitwardenCredentialStore {
|
|
|
471
511
|
}
|
|
472
512
|
catch (error) {
|
|
473
513
|
const err = error;
|
|
474
|
-
|
|
475
|
-
if (!isBwConfigLogoutRequired(err)) {
|
|
514
|
+
if (!isBwConfigLogoutRequired(err))
|
|
476
515
|
throw err;
|
|
477
|
-
|
|
516
|
+
// "Logout required" means bw already has local auth state; keep the
|
|
517
|
+
// existing behavior and proceed to login/unlock below.
|
|
478
518
|
}
|
|
479
519
|
}
|
|
480
|
-
if (
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
else if (status.status === "unauthenticated" || !status.status) {
|
|
486
|
-
// Not logged in — full login
|
|
487
|
-
let loginOutput;
|
|
488
|
-
try {
|
|
489
|
-
loginOutput = await this.execBwWithPasswordEnv(["login", this.email, "--raw"]);
|
|
490
|
-
}
|
|
491
|
-
catch (error) {
|
|
492
|
-
const err = error;
|
|
493
|
-
if (!isBwAlreadyLoggedInError(err)) {
|
|
494
|
-
throw err;
|
|
495
|
-
}
|
|
496
|
-
loginOutput = await this.execBwWithPasswordEnv(["unlock", "--raw"]);
|
|
520
|
+
if (!this.sessionToken) {
|
|
521
|
+
if (status.status === "locked") {
|
|
522
|
+
// Already logged in, just needs unlock.
|
|
523
|
+
const unlockOutput = await this.execWithLocalProfileRebuild("unlock rejected saved local unlock material", status, () => this.execBwWithPasswordEnv(["unlock", "--raw"]));
|
|
524
|
+
this.sessionToken = unlockOutput.trim();
|
|
497
525
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
this.sessionToken =
|
|
526
|
+
else if (status.status === "unauthenticated" || !status.status) {
|
|
527
|
+
// Not logged in -- full login.
|
|
528
|
+
this.sessionToken = this.sessionTokenFromLoginOutput(await this.loginWithPassword());
|
|
501
529
|
}
|
|
502
|
-
|
|
503
|
-
|
|
530
|
+
else {
|
|
531
|
+
// Status is "unlocked" -- already good, just need the session token.
|
|
532
|
+
const unlockOutput = await this.execWithLocalProfileRebuild("unlocked bw profile rejected saved local unlock material", status, () => this.execBwWithPasswordEnv(["unlock", "--raw"]));
|
|
533
|
+
this.sessionToken = unlockOutput.trim();
|
|
504
534
|
}
|
|
505
535
|
}
|
|
506
|
-
else {
|
|
507
|
-
// Status is "unlocked" — already good, just need the session token
|
|
508
|
-
const unlockOutput = await this.execBwWithPasswordEnv(["unlock", "--raw"]);
|
|
509
|
-
this.sessionToken = unlockOutput.trim();
|
|
510
|
-
}
|
|
511
536
|
if (this.shouldSyncVaultAfterSession(status)) {
|
|
512
537
|
/* v8 ignore next -- defensive: loginAttempt always sets sessionToken before sync @preserve */
|
|
513
538
|
await this.execBw(["sync"], this.sessionToken ?? undefined);
|
|
@@ -521,6 +546,128 @@ class BitwardenCredentialStore {
|
|
|
521
546
|
meta: { email: this.email, serverUrl: this.serverUrl, freshnessWindowMs: BW_SYNC_FRESH_MS },
|
|
522
547
|
});
|
|
523
548
|
}
|
|
549
|
+
this.terminalLoginError = null;
|
|
550
|
+
}
|
|
551
|
+
async readStatus() {
|
|
552
|
+
try {
|
|
553
|
+
const raw = await this.execBw(["status"]);
|
|
554
|
+
const parsed = JSON.parse(raw);
|
|
555
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
556
|
+
return {};
|
|
557
|
+
const record = parsed;
|
|
558
|
+
return {
|
|
559
|
+
...(typeof record.status === "string" ? { status: record.status } : {}),
|
|
560
|
+
...(typeof record.serverUrl === "string" ? { serverUrl: record.serverUrl } : {}),
|
|
561
|
+
...(typeof record.userEmail === "string" ? { userEmail: record.userEmail } : {}),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
// If bw CLI is not installed or a transient error, propagate it for retry.
|
|
566
|
+
if (err instanceof Error && (isBwNotInstalled(err) || isTransientError(err))) {
|
|
567
|
+
throw err;
|
|
568
|
+
}
|
|
569
|
+
// CLI not configured or broken -- proceed with full setup.
|
|
570
|
+
return {};
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
normalizedServerUrl(value) {
|
|
574
|
+
return value.trim().replace(/\/+$/, "").toLowerCase();
|
|
575
|
+
}
|
|
576
|
+
shouldRebuildLocalProfile(status) {
|
|
577
|
+
if (status.status !== "locked" && status.status !== "unlocked")
|
|
578
|
+
return false;
|
|
579
|
+
if (status.serverUrl && this.normalizedServerUrl(status.serverUrl) !== this.normalizedServerUrl(this.serverUrl))
|
|
580
|
+
return true;
|
|
581
|
+
if (status.userEmail && status.userEmail.trim().toLowerCase() !== this.email.trim().toLowerCase())
|
|
582
|
+
return true;
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
sessionTokenFromLoginOutput(loginOutput) {
|
|
586
|
+
try {
|
|
587
|
+
const parsed = JSON.parse(loginOutput);
|
|
588
|
+
return typeof parsed.access_token === "string" && parsed.access_token.trim()
|
|
589
|
+
? parsed.access_token.trim()
|
|
590
|
+
: loginOutput.trim();
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
return loginOutput.trim();
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
async loginWithPassword() {
|
|
597
|
+
try {
|
|
598
|
+
return await this.execBwWithPasswordEnv(["login", this.email, "--raw"]);
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
const err = error;
|
|
602
|
+
if (!isBwAlreadyLoggedInError(err))
|
|
603
|
+
throw err;
|
|
604
|
+
return this.execBwWithPasswordEnv(["unlock", "--raw"]);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
forgetLocalSyncMarker() {
|
|
608
|
+
if (!this.appDataDir)
|
|
609
|
+
return;
|
|
610
|
+
try {
|
|
611
|
+
fs.rmSync(path.join(this.appDataDir, BW_SYNC_MARKER_FILENAME), { force: true });
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
// A stale sync marker is only a cache hint; failure to remove it should not block auth repair.
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
async logoutLocalProfile(reason, status, cause) {
|
|
618
|
+
this.sessionToken = null;
|
|
619
|
+
this.structuredItemCache = null;
|
|
620
|
+
this.forgetLocalSyncMarker();
|
|
621
|
+
(0, runtime_1.emitNervesEvent)({
|
|
622
|
+
level: "warn",
|
|
623
|
+
event: "repertoire.bw_local_profile_rebuild",
|
|
624
|
+
component: "repertoire",
|
|
625
|
+
message: "rebuilding local bw profile for agent vault",
|
|
626
|
+
meta: {
|
|
627
|
+
email: this.email,
|
|
628
|
+
serverUrl: this.serverUrl,
|
|
629
|
+
reason,
|
|
630
|
+
/* v8 ignore next -- defensive: every profile rebuild path starts from a locked/unlocked bw status @preserve */
|
|
631
|
+
previousStatus: status.status ?? "unknown",
|
|
632
|
+
previousServerUrl: status.serverUrl ?? null,
|
|
633
|
+
previousUserEmail: status.userEmail ?? null,
|
|
634
|
+
/* v8 ignore next -- defensive: internal rebuild callers pass Error or undefined causes @preserve */
|
|
635
|
+
error: cause instanceof Error ? cause.message : cause ? String(cause) : undefined,
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
try {
|
|
639
|
+
await this.execBw(["logout"]);
|
|
640
|
+
}
|
|
641
|
+
catch (error) {
|
|
642
|
+
const err = error;
|
|
643
|
+
if (isBwLoggedOutOrUnauthenticatedError(err))
|
|
644
|
+
return;
|
|
645
|
+
(0, runtime_1.emitNervesEvent)({
|
|
646
|
+
level: "warn",
|
|
647
|
+
event: "repertoire.bw_local_profile_logout_failed",
|
|
648
|
+
component: "repertoire",
|
|
649
|
+
message: "failed to logout local bw profile before rebuild",
|
|
650
|
+
meta: { email: this.email, serverUrl: this.serverUrl, error: err.message },
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
async rebuildLocalProfile(reason, status, cause) {
|
|
655
|
+
await this.logoutLocalProfile(reason, status, cause);
|
|
656
|
+
await this.execBw(["config", "server", this.serverUrl]);
|
|
657
|
+
this.sessionToken = this.sessionTokenFromLoginOutput(await this.loginWithPassword());
|
|
658
|
+
}
|
|
659
|
+
async execWithLocalProfileRebuild(reason, status, operation) {
|
|
660
|
+
try {
|
|
661
|
+
return await operation();
|
|
662
|
+
}
|
|
663
|
+
catch (error) {
|
|
664
|
+
const err = error;
|
|
665
|
+
if (!isBwInvalidUnlockSecretMessage(err.message))
|
|
666
|
+
throw err;
|
|
667
|
+
await this.rebuildLocalProfile(reason, status, err);
|
|
668
|
+
/* v8 ignore next -- defensive: rebuildLocalProfile always stores a string session token on success @preserve */
|
|
669
|
+
return this.sessionToken ?? "";
|
|
670
|
+
}
|
|
524
671
|
}
|
|
525
672
|
async ensureSession() {
|
|
526
673
|
if (!this.sessionToken) {
|
|
@@ -44,6 +44,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
44
44
|
})();
|
|
45
45
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
46
|
exports.getCredentialStore = getCredentialStore;
|
|
47
|
+
exports.probeCredentialVaultAccess = probeCredentialVaultAccess;
|
|
47
48
|
exports.resetCredentialStore = resetCredentialStore;
|
|
48
49
|
const crypto = __importStar(require("node:crypto"));
|
|
49
50
|
const fs = __importStar(require("node:fs"));
|
|
@@ -64,13 +65,13 @@ function loadVaultSectionForAgent(agentName) {
|
|
|
64
65
|
return { configPath };
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
|
-
function bitwardenAppDataDir(agentName, vaultConfig) {
|
|
68
|
+
function bitwardenAppDataDir(agentName, vaultConfig, homeDir = os.homedir()) {
|
|
68
69
|
const digest = crypto
|
|
69
70
|
.createHash("sha256")
|
|
70
71
|
.update(`${agentName}:${vaultConfig.serverUrl}:${vaultConfig.email}`)
|
|
71
72
|
.digest("hex")
|
|
72
73
|
.slice(0, 24);
|
|
73
|
-
return path.join(
|
|
74
|
+
return path.join(homeDir, ".ouro-cli", "bitwarden", digest);
|
|
74
75
|
}
|
|
75
76
|
function getCredentialStore(agentNameInput) {
|
|
76
77
|
const agentName = agentNameInput ?? identity.getAgentName();
|
|
@@ -91,7 +92,63 @@ function getCredentialStore(agentNameInput) {
|
|
|
91
92
|
email: vaultConfig.email,
|
|
92
93
|
serverUrl: vaultConfig.serverUrl,
|
|
93
94
|
});
|
|
94
|
-
const
|
|
95
|
+
const unlockConfig = { agentName, email: vaultConfig.email, serverUrl: vaultConfig.serverUrl };
|
|
96
|
+
const unlockSource = unlock.source ?? unlockConfig;
|
|
97
|
+
let invalidUnlockCleared = false;
|
|
98
|
+
let canonicalUnlockStored = false;
|
|
99
|
+
const store = new bitwarden_store_1.BitwardenCredentialStore(vaultConfig.serverUrl, vaultConfig.email, unlock.secret, {
|
|
100
|
+
appDataDir: bitwardenAppDataDir(agentName, vaultConfig),
|
|
101
|
+
onInvalidUnlockSecret: (error) => {
|
|
102
|
+
if (invalidUnlockCleared)
|
|
103
|
+
return;
|
|
104
|
+
invalidUnlockCleared = true;
|
|
105
|
+
(0, vault_unlock_1.clearVaultUnlockSecret)({
|
|
106
|
+
agentName,
|
|
107
|
+
email: unlockSource.email,
|
|
108
|
+
serverUrl: unlockSource.serverUrl,
|
|
109
|
+
});
|
|
110
|
+
stores.delete(cacheKey);
|
|
111
|
+
(0, runtime_1.emitNervesEvent)({
|
|
112
|
+
level: "warn",
|
|
113
|
+
event: "repertoire.credential_store_invalid_unlock_cleared",
|
|
114
|
+
component: "repertoire",
|
|
115
|
+
message: "cleared rejected local vault unlock material",
|
|
116
|
+
meta: {
|
|
117
|
+
agentName,
|
|
118
|
+
serverUrl: vaultConfig.serverUrl,
|
|
119
|
+
email: vaultConfig.email,
|
|
120
|
+
sourceServerUrl: unlockSource.serverUrl,
|
|
121
|
+
error: error.message,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
onLoginSuccess: () => {
|
|
126
|
+
if (canonicalUnlockStored)
|
|
127
|
+
return;
|
|
128
|
+
if (unlockSource.serverUrl === vaultConfig.serverUrl && unlockSource.email === vaultConfig.email)
|
|
129
|
+
return;
|
|
130
|
+
canonicalUnlockStored = true;
|
|
131
|
+
try {
|
|
132
|
+
(0, vault_unlock_1.storeVaultUnlockSecret)(unlockConfig, unlock.secret);
|
|
133
|
+
(0, vault_unlock_1.noteVaultUnlockSelfHeal)(unlockConfig, unlock.store.kind, unlockSource.serverUrl);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
(0, runtime_1.emitNervesEvent)({
|
|
137
|
+
level: "warn",
|
|
138
|
+
event: "repertoire.vault_unlock_self_heal_failed",
|
|
139
|
+
component: "repertoire",
|
|
140
|
+
message: "failed to rewrite local unlock material using canonical vault coordinates",
|
|
141
|
+
meta: {
|
|
142
|
+
store: unlock.store.kind,
|
|
143
|
+
email: vaultConfig.email,
|
|
144
|
+
sourceServerUrl: unlockSource.serverUrl,
|
|
145
|
+
targetServerUrl: vaultConfig.serverUrl,
|
|
146
|
+
error: error instanceof Error ? error.message : String(error),
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
});
|
|
95
152
|
stores.set(cacheKey, store);
|
|
96
153
|
(0, runtime_1.emitNervesEvent)({
|
|
97
154
|
event: "repertoire.credential_store_init",
|
|
@@ -106,6 +163,16 @@ function getCredentialStore(agentNameInput) {
|
|
|
106
163
|
});
|
|
107
164
|
return store;
|
|
108
165
|
}
|
|
166
|
+
async function probeCredentialVaultAccess(agentNameInput, unlockSecret, options = {}) {
|
|
167
|
+
const agentName = agentNameInput;
|
|
168
|
+
const { configPath, vault } = loadVaultSectionForAgent(agentName);
|
|
169
|
+
if (!vault || typeof vault.email !== "string" || vault.email.trim().length === 0) {
|
|
170
|
+
throw new Error((0, vault_unlock_1.credentialVaultNotConfiguredError)(agentName, configPath));
|
|
171
|
+
}
|
|
172
|
+
const vaultConfig = identity.resolveVaultConfig(agentName, vault);
|
|
173
|
+
const store = new bitwarden_store_1.BitwardenCredentialStore(vaultConfig.serverUrl, vaultConfig.email, unlockSecret, { appDataDir: bitwardenAppDataDir(agentName, vaultConfig, options.homeDir) });
|
|
174
|
+
await store.get("__ouro_vault_probe__");
|
|
175
|
+
}
|
|
109
176
|
function resetCredentialStore() {
|
|
110
177
|
stores = new Map();
|
|
111
178
|
}
|
|
@@ -39,6 +39,8 @@ exports.isCredentialVaultNotConfiguredError = isCredentialVaultNotConfiguredErro
|
|
|
39
39
|
exports.vaultCreateRecoverFix = vaultCreateRecoverFix;
|
|
40
40
|
exports.promptConfirmedVaultUnlockSecret = promptConfirmedVaultUnlockSecret;
|
|
41
41
|
exports.resolveVaultUnlockStore = resolveVaultUnlockStore;
|
|
42
|
+
exports.noteVaultUnlockSelfHeal = noteVaultUnlockSelfHeal;
|
|
43
|
+
exports.clearVaultUnlockSecret = clearVaultUnlockSecret;
|
|
42
44
|
exports.readVaultUnlockSecret = readVaultUnlockSecret;
|
|
43
45
|
exports.storeVaultUnlockSecret = storeVaultUnlockSecret;
|
|
44
46
|
exports.getVaultUnlockStatus = getVaultUnlockStatus;
|
|
@@ -82,6 +84,12 @@ function vaultConfigCandidates(config) {
|
|
|
82
84
|
serverUrl,
|
|
83
85
|
}));
|
|
84
86
|
}
|
|
87
|
+
function exactVaultConfig(config) {
|
|
88
|
+
return {
|
|
89
|
+
...config,
|
|
90
|
+
serverUrl: config.serverUrl.trim().replace(/\/+$/, ""),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
85
93
|
function plaintextUnlockPath(config, deps) {
|
|
86
94
|
const digest = crypto.createHash("sha256").update(vaultKey(config)).digest("hex").slice(0, 24);
|
|
87
95
|
return path.join(homeDir(deps), PLAINTEXT_UNLOCK_DIR, `${digest}.secret`);
|
|
@@ -265,37 +273,13 @@ function noteVaultUnlockSelfHeal(config, storeKind, sourceServerUrl) {
|
|
|
265
273
|
},
|
|
266
274
|
});
|
|
267
275
|
}
|
|
268
|
-
function warnVaultUnlockSelfHealFailure(config, storeKind, sourceServerUrl, error) {
|
|
269
|
-
(0, runtime_1.emitNervesEvent)({
|
|
270
|
-
level: "warn",
|
|
271
|
-
component: "repertoire",
|
|
272
|
-
event: "repertoire.vault_unlock_self_heal_failed",
|
|
273
|
-
message: "failed to rewrite local unlock material using canonical vault coordinates",
|
|
274
|
-
meta: {
|
|
275
|
-
store: storeKind,
|
|
276
|
-
email: config.email,
|
|
277
|
-
sourceServerUrl,
|
|
278
|
-
targetServerUrl: config.serverUrl,
|
|
279
|
-
error: error instanceof Error ? error.message : String(error),
|
|
280
|
-
},
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
276
|
function readFromMacosKeychain(config, deps) {
|
|
284
277
|
const candidates = vaultConfigCandidates(config);
|
|
285
|
-
for (const
|
|
278
|
+
for (const candidate of candidates) {
|
|
286
279
|
const secret = readFromMacosKeychainExact(vaultKey(candidate), deps);
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
if (index > 0) {
|
|
290
|
-
try {
|
|
291
|
-
writeToMacosKeychain(config, secret, deps);
|
|
292
|
-
noteVaultUnlockSelfHeal(config, "macos-keychain", candidate.serverUrl);
|
|
293
|
-
}
|
|
294
|
-
catch (error) {
|
|
295
|
-
warnVaultUnlockSelfHealFailure(config, "macos-keychain", candidate.serverUrl, error);
|
|
296
|
-
}
|
|
280
|
+
if (secret) {
|
|
281
|
+
return { secret, source: exactVaultConfig(candidate) };
|
|
297
282
|
}
|
|
298
|
-
return secret;
|
|
299
283
|
}
|
|
300
284
|
return null;
|
|
301
285
|
}
|
|
@@ -337,20 +321,11 @@ function readFromLinuxSecretServiceExact(accountKey, deps) {
|
|
|
337
321
|
}
|
|
338
322
|
function readFromLinuxSecretService(config, deps) {
|
|
339
323
|
const candidates = vaultConfigCandidates(config);
|
|
340
|
-
for (const
|
|
324
|
+
for (const candidate of candidates) {
|
|
341
325
|
const secret = readFromLinuxSecretServiceExact(vaultKey(candidate), deps);
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
if (index > 0) {
|
|
345
|
-
try {
|
|
346
|
-
writeToLinuxSecretService(config, secret, deps);
|
|
347
|
-
noteVaultUnlockSelfHeal(config, "linux-secret-service", candidate.serverUrl);
|
|
348
|
-
}
|
|
349
|
-
catch (error) {
|
|
350
|
-
warnVaultUnlockSelfHealFailure(config, "linux-secret-service", candidate.serverUrl, error);
|
|
351
|
-
}
|
|
326
|
+
if (secret) {
|
|
327
|
+
return { secret, source: exactVaultConfig(candidate) };
|
|
352
328
|
}
|
|
353
|
-
return secret;
|
|
354
329
|
}
|
|
355
330
|
return null;
|
|
356
331
|
}
|
|
@@ -417,20 +392,11 @@ function readFromWindowsDpapiExact(config, deps) {
|
|
|
417
392
|
}
|
|
418
393
|
function readFromWindowsDpapi(config, deps) {
|
|
419
394
|
const candidates = vaultConfigCandidates(config);
|
|
420
|
-
for (const
|
|
395
|
+
for (const candidate of candidates) {
|
|
421
396
|
const secret = readFromWindowsDpapiExact(candidate, deps);
|
|
422
|
-
if (
|
|
423
|
-
|
|
424
|
-
if (index > 0) {
|
|
425
|
-
try {
|
|
426
|
-
writeToWindowsDpapi(config, secret, deps);
|
|
427
|
-
noteVaultUnlockSelfHeal(config, "windows-dpapi", candidate.serverUrl);
|
|
428
|
-
}
|
|
429
|
-
catch (error) {
|
|
430
|
-
warnVaultUnlockSelfHealFailure(config, "windows-dpapi", candidate.serverUrl, error);
|
|
431
|
-
}
|
|
397
|
+
if (secret) {
|
|
398
|
+
return { secret, source: exactVaultConfig(candidate) };
|
|
432
399
|
}
|
|
433
|
-
return secret;
|
|
434
400
|
}
|
|
435
401
|
return null;
|
|
436
402
|
}
|
|
@@ -455,20 +421,11 @@ function readFromPlaintextFileExact(config, deps) {
|
|
|
455
421
|
}
|
|
456
422
|
function readFromPlaintextFile(config, deps) {
|
|
457
423
|
const candidates = vaultConfigCandidates(config);
|
|
458
|
-
for (const
|
|
424
|
+
for (const candidate of candidates) {
|
|
459
425
|
const secret = readFromPlaintextFileExact(candidate, deps);
|
|
460
|
-
if (
|
|
461
|
-
|
|
462
|
-
if (index > 0) {
|
|
463
|
-
try {
|
|
464
|
-
writeToPlaintextFile(config, secret, deps);
|
|
465
|
-
noteVaultUnlockSelfHeal(config, "plaintext-file", candidate.serverUrl);
|
|
466
|
-
}
|
|
467
|
-
catch (error) {
|
|
468
|
-
warnVaultUnlockSelfHealFailure(config, "plaintext-file", candidate.serverUrl, error);
|
|
469
|
-
}
|
|
426
|
+
if (secret) {
|
|
427
|
+
return { secret, source: exactVaultConfig(candidate) };
|
|
470
428
|
}
|
|
471
|
-
return secret;
|
|
472
429
|
}
|
|
473
430
|
return null;
|
|
474
431
|
}
|
|
@@ -505,20 +462,96 @@ function writeToStore(config, store, secret, deps) {
|
|
|
505
462
|
}
|
|
506
463
|
writeToPlaintextFile(config, secret, deps);
|
|
507
464
|
}
|
|
465
|
+
function deleteFromMacosKeychainExact(config, deps) {
|
|
466
|
+
const result = spawnSync(deps)("security", [
|
|
467
|
+
"delete-generic-password",
|
|
468
|
+
"-s",
|
|
469
|
+
VAULT_UNLOCK_SERVICE,
|
|
470
|
+
"-a",
|
|
471
|
+
vaultKey(config),
|
|
472
|
+
], { encoding: "utf8" });
|
|
473
|
+
if (result.status === 0)
|
|
474
|
+
return true;
|
|
475
|
+
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
476
|
+
const error = result.error instanceof Error ? result.error.message : "";
|
|
477
|
+
const detail = stderr || error;
|
|
478
|
+
if (!detail || /could not be found in the keychain/i.test(detail))
|
|
479
|
+
return false;
|
|
480
|
+
throw new Error(`failed to clear vault unlock secret from macOS Keychain: ${detail}`);
|
|
481
|
+
}
|
|
482
|
+
function deleteFromLinuxSecretServiceExact(config, deps) {
|
|
483
|
+
const result = spawnSync(deps)("secret-tool", [
|
|
484
|
+
"clear",
|
|
485
|
+
"service",
|
|
486
|
+
VAULT_UNLOCK_SERVICE,
|
|
487
|
+
"account",
|
|
488
|
+
vaultKey(config),
|
|
489
|
+
], { encoding: "utf8" });
|
|
490
|
+
if (result.status === 0)
|
|
491
|
+
return true;
|
|
492
|
+
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
493
|
+
const error = result.error instanceof Error ? result.error.message : "";
|
|
494
|
+
const detail = stderr || error;
|
|
495
|
+
if (!detail || /not found/i.test(detail))
|
|
496
|
+
return false;
|
|
497
|
+
throw new Error(`failed to clear vault unlock secret from Linux Secret Service: ${detail}`);
|
|
498
|
+
}
|
|
499
|
+
function deleteFromWindowsDpapiExact(config, deps) {
|
|
500
|
+
const filePath = windowsDpapiUnlockPath(config, deps);
|
|
501
|
+
const existed = fs.existsSync(filePath);
|
|
502
|
+
fs.rmSync(filePath, { force: true });
|
|
503
|
+
return existed;
|
|
504
|
+
}
|
|
505
|
+
function deleteFromPlaintextFileExact(config, deps) {
|
|
506
|
+
const filePath = plaintextUnlockPath(config, deps);
|
|
507
|
+
const existed = fs.existsSync(filePath);
|
|
508
|
+
fs.rmSync(filePath, { force: true });
|
|
509
|
+
return existed;
|
|
510
|
+
}
|
|
511
|
+
function deleteFromStoreExact(config, store, deps) {
|
|
512
|
+
if (store.kind === "macos-keychain")
|
|
513
|
+
return deleteFromMacosKeychainExact(config, deps);
|
|
514
|
+
if (store.kind === "linux-secret-service")
|
|
515
|
+
return deleteFromLinuxSecretServiceExact(config, deps);
|
|
516
|
+
if (store.kind === "windows-dpapi")
|
|
517
|
+
return deleteFromWindowsDpapiExact(config, deps);
|
|
518
|
+
return deleteFromPlaintextFileExact(config, deps);
|
|
519
|
+
}
|
|
520
|
+
function clearVaultUnlockSecret(config, deps = {}) {
|
|
521
|
+
const canonicalConfig = canonicalizeVaultUnlockConfig(config);
|
|
522
|
+
const store = resolveVaultUnlockStore(canonicalConfig, deps);
|
|
523
|
+
const deletedAccounts = new Set();
|
|
524
|
+
let deleted = false;
|
|
525
|
+
for (const candidate of vaultConfigCandidates(config)) {
|
|
526
|
+
const exactCandidate = exactVaultConfig(candidate);
|
|
527
|
+
const key = vaultKey(exactCandidate);
|
|
528
|
+
if (deletedAccounts.has(key))
|
|
529
|
+
continue;
|
|
530
|
+
deletedAccounts.add(key);
|
|
531
|
+
deleted = deleteFromStoreExact(exactCandidate, store, deps) || deleted;
|
|
532
|
+
}
|
|
533
|
+
(0, runtime_1.emitNervesEvent)({
|
|
534
|
+
component: "repertoire",
|
|
535
|
+
event: "repertoire.vault_unlock_cleared",
|
|
536
|
+
message: "cleared local vault unlock material",
|
|
537
|
+
meta: { store: store.kind, secure: store.secure, hasAgentName: !!config.agentName, deleted },
|
|
538
|
+
});
|
|
539
|
+
return store;
|
|
540
|
+
}
|
|
508
541
|
function readVaultUnlockSecret(config, deps = {}) {
|
|
509
542
|
const canonicalConfig = canonicalizeVaultUnlockConfig(config);
|
|
510
543
|
const store = resolveVaultUnlockStore(canonicalConfig, deps);
|
|
511
|
-
const
|
|
512
|
-
if (!
|
|
544
|
+
const loaded = readFromStore(canonicalConfig, store, deps);
|
|
545
|
+
if (!loaded) {
|
|
513
546
|
throw new Error(lockedMessage(canonicalConfig, store));
|
|
514
547
|
}
|
|
515
548
|
(0, runtime_1.emitNervesEvent)({
|
|
516
549
|
component: "repertoire",
|
|
517
550
|
event: "repertoire.vault_unlock_loaded",
|
|
518
551
|
message: "loaded vault unlock material from local store",
|
|
519
|
-
meta: { store: store.kind, secure: store.secure, hasAgentName: !!config.agentName },
|
|
552
|
+
meta: { store: store.kind, secure: store.secure, hasAgentName: !!config.agentName, sourceServerUrl: loaded.source.serverUrl },
|
|
520
553
|
});
|
|
521
|
-
return { secret, store };
|
|
554
|
+
return { secret: loaded.secret, store, source: loaded.source };
|
|
522
555
|
}
|
|
523
556
|
function storeVaultUnlockSecret(config, secret, deps = {}) {
|
|
524
557
|
const canonicalConfig = canonicalizeVaultUnlockConfig(config);
|