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
79 lines
2.7 KiB
TypeScript
79 lines
2.7 KiB
TypeScript
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';
|
|
}
|
|
}
|