diff --git a/.changeset/auth-durable-secret-store.md b/.changeset/auth-durable-secret-store.md new file mode 100644 index 0000000..3d35056 --- /dev/null +++ b/.changeset/auth-durable-secret-store.md @@ -0,0 +1,17 @@ +--- +'@enbox/gitd': minor +--- + +feat: durable vault-unlock secret so commands stop re-prompting + +gitd now resolves the vault unlock password through a single path (explicit → +`GITD_PASSWORD` → public-reader secret → durable secret store → prompt) and +persists a freshly entered secret in a durable per-profile store — the OS +keychain (macOS `security`, Linux `secret-tool`) when available, otherwise a +machine-keyed AES-256-GCM encrypted file under `~/.enbox/secrets/`. After the +first unlock, subsequent commands restore the session without prompting for the +vault password again. + +`GITD_SECRET_BACKEND=keychain|file|none` pins or disables the backend. A cached +secret that is later rejected is cleared automatically so a stale secret can't +lock you out. `gitd auth logout` / `gitd auth reset` drop the stored secret. diff --git a/src/auth/secret-store.ts b/src/auth/secret-store.ts new file mode 100644 index 0000000..0d02991 --- /dev/null +++ b/src/auth/secret-store.ts @@ -0,0 +1,295 @@ +/** + * Durable per-profile secret store for gitd vault-unlock secrets. + * + * gitd unlocks a profile's Enbox vault with a secret. Persisting that secret + * durably lets the CLI and the credential helper restore a session without + * re-prompting on every command — the root cause of "asked for the vault + * password repeatedly". + * + * Backends, in preference order: + * 1. OS keychain — macOS `security`, Linux `secret-tool` (libsecret). + * 2. Encrypted file — AES-256-GCM under `~/.enbox/secrets/.enc`, + * keyed by a machine + user derived key, file mode 0600. + * + * The active backend can be pinned with `GITD_SECRET_BACKEND`: + * - `keychain` — OS keychain only (no file fallback). + * - `file` — encrypted file only (skip the keychain). + * - `none` — disable durable storage entirely (CI / ephemeral use). + * When unset, gitd prefers the keychain and falls back to the encrypted file. + * + * @module + */ + +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { hostname, userInfo } from 'node:os'; + +import { enboxHome } from '../profiles/config.js'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Keychain service name used for all gitd secrets. */ +const SERVICE = 'gitd'; + +/** Timeout for keychain CLI invocations. */ +const KEYCHAIN_TIMEOUT_MS = 5_000; + +/** Selectable backends. `undefined` means auto (keychain, then file). */ +type Backend = 'keychain' | 'file' | 'none'; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Retrieve the stored vault secret for a profile, or `undefined` if none. */ +export async function getVaultSecret(profileName: string): Promise { + const backend = configuredBackend(); + if (backend === 'none') { return undefined; } + + if (backend !== 'file' && keychainSupported()) { + const value = keychainGet(profileName); + if (value !== undefined) { return value; } + if (backend === 'keychain') { return undefined; } + } + + return fileGet(profileName); +} + +/** Persist the vault secret for a profile durably. */ +export async function setVaultSecret(profileName: string, secret: string): Promise { + const backend = configuredBackend(); + if (backend === 'none') { return; } + + if (backend !== 'file' && keychainSupported()) { + if (keychainSet(profileName, secret)) { + // Clear any stale file copy so reads stay consistent with the keychain. + fileDelete(profileName); + return; + } + if (backend === 'keychain') { + throw new Error('GITD_SECRET_BACKEND=keychain but the OS keychain is unavailable.'); + } + } + + fileSet(profileName, secret); +} + +/** Remove the stored vault secret for a profile from all backends. */ +export async function deleteVaultSecret(profileName: string): Promise { + const backend = configuredBackend(); + if (backend === 'none') { return; } + + if (backend !== 'file' && keychainSupported()) { + keychainDelete(profileName); + } + fileDelete(profileName); +} + +/** Describe the active backend for diagnostics (`gitd doctor`, status). */ +export function describeSecretBackend(): string { + const backend = configuredBackend(); + if (backend === 'none') { return 'disabled (GITD_SECRET_BACKEND=none)'; } + if (backend === 'file') { return 'encrypted file'; } + if (backend === 'keychain') { return 'OS keychain'; } + return keychainSupported() ? 'OS keychain (encrypted file fallback)' : 'encrypted file'; +} + +// --------------------------------------------------------------------------- +// Backend selection +// --------------------------------------------------------------------------- + +function configuredBackend(): Backend | undefined { + const value = process.env.GITD_SECRET_BACKEND?.toLowerCase(); + if (value === 'keychain' || value === 'file' || value === 'none') { return value; } + return undefined; +} + +function keychainSupported(): boolean { + return process.platform === 'darwin' || process.platform === 'linux'; +} + +/** Keychain account label for a profile. */ +function account(profileName: string): string { + return `vault:${profileName}`; +} + +// --------------------------------------------------------------------------- +// OS keychain backend +// --------------------------------------------------------------------------- + +function keychainGet(profileName: string): string | undefined { + try { + if (process.platform === 'darwin') { + const out = execFileSync( + 'security', + ['find-generic-password', '-s', SERVICE, '-a', account(profileName), '-w'], + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: KEYCHAIN_TIMEOUT_MS }, + ); + const value = out.replace(/\n$/, ''); + return value.length > 0 ? value : undefined; + } + if (process.platform === 'linux') { + const out = execFileSync( + 'secret-tool', + ['lookup', 'service', SERVICE, 'account', account(profileName)], + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: KEYCHAIN_TIMEOUT_MS }, + ); + return out.length > 0 ? out : undefined; + } + } catch { + // Not found, no keyring, or tool missing — fall back to the file backend. + } + return undefined; +} + +function keychainSet(profileName: string, secret: string): boolean { + try { + if (process.platform === 'darwin') { + execFileSync( + 'security', + ['add-generic-password', '-U', '-s', SERVICE, '-a', account(profileName), '-w', secret], + { stdio: 'ignore', timeout: KEYCHAIN_TIMEOUT_MS }, + ); + return true; + } + if (process.platform === 'linux') { + execFileSync( + 'secret-tool', + ['store', '--label', `gitd ${profileName}`, 'service', SERVICE, 'account', account(profileName)], + { input: secret, stdio: ['pipe', 'ignore', 'ignore'], timeout: KEYCHAIN_TIMEOUT_MS }, + ); + return true; + } + } catch { + // Keyring locked / tool missing — caller falls back to the file backend. + } + return false; +} + +function keychainDelete(profileName: string): void { + try { + if (process.platform === 'darwin') { + execFileSync( + 'security', + ['delete-generic-password', '-s', SERVICE, '-a', account(profileName)], + { stdio: 'ignore', timeout: KEYCHAIN_TIMEOUT_MS }, + ); + } else if (process.platform === 'linux') { + execFileSync( + 'secret-tool', + ['clear', 'service', SERVICE, 'account', account(profileName)], + { stdio: 'ignore', timeout: KEYCHAIN_TIMEOUT_MS }, + ); + } + } catch { + // Nothing to delete, or no keyring — safe to ignore. + } +} + +// --------------------------------------------------------------------------- +// Encrypted file backend +// --------------------------------------------------------------------------- + +/** Stored ciphertext envelope. */ +type SecretFile = { + v : 1; + iv : string; + tag : string; + ct : string; +}; + +function secretsDir(): string { + return join(enboxHome(), 'secrets'); +} + +function secretFilePath(profileName: string): string { + const safe = profileName.replace(/[^a-zA-Z0-9_-]/g, '_'); + return join(secretsDir(), `${safe}.enc`); +} + +function fileGet(profileName: string): string | undefined { + const path = secretFilePath(profileName); + if (!existsSync(path)) { return undefined; } + try { + const envelope = JSON.parse(readFileSync(path, 'utf-8')) as SecretFile; + const decipher = createDecipheriv('aes-256-gcm', fileKey(), Buffer.from(envelope.iv, 'hex')); + decipher.setAuthTag(Buffer.from(envelope.tag, 'hex')); + const plain = Buffer.concat([decipher.update(Buffer.from(envelope.ct, 'hex')), decipher.final()]); + return plain.toString('utf-8'); + } catch { + // Corrupt, tampered, or written by another machine/user — treat as absent. + return undefined; + } +} + +function fileSet(profileName: string, secret: string): void { + mkdirSync(secretsDir(), { recursive: true, mode: 0o700 }); + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', fileKey(), iv); + const ct = Buffer.concat([cipher.update(Buffer.from(secret, 'utf-8')), cipher.final()]); + const envelope: SecretFile = { + v : 1, + iv : iv.toString('hex'), + tag : cipher.getAuthTag().toString('hex'), + ct : ct.toString('hex'), + }; + writeFileSync(secretFilePath(profileName), JSON.stringify(envelope), { mode: 0o600 }); +} + +function fileDelete(profileName: string): void { + const path = secretFilePath(profileName); + if (existsSync(path)) { rmSync(path, { force: true }); } +} + +/** + * Derive the 32-byte AES key for the encrypted-file backend. + * + * The key is bound to this machine and user: a stable machine id plus the + * username, mixed with a per-install random key persisted under the secrets + * directory. Copying the `.enc` file to another machine or user will not + * decrypt it. + */ +function fileKey(): Buffer { + mkdirSync(secretsDir(), { recursive: true, mode: 0o700 }); + const keyPath = join(secretsDir(), '.machine-key'); + let base: Buffer; + if (existsSync(keyPath)) { + base = readFileSync(keyPath); + } else { + base = randomBytes(32); + writeFileSync(keyPath, base, { mode: 0o600 }); + } + return createHash('sha256') + .update(base) + .update(machineId()) + .update(userInfo().username) + .digest(); +} + +/** Best-effort stable machine identifier, falling back to the hostname. */ +function machineId(): string { + try { + if (process.platform === 'linux') { + for (const path of ['/etc/machine-id', '/var/lib/dbus/machine-id']) { + if (existsSync(path)) { return readFileSync(path, 'utf-8').trim(); } + } + } else if (process.platform === 'darwin') { + const out = execFileSync('ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice'], { + encoding : 'utf-8', + stdio : ['ignore', 'pipe', 'ignore'], + timeout : 2_000, + }); + const match = out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/); + if (match) { return match[1]; } + } else if (process.platform === 'win32') { + return process.env.COMPUTERNAME ?? hostname(); + } + } catch { + // Fall through to the hostname. + } + return hostname(); +} diff --git a/src/auth/vault-password.ts b/src/auth/vault-password.ts new file mode 100644 index 0000000..66a61af --- /dev/null +++ b/src/auth/vault-password.ts @@ -0,0 +1,158 @@ +/** + * Vault-password resolution and durable caching. + * + * One place that answers "what unlocks this profile's vault?", consulting, in + * order: an explicit value, `GITD_PASSWORD`, the hidden public-reader secret, + * the durable secret store (OS keychain / encrypted file), and finally an + * interactive prompt. The resolved source is tracked so a freshly entered + * secret can be cached for next time — turning the previously per-command + * password prompt into a one-time event. + * + * @module + */ + +import { readPublicReaderPasswordForActiveProfile } from '../profiles/public-reader.js'; + +import { deleteVaultSecret, getVaultSecret, setVaultSecret } from './secret-store.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Where a resolved password came from. */ +export type PasswordSource = 'explicit' | 'env' | 'public-reader' | 'keychain' | 'tty'; + +/** A resolved vault password together with its origin. */ +export type ResolvedPassword = { + password : string; + source : PasswordSource; +}; + +/** Options for {@link resolveVaultPassword}. */ +export type ResolveVaultPasswordOptions = { + /** Active profile name (used for public-reader + durable secret lookup). */ + profileName? : string; + /** Caller-supplied password (e.g. from a first-run wizard); wins outright. */ + explicit? : string; + /** Environment to read `GITD_PASSWORD` from. Defaults to `process.env`. */ + env? : NodeJS.ProcessEnv; + /** Whether to fall back to an interactive prompt. Defaults to `true`. */ + allowPrompt? : boolean; + /** Prompt label shown when reading interactively. */ + promptMessage? : string; +}; + +// --------------------------------------------------------------------------- +// Resolution +// --------------------------------------------------------------------------- + +/** + * Resolve the vault password for a profile. + * + * Precedence: explicit → `GITD_PASSWORD` → public-reader secret → durable + * secret store → interactive prompt. + * + * @throws If no password is available and prompting is disabled. + */ +export async function resolveVaultPassword(options: ResolveVaultPasswordOptions): Promise { + const { profileName, explicit, env = process.env, allowPrompt = true, promptMessage } = options; + + if (explicit) { return { password: explicit, source: 'explicit' }; } + + const envPassword = env.GITD_PASSWORD; + if (envPassword) { return { password: envPassword, source: 'env' }; } + + const publicReaderPassword = readPublicReaderPasswordForActiveProfile(profileName); + if (publicReaderPassword) { return { password: publicReaderPassword, source: 'public-reader' }; } + + if (profileName) { + const cached = await getVaultSecret(profileName); + if (cached) { return { password: cached, source: 'keychain' }; } + } + + if (!allowPrompt) { + throw new Error('No vault password available. Set GITD_PASSWORD or run `gitd auth login`.'); + } + + const password = await promptVaultPassword(promptMessage); + return { password, source: 'tty' }; +} + +/** + * Persist a freshly entered secret so later commands restore without a prompt. + * + * Only `tty` and `explicit` (first-run) secrets are cached. `env` secrets are + * left ephemeral by design, and `keychain` / `public-reader` secrets are + * already durable. + */ +export async function rememberVaultSecret(profileName: string | undefined, resolved: ResolvedPassword): Promise { + if (!profileName) { return; } + if (resolved.source !== 'tty' && resolved.source !== 'explicit') { return; } + try { + await setVaultSecret(profileName, resolved.password); + } catch (err) { + // Caching is an optimization — never fail the command over it. + console.error(`[auth] Could not cache unlock secret: ${(err as Error).message}`); + } +} + +/** Drop a profile's durable secret (logout, reset, or rejected-secret repair). */ +export async function forgetVaultSecret(profileName: string): Promise { + await deleteVaultSecret(profileName); +} + +// --------------------------------------------------------------------------- +// Interactive prompt +// --------------------------------------------------------------------------- + +/** + * Prompt for a vault password. + * + * On a TTY the input is read in raw mode with no echo; otherwise the password + * is read from piped stdin. Ctrl-C aborts with exit code 130. + */ +export async function promptVaultPassword(message = 'Identity password: '): Promise { + process.stdout.write(message); + + if (process.stdin.isTTY) { + return new Promise((resolve) => { + let buf = ''; + process.stdin.setRawMode(true); + process.stdin.setEncoding('utf8'); + process.stdin.resume(); + + const onData = (ch: string): void => { + const code = ch.charCodeAt(0); + + if (ch === '\r' || ch === '\n') { + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdin.removeListener('data', onData); + process.stdout.write('\n'); + resolve(buf); + } else if (code === 3) { + process.stdin.setRawMode(false); + process.stdout.write('\n'); + process.exit(130); + } else if (code === 127 || code === 8) { + if (buf.length > 0) { buf = buf.slice(0, -1); } + } else if (code >= 32) { + buf += ch; + } + }; + + process.stdin.on('data', onData); + }); + } + + // Non-TTY fallback (piped input). + return new Promise((resolve) => { + let buf = ''; + process.stdin.setEncoding('utf8'); + process.stdin.once('data', (chunk: string) => { + buf += chunk; + resolve(buf.trim()); + }); + process.stdin.resume(); + }); +} diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index 402ad0c..337d614 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -39,6 +39,7 @@ import { upsertProfile, writeConfig, } from '../../profiles/config.js'; +import { forgetVaultSecret, rememberVaultSecret } from '../../auth/vault-password.js'; const DEFAULT_IDENTITY_NAME = 'default'; @@ -290,6 +291,9 @@ async function authLogin(): Promise { createdAt : new Date().toISOString(), }); + // Persist the unlock secret so later commands restore without a prompt. + await rememberVaultSecret(profileName, { password: password as string, source: 'explicit' }); + spin.stop('Identity created!'); p.log.success(`DID: ${result.did}`); @@ -484,6 +488,7 @@ async function authReset(args: string[]): Promise { try { const result = resetIdentityLocally(name); + await forgetVaultSecret(name); p.log.success(`Identity "${result.name}" reset.`); if (result.backupPath) { p.log.info(`Backup: ${result.backupPath}`); @@ -572,6 +577,7 @@ async function authLogout(args: string[]): Promise { config.defaultProfile = remaining[0] ?? ''; } writeConfig(config); + await forgetVaultSecret(name); p.log.success(`Identity "${name}" removed.`); p.log.info(`Data directory preserved at: ${profileDataPath(name)}`); diff --git a/src/cli/identity-wizard.ts b/src/cli/identity-wizard.ts index f128079..9d5c576 100644 --- a/src/cli/identity-wizard.ts +++ b/src/cli/identity-wizard.ts @@ -12,6 +12,7 @@ import * as p from '@clack/prompts'; import { connectAgent } from './agent.js'; import { flagValue } from './flags.js'; +import { rememberVaultSecret } from '../auth/vault-password.js'; import { listProfiles, profileDataPath } from '../profiles/config.js'; import { normalizeIdentityNameInput, @@ -194,6 +195,9 @@ export async function createFirstIdentityFromSetup(setup: FirstIdentitySetup): P sync : 'off', }); + // Persist the unlock secret so later commands restore without a prompt. + await rememberVaultSecret(setup.profileName, { password: setup.password, source: 'explicit' }); + try { const recordedProfile = recordConnectedProfile(setup.profileName, ctx.did); if (recordedProfile.created && ctx.recoveryPhrase) { diff --git a/src/cli/main.ts b/src/cli/main.ts index 8acc3ee..d5e04ac 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -90,6 +90,7 @@ import { spawn } from 'node:child_process'; import { dirname, join } from 'node:path'; import type { FirstIdentitySetup } from './identity-wizard.js'; +import type { ResolvedPassword } from '../auth/vault-password.js'; import { authCommand } from './commands/auth.js'; import { cloneCommand } from './commands/clone.js'; @@ -98,12 +99,12 @@ import { dispatchAgentCommand } from './dispatch.js'; import { doctorCommand } from './commands/doctor.js'; import { flagValue } from './flags.js'; import { forwardCliCommandIfAvailable } from './local-rpc.js'; -import { readPublicReaderPasswordForActiveProfile } from '../profiles/public-reader.js'; import { repairCommand } from './commands/repair.js'; import { setupCommand } from './commands/setup.js'; import { checkGit, requireGit, warnGit } from './preflight.js'; import { createFirstIdentityFromSetup, maybePromptForFirstIdentitySetup } from './identity-wizard.js'; import { ensureDaemon, findGitdBin, markDeferredDaemonStart } from '../daemon/lifecycle.js'; +import { forgetVaultSecret, rememberVaultSecret, resolveVaultPassword } from '../auth/vault-password.js'; import { helperCommand, serveDaemonCommand } from './commands/serve-lifecycle.js'; import { printImplicitRecoveryPhrase, recordConnectedProfile, resolveCommandProfile } from './profile-session.js'; import { publicReaderWriteBlockMessage, shouldBlockPublicReaderCommand } from './public-reader-guard.js'; @@ -282,67 +283,18 @@ function printUsage(): void { // Password // --------------------------------------------------------------------------- -async function getPassword(): Promise { - // Prefer env var for non-interactive use / testing. - const env = process.env.GITD_PASSWORD; - if (env) { return env; } - - const publicReaderPassword = readPublicReaderPasswordForActiveProfile(); - if (publicReaderPassword) { return publicReaderPassword; } - - // Interactive prompt — hide input when running in a TTY. - process.stdout.write('Identity password: '); - - if (process.stdin.isTTY) { - // Raw mode: read character-by-character, echo nothing. - const password = await new Promise((resolve) => { - let buf = ''; - process.stdin.setRawMode(true); - process.stdin.setEncoding('utf8'); - process.stdin.resume(); - - const onData = (ch: string): void => { - const code = ch.charCodeAt(0); - - if (ch === '\r' || ch === '\n') { - // Enter — done. - process.stdin.setRawMode(false); - process.stdin.pause(); - process.stdin.removeListener('data', onData); - process.stdout.write('\n'); - resolve(buf); - } else if (code === 3) { - // Ctrl-C — abort. - process.stdin.setRawMode(false); - process.stdout.write('\n'); - process.exit(130); - } else if (code === 127 || code === 8) { - // Backspace / Delete. - if (buf.length > 0) { - buf = buf.slice(0, -1); - } - } else if (code >= 32) { - // Printable character. - buf += ch; - } - }; - - process.stdin.on('data', onData); - }); - return password; - } - - // Non-TTY fallback (piped input). - const response = await new Promise((resolve) => { - let buf = ''; - process.stdin.setEncoding('utf8'); - process.stdin.once('data', (chunk: string) => { - buf += chunk; - resolve(buf.trim()); - }); - process.stdin.resume(); - }); - return response; +/** + * Resolve the vault password for a command and, on a verified unlock, cache a + * freshly entered secret so later commands don't re-prompt. + * + * Resolution and caching live in `../auth/vault-password.js`; this wrapper + * keeps the call sites terse while threading the active profile name through. + */ +async function resolveCommandPassword( + profileName: string | undefined, + explicit?: string, +): Promise { + return resolveVaultPassword({ profileName, explicit }); } async function maybeDelayHelperStart(args: string[]): Promise { @@ -489,7 +441,6 @@ async function main(): Promise { if (setup) { await createFirstIdentityFromSetup(setup); } - const pw = setup?.password ?? await getPassword(); let profileName: string | undefined; try { profileName = setup?.profileName ?? resolveCommandProfile(flagValue(rest, '--profile')).name; @@ -497,12 +448,17 @@ async function main(): Promise { console.error(`gitd: ${(err as Error).message}`); process.exit(1); } + const resolved = await resolveCommandPassword(profileName, setup?.password); try { - const result = await ensureDaemon(pw, { profileName }); + const result = await ensureDaemon(resolved.password, { profileName }); + await rememberVaultSecret(profileName, resolved); for (const line of serveLocalHelperAliasLines(result.spawned, result.port)) { console.log(line); } } catch (err) { + if (resolved.source === 'keychain' && profileName) { + await forgetVaultSecret(profileName); + } console.error(`Failed to start local helper: ${(err as Error).message}`); process.exit(1); } @@ -559,14 +515,24 @@ async function main(): Promise { } // Commands that require the Enbox agent. - const password = firstIdentitySetup?.password ?? await getPassword(); + const resolved = await resolveCommandPassword(profileName, firstIdentitySetup?.password); - const ctx = await connectAgentWithRetry({ - password, - dataPath : commandProfile.dataPath, - sync : sync as any, - recoveryPhrase : firstIdentitySetup?.recoveryPhrase, - }); + let ctx: Awaited>; + try { + ctx = await connectAgentWithRetry({ + password : resolved.password, + dataPath : commandProfile.dataPath, + sync : sync as any, + recoveryPhrase : firstIdentitySetup?.recoveryPhrase, + }); + } catch (err) { + if (resolved.source === 'keychain' && profileName) { + await forgetVaultSecret(profileName); + console.error('gitd: the cached unlock secret was rejected and cleared. Re-run the command to re-enter it.'); + } + throw err; + } + await rememberVaultSecret(profileName, resolved); const recordedProfile = recordConnectedProfile(profileName, ctx.did); ctx.profileName = profileName; @@ -599,9 +565,9 @@ async function main(): Promise { // `gitd init` without hiding first-run recovery phrase output in the helper. if (completed && !longRunning && shouldAutoStartHelperAfterCommand(command, rest)) { try { - await ensureDaemon(password, { profileName: profileName ?? undefined }); + await ensureDaemon(resolved.password, { profileName: profileName ?? undefined }); } catch (err) { - if (isTransientHelperLockError(err) && scheduleDeferredHelperStart(password, profileName ?? undefined)) { + if (isTransientHelperLockError(err) && scheduleDeferredHelperStart(resolved.password, profileName ?? undefined)) { process.exit(0); } // Non-fatal — warn but don't block the command. diff --git a/tests/secret-store.spec.ts b/tests/secret-store.spec.ts new file mode 100644 index 0000000..da956b3 --- /dev/null +++ b/tests/secret-store.spec.ts @@ -0,0 +1,86 @@ +/** + * Durable secret-store tests. + * + * Pins the encrypted-file backend (`GITD_SECRET_BACKEND=file`) and an isolated + * `ENBOX_HOME` so the real OS keychain is never touched. + */ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; + +import { enboxHome } from '../src/profiles/config.js'; +import { deleteVaultSecret, getVaultSecret, setVaultSecret } from '../src/auth/secret-store.js'; + +let tempDir: string; +let originalEnboxHome: string | undefined; +let originalBackend: string | undefined; + +function secretFile(profile: string): string { + return join(enboxHome(), 'secrets', `${profile}.enc`); +} + +describe('secret store (encrypted file backend)', () => { + beforeEach(() => { + originalEnboxHome = process.env.ENBOX_HOME; + originalBackend = process.env.GITD_SECRET_BACKEND; + tempDir = mkdtempSync(join(tmpdir(), 'gitd-secret-store-')); + process.env.ENBOX_HOME = join(tempDir, 'enbox'); + process.env.GITD_SECRET_BACKEND = 'file'; + }); + + afterEach(() => { + if (originalEnboxHome) { process.env.ENBOX_HOME = originalEnboxHome; } else { delete process.env.ENBOX_HOME; } + if (originalBackend) { process.env.GITD_SECRET_BACKEND = originalBackend; } else { delete process.env.GITD_SECRET_BACKEND; } + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('returns undefined when nothing is stored', async () => { + expect(await getVaultSecret('default')).toBeUndefined(); + }); + + it('round-trips a stored secret', async () => { + await setVaultSecret('default', 'hunter2'); + expect(await getVaultSecret('default')).toBe('hunter2'); + }); + + it('stores the secret encrypted at rest (not plaintext)', async () => { + await setVaultSecret('default', 'super-secret-phrase'); + const path = secretFile('default'); + expect(existsSync(path)).toBe(true); + const raw = readFileSync(path, 'utf-8'); + expect(raw).not.toContain('super-secret-phrase'); + const envelope = JSON.parse(raw) as { v: number; iv: string; tag: string; ct: string }; + expect(envelope.v).toBe(1); + expect(envelope.iv.length).toBeGreaterThan(0); + expect(envelope.tag.length).toBeGreaterThan(0); + expect(envelope.ct.length).toBeGreaterThan(0); + }); + + it('overwrites an existing secret', async () => { + await setVaultSecret('default', 'first'); + await setVaultSecret('default', 'second'); + expect(await getVaultSecret('default')).toBe('second'); + }); + + it('isolates secrets per profile', async () => { + await setVaultSecret('work', 'work-pw'); + await setVaultSecret('personal', 'personal-pw'); + expect(await getVaultSecret('work')).toBe('work-pw'); + expect(await getVaultSecret('personal')).toBe('personal-pw'); + }); + + it('deletes a stored secret', async () => { + await setVaultSecret('default', 'hunter2'); + await deleteVaultSecret('default'); + expect(await getVaultSecret('default')).toBeUndefined(); + expect(existsSync(secretFile('default'))).toBe(false); + }); + + it('is a no-op when the backend is disabled', async () => { + process.env.GITD_SECRET_BACKEND = 'none'; + await setVaultSecret('default', 'hunter2'); + expect(await getVaultSecret('default')).toBeUndefined(); + }); +}); diff --git a/tests/vault-password.spec.ts b/tests/vault-password.spec.ts new file mode 100644 index 0000000..d6040ed --- /dev/null +++ b/tests/vault-password.spec.ts @@ -0,0 +1,111 @@ +/** + * Vault-password resolution + durable caching tests. + * + * Pins the encrypted-file secret backend and an isolated `ENBOX_HOME`. + * Prompting is disabled (`allowPrompt: false`) so resolution is deterministic. + */ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync } from 'node:fs'; + +import { forgetVaultSecret, rememberVaultSecret, resolveVaultPassword } from '../src/auth/vault-password.js'; +import { getVaultSecret, setVaultSecret } from '../src/auth/secret-store.js'; + +let tempDir: string; +let originalEnboxHome: string | undefined; +let originalBackend: string | undefined; +let originalPassword: string | undefined; + +describe('vault password resolution', () => { + beforeEach(() => { + originalEnboxHome = process.env.ENBOX_HOME; + originalBackend = process.env.GITD_SECRET_BACKEND; + originalPassword = process.env.GITD_PASSWORD; + tempDir = mkdtempSync(join(tmpdir(), 'gitd-vault-password-')); + process.env.ENBOX_HOME = join(tempDir, 'enbox'); + process.env.GITD_SECRET_BACKEND = 'file'; + delete process.env.GITD_PASSWORD; + }); + + afterEach(() => { + if (originalEnboxHome) { process.env.ENBOX_HOME = originalEnboxHome; } else { delete process.env.ENBOX_HOME; } + if (originalBackend) { process.env.GITD_SECRET_BACKEND = originalBackend; } else { delete process.env.GITD_SECRET_BACKEND; } + if (originalPassword) { process.env.GITD_PASSWORD = originalPassword; } else { delete process.env.GITD_PASSWORD; } + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('prefers an explicit password', async () => { + const resolved = await resolveVaultPassword({ profileName: 'default', explicit: 'explicit-pw', env: {} }); + expect(resolved).toEqual({ password: 'explicit-pw', source: 'explicit' }); + }); + + it('falls back to GITD_PASSWORD from the environment', async () => { + const resolved = await resolveVaultPassword({ profileName: 'default', env: { GITD_PASSWORD: 'env-pw' } }); + expect(resolved).toEqual({ password: 'env-pw', source: 'env' }); + }); + + it('explicit beats the environment', async () => { + const resolved = await resolveVaultPassword({ + profileName : 'default', + explicit : 'explicit-pw', + env : { GITD_PASSWORD: 'env-pw' }, + }); + expect(resolved.source).toBe('explicit'); + }); + + it('reads a cached secret from the durable store', async () => { + await setVaultSecret('default', 'cached-pw'); + const resolved = await resolveVaultPassword({ profileName: 'default', env: {}, allowPrompt: false }); + expect(resolved).toEqual({ password: 'cached-pw', source: 'keychain' }); + }); + + it('throws when nothing is available and prompting is disabled', async () => { + await expect( + resolveVaultPassword({ profileName: 'default', env: {}, allowPrompt: false }), + ).rejects.toThrow(); + }); +}); + +describe('vault secret caching', () => { + beforeEach(() => { + originalEnboxHome = process.env.ENBOX_HOME; + originalBackend = process.env.GITD_SECRET_BACKEND; + tempDir = mkdtempSync(join(tmpdir(), 'gitd-vault-cache-')); + process.env.ENBOX_HOME = join(tempDir, 'enbox'); + process.env.GITD_SECRET_BACKEND = 'file'; + }); + + afterEach(() => { + if (originalEnboxHome) { process.env.ENBOX_HOME = originalEnboxHome; } else { delete process.env.ENBOX_HOME; } + if (originalBackend) { process.env.GITD_SECRET_BACKEND = originalBackend; } else { delete process.env.GITD_SECRET_BACKEND; } + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('caches a freshly entered (tty) secret', async () => { + await rememberVaultSecret('default', { password: 'typed-pw', source: 'tty' }); + expect(await getVaultSecret('default')).toBe('typed-pw'); + }); + + it('caches a first-run (explicit) secret', async () => { + await rememberVaultSecret('default', { password: 'wizard-pw', source: 'explicit' }); + expect(await getVaultSecret('default')).toBe('wizard-pw'); + }); + + it('does not cache an environment secret', async () => { + await rememberVaultSecret('default', { password: 'env-pw', source: 'env' }); + expect(await getVaultSecret('default')).toBeUndefined(); + }); + + it('does not re-cache an already-durable secret', async () => { + await rememberVaultSecret('default', { password: 'kc-pw', source: 'keychain' }); + expect(await getVaultSecret('default')).toBeUndefined(); + }); + + it('forgets a cached secret', async () => { + await rememberVaultSecret('default', { password: 'typed-pw', source: 'tty' }); + await forgetVaultSecret('default'); + expect(await getVaultSecret('default')).toBeUndefined(); + }); +});