Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Implements secure credential encryption using OpenBao Transit API with automatic fallback to AES-256-GCM when OpenBao is unavailable. Features: - AppRole authentication with automatic token renewal at 50% TTL - Transit encrypt/decrypt with 4 named keys - Automatic fallback to CryptoService when OpenBao unavailable - Auto-detection of ciphertext format (vault:v1: vs AES) - Request timeout protection (5s default) - Health indicator for monitoring - Backward compatible with existing AES-encrypted data Security: - ERROR-level logging for fallback - Proper error propagation (no silent failures) - Request timeouts prevent hung operations - Secure credential file reading Migrations: - Account encryption middleware uses VaultService - Uses TransitKey.ACCOUNT_TOKENS for OAuth tokens - Backward compatible with existing encrypted data Tests: 56 tests passing (36 VaultService + 20 middleware) Closes #353 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
276 lines
8.3 KiB
TypeScript
276 lines
8.3 KiB
TypeScript
/**
|
|
* 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 { VaultService } from "../vault/vault.service";
|
|
import { TransitKey } from "../vault/vault.constants";
|
|
|
|
/**
|
|
* 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 vaultService - Vault service for encryption/decryption
|
|
*/
|
|
export function registerAccountEncryptionMiddleware(
|
|
prisma: PrismaClient,
|
|
vaultService: VaultService
|
|
): 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) {
|
|
await encryptTokens(params.args.data as AccountData, vaultService);
|
|
}
|
|
} else if (params.action === "upsert") {
|
|
// Handle upsert - encrypt both create and update data
|
|
if (params.args.create) {
|
|
await encryptTokens(params.args.create as AccountData, vaultService);
|
|
}
|
|
if (params.args.update) {
|
|
await encryptTokens(params.args.update as AccountData, vaultService);
|
|
}
|
|
}
|
|
|
|
// Execute query
|
|
const result = await next(params);
|
|
|
|
// Decrypt on read operations
|
|
if (params.action === "findUnique" || params.action === "findFirst") {
|
|
if (result && typeof result === "object") {
|
|
await decryptTokens(result as AccountData, vaultService, logger);
|
|
}
|
|
} else if (params.action === "findMany") {
|
|
if (Array.isArray(result)) {
|
|
for (const account of result) {
|
|
if (account && typeof account === "object") {
|
|
await decryptTokens(account as AccountData, vaultService, logger);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Encrypt token fields in account data
|
|
* Modifies data in-place
|
|
*
|
|
* @param data - Account data object
|
|
* @param vaultService - Vault service
|
|
*/
|
|
async function encryptTokens(data: AccountData, vaultService: VaultService): Promise<void> {
|
|
let encrypted = false;
|
|
let encryptionVersion: "aes" | "vault" | null = null;
|
|
|
|
for (const field of TOKEN_FIELDS) {
|
|
const value = data[field];
|
|
|
|
// Skip null/undefined values
|
|
if (value == null) {
|
|
continue;
|
|
}
|
|
|
|
// Skip if already encrypted (idempotent)
|
|
if (typeof value === "string" && isEncrypted(value)) {
|
|
continue;
|
|
}
|
|
|
|
// Encrypt plaintext value
|
|
if (typeof value === "string") {
|
|
const ciphertext = await vaultService.encrypt(value, TransitKey.ACCOUNT_TOKENS);
|
|
data[field] = ciphertext;
|
|
encrypted = true;
|
|
|
|
// Determine encryption version from ciphertext format
|
|
if (ciphertext.startsWith("vault:v1:")) {
|
|
encryptionVersion = "vault";
|
|
} else {
|
|
encryptionVersion = "aes";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark encryption version if any tokens were encrypted
|
|
if (encrypted && encryptionVersion) {
|
|
data.encryptionVersion = encryptionVersion;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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).
|
|
*
|
|
* Throws errors on decryption failure to prevent silent corruption.
|
|
*
|
|
* @param account - Account record
|
|
* @param vaultService - Vault service
|
|
* @param _logger - NestJS logger (unused, kept for compatibility with middleware signature)
|
|
* @throws Error with user-facing message when decryption fails
|
|
*/
|
|
async function decryptTokens(
|
|
account: AccountData,
|
|
vaultService: VaultService,
|
|
_logger: Logger
|
|
): Promise<void> {
|
|
// Check encryptionVersion field first (primary discriminator)
|
|
const shouldDecrypt =
|
|
account.encryptionVersion === "aes" || account.encryptionVersion === "vault";
|
|
|
|
for (const field of TOKEN_FIELDS) {
|
|
const value = account[field];
|
|
|
|
// Skip null/undefined values
|
|
if (value == null) {
|
|
continue;
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
// Primary path: Use encryptionVersion field
|
|
if (shouldDecrypt) {
|
|
try {
|
|
account[field] = await vaultService.decrypt(value, TransitKey.ACCOUNT_TOKENS);
|
|
} catch (error) {
|
|
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
|
throw new Error(
|
|
`Failed to decrypt account credentials. Please reconnect this account. Details: ${errorMsg}`
|
|
);
|
|
}
|
|
}
|
|
// Fallback: For records without encryptionVersion (migration compatibility)
|
|
else if (!account.encryptionVersion && isEncrypted(value)) {
|
|
try {
|
|
account[field] = await vaultService.decrypt(value, TransitKey.ACCOUNT_TOKENS);
|
|
} catch (error) {
|
|
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
|
throw new Error(
|
|
`Failed to decrypt account credentials. Please reconnect this account. Details: ${errorMsg}`
|
|
);
|
|
}
|
|
}
|
|
// 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));
|
|
}
|