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
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"exports": "./src/index.ts",
"scripts": {
"test": "vitest run --root ../.. apps/api/src/app.test.ts apps/api/src/start.test.ts apps/api/src/infrastructure/database/database.test.ts apps/api/src/application/legacy-import/legacy-import.test.ts",
"test": "vitest run --root ../.. apps/api/src/app.test.ts apps/api/src/start.test.ts apps/api/src/infrastructure/database/database.test.ts apps/api/src/infrastructure/secrets/keychain-secret-store.test.ts apps/api/src/application/legacy-import/legacy-import.test.ts apps/api/src/application/legacy-import/activate-pending-secret.test.ts",
"typecheck": "tsc -p tsconfig.json"
},
"dependencies": {
@@ -0,0 +1,277 @@
import Database from 'better-sqlite3';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { openDatabase } from '../../infrastructure/database/database.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import type { SecretKey, SecretStore } from '../../infrastructure/secrets/secret-store.js';
import { ActivationError, activatePendingSecret } from './activate-pending-secret.js';
const secret = 'activation-secret-never-in-sqlite';
const databases: Database.Database[] = [];
afterEach(() => {
for (const db of databases.splice(0)) db.close();
});
function pendingDatabase(): Database.Database {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
migrateDatabase(db);
const now = '2026-07-16T00:00:00.000Z';
db.prepare('INSERT INTO instances VALUES (?,?,?,?,?,?,?,?)').run(
'alpha',
'Alpha',
'https://203.0.113.8',
'none',
1,
3,
now,
now,
);
db.prepare('INSERT INTO app_settings VALUES (?,?,?,?)').run(
'legacy-import.instance.alpha',
JSON.stringify({
source: 'legacy-config',
secretPending: true,
requestedAuthMode: 'password',
secretPurpose: 'instance-password',
}),
now,
now,
);
databases.push(db);
return db;
}
class FakeStore implements SecretStore {
readonly sets: { key: SecretKey; value: string }[] = [];
readonly deletes: string[] = [];
setError?: Error;
deleteError?: Error;
reference = 'keychain://multi-simadmin/b3BhcXVl';
async set(key: SecretKey, value: string): Promise<string> {
this.sets.push({ key, value });
if (this.setError) throw this.setError;
return key.slot ? `${this.reference}-${key.slot}` : this.reference;
}
async get(): Promise<string | undefined> {
return undefined;
}
async delete(reference: string): Promise<boolean> {
this.deletes.push(reference);
if (this.deleteError) throw this.deleteError;
return true;
}
}
function allText(db: Database.Database): string {
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
.all() as { name: string }[];
const rows: unknown[] = [];
for (const { name } of tables) {
const columns = (
db.prepare(`PRAGMA table_info("${name}")`).all() as { name: string; type: string }[]
)
.filter(({ type }) => type.toUpperCase().includes('TEXT'))
.map(({ name: column }) => `"${column}"`);
if (columns.length)
rows.push(...db.prepare(`SELECT ${columns.join(',')} FROM "${name}"`).all());
}
return JSON.stringify(rows);
}
function state(db: Database.Database): Uint8Array {
return db.serialize();
}
describe('activatePendingSecret', () => {
it('writes keychain first then atomically activates password auth and clears pending metadata', async () => {
const db = pendingDatabase();
const store = new FakeStore();
const result = await activatePendingSecret({ db, store, instanceId: 'alpha', secret });
expect(store.sets).toHaveLength(1);
expect(store.sets[0]).toEqual({
key: {
instanceId: 'alpha',
purpose: 'instance-password',
slot: expect.stringMatching(/^[A-Za-z0-9_-]+$/),
},
value: secret,
});
const storedReference = `${store.reference}-${store.sets[0]?.key.slot}`;
expect(result).toEqual({
instanceId: 'alpha',
referenceId: 'legacy-secret:alpha:instance-password',
externalReference: storedReference,
configRevision: 4,
});
expect(
db.prepare('SELECT auth_mode,config_revision FROM instances WHERE id=?').get('alpha'),
).toEqual({
auth_mode: 'password',
config_revision: 4,
});
expect(
db
.prepare('SELECT id,instance_id,purpose,provider,external_reference FROM secret_references')
.get(),
).toEqual({
id: 'legacy-secret:alpha:instance-password',
instance_id: 'alpha',
purpose: 'instance-password',
provider: 'macos-keychain',
external_reference: storedReference,
});
const metadata = JSON.parse(
(
db
.prepare('SELECT value_json FROM app_settings WHERE key=?')
.get('legacy-import.instance.alpha') as { value_json: string }
).value_json,
);
expect(metadata).toMatchObject({ secretPending: false, requestedAuthMode: 'password' });
expect(JSON.stringify(metadata)).not.toContain(secret);
expect(allText(db)).not.toContain(secret);
});
it('rolls back every DB write and compensates keychain when the transaction fails', async () => {
const db = pendingDatabase();
const store = new FakeStore();
const before = state(db);
db.exec(
"CREATE TRIGGER fail_activation BEFORE INSERT ON secret_references BEGIN SELECT RAISE(ABORT, 'forced'); END",
);
const withTrigger = state(db);
await expect(
activatePendingSecret({ db, store, instanceId: 'alpha', secret }),
).rejects.toMatchObject({
code: 'DATABASE_FAILED',
});
expect(store.deletes).toHaveLength(1);
expect(store.deletes[0]).toBe(`${store.reference}-${store.sets[0]?.key.slot}`);
expect(state(db)).toEqual(withTrigger);
expect(withTrigger).not.toEqual(before);
});
it('returns a stable compensation error without leaking secret when cleanup fails', async () => {
const db = pendingDatabase();
db.exec(
"CREATE TRIGGER fail_activation BEFORE INSERT ON secret_references BEGIN SELECT RAISE(ABORT, 'forced'); END",
);
const store = new FakeStore();
store.deleteError = new Error(`malicious ${secret}`);
const error = await activatePendingSecret({ db, store, instanceId: 'alpha', secret }).catch(
(reason: unknown) => reason,
);
expect(error).toBeInstanceOf(ActivationError);
expect(error).toMatchObject({ code: 'COMPENSATION_FAILED' });
expect(
JSON.stringify({ name: (error as Error).name, message: (error as Error).message }),
).not.toContain(secret);
});
it('does not touch DB when store set fails', async () => {
const db = pendingDatabase();
const before = state(db);
const store = new FakeStore();
store.setError = new Error(`malicious ${secret}`);
const error = await activatePendingSecret({ db, store, instanceId: 'alpha', secret }).catch(
(reason: unknown) => reason,
);
expect(error).toMatchObject({ code: 'SECRET_STORE_FAILED' });
expect(state(db)).toEqual(before);
expect(
JSON.stringify({ name: (error as Error).name, message: (error as Error).message }),
).not.toContain(secret);
});
it('rejects duplicate activation before store access and leaves DB unchanged', async () => {
const db = pendingDatabase();
const firstStore = new FakeStore();
await activatePendingSecret({ db, store: firstStore, instanceId: 'alpha', secret });
const before = state(db);
const secondStore = new FakeStore();
await expect(
activatePendingSecret({ db, store: secondStore, instanceId: 'alpha', secret }),
).rejects.toMatchObject({ code: 'ALREADY_ACTIVE' });
expect(secondStore.sets).toHaveLength(0);
expect(state(db)).toEqual(before);
});
it('serializes concurrent activation so only one unique keychain item is created', async () => {
const db = pendingDatabase();
const store = new FakeStore();
const results = await Promise.allSettled([
activatePendingSecret({ db, store, instanceId: 'alpha', secret: `${secret}-one` }),
activatePendingSecret({ db, store, instanceId: 'alpha', secret: `${secret}-two` }),
]);
expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(1);
expect(results.filter(({ status }) => status === 'rejected')).toHaveLength(1);
expect(store.sets).toHaveLength(1);
expect(store.sets[0]?.key.slot).toMatch(/^[A-Za-z0-9_-]+$/);
expect(store.deletes).toHaveLength(0);
const saved = db.prepare('SELECT external_reference FROM secret_references').get() as {
external_reference: string;
};
expect(saved.external_reference).toContain(store.sets[0]?.key.slot);
});
it('serializes activation across two managed connections to the same physical database', async () => {
const directory = await mkdtemp(join(tmpdir(), 'secret-activation-'));
const path = join(directory, 'state.sqlite');
const first = openDatabase(path);
const second = openDatabase(path);
migrateDatabase(first);
const now = '2026-07-16T00:00:00.000Z';
first
.prepare('INSERT INTO instances VALUES (?,?,?,?,?,?,?,?)')
.run('alpha', 'Alpha', 'https://203.0.113.8', 'none', 1, 3, now, now);
first
.prepare('INSERT INTO app_settings VALUES (?,?,?,?)')
.run(
'legacy-import.instance.alpha',
JSON.stringify({ secretPending: true, requestedAuthMode: 'password' }),
now,
now,
);
const store = new FakeStore();
try {
const results = await Promise.allSettled([
activatePendingSecret({ db: first, store, instanceId: 'alpha', secret: `${secret}-one` }),
activatePendingSecret({ db: second, store, instanceId: 'alpha', secret: `${secret}-two` }),
]);
expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(1);
expect(results.filter(({ status }) => status === 'rejected')).toHaveLength(1);
expect(store.sets).toHaveLength(1);
expect(store.deletes).toHaveLength(0);
} finally {
second.close();
first.close();
await rm(directory, { recursive: true, force: true });
}
});
it('rejects empty secrets and invalid pending metadata before store access', async () => {
const db = pendingDatabase();
const store = new FakeStore();
await expect(
activatePendingSecret({ db, store, instanceId: 'alpha', secret: '' }),
).rejects.toMatchObject({ code: 'EMPTY_SECRET' });
db.prepare('UPDATE app_settings SET value_json=?').run(
JSON.stringify({ secretPending: false, requestedAuthMode: 'password' }),
);
await expect(
activatePendingSecret({ db, store, instanceId: 'alpha', secret }),
).rejects.toMatchObject({ code: 'NOT_PENDING' });
expect(store.sets).toHaveLength(0);
});
});
@@ -0,0 +1,205 @@
import type Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import { getManagedDatabaseIdentity } from '../../infrastructure/database/database.js';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
const PURPOSE = 'instance-password';
const PROVIDER = 'macos-keychain';
const metadataKey = (instanceId: string): string => `legacy-import.instance.${instanceId}`;
const referenceId = (instanceId: string): string => `legacy-secret:${instanceId}:${PURPOSE}`;
export type ActivationErrorCode =
| 'EMPTY_SECRET'
| 'INSTANCE_NOT_FOUND'
| 'ALREADY_ACTIVE'
| 'NOT_PENDING'
| 'SECRET_STORE_FAILED'
| 'DATABASE_FAILED'
| 'COMPENSATION_FAILED';
export class ActivationError extends Error {
constructor(
readonly code: ActivationErrorCode,
message: string,
) {
super(message);
this.name = 'ActivationError';
}
}
export interface ActivatePendingSecretInput {
readonly db: Database.Database;
readonly store: SecretStore;
readonly instanceId: string;
readonly secret: string;
}
export interface ActivatePendingSecretResult {
readonly instanceId: string;
readonly referenceId: string;
readonly externalReference: string;
readonly configRevision: number;
}
interface InstanceRow {
readonly auth_mode: string;
readonly config_revision: number;
}
interface MetadataRow {
readonly value_json: string;
}
const connectionActivationTails = new WeakMap<Database.Database, Map<string, Promise<void>>>();
const physicalActivationTails = new Map<string, Map<string, Promise<void>>>();
function activationTailMap(db: Database.Database): Map<string, Promise<void>> {
try {
const identity = getManagedDatabaseIdentity(db);
let tails = physicalActivationTails.get(identity.deviceInode);
if (!tails) {
tails = new Map();
physicalActivationTails.set(identity.deviceInode, tails);
}
return tails;
} catch {
let tails = connectionActivationTails.get(db);
if (!tails) {
tails = new Map();
connectionActivationTails.set(db, tails);
}
return tails;
}
}
async function withActivationLock<T>(
db: Database.Database,
instanceId: string,
action: () => Promise<T>,
): Promise<T> {
const instanceTails = activationTailMap(db);
const previous = instanceTails.get(instanceId) ?? Promise.resolve();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const tail = previous.then(() => gate);
instanceTails.set(instanceId, tail);
await previous;
try {
return await action();
} finally {
release();
if (instanceTails.get(instanceId) === tail) {
instanceTails.delete(instanceId);
try {
const identity = getManagedDatabaseIdentity(db);
if (instanceTails.size === 0) physicalActivationTails.delete(identity.deviceInode);
} catch {
// WeakMap-backed unmanaged connection state is reclaimed with the connection.
}
}
}
}
function inspectPending(
db: Database.Database,
instanceId: string,
): { metadata: Record<string, unknown>; revision: number } {
const instance = db
.prepare('SELECT auth_mode,config_revision FROM instances WHERE id=?')
.get(instanceId) as InstanceRow | undefined;
if (!instance) throw new ActivationError('INSTANCE_NOT_FOUND', 'Instance was not found');
if (instance.auth_mode !== 'none')
throw new ActivationError('ALREADY_ACTIVE', 'Instance authentication is already active');
const row = db
.prepare('SELECT value_json FROM app_settings WHERE key=?')
.get(metadataKey(instanceId)) as MetadataRow | undefined;
let metadata: Record<string, unknown>;
try {
metadata = JSON.parse(row?.value_json ?? '') as Record<string, unknown>;
} catch {
throw new ActivationError('NOT_PENDING', 'Instance has no valid pending password secret');
}
if (metadata.secretPending !== true || metadata.requestedAuthMode !== 'password')
throw new ActivationError('NOT_PENDING', 'Instance has no pending password secret');
return { metadata, revision: instance.config_revision };
}
export async function activatePendingSecret(
input: ActivatePendingSecretInput,
): Promise<ActivatePendingSecretResult> {
return withActivationLock(input.db, input.instanceId, () =>
activatePendingSecretExclusive(input),
);
}
async function activatePendingSecretExclusive({
db,
store,
instanceId,
secret,
}: ActivatePendingSecretInput): Promise<ActivatePendingSecretResult> {
if (typeof secret !== 'string' || secret.length === 0)
throw new ActivationError('EMPTY_SECRET', 'Secret must not be empty');
// Validate state before touching Keychain. It is checked again inside the transaction for race safety.
const pending = inspectPending(db, instanceId);
let externalReference: string;
try {
externalReference = await store.set(
{ instanceId, purpose: PURPOSE, slot: randomUUID() },
secret,
);
} catch {
throw new ActivationError('SECRET_STORE_FAILED', 'Could not store pending secret');
}
const id = referenceId(instanceId);
const now = new Date().toISOString();
try {
db.transaction(() => {
const current = inspectPending(db, instanceId);
if (current.revision !== pending.revision)
throw new ActivationError('DATABASE_FAILED', 'Instance changed during secret activation');
db.prepare(
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
).run(id, instanceId, PURPOSE, PROVIDER, externalReference, now, now);
const updated = db
.prepare(
"UPDATE instances SET auth_mode='password',config_revision=config_revision+1,updated_at=? WHERE id=? AND auth_mode='none' AND config_revision=?",
)
.run(now, instanceId, current.revision);
if (updated.changes !== 1)
throw new ActivationError('DATABASE_FAILED', 'Instance changed during secret activation');
const sanitizedMetadata: Record<string, unknown> = {
...current.metadata,
secretPending: false,
};
delete sanitizedMetadata.secret;
delete sanitizedMetadata.password;
const metadataUpdated = db
.prepare('UPDATE app_settings SET value_json=?,updated_at=? WHERE key=?')
.run(JSON.stringify(sanitizedMetadata), now, metadataKey(instanceId));
if (metadataUpdated.changes !== 1)
throw new ActivationError('DATABASE_FAILED', 'Pending secret metadata changed');
})();
} catch {
try {
const removed = await store.delete(externalReference);
if (!removed) throw new Error('cleanup did not remove stored secret');
} catch {
throw new ActivationError(
'COMPENSATION_FAILED',
'Database activation failed and stored-secret cleanup failed',
);
}
throw new ActivationError('DATABASE_FAILED', 'Database secret activation failed');
}
return {
instanceId,
referenceId: id,
externalReference,
configRevision: pending.revision + 1,
};
}
+23
View File
@@ -21,5 +21,28 @@ export type {
LegacyImportResult,
LegacyImportStatus,
} from './application/legacy-import/legacy-import.js';
export {
ActivationError,
activatePendingSecret,
} from './application/legacy-import/activate-pending-secret.js';
export type {
ActivatePendingSecretInput,
ActivatePendingSecretResult,
ActivationErrorCode,
} from './application/legacy-import/activate-pending-secret.js';
export {
MacOSKeychainSecretStore,
SecretStoreError,
SpawnCommandRunner,
parseKeychainReference,
} from './infrastructure/secrets/keychain-secret-store.js';
export type {
CommandInvocation,
CommandResult,
CommandRunner,
ParsedKeychainReference,
SecretStoreErrorCode,
} from './infrastructure/secrets/keychain-secret-store.js';
export type { SecretKey, SecretStore } from './infrastructure/secrets/secret-store.js';
export { MIGRATIONS, migrateDatabase } from './infrastructure/database/migrations.js';
export * as databaseSchema from './infrastructure/database/schema.js';
@@ -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>;
}