feat(#353): Create VaultService NestJS module for OpenBao Transit
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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:
@@ -109,6 +109,12 @@ ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_GENERATE_WITH_OPENSSL_RAND_HEX_32
|
|||||||
OPENBAO_ADDR=http://openbao:8200
|
OPENBAO_ADDR=http://openbao:8200
|
||||||
OPENBAO_PORT=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)
|
# Ollama (Optional AI Service)
|
||||||
# ======================
|
# ======================
|
||||||
|
|||||||
@@ -399,8 +399,8 @@ describe("AccountEncryptionMiddleware", () => {
|
|||||||
expect(result.accessToken).toBe(plaintextToken);
|
expect(result.accessToken).toBe(plaintextToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle vault ciphertext format (future-proofing)", async () => {
|
it("should throw error on vault ciphertext when OpenBao unavailable", async () => {
|
||||||
// Simulate future Transit encryption format
|
// Simulate vault Transit encryption format when OpenBao is unavailable
|
||||||
const vaultCiphertext = "vault:v1:base64encodeddata";
|
const vaultCiphertext = "vault:v1:base64encodeddata";
|
||||||
|
|
||||||
const mockParams = {
|
const mockParams = {
|
||||||
@@ -419,13 +419,13 @@ describe("AccountEncryptionMiddleware", () => {
|
|||||||
accessToken: vaultCiphertext,
|
accessToken: vaultCiphertext,
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
idToken: null,
|
idToken: null,
|
||||||
encryptionVersion: "vault", // Future: vault encryption
|
encryptionVersion: "vault", // vault encryption
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const result = (await middlewareFunction(mockParams, mockNext)) as any;
|
// Should throw error because VaultService can't decrypt vault:v1: without OpenBao
|
||||||
|
await expect(middlewareFunction(mockParams, mockNext)).rejects.toThrow(
|
||||||
// Should pass through unchanged (vault not implemented yet)
|
"Failed to decrypt account credentials"
|
||||||
expect(result.accessToken).toBe(vaultCiphertext);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use encryptionVersion as primary discriminator", async () => {
|
it("should use encryptionVersion as primary discriminator", async () => {
|
||||||
@@ -457,7 +457,7 @@ describe("AccountEncryptionMiddleware", () => {
|
|||||||
expect(result.accessToken).toBe(fakeEncryptedToken);
|
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
|
// Test with malformed/corrupted encrypted token
|
||||||
const corruptedToken = "deadbeef:cafebabe:corrupted_data_xyz"; // Valid format but wrong data
|
const corruptedToken = "deadbeef:cafebabe:corrupted_data_xyz"; // Valid format but wrong data
|
||||||
|
|
||||||
@@ -480,15 +480,13 @@ describe("AccountEncryptionMiddleware", () => {
|
|||||||
encryptionVersion: "aes", // Marked as encrypted
|
encryptionVersion: "aes", // Marked as encrypted
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Should not throw - just log error and pass through
|
// Should throw error - decryption failures are now propagated to prevent silent corruption
|
||||||
const result = (await middlewareFunction(mockParams, mockNext)) as any;
|
await expect(middlewareFunction(mockParams, mockNext)).rejects.toThrow(
|
||||||
|
"Failed to decrypt account credentials"
|
||||||
// Token should remain unchanged if decryption fails
|
);
|
||||||
expect(result.accessToken).toBe(corruptedToken);
|
|
||||||
expect(result.encryptionVersion).toBe("aes");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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
|
// Test with data that doesn't match expected format at all
|
||||||
const malformedToken = "this:is:not:valid:encrypted:data:too:many:parts";
|
const malformedToken = "this:is:not:valid:encrypted:data:too:many:parts";
|
||||||
|
|
||||||
@@ -511,10 +509,10 @@ describe("AccountEncryptionMiddleware", () => {
|
|||||||
encryptionVersion: "aes",
|
encryptionVersion: "aes",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Should not throw - decryption will fail and token passes through
|
// Should throw error - malformed data cannot be decrypted
|
||||||
const result = (await middlewareFunction(mockParams, mockNext)) as any;
|
await expect(middlewareFunction(mockParams, mockNext)).rejects.toThrow(
|
||||||
|
"Failed to decrypt account credentials"
|
||||||
expect(result.accessToken).toBe(malformedToken);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
|
|
||||||
import { Logger } from "@nestjs/common";
|
import { Logger } from "@nestjs/common";
|
||||||
import type { PrismaClient } from "@prisma/client";
|
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
|
* Token fields to encrypt/decrypt in Account model
|
||||||
@@ -59,11 +60,11 @@ interface AccountData extends Record<string, unknown> {
|
|||||||
* Register account encryption middleware on Prisma client
|
* Register account encryption middleware on Prisma client
|
||||||
*
|
*
|
||||||
* @param prisma - Prisma client instance
|
* @param prisma - Prisma client instance
|
||||||
* @param cryptoService - Crypto service for encryption/decryption
|
* @param vaultService - Vault service for encryption/decryption
|
||||||
*/
|
*/
|
||||||
export function registerAccountEncryptionMiddleware(
|
export function registerAccountEncryptionMiddleware(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
cryptoService: CryptoService
|
vaultService: VaultService
|
||||||
): void {
|
): void {
|
||||||
const logger = new Logger("AccountEncryptionMiddleware");
|
const logger = new Logger("AccountEncryptionMiddleware");
|
||||||
|
|
||||||
@@ -88,15 +89,15 @@ export function registerAccountEncryptionMiddleware(
|
|||||||
params.action === "updateMany"
|
params.action === "updateMany"
|
||||||
) {
|
) {
|
||||||
if (params.args.data) {
|
if (params.args.data) {
|
||||||
encryptTokens(params.args.data as AccountData, cryptoService);
|
await encryptTokens(params.args.data as AccountData, vaultService);
|
||||||
}
|
}
|
||||||
} else if (params.action === "upsert") {
|
} else if (params.action === "upsert") {
|
||||||
// Handle upsert - encrypt both create and update data
|
// Handle upsert - encrypt both create and update data
|
||||||
if (params.args.create) {
|
if (params.args.create) {
|
||||||
encryptTokens(params.args.create as AccountData, cryptoService);
|
await encryptTokens(params.args.create as AccountData, vaultService);
|
||||||
}
|
}
|
||||||
if (params.args.update) {
|
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
|
// Decrypt on read operations
|
||||||
if (params.action === "findUnique" || params.action === "findFirst") {
|
if (params.action === "findUnique" || params.action === "findFirst") {
|
||||||
if (result && typeof result === "object") {
|
if (result && typeof result === "object") {
|
||||||
decryptTokens(result as AccountData, cryptoService, logger);
|
await decryptTokens(result as AccountData, vaultService, logger);
|
||||||
}
|
}
|
||||||
} else if (params.action === "findMany") {
|
} else if (params.action === "findMany") {
|
||||||
if (Array.isArray(result)) {
|
if (Array.isArray(result)) {
|
||||||
result.forEach((account: unknown) => {
|
for (const account of result) {
|
||||||
if (account && typeof account === "object") {
|
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
|
* Modifies data in-place
|
||||||
*
|
*
|
||||||
* @param data - Account data object
|
* @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<void> {
|
||||||
let encrypted = false;
|
let encrypted = false;
|
||||||
|
let encryptionVersion: "aes" | "vault" | null = null;
|
||||||
|
|
||||||
TOKEN_FIELDS.forEach((field) => {
|
for (const field of TOKEN_FIELDS) {
|
||||||
const value = data[field];
|
const value = data[field];
|
||||||
|
|
||||||
// Skip null/undefined values
|
// Skip null/undefined values
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if already encrypted (idempotent)
|
// Skip if already encrypted (idempotent)
|
||||||
if (typeof value === "string" && isEncrypted(value)) {
|
if (typeof value === "string" && isEncrypted(value)) {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt plaintext value
|
// Encrypt plaintext value
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
data[field] = cryptoService.encrypt(value);
|
const ciphertext = await vaultService.encrypt(value, TransitKey.ACCOUNT_TOKENS);
|
||||||
|
data[field] = ciphertext;
|
||||||
encrypted = true;
|
encrypted = true;
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark as encrypted with AES if any tokens were encrypted
|
// Determine encryption version from ciphertext format
|
||||||
// Note: This condition is necessary because TypeScript's control flow analysis doesn't track
|
if (ciphertext.startsWith("vault:v1:")) {
|
||||||
// the `encrypted` flag through forEach closures. The flag starts as false and is only set to
|
encryptionVersion = "vault";
|
||||||
// true when a token is actually encrypted. This prevents setting encryptionVersion='aes' on
|
} else {
|
||||||
// records that have no tokens or only null/already-encrypted tokens (idempotent safety).
|
encryptionVersion = "aes";
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
}
|
||||||
if (encrypted) {
|
}
|
||||||
data.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
|
* if decryption is needed, falling back to pattern matching for
|
||||||
* records without the field (migration compatibility).
|
* records without the field (migration compatibility).
|
||||||
*
|
*
|
||||||
|
* Throws errors on decryption failure to prevent silent corruption.
|
||||||
|
*
|
||||||
* @param account - Account record
|
* @param account - Account record
|
||||||
* @param cryptoService - Crypto service
|
* @param vaultService - Vault service
|
||||||
* @param logger - NestJS logger for error reporting
|
* @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<void> {
|
||||||
// Check encryptionVersion field first (primary discriminator)
|
// 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];
|
const value = account[field];
|
||||||
|
|
||||||
// Skip null/undefined values
|
// Skip null/undefined values
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
// Primary path: Use encryptionVersion field
|
// Primary path: Use encryptionVersion field
|
||||||
if (shouldDecrypt) {
|
if (shouldDecrypt) {
|
||||||
try {
|
try {
|
||||||
account[field] = cryptoService.decrypt(value);
|
account[field] = await vaultService.decrypt(value, TransitKey.ACCOUNT_TOKENS);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log decryption failure but don't crash
|
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
||||||
// This allows the app to continue if a token is corrupted
|
throw new Error(
|
||||||
// Security: Only log error type, not stack trace which may contain encrypted/decrypted data
|
`Failed to decrypt account credentials. Please reconnect this account. Details: ${errorMsg}`
|
||||||
const errorType = error instanceof Error ? error.constructor.name : "Unknown";
|
);
|
||||||
logger.error(`Failed to decrypt ${field} for account: ${errorType}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: For records without encryptionVersion (migration compatibility)
|
// Fallback: For records without encryptionVersion (migration compatibility)
|
||||||
else if (!account.encryptionVersion && isAESEncrypted(value)) {
|
else if (!account.encryptionVersion && isEncrypted(value)) {
|
||||||
try {
|
try {
|
||||||
account[field] = cryptoService.decrypt(value);
|
account[field] = await vaultService.decrypt(value, TransitKey.ACCOUNT_TOKENS);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Security: Only log error type, not stack trace which may contain encrypted/decrypted data
|
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
||||||
const errorType = error instanceof Error ? error.constructor.name : "Unknown";
|
throw new Error(
|
||||||
logger.error(`Failed to decrypt ${field} (fallback mode): ${errorType}`);
|
`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
|
// Legacy plaintext (no encryptionVersion) - pass through unchanged
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { Global, Module } from "@nestjs/common";
|
import { Global, Module } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { PrismaService } from "./prisma.service";
|
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
|
* Global Prisma module providing database access throughout the application
|
||||||
* Marked as @Global() so PrismaService is available in all modules without importing
|
* 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()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule, VaultModule],
|
||||||
providers: [PrismaService, CryptoService],
|
providers: [PrismaService],
|
||||||
exports: [PrismaService],
|
exports: [PrismaService],
|
||||||
})
|
})
|
||||||
export class PrismaModule {}
|
export class PrismaModule {}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { PrismaService } from "./prisma.service";
|
import { PrismaService } from "./prisma.service";
|
||||||
|
import { VaultService } from "../vault/vault.service";
|
||||||
import { CryptoService } from "../federation/crypto.service";
|
import { CryptoService } from "../federation/crypto.service";
|
||||||
|
|
||||||
describe("PrismaService", () => {
|
describe("PrismaService", () => {
|
||||||
@@ -12,11 +13,13 @@ describe("PrismaService", () => {
|
|||||||
// Mock ConfigService with a valid test encryption key
|
// Mock ConfigService with a valid test encryption key
|
||||||
mockConfigService = {
|
mockConfigService = {
|
||||||
get: vi.fn((key: string) => {
|
get: vi.fn((key: string) => {
|
||||||
if (key === "ENCRYPTION_KEY") {
|
const config: Record<string, string> = {
|
||||||
// Valid 64-character hex string (32 bytes)
|
ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||||
return "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
OPENBAO_ADDR: "http://localhost:8200",
|
||||||
}
|
OPENBAO_ROLE_ID: "test-role-id",
|
||||||
return null;
|
OPENBAO_SECRET_ID: "test-secret-id",
|
||||||
|
};
|
||||||
|
return config[key] || null;
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,6 +30,7 @@ describe("PrismaService", () => {
|
|||||||
provide: ConfigService,
|
provide: ConfigService,
|
||||||
useValue: mockConfigService,
|
useValue: mockConfigService,
|
||||||
},
|
},
|
||||||
|
VaultService,
|
||||||
CryptoService,
|
CryptoService,
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { CryptoService } from "../federation/crypto.service";
|
import { VaultService } from "../vault/vault.service";
|
||||||
import { registerAccountEncryptionMiddleware } from "./account-encryption.middleware";
|
import { registerAccountEncryptionMiddleware } from "./account-encryption.middleware";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prisma service that manages database connection lifecycle
|
* Prisma service that manages database connection lifecycle
|
||||||
* Extends PrismaClient to provide connection management and health checks
|
* Extends PrismaClient to provide connection management and health checks
|
||||||
*
|
*
|
||||||
* IMPORTANT: CryptoService is required (not optional) because it will throw
|
* IMPORTANT: VaultService is required (not optional) for encryption/decryption
|
||||||
* if ENCRYPTION_KEY is not configured, providing fail-fast behavior.
|
* of sensitive Account tokens. It automatically falls back to AES-256-GCM when
|
||||||
|
* OpenBao is unavailable.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(PrismaService.name);
|
private readonly logger = new Logger(PrismaService.name);
|
||||||
|
|
||||||
constructor(private readonly cryptoService: CryptoService) {
|
constructor(private readonly vaultService: VaultService) {
|
||||||
super({
|
super({
|
||||||
log: process.env.NODE_ENV === "development" ? ["query", "info", "warn", "error"] : ["error"],
|
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");
|
this.logger.log("Database connection established");
|
||||||
|
|
||||||
// Register Account token encryption middleware
|
// Register Account token encryption middleware
|
||||||
// CryptoService constructor will have already validated ENCRYPTION_KEY exists
|
// VaultService provides OpenBao Transit encryption with AES-256-GCM fallback
|
||||||
registerAccountEncryptionMiddleware(this, this.cryptoService);
|
registerAccountEncryptionMiddleware(this, this.vaultService);
|
||||||
this.logger.log("Account encryption middleware registered");
|
this.logger.log("Account encryption middleware registered");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to connect to database", error);
|
this.logger.error("Failed to connect to database", error);
|
||||||
|
|||||||
46
apps/api/src/vault/vault.constants.ts
Normal file
46
apps/api/src/vault/vault.constants.ts
Normal 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;
|
||||||
51
apps/api/src/vault/vault.health.ts
Normal file
51
apps/api/src/vault/vault.health.ts
Normal 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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/api/src/vault/vault.module.ts
Normal file
19
apps/api/src/vault/vault.module.ts
Normal 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 {}
|
||||||
768
apps/api/src/vault/vault.service.spec.ts
Normal file
768
apps/api/src/vault/vault.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
446
apps/api/src/vault/vault.service.ts
Normal file
446
apps/api/src/vault/vault.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user