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
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:
parent
88fa06b503
commit
17776606ee
3 changed files with 158 additions and 1 deletions
112
app/server/cli/commands/rekey-2fa.ts
Normal file
112
app/server/cli/commands/rekey-2fa.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue