diff --git a/app/server/cli/commands/rekey-2fa.ts b/app/server/cli/commands/rekey-2fa.ts new file mode 100644 index 00000000..bd49aa67 --- /dev/null +++ b/app/server/cli/commands/rekey-2fa.ts @@ -0,0 +1,112 @@ +import crypto from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { promisify } from "node:util"; +import { Command } from "commander"; +import { eq } from "drizzle-orm"; +import { symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto"; +import { db } from "../../db/db"; +import { twoFactor } from "../../db/schema"; +import { RESTIC_PASS_FILE } from "~/server/core/constants"; +import { cryptoUtils } from "~/server/utils/crypto"; +import { toMessage } from "~/server/utils/errors"; + +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"); +}; + +const resolveLegacySecret = async (options: { legacySecret?: string; legacySecretFile?: string }) => { + if (options.legacySecret && options.legacySecretFile) { + throw new Error("Use either --legacy-secret or --legacy-secret-file, not both"); + } + + if (options.legacySecret) { + return options.legacySecret.trim(); + } + + const legacyPath = options.legacySecretFile ?? RESTIC_PASS_FILE; + + try { + const content = await readFile(legacyPath, "utf-8"); + const secret = content.trim(); + + if (!secret) { + throw new Error("Legacy secret file is empty"); + } + + return secret; + } catch (error) { + const message = toMessage(error); + throw new Error(`Failed to read legacy secret from ${legacyPath}: ${message}`); + } +}; + +const rekeyTwoFactor = async (legacySecret: string) => { + const legacyAuthSecret = await deriveSecretFromBase(legacySecret, "better-auth"); + const currentAuthSecret = await cryptoUtils.deriveSecret("better-auth"); + + return db.transaction(async (tx) => { + const records = await tx.query.twoFactor.findMany({}); + const errors: Array<{ userId: string; error: string }> = []; + let updated = 0; + + for (const record of records) { + try { + const decryptedSecret = await symmetricDecrypt({ key: legacyAuthSecret, data: record.secret }); + const decryptedBackupCodes = await symmetricDecrypt({ + key: legacyAuthSecret, + data: record.backupCodes, + }); + + await tx + .update(twoFactor) + .set({ + secret: await symmetricEncrypt({ key: currentAuthSecret, data: decryptedSecret }), + backupCodes: await symmetricEncrypt({ key: currentAuthSecret, data: decryptedBackupCodes }), + }) + .where(eq(twoFactor.id, record.id)); + + updated += 1; + } catch (error) { + errors.push({ userId: record.userId, error: toMessage(error) }); + } + } + + return { total: records.length, updated, errors }; + }); +}; + +export const rekey2FACommand = new Command("rekey-2fa") + .description("Re-encrypt 2FA secrets using the current APP_SECRET") + .option("-s, --legacy-secret ", "Legacy better-auth base secret (restic.pass content)") + .option("-f, --legacy-secret-file ", "Path to legacy secret file (defaults to RESTIC_PASS_FILE)") + .action(async (options) => { + console.info("\n🔐 Zerobyte 2FA Re-key\n"); + + try { + const legacySecret = await resolveLegacySecret(options); + const { total, updated, errors } = await rekeyTwoFactor(legacySecret); + + if (total === 0) { + console.info("ℹ️ No two-factor records found. Nothing to re-key."); + process.exit(0); + } + + if (errors.length > 0) { + console.error(`\n❌ Re-keyed ${updated}/${total} two-factor records.`); + for (const error of errors) { + console.error(` - User ${error.userId}: ${error.error}`); + } + process.exit(1); + } + + console.info(`\n✅ Re-keyed ${updated}/${total} two-factor records successfully.`); + process.exit(0); + } catch (error) { + console.error(`\n❌ Failed to re-key 2FA secrets: ${toMessage(error)}`); + process.exit(1); + } + }); diff --git a/app/server/cli/index.ts b/app/server/cli/index.ts index fe54cd99..e5c418e1 100644 --- a/app/server/cli/index.ts +++ b/app/server/cli/index.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { changeUsernameCommand } from "./commands/change-username"; import { disable2FACommand } from "./commands/disable-2fa"; +import { rekey2FACommand } from "./commands/rekey-2fa"; import { resetPasswordCommand } from "./commands/reset-password"; const program = new Command(); @@ -9,6 +10,7 @@ program.name("zerobyte").description("Zerobyte CLI - Backup automation tool buil program.addCommand(resetPasswordCommand); program.addCommand(disable2FACommand); program.addCommand(changeUsernameCommand); +program.addCommand(rekey2FACommand); export async function runCLI(argv: string[]): Promise { const args = argv.slice(2); diff --git a/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts b/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts index 1bdf01cd..52f70d07 100644 --- a/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts +++ b/app/server/modules/lifecycle/migrations/00002-isolate-restic-passwords.ts @@ -1,7 +1,14 @@ 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 } from "../../../db/schema"; +import { + organization, + repositoriesTable, + volumesTable, + notificationDestinationsTable, + twoFactor, +} from "../../../db/schema"; import { logger } from "../../../utils/logger"; import { toMessage } from "~/server/utils/errors"; import { cryptoUtils } from "~/server/utils/crypto"; @@ -9,6 +16,7 @@ import type { RepositoryConfig } from "~/schemas/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 @@ -50,6 +58,14 @@ const legacyDecrypt = async (encryptedData: string): Promise => { 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> => { @@ -148,6 +164,33 @@ const execute = async () => { } } + // 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 }; };