feat(secrets): add hardened macOS Keychain store

This commit is contained in:
chick
2026-07-16 14:56:10 +08:00
parent 8a307781c6
commit c2702b5fc6
7 changed files with 990 additions and 1 deletions
@@ -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>;
}