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>
74 lines
2.5 KiB
TypeScript
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);
|
|
});
|