This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
app/packages/db/src/crypto.ts
T
groombook-engineer[bot] f4acf5be23 feat(db): auth_provider_config table + AES-256-GCM encryption helpers
Renumbered migration 0021 → 0023 to resolve conflict with pet_image and
logo_key migrations that landed on main after this branch was created.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-02 17:16:35 +00:00

82 lines
2.6 KiB
TypeScript

import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12; // 96-bit IV for GCM
const AUTH_TAG_LENGTH = 16; // 128-bit auth tag
const SALT_LENGTH = 16;
/**
* Derives a 32-byte key from BETTER_AUTH_SECRET using scrypt.
* BETTER_AUTH_SECRET is used as the password, with a fixed salt derived from the package name.
*/
function deriveKey(secret: string): Buffer {
// Use a fixed salt derived from the package name for key derivation
// This gives us stable key derivation without storing an extra salt
const packageSalt = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH);
return scryptSync(secret, packageSalt, 32);
}
/**
* Encrypts a plaintext string using AES-256-GCM.
* Returns a base64-encoded string in the format: iv:ciphertext:authTag
*/
export function encryptSecret(plaintext: string): string {
const secret = process.env.BETTER_AUTH_SECRET;
if (!secret) {
throw new Error("BETTER_AUTH_SECRET environment variable is required");
}
const key = deriveKey(secret);
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv, {
authTagLength: AUTH_TAG_LENGTH,
});
let ciphertext = cipher.update(plaintext, "utf8");
ciphertext = Buffer.concat([ciphertext, cipher.final()]);
const authTag = cipher.getAuthTag();
// Format: base64(iv):base64(ciphertext):base64(authTag)
return [
iv.toString("base64"),
ciphertext.toString("base64"),
authTag.toString("base64"),
].join(":");
}
/**
* Decrypts a ciphertext string produced by encryptSecret.
* Expects the format: iv:ciphertext:authTag (all base64-encoded)
*/
export function decryptSecret(encrypted: string): string {
const secret = process.env.BETTER_AUTH_SECRET;
if (!secret) {
throw new Error("BETTER_AUTH_SECRET environment variable is required");
}
const parts = encrypted.split(":");
if (parts.length !== 3) {
throw new Error("Invalid encrypted value format: expected iv:ciphertext:authTag");
}
const ivBase64 = parts[0]!;
const ciphertextBase64 = parts[1]!;
const authTagBase64 = parts[2]!;
const iv = Buffer.from(ivBase64, "base64");
const ciphertext = Buffer.from(ciphertextBase64, "base64");
const authTag = Buffer.from(authTagBase64, "base64");
const key = deriveKey(secret);
const decipher = createDecipheriv(ALGORITHM, key, iv, {
authTagLength: AUTH_TAG_LENGTH,
});
decipher.setAuthTag(authTag);
let plaintext = decipher.update(ciphertext);
plaintext = Buffer.concat([plaintext, decipher.final()]);
return plaintext.toString("utf8");
}