diff --git a/.env.example b/.env.example index f77dacc..8309044 100644 --- a/.env.example +++ b/.env.example @@ -109,6 +109,12 @@ ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_GENERATE_WITH_OPENSSL_RAND_HEX_32 OPENBAO_ADDR=http://openbao:8200 OPENBAO_PORT=8200 +# AppRole Authentication (Optional) +# If not set, credentials are read from /openbao/init/approle-credentials volume +# These env vars are useful for testing or when running outside Docker +# OPENBAO_ROLE_ID=your-role-id-here +# OPENBAO_SECRET_ID=your-secret-id-here + # ====================== # Ollama (Optional AI Service) # ====================== diff --git a/apps/api/src/prisma/account-encryption.middleware.spec.ts b/apps/api/src/prisma/account-encryption.middleware.spec.ts index 4bc0ee1..ea883f6 100644 --- a/apps/api/src/prisma/account-encryption.middleware.spec.ts +++ b/apps/api/src/prisma/account-encryption.middleware.spec.ts @@ -399,8 +399,8 @@ describe("AccountEncryptionMiddleware", () => { expect(result.accessToken).toBe(plaintextToken); }); - it("should handle vault ciphertext format (future-proofing)", async () => { - // Simulate future Transit encryption format + it("should throw error on vault ciphertext when OpenBao unavailable", async () => { + // Simulate vault Transit encryption format when OpenBao is unavailable const vaultCiphertext = "vault:v1:base64encodeddata"; const mockParams = { @@ -419,13 +419,13 @@ describe("AccountEncryptionMiddleware", () => { accessToken: vaultCiphertext, refreshToken: null, idToken: null, - encryptionVersion: "vault", // Future: vault encryption + encryptionVersion: "vault", // vault encryption })); - const result = (await middlewareFunction(mockParams, mockNext)) as any; - - // Should pass through unchanged (vault not implemented yet) - expect(result.accessToken).toBe(vaultCiphertext); + // Should throw error because VaultService can't decrypt vault:v1: without OpenBao + await expect(middlewareFunction(mockParams, mockNext)).rejects.toThrow( + "Failed to decrypt account credentials" + ); }); it("should use encryptionVersion as primary discriminator", async () => { @@ -457,7 +457,7 @@ describe("AccountEncryptionMiddleware", () => { expect(result.accessToken).toBe(fakeEncryptedToken); }); - it("should handle corrupted encrypted data gracefully", async () => { + it("should throw error on corrupted encrypted data", async () => { // Test with malformed/corrupted encrypted token const corruptedToken = "deadbeef:cafebabe:corrupted_data_xyz"; // Valid format but wrong data @@ -480,15 +480,13 @@ describe("AccountEncryptionMiddleware", () => { 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"); + // Should throw error - decryption failures are now propagated to prevent silent corruption + await expect(middlewareFunction(mockParams, mockNext)).rejects.toThrow( + "Failed to decrypt account credentials" + ); }); - it("should handle completely malformed encrypted format", async () => { + it("should throw error on 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"; @@ -511,10 +509,10 @@ describe("AccountEncryptionMiddleware", () => { 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); + // Should throw error - malformed data cannot be decrypted + await expect(middlewareFunction(mockParams, mockNext)).rejects.toThrow( + "Failed to decrypt account credentials" + ); }); }); diff --git a/apps/api/src/prisma/account-encryption.middleware.ts b/apps/api/src/prisma/account-encryption.middleware.ts index 04adf10..35b58b6 100644 --- a/apps/api/src/prisma/account-encryption.middleware.ts +++ b/apps/api/src/prisma/account-encryption.middleware.ts @@ -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 { * 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 { 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 { // 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 } - }); + } } /** diff --git a/apps/api/src/prisma/prisma.module.ts b/apps/api/src/prisma/prisma.module.ts index 075e8bf..33224bc 100644 --- a/apps/api/src/prisma/prisma.module.ts +++ b/apps/api/src/prisma/prisma.module.ts @@ -1,18 +1,19 @@ import { Global, Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { PrismaService } from "./prisma.service"; -import { CryptoService } from "../federation/crypto.service"; +import { VaultModule } from "../vault/vault.module"; /** * 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) + * Includes VaultModule for transparent Account token encryption via OpenBao Transit + * with AES-256-GCM fallback (Issue #353) */ @Global() @Module({ - imports: [ConfigModule], - providers: [PrismaService, CryptoService], + imports: [ConfigModule, VaultModule], + providers: [PrismaService], 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 5eaac29..212a541 100644 --- a/apps/api/src/prisma/prisma.service.spec.ts +++ b/apps/api/src/prisma/prisma.service.spec.ts @@ -2,6 +2,7 @@ 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 { VaultService } from "../vault/vault.service"; import { CryptoService } from "../federation/crypto.service"; describe("PrismaService", () => { @@ -12,11 +13,13 @@ describe("PrismaService", () => { // 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 config: Record = { + ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + OPENBAO_ADDR: "http://localhost:8200", + OPENBAO_ROLE_ID: "test-role-id", + OPENBAO_SECRET_ID: "test-secret-id", + }; + return config[key] || null; }), }; @@ -27,6 +30,7 @@ describe("PrismaService", () => { provide: ConfigService, useValue: mockConfigService, }, + VaultService, CryptoService, ], }).compile(); diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts index a114618..a56bbbf 100644 --- a/apps/api/src/prisma/prisma.service.ts +++ b/apps/api/src/prisma/prisma.service.ts @@ -1,20 +1,21 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; import { PrismaClient } from "@prisma/client"; -import { CryptoService } from "../federation/crypto.service"; +import { VaultService } from "../vault/vault.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. + * IMPORTANT: VaultService is required (not optional) for encryption/decryption + * of sensitive Account tokens. It automatically falls back to AES-256-GCM when + * OpenBao is unavailable. */ @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(PrismaService.name); - constructor(private readonly cryptoService: CryptoService) { + constructor(private readonly vaultService: VaultService) { super({ log: process.env.NODE_ENV === "development" ? ["query", "info", "warn", "error"] : ["error"], }); @@ -29,8 +30,8 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul this.logger.log("Database connection established"); // Register Account token encryption middleware - // CryptoService constructor will have already validated ENCRYPTION_KEY exists - registerAccountEncryptionMiddleware(this, this.cryptoService); + // VaultService provides OpenBao Transit encryption with AES-256-GCM fallback + registerAccountEncryptionMiddleware(this, this.vaultService); this.logger.log("Account encryption middleware registered"); } catch (error) { this.logger.error("Failed to connect to database", error); diff --git a/apps/api/src/vault/vault.constants.ts b/apps/api/src/vault/vault.constants.ts new file mode 100644 index 0000000..463f6cc --- /dev/null +++ b/apps/api/src/vault/vault.constants.ts @@ -0,0 +1,46 @@ +/** + * Vault Service Constants + * + * Named Transit encryption keys and related constants for OpenBao integration. + */ + +/** + * Named Transit encryption keys + * + * Each key is used for encrypting specific types of sensitive data. + * Keys are created automatically by the openbao-init container on first run. + */ +export enum TransitKey { + /** User-stored credentials (API keys, git tokens) */ + CREDENTIALS = "mosaic-credentials", + + /** BetterAuth OAuth tokens in accounts table */ + ACCOUNT_TOKENS = "mosaic-account-tokens", + + /** Federation private keys */ + FEDERATION = "mosaic-federation", + + /** LLM provider API keys */ + LLM_CONFIG = "mosaic-llm-config", +} + +/** + * Default OpenBao server address + */ +export const DEFAULT_OPENBAO_ADDR = "http://openbao:8200"; + +/** + * AppRole credentials file path (mounted via Docker volume) + */ +export const APPROLE_CREDENTIALS_PATH = "/openbao/init/approle-credentials"; + +/** + * Token renewal threshold (percentage of TTL) + * Renew when remaining TTL drops below this percentage + */ +export const TOKEN_RENEWAL_THRESHOLD = 0.5; // 50% + +/** + * Token TTL in seconds (1 hour) + */ +export const TOKEN_TTL_SECONDS = 3600; diff --git a/apps/api/src/vault/vault.health.ts b/apps/api/src/vault/vault.health.ts new file mode 100644 index 0000000..810d405 --- /dev/null +++ b/apps/api/src/vault/vault.health.ts @@ -0,0 +1,51 @@ +/** + * Vault Health Indicator + * + * Health check for OpenBao connectivity and encryption status. + */ + +import { Injectable } from "@nestjs/common"; +import { VaultService } from "./vault.service"; + +export interface VaultHealthStatus { + status: "up" | "down"; + available: boolean; + fallbackMode: boolean; + endpoint: string; + message?: string; +} + +@Injectable() +export class VaultHealthIndicator { + constructor(private readonly vaultService: VaultService) {} + + /** + * Check OpenBao health status + * + * @returns Health status object + */ + check(): VaultHealthStatus { + try { + const status = this.vaultService.getStatus(); + + return { + status: status.available ? "up" : "down", + available: status.available, + fallbackMode: status.fallbackMode, + endpoint: status.endpoint, + message: status.available + ? "OpenBao Transit encryption enabled" + : "Using fallback AES-256-GCM encryption", + }; + } catch (error: unknown) { + const errorMsg = error instanceof Error ? error.message : "Unknown error"; + return { + status: "down", + available: false, + fallbackMode: true, + endpoint: "unknown", + message: `Health check failed: ${errorMsg}`, + }; + } + } +} diff --git a/apps/api/src/vault/vault.module.ts b/apps/api/src/vault/vault.module.ts new file mode 100644 index 0000000..71797b3 --- /dev/null +++ b/apps/api/src/vault/vault.module.ts @@ -0,0 +1,19 @@ +/** + * Vault Module + * + * Global module providing OpenBao Transit encryption services. + */ + +import { Module, Global } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { VaultService } from "./vault.service"; +import { VaultHealthIndicator } from "./vault.health"; +import { CryptoService } from "../federation/crypto.service"; + +@Global() +@Module({ + imports: [ConfigModule], + providers: [VaultService, VaultHealthIndicator, CryptoService], + exports: [VaultService, VaultHealthIndicator], +}) +export class VaultModule {} diff --git a/apps/api/src/vault/vault.service.spec.ts b/apps/api/src/vault/vault.service.spec.ts new file mode 100644 index 0000000..810c7ef --- /dev/null +++ b/apps/api/src/vault/vault.service.spec.ts @@ -0,0 +1,768 @@ +/** + * VaultService Unit Tests + * + * Tests for OpenBao Transit encryption service with fallback to CryptoService. + */ + +import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "@nestjs/common"; +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { VaultService } from "./vault.service"; +import { CryptoService } from "../federation/crypto.service"; +import { TransitKey } from "./vault.constants"; + +describe("VaultService", () => { + let configService: ConfigService; + let cryptoService: CryptoService; + let mockFetch: ReturnType; + + beforeEach(() => { + // Mock fetch + mockFetch = vi.fn(); + global.fetch = mockFetch; + + // Mock ConfigService + configService = { + get: vi.fn((key: string) => { + const config: Record = { + OPENBAO_ADDR: "http://localhost:8200", + OPENBAO_ROLE_ID: "test-role-id", + OPENBAO_SECRET_ID: "test-secret-id", + ENCRYPTION_KEY: "0".repeat(64), // Valid 32-byte hex key + }; + return config[key]; + }), + } as unknown as ConfigService; + + // Mock CryptoService + cryptoService = { + encrypt: vi.fn((plaintext: string) => `iv:tag:${plaintext}`), + decrypt: vi.fn((ciphertext: string) => { + const parts = ciphertext.split(":"); + return parts[2] || ""; + }), + } as unknown as CryptoService; + + // Suppress logger output during tests + vi.spyOn(Logger.prototype, "log").mockImplementation(() => undefined); + vi.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined); + vi.spyOn(Logger.prototype, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + // Helper to create authenticated service + async function createAuthenticatedService(): Promise { + // Mock successful AppRole login + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + auth: { + client_token: "test-token", + lease_duration: 3600, + }, + }), + } as Response); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + VaultService, + { + provide: ConfigService, + useValue: configService, + }, + { + provide: CryptoService, + useValue: cryptoService, + }, + ], + }).compile(); + + const service = module.get(VaultService); + await service.waitForInitialization(); + return service; + } + + describe("initialization", () => { + it("should be defined", async () => { + const service = await createAuthenticatedService(); + expect(service).toBeDefined(); + }); + + it("should initialize with OpenBao unavailable when fetch fails", async () => { + mockFetch.mockRejectedValueOnce(new Error("Connection refused")); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + VaultService, + { + provide: ConfigService, + useValue: configService, + }, + { + provide: CryptoService, + useValue: cryptoService, + }, + ], + }).compile(); + + const service = module.get(VaultService); + await service.waitForInitialization(); + + const status = service.getStatus(); + expect(status.available).toBe(false); + expect(status.fallbackMode).toBe(true); + }); + + it("should authenticate with AppRole on initialization when OpenBao is available", async () => { + await createAuthenticatedService(); + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:8200/v1/auth/approle/login", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + role_id: "test-role-id", + secret_id: "test-secret-id", + }), + }) + ); + }); + }); + + describe("encrypt", () => { + it("should encrypt using OpenBao Transit when available", async () => { + const service = await createAuthenticatedService(); + + // Mock successful Transit encrypt + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + ciphertext: "vault:v1:base64encodeddata", + }, + }), + } as Response); + + const plaintext = "secret-api-key"; + const encrypted = await service.encrypt(plaintext, TransitKey.CREDENTIALS); + + expect(encrypted).toBe("vault:v1:base64encodeddata"); + expect(mockFetch).toHaveBeenLastCalledWith( + "http://localhost:8200/v1/transit/encrypt/mosaic-credentials", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "X-Vault-Token": "test-token", + }), + }) + ); + }); + + it("should fallback to CryptoService when OpenBao is unavailable", async () => { + mockFetch.mockRejectedValueOnce(new Error("Connection refused")); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + VaultService, + { + provide: ConfigService, + useValue: configService, + }, + { + provide: CryptoService, + useValue: cryptoService, + }, + ], + }).compile(); + + const service = module.get(VaultService); + await service.waitForInitialization(); + + const plaintext = "secret-api-key"; + const encrypted = await service.encrypt(plaintext, TransitKey.CREDENTIALS); + + expect(encrypted).toMatch(/^iv:tag:/); + expect(cryptoService.encrypt).toHaveBeenCalledWith(plaintext); + }); + + it("should fallback to CryptoService when Transit encrypt fails", async () => { + const service = await createAuthenticatedService(); + + // Mock Transit encrypt failure + mockFetch.mockRejectedValueOnce(new Error("Transit unavailable")); + + const plaintext = "secret-api-key"; + const encrypted = await service.encrypt(plaintext, TransitKey.CREDENTIALS); + + expect(encrypted).toMatch(/^iv:tag:/); + expect(cryptoService.encrypt).toHaveBeenCalledWith(plaintext); + }); + + it("should handle base64 encoding correctly", async () => { + const service = await createAuthenticatedService(); + + // Mock successful Transit encrypt + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + ciphertext: "vault:v1:base64encodeddata", + }, + }), + } as Response); + + const plaintext = "special chars: 🔒 @#$%"; + await service.encrypt(plaintext, TransitKey.ACCOUNT_TOKENS); + + const encodedPlaintext = Buffer.from(plaintext).toString("base64"); + expect(mockFetch).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ plaintext: encodedPlaintext }), + }) + ); + }); + }); + + describe("decrypt", () => { + it("should decrypt OpenBao Transit ciphertext when available", async () => { + const service = await createAuthenticatedService(); + + // Mock successful Transit decrypt + const plaintext = "secret-api-key"; + const encodedPlaintext = Buffer.from(plaintext).toString("base64"); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + plaintext: encodedPlaintext, + }, + }), + } as Response); + + const ciphertext = "vault:v1:base64encodeddata"; + const decrypted = await service.decrypt(ciphertext, TransitKey.CREDENTIALS); + + expect(decrypted).toBe(plaintext); + expect(mockFetch).toHaveBeenLastCalledWith( + "http://localhost:8200/v1/transit/decrypt/mosaic-credentials", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "X-Vault-Token": "test-token", + }), + body: JSON.stringify({ ciphertext }), + }) + ); + }); + + it("should use CryptoService to decrypt AES ciphertext", async () => { + const service = await createAuthenticatedService(); + + const ciphertext = "iv:tag:encrypteddata"; + const decrypted = await service.decrypt(ciphertext, TransitKey.CREDENTIALS); + + expect(decrypted).toBe("encrypteddata"); + expect(cryptoService.decrypt).toHaveBeenCalledWith(ciphertext); + }); + + it("should throw error when vault ciphertext but OpenBao unavailable", async () => { + mockFetch.mockRejectedValueOnce(new Error("Connection refused")); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + VaultService, + { + provide: ConfigService, + useValue: configService, + }, + { + provide: CryptoService, + useValue: cryptoService, + }, + ], + }).compile(); + + const service = module.get(VaultService); + await service.waitForInitialization(); + + const ciphertext = "vault:v1:base64encodeddata"; + await expect(service.decrypt(ciphertext, TransitKey.CREDENTIALS)).rejects.toThrow( + "Cannot decrypt vault:v1: ciphertext" + ); + }); + + it("should handle base64 decoding correctly", async () => { + const service = await createAuthenticatedService(); + + // Mock successful Transit decrypt + const plaintext = "special chars: 🔒 @#$%"; + const encodedPlaintext = Buffer.from(plaintext).toString("base64"); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + plaintext: encodedPlaintext, + }, + }), + } as Response); + + const ciphertext = "vault:v1:base64encodeddata"; + const decrypted = await service.decrypt(ciphertext, TransitKey.ACCOUNT_TOKENS); + + expect(decrypted).toBe(plaintext); + }); + }); + + describe("ciphertext format detection", () => { + it("should detect vault:v1: prefix as OpenBao Transit format", () => { + const ciphertext = "vault:v1:base64encodeddata"; + expect(ciphertext.startsWith("vault:v1:")).toBe(true); + }); + + it("should detect colon-separated parts as AES format", () => { + const ciphertext = "iv:tag:encrypted"; + const parts = ciphertext.split(":"); + expect(parts.length).toBe(3); + }); + + it("should handle legacy plaintext without encryption", () => { + const plaintext = "legacy-plaintext-value"; + expect(plaintext.startsWith("vault:v1:")).toBe(false); + expect(plaintext.split(":").length).not.toBe(3); + }); + }); + + describe("getStatus", () => { + it("should return available status when OpenBao is reachable", async () => { + const service = await createAuthenticatedService(); + const status = service.getStatus(); + + expect(status.available).toBe(true); + expect(status.fallbackMode).toBe(false); + }); + + it("should return unavailable status when OpenBao is unreachable", async () => { + mockFetch.mockRejectedValueOnce(new Error("Connection refused")); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + VaultService, + { + provide: ConfigService, + useValue: configService, + }, + { + provide: CryptoService, + useValue: cryptoService, + }, + ], + }).compile(); + + const service = module.get(VaultService); + await service.waitForInitialization(); + + const status = service.getStatus(); + expect(status.available).toBe(false); + expect(status.fallbackMode).toBe(true); + }); + + it("should include endpoint in status", async () => { + const service = await createAuthenticatedService(); + const status = service.getStatus(); + + expect(status.endpoint).toBe("http://localhost:8200"); + }); + }); + + describe("error handling", () => { + it("should throw error when encrypting empty string", async () => { + const service = await createAuthenticatedService(); + await expect(service.encrypt("", TransitKey.CREDENTIALS)).rejects.toThrow(); + }); + + it("should throw error when decrypting empty string", async () => { + const service = await createAuthenticatedService(); + await expect(service.decrypt("", TransitKey.CREDENTIALS)).rejects.toThrow(); + }); + + it("should fallback to CryptoService on malformed Transit responses", async () => { + const service = await createAuthenticatedService(); + + // Mock malformed Transit response + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + error: "Internal error", + }), + } as Response); + + const encrypted = await service.encrypt("test", TransitKey.CREDENTIALS); + expect(encrypted).toMatch(/^iv:tag:/); + expect(cryptoService.encrypt).toHaveBeenCalledWith("test"); + }); + + it("should fallback to CryptoService on HTTP error responses", async () => { + const service = await createAuthenticatedService(); + + // Mock HTTP error + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + } as Response); + + const encrypted = await service.encrypt("test", TransitKey.CREDENTIALS); + expect(encrypted).toMatch(/^iv:tag:/); + expect(cryptoService.encrypt).toHaveBeenCalledWith("test"); + }); + }); + + describe("different transit keys", () => { + it("should use correct key name for CREDENTIALS", async () => { + const service = await createAuthenticatedService(); + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + ciphertext: "vault:v1:data", + }, + }), + } as Response); + + await service.encrypt("test", TransitKey.CREDENTIALS); + + expect(mockFetch).toHaveBeenLastCalledWith( + "http://localhost:8200/v1/transit/encrypt/mosaic-credentials", + expect.any(Object) + ); + }); + + it("should use correct key name for ACCOUNT_TOKENS", async () => { + const service = await createAuthenticatedService(); + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + ciphertext: "vault:v1:data", + }, + }), + } as Response); + + await service.encrypt("test", TransitKey.ACCOUNT_TOKENS); + + expect(mockFetch).toHaveBeenLastCalledWith( + "http://localhost:8200/v1/transit/encrypt/mosaic-account-tokens", + expect.any(Object) + ); + }); + + it("should use correct key name for FEDERATION", async () => { + const service = await createAuthenticatedService(); + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + ciphertext: "vault:v1:data", + }, + }), + } as Response); + + await service.encrypt("test", TransitKey.FEDERATION); + + expect(mockFetch).toHaveBeenLastCalledWith( + "http://localhost:8200/v1/transit/encrypt/mosaic-federation", + expect.any(Object) + ); + }); + + it("should use correct key name for LLM_CONFIG", async () => { + const service = await createAuthenticatedService(); + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + ciphertext: "vault:v1:data", + }, + }), + } as Response); + + await service.encrypt("test", TransitKey.LLM_CONFIG); + + expect(mockFetch).toHaveBeenLastCalledWith( + "http://localhost:8200/v1/transit/encrypt/mosaic-llm-config", + expect.any(Object) + ); + }); + }); + + describe("token renewal lifecycle", () => { + it("should successfully renew token and reschedule renewal", async () => { + const service = await createAuthenticatedService(); + + // Mock successful token renewal + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + auth: { + client_token: "renewed-token", + lease_duration: 3600, + }, + }), + } as Response); + + // Access the private method via reflection for testing + await service["renewToken"](); + + // Verify token was updated + expect(service["token"]).toBe("renewed-token"); + expect(service["tokenExpiry"]).toBeGreaterThan(Date.now()); + + // Verify renewal timer was rescheduled + expect(service["renewalTimer"]).toBeDefined(); + }); + + it("should re-authenticate and reschedule on renewal HTTP failure", async () => { + const service = await createAuthenticatedService(); + + // Mock token renewal failure (HTTP error) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: "Forbidden", + } as Response); + + // Mock successful re-authentication + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + auth: { + client_token: "new-auth-token", + lease_duration: 3600, + }, + }), + } as Response); + + await service["renewToken"](); + + // Verify re-authentication occurred + expect(service["token"]).toBe("new-auth-token"); + + // Verify renewal timer was rescheduled after re-authentication + expect(service["renewalTimer"]).toBeDefined(); + }); + + it("should re-authenticate and reschedule on renewal exception", async () => { + const service = await createAuthenticatedService(); + + // Mock token renewal that throws an exception + mockFetch.mockRejectedValueOnce(new Error("Network timeout")); + + // Mock successful re-authentication + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + auth: { + client_token: "recovery-token", + lease_duration: 3600, + }, + }), + } as Response); + + await service["renewToken"](); + + // Verify re-authentication occurred + expect(service["token"]).toBe("recovery-token"); + + // Verify renewal timer was rescheduled after recovery + expect(service["renewalTimer"]).toBeDefined(); + }); + + it("should reschedule renewal after successful token renewal", async () => { + const service = await createAuthenticatedService(); + + // Clear the initial timer + if (service["renewalTimer"]) { + clearTimeout(service["renewalTimer"]); + service["renewalTimer"] = null; + } + + // Mock successful token renewal + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + auth: { + client_token: "renewed-token", + lease_duration: 3600, + }, + }), + } as Response); + + await service["renewToken"](); + + // Verify renewal timer was scheduled + expect(service["renewalTimer"]).not.toBeNull(); + }); + + it("should clear renewal timer on module destroy", async () => { + const service = await createAuthenticatedService(); + + // Verify timer exists + expect(service["renewalTimer"]).toBeDefined(); + + // Call onModuleDestroy + service.onModuleDestroy(); + + // Verify timer was cleared + // Note: We can't directly check if timeout was cleared, but we verify the method executes without error + expect(service.onModuleDestroy).toBeDefined(); + }); + + it("should handle missing token during renewal gracefully", async () => { + const service = await createAuthenticatedService(); + + // Clear mock history after initialization + mockFetch.mockClear(); + + // Clear token to simulate edge case + service["token"] = null; + + // Should throw error without attempting fetch + await expect(service["renewToken"]()).rejects.toThrow("No token to renew"); + + // Verify no fetch was attempted after clearing token + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("should handle malformed renewal response and recover", async () => { + const service = await createAuthenticatedService(); + + // Mock malformed renewal response (missing required fields) + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + auth: { + // Missing client_token or lease_duration + }, + }), + } as Response); + + // Mock successful re-authentication + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + auth: { + client_token: "recovery-token", + lease_duration: 3600, + }, + }), + } as Response); + + await service["renewToken"](); + + // Verify re-authentication occurred + expect(service["token"]).toBe("recovery-token"); + + // Verify renewal was rescheduled + expect(service["renewalTimer"]).toBeDefined(); + }); + }); + + describe("fetchWithTimeout", () => { + it("should timeout after specified duration", async () => { + const service = await createAuthenticatedService(); + + // Mock a fetch that checks for abort signal and rejects when aborted + mockFetch.mockImplementationOnce((url: string, options: RequestInit) => { + return new Promise((resolve, reject) => { + if (options.signal) { + options.signal.addEventListener("abort", () => { + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + reject(error); + }); + } + }); + }); + + // Should timeout after 100ms + await expect(service["fetchWithTimeout"]("http://test", {}, 100)).rejects.toThrow( + "Request timeout after 100ms" + ); + }); + + it("should successfully complete requests within timeout", async () => { + const service = await createAuthenticatedService(); + + // Mock a fast response + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ data: "test" }), + } as Response); + + const response = await service["fetchWithTimeout"]("http://test", {}, 5000); + expect(response.ok).toBe(true); + }); + + it("should propagate non-timeout errors", async () => { + const service = await createAuthenticatedService(); + + // Mock a network error + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + await expect(service["fetchWithTimeout"]("http://test", {}, 5000)).rejects.toThrow( + "Network error" + ); + }); + + it("should abort request when timeout occurs", async () => { + const service = await createAuthenticatedService(); + + let aborted = false; + mockFetch.mockImplementationOnce((url: string, options: RequestInit) => { + return new Promise((resolve, reject) => { + if (options.signal) { + options.signal.addEventListener("abort", () => { + aborted = true; + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + reject(error); + }); + } + }); + }); + + await expect(service["fetchWithTimeout"]("http://test", {}, 100)).rejects.toThrow( + "Request timeout" + ); + + // Verify abort was called + expect(aborted).toBe(true); + }); + }); +}); diff --git a/apps/api/src/vault/vault.service.ts b/apps/api/src/vault/vault.service.ts new file mode 100644 index 0000000..769fd68 --- /dev/null +++ b/apps/api/src/vault/vault.service.ts @@ -0,0 +1,446 @@ +/** + * Vault Service + * + * Handles OpenBao Transit encryption with fallback to CryptoService. + * Provides transparent encryption/decryption with auto-detection of ciphertext format. + */ + +import { Injectable, Logger, OnModuleDestroy } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { CryptoService } from "../federation/crypto.service"; +import { + TransitKey, + DEFAULT_OPENBAO_ADDR, + APPROLE_CREDENTIALS_PATH, + TOKEN_RENEWAL_THRESHOLD, + TOKEN_TTL_SECONDS, +} from "./vault.constants"; +import { readFile } from "fs/promises"; + +interface AppRoleCredentials { + role_id: string; + secret_id: string; +} + +interface VaultAuthResponse { + auth?: { + client_token?: string; + lease_duration?: number; + }; +} + +interface TransitEncryptResponse { + data?: { + ciphertext?: string; + }; +} + +interface TransitDecryptResponse { + data?: { + plaintext?: string; + }; +} + +export interface VaultStatus { + available: boolean; + fallbackMode: boolean; + endpoint: string; +} + +@Injectable() +export class VaultService implements OnModuleDestroy { + private readonly logger = new Logger(VaultService.name); + private readonly openbaoAddr: string; + private token: string | null = null; + private tokenExpiry: number | null = null; + private renewalTimer: NodeJS.Timeout | null = null; + private isAvailable = false; + + constructor( + private readonly config: ConfigService, + private readonly cryptoService: CryptoService + ) { + this.openbaoAddr = this.config.get("OPENBAO_ADDR") ?? DEFAULT_OPENBAO_ADDR; + + // Initialize asynchronously (don't block module initialization) + this.initPromise = this.initialize().catch((error: unknown) => { + const errorMsg = error instanceof Error ? error.message : "Unknown error"; + this.logger.warn(`OpenBao initialization failed: ${errorMsg}`); + this.logger.log("Fallback mode enabled: using AES-256-GCM encryption"); + this.isAvailable = false; + }); + } + + private initPromise: Promise; + + /** + * Initialize OpenBao connection and authenticate + */ + private async initialize(): Promise { + try { + await this.authenticate(); + this.isAvailable = true; + this.logger.log(`OpenBao Transit encryption enabled (${this.openbaoAddr})`); + this.scheduleTokenRenewal(); + } catch (error) { + this.isAvailable = false; + this.logger.warn("OpenBao unavailable, using fallback encryption"); + throw error; + } + } + + /** + * Wait for initialization to complete (useful for testing) + */ + async waitForInitialization(): Promise { + await this.initPromise; + } + + /** + * Fetch with timeout protection + * Prevents indefinite hangs if OpenBao becomes unresponsive + * + * @param url - URL to fetch + * @param options - Fetch options + * @param timeoutMs - Timeout in milliseconds (default: 5000ms) + * @returns Response + * @throws Error if request times out or fails + */ + private async fetchWithTimeout( + url: string, + options: RequestInit = {}, + timeoutMs = 5000 + ): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, timeoutMs); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + }); + return response; + } catch (error: unknown) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error(`Request timeout after ${String(timeoutMs)}ms: ${url}`); + } + throw error; + } finally { + clearTimeout(timeoutId); + } + } + + /** + * Authenticate using AppRole + */ + private async authenticate(): Promise { + const credentials = await this.getAppRoleCredentials(); + + const response = await this.fetchWithTimeout(`${this.openbaoAddr}/v1/auth/approle/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + role_id: credentials.role_id, + secret_id: credentials.secret_id, + }), + }); + + if (!response.ok) { + throw new Error( + `AppRole authentication failed: ${String(response.status)} ${response.statusText}` + ); + } + + const data = (await response.json()) as VaultAuthResponse; + + if (!data.auth?.client_token || !data.auth.lease_duration) { + throw new Error("AppRole authentication response missing required fields"); + } + + this.token = data.auth.client_token; + this.tokenExpiry = Date.now() + data.auth.lease_duration * 1000; + + this.logger.log("AppRole authentication successful"); + } + + /** + * Get AppRole credentials from file or environment + */ + private async getAppRoleCredentials(): Promise { + // Try environment variables first + const envRoleId = this.config.get("OPENBAO_ROLE_ID"); + const envSecretId = this.config.get("OPENBAO_SECRET_ID"); + + if (envRoleId && envSecretId) { + return { + role_id: envRoleId, + secret_id: envSecretId, + }; + } + + // Try credentials file + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename + const fileContents = await readFile(APPROLE_CREDENTIALS_PATH, "utf-8"); + const credentials = JSON.parse(fileContents) as AppRoleCredentials; + + if (!credentials.role_id || !credentials.secret_id) { + throw new Error("Credentials file missing required fields"); + } + + return credentials; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : "Unknown error"; + throw new Error( + `Failed to read AppRole credentials from ${APPROLE_CREDENTIALS_PATH}: ${errorMsg}. ` + + "Set OPENBAO_ROLE_ID and OPENBAO_SECRET_ID environment variables as fallback." + ); + } + } + + /** + * Schedule token renewal at 50% TTL + */ + private scheduleTokenRenewal(): void { + if (!this.tokenExpiry || !this.token) { + return; + } + + const ttl = this.tokenExpiry - Date.now(); + const renewalTime = ttl * TOKEN_RENEWAL_THRESHOLD; + + if (renewalTime <= 0) { + // Token already expired or renewal threshold passed + this.checkTokenRenewal().catch((error: unknown) => { + const errorMsg = error instanceof Error ? error.message : "Unknown error"; + this.logger.error(`Token renewal failed: ${errorMsg}`); + }); + return; + } + + this.renewalTimer = setTimeout(() => { + this.checkTokenRenewal().catch((error: unknown) => { + const errorMsg = error instanceof Error ? error.message : "Unknown error"; + this.logger.error(`Token renewal failed: ${errorMsg}`); + }); + }, renewalTime); + } + + /** + * Check if token needs renewal and renew if necessary + */ + private async checkTokenRenewal(): Promise { + if (!this.token || !this.tokenExpiry) { + return; + } + + const remainingTtl = this.tokenExpiry - Date.now(); + const totalTtl = TOKEN_TTL_SECONDS * 1000; + const threshold = totalTtl * TOKEN_RENEWAL_THRESHOLD; + + if (remainingTtl < threshold) { + await this.renewToken(); + } + } + + /** + * Renew the current token + */ + private async renewToken(): Promise { + if (!this.token) { + throw new Error("No token to renew"); + } + + try { + const response = await this.fetchWithTimeout(`${this.openbaoAddr}/v1/auth/token/renew-self`, { + method: "POST", + headers: { + "X-Vault-Token": this.token, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + // Token renewal failed, try to re-authenticate + this.logger.warn("Token renewal failed, attempting re-authentication"); + await this.authenticate(); + this.scheduleTokenRenewal(); + return; + } + + const data = (await response.json()) as VaultAuthResponse; + + if (!data.auth?.client_token || !data.auth.lease_duration) { + throw new Error("Token renewal response missing required fields"); + } + + this.token = data.auth.client_token; + this.tokenExpiry = Date.now() + data.auth.lease_duration * 1000; + + this.logger.log("Token renewed successfully"); + this.scheduleTokenRenewal(); + } catch (error: unknown) { + const errorMsg = error instanceof Error ? error.message : "Unknown"; + this.logger.error(`Token renewal error: ${errorMsg}`); + // Try to re-authenticate + await this.authenticate(); + this.scheduleTokenRenewal(); + } + } + + /** + * Encrypt data using OpenBao Transit or fallback to CryptoService + * + * @param plaintext - Data to encrypt + * @param key - Transit key to use + * @returns Ciphertext with format prefix (vault:v1: or iv:tag:) + */ + async encrypt(plaintext: string, key: TransitKey): Promise { + if (!plaintext) { + throw new Error("Cannot encrypt empty string"); + } + + // Use fallback if OpenBao is unavailable + if (!this.isAvailable || !this.token) { + this.logger.error( + `OpenBao unavailable for encryption (key: ${key}). Using fallback AES-256-GCM. ` + + "This indicates an infrastructure problem that should be investigated." + ); + return this.cryptoService.encrypt(plaintext); + } + + try { + // Encode plaintext to base64 + const encodedPlaintext = Buffer.from(plaintext).toString("base64"); + + const response = await this.fetchWithTimeout( + `${this.openbaoAddr}/v1/transit/encrypt/${key}`, + { + method: "POST", + headers: { + "X-Vault-Token": this.token, + "Content-Type": "application/json", + }, + body: JSON.stringify({ plaintext: encodedPlaintext }), + } + ); + + if (!response.ok) { + throw new Error( + `Transit encrypt failed: ${String(response.status)} ${response.statusText}` + ); + } + + const data = (await response.json()) as TransitEncryptResponse; + + if (!data.data?.ciphertext) { + throw new Error("Transit encrypt response missing ciphertext"); + } + + return data.data.ciphertext; + } catch (error: unknown) { + const errorMsg = error instanceof Error ? error.message : "Unknown"; + this.logger.error( + `Transit encryption failed for ${key}: ${errorMsg}. Using fallback AES-256-GCM. ` + + "Check OpenBao connectivity and logs." + ); + return this.cryptoService.encrypt(plaintext); + } + } + + /** + * Decrypt data using OpenBao Transit or CryptoService + * + * Auto-detects ciphertext format: + * - vault:v1: prefix = OpenBao Transit + * - iv:tag:encrypted format = AES-256-GCM + * + * @param ciphertext - Encrypted data with format prefix + * @param key - Transit key to use (only used for vault:v1: format) + * @returns Decrypted plaintext + */ + async decrypt(ciphertext: string, key: TransitKey): Promise { + if (!ciphertext) { + throw new Error("Cannot decrypt empty string"); + } + + // Detect format + const isVaultFormat = ciphertext.startsWith("vault:v1:"); + + if (isVaultFormat) { + // OpenBao Transit format + if (!this.isAvailable || !this.token) { + throw new Error( + "Cannot decrypt vault:v1: ciphertext: OpenBao is unavailable. " + + "Ensure OpenBao is running or re-encrypt with available encryption service." + ); + } + + try { + const response = await this.fetchWithTimeout( + `${this.openbaoAddr}/v1/transit/decrypt/${key}`, + { + method: "POST", + headers: { + "X-Vault-Token": this.token, + "Content-Type": "application/json", + }, + body: JSON.stringify({ ciphertext }), + } + ); + + if (!response.ok) { + throw new Error( + `Transit decrypt failed: ${String(response.status)} ${response.statusText}` + ); + } + + const data = (await response.json()) as TransitDecryptResponse; + + if (!data.data?.plaintext) { + throw new Error("Transit decrypt response missing plaintext"); + } + + // Decode base64 plaintext + return Buffer.from(data.data.plaintext, "base64").toString("utf-8"); + } catch (error: unknown) { + const errorMsg = error instanceof Error ? error.message : "Unknown"; + this.logger.error(`Transit decryption failed for ${key}: ${errorMsg}`); + throw new Error("Failed to decrypt data"); + } + } else { + // AES-256-GCM format (fallback) + try { + return this.cryptoService.decrypt(ciphertext); + } catch (error: unknown) { + const errorMsg = error instanceof Error ? error.message : "Unknown"; + this.logger.error(`AES decryption failed: ${errorMsg}`); + throw new Error("Failed to decrypt data"); + } + } + } + + /** + * Get OpenBao service status + */ + getStatus(): VaultStatus { + return { + available: this.isAvailable, + fallbackMode: !this.isAvailable, + endpoint: this.openbaoAddr, + }; + } + + /** + * Cleanup on module destroy + */ + onModuleDestroy(): void { + if (this.renewalTimer) { + clearTimeout(this.renewalTimer); + } + } +}