From 1c7628459f750b8078085415901e42b4d451a485 Mon Sep 17 00:00:00 2001 From: Paperclip Date: Sat, 4 Apr 2026 13:14:18 +0000 Subject: [PATCH] fix(db): use random per-encryption salt in crypto.ts (GRO-453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generate a unique 16-byte random salt for each encryptSecret() call and store it as a prefix in the ciphertext. Format changed from iv:ciphertext:authTag → salt:iv:ciphertext:authTag decryptSecret() detects legacy 3-part format and uses the fixed package salt for backward compatibility with existing encrypted rows. Co-Authored-By: Paperclip --- apps/api/src/__tests__/crypto.test.ts | 11 +++-- packages/db/src/crypto.ts | 58 ++++++++++++++++++--------- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/apps/api/src/__tests__/crypto.test.ts b/apps/api/src/__tests__/crypto.test.ts index 36663f3..765c327 100644 --- a/apps/api/src/__tests__/crypto.test.ts +++ b/apps/api/src/__tests__/crypto.test.ts @@ -24,11 +24,11 @@ describe("encryptSecret / decryptSecret", () => { expect(decrypted).toBe(plaintext); }); - it("produces output in iv:ciphertext:authTag format", () => { + it("produces output in salt:iv:ciphertext:authTag format", () => { const encrypted = encryptSecret("test"); const parts = encrypted.split(":"); - expect(parts).toHaveLength(3); + expect(parts).toHaveLength(4); // Each part should be valid base64 parts.forEach((part) => { expect(() => Buffer.from(part, "base64")).not.toThrow(); @@ -61,12 +61,11 @@ describe("encryptSecret / decryptSecret", () => { }); it("throws when decrypting invalid format (wrong number of parts)", () => { - const encrypted = encryptSecret("test"); - // Replace the last ":authTag" part by matching colon + non-colon chars at the end - const invalid = encrypted.replace(/:[^:]+$/, ""); + // 2 parts is invalid for both legacy (3) and new (4) format + const invalid = "not-enough-parts"; expect(() => decryptSecret(invalid)).toThrow( - "Invalid encrypted value format: expected iv:ciphertext:authTag" + "Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag" ); }); diff --git a/packages/db/src/crypto.ts b/packages/db/src/crypto.ts index 371c3d3..b335af4 100644 --- a/packages/db/src/crypto.ts +++ b/packages/db/src/crypto.ts @@ -6,19 +6,22 @@ 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. + * Legacy fixed salt used for backward-compatible decryption of pre-salt format values. + * Do not use for new encryptions. */ -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); +const LEGACY_PACKAGE_SALT = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH); + +/** + * Derives a 32-byte key from BETTER_AUTH_SECRET using scrypt. + * Uses the provided salt (random per encryption for new values). + */ +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: iv:ciphertext:authTag + * 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; @@ -26,7 +29,8 @@ export function encryptSecret(plaintext: string): string { throw new Error("BETTER_AUTH_SECRET environment variable is required"); } - const key = deriveKey(secret); + const salt = randomBytes(SALT_LENGTH); + const key = deriveKey(secret, salt); const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv, { @@ -38,8 +42,9 @@ export function encryptSecret(plaintext: string): string { const authTag = cipher.getAuthTag(); - // Format: base64(iv):base64(ciphertext):base64(authTag) + // Format: base64(salt):base64(iv):base64(ciphertext):base64(authTag) return [ + salt.toString("base64"), iv.toString("base64"), ciphertext.toString("base64"), authTag.toString("base64"), @@ -48,7 +53,8 @@ export function encryptSecret(plaintext: string): string { /** * Decrypts a ciphertext string produced by encryptSecret. - * Expects the format: iv:ciphertext:authTag (all base64-encoded) + * Supports both new format (salt:iv:ciphertext:authTag) and legacy format (iv:ciphertext:authTag). + * All values are base64-encoded. */ export function decryptSecret(encrypted: string): string { const secret = process.env.BETTER_AUTH_SECRET; @@ -57,18 +63,30 @@ export function decryptSecret(encrypted: string): string { } const parts = encrypted.split(":"); - if (parts.length !== 3) { - throw new Error("Invalid encrypted value format: expected iv:ciphertext:authTag"); + if (parts.length !== 3 && parts.length !== 4) { + throw new Error("Invalid encrypted value format: expected salt:iv:ciphertext:authTag or 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"); + let salt: Buffer; + let iv: Buffer; + let ciphertext: Buffer; + let authTag: Buffer; - const key = deriveKey(secret); + 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 { + // Legacy format: iv:ciphertext:authTag — use fixed package salt + salt = LEGACY_PACKAGE_SALT; + iv = Buffer.from(parts[0]!, "base64"); + ciphertext = Buffer.from(parts[1]!, "base64"); + authTag = Buffer.from(parts[2]!, "base64"); + } + + const key = deriveKey(secret, salt); const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH,