temp: test re-key 2fas
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled

This commit is contained in:
Nicolas Meienberger 2026-01-22 22:44:23 +01:00
parent 88fa06b503
commit 17776606ee
3 changed files with 158 additions and 1 deletions

View file

@ -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<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);
}
});

View file

@ -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<boolean> {
const args = argv.slice(2);

View file

@ -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<string> => {
return decrypted.toString();
};
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");
};
type MigrationError = { name: string; error: string };
const rekeySecrets = async (config: Record<string, unknown>): Promise<Record<string, unknown>> => {
@ -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 };
};