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

@@ -399,8 +399,8 @@ describe("AccountEncryptionMiddleware", () => {
expect(result.accessToken).toBe(plaintextToken);
});
it("should handle vault ciphertext format (future-proofing)", async () => {
// Simulate future Transit encryption format
it("should throw error on vault ciphertext when OpenBao unavailable", async () => {
// Simulate vault Transit encryption format when OpenBao is unavailable
const vaultCiphertext = "vault:v1:base64encodeddata";
const mockParams = {
@@ -419,13 +419,13 @@ describe("AccountEncryptionMiddleware", () => {
accessToken: vaultCiphertext,
refreshToken: null,
idToken: null,
encryptionVersion: "vault", // Future: vault encryption
encryptionVersion: "vault", // vault encryption
}));
const result = (await middlewareFunction(mockParams, mockNext)) as any;
// Should pass through unchanged (vault not implemented yet)
expect(result.accessToken).toBe(vaultCiphertext);
// Should throw error because VaultService can't decrypt vault:v1: without OpenBao
await expect(middlewareFunction(mockParams, mockNext)).rejects.toThrow(
"Failed to decrypt account credentials"
);
});
it("should use encryptionVersion as primary discriminator", async () => {
@@ -457,7 +457,7 @@ describe("AccountEncryptionMiddleware", () => {
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
const corruptedToken = "deadbeef:cafebabe:corrupted_data_xyz"; // Valid format but wrong data
@@ -480,15 +480,13 @@ describe("AccountEncryptionMiddleware", () => {
encryptionVersion: "aes", // Marked as encrypted
}));
// Should not throw - just log error and pass through
const result = (await middlewareFunction(mockParams, mockNext)) as any;
// Token should remain unchanged if decryption fails
expect(result.accessToken).toBe(corruptedToken);
expect(result.encryptionVersion).toBe("aes");
// Should throw error - decryption failures are now propagated to prevent silent corruption
await expect(middlewareFunction(mockParams, mockNext)).rejects.toThrow(
"Failed to decrypt account credentials"
);
});
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
const malformedToken = "this:is:not:valid:encrypted:data:too:many:parts";
@@ -511,10 +509,10 @@ describe("AccountEncryptionMiddleware", () => {
encryptionVersion: "aes",
}));
// Should not throw - decryption will fail and token passes through
const result = (await middlewareFunction(mockParams, mockNext)) as any;
expect(result.accessToken).toBe(malformedToken);
// Should throw error - malformed data cannot be decrypted
await expect(middlewareFunction(mockParams, mockNext)).rejects.toThrow(
"Failed to decrypt account credentials"
);
});
});