diff --git a/apps/api/package.json b/apps/api/package.json index eab7306..16142a0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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": { diff --git a/apps/api/src/application/legacy-import/activate-pending-secret.test.ts b/apps/api/src/application/legacy-import/activate-pending-secret.test.ts new file mode 100644 index 0000000..79832d5 --- /dev/null +++ b/apps/api/src/application/legacy-import/activate-pending-secret.test.ts @@ -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 { + this.sets.push({ key, value }); + if (this.setError) throw this.setError; + return key.slot ? `${this.reference}-${key.slot}` : this.reference; + } + async get(): Promise { + return undefined; + } + async delete(reference: string): Promise { + 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); + }); +}); diff --git a/apps/api/src/application/legacy-import/activate-pending-secret.ts b/apps/api/src/application/legacy-import/activate-pending-secret.ts new file mode 100644 index 0000000..ac47107 --- /dev/null +++ b/apps/api/src/application/legacy-import/activate-pending-secret.ts @@ -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>>(); +const physicalActivationTails = new Map>>(); + +function activationTailMap(db: Database.Database): Map> { + 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( + db: Database.Database, + instanceId: string, + action: () => Promise, +): Promise { + const instanceTails = activationTailMap(db); + const previous = instanceTails.get(instanceId) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise((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; 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; + try { + metadata = JSON.parse(row?.value_json ?? '') as Record; + } 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 { + return withActivationLock(input.db, input.instanceId, () => + activatePendingSecretExclusive(input), + ); +} + +async function activatePendingSecretExclusive({ + db, + store, + instanceId, + secret, +}: ActivatePendingSecretInput): Promise { + 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 = { + ...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, + }; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index bccef31..ad1a85d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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'; diff --git a/apps/api/src/infrastructure/secrets/keychain-secret-store.test.ts b/apps/api/src/infrastructure/secrets/keychain-secret-store.test.ts new file mode 100644 index 0000000..1365878 --- /dev/null +++ b/apps/api/src/infrastructure/secrets/keychain-secret-store.test.ts @@ -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 { + 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); + } + }); +}); diff --git a/apps/api/src/infrastructure/secrets/keychain-secret-store.ts b/apps/api/src/infrastructure/secrets/keychain-secret-store.ts new file mode 100644 index 0000000..a56802b --- /dev/null +++ b/apps/api/src/infrastructure/secrets/keychain-secret-store.ts @@ -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>; + 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; +} + +export class SpawnCommandRunner implements CommandRunner { + run(invocation: CommandInvocation): Promise { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/api/src/infrastructure/secrets/secret-store.ts b/apps/api/src/infrastructure/secrets/secret-store.ts new file mode 100644 index 0000000..39aeb12 --- /dev/null +++ b/apps/api/src/infrastructure/secrets/secret-store.ts @@ -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; + get(reference: string): Promise; + delete(reference: string): Promise; +}