feat: encrypt repository credentials at rest

This commit is contained in:
Nicolas Meienberger
2025-10-18 15:15:30 +02:00
parent 6e8aa4b465
commit 100c24de13
3 changed files with 84 additions and 15 deletions

View File

@@ -7,12 +7,24 @@ import { db } from "../../db/db";
import { repositoriesTable } from "../../db/schema"; import { repositoriesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors"; import { toMessage } from "../../utils/errors";
import { restic } from "../../utils/restic"; import { restic } from "../../utils/restic";
import { cryptoUtils } from "../../utils/crypto";
const listRepositories = async () => { const listRepositories = async () => {
const repositories = await db.query.repositoriesTable.findMany({}); const repositories = await db.query.repositoriesTable.findMany({});
return repositories; return repositories;
}; };
const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig> => {
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 createRepository = async (name: string, config: RepositoryConfig, compressionMode?: CompressionMode) => {
const slug = slugify(name, { lower: true, strict: true }); 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 id = crypto.randomUUID();
const encryptedConfig = await encryptConfig(config);
const [created] = await db const [created] = await db
.insert(repositoriesTable) .insert(repositoriesTable)
.values({ .values({
id, id,
name: slug, name: slug,
backend: config.backend, backend: config.backend,
config, config: encryptedConfig,
compressionMode: compressionMode ?? "auto", compressionMode: compressionMode ?? "auto",
status: "unknown", status: "unknown",
}) })
@@ -42,7 +56,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
throw new InternalServerError("Failed to create repository"); throw new InternalServerError("Failed to create repository");
} }
const { success, error } = await restic.init(config); const { success, error } = await restic.init(encryptedConfig);
if (success) { if (success) {
await db await db
@@ -58,14 +72,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
} }
const errorMessage = toMessage(error); const errorMessage = toMessage(error);
await db await db.delete(repositoriesTable).where(eq(repositoriesTable.id, id));
.update(repositoriesTable)
.set({
status: "error",
lastError: errorMessage,
lastChecked: new Date(),
})
.where(eq(repositoriesTable.id, id));
throw new InternalServerError(`Failed to initialize repository: ${errorMessage}`); throw new InternalServerError(`Failed to initialize repository: ${errorMessage}`);
}; };

View File

@@ -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,
};

View File

@@ -6,6 +6,7 @@ import { type } from "arktype";
import { $ } from "bun"; import { $ } from "bun";
import { RESTIC_PASS_FILE } from "../core/constants"; import { RESTIC_PASS_FILE } from "../core/constants";
import { logger } from "./logger"; import { logger } from "./logger";
import { cryptoUtils } from "./crypto";
const backupOutputSchema = type({ const backupOutputSchema = type({
message_type: "'summary'", message_type: "'summary'",
@@ -45,13 +46,13 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
} }
}; };
const buildEnv = (config: RepositoryConfig): Record<string, string> => { const buildEnv = async (config: RepositoryConfig) => {
const env: Record<string, string> = {}; const env: Record<string, string> = {};
switch (config.backend) { switch (config.backend) {
case "s3": case "s3":
env.AWS_ACCESS_KEY_ID = config.accessKeyId; env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId);
env.AWS_SECRET_ACCESS_KEY = config.secretAccessKey; env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey);
break; break;
} }
@@ -62,7 +63,7 @@ const init = async (config: RepositoryConfig) => {
await ensurePassfile(); await ensurePassfile();
const repoUrl = buildRepoUrl(config); 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(); 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 backup = async (config: RepositoryConfig, source: string) => {
const repoUrl = buildRepoUrl(config); 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` const res = await $`restic --repo ${repoUrl} backup ${source} --password-file /data/secrets/restic.pass --json`
.env(env) .env(env)