UNPKG

@kya-os/mcp-i

Version:

The TypeScript MCP framework with identity features built-in

440 lines (439 loc) 15.5 kB
"use strict"; /** * Audit Logging System for MCP-I Runtime * * TODO: Update AuditLogger to implement IAuditLogger from @kya-os/mcp-i-core/runtime/audit-logger. * Currently using Node.js-specific implementation. This will allow the interface to be used * across all platform implementations while maintaining platform-specific crypto implementations. * * Handles audit record generation and logging with frozen format * according to requirements 5.4, 5.5, and 6.7 (configurable retention). * * ## Privacy Guarantees * * This system is designed with privacy-first principles: * * **NEVER Logged:** * - Request/response bodies (only SHA-256 hashes) * - Secrets (private keys, API keys, tokens, nonces) * - PII (names, emails, addresses, phone numbers) * - Key material (only key IDs are logged) * * **ALWAYS Logged:** * - Metadata only (DIDs, key IDs, timestamps) * - SHA-256 hashes (not plaintext data) * - Session IDs (for correlation, not sensitive) * - Verification results (yes/no only) * - Scope identifiers (application-level) * * ## Frozen Format * * The audit.v1 format is **frozen** and will not change: * * ``` * audit.v1 ts=<unix> session=<id> audience=<host> did=<did> kid=<kid> reqHash=<sha256:..> resHash=<sha256:..> verified=yes|no scope=<scopeId|-> * ``` * * ## Event-Driven Rotation * * This implementation uses **event-driven rotation** (not timers): * - Rotation checks happen on each `logAuditRecord()` call * - No setInterval/setTimeout (works in Cloudflare Workers) * - No cleanup needed (no timers to clear) * - Predictable behavior (rotation on activity) * * @module audit * @see {@link https://github.com/kya-os/xmcp-i/docs/concepts/audit-logging.md | Audit Logging Documentation} */ Object.defineProperty(exports, "__esModule", { value: true }); exports.defaultAuditLogger = exports.AuditLogger = void 0; exports.logKeyRotationAudit = logKeyRotationAudit; exports.parseAuditLine = parseAuditLine; exports.validateAuditRecord = validateAuditRecord; const time_1 = require("./utils/time"); const crypto_1 = require("crypto"); /** * Audit logger class with event-driven rotation support * * Privacy Guarantees: * - NEVER logs request/response bodies (only SHA-256 hashes) * - NEVER logs secrets (private keys, API keys, tokens, nonces) * - NEVER logs PII (personal information) * - Only logs metadata: DIDs, key IDs, timestamps, hashes, session IDs * - Frozen format: audit.v1 with fixed field order * * Rotation Strategy: * - Event-driven (no timers) - rotation checks on each logAuditRecord() call * - Works in all environments (Node.js, Cloudflare Workers, Vercel Edge) * - No cleanup needed (no timers to clear) */ class AuditLogger { config; sessionAuditLog = new Set(); // Track first call per session totalRecordsLogged = 0; // Total records logged (for count rotation) currentLogSize = 0; // Current log size in bytes (for size rotation) lastRotationTime = Date.now(); // Last rotation timestamp (for time rotation) destroyed = false; // Track if logger has been destroyed constructor(config = {}) { // Merge rotation config carefully to preserve defaults const rotationConfig = config.rotation ? { strategy: "custom", ...config.rotation, } : undefined; this.config = { enabled: true, logFunction: console.log, includePayloads: false, // Keep identity/proof data out by default ...config, rotation: rotationConfig, }; } /** * Emit audit record on first call per session * Requirements: 5.4, 5.5 * * This method: * 1. Checks if logger is destroyed * 2. Logs audit record (first call per session) * 3. Checks if rotation is needed (event-driven) * 4. Triggers rotation hooks if threshold met */ async logAuditRecord(context) { if (this.destroyed) { throw new Error("AuditLogger has been destroyed"); } if (!this.config.enabled) { return; } // Check if this is the first call for this session const sessionKey = `${context.session.sessionId}:${context.session.audience}`; if (this.sessionAuditLog.has(sessionKey)) { return; // Already logged for this session } // Mark session as logged this.sessionAuditLog.add(sessionKey); // Create audit record const auditRecord = { version: "audit.v1", ts: Math.floor(Date.now() / 1000), session: context.session.sessionId, audience: context.session.audience, did: context.identity.did, kid: context.identity.kid, reqHash: context.requestHash, resHash: context.responseHash, verified: context.verified, scope: context.scopeId || "-", // Use "-" for no scope }; // Format as frozen audit line const auditLine = this.formatAuditLine(auditRecord); // Track size in bytes (UTF-8) const sizeBytes = Buffer.byteLength(auditLine, "utf8"); this.currentLogSize += sizeBytes; this.totalRecordsLogged++; // Emit audit record this.config.logFunction(auditLine); // Check if rotation is needed (event-driven) await this.checkRotation(); } /** * Log an event using the frozen audit format WITHOUT session deduplication. * * Unlike logAuditRecord(), this method ALWAYS logs the event, regardless * of whether an event has already been logged for this session. This is * necessary for consent events where multiple events occur in the same session. * * The event still uses the frozen audit.v1 format for consistency, but * bypasses the "once per session" constraint. * * @param context - Event context including eventType, identity, session, and eventData */ async logEvent(context) { if (this.destroyed) { throw new Error("AuditLogger has been destroyed"); } if (!this.config.enabled) { return; } // Generate event hash const eventHash = this.hashEvent(context.eventType, context.eventData); // Create audit record (same format as regular audit logs) const auditRecord = { version: "audit.v1", ts: Math.floor(Date.now() / 1000), session: context.session.sessionId, audience: context.session.audience, did: context.identity.did, kid: context.identity.kid, reqHash: `sha256:${eventHash}`, resHash: `sha256:${eventHash}`, // Same hash for events verified: "yes", scope: context.eventType, // Use eventType as scope }; // Format and log (NO session deduplication check) const auditLine = this.formatAuditLine(auditRecord); // Track size and count const sizeBytes = Buffer.byteLength(auditLine, "utf8"); this.currentLogSize += sizeBytes; this.totalRecordsLogged++; // Emit audit record this.config.logFunction(auditLine); // Check rotation await this.checkRotation(); } /** * Generate deterministic hash for event */ hashEvent(type, data) { const content = JSON.stringify({ type, data, ts: Date.now(), nonce: (0, crypto_1.randomBytes)(16).toString("hex"), }); return (0, crypto_1.createHash)("sha256").update(content).digest("hex"); } /** * Format audit record as frozen audit line * Format: audit.v1 ts=<unix> session=<id> audience=<host> did=<did> kid=<kid> reqHash=<sha256:..> resHash=<sha256:..> verified=yes|no scope=<scopeId|-> */ formatAuditLine(record) { const fields = [ `${record.version}`, `ts=${record.ts}`, `session=${record.session}`, `audience=${record.audience}`, `did=${record.did}`, `kid=${record.kid}`, `reqHash=${record.reqHash}`, `resHash=${record.resHash}`, `verified=${record.verified}`, `scope=${record.scope}`, ]; return fields.join(" "); } /** * Check if rotation is needed and trigger if necessary (event-driven) * * This method is called on each logAuditRecord() call. * No timers are used - rotation is checked based on current state. */ async checkRotation() { if (!this.config.rotation) { return; } const { strategy, sizeLimit, timeInterval, countThreshold, hooks } = this.config.rotation; let shouldRotate = false; let trigger = ""; // Size-based rotation if (strategy === "size" && sizeLimit && this.currentLogSize >= sizeLimit) { shouldRotate = true; trigger = "size-limit"; await hooks?.onSizeLimit?.(this.currentLogSize, sizeLimit); } // Time-based rotation (event-driven, not timer-based) if (strategy === "time" && timeInterval && Date.now() - this.lastRotationTime >= timeInterval) { shouldRotate = true; trigger = "time-interval"; const interval = (0, time_1.formatTimeInterval)(timeInterval); await hooks?.onTimeBased?.(interval); } // Count-based rotation if (strategy === "count" && countThreshold && this.totalRecordsLogged >= countThreshold) { shouldRotate = true; trigger = "count-threshold"; await hooks?.onCountThreshold?.(this.totalRecordsLogged, countThreshold); } // Trigger rotation if needed if (shouldRotate) { await this.rotateNow(trigger); } } /** * Rotate audit log now (manually triggered) * * This method: * 1. Calls onRotation hook (for user to archive/rotate log file) * 2. Resets rotation counters (size, time, count) * * @param trigger - Reason for rotation (e.g., "manual", "size-limit") */ async rotateNow(trigger = "manual") { if (!this.config.rotation?.hooks?.onRotation) { return; } const context = { strategy: this.config.rotation.strategy || "custom", trigger, recordsLogged: this.totalRecordsLogged, timestamp: Date.now(), }; // Call rotation hook (user implements log file rotation) await this.config.rotation.hooks.onRotation(context); // Reset rotation counters this.currentLogSize = 0; this.lastRotationTime = Date.now(); this.totalRecordsLogged = 0; } /** * Destroy audit logger and cleanup resources * * This method: * 1. Clears session tracking (memory) * 2. Resets statistics * 3. Marks logger as destroyed * * Note: No timers to clear (event-driven rotation) */ destroy() { if (this.destroyed) { return; // Idempotent } this.sessionAuditLog.clear(); this.totalRecordsLogged = 0; this.currentLogSize = 0; this.destroyed = true; } /** * Clear session audit log (useful for testing) */ clearSessionLog() { this.sessionAuditLog.clear(); } /** * Get audit statistics */ getStats() { return { enabled: this.config.enabled, sessionsLogged: this.sessionAuditLog.size, includePayloads: this.config.includePayloads, totalRecordsLogged: this.totalRecordsLogged, currentLogSize: this.currentLogSize, lastRotationTime: this.lastRotationTime, }; } /** * Update configuration */ updateConfig(config) { this.config = { ...this.config, ...config }; } } exports.AuditLogger = AuditLogger; /** * Log key rotation audit record * Format: keys.rotate.v1 ts=<unix> did=<did> oldKid=<kid> newKid=<kid> mode=dev|prod delegated=yes|no force=yes|no */ function logKeyRotationAudit(context, logFunction = console.log) { const fields = [ "keys.rotate.v1", `ts=${Math.floor(Date.now() / 1000)}`, `did=${context.identity.did}`, `oldKid=${context.oldKeyId}`, `newKid=${context.newKeyId}`, `mode=${context.mode}`, `delegated=${context.delegated}`, `force=${context.force}`, ]; const auditLine = fields.join(" "); logFunction(auditLine); } /** * Default audit logger instance */ exports.defaultAuditLogger = new AuditLogger(); /** * Utility functions */ /** * Parse audit line back to record (for testing/analysis) */ function parseAuditLine(line) { try { const parts = line.split(" "); if (parts.length < 10 || !parts[0].startsWith("audit.v")) { return null; } const record = { version: parts[0], }; // Parse key=value pairs for (let i = 1; i < parts.length; i++) { const [key, value] = parts[i].split("=", 2); if (!key || value === undefined) continue; switch (key) { case "ts": record.ts = parseInt(value, 10); break; case "session": record.session = value; break; case "audience": record.audience = value; break; case "did": record.did = value; break; case "kid": record.kid = value; break; case "reqHash": record.reqHash = value; break; case "resHash": record.resHash = value; break; case "verified": record.verified = value; break; case "scope": record.scope = value; break; } } // Validate required fields if (record.version && record.ts && record.session && record.audience && record.did && record.kid && record.reqHash && record.resHash && record.verified && record.scope !== undefined) { return record; } return null; } catch { return null; } } /** * Validate audit record format */ function validateAuditRecord(record) { return (typeof record === "object" && record !== null && record.version === "audit.v1" && typeof record.ts === "number" && typeof record.session === "string" && typeof record.audience === "string" && typeof record.did === "string" && typeof record.kid === "string" && typeof record.reqHash === "string" && record.reqHash.startsWith("sha256:") && typeof record.resHash === "string" && record.resHash.startsWith("sha256:") && (record.verified === "yes" || record.verified === "no") && typeof record.scope === "string"); }