@kya-os/cli
Version:
CLI for MCP-I setup and management
491 lines ⢠17.5 kB
JavaScript
/**
* 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