Files
2026-08-01 14:01:17 +02:00

44 lines
1.9 KiB
TypeScript

import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "crypto";
// Encrypts secrets at rest (LinkedIn session cookies, email passwords, API keys) so a
// leaked/copied linki.db doesn't hand those out in plaintext. Key is derived from
// NEXTAUTH_SECRET (already a required, real secret) via HKDF — no new env var needed.
//
// Format: "v1:<iv-base64>:<authTag-base64>:<ciphertext-base64>". decryptSecret() passes
// any value NOT in this format straight through unchanged, so pre-migration plaintext
// rows keep working — see lib/db.ts's encryptLegacySecretsMigration.
const VERSION = "v1";
const ALGORITHM = "aes-256-gcm";
function deriveKey(): Buffer {
const secret = process.env.NEXTAUTH_SECRET;
if (!secret) throw new Error("NEXTAUTH_SECRET must be set to encrypt/decrypt stored secrets");
return Buffer.from(hkdfSync("sha256", secret, "", "linki-secret-encryption", 32));
}
export function encryptSecret(plaintext: string): string {
const key = deriveKey();
const iv = randomBytes(12);
const cipher = createCipheriv(ALGORITHM, key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
return [VERSION, iv.toString("base64"), authTag.toString("base64"), ciphertext.toString("base64")].join(":");
}
export function decryptSecret(value: string | null): string | null {
if (value === null) return null;
if (!isEncrypted(value)) return value; // not-yet-migrated plaintext — pass through
const [, ivB64, authTagB64, dataB64] = value.split(":");
const key = deriveKey();
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivB64, "base64"));
decipher.setAuthTag(Buffer.from(authTagB64, "base64"));
const plaintext = Buffer.concat([decipher.update(Buffer.from(dataB64, "base64")), decipher.final()]);
return plaintext.toString("utf8");
}
export function isEncrypted(value: string): boolean {
return value.startsWith(`${VERSION}:`);
}