56 lines
1.7 KiB
TypeScript
56 lines
1.7 KiB
TypeScript
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
|
|
|
const ENC_PREFIX = "enc:v1:";
|
|
|
|
function getEncryptionKey() {
|
|
const secret = process.env.TOKEN_ENCRYPTION_KEY || process.env.NEXTAUTH_SECRET;
|
|
if (!secret) {
|
|
throw new Error("TOKEN_ENCRYPTION_KEY or NEXTAUTH_SECRET must be set to encrypt secrets");
|
|
}
|
|
return createHash("sha256").update(secret).digest();
|
|
}
|
|
|
|
/** Encrypt a secret for DB storage (AES-256-GCM). Idempotent if already encrypted. */
|
|
export function encryptSecret(plaintext: string): string {
|
|
if (!plaintext) return plaintext;
|
|
if (plaintext.startsWith(ENC_PREFIX)) return plaintext;
|
|
|
|
const iv = randomBytes(12);
|
|
const cipher = createCipheriv("aes-256-gcm", getEncryptionKey(), iv);
|
|
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
const tag = cipher.getAuthTag();
|
|
|
|
return [
|
|
ENC_PREFIX,
|
|
iv.toString("base64url"),
|
|
".",
|
|
tag.toString("base64url"),
|
|
".",
|
|
encrypted.toString("base64url"),
|
|
].join("");
|
|
}
|
|
|
|
/** Decrypt a stored secret. Legacy plaintext values are returned as-is. */
|
|
export function decryptSecret(value: string): string {
|
|
if (!value) return value;
|
|
if (!value.startsWith(ENC_PREFIX)) return value;
|
|
|
|
const payload = value.slice(ENC_PREFIX.length);
|
|
const [ivB64, tagB64, dataB64] = payload.split(".");
|
|
if (!ivB64 || !tagB64 || !dataB64) {
|
|
throw new Error("Invalid encrypted secret format");
|
|
}
|
|
|
|
const decipher = createDecipheriv(
|
|
"aes-256-gcm",
|
|
getEncryptionKey(),
|
|
Buffer.from(ivB64, "base64url"),
|
|
);
|
|
decipher.setAuthTag(Buffer.from(tagB64, "base64url"));
|
|
const decrypted = Buffer.concat([
|
|
decipher.update(Buffer.from(dataB64, "base64url")),
|
|
decipher.final(),
|
|
]);
|
|
return decrypted.toString("utf8");
|
|
}
|