import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { config } from "../core/config"; import { isNodeJSErrnoException } from "./fs"; import { promisify } from "node:util"; const hkdf = promisify(crypto.hkdf); const algorithm = "aes-256-gcm" as const; const keyLength = 32; const encryptionPrefix = "encv1:"; const envSecretPrefix = "env://"; const fileSecretPrefix = "file://"; /** * Checks if a given string is encrypted by looking for the encryption prefix. */ const isEncrypted = (val?: string): boolean => { return typeof val === "string" && val.startsWith(encryptionPrefix); }; /** * Checks if a string looks like a supported secret reference. * - env://VAR_NAME -> reads process.env.VAR_NAME * - file://name -> reads a file /run/secrets/name */ const isSecretReference = (val?: string): boolean => { return typeof val === "string" && (val.startsWith(envSecretPrefix) || val.startsWith(fileSecretPrefix)); }; /** * Resolves an environment variable secret reference. */ const resolveEnvSecret = (ref: string): string => { const name = ref.slice(envSecretPrefix.length); if (!name) { throw new Error("env:// reference is missing variable name"); } const value = process.env[name]; if (value === undefined) { throw new Error(`Environment variable not set: ${name}`); } return value; }; /** * Resolves a file-based secret reference. * Reads the secret from /run/secrets/{name} */ const resolveFileSecret = async (ref: string): Promise => { const secretName = ref.slice(fileSecretPrefix.length); if (!secretName) { throw new Error("file:// reference is missing secret name"); } const normalizedName = secretName.replace(/^\/+/, ""); if (!normalizedName) { throw new Error("file:// reference is missing secret name"); } if (normalizedName.includes("\0") || normalizedName.includes("/") || normalizedName.includes("\\")) { throw new Error("Invalid secret reference: secret name must be a single path segment"); } const resolvedPath = path.join("/run/secrets", normalizedName); try { const content = await fs.readFile(resolvedPath, "utf-8"); return content.trimEnd(); } catch (error) { if (isNodeJSErrnoException(error)) { if (error.code === "ENOENT") { throw new Error(`Secret file not found: ${resolvedPath}`); } if (error.code === "EACCES") { throw new Error(`Permission denied reading secret file: ${resolvedPath}`); } } throw new Error(`Failed to read secret file ${resolvedPath}: ${(error as Error).message}`); } }; /** * Given a string, encrypts it using a randomly generated salt and the APP_SECRET. * Returns the input unchanged if it's empty or already encrypted. */ const encrypt = async (data: string) => { if (!data) { return data; } if (isEncrypted(data)) { return data; } const salt = crypto.randomBytes(16); const key = crypto.pbkdf2Sync(config.appSecret, salt, 100000, keyLength, "sha256"); const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv(algorithm, key, iv); const encrypted = Buffer.concat([cipher.update(data), cipher.final()]); const tag = cipher.getAuthTag(); return `${encryptionPrefix}${salt.toString("hex")}:${iv.toString("hex")}:${encrypted.toString("hex")}:${tag.toString("hex")}`; }; /** * Given an encrypted string, decrypts it using the salt stored in the string and the APP_SECRET. * Returns the input unchanged if it's not encrypted (for backward compatibility). */ const decrypt = async (encryptedData: string) => { if (!isEncrypted(encryptedData)) { return encryptedData; } const parts = encryptedData.split(":").slice(1); // Remove prefix const saltHex = parts.shift() as string; const salt = Buffer.from(saltHex, "hex"); const key = crypto.pbkdf2Sync(config.appSecret, salt, 100000, keyLength, "sha256"); const iv = Buffer.from(parts.shift() as string, "hex"); const encrypted = Buffer.from(parts.shift() as string, "hex"); const tag = Buffer.from(parts.shift() as string, "hex"); const decipher = crypto.createDecipheriv(algorithm, key, iv); decipher.setAuthTag(tag); let decrypted = decipher.update(encrypted); decrypted = Buffer.concat([decrypted, decipher.final()]); return decrypted.toString(); }; /** * Resolves secret references and encrypted database values. * * - encv1:... -> decrypt * - env://VAR -> read process.env.VAR * - file://name -> read /run/secrets/name * - otherwise returns value unchanged */ const resolveSecret = async (value: string): Promise => { if (!value) { return value; } if (isEncrypted(value)) { return decrypt(value); } if (value.startsWith(envSecretPrefix)) { return resolveEnvSecret(value); } if (value.startsWith(fileSecretPrefix)) { return resolveFileSecret(value); } return value; }; /** * Prepares a secret value for storage. * * - env://... and file://... are stored as-is (references) * - encv1:... is stored as-is (already encrypted) * - otherwise encrypt before storing */ const sealSecret = async (value: string): Promise => { if (!value) { return value; } if (isEncrypted(value) || isSecretReference(value)) { return value; } return encrypt(value); }; async function deriveSecret(label: string) { const derivedKey = await hkdf("sha256", config.appSecret, "", label, 32); return Buffer.from(derivedKey).toString("hex"); } function generateResticPassword(): string { return crypto.randomBytes(32).toString("hex"); } export const cryptoUtils = { resolveSecret, sealSecret, deriveSecret, generateResticPassword, isEncrypted, };