feat(#352): Encrypt existing plaintext Account tokens
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

Implements transparent encryption/decryption of OAuth tokens via Prisma middleware with progressive migration strategy.

Core Implementation:
- Prisma middleware transparently encrypts tokens on write, decrypts on read
- Auto-detects ciphertext format: aes:iv:authTag:encrypted, vault:v1:..., or plaintext
- Uses existing CryptoService (AES-256-GCM) for encryption
- Progressive encryption: tokens encrypted as they're accessed/refreshed
- Zero-downtime migration (schema change only, no bulk data migration)

Security Features:
- Startup key validation prevents silent data loss if ENCRYPTION_KEY changes
- Secure error logging (no stack traces that could leak sensitive data)
- Graceful handling of corrupted encrypted data
- Idempotent encryption prevents double-encryption
- Future-proofed for OpenBao Transit encryption (Phase 2)

Token Fields Encrypted:
- accessToken (OAuth access tokens)
- refreshToken (OAuth refresh tokens)
- idToken (OpenID Connect ID tokens)

Backward Compatibility:
- Existing plaintext tokens readable (encryptionVersion = NULL)
- Progressive encryption on next write
- BetterAuth integration transparent (middleware layer)

Test Coverage:
- 20 comprehensive unit tests (89.06% coverage)
- Encryption/decryption scenarios
- Null/undefined handling
- Corrupted data handling
- Legacy plaintext compatibility
- Future vault format support
- All CRUD operations (create, update, updateMany, upsert)

Files Created:
- apps/api/src/prisma/account-encryption.middleware.ts
- apps/api/src/prisma/account-encryption.middleware.spec.ts
- apps/api/prisma/migrations/20260207_encrypt_account_tokens/migration.sql

Files Modified:
- apps/api/src/prisma/prisma.service.ts (register middleware)
- apps/api/src/prisma/prisma.module.ts (add CryptoService)
- apps/api/src/federation/crypto.service.ts (add key validation)
- apps/api/prisma/schema.prisma (add encryptionVersion)
- .env.example (document ENCRYPTION_KEY)

Fixes #352

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 13:16:43 -06:00
parent 89464583a4
commit 737eb40d18
9 changed files with 951 additions and 3 deletions

View File

@@ -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<string, unknown>;
where?: Record<string, unknown>;
select?: Record<string, unknown>;
create?: Record<string, unknown>;
update?: Record<string, unknown>;
};
dataPath: string[];
runInTransaction: boolean;
}
/**
* Account data with token fields
*/
interface AccountData extends Record<string, unknown> {
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<unknown>) => {
// 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));
}