From 100c24de13141c58c0ff9fa515e1b04cc48cfb6e Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sat, 18 Oct 2025 15:15:30 +0200 Subject: [PATCH] feat: encrypt repository credentials at rest --- .../repositories/repositories.service.ts | 27 +++++--- apps/server/src/utils/crypto.ts | 61 +++++++++++++++++++ apps/server/src/utils/restic.ts | 11 ++-- 3 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 apps/server/src/utils/crypto.ts diff --git a/apps/server/src/modules/repositories/repositories.service.ts b/apps/server/src/modules/repositories/repositories.service.ts index e9f8a70..c8cc642 100644 --- a/apps/server/src/modules/repositories/repositories.service.ts +++ b/apps/server/src/modules/repositories/repositories.service.ts @@ -7,12 +7,24 @@ import { db } from "../../db/db"; import { repositoriesTable } from "../../db/schema"; import { toMessage } from "../../utils/errors"; import { restic } from "../../utils/restic"; +import { cryptoUtils } from "../../utils/crypto"; const listRepositories = async () => { const repositories = await db.query.repositoriesTable.findMany({}); return repositories; }; +const encryptConfig = async (config: RepositoryConfig): Promise => { + const encryptedConfig = { ...config }; + switch (config.backend) { + case "s3": + encryptedConfig.accessKeyId = await cryptoUtils.encrypt(config.accessKeyId); + encryptedConfig.secretAccessKey = await cryptoUtils.encrypt(config.secretAccessKey); + break; + } + return encryptedConfig; +}; + const createRepository = async (name: string, config: RepositoryConfig, compressionMode?: CompressionMode) => { const slug = slugify(name, { lower: true, strict: true }); @@ -26,13 +38,15 @@ const createRepository = async (name: string, config: RepositoryConfig, compress const id = crypto.randomUUID(); + const encryptedConfig = await encryptConfig(config); + const [created] = await db .insert(repositoriesTable) .values({ id, name: slug, backend: config.backend, - config, + config: encryptedConfig, compressionMode: compressionMode ?? "auto", status: "unknown", }) @@ -42,7 +56,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress throw new InternalServerError("Failed to create repository"); } - const { success, error } = await restic.init(config); + const { success, error } = await restic.init(encryptedConfig); if (success) { await db @@ -58,14 +72,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress } const errorMessage = toMessage(error); - await db - .update(repositoriesTable) - .set({ - status: "error", - lastError: errorMessage, - lastChecked: new Date(), - }) - .where(eq(repositoriesTable.id, id)); + await db.delete(repositoriesTable).where(eq(repositoriesTable.id, id)); throw new InternalServerError(`Failed to initialize repository: ${errorMessage}`); }; diff --git a/apps/server/src/utils/crypto.ts b/apps/server/src/utils/crypto.ts new file mode 100644 index 0000000..651bebe --- /dev/null +++ b/apps/server/src/utils/crypto.ts @@ -0,0 +1,61 @@ +import crypto from "node:crypto"; +import { RESTIC_PASS_FILE } from "../core/constants"; + +const algorithm = "aes-256-gcm" as const; +const keyLength = 32; +const encryptionPrefix = "encv1"; + +/** + * Given a string, encrypts it using a randomly generated salt + */ +const encrypt = async (data: string) => { + if (!data) { + return data; + } + + if (data.startsWith(encryptionPrefix)) { + return data; + } + + const secret = (await Bun.file(RESTIC_PASS_FILE).text()).trim(); + + 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 + */ +const decrypt = async (encryptedData: string) => { + const secret = await Bun.file(RESTIC_PASS_FILE).text(); + + 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(); +}; + +export const cryptoUtils = { + encrypt, + decrypt, +}; diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index cb24402..01b46a8 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -6,6 +6,7 @@ import { type } from "arktype"; import { $ } from "bun"; import { RESTIC_PASS_FILE } from "../core/constants"; import { logger } from "./logger"; +import { cryptoUtils } from "./crypto"; const backupOutputSchema = type({ message_type: "'summary'", @@ -45,13 +46,13 @@ const buildRepoUrl = (config: RepositoryConfig): string => { } }; -const buildEnv = (config: RepositoryConfig): Record => { +const buildEnv = async (config: RepositoryConfig) => { const env: Record = {}; switch (config.backend) { case "s3": - env.AWS_ACCESS_KEY_ID = config.accessKeyId; - env.AWS_SECRET_ACCESS_KEY = config.secretAccessKey; + env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId); + env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey); break; } @@ -62,7 +63,7 @@ const init = async (config: RepositoryConfig) => { await ensurePassfile(); const repoUrl = buildRepoUrl(config); - const env = buildEnv(config); + const env = await buildEnv(config); const res = await $`restic init --repo ${repoUrl} --password-file ${RESTIC_PASS_FILE} --json`.env(env).nothrow(); @@ -77,7 +78,7 @@ const init = async (config: RepositoryConfig) => { const backup = async (config: RepositoryConfig, source: string) => { const repoUrl = buildRepoUrl(config); - const env = buildEnv(config); + const env = await buildEnv(config); const res = await $`restic --repo ${repoUrl} backup ${source} --password-file /data/secrets/restic.pass --json` .env(env)