/** * 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); } } }