import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, type Stats, } from "node:fs"; import { AUTHENTICATION_SECRET_LIMITS, isAuthenticationSecretReference, isUsableAuthenticationSecret, } from "../auth/secret-policy.js"; /** Credential names that Installation Model Catalog providers may reference. */ export const METADATA_GENERATION_SECRET_KEYS = Object.freeze([ "THT_METADATA_API_KEY", "ANTHROPIC_API_KEY", "AZURE_API_KEY", "GEMINI_API_KEY", "DEEPSEEK_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "ZAI_API_KEY", ] as const); /** Keys accepted by the deployment bundle. Keep this list intentionally explicit. */ export const SECRET_BUNDLE_KEYS = Object.freeze([ "THT_MODEL_API_KEY", "THT_DWH_API_KEY", "THT_VEC_API_KEY", "THT_VEC_WRITE_API_KEY", "THT_CA", "THT_SSL_CA", "THT_VECTOR_BOOTSTRAP_PASSWORD", "THT_VECTOR_MIGRATOR_PASSWORD", "THT_VECTOR_READER_PASSWORD", "THT_VECTOR_WRITER_PASSWORD", "PI_PROVIDER_API_KEY", "THT_OIDC_CLIENT_SECRET", "THT_AUTHENTIK_API_TOKEN", ...METADATA_GENERATION_SECRET_KEYS, ] as const); const ALLOWED = new Set(SECRET_BUNDLE_KEYS); const LEGACY_FILES: Readonly> = { THT_MODEL_API_KEY: "THT_MODEL_API_KEY_SECRET_FILE", THT_DWH_API_KEY: "THT_DWH_API_KEY_SECRET_FILE", THT_VEC_API_KEY: "THT_VEC_API_KEY_SECRET_FILE", THT_VEC_WRITE_API_KEY: "THT_VEC_WRITE_API_KEY_SECRET_FILE", THT_CA: "THT_CA_SECRET_FILE", THT_SSL_CA: "THT_CA_SECRET_FILE", THT_VECTOR_BOOTSTRAP_PASSWORD: "THT_VECTOR_BOOTSTRAP_PASSWORD_SECRET_FILE", THT_VECTOR_MIGRATOR_PASSWORD: "THT_VECTOR_MIGRATOR_PASSWORD_SECRET_FILE", THT_VECTOR_READER_PASSWORD: "THT_VECTOR_READER_PASSWORD_SECRET_FILE", THT_VECTOR_WRITER_PASSWORD: "THT_VECTOR_WRITER_PASSWORD_SECRET_FILE", }; const MAX_BUNDLE_BYTES = 64 * 1024; const MAX_LINE_BYTES = Math.max( 16 * 1024, ...Object.entries(AUTHENTICATION_SECRET_LIMITS).map(([name, maximum]) => name.length + 1 + maximum), ); export interface SecretBundleConfig { secretsFile?: string; secretFiles?: Readonly>; /** Accepted for callers that pass the raw process environment. */ THT_SECRETS_FILE?: string; } /** Injectable filesystem boundary used by the race-condition tests. */ export interface SecretBundleFsOps { lstat(path: string): Stats; open(path: string, flags: number): number; fstat(fd: number): Stats; read(fd: number): string; close(fd: number): void; } const realFs: SecretBundleFsOps = { lstat: lstatSync, open: openSync, fstat: fstatSync, read: (fd) => readFileSync(fd, "utf8"), close: closeSync, }; function unavailable(): Error { return new Error("secret bundle is unavailable"); } function secureStat(info: Stats, docker: boolean): boolean { const mode = info.mode & 0o777; if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size > MAX_BUNDLE_BYTES) return false; if (docker) { return (info.uid === 0 && mode === 0o444) || (info.uid === (process.getuid?.() ?? info.uid) && (mode === 0o400 || mode === 0o600)); } return info.uid === (process.getuid?.() ?? info.uid) && (mode === 0o400 || mode === 0o600); } function readSecure(file: string, fs: SecretBundleFsOps): string { let fd: number | undefined; try { if (!file || file.trim() !== file || file.includes("\0")) throw unavailable(); const docker = file.startsWith("/run/secrets/") && !file.slice("/run/secrets/".length).includes("/"); if (file.startsWith("/run/secrets/") && !docker) throw unavailable(); if (docker) { const parent = fs.lstat("/run/secrets"); if (!parent.isDirectory() || parent.uid !== 0 || (parent.mode & 0o022) !== 0) throw unavailable(); } const before = fs.lstat(file); if (!secureStat(before, docker)) throw unavailable(); fd = fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW); const opened = fs.fstat(fd); if (!secureStat(opened, docker) || before.dev !== opened.dev || before.ino !== opened.ino) throw unavailable(); return fs.read(fd); } catch { throw unavailable(); } finally { if (fd !== undefined) try { fs.close(fd); } catch { /* sanitized by design */ } } } function parseBundle(text: string): ReadonlyMap { const values = new Map(); const lines = text.split("\n"); for (const raw of lines) { if (raw.length > MAX_LINE_BYTES) throw unavailable(); const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw; const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line); if (!match) throw unavailable(); const [, key, value] = match; if (!ALLOWED.has(key) || values.has(key) || value.length === 0 || /[\r\n]/.test(value) || isAuthenticationSecretReference(key) && !isUsableAuthenticationSecret(key, value)) { throw unavailable(); } values.set(key, value); } return values; } export function loadSecretBundle(file: string): ReadonlyMap { return loadSecretBundleWithFs(file, realFs); } /** Same loader with an injectable filesystem boundary; useful for TOCTOU tests. */ export function loadSecretBundleWithFs(file: string, fs: SecretBundleFsOps): ReadonlyMap { try { return parseBundle(readSecure(file, fs)); } catch { throw unavailable(); } } /** Resolve a value from the bundle, with the pre-bundle *_SECRET_FILE fallback. */ export function secretValue(config: SecretBundleConfig, key: string): string | undefined { const bundlePath = config.secretsFile ?? config.THT_SECRETS_FILE; if (bundlePath) { const found = loadSecretBundle(bundlePath).get(key); if (found !== undefined) return found; } const legacyName = LEGACY_FILES[key]; const legacyPath = legacyName ? config.secretFiles?.[legacyName] ?? (() => { const raw = (config as unknown as Record)[legacyName]; return typeof raw === "string" ? raw : undefined; })() : undefined; if (!legacyPath) return undefined; const value = readSecure(legacyPath, realFs); if (!value || /\s/.test(value)) throw unavailable(); return value; } export function legacySecretEnvNames(): Readonly> { return LEGACY_FILES; }