feat(#353): Create VaultService NestJS module for OpenBao Transit
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>
This commit is contained in:
2026-02-07 16:13:05 -06:00
parent d4d1e59885
commit dd171b287f
11 changed files with 1431 additions and 79 deletions

View File

@@ -21,7 +21,8 @@
import { Logger } from "@nestjs/common";
import type { PrismaClient } from "@prisma/client";
import type { CryptoService } from "../federation/crypto.service";
import type { VaultService } from "../vault/vault.service";
import { TransitKey } from "../vault/vault.constants";
/**
* Token fields to encrypt/decrypt in Account model
@@ -59,11 +60,11 @@ interface AccountData extends Record<string, unknown> {
* Register account encryption middleware on Prisma client
*
* @param prisma - Prisma client instance
* @param cryptoService - Crypto service for encryption/decryption
* @param vaultService - Vault service for encryption/decryption
*/
export function registerAccountEncryptionMiddleware(
prisma: PrismaClient,
cryptoService: CryptoService
vaultService: VaultService
): void {
const logger = new Logger("AccountEncryptionMiddleware");
@@ -88,15 +89,15 @@ export function registerAccountEncryptionMiddleware(
params.action === "updateMany"
) {
if (params.args.data) {
encryptTokens(params.args.data as AccountData, cryptoService);
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) {
encryptTokens(params.args.create as AccountData, cryptoService);
await encryptTokens(params.args.create as AccountData, vaultService);
}
if (params.args.update) {
encryptTokens(params.args.update as AccountData, cryptoService);
await encryptTokens(params.args.update as AccountData, vaultService);
}
}
@@ -106,15 +107,15 @@ export function registerAccountEncryptionMiddleware(
// Decrypt on read operations
if (params.action === "findUnique" || params.action === "findFirst") {
if (result && typeof result === "object") {
decryptTokens(result as AccountData, cryptoService, logger);
await decryptTokens(result as AccountData, vaultService, logger);
}
} else if (params.action === "findMany") {
if (Array.isArray(result)) {
result.forEach((account: unknown) => {
for (const account of result) {
if (account && typeof account === "object") {
decryptTokens(account as AccountData, cryptoService, logger);
await decryptTokens(account as AccountData, vaultService, logger);
}
});
}
}
}
@@ -128,39 +129,43 @@ export function registerAccountEncryptionMiddleware(
* Modifies data in-place
*
* @param data - Account data object
* @param cryptoService - Crypto service
* @param vaultService - Vault service
*/
function encryptTokens(data: AccountData, cryptoService: CryptoService): void {
async function encryptTokens(data: AccountData, vaultService: VaultService): Promise<void> {
let encrypted = false;
let encryptionVersion: "aes" | "vault" | null = null;
TOKEN_FIELDS.forEach((field) => {
for (const field of TOKEN_FIELDS) {
const value = data[field];
// Skip null/undefined values
if (value == null) {
return;
continue;
}
// Skip if already encrypted (idempotent)
if (typeof value === "string" && isEncrypted(value)) {
return;
continue;
}
// Encrypt plaintext value
if (typeof value === "string") {
data[field] = cryptoService.encrypt(value);
const ciphertext = await vaultService.encrypt(value, TransitKey.ACCOUNT_TOKENS);
data[field] = ciphertext;
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";
// 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;
}
}
@@ -172,49 +177,56 @@ function encryptTokens(data: AccountData, cryptoService: CryptoService): void {
* 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 cryptoService - Crypto service
* @param logger - NestJS logger for error reporting
* @param vaultService - Vault service
* @param _logger - NestJS logger (unused, kept for compatibility with middleware signature)
* @throws Error with user-facing message when decryption fails
*/
function decryptTokens(account: AccountData, cryptoService: CryptoService, logger: Logger): void {
async function decryptTokens(
account: AccountData,
vaultService: VaultService,
_logger: Logger
): Promise<void> {
// Check encryptionVersion field first (primary discriminator)
const shouldDecrypt = account.encryptionVersion === "aes";
const shouldDecrypt =
account.encryptionVersion === "aes" || account.encryptionVersion === "vault";
TOKEN_FIELDS.forEach((field) => {
for (const field of TOKEN_FIELDS) {
const value = account[field];
// Skip null/undefined values
if (value == null) {
return;
continue;
}
if (typeof value === "string") {
// Primary path: Use encryptionVersion field
if (shouldDecrypt) {
try {
account[field] = cryptoService.decrypt(value);
account[field] = await vaultService.decrypt(value, TransitKey.ACCOUNT_TOKENS);
} 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}`);
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 && isAESEncrypted(value)) {
else if (!account.encryptionVersion && isEncrypted(value)) {
try {
account[field] = cryptoService.decrypt(value);
account[field] = await vaultService.decrypt(value, TransitKey.ACCOUNT_TOKENS);
} 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}`);
const errorMsg = error instanceof Error ? error.message : "Unknown error";
throw new Error(
`Failed to decrypt account credentials. Please reconnect this account. Details: ${errorMsg}`
);
}
}
// Vault format (encryptionVersion === 'vault') - pass through for now (Phase 2)
// Legacy plaintext (no encryptionVersion) - pass through unchanged
}
});
}
}
/**