From d61d1226c8fec8028c20c5fc836adeca2153e7f4 Mon Sep 17 00:00:00 2001 From: BubBeatz Production Date: Mon, 3 Aug 2026 23:32:52 +0000 Subject: [PATCH] feat: add signed capability evidence modules Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../covenant/accountable-intermediary.test.ts | 68 +++ src/lib/covenant/accountable-intermediary.ts | 284 +++++++++++ .../covenant/capability-attenuation.test.ts | 104 ++++ src/lib/covenant/capability-attenuation.ts | 301 ++++++++++++ src/lib/covenant/evidence-standard.test.ts | 120 +++++ src/lib/covenant/evidence-standard.ts | 450 ++++++++++++++++++ 6 files changed, 1327 insertions(+) create mode 100644 src/lib/covenant/accountable-intermediary.test.ts create mode 100644 src/lib/covenant/accountable-intermediary.ts create mode 100644 src/lib/covenant/capability-attenuation.test.ts create mode 100644 src/lib/covenant/capability-attenuation.ts create mode 100644 src/lib/covenant/evidence-standard.test.ts create mode 100644 src/lib/covenant/evidence-standard.ts diff --git a/src/lib/covenant/accountable-intermediary.test.ts b/src/lib/covenant/accountable-intermediary.test.ts new file mode 100644 index 0000000..310ea76 --- /dev/null +++ b/src/lib/covenant/accountable-intermediary.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { generateKeyPair } from "./crypto"; +import { ManagedIntermediary, type IntermediaryReceipt } from "./accountable-intermediary"; + +function intermediary() { + const keys = generateKeyPair(); + return new ManagedIntermediary("gateway-test", keys.privateKeyB64, keys.publicKeyB64); +} + +const request = { + method: "call_tool", + resource: "approve_invoice", + params: { invoice_id: "inv-1", amount: 5000 }, +}; + +describe("accountable intermediary", () => { + it("creates a verifiable forwarded receipt", async () => { + const gateway = intermediary(); + gateway.connectDownstream("server-test", "https://server.example/api"); + const result = await gateway.processRequest({ request_id: "req-1", request }); + expect(result.receipt.decision.type).toBe("forwarded"); + expect(gateway.verifyReceipt(result.receipt)).toEqual({ valid: true }); + }); + + it("creates verifiable rate-limit and policy denial receipts", async () => { + const gateway = intermediary(); + const rateLimited = await gateway.processRequest({ + request_id: "req-rate", + request, + rate_limit_check: () => ({ allowed: false, reason: "rate limit exceeded" }), + }); + const policyDenied = await gateway.processRequest({ + request_id: "req-policy", + request, + policy_check: () => ({ allowed: false, reason: "policy denied" }), + }); + expect(rateLimited.receipt.action.denial_code).toBe(429); + expect(policyDenied.receipt.action.denial_code).toBe(403); + expect(gateway.verifyReceipt(rateLimited.receipt).valid).toBe(true); + expect(gateway.verifyReceipt(policyDenied.receipt).valid).toBe(true); + }); + + it("commits cached responses and detects any receipt tampering", async () => { + const gateway = intermediary(); + const result = await gateway.processRequest({ + request_id: "req-cache", + request, + cache_check: () => ({ cached: true, response: { approved: true } }), + }); + expect(result.receipt.decision.type).toBe("cached"); + expect(result.receipt.downstream?.response_hash?.value).toBeTruthy(); + expect(gateway.verifyReceipt(result.receipt).valid).toBe(true); + + const tamperCases: Array<(copy: IntermediaryReceipt) => void> = [ + (copy) => { copy.received_request.request_id = "tampered"; }, + (copy) => { copy.decision.reason = "tampered"; }, + (copy) => { copy.action.processing_duration_ms += 1; }, + (copy) => { copy.receipt_hash.value = "tampered"; }, + (copy) => { copy.signature.value = "tampered"; }, + ]; + for (const tamper of tamperCases) { + const copy = structuredClone(result.receipt); + tamper(copy); + expect(gateway.verifyReceipt(copy).valid).toBe(false); + } + expect(gateway.getReceipts()).toHaveLength(1); + }); +}); diff --git a/src/lib/covenant/accountable-intermediary.ts b/src/lib/covenant/accountable-intermediary.ts new file mode 100644 index 0000000..71acc51 --- /dev/null +++ b/src/lib/covenant/accountable-intermediary.ts @@ -0,0 +1,284 @@ +/** + * Signed accountability receipts for MCP gateways and intermediaries. + * + * Each receipt commits to the received request, intermediary decision, and + * resulting action. Keys and signatures use base64 DER/base64 primitives. + */ + +import { randomUUID } from "node:crypto"; +import { canonicalEncode } from "./evidence-standard"; +import { sha256, signMessage, verifyMessage } from "./crypto"; +import type { JsonObject, JsonValue } from "./evidence-standard"; + +export interface IntermediaryReceipt { + receipt_id: string; + receipt_version: "1.0"; + intermediary_id: string; + intermediary_public_key: string; + received_request: { + request_id: string; + request_hash: { algorithm: "sha256"; value: string }; + received_at_utc: string; + }; + decision: { + type: "forwarded" | "cached" | "denied" | "transformed" | "queued"; + reason: string; + timestamp_utc: string; + }; + action: { + forwarded_to?: { + server_id: string; + server_endpoint: string; + timestamp_sent_utc: string; + }; + denial_reason?: string; + denial_code?: number; + cache_key?: string; + cache_ttl_seconds?: number; + cache_hit?: boolean; + transformations?: Array<{ + type: "header_added" | "header_removed" | "body_modified"; + field?: string; + reason?: string; + }>; + processing_duration_ms: number; + }; + downstream?: { + downstream_receipt_hash?: string; + response_hash?: { algorithm: "sha256"; value: string }; + }; + signature: { algorithm: "Ed25519"; value: string }; + receipt_hash: { algorithm: "sha256"; value: string }; + chain: { + previous_intermediary_receipt_hash?: string; + position_in_path: number; + }; +} + +interface UnsignedReceipt { + receipt_id: string; + receipt_version: "1.0"; + intermediary_id: string; + intermediary_public_key: string; + received_request: IntermediaryReceipt["received_request"]; + decision: IntermediaryReceipt["decision"]; + action: IntermediaryReceipt["action"]; + downstream?: IntermediaryReceipt["downstream"]; + chain: IntermediaryReceipt["chain"]; +} + +function unsignedReceipt(receipt: IntermediaryReceipt): UnsignedReceipt { + return { + receipt_id: receipt.receipt_id, + receipt_version: receipt.receipt_version, + intermediary_id: receipt.intermediary_id, + intermediary_public_key: receipt.intermediary_public_key, + received_request: receipt.received_request, + decision: receipt.decision, + action: receipt.action, + ...(receipt.downstream === undefined ? {} : { downstream: receipt.downstream }), + chain: receipt.chain, + }; +} + +export class ManagedIntermediary { + private readonly receipts = new Map(); + private downstreamServer?: { id: string; endpoint: string }; + + constructor( + private readonly intermediaryId: string, + private readonly intermediaryPrivateKeyB64: string, + readonly intermediaryPublicKey: string, + ) {} + + connectDownstream(serverId: string, serverEndpoint: string): void { + this.downstreamServer = { id: serverId, endpoint: serverEndpoint }; + } + + async processRequest(params: { + request_id: string; + request: JsonObject; + rate_limit_check?: () => { allowed: boolean; reason?: string }; + policy_check?: () => { allowed: boolean; reason?: string }; + cache_check?: () => { cached: boolean; response?: JsonValue; reason?: string }; + }): Promise<{ + receipt: IntermediaryReceipt; + forwarded_to_backend?: { endpoint: string }; + cached_response?: JsonValue; + }> { + const receiptId = randomUUID(); + const now = new Date().toISOString(); + const startTime = Date.now(); + const requestHash = sha256(canonicalEncode(params.request)); + const rateCheck = params.rate_limit_check?.() ?? { allowed: true }; + if (!rateCheck.allowed) { + return { + receipt: this.createReceipt({ + receipt_id: receiptId, + decision_type: "denied", + decision_reason: rateCheck.reason ?? "rate_limit_exceeded", + request_id: params.request_id, + request_hash: requestHash, + denial_code: 429, + processing_duration_ms: Date.now() - startTime, + now, + }), + }; + } + + const policyCheck = params.policy_check?.() ?? { allowed: true }; + if (!policyCheck.allowed) { + return { + receipt: this.createReceipt({ + receipt_id: receiptId, + decision_type: "denied", + decision_reason: policyCheck.reason ?? "policy_denied", + request_id: params.request_id, + request_hash: requestHash, + denial_code: 403, + processing_duration_ms: Date.now() - startTime, + now, + }), + }; + } + + const cacheCheck = params.cache_check?.(); + if (cacheCheck?.cached && cacheCheck.response !== undefined) { + return { + receipt: this.createReceipt({ + receipt_id: receiptId, + decision_type: "cached", + decision_reason: cacheCheck.reason ?? "cache_hit", + request_id: params.request_id, + request_hash: requestHash, + response_hash: sha256(canonicalEncode(cacheCheck.response)), + processing_duration_ms: Date.now() - startTime, + now, + }), + cached_response: cacheCheck.response, + }; + } + + if (this.downstreamServer) { + const forwardedTo = { + server_id: this.downstreamServer.id, + server_endpoint: this.downstreamServer.endpoint, + timestamp_sent_utc: new Date().toISOString(), + }; + return { + receipt: this.createReceipt({ + receipt_id: receiptId, + decision_type: "forwarded", + decision_reason: "within_limits_and_policy", + request_id: params.request_id, + request_hash: requestHash, + forwarded_to: forwardedTo, + processing_duration_ms: Date.now() - startTime, + now, + }), + forwarded_to_backend: { endpoint: this.downstreamServer.endpoint }, + }; + } + return { + receipt: this.createReceipt({ + receipt_id: receiptId, + decision_type: "queued", + decision_reason: "no_downstream_configured", + request_id: params.request_id, + request_hash: requestHash, + processing_duration_ms: Date.now() - startTime, + now, + }), + }; + } + + private createReceipt(params: { + receipt_id: string; + decision_type: IntermediaryReceipt["decision"]["type"]; + decision_reason: string; + request_id: string; + request_hash: string; + denial_code?: number; + response_hash?: string; + forwarded_to?: NonNullable; + processing_duration_ms: number; + now: string; + }): IntermediaryReceipt { + const unsigned: UnsignedReceipt = { + receipt_id: params.receipt_id, + receipt_version: "1.0", + intermediary_id: this.intermediaryId, + intermediary_public_key: this.intermediaryPublicKey, + received_request: { + request_id: params.request_id, + request_hash: { algorithm: "sha256", value: params.request_hash }, + received_at_utc: params.now, + }, + decision: { + type: params.decision_type, + reason: params.decision_reason, + timestamp_utc: params.now, + }, + action: { + ...(params.forwarded_to === undefined ? {} : { forwarded_to: params.forwarded_to }), + ...(params.denial_code === undefined + ? {} + : { denial_code: params.denial_code, denial_reason: params.decision_reason }), + processing_duration_ms: params.processing_duration_ms, + }, + chain: { position_in_path: 0 }, + ...(params.response_hash === undefined + ? {} + : { + downstream: { + response_hash: { algorithm: "sha256", value: params.response_hash }, + }, + }), + }; + const receiptHash = sha256(canonicalEncode(unsigned)); + const receipt: IntermediaryReceipt = { + ...unsigned, + signature: { + algorithm: "Ed25519", + value: signMessage(Buffer.from(receiptHash, "utf8"), this.intermediaryPrivateKeyB64), + }, + receipt_hash: { algorithm: "sha256", value: receiptHash }, + }; + this.receipts.set(receipt.receipt_id, receipt); + return receipt; + } + + verifyReceipt(receipt: IntermediaryReceipt): { valid: boolean; reason?: string } { + try { + if (receipt.signature.algorithm !== "Ed25519") { + return { valid: false, reason: "Unsupported signature algorithm" }; + } + if (receipt.receipt_hash.algorithm !== "sha256") { + return { valid: false, reason: "Unsupported hash algorithm" }; + } + if (receipt.intermediary_public_key !== this.intermediaryPublicKey) { + return { valid: false, reason: "Intermediary public key mismatch" }; + } + const recomputedHash = sha256(canonicalEncode(unsignedReceipt(receipt))); + if (recomputedHash !== receipt.receipt_hash.value) { + return { valid: false, reason: "Receipt hash mismatch" }; + } + return verifyMessage( + Buffer.from(recomputedHash, "utf8"), + receipt.signature.value, + receipt.intermediary_public_key, + ) + ? { valid: true } + : { valid: false, reason: "Signature verification failed" }; + } catch (error: unknown) { + return { + valid: false, + reason: error instanceof Error ? error.message : "Receipt verification failed", + }; + } + } + + getReceipts(): IntermediaryReceipt[] { + return Array.from(this.receipts.values()); + } +} diff --git a/src/lib/covenant/capability-attenuation.test.ts b/src/lib/covenant/capability-attenuation.test.ts new file mode 100644 index 0000000..a7826bf --- /dev/null +++ b/src/lib/covenant/capability-attenuation.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { generateKeyPair } from "./crypto"; +import { + DelegationRegistry, + hasCapability, + validateAttenuation, + type Capability, +} from "./capability-attenuation"; + +const supervisorCapabilities: Capability[] = [ + { resource: "invoice", action: "read" }, + { resource: "invoice", action: "approve", constraints: { max_amount: 100_000 } }, +]; + +describe("capability attenuation", () => { + it("accepts subsets and rejects escalation", () => { + expect(validateAttenuation(supervisorCapabilities, [ + { resource: "invoice", action: "approve", constraints: { max_amount: 10_000 } }, + ]).valid).toBe(true); + const result = validateAttenuation(supervisorCapabilities, [ + { resource: "payment", action: "execute" }, + ]); + expect(result.valid).toBe(false); + expect(result.violations[0]).toContain("payment:execute"); + }); + + it("creates and verifies a signed delegation", () => { + const keys = generateKeyPair(); + const registry = new DelegationRegistry(); + registry.registerAgent("supervisor", keys.publicKeyB64); + const result = registry.createDelegation({ + delegator_agent_id: "supervisor", + delegator_private_key_b64: keys.privateKeyB64, + delegatee_agent_id: "assistant", + delegator_capabilities: supervisorCapabilities, + granted_capabilities: [{ resource: "invoice", action: "read" }], + audience: "https://finance.example", + expires_at: new Date(Date.now() + 60_000).toISOString(), + max_delegation_depth: 1, + current_delegation_depth: 0, + reason: "invoice review", + }); + expect(result.delegation).not.toBeNull(); + expect(registry.verifyDelegation(result.delegation!)).toEqual({ valid: true }); + }); + + it("checks granted, excluded, and constrained capabilities", () => { + const auth = { + delegation_id: "d", + delegator_agent_id: "supervisor", + delegatee_agent_id: "assistant", + audience: "https://finance.example", + granted_capabilities: supervisorCapabilities, + excluded_capabilities: [{ resource: "invoice", action: "read" }], + issued_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 60_000).toISOString(), + max_delegation_depth: 1, + current_delegation_depth: 0, + reason: "test", + delegator_signature: { algorithm: "Ed25519" as const, value: "signature" }, + }; + expect(hasCapability(auth, { resource: "invoice", action: "read" }).granted).toBe(false); + expect(hasCapability(auth, { + resource: "invoice", + action: "approve", + constraints: { max_amount: 200_000 }, + }).granted).toBe(false); + expect(hasCapability(auth, { resource: "invoice", action: "approve" }).granted).toBe(true); + }); + + it("rejects expired delegations and cascades revocation downstream", () => { + const keys = generateKeyPair(); + const registry = new DelegationRegistry(); + registry.registerAgent("supervisor", keys.publicKeyB64); + const parent = registry.createDelegation({ + delegator_agent_id: "supervisor", + delegator_private_key_b64: keys.privateKeyB64, + delegatee_agent_id: "assistant", + delegator_capabilities: supervisorCapabilities, + granted_capabilities: [{ resource: "invoice", action: "read" }], + audience: "https://finance.example", + expires_at: new Date(Date.now() + 60_000).toISOString(), + max_delegation_depth: 2, + current_delegation_depth: 0, + reason: "parent", + }).delegation!; + const downstream = registry.createDelegation({ + delegator_agent_id: "assistant", + delegator_private_key_b64: keys.privateKeyB64, + delegatee_agent_id: "worker", + delegator_capabilities: [{ resource: "invoice", action: "read" }], + granted_capabilities: [{ resource: "invoice", action: "read" }], + audience: "https://finance.example", + expires_at: new Date(Date.now() + 60_000).toISOString(), + max_delegation_depth: 2, + current_delegation_depth: 1, + reason: "downstream", + }).delegation!; + registry.revokeDelegation(parent.delegation_id, "admin", "compromised"); + expect(registry.verifyDelegation(downstream).valid).toBe(false); + const expired = { ...parent, revoked_at: undefined, expires_at: new Date(Date.now() - 1).toISOString() }; + expect(hasCapability(expired, { resource: "invoice", action: "read" }).reason).toBe("Delegation expired"); + }); +}); diff --git a/src/lib/covenant/capability-attenuation.ts b/src/lib/covenant/capability-attenuation.ts new file mode 100644 index 0000000..6df95e3 --- /dev/null +++ b/src/lib/covenant/capability-attenuation.ts @@ -0,0 +1,301 @@ +/** + * Explicit capability attenuation for delegated agent authority. + * + * Delegations are signed by the delegator and constrain audience, expiry, + * capability scope, and delegation depth. Keys and signatures use the same + * base64 DER representation as the Covenant crypto primitives. + */ + +import { randomUUID } from "node:crypto"; +import { canonicalEncode } from "./evidence-standard"; +import { sha256, signMessage, verifyMessage } from "./crypto"; + +export interface CapabilityConstraints { + max_amount?: number; + time_window?: { + start_hour?: number; + end_hour?: number; + timezone?: string; + }; + rate_limit?: { + per_minute?: number; + per_hour?: number; + per_day?: number; + }; +} + +export interface Capability { + resource: string; + action: string; + constraints?: CapabilityConstraints; +} + +export interface DelegatedAuthority { + delegation_id: string; + delegator_agent_id: string; + delegatee_agent_id: string; + audience: string; + granted_capabilities: Capability[]; + excluded_capabilities?: Capability[]; + issued_at: string; + expires_at: string; + max_delegation_depth: number; + current_delegation_depth: number; + revoked_at?: string; + revoked_by?: string; + revocation_reason?: string; + delegator_signature: { + algorithm: "Ed25519"; + value: string; + }; + reason: string; + metadata?: Record; +} + +interface UnsignedDelegation { + delegation_id: string; + delegator_agent_id: string; + delegatee_agent_id: string; + audience: string; + granted_capabilities: Capability[]; + excluded_capabilities?: Capability[]; + issued_at: string; + expires_at: string; + max_delegation_depth: number; + current_delegation_depth: number; + reason: string; +} + +function unsignedDelegation(delegation: DelegatedAuthority): UnsignedDelegation { + return { + delegation_id: delegation.delegation_id, + delegator_agent_id: delegation.delegator_agent_id, + delegatee_agent_id: delegation.delegatee_agent_id, + audience: delegation.audience, + granted_capabilities: delegation.granted_capabilities, + ...(delegation.excluded_capabilities === undefined + ? {} + : { excluded_capabilities: delegation.excluded_capabilities }), + issued_at: delegation.issued_at, + expires_at: delegation.expires_at, + max_delegation_depth: delegation.max_delegation_depth, + current_delegation_depth: delegation.current_delegation_depth, + reason: delegation.reason, + }; +} + +function capabilityMatches(left: Capability, right: Capability): boolean { + return left.resource === right.resource && left.action === right.action; +} + +export function hasCapability( + auth: DelegatedAuthority, + required: Capability, +): { granted: boolean; reason?: string } { + const expires = new Date(auth.expires_at).getTime(); + if (!Number.isFinite(expires) || Date.now() > expires) { + return { granted: false, reason: "Delegation expired" }; + } + if (auth.revoked_at) { + return { granted: false, reason: "Delegation revoked" }; + } + if (auth.excluded_capabilities?.some((capability) => capabilityMatches(capability, required))) { + return { + granted: false, + reason: `Capability ${required.resource}:${required.action} is explicitly excluded`, + }; + } + + const granted = auth.granted_capabilities.find((capability) => + capabilityMatches(capability, required), + ); + if (!granted) { + return { + granted: false, + reason: `Capability ${required.resource}:${required.action} not in delegation`, + }; + } + + const requestedAmount = required.constraints?.max_amount; + const grantedAmount = granted.constraints?.max_amount; + if ( + requestedAmount !== undefined && + grantedAmount !== undefined && + requestedAmount > grantedAmount + ) { + return { + granted: false, + reason: `Amount ${requestedAmount} exceeds max ${grantedAmount}`, + }; + } + return { granted: true }; +} + +export function validateAttenuation( + delegatorCapabilities: Capability[], + delegateeCapabilities: Capability[], +): { valid: boolean; violations: string[] } { + const violations: string[] = []; + for (const delegateeCapability of delegateeCapabilities) { + const delegatorCapability = delegatorCapabilities.find((capability) => + capabilityMatches(capability, delegateeCapability), + ); + if (!delegatorCapability) { + violations.push( + `Delegatee granted ${delegateeCapability.resource}:${delegateeCapability.action} that delegator doesn't have`, + ); + continue; + } + + const delegateeAmount = delegateeCapability.constraints?.max_amount; + const delegatorAmount = delegatorCapability.constraints?.max_amount; + if ( + delegateeAmount !== undefined && + delegatorAmount !== undefined && + delegateeAmount > delegatorAmount + ) { + violations.push( + `Delegatee max_amount ${delegateeAmount} exceeds delegator max_amount ${delegatorAmount}`, + ); + } + } + return { valid: violations.length === 0, violations }; +} + +export class DelegationRegistry { + private readonly delegations = new Map(); + private readonly delegatorKeys = new Map(); + + registerAgent(agentId: string, publicKeyB64: string): void { + this.delegatorKeys.set(agentId, publicKeyB64); + } + + createDelegation(params: { + delegator_agent_id: string; + delegator_private_key_b64: string; + delegatee_agent_id: string; + delegator_capabilities: Capability[]; + granted_capabilities: Capability[]; + excluded_capabilities?: Capability[]; + audience: string; + expires_at: string; + max_delegation_depth: number; + current_delegation_depth: number; + reason: string; + metadata?: Record; + }): { delegation: DelegatedAuthority | null; error?: string } { + const expiresAt = new Date(params.expires_at).getTime(); + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + return { delegation: null, error: "Expiry time is in the past" }; + } + const attenuation = validateAttenuation( + params.delegator_capabilities, + params.granted_capabilities, + ); + if (!attenuation.valid) { + return { + delegation: null, + error: `Attenuation validation failed: ${attenuation.violations.join("; ")}`, + }; + } + if ( + params.current_delegation_depth < 0 || + params.max_delegation_depth < 0 || + params.current_delegation_depth >= params.max_delegation_depth + ) { + return { + delegation: null, + error: `Delegation depth ${params.current_delegation_depth} exceeds max ${params.max_delegation_depth}`, + }; + } + + const unsigned: UnsignedDelegation = { + delegation_id: randomUUID(), + delegator_agent_id: params.delegator_agent_id, + delegatee_agent_id: params.delegatee_agent_id, + audience: params.audience, + granted_capabilities: params.granted_capabilities, + ...(params.excluded_capabilities === undefined + ? {} + : { excluded_capabilities: params.excluded_capabilities }), + issued_at: new Date().toISOString(), + expires_at: params.expires_at, + max_delegation_depth: params.max_delegation_depth, + current_delegation_depth: params.current_delegation_depth, + reason: params.reason, + }; + const signature = signMessage( + Buffer.from(canonicalEncode(unsigned), "utf8"), + params.delegator_private_key_b64, + ); + const delegation: DelegatedAuthority = { + ...unsigned, + ...(params.metadata === undefined ? {} : { metadata: params.metadata }), + delegator_signature: { algorithm: "Ed25519", value: signature }, + }; + this.delegations.set(delegation.delegation_id, delegation); + return { delegation }; + } + + verifyDelegation(delegation: DelegatedAuthority): { valid: boolean; reason?: string } { + const expiresAt = new Date(delegation.expires_at).getTime(); + if (!Number.isFinite(expiresAt) || Date.now() > expiresAt) { + return { valid: false, reason: "Delegation expired" }; + } + if (delegation.revoked_at) { + return { valid: false, reason: "Delegation revoked" }; + } + if (delegation.delegator_signature.algorithm !== "Ed25519") { + return { valid: false, reason: "Unsupported signature algorithm" }; + } + const publicKeyB64 = this.delegatorKeys.get(delegation.delegator_agent_id); + if (!publicKeyB64) { + return { valid: false, reason: "Delegator public key not found" }; + } + return verifyMessage( + Buffer.from(canonicalEncode(unsignedDelegation(delegation)), "utf8"), + delegation.delegator_signature.value, + publicKeyB64, + ) + ? { valid: true } + : { valid: false, reason: "Signature verification failed" }; + } + + revokeDelegation(delegationId: string, revokedBy: string, reason: string): void { + const delegation = this.delegations.get(delegationId); + if (!delegation || delegation.revoked_at) return; + delegation.revoked_at = new Date().toISOString(); + delegation.revoked_by = revokedBy; + delegation.revocation_reason = reason; + for (const downstream of this.delegations.values()) { + if (downstream.delegator_agent_id === delegation.delegatee_agent_id) { + this.revokeDelegation(downstream.delegation_id, revokedBy, `Cascaded from ${delegationId}`); + } + } + } + + getEffectiveCapabilities( + agentId: string, + audience: string, + baseCapabilities: Capability[], + ): Capability[] { + const effective = [...baseCapabilities]; + for (const delegation of this.delegations.values()) { + if ( + delegation.delegatee_agent_id === agentId && + delegation.audience === audience && + !delegation.revoked_at && + Date.now() <= new Date(delegation.expires_at).getTime() + ) { + effective.push(...delegation.granted_capabilities); + } + } + return effective; + } + + queryDelegationsForAgent(agentId: string): DelegatedAuthority[] { + return Array.from(this.delegations.values()).filter( + (delegation) => delegation.delegatee_agent_id === agentId, + ); + } +} diff --git a/src/lib/covenant/evidence-standard.test.ts b/src/lib/covenant/evidence-standard.test.ts new file mode 100644 index 0000000..d9fdaca --- /dev/null +++ b/src/lib/covenant/evidence-standard.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { generateKeyPair } from "./crypto"; +import { + canonicalEncode, + EvidenceGenerator, + getServerPublicKey, + type ExecutionEvidence, +} from "./evidence-standard"; + +function evidenceParams(agentId: string) { + return { + agent_id: agentId, + agent_public_key: generateKeyPair().publicKeyB64, + request: { + action: "approve_invoice", + params: { amount: 50000, invoice_id: "12345" }, + }, + authorization_decision: "authorized" as const, + policy_id: "policy-finance-approval", + decision_reason: "agent is in the finance team", + execution_status: "success" as const, + execution_result: { invoice_approved: true }, + duration_ms: 145, + }; +} + +describe("canonicalEncode", () => { + it("is deterministic for nested objects and arrays", () => { + const first = { + z: { b: 2, a: [{ d: true, c: "value with spaces" }] }, + a: 1, + }; + const second = { + a: 1, + z: { a: [{ c: "value with spaces", d: true }] , b: 2 }, + }; + + expect(canonicalEncode(first)).toBe(canonicalEncode(second)); + expect(canonicalEncode(first)).toBe( + '{"a":1,"z":{"a":[{"c":"value with spaces","d":true}],"b":2}}', + ); + }); +}); + +describe("EvidenceGenerator", () => { + it("generates evidence that verifies with the exposed server public key", async () => { + const generator = new EvidenceGenerator("server-test"); + const evidence = await generator.generateEvidence(evidenceParams("agent-a")); + + expect(evidence.actor.server_public_key).toBe(generator.serverPublicKey); + expect(generator.getServerPublicKey()).toBe(generator.serverPublicKey); + expect(getServerPublicKey()).toBe(generator.serverPublicKey); + expect(generator.verifyEvidence(evidence)).toEqual({ valid: true }); + }); + + it("fails closed without a signing key in production", () => { + const env = process.env as Record; + const previousNodeEnv = env.NODE_ENV; + const previousSigningKey = env.COVENANT_EVIDENCE_SIGNING_KEY; + env.NODE_ENV = "production"; + delete env.COVENANT_EVIDENCE_SIGNING_KEY; + + try { + expect(() => new EvidenceGenerator("server-production")).toThrow( + "COVENANT_EVIDENCE_SIGNING_KEY is required in production", + ); + } finally { + if (previousNodeEnv === undefined) delete env.NODE_ENV; + else env.NODE_ENV = previousNodeEnv; + if (previousSigningKey === undefined) delete env.COVENANT_EVIDENCE_SIGNING_KEY; + else env.COVENANT_EVIDENCE_SIGNING_KEY = previousSigningKey; + } + }); + + it("detects tampering with every signed envelope section", async () => { + const generator = new EvidenceGenerator("server-test"); + const evidence = await generator.generateEvidence(evidenceParams("agent-a")); + const tamperCases: Array<(copy: ExecutionEvidence) => void> = [ + (copy) => { copy.request_commitment.canonicalized_request = "{}"; }, + (copy) => { copy.authorization.decision_reason = "tampered"; }, + (copy) => { copy.execution.duration_ms += 1; }, + (copy) => { copy.actor.agent_id = "agent-tampered"; }, + (copy) => { copy.chain.chain_depth += 1; }, + (copy) => { copy.compliance.immutable = false as true; }, + ]; + + for (const tamper of tamperCases) { + const copy = structuredClone(evidence); + tamper(copy); + expect(generator.verifyEvidence(copy).valid).toBe(false); + } + }); + + it("maintains independent per-agent chain linkage", async () => { + const generator = new EvidenceGenerator("server-test"); + const firstA = await generator.generateEvidence(evidenceParams("agent-a")); + const firstB = await generator.generateEvidence(evidenceParams("agent-b")); + const secondA = await generator.generateEvidence(evidenceParams("agent-a")); + + expect(firstA.chain.previous_evidence_hash).toBeUndefined(); + expect(firstB.chain.previous_evidence_hash).toBeUndefined(); + expect(secondA.chain.previous_evidence_hash).toBe( + firstA.signatures.envelope_hash.value, + ); + expect(secondA.chain.agent_chain_id).toBe(firstA.chain.agent_chain_id); + expect(firstB.chain.agent_chain_id).not.toBe(firstA.chain.agent_chain_id); + expect(generator.getEvidenceChain(secondA.evidence_id)).toEqual([firstA, secondA]); + }); + + it("queries only records belonging to the requested agent", async () => { + const generator = new EvidenceGenerator("server-test"); + await generator.generateEvidence(evidenceParams("agent-a")); + await generator.generateEvidence(evidenceParams("agent-b")); + await generator.generateEvidence(evidenceParams("agent-a")); + + const records = generator.queryEvidenceByAgent("agent-a"); + expect(records).toHaveLength(2); + expect(records.every((record) => record.actor.agent_id === "agent-a")).toBe(true); + }); +}); diff --git a/src/lib/covenant/evidence-standard.ts b/src/lib/covenant/evidence-standard.ts new file mode 100644 index 0000000..d7c5ae6 --- /dev/null +++ b/src/lib/covenant/evidence-standard.ts @@ -0,0 +1,450 @@ +/** + * Verifiable execution evidence with canonical encoding, server signatures, + * and independent per-agent chain linkage. + * + * Evidence signing keys are read from COVENANT_EVIDENCE_SIGNING_KEY in + * production. Development-only ephemeral keys are isolated to non-production + * environments and are never used as a production fallback. + */ + +import { + createPrivateKey, + createPublicKey, + randomUUID, +} from "node:crypto"; +import { + generateKeyPair, + sha256, + signMessage, + verifyMessage, +} from "./crypto"; + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; +export interface JsonObject { + [key: string]: JsonValue; +} + +export type AuthorizationDecision = "authorized" | "denied" | "quarantined"; +export type ExecutionStatus = "success" | "error" | "pending"; + +export interface ExecutionEvidence { + evidence_id: string; + evidence_version: "1.0"; + request_commitment: { + hash_algorithm: "sha256"; + hash_value: string; + canonicalized_request: string; + }; + authorization: { + decision: AuthorizationDecision; + policy_id: string; + decision_reason: string; + timestamp_utc: string; + }; + execution: { + status: ExecutionStatus; + result_commitment: { + hash_algorithm: "sha256"; + hash_value: string; + }; + duration_ms: number; + timestamp_utc: string; + }; + actor: { + agent_id: string; + agent_public_key: string; + server_id: string; + server_public_key: string; + }; + chain: { + previous_evidence_hash?: string; + chain_depth: number; + agent_chain_id: string; + }; + signatures: { + server_signature: { + algorithm: "Ed25519"; + value: string; + public_key: string; + }; + envelope_hash: { + algorithm: "sha256"; + value: string; + }; + }; + intermediaries?: Array<{ + intermediary_id: string; + decision: "forwarded" | "cached" | "denied" | "transformed"; + decision_reason: string; + timestamp_utc: string; + signature: { + algorithm: "Ed25519"; + value: string; + }; + }>; + compliance: { + regulatory_category?: string; + retention_years?: number; + immutable: true; + tamper_evident: true; + }; +} + +export interface GenerateEvidenceParams { + agent_id: string; + agent_public_key: string; + request: JsonObject; + authorization_decision: AuthorizationDecision; + policy_id: string; + decision_reason: string; + execution_status: Exclude; + execution_result: JsonValue; + duration_ms: number; + intermediaries?: ExecutionEvidence["intermediaries"]; + regulatory_category?: string; + retention_years?: number; +} + +export interface EvidenceVerification { + valid: boolean; + reason?: string; +} + +interface UnsignedEvidence { + evidence_id: string; + evidence_version: "1.0"; + request_commitment: ExecutionEvidence["request_commitment"]; + authorization: ExecutionEvidence["authorization"]; + execution: ExecutionEvidence["execution"]; + actor: ExecutionEvidence["actor"]; + chain: ExecutionEvidence["chain"]; + intermediaries?: ExecutionEvidence["intermediaries"]; + compliance: ExecutionEvidence["compliance"]; +} + +interface SigningKeyPair { + privateKeyB64: string; + publicKeyB64: string; +} + +let ephemeralKeyPair: SigningKeyPair | undefined; +let warnedAboutEphemeralKey = false; +let configuredKeyPair: SigningKeyPair | undefined; +let configuredKeyMaterial: string | undefined; + +function signingKeyPairFromPrivateKey(privateKeyB64: string): SigningKeyPair { + const privateKey = createPrivateKey({ + key: Buffer.from(privateKeyB64, "base64"), + format: "der", + type: "pkcs8", + }); + const publicKeyB64 = createPublicKey(privateKey) + .export({ format: "der", type: "spki" }) + .toString("base64"); + return { privateKeyB64, publicKeyB64 }; +} + +function resolveSigningKeyPair(): SigningKeyPair { + const configuredKey = process.env.COVENANT_EVIDENCE_SIGNING_KEY?.trim(); + if (configuredKey) { + if (configuredKeyMaterial !== configuredKey || !configuredKeyPair) { + configuredKeyPair = signingKeyPairFromPrivateKey(configuredKey); + configuredKeyMaterial = configuredKey; + } + return configuredKeyPair; + } + + if (process.env.NODE_ENV === "production") { + throw new Error( + "COVENANT_EVIDENCE_SIGNING_KEY is required in production; refusing to use an ephemeral evidence signing key.", + ); + } + + if (!ephemeralKeyPair) { + ephemeralKeyPair = generateKeyPair(); + } + if (!warnedAboutEphemeralKey) { + console.warn( + "COVENANT_EVIDENCE_SIGNING_KEY is not configured; evidence signatures are ephemeral and development-only.", + ); + warnedAboutEphemeralKey = true; + } + return ephemeralKeyPair; +} + +/** Return the public key corresponding to the configured evidence signer. */ +export function getServerPublicKey(): string { + return resolveSigningKeyPair().publicKeyB64; +} + +function assertJsonValue(value: unknown): asserts value is JsonValue { + if (value === null || typeof value === "string" || typeof value === "boolean") { + return; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new TypeError("Canonical JSON cannot encode non-finite numbers."); + } + return; + } + if (Array.isArray(value)) { + value.forEach(assertJsonValue); + return; + } + if (typeof value === "object") { + Object.values(value).forEach(assertJsonValue); + return; + } + throw new TypeError(`Canonical JSON cannot encode ${typeof value}.`); +} + +/** + * Encode JSON recursively with lexicographically sorted object keys and no + * insignificant whitespace. String contents are preserved verbatim. + */ +export function canonicalEncode(value: unknown): string { + assertJsonValue(value); + + if (value === null || typeof value === "string" || typeof value === "boolean") { + return JSON.stringify(value); + } + if (typeof value === "number") { + return JSON.stringify(Object.is(value, -0) ? 0 : value); + } + if (Array.isArray(value)) { + return `[${value.map(canonicalEncode).join(",")}]`; + } + + const entries = Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalEncode(value[key])}`); + return `{${entries.join(",")}}`; +} + +function unsignedEvidence(evidence: ExecutionEvidence): UnsignedEvidence { + const { + evidence_id, + evidence_version, + request_commitment, + authorization, + execution, + actor, + chain, + intermediaries, + compliance, + } = evidence; + return { + evidence_id, + evidence_version, + request_commitment, + authorization, + execution, + actor, + chain, + ...(intermediaries === undefined ? {} : { intermediaries }), + compliance, + }; +} + +function hashUnsignedEvidence(evidence: ExecutionEvidence): string { + return sha256(canonicalEncode(unsignedEvidence(evidence))); +} + +export class AgentEvidenceChain { + private chainHead: string | null = null; + private chainDepth = 0; + private readonly chainId: string; + + constructor(public readonly agentId: string) { + this.chainId = `chain:${agentId}:${randomUUID()}`; + } + + getChainHead(): string | null { + return this.chainHead; + } + + getChainMetadata(): ExecutionEvidence["chain"] { + return { + ...(this.chainHead === null ? {} : { previous_evidence_hash: this.chainHead }), + chain_depth: this.chainDepth, + agent_chain_id: this.chainId, + }; + } + + recordEvidence(evidence: ExecutionEvidence): void { + if (evidence.actor.agent_id !== this.agentId) { + throw new Error("Evidence agent does not match this evidence chain."); + } + this.chainHead = evidence.signatures.envelope_hash.value; + this.chainDepth += 1; + } +} + +export class EvidenceGenerator { + private readonly serverId: string; + private readonly serverPrivateKey: string; + readonly serverPublicKey: string; + private readonly agentChains = new Map(); + private readonly evidenceStore = new Map(); + + constructor(serverId: string) { + const keyPair = resolveSigningKeyPair(); + this.serverId = serverId; + this.serverPrivateKey = keyPair.privateKeyB64; + this.serverPublicKey = keyPair.publicKeyB64; + } + + getServerPublicKey(): string { + return this.serverPublicKey; + } + + async generateEvidence(params: GenerateEvidenceParams): Promise { + const evidenceId = randomUUID(); + const now = new Date().toISOString(); + const chain = this.agentChains.get(params.agent_id) ?? new AgentEvidenceChain(params.agent_id); + this.agentChains.set(params.agent_id, chain); + + const canonicalRequest = canonicalEncode(params.request); + const unsigned: ExecutionEvidence = { + evidence_id: evidenceId, + evidence_version: "1.0", + request_commitment: { + hash_algorithm: "sha256", + hash_value: sha256(canonicalRequest), + canonicalized_request: canonicalRequest, + }, + authorization: { + decision: params.authorization_decision, + policy_id: params.policy_id, + decision_reason: params.decision_reason, + timestamp_utc: now, + }, + execution: { + status: params.execution_status, + result_commitment: { + hash_algorithm: "sha256", + hash_value: sha256(canonicalEncode(params.execution_result)), + }, + duration_ms: params.duration_ms, + timestamp_utc: now, + }, + actor: { + agent_id: params.agent_id, + agent_public_key: params.agent_public_key, + server_id: this.serverId, + server_public_key: this.serverPublicKey, + }, + chain: chain.getChainMetadata(), + ...(params.intermediaries === undefined ? {} : { intermediaries: params.intermediaries }), + compliance: { + ...(params.regulatory_category === undefined + ? {} + : { regulatory_category: params.regulatory_category }), + ...(params.retention_years === undefined + ? {} + : { retention_years: params.retention_years }), + immutable: true, + tamper_evident: true, + }, + signatures: { + server_signature: { + algorithm: "Ed25519", + value: "", + public_key: this.serverPublicKey, + }, + envelope_hash: { + algorithm: "sha256", + value: "", + }, + }, + }; + + const envelopeHash = hashUnsignedEvidence(unsigned); + unsigned.signatures.envelope_hash.value = envelopeHash; + unsigned.signatures.server_signature.value = signMessage( + Buffer.from(envelopeHash, "utf8"), + this.serverPrivateKey, + ); + + this.evidenceStore.set(evidenceId, unsigned); + chain.recordEvidence(unsigned); + return unsigned; + } + + verifyEvidence(evidence: ExecutionEvidence): EvidenceVerification { + try { + if (evidence.signatures.server_signature.algorithm !== "Ed25519") { + return { valid: false, reason: "Unsupported evidence signature algorithm." }; + } + if (evidence.signatures.envelope_hash.algorithm !== "sha256") { + return { valid: false, reason: "Unsupported evidence hash algorithm." }; + } + if ( + evidence.actor.server_public_key !== + evidence.signatures.server_signature.public_key + ) { + return { valid: false, reason: "Evidence server public key mismatch." }; + } + + const recomputedHash = hashUnsignedEvidence(evidence); + if (recomputedHash !== evidence.signatures.envelope_hash.value) { + return { valid: false, reason: "Evidence envelope hash mismatch." }; + } + + const valid = verifyMessage( + Buffer.from(recomputedHash, "utf8"), + evidence.signatures.server_signature.value, + evidence.signatures.server_signature.public_key, + ); + return valid + ? { valid: true } + : { valid: false, reason: "Ed25519 evidence signature verification failed." }; + } catch (error: unknown) { + return { + valid: false, + reason: error instanceof Error ? error.message : "Evidence verification failed.", + }; + } + } + + getEvidenceChain(evidenceId: string): ExecutionEvidence[] { + const chain: ExecutionEvidence[] = []; + let current = this.evidenceStore.get(evidenceId); + + while (current) { + chain.unshift(current); + const previousHash = current.chain.previous_evidence_hash; + if (!previousHash) break; + current = Array.from(this.evidenceStore.values()).find( + (candidate) => candidate.signatures.envelope_hash.value === previousHash, + ); + } + return chain; + } + + queryEvidenceByAgent(agentId: string): ExecutionEvidence[] { + return Array.from(this.evidenceStore.values()) + .filter((evidence) => evidence.actor.agent_id === agentId) + .sort( + (a, b) => + new Date(a.authorization.timestamp_utc).getTime() - + new Date(b.authorization.timestamp_utc).getTime(), + ); + } + + queryEvidenceByTime(startUtc: string, endUtc: string): ExecutionEvidence[] { + const start = new Date(startUtc).getTime(); + const end = new Date(endUtc).getTime(); + return Array.from(this.evidenceStore.values()) + .filter((evidence) => { + const timestamp = new Date(evidence.authorization.timestamp_utc).getTime(); + return timestamp >= start && timestamp <= end; + }) + .sort( + (a, b) => + new Date(a.authorization.timestamp_utc).getTime() - + new Date(b.authorization.timestamp_utc).getTime(), + ); + } +}