abac9dfe6c
- Add source code from apps/api - Add packages/db and packages/types workspace dependencies - Add GitHub Actions CI workflow (lint, typecheck, test, docker) - Generate pnpm-lock.yaml - Add .gitignore Co-Authored-By: Paperclip <noreply@paperclip.ing>
95 lines
2.9 KiB
TypeScript
95 lines
2.9 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.
|
|
* A unique random salt is generated per encryptSecret() call and prepended to the output.
|
|
*/
|
|
function deriveKey(secret: string, salt: Buffer): Buffer {
|
|
return scryptSync(secret, salt, 32);
|
|
}
|
|
|
|
/**
|
|
* Encrypts a plaintext string using AES-256-GCM.
|
|
* Returns a base64-encoded string in the format: salt: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 salt = randomBytes(SALT_LENGTH);
|
|
const key = deriveKey(secret, salt);
|
|
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(salt):base64(iv):base64(ciphertext):base64(authTag)
|
|
return [
|
|
salt.toString("base64"),
|
|
iv.toString("base64"),
|
|
ciphertext.toString("base64"),
|
|
authTag.toString("base64"),
|
|
].join(":");
|
|
}
|
|
|
|
/**
|
|
* Decrypts a ciphertext string produced by encryptSecret.
|
|
* Supports both new format (salt:iv:ciphertext:authTag) and legacy format (iv:ciphertext:authTag).
|
|
*/
|
|
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(":");
|
|
|
|
let salt: Buffer;
|
|
let iv: Buffer;
|
|
let ciphertext: Buffer;
|
|
let authTag: Buffer;
|
|
|
|
if (parts.length === 4) {
|
|
// New format: salt:iv:ciphertext:authTag
|
|
salt = Buffer.from(parts[0]!, "base64");
|
|
iv = Buffer.from(parts[1]!, "base64");
|
|
ciphertext = Buffer.from(parts[2]!, "base64");
|
|
authTag = Buffer.from(parts[3]!, "base64");
|
|
} else if (parts.length === 3) {
|
|
// Legacy format: iv:ciphertext:authTag — use fixed package salt
|
|
salt = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH);
|
|
iv = Buffer.from(parts[0]!, "base64");
|
|
ciphertext = Buffer.from(parts[1]!, "base64");
|
|
authTag = Buffer.from(parts[2]!, "base64");
|
|
} else {
|
|
throw new Error(
|
|
"Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag"
|
|
);
|
|
}
|
|
|
|
const key = deriveKey(secret, salt);
|
|
|
|
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");
|
|
}
|