Files
zerobyte/app/server/cli/commands/rekey-2fa.ts
Nicolas Meienberger 17776606ee temp: test re-key 2fas
2026-01-22 22:44:23 +01:00

113 lines
3.7 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");
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 <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);
}
});