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