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

@@ -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;

View File

@@ -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}`,
};
}
}
}

View File

@@ -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 {}

View File

@@ -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<typeof vi.fn>;
beforeEach(() => {
// Mock fetch
mockFetch = vi.fn();
global.fetch = mockFetch;
// Mock ConfigService
configService = {
get: vi.fn((key: string) => {
const config: Record<string, string> = {
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<VaultService> {
// 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>(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>(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>(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>(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>(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);
});
});
});

View File

@@ -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<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);
}
}
}