155 lines
6.1 KiB
TypeScript
155 lines
6.1 KiB
TypeScript
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<string>(SECRET_BUNDLE_KEYS);
|
|
const LEGACY_FILES: Readonly<Record<string, string>> = {
|
|
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<Record<string, string | undefined>>;
|
|
/** 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<string, string> {
|
|
const values = new Map<string, string>();
|
|
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<string, string> {
|
|
return loadSecretBundleWithFs(file, realFs);
|
|
}
|
|
|
|
/** Same loader with an injectable filesystem boundary; useful for TOCTOU tests. */
|
|
export function loadSecretBundleWithFs(file: string, fs: SecretBundleFsOps): ReadonlyMap<string, string> {
|
|
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<string, unknown>)[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<Record<string, string>> { return LEGACY_FILES; }
|