This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
@@ -9,132 +9,142 @@
|
||||
* npx tsx scripts/migrate-aes-cbc-to-gcm.ts
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHmac,
|
||||
randomBytes,
|
||||
timingSafeEqual,
|
||||
} from "node:crypto";
|
||||
import { prisma } from "../src/lib/prisma";
|
||||
|
||||
function getKey(appKey: string): Buffer {
|
||||
const raw = appKey.startsWith("base64:")
|
||||
? Buffer.from(appKey.slice("base64:".length), "base64")
|
||||
: Buffer.from(appKey, "utf8");
|
||||
if (raw.length !== 32) {
|
||||
throw new Error(`APP_KEY must decode to 32 bytes (got ${raw.length})`);
|
||||
}
|
||||
return raw;
|
||||
const raw = appKey.startsWith("base64:")
|
||||
? Buffer.from(appKey.slice("base64:".length), "base64")
|
||||
: Buffer.from(appKey, "utf8");
|
||||
if (raw.length !== 32) {
|
||||
throw new Error(`APP_KEY must decode to 32 bytes (got ${raw.length})`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** OLD: AES-256-CBC decrypt with HMAC-SHA256 verification. */
|
||||
function decryptCbc(payload: string, key: Buffer, serialize = true): string {
|
||||
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as {
|
||||
iv: string;
|
||||
value: string;
|
||||
mac: string;
|
||||
};
|
||||
const expected = createHmac("sha256", key)
|
||||
.update(json.iv + json.value)
|
||||
.digest("hex");
|
||||
const a = Buffer.from(expected, "hex");
|
||||
const b = Buffer.from(json.mac, "hex");
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error("The MAC is invalid (CBC payload).");
|
||||
}
|
||||
const iv = Buffer.from(json.iv, "base64");
|
||||
const decipher = createDecipheriv("aes-256-cbc", key, iv);
|
||||
const plain = decipher.update(json.value, "base64", "utf8") + decipher.final("utf8");
|
||||
return serialize ? phpUnserializeString(plain) : plain;
|
||||
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as {
|
||||
iv: string;
|
||||
value: string;
|
||||
mac: string;
|
||||
};
|
||||
const expected = createHmac("sha256", key)
|
||||
.update(json.iv + json.value)
|
||||
.digest("hex");
|
||||
const a = Buffer.from(expected, "hex");
|
||||
const b = Buffer.from(json.mac, "hex");
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error("The MAC is invalid (CBC payload).");
|
||||
}
|
||||
const iv = Buffer.from(json.iv, "base64");
|
||||
const decipher = createDecipheriv("aes-256-cbc", key, iv);
|
||||
const plain =
|
||||
decipher.update(json.value, "base64", "utf8") + decipher.final("utf8");
|
||||
return serialize ? phpUnserializeString(plain) : plain;
|
||||
}
|
||||
|
||||
/** NEW: AES-256-GCM encrypt (mirrors current LaravelEncrypter). */
|
||||
function encryptGcm(plaintext: string, key: Buffer, serialize = true): string {
|
||||
const iv = randomBytes(12);
|
||||
const data = serialize ? phpSerializeString(plaintext) : plaintext;
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const valueB64 = cipher.update(data, "utf8", "base64") + cipher.final("base64");
|
||||
const tag = cipher.getAuthTag();
|
||||
const ivB64 = iv.toString("base64");
|
||||
const tagB64 = tag.toString("base64");
|
||||
const payload = JSON.stringify({ iv: ivB64, value: valueB64, tag: tagB64 });
|
||||
return Buffer.from(payload, "utf8").toString("base64");
|
||||
const iv = randomBytes(12);
|
||||
const data = serialize ? phpSerializeString(plaintext) : plaintext;
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const valueB64 =
|
||||
cipher.update(data, "utf8", "base64") + cipher.final("base64");
|
||||
const tag = cipher.getAuthTag();
|
||||
const ivB64 = iv.toString("base64");
|
||||
const tagB64 = tag.toString("base64");
|
||||
const payload = JSON.stringify({ iv: ivB64, value: valueB64, tag: tagB64 });
|
||||
return Buffer.from(payload, "utf8").toString("base64");
|
||||
}
|
||||
|
||||
/** Tries to decrypt a payload with the NEW GCM logic; if it works, skip. */
|
||||
function isAlreadyGcm(payload: string, key: Buffer): boolean {
|
||||
try {
|
||||
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
||||
if (!json.tag && !json.mac) return false; // can't determine format
|
||||
if (json.tag) return true; // has authTag => GCM
|
||||
return false; // has mac => CBC
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
function isAlreadyGcm(payload: string, _key: Buffer): boolean {
|
||||
try {
|
||||
const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
|
||||
if (!json.tag && !json.mac) return false; // can't determine format
|
||||
if (json.tag) return true; // has authTag => GCM
|
||||
return false; // has mac => CBC
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const appKey = process.env.APP_KEY;
|
||||
if (!appKey) {
|
||||
console.error("APP_KEY environment variable is required.");
|
||||
process.exit(1);
|
||||
}
|
||||
const key = getKey(appKey);
|
||||
const appKey = process.env.APP_KEY;
|
||||
if (!appKey) {
|
||||
console.error("APP_KEY environment variable is required.");
|
||||
process.exit(1);
|
||||
}
|
||||
const key = getKey(appKey);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: { twoFactorSecret: { not: null } },
|
||||
select: { id: true, twoFactorSecret: true },
|
||||
});
|
||||
const users = await prisma.user.findMany({
|
||||
where: { twoFactorSecret: { not: null } },
|
||||
select: { id: true, twoFactorSecret: true },
|
||||
});
|
||||
|
||||
console.log(`Found ${users.length} user(s) with a twoFactorSecret.`);
|
||||
console.log(`Found ${users.length} user(s) with a twoFactorSecret.`);
|
||||
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const user of users) {
|
||||
if (!user.twoFactorSecret) continue;
|
||||
for (const user of users) {
|
||||
if (!user.twoFactorSecret) continue;
|
||||
|
||||
if (isAlreadyGcm(user.twoFactorSecret, key)) {
|
||||
console.log(` [SKIP] User ${user.id} — already GCM`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (isAlreadyGcm(user.twoFactorSecret, key)) {
|
||||
console.log(` [SKIP] User ${user.id} — already GCM`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const plaintext = decryptCbc(user.twoFactorSecret, key);
|
||||
const reEncrypted = encryptGcm(plaintext, key);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { twoFactorSecret: reEncrypted },
|
||||
});
|
||||
console.log(` [OK] User ${user.id} — migrated`);
|
||||
migrated++;
|
||||
} catch (err) {
|
||||
console.error(` [FAIL] User ${user.id} — ${err}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const plaintext = decryptCbc(user.twoFactorSecret, key);
|
||||
const reEncrypted = encryptGcm(plaintext, key);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { twoFactorSecret: reEncrypted },
|
||||
});
|
||||
console.log(` [OK] User ${user.id} — migrated`);
|
||||
migrated++;
|
||||
} catch (err) {
|
||||
console.error(` [FAIL] User ${user.id} — ${err}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone: ${migrated} migrated, ${skipped} skipped, ${errors} errors.`);
|
||||
if (errors > 0) process.exit(1);
|
||||
console.log(
|
||||
`\nDone: ${migrated} migrated, ${skipped} skipped, ${errors} errors.`,
|
||||
);
|
||||
if (errors > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
|
||||
/* ---- helpers (mirrored from laravel-encrypter.ts) ---- */
|
||||
|
||||
function phpSerializeString(value: string): string {
|
||||
return `s:${Buffer.byteLength(value, "utf8")}:"${value}";`;
|
||||
return `s:${Buffer.byteLength(value, "utf8")}:"${value}";`;
|
||||
}
|
||||
|
||||
function phpUnserializeString(serialized: string): string {
|
||||
const m = /^s:(\d+):"/.exec(serialized);
|
||||
if (!m) throw new Error("Not a serialized PHP string");
|
||||
const byteLen = Number(m[1]);
|
||||
const start = m[0].length;
|
||||
const bytes = Buffer.from(serialized, "utf8").subarray(
|
||||
Buffer.byteLength(serialized.slice(0, start), "utf8"),
|
||||
);
|
||||
return bytes.subarray(0, byteLen).toString("utf8");
|
||||
const m = /^s:(\d+):"/.exec(serialized);
|
||||
if (!m) throw new Error("Not a serialized PHP string");
|
||||
const byteLen = Number(m[1]);
|
||||
const start = m[0].length;
|
||||
const bytes = Buffer.from(serialized, "utf8").subarray(
|
||||
Buffer.byteLength(serialized.slice(0, start), "utf8"),
|
||||
);
|
||||
return bytes.subarray(0, byteLen).toString("utf8");
|
||||
}
|
||||
Reference in new issue
Block a user