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 "@zerobyte/contracts/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 => { 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 => { 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): Promise> => { const rekeyedConfig: Record = { ...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); } } 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, };