UNPKG

@kya-os/cli

Version:

CLI for MCP-I setup and management

491 lines • 17.5 kB
/** * Local verification command for XMCP-I CLI * * Implements Requirements 15.5, 15.6: * - Perform offline verification without KTA calls * - Validate signatures, proofs, and key relationships locally * - Return detailed validation results for debugging */ import chalk from "chalk"; import ora from "ora"; import { readFileSync, existsSync } from "fs"; import { importJWK, jwtVerify } from "jose"; import { createHash } from "crypto"; import { loadCurrentIdentity } from "../utils/identity-manager.js"; import { showSuccess, showError, } from "../utils/prompts.js"; /** * Verify command implementation */ export async function verify(options = {}) { const { local = false, json = false, verbose = false } = options; if (!local) { if (json) { console.log(JSON.stringify({ success: false, error: "Only --local verification is currently supported", }, null, 2)); } else { showError("Only --local verification is currently supported"); console.log(chalk.gray("Use: mcpi verify --local")); } process.exit(1); } if (!json) { console.log(chalk.cyan("\nšŸ” Local Proof Verification\n")); } const spinner = json ? null : ora("Loading identity and proof data...").start(); try { // Load current identity const identity = loadCurrentIdentity(); if (!identity) { const error = 'No XMCP-I identity found. Run "mcpi init" first.'; if (json) { console.log(JSON.stringify({ success: false, error }, null, 2)); } else { spinner?.fail(error); } process.exit(1); } // Load proof data const proofData = await loadProofData(options); if (!proofData) { const error = "No proof data provided. Use --proof, --request, and --response options."; if (json) { console.log(JSON.stringify({ success: false, error }, null, 2)); } else { spinner?.fail(error); } process.exit(1); } if (spinner) { spinner.text = "Performing local verification..."; } // Perform local verification const result = await performLocalVerification(identity, proofData); if (json) { console.log(JSON.stringify(result, null, 2)); } else { spinner?.stop(); displayVerificationResults(result, verbose); } // Exit with appropriate code process.exit(result.success ? 0 : 1); } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; if (json) { console.log(JSON.stringify({ success: false, error: errorMessage, }, null, 2)); } else { spinner?.fail(`Verification failed: ${errorMessage}`); } process.exit(1); } } /** * Load proof data from options or interactive input */ async function loadProofData(options) { try { let proof; let request; let response; // Load proof if (options.proof) { proof = loadJsonData(options.proof, "proof"); } else { // Try to find proof in common locations const commonProofPaths = [ ".mcpi/last-proof.json", "proof.json", "test-proof.json", ]; let proofPath = null; for (const path of commonProofPaths) { if (existsSync(path)) { proofPath = path; break; } } if (!proofPath) { return null; } proof = loadJsonData(proofPath, "proof"); } // Load request if provided if (options.request) { request = loadJsonData(options.request, "request"); } // Load response if provided if (options.response) { response = loadJsonData(options.response, "response"); } return { proof, request, response }; } catch (error) { throw new Error(`Failed to load proof data: ${error instanceof Error ? error.message : "Unknown error"}`); } } /** * Load JSON data from file path or JSON string */ function loadJsonData(input, type) { try { // Check if it's a file path if (existsSync(input)) { const content = readFileSync(input, "utf-8"); return JSON.parse(content); } // Try to parse as JSON string return JSON.parse(input); } catch (error) { throw new Error(`Invalid ${type} data: ${error instanceof Error ? error.message : "Parse error"}`); } } /** * Perform comprehensive local verification */ async function performLocalVerification(identity, proofData) { const result = { success: false, details: { proofStructure: { valid: false, issues: [] }, signature: { valid: false, issues: [] }, hashes: { valid: false, issues: [] }, keyRelationship: { valid: false, issues: [] }, timestamp: { valid: false, issues: [] }, }, warnings: [], identity: { did: identity.did, kid: identity.kid, environment: process.env.AGENT_PRIVATE_KEY ? "prod" : "dev", }, }; // 1. Validate proof structure result.details.proofStructure = validateProofStructure(proofData.proof); // 2. Validate signature result.details.signature = await validateSignature(proofData.proof, identity); // 3. Validate hashes (if request/response provided) if (proofData.request && proofData.response) { result.details.hashes = validateHashes(proofData.proof, proofData.request, proofData.response); } else { result.warnings.push("Request/response data not provided - hash validation skipped"); } // 4. Validate key relationship result.details.keyRelationship = validateKeyRelationship(proofData.proof, identity); // 5. Validate timestamp result.details.timestamp = validateTimestamp(proofData.proof); // Determine overall success result.success = result.details.proofStructure.valid && result.details.signature.valid && (result.details.hashes.valid || !proofData.request || !proofData.response) && result.details.keyRelationship.valid && result.details.timestamp.valid; return result; } /** * Validate proof structure */ function validateProofStructure(proof) { const issues = []; // Check required fields if (!proof.jws) { issues.push("Missing JWS field"); } else { // Check detached JWS format (header..signature) const parts = proof.jws.split("."); if (parts.length !== 3) { issues.push("Invalid JWS format - expected 3 parts"); } else if (parts[1] !== "") { issues.push("JWS is not detached - payload should be empty"); } } if (!proof.meta) { issues.push("Missing meta field"); } else { const meta = proof.meta; const requiredFields = [ "did", "kid", "ts", "nonce", "audience", "sessionId", "requestHash", "responseHash", ]; for (const field of requiredFields) { if (!meta[field]) { issues.push(`Missing meta.${field}`); } } // Validate hash format if (meta.requestHash && !meta.requestHash.match(/^sha256:[a-f0-9]{64}$/)) { issues.push("Invalid requestHash format - expected sha256:hex"); } if (meta.responseHash && !meta.responseHash.match(/^sha256:[a-f0-9]{64}$/)) { issues.push("Invalid responseHash format - expected sha256:hex"); } } return { valid: issues.length === 0, issues, }; } /** * Validate JWS signature */ async function validateSignature(proof, identity) { const issues = []; try { // Convert base64 public key to JWK format for JOSE const publicKeyBuffer = Buffer.from(identity.publicKey, "base64"); // For Ed25519, we need to create a proper JWK const publicKeyJwk = { kty: "OKP", crv: "Ed25519", x: Buffer.from(publicKeyBuffer).toString("base64url"), }; // Import the public key const publicKey = await importJWK(publicKeyJwk, "EdDSA"); // Reconstruct the full JWT for verification const payload = Buffer.from(JSON.stringify(proof.meta)).toString("base64url"); const fullJwt = proof.jws.replace("..", `.${payload}.`); // Verify the signature const { payload: verifiedPayload } = await jwtVerify(fullJwt, publicKey, { algorithms: ["EdDSA"], }); // Verify the payload matches our meta const expectedMeta = JSON.stringify(proof.meta); const actualMeta = JSON.stringify(verifiedPayload); if (expectedMeta !== actualMeta) { issues.push("Payload mismatch - meta does not match signed payload"); } } catch (error) { issues.push(`Signature verification failed: ${error instanceof Error ? error.message : "Unknown error"}`); } return { valid: issues.length === 0, issues, }; } /** * Validate hashes against request/response */ function validateHashes(proof, request, response) { const issues = []; try { // Generate expected hashes using the same canonicalization as the runtime const expectedHashes = generateCanonicalHashes(request, response); const result = { valid: false, issues, expected: expectedHashes, actual: { requestHash: proof.meta.requestHash, responseHash: proof.meta.responseHash, }, }; // Compare hashes if (proof.meta.requestHash !== expectedHashes.requestHash) { issues.push(`Request hash mismatch - expected ${expectedHashes.requestHash}, got ${proof.meta.requestHash}`); } if (proof.meta.responseHash !== expectedHashes.responseHash) { issues.push(`Response hash mismatch - expected ${expectedHashes.responseHash}, got ${proof.meta.responseHash}`); } result.valid = issues.length === 0; return result; } catch (error) { issues.push(`Hash validation failed: ${error instanceof Error ? error.message : "Unknown error"}`); return { valid: false, issues }; } } /** * Generate canonical hashes (matching runtime implementation) */ function generateCanonicalHashes(request, response) { // Canonicalize request (exclude transport metadata, include method and params) const canonicalRequest = { method: request.method, ...(request.params && { params: request.params }), }; // Canonicalize response (only the data part, exclude meta) const canonicalResponse = response.data || response; // Generate SHA-256 hashes with JCS canonicalization const requestHash = generateSHA256Hash(canonicalRequest); const responseHash = generateSHA256Hash(canonicalResponse); return { requestHash, responseHash }; } /** * Generate SHA-256 hash with JCS canonicalization */ function generateSHA256Hash(data) { // JCS canonicalization (simplified implementation) const canonicalJson = canonicalizeJSON(data); // Generate SHA-256 hash const hash = createHash("sha256").update(canonicalJson, "utf8").digest("hex"); return `sha256:${hash}`; } /** * JCS canonicalization implementation (matching runtime) */ function canonicalizeJSON(obj) { if (obj === null) return "null"; if (typeof obj === "boolean") return obj.toString(); if (typeof obj === "number") { if (Number.isNaN(obj)) return "null"; if (!Number.isFinite(obj)) return "null"; return obj.toString(); } if (typeof obj === "string") return JSON.stringify(obj); if (Array.isArray(obj)) { const items = obj.map((item) => canonicalizeJSON(item)); return `[${items.join(",")}]`; } if (typeof obj === "object") { // Sort keys for canonical ordering const sortedKeys = Object.keys(obj).sort(); const pairs = sortedKeys.map((key) => { const value = canonicalizeJSON(obj[key]); return `${JSON.stringify(key)}:${value}`; }); return `{${pairs.join(",")}}`; } // Fallback for other types return JSON.stringify(obj); } /** * Validate key relationship between proof and identity */ function validateKeyRelationship(proof, identity) { const issues = []; // Check DID match if (proof.meta.did !== identity.did) { issues.push(`DID mismatch - proof: ${proof.meta.did}, identity: ${identity.did}`); } // Check key ID match if (proof.meta.kid !== identity.kid) { issues.push(`Key ID mismatch - proof: ${proof.meta.kid}, identity: ${identity.kid}`); } return { valid: issues.length === 0, issues, }; } /** * Validate timestamp */ function validateTimestamp(proof) { const issues = []; const now = Math.floor(Date.now() / 1000); const proofTime = proof.meta.ts; const skew = Math.abs(now - proofTime); // Default skew tolerance (can be configured via XMCP_I_TS_SKEW_SEC) const maxSkew = parseInt(process.env.XMCP_I_TS_SKEW_SEC || "120"); if (skew > maxSkew) { issues.push(`Timestamp skew too large: ${skew}s (max: ${maxSkew}s)`); } return { valid: issues.length === 0, issues, skew, }; } /** * Display verification results in human-readable format */ function displayVerificationResults(result, verbose) { console.log(chalk.bold("\nšŸ“Š Verification Results\n")); // Overall status if (result.success) { showSuccess("āœ… Local verification passed"); } else { showError("āŒ Local verification failed"); } // Identity info if (result.identity) { console.log(chalk.bold("\nšŸ”‘ Identity:")); console.log(` DID: ${chalk.cyan(result.identity.did)}`); console.log(` Key ID: ${chalk.cyan(result.identity.kid)}`); console.log(` Environment: ${chalk.cyan(result.identity.environment)}`); } // Detailed results console.log(chalk.bold("\nšŸ” Verification Details:")); const checks = [ { name: "Proof Structure", result: result.details.proofStructure }, { name: "Signature", result: result.details.signature }, { name: "Hash Validation", result: result.details.hashes }, { name: "Key Relationship", result: result.details.keyRelationship }, { name: "Timestamp", result: result.details.timestamp }, ]; for (const check of checks) { const status = check.result.valid ? "āœ…" : "āŒ"; const color = check.result.valid ? chalk.green : chalk.red; console.log(` ${status} ${color(check.name)}`); if (check.result.issues.length > 0 && (verbose || !check.result.valid)) { for (const issue of check.result.issues) { console.log(` ${chalk.gray("•")} ${chalk.yellow(issue)}`); } } } // Hash details if (result.details.hashes.expected && result.details.hashes.actual && verbose) { console.log(chalk.bold("\nšŸ“ Hash Details:")); console.log(` Expected Request Hash: ${chalk.gray(result.details.hashes.expected.requestHash)}`); console.log(` Actual Request Hash: ${chalk.gray(result.details.hashes.actual.requestHash)}`); console.log(` Expected Response Hash: ${chalk.gray(result.details.hashes.expected.responseHash)}`); console.log(` Actual Response Hash: ${chalk.gray(result.details.hashes.actual.responseHash)}`); } // Timestamp details if (result.details.timestamp.skew !== undefined && verbose) { console.log(chalk.bold("\nā° Timestamp Details:")); console.log(` Clock Skew: ${chalk.gray(result.details.timestamp.skew + "s")}`); console.log(` Max Allowed: ${chalk.gray(process.env.XMCP_I_TS_SKEW_SEC || "120")}s`); } // Warnings if (result.warnings.length > 0) { console.log(chalk.bold("\nāš ļø Warnings:")); for (const warning of result.warnings) { console.log(` ${chalk.yellow("•")} ${warning}`); } } // Usage hints if (!result.success || verbose) { console.log(chalk.bold("\nšŸ’” Usage:")); console.log(chalk.gray(" mcpi verify --local --proof proof.json")); console.log(chalk.gray(" mcpi verify --local --proof proof.json --request request.json --response response.json")); console.log(chalk.gray(" mcpi verify --local --json # JSON output")); console.log(chalk.gray(" mcpi verify --local --verbose # Detailed output")); } } //# sourceMappingURL=verify.js.map