diff --git a/.changeset/auth-daemon-independent-push.md b/.changeset/auth-daemon-independent-push.md new file mode 100644 index 0000000..fc9c328 --- /dev/null +++ b/.changeset/auth-daemon-independent-push.md @@ -0,0 +1,16 @@ +--- +'@enbox/gitd': minor +--- + +feat: git push/fetch work without a running helper + +The git credential helper's fallback and the daemon auto-start now resolve the +vault password from the durable secret store (OS keychain / encrypted file) that +was set on the first unlock — in addition to `GITD_PASSWORD` and the `/dev/tty` +prompt. As a result `git push` and `git fetch` succeed even when no local helper +is running and without an interactive prompt: the credential helper signs a +fresh DID push token directly, so a dead or never-started helper no longer +blocks pushes. + +Daemon lockfiles are now written atomically (temp file + rename) so a crash +mid-write can't leave a corrupt lockfile behind. 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/commands/serve-lifecycle.ts b/src/cli/commands/serve-lifecycle.ts index 0a34dc8..5ea4132 100644 --- a/src/cli/commands/serve-lifecycle.ts +++ b/src/cli/commands/serve-lifecycle.ts @@ -161,7 +161,7 @@ async function helperStartCredentials( const setup = await maybePromptForFirstIdentitySetup(surface, args, flagValue(args, '--profile')); if (!setup) { return { - password: getVaultPassword() ?? undefined, + password: (await getVaultPassword()) ?? undefined, profileName, }; } 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/src/daemon/lockfile.ts b/src/daemon/lockfile.ts index 2382d52..def24e5 100644 --- a/src/daemon/lockfile.ts +++ b/src/daemon/lockfile.ts @@ -14,7 +14,7 @@ */ import { dirname, join } from 'node:path'; -import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; import { enboxHome, profilesDir } from '../profiles/config.js'; @@ -216,7 +216,17 @@ export function writeLockfile( }; const path = lockfilePath(options.profileName); mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, JSON.stringify(lock, null, 2) + '\n', { mode: 0o644 }); + atomicWriteFileSync(path, JSON.stringify(lock, null, 2) + '\n', 0o644); +} + +/** + * Write a file atomically: write a temp sibling then rename over the target, + * so a crash mid-write can never leave a half-written (corrupt) lockfile. + */ +function atomicWriteFileSync(path: string, data: string, mode: number): void { + const tmp = `${path}.tmp.${process.pid}`; + writeFileSync(tmp, data, { mode }); + renameSync(tmp, path); } /** Record a repo context on an already-running helper lockfile. */ @@ -237,10 +247,10 @@ export function recordLockfileRepoContext( ].slice(0, MAX_HELPER_REPO_CONTEXTS); const path = lockfilePath(profileName); - writeFileSync(path, JSON.stringify({ + atomicWriteFileSync(path, JSON.stringify({ ...lock, repoContexts, - }, null, 2) + '\n', { mode: 0o644 }); + }, null, 2) + '\n', 0o644); return true; } diff --git a/src/git-remote/credential-main.ts b/src/git-remote/credential-main.ts index 36df4ea..23d2a6b 100644 --- a/src/git-remote/credential-main.ts +++ b/src/git-remote/credential-main.ts @@ -17,8 +17,9 @@ * * The preferred path (daemon running) requires NO password — the daemon * already holds the agent lock and signs on our behalf. The fallback path - * (no daemon) still requires a vault password via `GITD_PASSWORD` or an - * interactive `/dev/tty` prompt. + * (no daemon) signs directly, getting the vault password from the durable + * secret store (cached on first unlock), `GITD_PASSWORD`, or a `/dev/tty` + * prompt — so `git push`/`fetch` keep working even when no helper is running. * * Install in .gitconfig: * [credential] @@ -144,7 +145,7 @@ async function handleGet(request: { protocol?: string; host?: string; path?: str } // --- Fallback: direct agent connection (no daemon running) --- - const password = getVaultPassword(); + const password = await getVaultPassword(); if (!password) { console.error('git-remote-did-credential: no running daemon and no identity password available.'); console.error('Hint: run `gitd helper start` in another terminal, or set GITD_PASSWORD and retry.'); diff --git a/src/git-remote/resolve.ts b/src/git-remote/resolve.ts index cb665d4..475eca2 100644 --- a/src/git-remote/resolve.ts +++ b/src/git-remote/resolve.ts @@ -221,7 +221,7 @@ async function resolveLocalDaemon( // a daemon without a password will always fail (vault can't unlock). const password = profileSelection.implicitPublicReader ? getOrCreatePublicReaderPassword().password - : getVaultPassword() ?? undefined; + : (await getVaultPassword()) ?? undefined; if (!password) { if (!hasRecentDeferredDaemonStart(profileName)) { return null; } return waitForReachableLocalDaemon(did, repo, mode, profileName); diff --git a/src/git-remote/tty-prompt.ts b/src/git-remote/tty-prompt.ts index 93fc718..5729130 100644 --- a/src/git-remote/tty-prompt.ts +++ b/src/git-remote/tty-prompt.ts @@ -17,7 +17,9 @@ import { execSync } from 'node:child_process'; import { closeSync, openSync, readSync, writeSync } from 'node:fs'; +import { getVaultSecret } from '../auth/secret-store.js'; import { readPublicReaderPasswordForActiveProfile } from '../profiles/public-reader.js'; +import { resolveProfile } from '../profiles/config.js'; // --------------------------------------------------------------------------- // Public API @@ -29,18 +31,32 @@ import { readPublicReaderPasswordForActiveProfile } from '../profiles/public-rea * Resolution order: * 1. `GITD_PASSWORD` environment variable (non-interactive) * 2. Hidden public-read profile password - * 3. Interactive prompt via `/dev/tty` (hidden input) - * 4. `null` if no TTY is available + * 3. Durable secret store (OS keychain / encrypted file) for the profile + * 4. Interactive prompt via `/dev/tty` (hidden input) + * 5. `null` if no source is available and no TTY can be opened * + * Step 3 is what lets `git push`/`fetch` and the daemon auto-start succeed + * without a prompt when no helper is already running — the secret is cached + * on the first unlock (see `../auth/secret-store.js`). + * + * @param profileName - Profile to resolve the secret for. Defaults to the + * active profile. * @returns The password string, or `null` if unavailable. */ -export function getVaultPassword(): string | null { +export async function getVaultPassword( + profileName: string | undefined = resolveProfile() ?? undefined, +): Promise { const env = process.env.GITD_PASSWORD; if (env) { return env; } - const publicReaderPassword = readPublicReaderPasswordForActiveProfile(); + const publicReaderPassword = readPublicReaderPasswordForActiveProfile(profileName); if (publicReaderPassword) { return publicReaderPassword; } + if (profileName) { + const cached = await getVaultSecret(profileName); + if (cached) { return cached; } + } + return promptTtyPassword('Identity password: '); } 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/tty-prompt.spec.ts b/tests/tty-prompt.spec.ts index d12b220..4ee280a 100644 --- a/tests/tty-prompt.spec.ts +++ b/tests/tty-prompt.spec.ts @@ -1,12 +1,13 @@ /** - * Tests for the TTY password prompt utility used by git helpers. + * Tests for the TTY password resolver used by git helpers. * - * These tests verify that `getVaultPassword()` correctly prefers the - * `GITD_PASSWORD` env var, falls back to `/dev/tty` prompting, and - * returns `null` when no password source is available. + * `getVaultPassword()` prefers `GITD_PASSWORD`, then the hidden public-read + * profile secret, then the durable secret store, then a `/dev/tty` prompt. * - * Actual TTY interaction cannot be tested in CI (no controlling terminal), - * so those paths are tested only for graceful fallback to `null`. + * Actual TTY interaction cannot be tested (no controlling terminal), so those + * paths are only exercised for graceful fallback to `null`. The durable-store + * path is pinned to the encrypted-file backend so the real OS keychain is + * never touched. */ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; @@ -15,20 +16,18 @@ import { tmpdir } from 'node:os'; import { mkdtempSync, rmSync } from 'node:fs'; import { getVaultPassword } from '../src/git-remote/tty-prompt.js'; +import { setVaultSecret } from '../src/auth/secret-store.js'; import { getOrCreatePublicReaderPassword, PUBLIC_READER_PROFILE, } from '../src/profiles/public-reader.js'; -// --------------------------------------------------------------------------- -// getVaultPassword -// --------------------------------------------------------------------------- - describe('getVaultPassword', () => { let origPassword: string | undefined; let origProfile: string | undefined; let origEnboxProfile: string | undefined; let origEnboxHome: string | undefined; + let origBackend: string | undefined; let tempHome: string | undefined; beforeEach(() => { @@ -36,73 +35,70 @@ describe('getVaultPassword', () => { origProfile = process.env.GITD_PROFILE; origEnboxProfile = process.env.ENBOX_PROFILE; origEnboxHome = process.env.ENBOX_HOME; + origBackend = process.env.GITD_SECRET_BACKEND; delete process.env.GITD_PROFILE; delete process.env.ENBOX_PROFILE; }); afterEach(() => { - if (origPassword !== undefined) { - process.env.GITD_PASSWORD = origPassword; - } else { - delete process.env.GITD_PASSWORD; - } - if (origProfile !== undefined) { - process.env.GITD_PROFILE = origProfile; - } else { - delete process.env.GITD_PROFILE; - } - if (origEnboxProfile !== undefined) { - process.env.ENBOX_PROFILE = origEnboxProfile; - } else { - delete process.env.ENBOX_PROFILE; - } - if (origEnboxHome !== undefined) { - process.env.ENBOX_HOME = origEnboxHome; - } else { - delete process.env.ENBOX_HOME; - } + restoreEnv('GITD_PASSWORD', origPassword); + restoreEnv('GITD_PROFILE', origProfile); + restoreEnv('ENBOX_PROFILE', origEnboxProfile); + restoreEnv('ENBOX_HOME', origEnboxHome); + restoreEnv('GITD_SECRET_BACKEND', origBackend); if (tempHome) { rmSync(tempHome, { recursive: true, force: true }); tempHome = undefined; } }); - it('should return GITD_PASSWORD when set', () => { + function restoreEnv(key: string, value: string | undefined): void { + if (value !== undefined) { process.env[key] = value; } else { delete process.env[key]; } + } + + it('returns GITD_PASSWORD when set', async () => { process.env.GITD_PASSWORD = 'test-secret'; - expect(getVaultPassword()).toBe('test-secret'); + expect(await getVaultPassword()).toBe('test-secret'); }); - it('should return the hidden public reader password for public-read repos', () => { + it('returns the hidden public reader password for public-read repos', async () => { delete process.env.GITD_PASSWORD; tempHome = mkdtempSync(join(tmpdir(), 'gitd-tty-public-reader-')); process.env.ENBOX_HOME = tempHome; process.env.GITD_PROFILE = PUBLIC_READER_PROFILE; const reader = getOrCreatePublicReaderPassword(); - expect(getVaultPassword()).toBe(reader.password); + expect(await getVaultPassword()).toBe(reader.password); }); - it('should return GITD_PASSWORD even when empty string', () => { - // An empty string is falsy but still "set" — the user explicitly - // provided it, so we should respect it (e.g. unlocked vaults). - process.env.GITD_PASSWORD = ''; - // Empty string is falsy, so getVaultPassword will try /dev/tty next. - // This is actually correct behavior — an empty password is nonsensical. - const result = getVaultPassword(); - // In CI without /dev/tty, should fall back to null. - expect(result === '' || result === null).toBe(true); + it('returns the durable secret for the profile when env is unset', async () => { + delete process.env.GITD_PASSWORD; + tempHome = mkdtempSync(join(tmpdir(), 'gitd-tty-keychain-')); + process.env.ENBOX_HOME = tempHome; + process.env.GITD_SECRET_BACKEND = 'file'; + + await setVaultSecret('work', 'stored-pw'); + expect(await getVaultPassword('work')).toBe('stored-pw'); + }); + + it('prefers GITD_PASSWORD over the durable secret', async () => { + tempHome = mkdtempSync(join(tmpdir(), 'gitd-tty-precedence-')); + process.env.ENBOX_HOME = tempHome; + process.env.GITD_SECRET_BACKEND = 'file'; + process.env.GITD_PASSWORD = 'env-pw'; + + await setVaultSecret('work', 'stored-pw'); + expect(await getVaultPassword('work')).toBe('env-pw'); }); - it('should return null or string when GITD_PASSWORD is not set (no TTY in CI)', () => { + it('falls back gracefully when no source is available (no TTY in CI)', async () => { delete process.env.GITD_PASSWORD; - const result = getVaultPassword(); - // In CI there's no controlling terminal, so /dev/tty open will fail - // and we should get null. In a real terminal we'd get prompted. + const result = await getVaultPassword(); expect(result === null || typeof result === 'string').toBe(true); }); - it('should not throw when GITD_PASSWORD is not set', () => { + it('does not throw when no password source is available', async () => { delete process.env.GITD_PASSWORD; - expect(() => getVaultPassword()).not.toThrow(); + await getVaultPassword(); }); }); 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(); + }); +});