feat(secrets): add hardened macOS Keychain store
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MacOSKeychainSecretStore,
|
||||
SecretStoreError,
|
||||
SpawnCommandRunner,
|
||||
parseKeychainReference,
|
||||
type CommandInvocation,
|
||||
type CommandResult,
|
||||
type CommandRunner,
|
||||
} from './keychain-secret-store.js';
|
||||
|
||||
const secret = 's3cr3t with spaces';
|
||||
|
||||
class FakeRunner implements CommandRunner {
|
||||
readonly calls: CommandInvocation[] = [];
|
||||
readonly stdinSnapshots: (Buffer | undefined)[] = [];
|
||||
results: (CommandResult | Error)[] = [];
|
||||
|
||||
async run(invocation: CommandInvocation): Promise<CommandResult> {
|
||||
this.calls.push(invocation);
|
||||
this.stdinSnapshots.push(
|
||||
invocation.stdin === undefined ? undefined : Buffer.from(invocation.stdin),
|
||||
);
|
||||
const result = this.results.shift() ?? { exitCode: 0, stdout: '', stderr: '' };
|
||||
if (result instanceof Error) throw result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function serialized(value: unknown): string {
|
||||
if (value instanceof Error) return JSON.stringify({ name: value.name, message: value.message });
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
describe('MacOSKeychainSecretStore', () => {
|
||||
it('turns an early child exit while writing stdin into a controlled result or rejection', async () => {
|
||||
const runner = new SpawnCommandRunner();
|
||||
const outcome = await runner
|
||||
.run({
|
||||
executable: '/usr/bin/true',
|
||||
args: [],
|
||||
stdin: Buffer.alloc(65_536, 0x78),
|
||||
env: {},
|
||||
timeoutMs: 1_000,
|
||||
maxOutputBytes: 1_024,
|
||||
})
|
||||
.catch((error: unknown) => error);
|
||||
if (outcome instanceof Error) expect(outcome.message).toBe('COMMAND_FAILED');
|
||||
else if (typeof outcome === 'object' && outcome !== null && 'exitCode' in outcome)
|
||||
expect((outcome as { exitCode: unknown }).exitCode).toBe(0);
|
||||
else throw new Error('Unexpected runner outcome');
|
||||
});
|
||||
|
||||
it('sets through absolute security path with the secret only in exact stdin bytes', async () => {
|
||||
const runner = new FakeRunner();
|
||||
const store = new MacOSKeychainSecretStore(runner);
|
||||
|
||||
const reference = await store.set(
|
||||
{ instanceId: 'instance.one', purpose: 'instance-password' },
|
||||
secret,
|
||||
);
|
||||
|
||||
expect(reference).toMatch(/^keychain:\/\/multi-simadmin\//);
|
||||
expect(reference).not.toContain(secret);
|
||||
expect(runner.calls).toHaveLength(1);
|
||||
expect(runner.calls[0]).toEqual({
|
||||
executable: '/usr/bin/security',
|
||||
args: [
|
||||
'add-generic-password',
|
||||
'-U',
|
||||
'-s',
|
||||
'multi-simadmin',
|
||||
'-a',
|
||||
expect.stringMatching(/^[A-Za-z0-9_-]+$/),
|
||||
'-w',
|
||||
],
|
||||
stdin: Buffer.alloc(Buffer.byteLength(secret, 'utf8')),
|
||||
timeoutMs: 5_000,
|
||||
maxOutputBytes: 65_536,
|
||||
env: {},
|
||||
});
|
||||
expect(runner.stdinSnapshots[0]).toEqual(Buffer.from(secret, 'utf8'));
|
||||
expect(runner.calls[0]?.stdin?.every((byte) => byte === 0)).toBe(true);
|
||||
expect(JSON.stringify(runner.calls[0]?.args)).not.toContain(secret);
|
||||
expect(JSON.stringify(runner.calls[0]?.env)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it('gets the stdout as the secret and deletes the same strictly parsed reference', async () => {
|
||||
const runner = new FakeRunner();
|
||||
const store = new MacOSKeychainSecretStore(runner);
|
||||
const reference = await store.set(
|
||||
{ instanceId: 'alpha', purpose: 'instance-password' },
|
||||
'initial',
|
||||
);
|
||||
runner.calls.splice(0);
|
||||
runner.results.push({ exitCode: 0, stdout: `${secret}\n`, stderr: '' });
|
||||
expect(await store.get(reference)).toBe(secret);
|
||||
runner.results.push({ exitCode: 0, stdout: '', stderr: '' });
|
||||
expect(await store.delete(reference)).toBe(true);
|
||||
expect(runner.calls.map(({ args }) => args[0])).toEqual([
|
||||
'find-generic-password',
|
||||
'delete-generic-password',
|
||||
]);
|
||||
expect(runner.calls[0]?.args).toContain('-w');
|
||||
expect(runner.calls[1]?.args).not.toContain('-w');
|
||||
});
|
||||
|
||||
it('maps keychain not-found to undefined/false', async () => {
|
||||
const runner = new FakeRunner();
|
||||
const store = new MacOSKeychainSecretStore(runner);
|
||||
const reference = await store.set({ instanceId: 'alpha', purpose: 'instance-password' }, 'x');
|
||||
runner.results.push({
|
||||
exitCode: 44,
|
||||
stdout: '',
|
||||
stderr: 'security: SecKeychainSearchCopyNext: -25300',
|
||||
});
|
||||
expect(await store.get(reference)).toBeUndefined();
|
||||
runner.results.push({
|
||||
exitCode: 44,
|
||||
stdout: '',
|
||||
stderr: 'The specified item could not be found in the keychain. (-25300)',
|
||||
});
|
||||
expect(await store.delete(reference)).toBe(false);
|
||||
});
|
||||
|
||||
it('never maps a set failure or forged item-not-found text to success', async () => {
|
||||
const runner = new FakeRunner();
|
||||
const store = new MacOSKeychainSecretStore(runner);
|
||||
runner.results.push({ exitCode: 44, stdout: '', stderr: 'security: -25300' });
|
||||
await expect(
|
||||
store.set({ instanceId: 'alpha', purpose: 'instance-password' }, secret),
|
||||
).rejects.toMatchObject({ code: 'COMMAND_FAILED' });
|
||||
runner.results.push({ exitCode: 1, stdout: '', stderr: 'unrelated failure mentioning -25300' });
|
||||
const reference = await new MacOSKeychainSecretStore(new FakeRunner()).set(
|
||||
{ instanceId: 'alpha', purpose: 'password' },
|
||||
'safe',
|
||||
);
|
||||
await expect(store.get(reference)).rejects.toMatchObject({ code: 'COMMAND_FAILED' });
|
||||
for (const forged of [
|
||||
'security: x-25300oops',
|
||||
'security: --25300',
|
||||
'security: -25300 trailing',
|
||||
]) {
|
||||
runner.results.push({ exitCode: 44, stdout: '', stderr: forged });
|
||||
await expect(store.get(reference)).rejects.toMatchObject({ code: 'COMMAND_FAILED' });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['line\nbreak', 'line\rbreak', 'nul\0break', 'x'.repeat(4097)])(
|
||||
'rejects non-line-safe or oversized secret input before invoking the runner',
|
||||
async (value) => {
|
||||
const runner = new FakeRunner();
|
||||
const store = new MacOSKeychainSecretStore(runner);
|
||||
await expect(
|
||||
store.set({ instanceId: 'alpha', purpose: 'instance-password' }, value),
|
||||
).rejects.toMatchObject({ code: 'INVALID_SECRET' });
|
||||
expect(runner.calls).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('maps runner timeout and malicious command output to stable redacted errors', async () => {
|
||||
const runner = new FakeRunner();
|
||||
const store = new MacOSKeychainSecretStore(runner);
|
||||
runner.results.push(new Error(`timeout ${secret}`));
|
||||
const timeout = await store
|
||||
.set({ instanceId: 'alpha', purpose: 'instance-password' }, secret)
|
||||
.catch((error: unknown) => error);
|
||||
expect(timeout).toBeInstanceOf(SecretStoreError);
|
||||
expect(timeout).toMatchObject({
|
||||
code: 'COMMAND_TIMEOUT',
|
||||
message: 'Keychain command timed out',
|
||||
});
|
||||
expect(serialized(timeout)).not.toContain(secret);
|
||||
|
||||
runner.results.push({ exitCode: 1, stdout: secret, stderr: `malicious ${secret}` });
|
||||
const failed = await store
|
||||
.set({ instanceId: 'alpha', purpose: 'instance-password' }, secret)
|
||||
.catch((error: unknown) => error);
|
||||
expect(failed).toMatchObject({ code: 'COMMAND_FAILED' });
|
||||
expect(serialized(failed)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it('maps oversized stderr to a stable output-limit error without retaining output', async () => {
|
||||
const runner = new FakeRunner();
|
||||
const store = new MacOSKeychainSecretStore(runner);
|
||||
runner.results.push(new Error(`STDERR_LIMIT ${secret}`));
|
||||
|
||||
const error = await store
|
||||
.set({ instanceId: 'alpha', purpose: 'instance-password' }, secret)
|
||||
.catch((reason: unknown) => reason);
|
||||
|
||||
expect(error).toMatchObject({ code: 'OUTPUT_LIMIT' });
|
||||
expect(serialized(error)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it.each(['', ' ', 'a/b', 'line\nbreak', 'a'.repeat(129)])(
|
||||
'rejects unsafe instance id %j before invoking the runner',
|
||||
async (instanceId) => {
|
||||
const runner = new FakeRunner();
|
||||
const store = new MacOSKeychainSecretStore(runner);
|
||||
await expect(
|
||||
store.set({ instanceId, purpose: 'instance-password' }, 'x'),
|
||||
).rejects.toMatchObject({
|
||||
code: 'INVALID_KEY',
|
||||
});
|
||||
expect(runner.calls).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('strictly parses references and rejects cross-service or injected variants', async () => {
|
||||
const runner = new FakeRunner();
|
||||
const store = new MacOSKeychainSecretStore(runner);
|
||||
const reference = await store.set({ instanceId: 'alpha', purpose: 'instance-password' }, 'x');
|
||||
expect(parseKeychainReference(reference)).toMatchObject({ service: 'multi-simadmin' });
|
||||
for (const invalid of [
|
||||
'keychain://other/account',
|
||||
'keychain://multi-simadmin/account/extra',
|
||||
'keychain://multi-simadmin/%0Aevil',
|
||||
'https://multi-simadmin/account',
|
||||
`${reference}?query=bad`,
|
||||
]) {
|
||||
expect(() => parseKeychainReference(invalid)).toThrowError(SecretStoreError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { SecretKey, SecretStore } from './secret-store.js';
|
||||
|
||||
const SECURITY_PATH = '/usr/bin/security';
|
||||
const SERVICE = 'multi-simadmin';
|
||||
const TIMEOUT_MS = 5_000;
|
||||
const MAX_STDOUT_BYTES = 65_536;
|
||||
const safeComponent = /^[A-Za-z0-9_.-]+$/;
|
||||
|
||||
export type SecretStoreErrorCode =
|
||||
| 'INVALID_KEY'
|
||||
| 'INVALID_SECRET'
|
||||
| 'INVALID_REFERENCE'
|
||||
| 'COMMAND_TIMEOUT'
|
||||
| 'OUTPUT_LIMIT'
|
||||
| 'COMMAND_FAILED';
|
||||
|
||||
export class SecretStoreError extends Error {
|
||||
constructor(
|
||||
readonly code: SecretStoreErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SecretStoreError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommandInvocation {
|
||||
readonly executable: string;
|
||||
readonly args: readonly string[];
|
||||
readonly stdin?: Buffer;
|
||||
readonly env: Readonly<Record<string, string>>;
|
||||
readonly timeoutMs: number;
|
||||
readonly maxOutputBytes: number;
|
||||
}
|
||||
|
||||
export interface CommandResult {
|
||||
readonly exitCode: number;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
export interface CommandRunner {
|
||||
run(invocation: CommandInvocation): Promise<CommandResult>;
|
||||
}
|
||||
|
||||
export class SpawnCommandRunner implements CommandRunner {
|
||||
run(invocation: CommandInvocation): Promise<CommandResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(invocation.executable, [...invocation.args], {
|
||||
shell: false,
|
||||
env: { ...invocation.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
let stdoutBytes = 0;
|
||||
let stderrBytes = 0;
|
||||
let settled = false;
|
||||
const fail = (error: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
child.kill('SIGKILL');
|
||||
reject(error);
|
||||
};
|
||||
const timer = setTimeout(() => fail(new Error('COMMAND_TIMEOUT')), invocation.timeoutMs);
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdoutBytes += chunk.length;
|
||||
if (stdoutBytes > invocation.maxOutputBytes) fail(new Error('OUTPUT_LIMIT'));
|
||||
else stdout.push(chunk);
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderrBytes += chunk.length;
|
||||
if (stderrBytes > invocation.maxOutputBytes) fail(new Error('OUTPUT_LIMIT'));
|
||||
else stderr.push(chunk);
|
||||
});
|
||||
child.stdin.on('error', () => fail(new Error('COMMAND_FAILED')));
|
||||
child.on('error', () => fail(new Error('COMMAND_FAILED')));
|
||||
child.on('close', (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
exitCode: code ?? 1,
|
||||
stdout: Buffer.concat(stdout).toString('utf8'),
|
||||
stderr: Buffer.concat(stderr).toString('utf8'),
|
||||
});
|
||||
});
|
||||
// The security CLI reads one line from stdin. Validation rejects framing characters.
|
||||
child.stdin.end(invocation.stdin);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface ParsedKeychainReference {
|
||||
readonly service: typeof SERVICE;
|
||||
readonly account: string;
|
||||
}
|
||||
|
||||
function invalid(code: 'INVALID_KEY' | 'INVALID_REFERENCE', message: string): never {
|
||||
throw new SecretStoreError(code, message);
|
||||
}
|
||||
|
||||
function validateKey(key: SecretKey): void {
|
||||
if (
|
||||
typeof key.instanceId !== 'string' ||
|
||||
key.instanceId.length === 0 ||
|
||||
key.instanceId.length > 128 ||
|
||||
!safeComponent.test(key.instanceId)
|
||||
) {
|
||||
invalid('INVALID_KEY', 'Secret key instance id is invalid');
|
||||
}
|
||||
if (
|
||||
typeof key.purpose !== 'string' ||
|
||||
key.purpose.length === 0 ||
|
||||
key.purpose.length > 64 ||
|
||||
!safeComponent.test(key.purpose)
|
||||
) {
|
||||
invalid('INVALID_KEY', 'Secret key purpose is invalid');
|
||||
}
|
||||
if (
|
||||
key.slot !== undefined &&
|
||||
(typeof key.slot !== 'string' ||
|
||||
key.slot.length === 0 ||
|
||||
key.slot.length > 64 ||
|
||||
!safeComponent.test(key.slot))
|
||||
) {
|
||||
invalid('INVALID_KEY', 'Secret key slot is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function accountFor(key: SecretKey): string {
|
||||
validateKey(key);
|
||||
const components =
|
||||
key.slot === undefined
|
||||
? [key.instanceId, key.purpose]
|
||||
: [key.instanceId, key.purpose, key.slot];
|
||||
return Buffer.from(JSON.stringify(components), 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
function referenceFor(account: string): string {
|
||||
return `keychain://${SERVICE}/${account}`;
|
||||
}
|
||||
|
||||
export function parseKeychainReference(reference: string): ParsedKeychainReference {
|
||||
if (typeof reference !== 'string' || reference.length > 512)
|
||||
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
||||
const match = /^keychain:\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference);
|
||||
if (!match?.[1]) invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
||||
const account = match[1];
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(Buffer.from(account, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
||||
}
|
||||
if (!Array.isArray(decoded) || (decoded.length !== 2 && decoded.length !== 3))
|
||||
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
||||
const [instanceId, purpose, slot] = decoded;
|
||||
const key = { instanceId, purpose, ...(slot === undefined ? {} : { slot }) } as SecretKey;
|
||||
validateKey(key);
|
||||
if (accountFor(key) !== account) invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
|
||||
return { service: SERVICE, account };
|
||||
}
|
||||
|
||||
export class MacOSKeychainSecretStore implements SecretStore {
|
||||
constructor(private readonly runner: CommandRunner = new SpawnCommandRunner()) {}
|
||||
|
||||
async set(key: SecretKey, value: string): Promise<string> {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
/[\0\r\n]/.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > 4096
|
||||
) {
|
||||
throw new SecretStoreError('INVALID_SECRET', 'Secret is not valid for Keychain storage');
|
||||
}
|
||||
const account = accountFor(key);
|
||||
await this.execute(
|
||||
['add-generic-password', '-U', '-s', SERVICE, '-a', account, '-w'],
|
||||
Buffer.from(value, 'utf8'),
|
||||
'set',
|
||||
);
|
||||
return referenceFor(account);
|
||||
}
|
||||
|
||||
async get(reference: string): Promise<string | undefined> {
|
||||
const { account } = parseKeychainReference(reference);
|
||||
const result = await this.execute(
|
||||
['find-generic-password', '-s', SERVICE, '-a', account, '-w'],
|
||||
undefined,
|
||||
'get',
|
||||
);
|
||||
if (result === undefined) return undefined;
|
||||
if (result.stdout.endsWith('\r\n')) return result.stdout.slice(0, -2);
|
||||
if (result.stdout.endsWith('\n')) return result.stdout.slice(0, -1);
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async delete(reference: string): Promise<boolean> {
|
||||
const { account } = parseKeychainReference(reference);
|
||||
const result = await this.execute(
|
||||
['delete-generic-password', '-s', SERVICE, '-a', account],
|
||||
undefined,
|
||||
'delete',
|
||||
);
|
||||
return result !== undefined;
|
||||
}
|
||||
|
||||
private async execute(
|
||||
args: readonly string[],
|
||||
stdin: Buffer | undefined,
|
||||
operation: 'set' | 'get' | 'delete',
|
||||
): Promise<CommandResult | undefined> {
|
||||
let result: CommandResult;
|
||||
try {
|
||||
result = await this.runner.run({
|
||||
executable: SECURITY_PATH,
|
||||
args,
|
||||
...(stdin === undefined ? {} : { stdin }),
|
||||
env: {},
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
maxOutputBytes: MAX_STDOUT_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
const marker = error instanceof Error ? error.message : '';
|
||||
if (marker.includes('OUTPUT_LIMIT') || marker.includes('STDERR_LIMIT'))
|
||||
throw new SecretStoreError('OUTPUT_LIMIT', 'Keychain command output exceeded limit');
|
||||
if (/timeout/i.test(marker))
|
||||
throw new SecretStoreError('COMMAND_TIMEOUT', 'Keychain command timed out');
|
||||
throw new SecretStoreError('COMMAND_FAILED', `Keychain ${operation} failed`);
|
||||
} finally {
|
||||
stdin?.fill(0);
|
||||
}
|
||||
if (result.exitCode !== 0) {
|
||||
if (
|
||||
operation !== 'set' &&
|
||||
result.exitCode === 44 &&
|
||||
/(?:^|\n)(?:[^\r\n]*: -25300|[^\r\n]*\(-25300\))\s*$/.test(result.stderr)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
throw new SecretStoreError('COMMAND_FAILED', `Keychain ${operation} failed`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface SecretKey {
|
||||
readonly instanceId: string;
|
||||
readonly purpose: string;
|
||||
readonly slot?: string;
|
||||
}
|
||||
|
||||
export interface SecretStore {
|
||||
set(key: SecretKey, value: string): Promise<string>;
|
||||
get(reference: string): Promise<string | undefined>;
|
||||
delete(reference: string): Promise<boolean>;
|
||||
}
|
||||
Reference in New Issue
Block a user