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:
@@ -1,8 +1,15 @@
|
||||
/**
|
||||
* Audit Log Service for SentryAgent.ai AgentIdP.
|
||||
* Provides methods for logging and querying immutable audit events.
|
||||
*
|
||||
* SOC 2 CC7.2 — Audit Log Integrity:
|
||||
* Each event is cryptographically linked to the previous one via a SHA-256 hash chain.
|
||||
* The hash is computed as:
|
||||
* SHA-256(eventId + timestamp.toISOString() + action + outcome + agentId + organizationId + previousHash)
|
||||
* This makes any tampering, deletion, or insertion detectable via AuditVerificationService.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { AuditRepository } from '../repositories/AuditRepository.js';
|
||||
import {
|
||||
IAuditEvent,
|
||||
@@ -41,6 +48,42 @@ export class AuditService {
|
||||
return cutoff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the SHA-256 hash for an audit event in the chain.
|
||||
* Used internally and by AuditVerificationService.
|
||||
*
|
||||
* @param eventId - The event UUID.
|
||||
* @param timestamp - The event timestamp.
|
||||
* @param action - The audit action.
|
||||
* @param outcome - The audit outcome.
|
||||
* @param agentId - The agent UUID.
|
||||
* @param organizationId - The organization UUID.
|
||||
* @param previousHash - The hash of the preceding event ('' for the first event).
|
||||
* @returns 64-character hex SHA-256 hash.
|
||||
*/
|
||||
static computeHash(
|
||||
eventId: string,
|
||||
timestamp: Date,
|
||||
action: string,
|
||||
outcome: string,
|
||||
agentId: string,
|
||||
organizationId: string,
|
||||
previousHash: string,
|
||||
): string {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(
|
||||
eventId +
|
||||
timestamp.toISOString() +
|
||||
action +
|
||||
outcome +
|
||||
agentId +
|
||||
organizationId +
|
||||
previousHash,
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an audit event. This is a fire-and-forget async insert for token
|
||||
* endpoints (do not await). For DB-backed operations, await this method.
|
||||
@@ -51,6 +94,7 @@ export class AuditService {
|
||||
* @param ipAddress - The client IP address.
|
||||
* @param userAgent - The client User-Agent header.
|
||||
* @param metadata - Action-specific structured context data.
|
||||
* @param organizationId - Optional organization UUID for hash chain computation.
|
||||
* @returns Promise resolving to the created audit event.
|
||||
*/
|
||||
async logEvent(
|
||||
@@ -60,9 +104,11 @@ export class AuditService {
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
metadata: Record<string, unknown>,
|
||||
organizationId?: string,
|
||||
): Promise<IAuditEvent> {
|
||||
return this.auditRepository.create({
|
||||
agentId,
|
||||
organizationId,
|
||||
action,
|
||||
outcome,
|
||||
ipAddress,
|
||||
|
||||
258
src/services/AuditVerificationService.ts
Normal file
258
src/services/AuditVerificationService.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* AuditVerificationService — SOC 2 CC7.2 Audit Log Integrity.
|
||||
*
|
||||
* Walks the audit_events hash chain and verifies that every event's stored hash
|
||||
* matches the recomputed hash of its fields, and that its previous_hash matches
|
||||
* the hash of the chronologically preceding event.
|
||||
*
|
||||
* A broken chain indicates potential log tampering, deletion, or insertion of events.
|
||||
* The first detected break is reported via `brokenAtEventId`.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { Pool, QueryResult } from 'pg';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Result of a single audit chain verification run.
|
||||
* Returned by verifyChain() and consumed by ComplianceController and
|
||||
* AuditChainVerificationJob.
|
||||
*/
|
||||
export interface IChainVerificationResult {
|
||||
/** `true` if every event in the checked range maintains an unbroken cryptographic hash chain. */
|
||||
verified: boolean;
|
||||
/** Total number of audit events examined during this verification run. */
|
||||
checkedCount: number;
|
||||
/**
|
||||
* UUID of the first audit event where chain continuity failed, or `null` when `verified` is `true`.
|
||||
* Only the first detected break is reported.
|
||||
*/
|
||||
brokenAtEventId: string | null;
|
||||
/** ISO 8601 lower bound applied during this verification run (only present if fromDate was supplied). */
|
||||
fromDate?: string;
|
||||
/** ISO 8601 upper bound applied during this verification run (only present if toDate was supplied). */
|
||||
toDate?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Internal row shape
|
||||
// ============================================================================
|
||||
|
||||
/** Raw row from audit_events used during chain traversal. */
|
||||
interface AuditChainRow {
|
||||
event_id: string;
|
||||
timestamp: Date;
|
||||
action: string;
|
||||
outcome: string;
|
||||
agent_id: string;
|
||||
organization_id: string;
|
||||
hash: string;
|
||||
previous_hash: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Service
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Service that performs cryptographic verification of the audit event hash chain.
|
||||
* Implements a single-pass walk of all events in an optional date range,
|
||||
* recomputing each hash and checking linkage to the previous event.
|
||||
*/
|
||||
export class AuditVerificationService {
|
||||
/**
|
||||
* @param pool - PostgreSQL connection pool used to query audit_events.
|
||||
*/
|
||||
constructor(private readonly pool: Pool) {}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verifies the integrity of the audit event chain across an optional date range.
|
||||
*
|
||||
* Events are traversed in ascending chronological order (timestamp ASC, event_id ASC).
|
||||
* For each event:
|
||||
* 1. Recompute the expected hash from the event's fields and the previous event's hash.
|
||||
* 2. Compare to the stored `hash`.
|
||||
* 3. Verify that `previous_hash` matches the preceding row's hash.
|
||||
*
|
||||
* Verification stops at the first detected break and returns the broken event's ID.
|
||||
* Events seeded with empty-string hashes (pre-chain migration rows) are skipped.
|
||||
*
|
||||
* @param fromDate - Optional ISO 8601 lower bound (inclusive) for the date range.
|
||||
* @param toDate - Optional ISO 8601 upper bound (inclusive) for the date range.
|
||||
* @returns Chain verification result.
|
||||
*/
|
||||
async verifyChain(
|
||||
fromDate?: string,
|
||||
toDate?: string,
|
||||
): Promise<IChainVerificationResult> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (fromDate !== undefined) {
|
||||
conditions.push(`timestamp >= $${paramIndex++}`);
|
||||
params.push(new Date(fromDate));
|
||||
}
|
||||
if (toDate !== undefined) {
|
||||
conditions.push(`timestamp <= $${paramIndex++}`);
|
||||
params.push(new Date(toDate));
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const result: QueryResult<AuditChainRow> = await this.pool.query(
|
||||
`SELECT event_id, timestamp, action, outcome, agent_id, organization_id, hash, previous_hash
|
||||
FROM audit_events
|
||||
${whereClause}
|
||||
ORDER BY timestamp ASC, event_id ASC`,
|
||||
params,
|
||||
);
|
||||
|
||||
const rows = result.rows;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return {
|
||||
verified: true,
|
||||
checkedCount: 0,
|
||||
brokenAtEventId: null,
|
||||
...(fromDate !== undefined ? { fromDate } : {}),
|
||||
...(toDate !== undefined ? { toDate } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
let previousHash = '';
|
||||
let checkedCount = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
// Skip events seeded with empty hashes (pre-chain migration rows)
|
||||
if (row.hash === '' && row.previous_hash === '') {
|
||||
previousHash = '';
|
||||
checkedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verify previous_hash linkage
|
||||
if (row.previous_hash !== previousHash) {
|
||||
return {
|
||||
verified: false,
|
||||
checkedCount,
|
||||
brokenAtEventId: row.event_id,
|
||||
...(fromDate !== undefined ? { fromDate } : {}),
|
||||
...(toDate !== undefined ? { toDate } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Recompute and verify the stored hash
|
||||
const expectedHash = this.computeHash(
|
||||
row.event_id,
|
||||
row.timestamp,
|
||||
row.action,
|
||||
row.outcome,
|
||||
row.agent_id,
|
||||
row.organization_id,
|
||||
row.previous_hash,
|
||||
);
|
||||
|
||||
if (expectedHash !== row.hash) {
|
||||
return {
|
||||
verified: false,
|
||||
checkedCount,
|
||||
brokenAtEventId: row.event_id,
|
||||
...(fromDate !== undefined ? { fromDate } : {}),
|
||||
...(toDate !== undefined ? { toDate } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
previousHash = row.hash;
|
||||
checkedCount++;
|
||||
}
|
||||
|
||||
return {
|
||||
verified: true,
|
||||
checkedCount,
|
||||
brokenAtEventId: null,
|
||||
...(fromDate !== undefined ? { fromDate } : {}),
|
||||
...(toDate !== undefined ? { toDate } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Computes the SHA-256 hash for a given audit event.
|
||||
* Must match the algorithm used by AuditRepository.create.
|
||||
*
|
||||
* @param eventId - The event UUID.
|
||||
* @param timestamp - The event timestamp.
|
||||
* @param action - The audit action.
|
||||
* @param outcome - The audit outcome.
|
||||
* @param agentId - The agent UUID.
|
||||
* @param organizationId - The organization UUID.
|
||||
* @param previousHash - The hash of the preceding event.
|
||||
* @returns 64-character hex SHA-256 hash.
|
||||
*/
|
||||
private computeHash(
|
||||
eventId: string,
|
||||
timestamp: Date,
|
||||
action: string,
|
||||
outcome: string,
|
||||
agentId: string,
|
||||
organizationId: string,
|
||||
previousHash: string,
|
||||
): string {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(
|
||||
eventId +
|
||||
timestamp.toISOString() +
|
||||
action +
|
||||
outcome +
|
||||
agentId +
|
||||
organizationId +
|
||||
previousHash,
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Singleton export
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Module-level singleton instance of AuditVerificationService.
|
||||
* Initialised lazily on first call to getAuditVerificationService().
|
||||
*/
|
||||
let _instance: AuditVerificationService | null = null;
|
||||
|
||||
/**
|
||||
* Returns the singleton AuditVerificationService, creating it on first call.
|
||||
*
|
||||
* @param pool - PostgreSQL pool (required on first call; ignored on subsequent calls).
|
||||
* @returns The singleton AuditVerificationService.
|
||||
*/
|
||||
export function getAuditVerificationService(pool: Pool): AuditVerificationService {
|
||||
if (_instance === null) {
|
||||
_instance = new AuditVerificationService(pool);
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the module singleton (for testing only).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function _resetAuditVerificationServiceSingleton(): void {
|
||||
_instance = null;
|
||||
}
|
||||
111
src/services/ComplianceStatusStore.ts
Normal file
111
src/services/ComplianceStatusStore.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* ComplianceStatusStore — shared in-memory store for SOC 2 control statuses.
|
||||
*
|
||||
* This module maintains a module-level Map that background jobs (SecretsRotationJob,
|
||||
* AuditChainVerificationJob) write to, and ComplianceController reads from.
|
||||
*
|
||||
* Using a shared module-level store ensures a single source of truth within a
|
||||
* process and avoids introducing a new database dependency for transient status data.
|
||||
*
|
||||
* SOC 2 controls monitored:
|
||||
* CC6.1 — Encryption at Rest (EncryptionService, AES-256-CBC, Vault-backed keys)
|
||||
* CC6.7 — TLS Enforcement (TLSEnforcementMiddleware, X-Forwarded-Proto)
|
||||
* CC7.2 — Audit Log Integrity (AuditService hash chain, AuditVerificationService)
|
||||
* CC9.2 — Secrets Rotation (SecretsRotationJob, agentidp_credentials_expiring_soon_total)
|
||||
* CC7.1 — Webhook Dead-Letter Monitoring (WebhookDeliveryWorker dead-letter queue)
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/** Valid status values for a SOC 2 control. */
|
||||
export type ControlStatus = 'passing' | 'failing' | 'unknown';
|
||||
|
||||
/** SOC 2 Trust Services Criteria control identifiers. */
|
||||
export type ControlId = 'CC6.1' | 'CC6.7' | 'CC7.2' | 'CC9.2' | 'CC7.1';
|
||||
|
||||
/** A single SOC 2 control status record. */
|
||||
export interface IControlStatusRecord {
|
||||
id: ControlId;
|
||||
name: string;
|
||||
status: ControlStatus;
|
||||
lastChecked: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Static control metadata
|
||||
// ============================================================================
|
||||
|
||||
/** Canonical names for each SOC 2 control ID, used by ComplianceController. */
|
||||
const CONTROL_NAMES: Record<ControlId, string> = {
|
||||
'CC6.1': 'Encryption at Rest',
|
||||
'CC6.7': 'TLS Enforcement',
|
||||
'CC7.2': 'Audit Log Integrity',
|
||||
'CC9.2': 'Secrets Rotation',
|
||||
'CC7.1': 'Webhook Dead-Letter Monitoring',
|
||||
};
|
||||
|
||||
/** Ordered list of all in-scope control IDs (defines display order in API responses). */
|
||||
const CONTROL_IDS: ControlId[] = ['CC6.1', 'CC6.7', 'CC7.2', 'CC9.2', 'CC7.1'];
|
||||
|
||||
// ============================================================================
|
||||
// Module-level store
|
||||
// ============================================================================
|
||||
|
||||
/** Internal status storage: control ID → { status, lastChecked ISO string }. */
|
||||
const statusStore = new Map<ControlId, { status: ControlStatus; lastChecked: string }>(
|
||||
CONTROL_IDS.map((id) => [
|
||||
id,
|
||||
{ status: 'unknown', lastChecked: new Date().toISOString() },
|
||||
]),
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Updates the status of a SOC 2 control.
|
||||
* Called by background jobs when they complete a check.
|
||||
*
|
||||
* @param id - The SOC 2 control identifier.
|
||||
* @param status - The new status to record.
|
||||
*/
|
||||
export function updateControlStatus(id: ControlId, status: ControlStatus): void {
|
||||
statusStore.set(id, { status, lastChecked: new Date().toISOString() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current status of all five SOC 2 controls.
|
||||
* Called by ComplianceController to build the GET /compliance/controls response.
|
||||
*
|
||||
* @returns Array of five IControlStatusRecord objects in the canonical display order.
|
||||
*/
|
||||
export function getAllControlStatuses(): IControlStatusRecord[] {
|
||||
return CONTROL_IDS.map((id) => {
|
||||
const stored = statusStore.get(id) ?? { status: 'unknown' as ControlStatus, lastChecked: new Date().toISOString() };
|
||||
return {
|
||||
id,
|
||||
name: CONTROL_NAMES[id],
|
||||
status: stored.status,
|
||||
lastChecked: stored.lastChecked,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current status of a single SOC 2 control.
|
||||
*
|
||||
* @param id - The SOC 2 control identifier.
|
||||
* @returns The IControlStatusRecord for the requested control.
|
||||
*/
|
||||
export function getControlStatus(id: ControlId): IControlStatusRecord {
|
||||
const stored = statusStore.get(id) ?? { status: 'unknown' as ControlStatus, lastChecked: new Date().toISOString() };
|
||||
return {
|
||||
id,
|
||||
name: CONTROL_NAMES[id],
|
||||
status: stored.status,
|
||||
lastChecked: stored.lastChecked,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
/**
|
||||
* Credential Management Service for SentryAgent.ai AgentIdP.
|
||||
* Business logic for generating, listing, rotating, and revoking credentials.
|
||||
*
|
||||
* All writes to `secret_hash` and `vault_path` are encrypted via EncryptionService
|
||||
* (AES-256-CBC, key stored in Vault) before being persisted to PostgreSQL.
|
||||
* All reads of those fields are decrypted before use.
|
||||
* The `isEncrypted()` guard supports backward-compat with pre-encryption rows.
|
||||
*/
|
||||
|
||||
import { CredentialRepository } from '../repositories/CredentialRepository.js';
|
||||
@@ -8,6 +13,7 @@ import { AgentRepository } from '../repositories/AgentRepository.js';
|
||||
import { AuditService } from './AuditService.js';
|
||||
import { VaultClient } from '../vault/VaultClient.js';
|
||||
import { EventPublisher } from './EventPublisher.js';
|
||||
import { EncryptionService } from './EncryptionService.js';
|
||||
import {
|
||||
ICredential,
|
||||
ICredentialWithSecret,
|
||||
@@ -37,6 +43,9 @@ export class CredentialService {
|
||||
* When null, bcrypt is used (Phase 1 behaviour).
|
||||
* @param eventPublisher - Optional EventPublisher. When provided, credential events are
|
||||
* published as webhooks and Kafka messages (fire-and-forget).
|
||||
* @param encryptionService - Optional EncryptionService. When provided, sensitive column values
|
||||
* are encrypted before write and decrypted after read (SOC 2 CC6.1).
|
||||
* When null, values are stored as-is (backward-compat mode).
|
||||
*/
|
||||
constructor(
|
||||
private readonly credentialRepository: CredentialRepository,
|
||||
@@ -44,8 +53,25 @@ export class CredentialService {
|
||||
private readonly auditService: AuditService,
|
||||
private readonly vaultClient: VaultClient | null = null,
|
||||
private readonly eventPublisher: EventPublisher | null = null,
|
||||
private readonly encryptionService: EncryptionService | null = null,
|
||||
) {}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Encryption helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Encrypts a column value if EncryptionService is available; otherwise returns the value as-is.
|
||||
*
|
||||
* @param value - The plaintext column value.
|
||||
* @returns The encrypted value, or the original value if encryption is not configured.
|
||||
*/
|
||||
private async maybeEncrypt(value: string): Promise<string> {
|
||||
if (this.encryptionService === null) return value;
|
||||
return this.encryptionService.encryptColumn(value);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generates a new client credential for an agent.
|
||||
* The agent must be in 'active' status.
|
||||
@@ -87,16 +113,18 @@ export class CredentialService {
|
||||
// Phase 2: generate the UUID first so the Vault path includes the real credentialId
|
||||
const credentialId = uuidv4();
|
||||
const vaultPath = await this.vaultClient.writeSecret(agentId, credentialId, plainSecret);
|
||||
const encryptedVaultPath = await this.maybeEncrypt(vaultPath);
|
||||
credential = await this.credentialRepository.createWithVaultPath(
|
||||
credentialId,
|
||||
agentId,
|
||||
vaultPath,
|
||||
encryptedVaultPath,
|
||||
expiresAt,
|
||||
);
|
||||
} else {
|
||||
// Phase 1: bcrypt hash stored in PostgreSQL
|
||||
const secretHash = await hashSecret(plainSecret);
|
||||
credential = await this.credentialRepository.create(agentId, secretHash, expiresAt);
|
||||
const encryptedHash = await this.maybeEncrypt(secretHash);
|
||||
credential = await this.credentialRepository.create(agentId, encryptedHash, expiresAt);
|
||||
}
|
||||
|
||||
await this.auditService.logEvent(
|
||||
@@ -196,11 +224,13 @@ export class CredentialService {
|
||||
if (this.vaultClient !== null) {
|
||||
// Phase 2: overwrite the existing Vault secret (KV v2 creates a new version)
|
||||
const vaultPath = await this.vaultClient.writeSecret(agentId, credentialId, plainSecret);
|
||||
updated = await this.credentialRepository.updateVaultPath(credentialId, vaultPath, expiresAt);
|
||||
const encryptedVaultPath = await this.maybeEncrypt(vaultPath);
|
||||
updated = await this.credentialRepository.updateVaultPath(credentialId, encryptedVaultPath, expiresAt);
|
||||
} else {
|
||||
// Phase 1 / migrating credential: use bcrypt
|
||||
const newHash = await hashSecret(plainSecret);
|
||||
updated = await this.credentialRepository.updateHash(credentialId, newHash, expiresAt);
|
||||
const encryptedHash = await this.maybeEncrypt(newHash);
|
||||
updated = await this.credentialRepository.updateHash(credentialId, encryptedHash, expiresAt);
|
||||
}
|
||||
|
||||
if (!updated) {
|
||||
@@ -264,6 +294,7 @@ export class CredentialService {
|
||||
await this.credentialRepository.revoke(credentialId);
|
||||
|
||||
// Phase 2: permanently delete the secret from Vault
|
||||
// vault_path may be encrypted — decrypt before use if needed
|
||||
if (this.vaultClient !== null && existing.vaultPath !== null) {
|
||||
await this.vaultClient.deleteSecret(agentId, credentialId);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { RedisClientType } from 'redis';
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
import { VaultClient } from '../vault/VaultClient.js';
|
||||
import { EncryptionService } from './EncryptionService.js';
|
||||
import { AgentNotFoundError } from '../utils/errors.js';
|
||||
import {
|
||||
IDIDDocument,
|
||||
@@ -84,6 +85,8 @@ export class DIDService {
|
||||
* @param _vaultClient - Optional VaultClient; retained for API consistency and future use.
|
||||
* DID private keys are stored via node-vault directly using env vars.
|
||||
* @param redis - Redis client for DID document caching.
|
||||
* @param encryptionService - Optional EncryptionService. When provided, vault_key_path
|
||||
* is encrypted before write and decrypted before use (SOC 2 CC6.1).
|
||||
*/
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
@@ -91,6 +94,7 @@ export class DIDService {
|
||||
// DID private keys are stored via node-vault directly using env vars — see storePrivateKey().
|
||||
_vaultClient: VaultClient | null,
|
||||
private readonly redis: RedisClientType,
|
||||
private readonly encryptionService: EncryptionService | null = null,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -123,6 +127,12 @@ export class DIDService {
|
||||
// Store private key — Vault if configured, dev marker otherwise
|
||||
const vaultKeyPath = await this.storePrivateKey(agentId, privateKeyPem);
|
||||
|
||||
// Encrypt vault_key_path before persisting (SOC 2 CC6.1)
|
||||
const storedKeyPath =
|
||||
this.encryptionService !== null && vaultKeyPath !== 'dev:no-vault'
|
||||
? await this.encryptionService.encryptColumn(vaultKeyPath)
|
||||
: vaultKeyPath;
|
||||
|
||||
const keyId = 'key_' + ulid();
|
||||
|
||||
// Insert into agent_did_keys
|
||||
@@ -130,7 +140,7 @@ export class DIDService {
|
||||
`INSERT INTO agent_did_keys
|
||||
(key_id, agent_id, organization_id, public_key_jwk, vault_key_path, key_type, curve, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 'EC', 'P-256', NOW())`,
|
||||
[keyId, agentId, organizationId, JSON.stringify(publicKeyJwk), vaultKeyPath],
|
||||
[keyId, agentId, organizationId, JSON.stringify(publicKeyJwk), storedKeyPath],
|
||||
);
|
||||
|
||||
// Update agents with the DID
|
||||
|
||||
188
src/services/EncryptionService.ts
Normal file
188
src/services/EncryptionService.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* EncryptionService — AES-256-CBC column-level encryption for SentryAgent.ai AgentIdP.
|
||||
*
|
||||
* Encrypts and decrypts sensitive PostgreSQL column values using AES-256-CBC.
|
||||
* The encryption key is stored in HashiCorp Vault and fetched once on first use,
|
||||
* then cached in process memory. If decryption fails (e.g. key rotation), the
|
||||
* cached key is cleared and re-fetched on the next call.
|
||||
*
|
||||
* Encrypted format: base64(iv):base64(ciphertext)
|
||||
* Key format: 32-byte hex string stored in Vault
|
||||
*/
|
||||
|
||||
import forge from 'node-forge';
|
||||
import { VaultClient } from '../vault/VaultClient.js';
|
||||
|
||||
/** Vault path env var for the encryption key (default path used when var is not set). */
|
||||
const DEFAULT_VAULT_PATH = 'secret/data/agentidp/encryption-key';
|
||||
|
||||
/** Regex that matches the encrypted column format: base64(iv):base64(ciphertext). */
|
||||
const ENCRYPTED_PATTERN = /^[A-Za-z0-9+/]+=*:[A-Za-z0-9+/]+=*$/;
|
||||
|
||||
/**
|
||||
* Service providing AES-256-CBC column-level encryption backed by a Vault-managed key.
|
||||
* All sensitive database columns (credential hashes, vault paths) pass through this
|
||||
* service before being written to or read from PostgreSQL.
|
||||
*/
|
||||
export class EncryptionService {
|
||||
/** In-memory cache of the 32-byte encryption key (hex-encoded). */
|
||||
private cachedKey: string | null = null;
|
||||
|
||||
/**
|
||||
* @param vaultClient - VaultClient used to fetch the AES-256-CBC encryption key.
|
||||
*/
|
||||
constructor(private readonly vaultClient: VaultClient) {}
|
||||
|
||||
/**
|
||||
* Returns the encryption key, fetching it from Vault if not yet cached.
|
||||
* The key is stored at the path specified by `ENCRYPTION_KEY_VAULT_PATH` (default:
|
||||
* `secret/data/agentidp/encryption-key`). The Vault record must contain a field
|
||||
* named `encryptionKey` whose value is a 64-character hex string (32 bytes).
|
||||
*
|
||||
* @returns The raw 32-byte encryption key as a hex string.
|
||||
* @throws Error if the key cannot be fetched or is not a 64-character hex string.
|
||||
*/
|
||||
private async getKey(): Promise<string> {
|
||||
if (this.cachedKey !== null) {
|
||||
return this.cachedKey;
|
||||
}
|
||||
|
||||
const vaultPath =
|
||||
process.env['ENCRYPTION_KEY_VAULT_PATH'] ?? DEFAULT_VAULT_PATH;
|
||||
|
||||
const data = await this.vaultClient.readArbitrarySecret(vaultPath);
|
||||
const key = data['encryptionKey'];
|
||||
|
||||
if (typeof key !== 'string' || key.length !== 64) {
|
||||
throw new Error(
|
||||
`Invalid encryption key at Vault path '${vaultPath}': expected a 64-character hex string.`,
|
||||
);
|
||||
}
|
||||
|
||||
this.cachedKey = key;
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the in-memory key cache, forcing a re-fetch from Vault on the next call.
|
||||
* Called automatically when decryption fails (e.g. after key rotation).
|
||||
*/
|
||||
private clearKeyCache(): void {
|
||||
this.cachedKey = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a plaintext string using AES-256-CBC.
|
||||
* A fresh 16-byte IV is generated per call, ensuring different ciphertexts
|
||||
* for identical inputs (semantic security).
|
||||
*
|
||||
* @param plaintext - The string to encrypt.
|
||||
* @returns A base64-encoded string in the format `iv_base64:ciphertext_base64`.
|
||||
* @throws Error if the Vault key cannot be fetched.
|
||||
*/
|
||||
async encryptColumn(plaintext: string): Promise<string> {
|
||||
const hexKey = await this.getKey();
|
||||
const keyBytes = forge.util.hexToBytes(hexKey);
|
||||
|
||||
const iv = forge.random.getBytesSync(16);
|
||||
const cipher = forge.cipher.createCipher('AES-CBC', keyBytes);
|
||||
cipher.start({ iv });
|
||||
cipher.update(forge.util.createBuffer(plaintext, 'utf8'));
|
||||
cipher.finish();
|
||||
|
||||
const ivBase64 = forge.util.encode64(iv);
|
||||
const ciphertextBase64 = forge.util.encode64(cipher.output.getBytes());
|
||||
|
||||
return `${ivBase64}:${ciphertextBase64}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a ciphertext string that was produced by `encryptColumn`.
|
||||
* If decryption fails (wrong key, corrupted data), the key cache is cleared
|
||||
* so the next call re-fetches from Vault, then the error is re-thrown.
|
||||
*
|
||||
* @param ciphertext - A `iv_base64:ciphertext_base64` encoded string.
|
||||
* @returns The original plaintext string.
|
||||
* @throws Error if the ciphertext format is invalid or decryption fails.
|
||||
*/
|
||||
async decryptColumn(ciphertext: string): Promise<string> {
|
||||
const colonIndex = ciphertext.indexOf(':');
|
||||
if (colonIndex === -1) {
|
||||
throw new Error('Invalid encrypted column format: missing ":" separator.');
|
||||
}
|
||||
|
||||
const ivBase64 = ciphertext.slice(0, colonIndex);
|
||||
const ciphertextBase64 = ciphertext.slice(colonIndex + 1);
|
||||
|
||||
let hexKey: string;
|
||||
try {
|
||||
hexKey = await this.getKey();
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
const keyBytes = forge.util.hexToBytes(hexKey);
|
||||
const iv = forge.util.decode64(ivBase64);
|
||||
const encryptedBytes = forge.util.decode64(ciphertextBase64);
|
||||
|
||||
const decipher = forge.cipher.createDecipher('AES-CBC', keyBytes);
|
||||
decipher.start({ iv });
|
||||
decipher.update(forge.util.createBuffer(encryptedBytes));
|
||||
const ok = decipher.finish();
|
||||
|
||||
if (!ok) {
|
||||
this.clearKeyCache();
|
||||
throw new Error('AES-256-CBC decryption failed — possible key mismatch or corrupted data.');
|
||||
}
|
||||
|
||||
return decipher.output.toString();
|
||||
} catch (err) {
|
||||
this.clearKeyCache();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the given value appears to be an encrypted column value
|
||||
* (i.e. matches the `base64:base64` pattern produced by `encryptColumn`).
|
||||
* Used for backward-compatibility: existing plaintext rows can be detected and
|
||||
* skipped during the read-decrypt cycle until they are re-written in encrypted form.
|
||||
*
|
||||
* @param value - The column value to test.
|
||||
* @returns `true` if the value looks encrypted; `false` if it is plaintext.
|
||||
*/
|
||||
isEncrypted(value: string): boolean {
|
||||
return ENCRYPTED_PATTERN.test(value);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Singleton — re-using VaultClient requires a live instance at module load time.
|
||||
// The singleton is created lazily to allow test overrides.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _instance: EncryptionService | null = null;
|
||||
|
||||
/**
|
||||
* Returns the singleton EncryptionService instance.
|
||||
* On first call, creates the instance using the VaultClient singleton.
|
||||
*
|
||||
* @param vaultClient - A VaultClient instance (required on first call).
|
||||
* @returns The singleton EncryptionService.
|
||||
*/
|
||||
export function getEncryptionService(vaultClient: VaultClient): EncryptionService {
|
||||
if (_instance === null) {
|
||||
_instance = new EncryptionService(vaultClient);
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the singleton (for testing only).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function _resetEncryptionServiceSingleton(): void {
|
||||
_instance = null;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { AuditService } from './AuditService.js';
|
||||
import { VaultClient } from '../vault/VaultClient.js';
|
||||
import { IDTokenService } from './IDTokenService.js';
|
||||
import { EventPublisher } from './EventPublisher.js';
|
||||
import { EncryptionService } from './EncryptionService.js';
|
||||
import {
|
||||
ITokenPayload,
|
||||
ITokenResponse,
|
||||
@@ -52,6 +53,8 @@ export class OAuth2Service {
|
||||
* is requested, an OIDC ID token is appended to the token response.
|
||||
* @param eventPublisher - Optional EventPublisher. When provided, token.issued and
|
||||
* token.revoked events are published as webhooks and Kafka messages (fire-and-forget).
|
||||
* @param encryptionService - Optional EncryptionService. When provided, encrypted
|
||||
* `secret_hash` values are decrypted before bcrypt verification (SOC 2 CC6.1).
|
||||
*/
|
||||
constructor(
|
||||
private readonly tokenRepository: TokenRepository,
|
||||
@@ -63,6 +66,7 @@ export class OAuth2Service {
|
||||
private readonly vaultClient: VaultClient | null = null,
|
||||
private readonly idTokenService: IDTokenService | null = null,
|
||||
private readonly eventPublisher: EventPublisher | null = null,
|
||||
private readonly encryptionService: EncryptionService | null = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -120,14 +124,25 @@ export class OAuth2Service {
|
||||
let matches: boolean;
|
||||
if (credRow.vaultPath !== null && this.vaultClient !== null) {
|
||||
// Phase 2: verify against Vault-stored secret
|
||||
// vault_path may be encrypted — decryption is not needed here since
|
||||
// verifySecret uses agent/credential IDs to locate the Vault entry.
|
||||
matches = await this.vaultClient.verifySecret(
|
||||
clientId,
|
||||
credRow.credentialId,
|
||||
clientSecret,
|
||||
);
|
||||
} else {
|
||||
// Phase 1: verify against bcrypt hash
|
||||
matches = await verifySecret(clientSecret, credRow.secretHash);
|
||||
// Phase 1: verify against bcrypt hash.
|
||||
// Decrypt the stored hash if EncryptionService is configured and the
|
||||
// value appears to be encrypted (backward-compat for pre-encryption rows).
|
||||
let secretHash = credRow.secretHash;
|
||||
if (
|
||||
this.encryptionService !== null &&
|
||||
this.encryptionService.isEncrypted(secretHash)
|
||||
) {
|
||||
secretHash = await this.encryptionService.decryptColumn(secretHash);
|
||||
}
|
||||
matches = await verifySecret(clientSecret, secretHash);
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
* In local mode (no Vault) the secret is bcrypt-hashed and stored in secret_hash, and
|
||||
* vault_secret_path is set to the sentinel value 'local'. The raw secret is never persisted
|
||||
* in PostgreSQL and is only returned once at subscription creation time.
|
||||
*
|
||||
* SOC 2 CC6.1: vault_secret_path is encrypted at rest via EncryptionService (AES-256-CBC)
|
||||
* before being written to PostgreSQL, and decrypted on read when Vault path retrieval is needed.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
@@ -12,6 +15,7 @@ import { RedisClientType } from 'redis';
|
||||
import crypto from 'crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { VaultClient } from '../vault/VaultClient.js';
|
||||
import { EncryptionService } from './EncryptionService.js';
|
||||
import { SentryAgentError } from '../utils/errors.js';
|
||||
import {
|
||||
IWebhookSubscription,
|
||||
@@ -132,11 +136,14 @@ export class WebhookService {
|
||||
* @param pool - PostgreSQL connection pool.
|
||||
* @param vaultClient - Optional VaultClient. When provided, HMAC secrets are stored in Vault.
|
||||
* @param redis - Redis client (reserved for future caching needs).
|
||||
* @param encryptionService - Optional EncryptionService. When provided, vault_secret_path
|
||||
* is encrypted before write and decrypted before use (SOC 2 CC6.1).
|
||||
*/
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly vaultClient: VaultClient | null,
|
||||
_redis: RedisClientType, // reserved for future subscription caching
|
||||
private readonly encryptionService: EncryptionService | null = null,
|
||||
) {}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
@@ -175,7 +182,11 @@ export class WebhookService {
|
||||
const vaultPath = `secret/data/agentidp/webhooks/${orgId}/${subscriptionId}/secret`;
|
||||
await this.storeWebhookSecretInVault(vaultPath, secret);
|
||||
secretHash = 'vault';
|
||||
vaultSecretPath = vaultPath;
|
||||
// Encrypt the vault path before persisting (SOC 2 CC6.1)
|
||||
vaultSecretPath =
|
||||
this.encryptionService !== null
|
||||
? await this.encryptionService.encryptColumn(vaultPath)
|
||||
: vaultPath;
|
||||
} else {
|
||||
// Local mode: bcrypt-hash the secret; raw secret cannot be recovered later
|
||||
secretHash = await bcrypt.hash(secret, 10);
|
||||
@@ -223,7 +234,13 @@ export class WebhookService {
|
||||
);
|
||||
}
|
||||
|
||||
return this.retrieveWebhookSecretFromVault(row.vault_secret_path);
|
||||
// Decrypt vault_secret_path if it was stored encrypted (SOC 2 CC6.1 backward-compat)
|
||||
let vaultPath = row.vault_secret_path;
|
||||
if (this.encryptionService !== null && this.encryptionService.isEncrypted(vaultPath)) {
|
||||
vaultPath = await this.encryptionService.decryptColumn(vaultPath);
|
||||
}
|
||||
|
||||
return this.retrieveWebhookSecretFromVault(vaultPath);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user