- Add resolveSecretsDeep() to recursively resolve secret placeholders (env://, file://, encv1:) in any config field - Refactor restic operations to resolve secrets once via resolveAndBuild() - Refactor volume backend dispatcher to resolve secrets at mount time - Remove redundant per-field resolveSecret() calls from SMB, WebDAV, SFTP backends - Simplify notifications service by replacing switch-based decryption - Update backend-compatibility to use deep resolution for credential comparison This allows users to use secret references in any configuration field, not just the designated sensitive fields.
235 lines
6.4 KiB
TypeScript
235 lines
6.4 KiB
TypeScript
import crypto from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { RESTIC_PASS_FILE } from "../core/constants";
|
|
import { isNodeJSErrnoException } from "./fs";
|
|
|
|
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<string> => {
|
|
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.
|
|
* 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 secret = await Bun.file(RESTIC_PASS_FILE).text();
|
|
|
|
const salt = crypto.randomBytes(16);
|
|
const key = crypto.pbkdf2Sync(secret, 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.
|
|
* Returns the input unchanged if it's not encrypted (for backward compatibility).
|
|
*/
|
|
const decrypt = async (encryptedData: string) => {
|
|
if (!isEncrypted(encryptedData)) {
|
|
return encryptedData;
|
|
}
|
|
|
|
const secret = (await Bun.file(RESTIC_PASS_FILE).text()).trim();
|
|
|
|
const parts = encryptedData.split(":").slice(1); // Remove prefix
|
|
const saltHex = parts.shift() as string;
|
|
const salt = Buffer.from(saltHex, "hex");
|
|
|
|
const key = crypto.pbkdf2Sync(secret, 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<string> => {
|
|
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<string> => {
|
|
if (!value) {
|
|
return value;
|
|
}
|
|
|
|
if (isEncrypted(value) || isSecretReference(value)) {
|
|
return value;
|
|
}
|
|
|
|
return encrypt(value);
|
|
};
|
|
|
|
/**
|
|
* Helper to check if a value is a plain object (not null, array, Date, etc.)
|
|
*/
|
|
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) && value.constructor === Object;
|
|
};
|
|
|
|
/**
|
|
* Recursively resolves all secret references in an object or array.
|
|
* Handles nested objects, arrays, and circular references.
|
|
*/
|
|
const resolveSecretsDeep = async <T>(input: T): Promise<T> => {
|
|
const seen = new WeakMap<object, unknown>();
|
|
|
|
const resolve = async (value: unknown): Promise<unknown> => {
|
|
if (typeof value === "string") {
|
|
return resolveSecret(value);
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
if (seen.has(value)) return seen.get(value);
|
|
const result: unknown[] = [];
|
|
seen.set(value, result);
|
|
for (const item of value) {
|
|
result.push(await resolve(item));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
if (isPlainObject(value)) {
|
|
if (seen.has(value)) return seen.get(value);
|
|
const result: Record<string, unknown> = {};
|
|
seen.set(value, result);
|
|
for (const [key, val] of Object.entries(value)) {
|
|
result[key] = await resolve(val);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
return value;
|
|
};
|
|
|
|
return resolve(input) as Promise<T>;
|
|
};
|
|
|
|
export const cryptoUtils = {
|
|
resolveSecret,
|
|
sealSecret,
|
|
resolveSecretsDeep,
|
|
};
|