diff --git a/apps/api/src/__tests__/crypto.test.ts b/apps/api/src/__tests__/crypto.test.ts index 36663f3..2602264 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(); @@ -62,11 +62,12 @@ 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(/:[^:]+$/, ""); + // 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 iv:ciphertext:authTag" + "Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag" ); }); @@ -93,4 +94,4 @@ describe("encryptSecret / decryptSecret", () => { expect(decrypted).toBe(plaintext); }); -}); \ No newline at end of file +}); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2d93fbd..e663986 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -167,7 +167,6 @@ api.route("/impersonation", impersonationRouter); api.route("/admin/settings", settingsRouter); api.route("/admin/auth-provider", authProviderRouter); api.route("/admin/seed", adminSeedRouter); -api.route("/admin/auth-provider", authProviderRouter); api.route("/search", searchRouter); const port = Number(process.env.PORT ?? 3000); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 066b753..7beaaa5 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -40,6 +40,9 @@ export default defineConfig({ }, workbox: { globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"], + navigateFallbackDenylist: [ + /^\/api\/auth\/oauth2\/callback\//, + ], runtimeCaching: [ { urlPattern: /^http.*\/api\/.*/i, diff --git a/packages/db/src/crypto.ts b/packages/db/src/crypto.ts index 371c3d3..541d5a3 100644 --- a/packages/db/src/crypto.ts +++ b/packages/db/src/crypto.ts @@ -7,18 +7,15 @@ 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. + * A unique random salt is generated per encryptSecret() call and prepended to the output. */ -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); +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 +23,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 +36,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 +47,7 @@ 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). */ export function decryptSecret(encrypted: string): string { const secret = process.env.BETTER_AUTH_SECRET; @@ -57,18 +56,31 @@ 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"); + + 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 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 key = deriveKey(secret, salt); const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH, @@ -79,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 +}