mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat: encrypt repository credentials at rest
This commit is contained in:
@@ -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}`);
|
||||||
};
|
};
|
||||||
|
|||||||
61
apps/server/src/utils/crypto.ts
Normal file
61
apps/server/src/utils/crypto.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user