diff --git a/.env.example b/.env.example index c0205d9..2e36dd6 100644 --- a/.env.example +++ b/.env.example @@ -90,6 +90,17 @@ AUTHENTIK_PORT_HTTPS=9443 JWT_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS JWT_EXPIRATION=24h +# ====================== +# Encryption (Credential Security) +# ====================== +# CRITICAL: Generate a random 32-byte (256-bit) encryption key +# This key is used for AES-256-GCM encryption of OAuth tokens and sensitive data +# Command to generate: openssl rand -hex 32 +# SECURITY: Never commit this key to version control +# SECURITY: Use different keys for development, staging, and production +# SECURITY: Store production keys in a secure secrets manager (see docs/design/credential-security.md) +ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_GENERATE_WITH_OPENSSL_RAND_HEX_32 + # ====================== # Ollama (Optional AI Service) # ====================== diff --git a/apps/api/prisma/migrations/20260207_encrypt_account_tokens/migration.sql b/apps/api/prisma/migrations/20260207_encrypt_account_tokens/migration.sql new file mode 100644 index 0000000..e4fd3ba --- /dev/null +++ b/apps/api/prisma/migrations/20260207_encrypt_account_tokens/migration.sql @@ -0,0 +1,37 @@ +-- Encrypt existing plaintext Account tokens +-- This migration adds an encryption_version column and marks existing records for encryption +-- The actual encryption happens via Prisma middleware on first read/write + +-- Add encryption_version column to track encryption state +-- NULL = not encrypted (legacy plaintext) +-- 'aes' = AES-256-GCM encrypted +-- 'vault' = OpenBao Transit encrypted (Phase 2) +ALTER TABLE accounts ADD COLUMN IF NOT EXISTS encryption_version VARCHAR(20); + +-- Create index for efficient queries filtering by encryption status +-- This index is also declared in Prisma schema (@@index([encryptionVersion])) +-- Using CREATE INDEX IF NOT EXISTS for idempotency +CREATE INDEX IF NOT EXISTS "accounts_encryption_version_idx" ON accounts(encryption_version); + +-- Verify index was created successfully by running: +-- SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'accounts' AND indexname = 'accounts_encryption_version_idx'; + +-- Update statistics for query planner +ANALYZE accounts; + +-- Migration Note: +-- This migration does NOT encrypt data in-place to avoid downtime and data corruption risks. +-- Instead, the Prisma middleware (account-encryption.middleware.ts) handles encryption: +-- +-- 1. On READ: Detects format (plaintext vs encrypted) and decrypts if needed +-- 2. On WRITE: Encrypts tokens and sets encryption_version = 'aes' +-- 3. Backward compatible: Plaintext tokens (encryption_version = NULL) are passed through unchanged +-- +-- To actively encrypt existing tokens, run the companion script: +-- node scripts/encrypt-account-tokens.js +-- +-- This approach ensures: +-- - Zero downtime migration +-- - No risk of corrupting tokens during bulk encryption +-- - Progressive encryption as tokens are accessed/refreshed +-- - Easy rollback (middleware is idempotent) diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index cba237a..6015c2b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -783,6 +783,7 @@ model Account { refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at") @db.Timestamptz scope String? password String? + encryptionVersion String? @map("encryption_version") @db.VarChar(20) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz @@ -791,6 +792,7 @@ model Account { @@unique([providerId, accountId]) @@index([userId]) + @@index([encryptionVersion]) @@map("accounts") } diff --git a/apps/api/src/federation/crypto.service.ts b/apps/api/src/federation/crypto.service.ts index 8acee35..7710be8 100644 --- a/apps/api/src/federation/crypto.service.ts +++ b/apps/api/src/federation/crypto.service.ts @@ -26,6 +26,27 @@ export class CryptoService { } this.encryptionKey = Buffer.from(keyHex, "hex"); + + // Validate key works by performing encrypt/decrypt round-trip + // This prevents silent data loss if the key is changed after data is encrypted + try { + const testValue = "encryption_key_validation_test"; + const encrypted = this.encrypt(testValue); + const decrypted = this.decrypt(encrypted); + + if (decrypted !== testValue) { + throw new Error("Encryption key validation failed: round-trip mismatch"); + } + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : "Unknown encryption key validation error"; + throw new Error( + `ENCRYPTION_KEY validation failed: ${errorMsg}. ` + + "If you recently changed the key, existing encrypted data cannot be decrypted. " + + "See docs/design/credential-security.md for key rotation procedures." + ); + } + this.logger.log("Crypto service initialized with AES-256-GCM encryption"); } diff --git a/apps/api/src/prisma/account-encryption.middleware.spec.ts b/apps/api/src/prisma/account-encryption.middleware.spec.ts new file mode 100644 index 0000000..4bc0ee1 --- /dev/null +++ b/apps/api/src/prisma/account-encryption.middleware.spec.ts @@ -0,0 +1,576 @@ +/** + * Account Encryption Middleware Tests + * + * Tests transparent encryption/decryption of OAuth tokens in Account model + * using Prisma middleware. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { PrismaClient } from "@prisma/client"; +import { CryptoService } from "../federation/crypto.service"; +import { ConfigService } from "@nestjs/config"; +import { registerAccountEncryptionMiddleware } from "./account-encryption.middleware"; + +describe("AccountEncryptionMiddleware", () => { + let mockPrisma: any; + let cryptoService: CryptoService; + let mockConfigService: Partial; + let middlewareFunction: any; + + beforeAll(() => { + // Mock ConfigService with a valid test encryption key + mockConfigService = { + get: vi.fn((key: string) => { + if (key === "ENCRYPTION_KEY") { + // Valid 64-character hex string (32 bytes) + return "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + } + return null; + }), + }; + + cryptoService = new CryptoService(mockConfigService as ConfigService); + + // Create a mock Prisma client + mockPrisma = { + $use: vi.fn((fn) => { + middlewareFunction = fn; + }), + }; + + // Register the middleware + registerAccountEncryptionMiddleware(mockPrisma, cryptoService); + }); + + afterAll(async () => { + // No cleanup needed for mocks + }); + + describe("Encryption on Create", () => { + it("should encrypt accessToken on account creation", async () => { + const plainAccessToken = "test-access-token-12345"; + const mockParams = { + model: "Account", + action: "create" as const, + args: { + data: { + userId: "test-user-id", + accountId: "test-account-id", + providerId: "github", + accessToken: plainAccessToken, + }, + }, + }; + + // Middleware should modify args + const result = await callMiddleware(mockPrisma, mockParams); + + // Verify accessToken is encrypted (starts with hex:hex:hex format) + expect(result.args.data.accessToken).toBeDefined(); + expect(result.args.data.accessToken).not.toBe(plainAccessToken); + expect(result.args.data.accessToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/); + }); + + it("should encrypt refreshToken on account creation", async () => { + const plainRefreshToken = "test-refresh-token-67890"; + const mockParams = { + model: "Account", + action: "create" as const, + args: { + data: { + userId: "test-user-id", + accountId: "test-account-id", + providerId: "github", + refreshToken: plainRefreshToken, + }, + }, + }; + + const result = await callMiddleware(mockPrisma, mockParams); + + expect(result.args.data.refreshToken).toBeDefined(); + expect(result.args.data.refreshToken).not.toBe(plainRefreshToken); + expect(result.args.data.refreshToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/); + }); + + it("should encrypt idToken on account creation", async () => { + const plainIdToken = "test-id-token-abcdef"; + const mockParams = { + model: "Account", + action: "create" as const, + args: { + data: { + userId: "test-user-id", + accountId: "test-account-id", + providerId: "oauth", + idToken: plainIdToken, + }, + }, + }; + + const result = await callMiddleware(mockPrisma, mockParams); + + expect(result.args.data.idToken).toBeDefined(); + expect(result.args.data.idToken).not.toBe(plainIdToken); + expect(result.args.data.idToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/); + }); + + it("should encrypt all three tokens when present", async () => { + const mockParams = { + model: "Account", + action: "create" as const, + args: { + data: { + userId: "test-user-id", + accountId: "test-account-id", + providerId: "oauth", + accessToken: "access-123", + refreshToken: "refresh-456", + idToken: "id-789", + }, + }, + }; + + const result = await callMiddleware(mockPrisma, mockParams); + + expect(result.args.data.accessToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/); + expect(result.args.data.refreshToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/); + expect(result.args.data.idToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/); + }); + + it("should handle null tokens gracefully", async () => { + const mockParams = { + model: "Account", + action: "create" as const, + args: { + data: { + userId: "test-user-id", + accountId: "test-account-id", + providerId: "github", + accessToken: null, + refreshToken: null, + idToken: null, + }, + }, + }; + + const result = await callMiddleware(mockPrisma, mockParams); + + expect(result.args.data.accessToken).toBeNull(); + expect(result.args.data.refreshToken).toBeNull(); + expect(result.args.data.idToken).toBeNull(); + }); + + it("should handle undefined tokens gracefully", async () => { + const mockParams = { + model: "Account", + action: "create" as const, + args: { + data: { + userId: "test-user-id", + accountId: "test-account-id", + providerId: "github", + }, + }, + }; + + const result = await callMiddleware(mockPrisma, mockParams); + + expect(result.args.data.accessToken).toBeUndefined(); + expect(result.args.data.refreshToken).toBeUndefined(); + expect(result.args.data.idToken).toBeUndefined(); + }); + }); + + describe("Encryption on Update", () => { + it("should encrypt accessToken on account update", async () => { + const plainAccessToken = "updated-access-token"; + const mockParams = { + model: "Account", + action: "update" as const, + args: { + where: { id: "account-id" }, + data: { + accessToken: plainAccessToken, + }, + }, + }; + + const result = await callMiddleware(mockPrisma, mockParams); + + expect(result.args.data.accessToken).toBeDefined(); + expect(result.args.data.accessToken).not.toBe(plainAccessToken); + expect(result.args.data.accessToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/); + }); + + it("should handle updateMany action", async () => { + const mockParams = { + model: "Account", + action: "updateMany" as const, + args: { + where: { providerId: "github" }, + data: { + accessToken: "new-token", + }, + }, + }; + + const result = await callMiddleware(mockPrisma, mockParams); + + expect(result.args.data.accessToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/); + }); + + it("should not encrypt already encrypted tokens (idempotent)", async () => { + // Simulate a token that's already encrypted + const encryptedToken = cryptoService.encrypt("original-token"); + const mockParams = { + model: "Account", + action: "update" as const, + args: { + where: { id: "account-id" }, + data: { + accessToken: encryptedToken, + }, + }, + }; + + const result = await callMiddleware(mockPrisma, mockParams); + + // Should remain unchanged if already encrypted + expect(result.args.data.accessToken).toBe(encryptedToken); + }); + + it("should handle upsert action (encrypt both create and update)", async () => { + const plainCreateToken = "create-token"; + const plainUpdateToken = "update-token"; + + const mockParams = { + model: "Account", + action: "upsert" as const, + args: { + where: { id: "account-id" }, + create: { + userId: "user-id", + accountId: "account-id", + providerId: "github", + accessToken: plainCreateToken, + }, + update: { + accessToken: plainUpdateToken, + }, + }, + }; + + const result = await callMiddleware(mockPrisma, mockParams); + + // Both create and update data should be encrypted + expect(result.args.create.accessToken).toBeDefined(); + expect(result.args.create.accessToken).not.toBe(plainCreateToken); + expect(result.args.create.accessToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/); + + expect(result.args.update.accessToken).toBeDefined(); + expect(result.args.update.accessToken).not.toBe(plainUpdateToken); + expect(result.args.update.accessToken).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/); + }); + }); + + describe("Decryption on Read", () => { + it("should decrypt accessToken on findUnique", async () => { + const plainToken = "my-access-token"; + const encryptedToken = cryptoService.encrypt(plainToken); + + const mockParams = { + model: "Account", + action: "findUnique" as const, + args: { + where: { id: "account-id" }, + }, + }; + + // Mock database returning encrypted data with encryptionVersion + const mockNext = vi.fn(async () => ({ + id: "account-id", + userId: "user-id", + accountId: "account-id", + providerId: "github", + accessToken: encryptedToken, + refreshToken: null, + idToken: null, + encryptionVersion: "aes", + })); + + // Call middleware - it should decrypt the result + const result = (await middlewareFunction(mockParams, mockNext)) as any; + + expect(mockNext).toHaveBeenCalledWith(mockParams); + expect(result.accessToken).toBe(plainToken); // Decrypted by middleware + expect(result.encryptionVersion).toBe("aes"); + }); + + it("should decrypt all tokens on findMany", async () => { + const plainAccess = "access-token"; + const plainRefresh = "refresh-token"; + const plainId = "id-token"; + + const mockParams = { + model: "Account", + action: "findMany" as const, + args: { + where: { providerId: "github" }, + }, + }; + + // Mock database returning multiple encrypted records + const mockNext = vi.fn(async () => [ + { + id: "account-1", + userId: "user-id", + accountId: "account-1", + providerId: "github", + accessToken: cryptoService.encrypt(plainAccess), + refreshToken: cryptoService.encrypt(plainRefresh), + idToken: cryptoService.encrypt(plainId), + encryptionVersion: "aes", + }, + ]); + + const result = (await middlewareFunction(mockParams, mockNext)) as any[]; + + expect(mockNext).toHaveBeenCalledWith(mockParams); + expect(result[0].accessToken).toBe(plainAccess); + expect(result[0].refreshToken).toBe(plainRefresh); + expect(result[0].idToken).toBe(plainId); + }); + + it("should handle null tokens on read", async () => { + const mockParams = { + model: "Account", + action: "findUnique" as const, + args: { + where: { id: "account-id" }, + }, + }; + + const mockNext = vi.fn(async () => ({ + id: "account-id", + userId: "user-id", + accountId: "account-id", + providerId: "github", + accessToken: null, + refreshToken: null, + idToken: null, + encryptionVersion: null, + })); + + const result = (await middlewareFunction(mockParams, mockNext)) as any; + + expect(result.accessToken).toBeNull(); + expect(result.refreshToken).toBeNull(); + expect(result.idToken).toBeNull(); + }); + + it("should handle legacy plaintext tokens (backward compatibility)", async () => { + // Simulate old data without encryptionVersion field + const plaintextToken = "legacy-plaintext-token"; + + const mockParams = { + model: "Account", + action: "findUnique" as const, + args: { + where: { id: "account-id" }, + }, + }; + + const mockNext = vi.fn(async () => ({ + id: "account-id", + userId: "user-id", + accountId: "account-id", + providerId: "github", + accessToken: plaintextToken, + refreshToken: null, + idToken: null, + encryptionVersion: null, // No encryption version = plaintext + })); + + const result = (await middlewareFunction(mockParams, mockNext)) as any; + + // Should pass through unchanged (no encryptionVersion) + expect(result.accessToken).toBe(plaintextToken); + }); + + it("should handle vault ciphertext format (future-proofing)", async () => { + // Simulate future Transit encryption format + const vaultCiphertext = "vault:v1:base64encodeddata"; + + const mockParams = { + model: "Account", + action: "findUnique" as const, + args: { + where: { id: "account-id" }, + }, + }; + + const mockNext = vi.fn(async () => ({ + id: "account-id", + userId: "user-id", + accountId: "account-id", + providerId: "oauth", + accessToken: vaultCiphertext, + refreshToken: null, + idToken: null, + encryptionVersion: "vault", // Future: vault encryption + })); + + const result = (await middlewareFunction(mockParams, mockNext)) as any; + + // Should pass through unchanged (vault not implemented yet) + expect(result.accessToken).toBe(vaultCiphertext); + }); + + it("should use encryptionVersion as primary discriminator", async () => { + // Even if token looks like AES format, should not decrypt if encryptionVersion != 'aes' + const fakeEncryptedToken = "abc123:def456:789ghi"; // Looks like AES format + + const mockParams = { + model: "Account", + action: "findUnique" as const, + args: { + where: { id: "account-id" }, + }, + }; + + const mockNext = vi.fn(async () => ({ + id: "account-id", + userId: "user-id", + accountId: "account-id", + providerId: "github", + accessToken: fakeEncryptedToken, + refreshToken: null, + idToken: null, + encryptionVersion: null, // No encryption version + })); + + const result = (await middlewareFunction(mockParams, mockNext)) as any; + + // Should NOT attempt to decrypt (encryptionVersion is null) + expect(result.accessToken).toBe(fakeEncryptedToken); + }); + + it("should handle corrupted encrypted data gracefully", async () => { + // Test with malformed/corrupted encrypted token + const corruptedToken = "deadbeef:cafebabe:corrupted_data_xyz"; // Valid format but wrong data + + const mockParams = { + model: "Account", + action: "findUnique" as const, + args: { + where: { id: "account-id" }, + }, + }; + + const mockNext = vi.fn(async () => ({ + id: "account-id", + userId: "user-id", + accountId: "account-id", + providerId: "github", + accessToken: corruptedToken, + refreshToken: null, + idToken: null, + encryptionVersion: "aes", // Marked as encrypted + })); + + // Should not throw - just log error and pass through + const result = (await middlewareFunction(mockParams, mockNext)) as any; + + // Token should remain unchanged if decryption fails + expect(result.accessToken).toBe(corruptedToken); + expect(result.encryptionVersion).toBe("aes"); + }); + + it("should handle completely malformed encrypted format", async () => { + // Test with data that doesn't match expected format at all + const malformedToken = "this:is:not:valid:encrypted:data:too:many:parts"; + + const mockParams = { + model: "Account", + action: "findUnique" as const, + args: { + where: { id: "account-id" }, + }, + }; + + const mockNext = vi.fn(async () => ({ + id: "account-id", + userId: "user-id", + accountId: "account-id", + providerId: "github", + accessToken: malformedToken, + refreshToken: null, + idToken: null, + encryptionVersion: "aes", + })); + + // Should not throw - decryption will fail and token passes through + const result = (await middlewareFunction(mockParams, mockNext)) as any; + + expect(result.accessToken).toBe(malformedToken); + }); + }); + + describe("Non-Account Models", () => { + it("should not process other models", async () => { + const mockParams = { + model: "User", + action: "create" as const, + args: { + data: { + email: "test@example.com", + name: "Test User", + }, + }, + }; + + const result = await callMiddleware(mockPrisma, mockParams); + + // Should pass through unchanged + expect(result.args.data).toEqual({ + email: "test@example.com", + name: "Test User", + }); + }); + + it("should not process Account queries without token fields", async () => { + const mockParams = { + model: "Account", + action: "findUnique" as const, + args: { + where: { id: "account-id" }, + select: { + id: true, + providerId: true, + }, + }, + }; + + const result = await callMiddleware(mockPrisma, mockParams); + + // Should pass through without modification + expect(result.args.select).toEqual({ + id: true, + providerId: true, + }); + }); + }); + + // Helper function to simulate middleware execution for write operations + async function callMiddleware(client: any, params: any) { + if (!middlewareFunction) { + throw new Error("Middleware not registered"); + } + + // Call middleware with a mock next function that returns the params unchanged + // This is useful for testing write operations where we check if data was encrypted + return middlewareFunction(params, async (p: any) => p); + } +}); diff --git a/apps/api/src/prisma/account-encryption.middleware.ts b/apps/api/src/prisma/account-encryption.middleware.ts new file mode 100644 index 0000000..04adf10 --- /dev/null +++ b/apps/api/src/prisma/account-encryption.middleware.ts @@ -0,0 +1,263 @@ +/** + * Account Encryption Middleware + * + * Prisma middleware that transparently encrypts/decrypts OAuth tokens + * in the Account table using AES-256-GCM encryption. + * + * Encryption happens on: + * - create: New account records + * - update/updateMany: Token updates + * - upsert: Both create and update data + * + * Decryption happens on: + * - findUnique/findMany/findFirst: Read operations + * + * Format detection: + * - encryptionVersion field is the primary discriminator + * - `aes` = AES-256-GCM encrypted + * - `vault` = OpenBao Transit encrypted (future, Phase 2) + * - null/undefined = Legacy plaintext (backward compatible) + */ + +import { Logger } from "@nestjs/common"; +import type { PrismaClient } from "@prisma/client"; +import type { CryptoService } from "../federation/crypto.service"; + +/** + * Token fields to encrypt/decrypt in Account model + */ +const TOKEN_FIELDS = ["accessToken", "refreshToken", "idToken"] as const; + +/** + * Prisma middleware parameters interface + */ +interface MiddlewareParams { + model?: string; + action: string; + args: { + data?: Record; + where?: Record; + select?: Record; + create?: Record; + update?: Record; + }; + dataPath: string[]; + runInTransaction: boolean; +} + +/** + * Account data with token fields + */ +interface AccountData extends Record { + accessToken?: string | null; + refreshToken?: string | null; + idToken?: string | null; + encryptionVersion?: string | null; +} + +/** + * Register account encryption middleware on Prisma client + * + * @param prisma - Prisma client instance + * @param cryptoService - Crypto service for encryption/decryption + */ +export function registerAccountEncryptionMiddleware( + prisma: PrismaClient, + cryptoService: CryptoService +): void { + const logger = new Logger("AccountEncryptionMiddleware"); + + // TODO: Replace with Prisma Client Extensions (https://www.prisma.io/docs/concepts/components/prisma-client/client-extensions) + // when stable. Client extensions provide a type-safe alternative to middleware without requiring + // type assertions or eslint-disable directives. Migration path: + // 1. Wait for Prisma 6.x stable release with full extension support + // 2. Create extension using prisma.$extends({ query: { account: { ... } } }) + // 3. Remove this middleware and eslint-disable comments + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + (prisma as any).$use( + async (params: MiddlewareParams, next: (params: MiddlewareParams) => Promise) => { + // Only process Account model operations + if (params.model !== "Account") { + return next(params); + } + + // Encrypt on write operations + if ( + params.action === "create" || + params.action === "update" || + params.action === "updateMany" + ) { + if (params.args.data) { + encryptTokens(params.args.data as AccountData, cryptoService); + } + } else if (params.action === "upsert") { + // Handle upsert - encrypt both create and update data + if (params.args.create) { + encryptTokens(params.args.create as AccountData, cryptoService); + } + if (params.args.update) { + encryptTokens(params.args.update as AccountData, cryptoService); + } + } + + // Execute query + const result = await next(params); + + // Decrypt on read operations + if (params.action === "findUnique" || params.action === "findFirst") { + if (result && typeof result === "object") { + decryptTokens(result as AccountData, cryptoService, logger); + } + } else if (params.action === "findMany") { + if (Array.isArray(result)) { + result.forEach((account: unknown) => { + if (account && typeof account === "object") { + decryptTokens(account as AccountData, cryptoService, logger); + } + }); + } + } + + return result; + } + ); +} + +/** + * Encrypt token fields in account data + * Modifies data in-place + * + * @param data - Account data object + * @param cryptoService - Crypto service + */ +function encryptTokens(data: AccountData, cryptoService: CryptoService): void { + let encrypted = false; + + TOKEN_FIELDS.forEach((field) => { + const value = data[field]; + + // Skip null/undefined values + if (value == null) { + return; + } + + // Skip if already encrypted (idempotent) + if (typeof value === "string" && isEncrypted(value)) { + return; + } + + // Encrypt plaintext value + if (typeof value === "string") { + data[field] = cryptoService.encrypt(value); + encrypted = true; + } + }); + + // Mark as encrypted with AES if any tokens were encrypted + // Note: This condition is necessary because TypeScript's control flow analysis doesn't track + // the `encrypted` flag through forEach closures. The flag starts as false and is only set to + // true when a token is actually encrypted. This prevents setting encryptionVersion='aes' on + // records that have no tokens or only null/already-encrypted tokens (idempotent safety). + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (encrypted) { + data.encryptionVersion = "aes"; + } +} + +/** + * Decrypt token fields in account record + * Modifies record in-place + * + * Uses encryptionVersion field as primary discriminator to determine + * if decryption is needed, falling back to pattern matching for + * records without the field (migration compatibility). + * + * @param account - Account record + * @param cryptoService - Crypto service + * @param logger - NestJS logger for error reporting + */ +function decryptTokens(account: AccountData, cryptoService: CryptoService, logger: Logger): void { + // Check encryptionVersion field first (primary discriminator) + const shouldDecrypt = account.encryptionVersion === "aes"; + + TOKEN_FIELDS.forEach((field) => { + const value = account[field]; + + // Skip null/undefined values + if (value == null) { + return; + } + + if (typeof value === "string") { + // Primary path: Use encryptionVersion field + if (shouldDecrypt) { + try { + account[field] = cryptoService.decrypt(value); + } catch (error) { + // Log decryption failure but don't crash + // This allows the app to continue if a token is corrupted + // Security: Only log error type, not stack trace which may contain encrypted/decrypted data + const errorType = error instanceof Error ? error.constructor.name : "Unknown"; + logger.error(`Failed to decrypt ${field} for account: ${errorType}`); + } + } + // Fallback: For records without encryptionVersion (migration compatibility) + else if (!account.encryptionVersion && isAESEncrypted(value)) { + try { + account[field] = cryptoService.decrypt(value); + } catch (error) { + // Security: Only log error type, not stack trace which may contain encrypted/decrypted data + const errorType = error instanceof Error ? error.constructor.name : "Unknown"; + logger.error(`Failed to decrypt ${field} (fallback mode): ${errorType}`); + } + } + // Vault format (encryptionVersion === 'vault') - pass through for now (Phase 2) + // Legacy plaintext (no encryptionVersion) - pass through unchanged + } + }); +} + +/** + * Check if a value is encrypted (any format) + * + * @param value - String value to check + * @returns true if value appears to be encrypted + */ +function isEncrypted(value: string): boolean { + if (!value || typeof value !== "string") { + return false; + } + + // AES format: iv:authTag:encrypted (3 colon-separated hex parts) + if (isAESEncrypted(value)) { + return true; + } + + // Vault format: vault:v1:... + if (value.startsWith("vault:v1:")) { + return true; + } + + return false; +} + +/** + * Check if a value is AES-256-GCM encrypted + * + * @param value - String value to check + * @returns true if value is in AES format + */ +function isAESEncrypted(value: string): boolean { + if (!value || typeof value !== "string") { + return false; + } + + // AES format: iv:authTag:encrypted (3 parts, all hex) + const parts = value.split(":"); + if (parts.length !== 3) { + return false; + } + + // Verify all parts are hex strings + return parts.every((part) => /^[0-9a-f]+$/i.test(part)); +} diff --git a/apps/api/src/prisma/prisma.module.ts b/apps/api/src/prisma/prisma.module.ts index 036b139..075e8bf 100644 --- a/apps/api/src/prisma/prisma.module.ts +++ b/apps/api/src/prisma/prisma.module.ts @@ -1,13 +1,18 @@ import { Global, Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; import { PrismaService } from "./prisma.service"; +import { CryptoService } from "../federation/crypto.service"; /** * Global Prisma module providing database access throughout the application * Marked as @Global() so PrismaService is available in all modules without importing + * + * Includes CryptoService for transparent Account token encryption (Issue #352) */ @Global() @Module({ - providers: [PrismaService], + imports: [ConfigModule], + providers: [PrismaService, CryptoService], exports: [PrismaService], }) export class PrismaModule {} diff --git a/apps/api/src/prisma/prisma.service.spec.ts b/apps/api/src/prisma/prisma.service.spec.ts index d642636..5eaac29 100644 --- a/apps/api/src/prisma/prisma.service.spec.ts +++ b/apps/api/src/prisma/prisma.service.spec.ts @@ -1,13 +1,34 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; import { PrismaService } from "./prisma.service"; +import { CryptoService } from "../federation/crypto.service"; describe("PrismaService", () => { let service: PrismaService; + let mockConfigService: Partial; beforeEach(async () => { + // Mock ConfigService with a valid test encryption key + mockConfigService = { + get: vi.fn((key: string) => { + if (key === "ENCRYPTION_KEY") { + // Valid 64-character hex string (32 bytes) + return "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + } + return null; + }), + }; + const module: TestingModule = await Test.createTestingModule({ - providers: [PrismaService], + providers: [ + PrismaService, + { + provide: ConfigService, + useValue: mockConfigService, + }, + CryptoService, + ], }).compile(); service = module.get(PrismaService); @@ -25,6 +46,8 @@ describe("PrismaService", () => { describe("onModuleInit", () => { it("should connect to the database", async () => { const connectSpy = vi.spyOn(service, "$connect").mockResolvedValue(undefined); + // Mock $use to prevent middleware registration errors in tests + (service as any).$use = vi.fn(); await service.onModuleInit(); diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts index 6f33293..a114618 100644 --- a/apps/api/src/prisma/prisma.service.ts +++ b/apps/api/src/prisma/prisma.service.ts @@ -1,15 +1,20 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; import { PrismaClient } from "@prisma/client"; +import { CryptoService } from "../federation/crypto.service"; +import { registerAccountEncryptionMiddleware } from "./account-encryption.middleware"; /** * Prisma service that manages database connection lifecycle * Extends PrismaClient to provide connection management and health checks + * + * IMPORTANT: CryptoService is required (not optional) because it will throw + * if ENCRYPTION_KEY is not configured, providing fail-fast behavior. */ @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(PrismaService.name); - constructor() { + constructor(private readonly cryptoService: CryptoService) { super({ log: process.env.NODE_ENV === "development" ? ["query", "info", "warn", "error"] : ["error"], }); @@ -22,6 +27,11 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul try { await this.$connect(); this.logger.log("Database connection established"); + + // Register Account token encryption middleware + // CryptoService constructor will have already validated ENCRYPTION_KEY exists + registerAccountEncryptionMiddleware(this, this.cryptoService); + this.logger.log("Account encryption middleware registered"); } catch (error) { this.logger.error("Failed to connect to database", error); throw error;