feat(secrets): add a 0600 file secret backend so production runs on Linux

The production composition hardwired the macOS Keychain (/usr/bin/security),
which made Linux deployment impossible. Now:

- SecretStore gains a stable provider identity persisted in
  secret_references.provider; services stop hardcoding 'macos-keychain'
- shared reference codec (secret-reference.ts) understands both
  keychain:// and secret-file:// references
- FileSecretStore: single 0600 JSON map under the data root, atomic
  temp+rename writes, serialized in-process, same validation envelope
- production composition picks the backend via
  MULTI_SIMADMIN_SECRET_BACKEND (default: Keychain on darwin, file store
  elsewhere) and readiness probes the matching backend
This commit is contained in:
chick
2026-09-07 01:32:07 +08:00
parent c57dc81e42
commit e4c4bce75b
26 changed files with 605 additions and 76 deletions
@@ -54,7 +54,8 @@ describe('verified backup, restore, and rollback foundation', () => {
// Activation renames a WAL file that a live connection still holds open;
// POSIX permits that, Windows file locking does not.
it('verifies a separate candidate and digest-binds activation', { skip: process.platform === 'win32' && 'relies on POSIX rename-over-open-file semantics' }, async () => {
const itPosix = process.platform === 'win32' ? it.skip : it;
itPosix('verifies a separate candidate and digest-binds activation', async () => {
const { livePath, backupPath, database } = await fixture();
const snapshot = await backupDatabase(database, backupPath);
database.exec('UPDATE app_settings SET value_json = \'"after"\'');
@@ -121,7 +122,7 @@ describe('verified backup, restore, and rollback foundation', () => {
expect(await readFile(livePath)).toEqual(before);
});
it('rejects in-place staged-byte mutation even when metadata is preserved', { skip: process.platform === 'win32' && 'uses python3 utime and POSIX rename-over-open-file semantics' }, async () => {
itPosix('rejects in-place staged-byte mutation even when metadata is preserved', async () => {
const { livePath, backupPath, database } = await fixture();
await backupDatabase(database, backupPath);
database.close();
@@ -0,0 +1,174 @@
import { randomUUID } from 'node:crypto';
import { chmod, mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises';
import { dirname, isAbsolute } from 'node:path';
import {
accountFor,
parseSecretAccount,
validateKey,
SecretReferenceError,
} from './secret-reference.js';
import type { SecretKey, SecretStore } from './secret-store.js';
const PROVIDER = 'secret-file';
const SERVICE = 'multi-simadmin';
const MAX_SECRET_BYTES = 16_384;
const MAX_STORE_BYTES = 4 * 1024 * 1024;
export type FileSecretStoreErrorCode =
| 'INVALID_KEY'
| 'INVALID_SECRET'
| 'INVALID_REFERENCE'
| 'INVALID_STORE_PATH'
| 'SIZE_LIMIT'
| 'PAYLOAD_INVALID';
export class FileSecretStoreError extends Error {
constructor(
readonly code: FileSecretStoreErrorCode,
message: string,
) {
super(message);
this.name = 'FileSecretStoreError';
}
}
function invalid(code: FileSecretStoreErrorCode, message: string): never {
throw new FileSecretStoreError(code, message);
}
/**
* POSIX filesystem-backed secret store for hosts without a system keyring
* (e.g. Linux servers). Secrets live in a single 0600 JSON map under the data
* root, replaced atomically via temp file + rename, with writes serialized
* in-process.
*/
export class FileSecretStore implements SecretStore {
readonly provider = PROVIDER;
private queue: Promise<unknown> = Promise.resolve();
constructor(readonly filePath: string) {
if (typeof filePath !== 'string' || filePath.length === 0 || !isAbsolute(filePath))
invalid('INVALID_STORE_PATH', 'Secret file path must be absolute');
}
async set(key: SecretKey, value: string): Promise<string> {
if (
typeof value !== 'string' ||
value.length === 0 ||
/[\0\r\n]/.test(value) ||
Buffer.byteLength(value, 'utf8') > MAX_SECRET_BYTES
) {
invalid('INVALID_SECRET', 'Secret is not valid for file storage');
}
validateKey(key);
const account = accountFor(key);
await this.enqueue(() => this.withStore((store) => ({ ...store, [account]: value })));
return `secret-file://${SERVICE}/${account}`;
}
async get(reference: string): Promise<string | undefined> {
const { account } = this.parse(reference);
const store = await this.readStore();
return store[account];
}
async delete(reference: string): Promise<boolean> {
const { account } = this.parse(reference);
let removed = false;
await this.enqueue(() =>
this.withStore((store) => {
if (!(account in store)) return store;
removed = true;
const next = { ...store };
delete next[account];
return next;
}),
);
return removed;
}
private parse(reference: string): { account: string } {
if (typeof reference !== 'string' || reference.length > 512)
invalid('INVALID_REFERENCE', 'Secret file reference is invalid');
const match = /^secret-file:\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference);
if (!match?.[1]) invalid('INVALID_REFERENCE', 'Secret file reference is invalid');
try {
parseSecretAccount(match[1], PROVIDER);
} catch (error) {
if (error instanceof SecretReferenceError)
invalid('INVALID_REFERENCE', 'Secret file reference is invalid');
throw error;
}
return { account: match[1] };
}
private enqueue<T>(task: () => Promise<T>): Promise<T> {
const run = this.queue.then(task, task);
this.queue = run.catch(() => {});
return run;
}
private async readStore(): Promise<Record<string, string>> {
let raw: string;
try {
raw = await readFile(this.filePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {};
throw error;
}
if (Buffer.byteLength(raw, 'utf8') > MAX_STORE_BYTES)
invalid('SIZE_LIMIT', 'Secret file exceeds the size limit');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
invalid('PAYLOAD_INVALID', 'Secret file payload is invalid');
}
if (!isPlainStringMap(parsed)) invalid('PAYLOAD_INVALID', 'Secret file payload is invalid');
return parsed;
}
/** Reads the current map, applies `mutate`, and durably replaces the file. */
private async withStore(
mutate: (store: Record<string, string>) => Record<string, string>,
): Promise<void> {
const current = await this.readStore();
const next = mutate(current);
const directory = dirname(this.filePath);
await mkdir(directory, { recursive: true, mode: 0o700 });
if (process.platform !== 'win32') {
const info = await stat(directory);
if (info.isDirectory() && (info.mode & 0o077) !== 0) await chmod(directory, 0o700);
}
const temporary = `${this.filePath}.${randomUUID()}.tmp`;
const handle = await open(temporary, 'wx', 0o600);
try {
await handle.writeFile(`${JSON.stringify(next, null, 2)}\n`, 'utf8');
// Windows FlushFileBuffers is skipped along with the directory fsync below;
// durability anchoring is POSIX-only by design.
if (process.platform !== 'win32') await handle.sync();
} finally {
await handle.close();
}
try {
await rename(temporary, this.filePath);
if (process.platform !== 'win32') {
const directoryHandle = await open(directory, 'r');
try {
await directoryHandle.sync();
} finally {
await directoryHandle.close();
}
}
} catch (error) {
await rm(temporary, { force: true });
throw error;
}
}
}
function isPlainStringMap(value: unknown): value is Record<string, string> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
return Object.values(value).every((entry) => typeof entry === 'string');
}
@@ -1,11 +1,15 @@
import { spawn } from 'node:child_process';
import {
accountFor,
parseSecretAccount,
SecretReferenceError,
} from './secret-reference.js';
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'
@@ -105,43 +109,6 @@ function invalid(code: 'INVALID_KEY' | 'INVALID_REFERENCE', message: string): ne
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}`;
}
@@ -152,28 +119,26 @@ export function parseKeychainReference(reference: string): ParsedKeychainReferen
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;
let parsed;
try {
decoded = JSON.parse(Buffer.from(account, 'base64url').toString('utf8'));
} catch {
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
parsed = parseSecretAccount(account, 'macos-keychain');
} catch (error) {
if (error instanceof SecretReferenceError)
invalid('INVALID_REFERENCE', 'Keychain reference is invalid');
throw error;
}
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,
instanceId: key.instanceId,
purpose: key.purpose,
...(key.slot === undefined ? {} : { slot: key.slot }),
instanceId: parsed.instanceId,
purpose: parsed.purpose,
...(parsed.slot === undefined ? {} : { slot: parsed.slot }),
};
}
export class MacOSKeychainSecretStore implements SecretStore {
readonly provider = 'macos-keychain';
constructor(private readonly runner: CommandRunner = new SpawnCommandRunner()) {}
async set(key: SecretKey, value: string): Promise<string> {
@@ -0,0 +1,162 @@
import { describe, expect, it } from 'vitest';
import { mkdtemp, readFile, readdir, rm, stat, writeFile, chmod } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { FileSecretStore, FileSecretStoreError } from './file-secret-store.js';
import { SecretReferenceError } from './secret-reference.js';
import {
createDefaultSecretStore,
defaultSecretFileMetadataCheck,
resolveSecretBackend,
SecretBackendError,
} from './secret-backend.js';
import { MacOSKeychainSecretStore } from './keychain-secret-store.js';
describe('FileSecretStore', () => {
it('round-trips set/get/delete with stable secret-file references', async () => {
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
try {
const store = new FileSecretStore(join(directory, 'secrets.json'));
const reference = await store.set(
{ instanceId: 'device-1', purpose: 'instance-password', slot: 'slot-1' },
'pa$$word',
);
expect(reference).toMatch(/^secret-file:\/\/multi-simadmin\/[A-Za-z0-9_-]+$/);
expect(await store.get(reference)).toBe('pa$$word');
expect(await store.delete(reference)).toBe(true);
expect(await store.get(reference)).toBeUndefined();
expect(await store.delete(reference)).toBe(false);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('rejects empty, framing, oversize, and malformed keys/values', async () => {
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
try {
const store = new FileSecretStore(join(directory, 'secrets.json'));
await expect(store.set({ instanceId: 'x', purpose: 'p' }, '')).rejects.toThrow(
FileSecretStoreError,
);
await expect(store.set({ instanceId: 'x', purpose: 'p' }, 'a\nb')).rejects.toThrow(
FileSecretStoreError,
);
await expect(
store.set({ instanceId: 'x', purpose: 'p' }, 'a'.repeat(16_385)),
).rejects.toThrow(FileSecretStoreError);
await expect(store.set({ instanceId: '', purpose: 'p' }, 'v')).rejects.toThrow(
SecretReferenceError,
);
await expect(store.get('https://evil.example/one')).rejects.toThrow(FileSecretStoreError);
await expect(store.get('secret-file://other-service/abc')).rejects.toThrow(
FileSecretStoreError,
);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('keeps the map private (0600) and leaves no temporary files behind', async () => {
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
try {
const path = join(directory, 'nested', 'secrets.json');
const store = new FileSecretStore(path);
const reference = await store.set({ instanceId: 'x', purpose: 'p', slot: 's' }, 'secret');
if (process.platform !== 'win32')
expect((await stat(path)).mode & 0o777).toBe(0o600);
expect(await readdir(join(directory, 'nested'))).toEqual(['secrets.json']);
const raw = await readFile(path, 'utf8');
expect(JSON.parse(raw)).toEqual({
[reference.slice('secret-file://multi-simadmin/'.length)]: 'secret',
});
expect(await store.get(reference)).toBe('secret');
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('serializes concurrent writes so every mutation lands', async () => {
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
try {
const store = new FileSecretStore(join(directory, 'secrets.json'));
const references = await Promise.all(
Array.from({ length: 25 }, (_, index) =>
store.set({ instanceId: 'x', purpose: `p${index}`, slot: 's' }, `v${index}`),
),
);
for (const [index, reference] of references.entries())
expect(await store.get(reference)).toBe(`v${index}`);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('refuses a corrupted or non-map payload instead of failing open', async () => {
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-'));
try {
const path = join(directory, 'secrets.json');
await writeFile(path, 'not json', 'utf8');
const store = new FileSecretStore(path);
await expect(store.get('secret-file://multi-simadmin/abc')).rejects.toThrow(
FileSecretStoreError,
);
await writeFile(path, '{"account": 42}', 'utf8');
await expect(store.get('secret-file://multi-simadmin/abc')).rejects.toThrow(
FileSecretStoreError,
);
expect(() => new FileSecretStore('relative/secrets.json')).toThrow(FileSecretStoreError);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
describe('secret backend selection', () => {
it('defaults to the keychain on darwin and the file store elsewhere', () => {
expect(resolveSecretBackend({}, 'darwin')).toBe('macos-keychain');
expect(resolveSecretBackend({}, 'linux')).toBe('secret-file');
expect(resolveSecretBackend({ MULTI_SIMADMIN_SECRET_BACKEND: 'secret-file' }, 'darwin')).toBe(
'secret-file',
);
expect(() =>
resolveSecretBackend({ MULTI_SIMADMIN_SECRET_BACKEND: 'vault' }, 'darwin'),
).toThrow(SecretBackendError);
});
it('builds the matching store and refuses keychain off macOS', () => {
const directory = join(tmpdir(), 'msa-backend-selection');
expect(
createDefaultSecretStore({ env: {}, secretFilePath: join(directory, 's.json'), platform: 'linux' }),
).toBeInstanceOf(FileSecretStore);
expect(
createDefaultSecretStore({
env: { MULTI_SIMADMIN_SECRET_BACKEND: 'macos-keychain' },
secretFilePath: join(directory, 's.json'),
platform: 'darwin',
}),
).toBeInstanceOf(MacOSKeychainSecretStore);
expect(() =>
createDefaultSecretStore({
env: { MULTI_SIMADMIN_SECRET_BACKEND: 'macos-keychain' },
secretFilePath: join(directory, 's.json'),
platform: 'linux',
}),
).toThrow(SecretBackendError);
});
it('file metadata probe accepts a missing or private file and rejects a public one', async () => {
const directory = await mkdtemp(join(tmpdir(), 'msa-file-secrets-meta-'));
try {
const path = join(directory, 'secrets.json');
expect(await defaultSecretFileMetadataCheck(path)).toBe(true);
await writeFile(path, '{}\n', { mode: 0o600 });
expect(await defaultSecretFileMetadataCheck(path)).toBe(true);
if (process.platform !== 'win32') {
await chmod(path, 0o644);
expect(await defaultSecretFileMetadataCheck(path)).toBe(false);
}
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,78 @@
import { stat } from 'node:fs/promises';
import { FileSecretStore } from './file-secret-store.js';
import { MacOSKeychainSecretStore } from './keychain-secret-store.js';
import type { SecretStore } from './secret-store.js';
export type SecretBackend = 'macos-keychain' | 'secret-file';
const BACKENDS: readonly SecretBackend[] = ['macos-keychain', 'secret-file'];
export class SecretBackendError extends Error {
constructor(message: string) {
super(message);
this.name = 'SecretBackendError';
}
}
/**
* Resolves the secret backend: explicit MULTI_SIMADMIN_SECRET_BACKEND override,
* otherwise the macOS Keychain on darwin and the 0600 file store elsewhere.
*/
export function resolveSecretBackend(
env: Readonly<Record<string, string | undefined>> = process.env,
platform: NodeJS.Platform = process.platform,
): SecretBackend {
const raw = env.MULTI_SIMADMIN_SECRET_BACKEND;
if (raw !== undefined) {
if (!BACKENDS.includes(raw as SecretBackend))
throw new SecretBackendError(
'MULTI_SIMADMIN_SECRET_BACKEND must be one of: macos-keychain, secret-file',
);
return raw as SecretBackend;
}
return platform === 'darwin' ? 'macos-keychain' : 'secret-file';
}
export interface CreateDefaultSecretStoreOptions {
readonly env?: Readonly<Record<string, string | undefined>>;
/** Absolute path of the file store map; required for the secret-file backend. */
readonly secretFilePath: string;
readonly platform?: NodeJS.Platform;
}
export function createDefaultSecretStore(options: CreateDefaultSecretStoreOptions): SecretStore {
const platform = options.platform ?? process.platform;
const backend = resolveSecretBackend(options.env, platform);
if (backend === 'secret-file') return new FileSecretStore(options.secretFilePath);
if (platform !== 'darwin')
throw new SecretBackendError(
'The macos-keychain backend requires /usr/bin/security (macOS only)',
);
return new MacOSKeychainSecretStore();
}
interface SecretFileMetadata {
isFile(): boolean;
readonly mode: number;
}
/**
* Readiness metadata probe for the file backend. Availability only — this must
* never read secret material.
*/
export async function defaultSecretFileMetadataCheck(
filePath: string,
platform: NodeJS.Platform = process.platform,
statFn: (path: string) => Promise<SecretFileMetadata> = stat,
): Promise<boolean> {
try {
const info = await statFn(filePath);
if (!info.isFile()) return false;
// Windows keeps no POSIX mode bits; NTFS ACLs govern access there.
if (platform !== 'win32' && (info.mode & 0o777) !== 0o600) return false;
return true;
} catch (error) {
// A missing map is healthy: secrets are added lazily by instance setup.
return (error as NodeJS.ErrnoException).code === 'ENOENT';
}
}
@@ -0,0 +1,104 @@
import type { SecretKey } from './secret-store.js';
export const SECRET_SERVICE = 'multi-simadmin';
export type SecretProvider = 'macos-keychain' | 'secret-file';
export const SECRET_PROVIDERS: readonly SecretProvider[] = ['macos-keychain', 'secret-file'];
const safeComponent = /^[A-Za-z0-9_.-]+$/;
export type SecretReferenceErrorCode = 'INVALID_KEY' | 'INVALID_REFERENCE';
export function parseSecretAccount(
account: string,
provider: SecretProvider,
): ParsedSecretReference {
let decoded: unknown;
try {
decoded = JSON.parse(Buffer.from(account, 'base64url').toString('utf8'));
} catch {
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
}
if (!Array.isArray(decoded) || (decoded.length !== 2 && decoded.length !== 3))
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
const [instanceId, purpose, slot] = decoded;
const key = { instanceId, purpose, ...(slot === undefined ? {} : { slot }) } as SecretKey;
validateKey(key);
if (accountFor(key) !== account)
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
return {
provider,
instanceId: key.instanceId,
purpose: key.purpose,
...(key.slot === undefined ? {} : { slot: key.slot }),
};
}
export function validateKey(key: SecretKey): void {
if (
typeof key.instanceId !== 'string' ||
key.instanceId.length === 0 ||
key.instanceId.length > 128 ||
!safeComponent.test(key.instanceId)
) {
throw new SecretReferenceError('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)
) {
throw new SecretReferenceError('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))
) {
throw new SecretReferenceError('INVALID_KEY', 'Secret key slot is invalid');
}
}
export 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');
}
export interface ParsedSecretReference {
readonly provider: SecretProvider;
readonly instanceId: string;
readonly purpose: string;
readonly slot?: string;
}
export class SecretReferenceError extends Error {
constructor(
readonly code: SecretReferenceErrorCode,
message: string,
) {
super(message);
this.name = 'SecretReferenceError';
}
}
/** Accepts `keychain://multi-simadmin/<account>` and `secret-file://multi-simadmin/<account>`. */
export function parseSecretReference(reference: string): ParsedSecretReference {
if (typeof reference !== 'string' || reference.length > 512)
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
const match = /^([a-z-]+):\/\/multi-simadmin\/([A-Za-z0-9_-]+)$/.exec(reference);
if (!match?.[1] || !match[2])
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
const scheme = match[1];
const provider = scheme === 'keychain' ? 'macos-keychain' : scheme === 'secret-file' ? 'secret-file' : undefined;
if (!provider)
throw new SecretReferenceError('INVALID_REFERENCE', 'Secret reference is invalid');
return parseSecretAccount(match[2], provider);
}
@@ -5,6 +5,8 @@ export interface SecretKey {
}
export interface SecretStore {
/** Stable identifier persisted in secret_references.provider ('macos-keychain' | 'secret-file'). */
readonly provider: string;
set(key: SecretKey, value: string): Promise<string>;
get(reference: string): Promise<string | undefined>;
delete(reference: string): Promise<boolean>;