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]>
447 lines
13 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|