* refactor: extract restic in core package * chore: add turbo task runner * refactor: split server utils * chore: simplify withDeps signature and fix non-null assertion
200 lines
6.6 KiB
TypeScript
200 lines
6.6 KiB
TypeScript
import crypto from "node:crypto";
|
|
import { promisify } from "node:util";
|
|
import { eq } from "drizzle-orm";
|
|
import { db } from "../../../db/db";
|
|
import {
|
|
organization,
|
|
repositoriesTable,
|
|
volumesTable,
|
|
notificationDestinationsTable,
|
|
twoFactor,
|
|
} from "../../../db/schema";
|
|
import { logger } from "@zerobyte/core/node";
|
|
import { toMessage } from "~/server/utils/errors";
|
|
import { cryptoUtils } from "~/server/utils/crypto";
|
|
import type { RepositoryConfig } from "@zerobyte/core/restic";
|
|
import type { BackendConfig } from "~/schemas/volumes";
|
|
import type { NotificationConfig } from "~/schemas/notifications";
|
|
import { RESTIC_PASS_FILE } from "~/server/core/constants";
|
|
import { symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto";
|
|
|
|
/**
|
|
* Migration: Isolate Restic Passwords
|
|
*
|
|
* This migration performs two critical tasks:
|
|
* 1. Assigns unique restic passwords to each organization (using the legacy password for existing orgs)
|
|
* 2. Re-keys all encrypted secrets from the legacy restic passfile to use the new APP_SECRET
|
|
*
|
|
* This allows per-organization encryption key isolation while ensuring
|
|
* database encryption is decoupled from restic repository passwords.
|
|
*/
|
|
|
|
const legacyDecrypt = async (encryptedData: string): Promise<string> => {
|
|
const keyLength = 32;
|
|
const algorithm = "aes-256-gcm" as const;
|
|
|
|
if (!cryptoUtils.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();
|
|
};
|
|
|
|
const hkdf = promisify(crypto.hkdf);
|
|
|
|
const deriveSecretFromBase = async (baseSecret: string, label: string): Promise<string> => {
|
|
const derivedKey = await hkdf("sha256", baseSecret, "", label, 32);
|
|
|
|
return Buffer.from(derivedKey).toString("hex");
|
|
};
|
|
|
|
type MigrationError = { name: string; error: string };
|
|
|
|
const rekeySecrets = async (config: Record<string, unknown>): Promise<Record<string, unknown>> => {
|
|
const rekeyedConfig: Record<string, unknown> = { ...config };
|
|
|
|
for (const [key, value] of Object.entries(rekeyedConfig)) {
|
|
if (typeof value === "string" && cryptoUtils.isEncrypted(value)) {
|
|
const decrypted = await legacyDecrypt(value);
|
|
rekeyedConfig[key] = await cryptoUtils.sealSecret(decrypted);
|
|
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
rekeyedConfig[key] = await rekeySecrets(value as Record<string, unknown>);
|
|
}
|
|
}
|
|
|
|
return rekeyedConfig;
|
|
};
|
|
|
|
const execute = async () => {
|
|
const errors: MigrationError[] = [];
|
|
|
|
// Step 1: Read the legacy restic passfile
|
|
const legacyPassword = (await Bun.file(RESTIC_PASS_FILE).text()).trim();
|
|
|
|
if (!legacyPassword) {
|
|
throw new Error("Legacy restic passfile is empty");
|
|
}
|
|
|
|
// Step 2: Assign restic passwords to all existing organizations
|
|
const organizations = await db.query.organization.findMany({});
|
|
|
|
for (const org of organizations) {
|
|
try {
|
|
const currentMetadata = org.metadata;
|
|
|
|
if (!currentMetadata?.resticPassword) {
|
|
const newMetadata = {
|
|
...currentMetadata,
|
|
resticPassword: await cryptoUtils.sealSecret(legacyPassword),
|
|
};
|
|
|
|
await db.update(organization).set({ metadata: newMetadata }).where(eq(organization.id, org.id));
|
|
|
|
logger.info(`Assigned restic password to organization: ${org.name}`);
|
|
}
|
|
} catch (err) {
|
|
errors.push({ name: `org:${org.name}`, error: toMessage(err) });
|
|
}
|
|
}
|
|
|
|
// Step 3: Re-key all repository secrets
|
|
const repositories = await db.query.repositoriesTable.findMany({});
|
|
|
|
for (const repo of repositories) {
|
|
try {
|
|
const rekeyedConfig = (await rekeySecrets(repo.config)) as RepositoryConfig;
|
|
|
|
await db.update(repositoriesTable).set({ config: rekeyedConfig }).where(eq(repositoriesTable.id, repo.id));
|
|
|
|
logger.info(`Re-keyed secrets for repository: ${repo.name}`);
|
|
} catch (err) {
|
|
errors.push({ name: `repo:${repo.name}`, error: toMessage(err) });
|
|
}
|
|
}
|
|
|
|
// Step 4: Re-key all volume secrets
|
|
const volumes = await db.query.volumesTable.findMany({});
|
|
|
|
for (const volume of volumes) {
|
|
try {
|
|
const rekeyedConfig = (await rekeySecrets(volume.config)) as BackendConfig;
|
|
|
|
await db.update(volumesTable).set({ config: rekeyedConfig }).where(eq(volumesTable.id, volume.id));
|
|
|
|
logger.info(`Re-keyed secrets for volume: ${volume.name}`);
|
|
} catch (err) {
|
|
errors.push({ name: `volume:${volume.name}`, error: toMessage(err) });
|
|
}
|
|
}
|
|
|
|
// Step 5: Re-key all notification secrets
|
|
const notifications = await db.query.notificationDestinationsTable.findMany({});
|
|
|
|
for (const notification of notifications) {
|
|
try {
|
|
const rekeyedConfig = (await rekeySecrets(notification.config)) as NotificationConfig;
|
|
|
|
await db
|
|
.update(notificationDestinationsTable)
|
|
.set({ config: rekeyedConfig })
|
|
.where(eq(notificationDestinationsTable.id, notification.id));
|
|
|
|
logger.info(`Re-keyed secrets for notification: ${notification.name}`);
|
|
} catch (err) {
|
|
errors.push({ name: `notification:${notification.name}`, error: toMessage(err) });
|
|
}
|
|
}
|
|
|
|
// Step 6: Re-key two-factor secrets
|
|
const legacyAuthSecret = await deriveSecretFromBase(legacyPassword, "better-auth");
|
|
const currentAuthSecret = await cryptoUtils.deriveSecret("better-auth");
|
|
const twoFactorRecords = await db.query.twoFactor.findMany({});
|
|
|
|
for (const record of twoFactorRecords) {
|
|
try {
|
|
const decryptedSecret = await symmetricDecrypt({ key: legacyAuthSecret, data: record.secret });
|
|
const decryptedBackupCodes = await symmetricDecrypt({
|
|
key: legacyAuthSecret,
|
|
data: record.backupCodes,
|
|
});
|
|
|
|
await db
|
|
.update(twoFactor)
|
|
.set({
|
|
secret: await symmetricEncrypt({ key: currentAuthSecret, data: decryptedSecret }),
|
|
backupCodes: await symmetricEncrypt({ key: currentAuthSecret, data: decryptedBackupCodes }),
|
|
})
|
|
.where(eq(twoFactor.id, record.id));
|
|
|
|
logger.info(`Re-keyed two-factor secrets for user: ${record.userId}`);
|
|
} catch (err) {
|
|
errors.push({ name: `twoFactor:${record.userId}`, error: toMessage(err) });
|
|
}
|
|
}
|
|
|
|
return { success: errors.length === 0, errors };
|
|
};
|
|
|
|
export const v00002 = {
|
|
execute,
|
|
id: "00002-isolate-restic-passwords",
|
|
type: "critical" as const,
|
|
};
|