Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions packages/cli/src/cloud-vault.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const promptsMock = vi.fn();
vi.mock('prompts', () => ({
default: (...args: unknown[]) => promptsMock(...args),
}));

import { resolveVaultPassphrase } from './cloud-vault.js';

// Regression guard for cloud-vault passphrase handling on non-interactive stdin.
//
// `prompts` reads keystrokes from stdin. When stdin is not a TTY (CI, `< /dev/null`,
// a piped script) it never receives input that resolves or cancels the prompt, and
// once nothing else is keeping the event loop alive Node exits on its own — abandoning
// the still-pending `await prompts(...)` before the "no passphrase entered" guard (added
// for the cancelled-prompt case) ever runs. That left non-interactive cloud-vault access
// (encrypt on `secret set --cloud`, decrypt on `secret get --cloud`) hanging indefinitely
// instead of failing with a message. `resolveVaultPassphrase` must now fail fast without a
// TTY, and `SH1PT_VAULT_PASSPHRASE` must be able to skip the prompt entirely.
describe('resolveVaultPassphrase', () => {
let originalIsTTY: boolean | undefined;

beforeEach(() => {
promptsMock.mockReset();
originalIsTTY = process.stdin.isTTY;
delete process.env.SH1PT_VAULT_PASSPHRASE;
});

afterEach(() => {
process.stdin.isTTY = originalIsTTY;
delete process.env.SH1PT_VAULT_PASSPHRASE;
vi.restoreAllMocks();
});

it('throws instead of hanging when stdin is not a TTY and SH1PT_VAULT_PASSPHRASE is unset', async () => {
process.stdin.isTTY = undefined;

await expect(resolveVaultPassphrase(false)).rejects.toThrow(/no TTY/i);
expect(promptsMock).not.toHaveBeenCalled();
});

it('returns SH1PT_VAULT_PASSPHRASE without prompting, even without a TTY', async () => {
process.stdin.isTTY = undefined;
process.env.SH1PT_VAULT_PASSPHRASE = 'correct horse battery staple';

const passphrase = await resolveVaultPassphrase(false);

expect(passphrase).toBe('correct horse battery staple');
expect(promptsMock).not.toHaveBeenCalled();
});

it('rejects a too-short SH1PT_VAULT_PASSPHRASE the same way the interactive prompt validation would', async () => {
process.stdin.isTTY = undefined;
process.env.SH1PT_VAULT_PASSPHRASE = 'short';

await expect(resolveVaultPassphrase(false)).rejects.toThrow(/at least 8 characters/i);
});

it('still prompts interactively when stdin is a TTY and no env passphrase is set', async () => {
process.stdin.isTTY = true;
promptsMock.mockResolvedValue({ p: 'correct horse battery staple' });

const passphrase = await resolveVaultPassphrase(true);

expect(promptsMock).toHaveBeenCalledTimes(1);
expect(passphrase).toBe('correct horse battery staple');
});

it('throws when the interactive prompt is cancelled', async () => {
process.stdin.isTTY = true;
promptsMock.mockResolvedValue({});

await expect(resolveVaultPassphrase(true)).rejects.toThrow(/no passphrase entered/i);
});
});
53 changes: 41 additions & 12 deletions packages/cli/src/cloud-vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,45 @@ async function getOrInitKdf(): Promise<{ salt: Uint8Array; ops: number; mem: num
throw new Error(`vault/keys failed: ${get.res.status} ${detail}`);
}

// Resolves the vault passphrase without touching sodium or the network, so it can be
// unit-tested on its own. Order: SH1PT_VAULT_PASSPHRASE env var, then an interactive
// prompt — but only when stdin is a TTY.
//
// `prompts` reads keystrokes from stdin. When stdin is not a TTY (CI, `< /dev/null`,
// a piped script) it never receives input that resolves or cancels the prompt, and
// once nothing else is keeping the event loop alive Node exits on its own — abandoning
// the still-pending `await prompts(...)` before the "no passphrase entered" guard below
// (added for the cancelled-prompt case) ever runs. That left non-interactive cloud vault
// access (encrypt on write, decrypt on read) hanging indefinitely instead of failing with
// a message. Fail fast instead; SH1PT_VAULT_PASSPHRASE is the non-interactive escape
// hatch (an env var, not a flag, so the passphrase never lands in shell history or `ps`).
export async function resolveVaultPassphrase(isFirstRun: boolean): Promise<string> {
let passphrase: string | undefined = process.env.SH1PT_VAULT_PASSPHRASE;
if (!passphrase) {
if (!process.stdin.isTTY) {
throw new Error(
'No vault passphrase available — no TTY to prompt for one (CI or a piped script?). Set SH1PT_VAULT_PASSPHRASE to run non-interactively.',
);
}
const answer = await prompts({
type: 'password',
name: 'p',
message: isFirstRun
? 'Set a vault passphrase (will be required to read your secrets — no recovery if lost):'
: 'Vault passphrase:',
validate: (v: string) => (v && v.length >= 8) || 'Passphrase must be at least 8 characters.',
});
passphrase = answer.p;
}
if (!passphrase) {
throw new Error('No passphrase entered.');
}
if (passphrase.length < 8) {
throw new Error('Passphrase must be at least 8 characters.');
}
return passphrase;
}

// Derive (and cache) the XSalsa20 key. Prompts for a passphrase on
// first call; subsequent calls in the same process reuse the derived
// key in memory.
Expand All @@ -87,22 +126,12 @@ export async function getVaultKey(): Promise<Uint8Array> {
const kdf = await getOrInitKdf();

const isFirstRun = await isFirstRunForUser();
const passphrase = await prompts({
type: 'password',
name: 'p',
message: isFirstRun
? 'Set a vault passphrase (will be required to read your secrets — no recovery if lost):'
: 'Vault passphrase:',
validate: (v: string) => (v && v.length >= 8) || 'Passphrase must be at least 8 characters.',
});
if (!passphrase.p) {
throw new Error('No passphrase entered.');
}
const passphrase = await resolveVaultPassphrase(isFirstRun);

console.log(kleur.dim(' deriving key (Argon2id)…'));
const key = sodium.crypto_pwhash(
sodium.crypto_secretbox_KEYBYTES,
passphrase.p,
passphrase,
kdf.salt,
kdf.ops,
kdf.mem,
Expand Down
Loading