feat(secrets): add hardened macOS Keychain store
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user