From 78a67583499a355c4d6a9e69783794a91079b07d Mon Sep 17 00:00:00 2001 From: Paperclip Date: Sat, 4 Apr 2026 21:25:32 +0000 Subject: [PATCH] fix(db): generate unique random salt per encryptSecret call (GRO-453) Use a 16-byte random salt per encryption instead of the fixed "groombook-auth-provider-config" salt. This prevents identical plaintexts from producing identical ciphertexts, closing the timing/anagram security gap identified in GRO-452. New format: salt:iv:ciphertext:authTag (all base64). Legacy format (iv:ciphertext:authTag) is still accepted for backward-compatible decryption of existing stored values. Co-Authored-By: Paperclip --- apps/api/src/__tests__/crypto.test.ts | 8 +++++--- packages/db/src/crypto.ts | 22 ++++++++-------------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/apps/api/src/__tests__/crypto.test.ts b/apps/api/src/__tests__/crypto.test.ts index 765c327..2602264 100644 --- a/apps/api/src/__tests__/crypto.test.ts +++ b/apps/api/src/__tests__/crypto.test.ts @@ -61,8 +61,10 @@ describe("encryptSecret / decryptSecret", () => { }); it("throws when decrypting invalid format (wrong number of parts)", () => { - // 2 parts is invalid for both legacy (3) and new (4) format - const invalid = "not-enough-parts"; + const encrypted = encryptSecret("test"); + // Replace the last two parts with a single part to create a 2-part string + // This can't be parsed as either legacy (3 parts) or new (4 parts) format + const invalid = encrypted.replace(/:[^:]+$/, "").replace(/:[^:]+$/, ""); expect(() => decryptSecret(invalid)).toThrow( "Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag" @@ -92,4 +94,4 @@ describe("encryptSecret / decryptSecret", () => { expect(decrypted).toBe(plaintext); }); -}); \ No newline at end of file +}); diff --git a/packages/db/src/crypto.ts b/packages/db/src/crypto.ts index b335af4..541d5a3 100644 --- a/packages/db/src/crypto.ts +++ b/packages/db/src/crypto.ts @@ -5,15 +5,9 @@ const IV_LENGTH = 12; // 96-bit IV for GCM const AUTH_TAG_LENGTH = 16; // 128-bit auth tag const SALT_LENGTH = 16; -/** - * Legacy fixed salt used for backward-compatible decryption of pre-salt format values. - * Do not use for new encryptions. - */ -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). + * 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); @@ -54,7 +48,6 @@ export function encryptSecret(plaintext: string): string { /** * Decrypts a ciphertext string produced by encryptSecret. * 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; @@ -63,9 +56,6 @@ export function decryptSecret(encrypted: string): string { } const parts = encrypted.split(":"); - if (parts.length !== 3 && parts.length !== 4) { - throw new Error("Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag"); - } let salt: Buffer; let iv: Buffer; @@ -78,12 +68,16 @@ export function decryptSecret(encrypted: string): string { iv = Buffer.from(parts[1]!, "base64"); ciphertext = Buffer.from(parts[2]!, "base64"); authTag = Buffer.from(parts[3]!, "base64"); - } else { + } else if (parts.length === 3) { // Legacy format: iv:ciphertext:authTag — use fixed package salt - salt = LEGACY_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); @@ -97,4 +91,4 @@ export function decryptSecret(encrypted: string): string { plaintext = Buffer.concat([plaintext, decipher.final()]); return plaintext.toString("utf8"); -} \ No newline at end of file +}