@kya-os/mcp-i
Version:
The TypeScript MCP framework with identity features built-in
440 lines (439 loc) • 15.5 kB
JavaScript
;
/**
* 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");
}