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
@@ -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';
}
}