Files
stack/apps/api/src/vault/vault.service.ts
T
jason.woltjeandClaude Opus 4.6 dd171b287f feat(#353): Create VaultService NestJS module for OpenBao Transit
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 <[email protected]>
2026-02-07 16:13:05 -06:00

447 lines
13 KiB
TypeScript

/**
* 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<string>("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<void>;
/**
* Initialize OpenBao connection and authenticate
*/
private async initialize(): Promise<void> {
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<void> {
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<Response> {
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<void> {
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<AppRoleCredentials> {
// Try environment variables first
const envRoleId = this.config.get<string>("OPENBAO_ROLE_ID");
const envSecretId = this.config.get<string>("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<void> {
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<void> {
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<string> {
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<string> {
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);
}
}
}