feat(phase-3): workstream 6 — SOC 2 Type II Preparation
Implements all 22 WS6 tasks completing Phase 3 Enterprise. Column-level encryption (AES-256-CBC, Vault-backed key) via EncryptionService applied to credentials.secret_hash, credentials.vault_path, webhook_subscriptions.vault_secret_path, and agent_did_keys.vault_key_path. Backward-compatible: isEncrypted() guard skips decryption for existing plaintext rows until next read-write cycle. Audit chain integrity (CC7.2): AuditRepository computes SHA-256 Merkle hash on every INSERT (hash = SHA-256(eventId+timestamp+action+outcome+agentId+orgId+prevHash)). AuditVerificationService walks the full chain verifying hash continuity. AuditChainVerificationJob runs hourly; sets agentidp_audit_chain_integrity Prometheus gauge to 1 (pass) or 0 (fail). TLS enforcement (CC6.7): TLSEnforcementMiddleware registered as first middleware in Express stack; 301 redirect on non-https X-Forwarded-Proto in production. SecretsRotationJob (CC9.2): hourly scan for credentials expiring within 7 days; increments agentidp_credentials_expiring_soon_total. ComplianceController + routes: GET /audit/verify (auth+audit:read scope, 30/min rate-limit); GET /compliance/controls (public, Cache-Control 60s). ComplianceStatusStore: module-level map updated by jobs, consumed by controller. Prometheus: 2 new metrics (agentidp_credentials_expiring_soon_total, agentidp_audit_chain_integrity); 6 alerting rules in alerts.yml. Compliance docs: soc2-controls-matrix.md, encryption-runbook.md, audit-log-runbook.md, incident-response.md, secrets-rotation.md. Tests: 557 unit tests passing (35 suites); 26 new tests (EncryptionService, AuditVerificationService); 19 compliance integration tests. TypeScript clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
190
tests/unit/services/EncryptionService.test.ts
Normal file
190
tests/unit/services/EncryptionService.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Unit tests for EncryptionService — AES-256-CBC column-level encryption.
|
||||
*
|
||||
* Tests:
|
||||
* 1. Encrypt/decrypt round-trip returns original plaintext
|
||||
* 2. isEncrypted: true for base64:base64 format, false for plaintext strings
|
||||
* 3. encryptColumn produces different ciphertext on each call (IV randomness)
|
||||
* 4. Singleton reset utility works for test isolation
|
||||
*/
|
||||
|
||||
import {
|
||||
EncryptionService,
|
||||
getEncryptionService,
|
||||
_resetEncryptionServiceSingleton,
|
||||
} from '../../../src/services/EncryptionService';
|
||||
import { VaultClient } from '../../../src/vault/VaultClient';
|
||||
|
||||
// ============================================================================
|
||||
// Mock VaultClient
|
||||
// ============================================================================
|
||||
|
||||
/** A 32-byte (64-char hex) test encryption key. */
|
||||
const TEST_KEY = 'a'.repeat(64); // 64 x 'a' = valid 32-byte hex key
|
||||
|
||||
/**
|
||||
* Creates a mock VaultClient that returns TEST_KEY from readArbitrarySecret.
|
||||
*/
|
||||
function makeMockVaultClient(): VaultClient {
|
||||
const mock = {
|
||||
readArbitrarySecret: jest.fn().mockResolvedValue({ encryptionKey: TEST_KEY }),
|
||||
writeArbitrarySecret: jest.fn().mockResolvedValue(undefined),
|
||||
writeSecret: jest.fn(),
|
||||
readSecret: jest.fn(),
|
||||
verifySecret: jest.fn(),
|
||||
deleteSecret: jest.fn(),
|
||||
};
|
||||
return mock as unknown as VaultClient;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('EncryptionService', () => {
|
||||
let service: EncryptionService;
|
||||
let mockVaultClient: VaultClient;
|
||||
|
||||
beforeEach(() => {
|
||||
_resetEncryptionServiceSingleton();
|
||||
mockVaultClient = makeMockVaultClient();
|
||||
service = new EncryptionService(mockVaultClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_resetEncryptionServiceSingleton();
|
||||
});
|
||||
|
||||
// ── Round-trip ────────────────────────────────────────────────────────────
|
||||
|
||||
it('should encrypt and then decrypt back to the original plaintext', async () => {
|
||||
const plaintext = 'super-secret-credential-hash-value';
|
||||
|
||||
const encrypted = await service.encryptColumn(plaintext);
|
||||
expect(encrypted).not.toBe(plaintext);
|
||||
expect(encrypted).toContain(':');
|
||||
|
||||
const decrypted = await service.decryptColumn(encrypted);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('should handle empty string round-trip', async () => {
|
||||
const plaintext = '';
|
||||
const encrypted = await service.encryptColumn(plaintext);
|
||||
const decrypted = await service.decryptColumn(encrypted);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('should handle unicode strings in round-trip', async () => {
|
||||
const plaintext = 'secret/data/agentidp/agents/über-agent/credentials/cred-123';
|
||||
const encrypted = await service.encryptColumn(plaintext);
|
||||
const decrypted = await service.decryptColumn(encrypted);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
// ── IV randomness ─────────────────────────────────────────────────────────
|
||||
|
||||
it('should produce different ciphertext on each call (random IV)', async () => {
|
||||
const plaintext = 'same-plaintext-value';
|
||||
|
||||
const encrypted1 = await service.encryptColumn(plaintext);
|
||||
const encrypted2 = await service.encryptColumn(plaintext);
|
||||
|
||||
// Same plaintext but different IV → different ciphertext
|
||||
expect(encrypted1).not.toBe(encrypted2);
|
||||
|
||||
// Both must still decrypt to the same plaintext
|
||||
expect(await service.decryptColumn(encrypted1)).toBe(plaintext);
|
||||
expect(await service.decryptColumn(encrypted2)).toBe(plaintext);
|
||||
});
|
||||
|
||||
// ── isEncrypted ──────────────────────────────────────────────────────────
|
||||
|
||||
it('should return true for a value in base64:base64 format', async () => {
|
||||
const encrypted = await service.encryptColumn('test-value');
|
||||
expect(service.isEncrypted(encrypted)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for a plaintext bcrypt hash', () => {
|
||||
const bcryptHash = '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy';
|
||||
expect(service.isEncrypted(bcryptHash)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for a Vault path string', () => {
|
||||
expect(service.isEncrypted('secret/data/agentidp/agents/abc/credentials/xyz')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for an empty string', () => {
|
||||
expect(service.isEncrypted('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for a plain UUID', () => {
|
||||
expect(service.isEncrypted('550e8400-e29b-41d4-a716-446655440000')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for a manually constructed base64:base64 string', () => {
|
||||
const iv = Buffer.from('deadbeef12345678', 'hex').toString('base64');
|
||||
const ct = Buffer.from('cafebabe00112233', 'hex').toString('base64');
|
||||
expect(service.isEncrypted(`${iv}:${ct}`)).toBe(true);
|
||||
});
|
||||
|
||||
// ── Vault key fetching ────────────────────────────────────────────────────
|
||||
|
||||
it('should call Vault readArbitrarySecret once and cache the key', async () => {
|
||||
const plaintext = 'value1';
|
||||
await service.encryptColumn(plaintext);
|
||||
await service.encryptColumn(plaintext);
|
||||
await service.encryptColumn(plaintext);
|
||||
|
||||
// Key should be fetched only once
|
||||
expect(
|
||||
(mockVaultClient.readArbitrarySecret as jest.Mock).mock.calls.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('should use the ENCRYPTION_KEY_VAULT_PATH env var for the Vault path', async () => {
|
||||
const originalPath = process.env['ENCRYPTION_KEY_VAULT_PATH'];
|
||||
process.env['ENCRYPTION_KEY_VAULT_PATH'] = 'secret/data/custom/path';
|
||||
|
||||
const freshService = new EncryptionService(mockVaultClient);
|
||||
await freshService.encryptColumn('test');
|
||||
|
||||
expect(
|
||||
(mockVaultClient.readArbitrarySecret as jest.Mock).mock.calls[0][0],
|
||||
).toBe('secret/data/custom/path');
|
||||
|
||||
// Restore env
|
||||
if (originalPath === undefined) {
|
||||
delete process.env['ENCRYPTION_KEY_VAULT_PATH'];
|
||||
} else {
|
||||
process.env['ENCRYPTION_KEY_VAULT_PATH'] = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Error handling ────────────────────────────────────────────────────────
|
||||
|
||||
it('should throw when ciphertext has no colon separator', async () => {
|
||||
await expect(service.decryptColumn('invalidformat')).rejects.toThrow(
|
||||
'Invalid encrypted column format',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when Vault returns an invalid key', async () => {
|
||||
const badVault = {
|
||||
readArbitrarySecret: jest.fn().mockResolvedValue({ encryptionKey: 'tooshort' }),
|
||||
} as unknown as VaultClient;
|
||||
|
||||
const badService = new EncryptionService(badVault);
|
||||
await expect(badService.encryptColumn('test')).rejects.toThrow(
|
||||
'expected a 64-character hex string',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Singleton ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('getEncryptionService should return the same instance on repeated calls', () => {
|
||||
const instance1 = getEncryptionService(mockVaultClient);
|
||||
const instance2 = getEncryptionService(mockVaultClient);
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user