231 lines
8.7 KiB
TypeScript
231 lines
8.7 KiB
TypeScript
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(parseKeychainReference(reference)).toMatchObject({
|
|
service: 'multi-simadmin',
|
|
instanceId: 'instance.one',
|
|
purpose: 'instance-password',
|
|
});
|
|
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);
|
|
}
|
|
});
|
|
});
|