Files
zerobyte/app/server/cli/commands/rekey-2fa.ts

127 lines
4.0 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string> => {
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");
const records = await db.query.twoFactor.findMany({});
const errors: Array<{ userId: string; error: string }> = [];
const updates: Array<{ id: string; userId: string; secret: string; backupCodes: string }> = [];
for (const record of records) {
try {
const decryptedSecret = await symmetricDecrypt({ key: legacyAuthSecret, data: record.secret });
const decryptedBackupCodes = await symmetricDecrypt({
key: legacyAuthSecret,
data: record.backupCodes,
});
updates.push({
id: record.id,
userId: record.userId,
secret: await symmetricEncrypt({ key: currentAuthSecret, data: decryptedSecret }),
backupCodes: await symmetricEncrypt({ key: currentAuthSecret, data: decryptedBackupCodes }),
});
} catch (error) {
errors.push({ userId: record.userId, error: toMessage(error) });
}
}
let updated = 0;
db.transaction((tx) => {
for (const record of updates) {
try {
tx.update(twoFactor)
.set({
secret: record.secret,
backupCodes: record.backupCodes,
})
.where(eq(twoFactor.id, record.id))
.run();
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 <secret>", "Legacy better-auth base secret (restic.pass content)")
.option("-f, --legacy-secret-file <path>", "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);
}
});