mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-02-15 07:51:41 -05:00
113 lines
3.7 KiB
TypeScript
113 lines
3.7 KiB
TypeScript
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);
|
||
}
|
||
});
|