UNPKG

schemapin

Version:

Cryptographic schema integrity verification for AI tools

383 lines (341 loc) 13.1 kB
/** * Skill folder signing and verification for SchemaPin v1.3. * * Extends SchemaPin's ECDSA P-256 signing to cover file-based skill folders * (AgentSkills spec). Same keys, same .well-known discovery, new canonicalization * target. */ import { createHash, createPrivateKey, createPublicKey } from 'node:crypto'; import { readFileSync, readdirSync, writeFileSync, lstatSync, existsSync } from 'node:fs'; import { join, relative, basename, resolve } from 'node:path'; import { KeyManager, SignatureManager } from './crypto.js'; import { checkRevocationCombined } from './revocation.js'; import { ErrorCode } from './verification.js'; export const SIGNATURE_FILENAME = '.schemapin.sig'; const SCHEMAPIN_VERSION = '1.3'; /** * Recursively walk a directory in sorted order, collecting file entries. * * @param {string} dir - Current directory to walk * @param {string} baseDir - Root skill directory for computing relative paths * @param {Object} manifest - Accumulator for manifest entries */ function walkSorted(dir, baseDir, manifest) { const entries = readdirSync(dir, { withFileTypes: true }); // Separate dirs and files, sort each by name const dirs = []; const files = []; for (const entry of entries) { if (entry.isDirectory()) { dirs.push(entry.name); } else { files.push(entry.name); } } dirs.sort(); files.sort(); // Process files first (matching Python os.walk behavior within each directory) for (const fname of files) { if (fname === SIGNATURE_FILENAME) { continue; } const fullPath = join(dir, fname); // Skip symlinks const lstats = lstatSync(fullPath); if (lstats.isSymbolicLink()) { continue; } const relPath = relative(baseDir, fullPath).split('\\').join('/'); const fileBytes = readFileSync(fullPath); const pathBytes = Buffer.from(relPath, 'utf-8'); const digest = createHash('sha256') .update(pathBytes) .update(fileBytes) .digest('hex'); manifest[relPath] = `sha256:${digest}`; } // Recurse into subdirectories in sorted order for (const dname of dirs) { const subdir = join(dir, dname); // Skip symlinked directories const lstats = lstatSync(subdir); if (lstats.isSymbolicLink()) { continue; } walkSorted(subdir, baseDir, manifest); } } /** * Canonicalize a skill directory deterministically and compute a root hash. * * Algorithm: * 1. Recursive sorted walk via readdirSync * 2. Skip .schemapin.sig and symlinks * 3. Normalize paths to forward slashes * 4. Per-file: sha256(rel_path_utf8 + file_bytes).hexdigest() * 5. Root: sha256(concat of all hexdigests, sorted by rel_path).digest() * * @param {string} skillDir - Path to skill directory * @returns {{ rootHash: Buffer, manifest: Object }} Root hash and file manifest * @throws {Error} If directory is empty or contains no signable files */ export function canonicalizeSkill(skillDir) { const skillPath = resolve(skillDir); const manifest = {}; walkSorted(skillPath, skillPath, manifest); if (Object.keys(manifest).length === 0) { throw new Error(`Skill directory is empty or contains no signable files: ${skillDir}`); } // Root hash: sort manifest keys, extract hex digests, concatenate, sha256 const sortedKeys = Object.keys(manifest).sort(); const sortedDigests = sortedKeys.map(k => { const parts = manifest[k].split(':'); return parts.slice(1).join(':'); }); const rootHash = createHash('sha256') .update(sortedDigests.join(''), 'utf-8') .digest(); return { rootHash, manifest }; } /** * Extract the skill name from SKILL.md frontmatter. * * Falls back to the directory basename if SKILL.md is missing or * has no name: field. * * @param {string} skillDir - Path to skill directory * @returns {string} Skill name */ export function parseSkillName(skillDir) { const skillPath = resolve(skillDir); const skillMd = join(skillPath, 'SKILL.md'); if (existsSync(skillMd)) { try { const text = readFileSync(skillMd, 'utf-8'); const fmMatch = text.match(/^---\s*\n([\s\S]*?)\n---/); if (fmMatch) { const frontmatter = fmMatch[1]; const nameMatch = frontmatter.match(/^name:\s*['"]?([^'"#\n]+?)['"]?\s*$/m); if (nameMatch) { return nameMatch[1].trim(); } } } catch { // Fall through to basename } } return basename(skillPath); } /** * Read and parse the .schemapin.sig file from a skill directory. * * @param {string} skillDir - Path to skill directory * @returns {Object} Parsed signature document * @throws {Error} If .schemapin.sig does not exist */ export function loadSignature(skillDir) { const sigPath = join(resolve(skillDir), SIGNATURE_FILENAME); if (!existsSync(sigPath)) { throw new Error(`Signature file not found: ${sigPath}`); } const text = readFileSync(sigPath, 'utf-8'); return JSON.parse(text); } /** * Canonicalize a skill directory, sign, and write .schemapin.sig. * * @param {string} skillDir - Path to the skill folder * @param {string} privateKeyPem - PEM-encoded ECDSA P-256 private key * @param {string} domain - Signing domain (e.g. "thirdkey.ai") * @param {string|null} signerKid - Optional key ID (fingerprint). Auto-computed if null. * @param {string|null} skillName - Override for the skill name. Parsed from SKILL.md if not provided. * @returns {Object} The signature document that was written */ export function signSkill(skillDir, privateKeyPem, domain, signerKid = null, skillName = null) { const skillPath = resolve(skillDir); const privateKey = KeyManager.loadPrivateKeyPem(privateKeyPem); // Derive public key PEM from private key const privKeyObj = createPrivateKey(privateKeyPem); const pubKeyObj = createPublicKey(privKeyObj); const publicKeyPem = pubKeyObj.export({ type: 'spki', format: 'pem' }); const { rootHash, manifest } = canonicalizeSkill(skillPath); if (skillName === null || skillName === undefined) { skillName = parseSkillName(skillPath); } if (signerKid === null || signerKid === undefined) { signerKid = KeyManager.calculateKeyFingerprint(publicKeyPem); } const signatureB64 = SignatureManager.signHash(rootHash, privateKey); const sigDoc = { schemapin_version: SCHEMAPIN_VERSION, skill_name: skillName, skill_hash: `sha256:${rootHash.toString('hex')}`, signature: signatureB64, signed_at: new Date().toISOString(), domain: domain, signer_kid: signerKid, file_manifest: manifest }; const sigPath = join(skillPath, SIGNATURE_FILENAME); writeFileSync(sigPath, JSON.stringify(sigDoc, null, 2) + '\n', 'utf-8'); return sigDoc; } /** * Verify a signed skill folder offline (7-step flow). * * Mirrors verifySchemaOffline(): * 1. Load or accept signature data * 2. Validate discovery document * 3. Extract public key and compute fingerprint * 4. Check revocation * 5. TOFU key pinning * 6. Canonicalize skill and verify ECDSA signature * 7. Return structured result * * @param {string} skillDir - Path to skill directory * @param {Object} discovery - Well-known discovery document * @param {Object|null} signatureData - Pre-loaded signature data (loads from file if null) * @param {Object|null} revocationDoc - Standalone revocation document * @param {KeyPinStore|null} pinStore - TOFU pin store * @param {string|null} toolId - Tool identifier for pinning * @returns {Object} Verification result */ export function verifySkillOffline(skillDir, discovery, signatureData = null, revocationDoc = null, pinStore = null, toolId = null) { const skillPath = resolve(skillDir); // Step 1: Load signature data if (signatureData === null || signatureData === undefined) { try { signatureData = loadSignature(skillPath); } catch { return { valid: false, error_code: ErrorCode.SIGNATURE_INVALID, error_message: 'No .schemapin.sig found in skill directory' }; } } const domain = signatureData.domain || ''; if (toolId === null || toolId === undefined) { toolId = signatureData.skill_name || basename(skillPath); } // Step 2: Validate discovery document const publicKeyPem = discovery?.public_key_pem; if (!publicKeyPem || !publicKeyPem.includes('-----BEGIN PUBLIC KEY-----')) { return { valid: false, domain, error_code: ErrorCode.DISCOVERY_INVALID, error_message: 'Discovery document missing or invalid public_key_pem' }; } // Step 3: Extract public key and compute fingerprint let publicKey, fingerprint; try { publicKey = KeyManager.loadPublicKeyPem(publicKeyPem); fingerprint = KeyManager.calculateKeyFingerprint(publicKeyPem); } catch (e) { return { valid: false, domain, error_code: ErrorCode.KEY_NOT_FOUND, error_message: `Failed to load public key: ${e.message}` }; } // Step 4: Check revocation const simpleRevoked = discovery.revoked_keys || []; try { checkRevocationCombined(simpleRevoked, revocationDoc, fingerprint); } catch (e) { return { valid: false, domain, error_code: ErrorCode.KEY_REVOKED, error_message: e.message }; } // Step 5: TOFU key pinning let pinResult = null; if (pinStore !== null && pinStore !== undefined) { pinResult = pinStore.checkAndPin(toolId, domain, fingerprint); if (pinResult === 'changed') { return { valid: false, domain, error_code: ErrorCode.KEY_PIN_MISMATCH, error_message: 'Key fingerprint changed since last use' }; } } // Step 6: Canonicalize and verify signature let rootHash; try { const result = canonicalizeSkill(skillPath); rootHash = result.rootHash; } catch (e) { return { valid: false, domain, error_code: ErrorCode.SCHEMA_CANONICALIZATION_FAILED, error_message: `Failed to canonicalize skill: ${e.message}` }; } const signatureB64 = signatureData.signature || ''; const valid = SignatureManager.verifySignature(rootHash, signatureB64, publicKey); if (!valid) { return { valid: false, domain, error_code: ErrorCode.SIGNATURE_INVALID, error_message: 'Signature verification failed' }; } // Step 7: Return success const resultObj = { valid: true, domain, developer_name: discovery.developer_name || null, key_pinning: pinResult ? { status: pinResult } : null, warnings: [] }; return resultObj; } /** * Verify a signed skill folder using a resolver for discovery. * * @param {string} skillDir - Path to skill directory * @param {string} domain - Domain to resolve discovery for * @param {Object} resolver - SchemaResolver instance * @param {KeyPinStore|null} pinStore - TOFU pin store * @param {string|null} toolId - Tool identifier for pinning * @returns {Promise<Object>} Verification result */ export async function verifySkillWithResolver(skillDir, domain, resolver, pinStore = null, toolId = null) { const discovery = await resolver.resolveDiscovery(domain); if (!discovery) { return { valid: false, domain, error_code: ErrorCode.DISCOVERY_FETCH_FAILED, error_message: `Could not resolve discovery for domain: ${domain}` }; } const revocation = await resolver.resolveRevocation(domain, discovery); return verifySkillOffline(skillDir, discovery, null, revocation, pinStore, toolId); } /** * Compare current file manifest against the signed manifest. * * @param {Object} currentManifest - Current file manifest * @param {Object} signedManifest - Signed file manifest * @returns {{ modified: string[], added: string[], removed: string[] }} */ export function detectTamperedFiles(currentManifest, signedManifest) { const currentKeys = new Set(Object.keys(currentManifest)); const signedKeys = new Set(Object.keys(signedManifest)); const added = [...currentKeys].filter(k => !signedKeys.has(k)).sort(); const removed = [...signedKeys].filter(k => !currentKeys.has(k)).sort(); const modified = [...currentKeys] .filter(k => signedKeys.has(k) && currentManifest[k] !== signedManifest[k]) .sort(); return { modified, added, removed }; }