Files
app/scripts/rotate-totp-key.ts
BOHA 519edce373 fix: 2026-06-09 full-codebase audit hardening
Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace
the raw number|string transform idiom across every schema (the root-cause NaN bug
class); emailOrEmpty + lenient isoDateString/timeString.

Security: roles privilege-escalation closed; refresh-token family revocation on
reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices
gross VAT on all paths; orders-pdf custom-items authz.

Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse
confirm/cancel, attendance lockUserRow); uniqueness checks moved into create
transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision
timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO).

Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted
fields; dashboard invalidation gaps; stale-closure confirm bugs.

Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier;
tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit
fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes),
isolated on app_test via .env.test with a hard-throw setup guard.

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:45:26 +02:00

74 lines
2.5 KiB
TypeScript

import { PrismaClient } from "@prisma/client";
import { decryptWithKey, encryptWithKey } from "../src/utils/encryption";
const prisma = new PrismaClient();
// Reuse the dual-format decrypt/encrypt from src/utils/encryption.ts so this
// script handles BOTH wire formats: the TS `hex:hex:hex` form and the
// PHP-legacy single-base64 blob. The previous hand-rolled `decrypt` only parsed
// the TS form and threw `Buffer.from(undefined, 'hex')` on any legacy secret —
// inside the $transaction that aborted/rolled back the ENTIRE batch on the first
// legacy user. These key-parameterized helpers accept the old/new keys passed on
// the command line (the config-bound `decrypt`/`encrypt` use the env key only).
const decrypt = (ciphertext: string, keyHex: string): string =>
decryptWithKey(ciphertext, keyHex);
const encrypt = (plaintext: string, keyHex: string): string =>
encryptWithKey(plaintext, keyHex);
async function main() {
const oldKey = process.argv[2];
const newKey = process.argv[3];
const dryRun = process.argv.includes("--dry-run");
if (!oldKey || !newKey) {
console.error(
"Usage: tsx scripts/rotate-totp-key.ts <old-key-hex> <new-key-hex> [--dry-run]",
);
process.exit(1);
}
const users = await prisma.users.findMany({
where: { totp_enabled: true, totp_secret: { not: null } },
select: { id: true, username: true, totp_secret: true },
});
console.log(`Found ${users.length} users with TOTP enabled`);
if (dryRun) {
for (const user of users) {
try {
const decrypted = decrypt(user.totp_secret!, oldKey);
const reEncrypted = encrypt(decrypted, newKey);
decrypt(reEncrypted, newKey); // verify round-trip
console.log(
` [OK] ${user.username} (id=${user.id}) — decryption and re-encryption verified`,
);
} catch (e) {
console.error(` [FAIL] ${user.username} (id=${user.id}) — ${e}`);
}
}
console.log("\nDry run complete. No changes made.");
return;
}
await prisma.$transaction(async (tx) => {
for (const user of users) {
const decrypted = decrypt(user.totp_secret!, oldKey);
const reEncrypted = encrypt(decrypted, newKey);
await tx.users.update({
where: { id: user.id },
data: { totp_secret: reEncrypted },
});
console.log(` Re-encrypted TOTP for ${user.username} (id=${user.id})`);
}
});
console.log("\nAll TOTP secrets re-encrypted successfully.");
await prisma.$disconnect();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});