diff --git a/src/cli.ts b/src/cli.ts index 30282096..da1872db 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,6 +14,7 @@ import { removeCommand, parseRemoveOptions } from './remove.ts'; import { runSync, parseSyncOptions } from './sync.ts'; import { track } from './telemetry.ts'; import { fetchSkillFolderHash, getGitHubToken } from './skill-lock.ts'; +import { verifySkillSignature, formatVerificationResult } from './signature.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -113,6 +114,7 @@ ${BOLD}Manage Skills:${RESET} https://github.com/vercel-labs/agent-skills remove [skills] Remove installed skills list, ls List installed skills + verify [skills] Verify skill signatures find [query] Search for skills interactively ${BOLD}Updates:${RESET} @@ -161,6 +163,8 @@ ${BOLD}Examples:${RESET} ${DIM}$${RESET} skills add vercel-labs/agent-skills --skill pr-review commit ${DIM}$${RESET} skills remove ${DIM}# interactive remove${RESET} ${DIM}$${RESET} skills remove web-design ${DIM}# remove by name${RESET} + ${DIM}$${RESET} skills verify ${DIM}# verify all skill signatures${RESET} + ${DIM}$${RESET} skills verify web-design ${DIM}# verify specific skill${RESET} ${DIM}$${RESET} skills rm --global frontend-design ${DIM}$${RESET} skills list ${DIM}# list project skills${RESET} ${DIM}$${RESET} skills ls -g ${DIM}# list global skills${RESET} @@ -396,6 +400,87 @@ function printSkippedSkills(skipped: SkippedSkill[]): void { } } +/** + * Verify signatures of installed skills. + */ +async function runVerify(args: string[]): Promise { + const isGlobal = args.includes('-g') || args.includes('--global'); + const skillFilter = args.filter((a) => !a.startsWith('-')); + + console.log(`${TEXT}Verifying skill signatures...${RESET}`); + console.log(); + + const lock = readSkillLock(); + const skillNames = Object.keys(lock.skills); + + if (skillNames.length === 0) { + console.log(`${DIM}No skills tracked in lock file.${RESET}`); + return; + } + + // Filter to specific skills if provided + const toVerify = + skillFilter.length > 0 + ? skillNames.filter((n) => skillFilter.some((f) => n.toLowerCase().includes(f.toLowerCase()))) + : skillNames; + + if (toVerify.length === 0) { + console.log(`${DIM}No matching skills found.${RESET}`); + return; + } + + let verified = 0; + let unsigned = 0; + let failed = 0; + + for (const skillName of toVerify) { + const entry = lock.skills[skillName]; + if (!entry) continue; + + // Find the installed SKILL.md + const canonicalDir = join(homedir(), '.agents', 'skills', skillName); + const skillMdPath = join(canonicalDir, 'SKILL.md'); + + try { + if (!existsSync(skillMdPath)) { + console.log(` ${DIM}○${RESET} ${skillName} ${DIM}(not found on disk)${RESET}`); + continue; + } + + const content = readFileSync(skillMdPath, 'utf-8'); + const result = await verifySkillSignature(content); + const formatted = formatVerificationResult(result); + + if (result.status === 'verified') { + console.log(` ${TEXT}✓${RESET} ${skillName} ${DIM}${formatted}${RESET}`); + verified++; + } else if (result.status === 'no-signature') { + console.log(` ${DIM}○${RESET} ${skillName} ${DIM}${formatted}${RESET}`); + unsigned++; + } else { + console.log(` ${TEXT}✗${RESET} ${skillName} ${formatted}`); + failed++; + } + } catch (err) { + console.log( + ` ${TEXT}✗${RESET} ${skillName} ${DIM}(error: ${err instanceof Error ? err.message : String(err)})${RESET}` + ); + failed++; + } + } + + console.log(); + console.log( + `${TEXT}Results:${RESET} ${verified} verified, ${unsigned} unsigned, ${failed} failed` + ); + + if (failed > 0) { + console.log(); + console.log(`${DIM}⚠ Skills with failed verification may have been tampered with.${RESET}`); + console.log(`${DIM} Reinstall from a trusted source: npx skills add -g -y${RESET}`); + } +} + async function runCheck(args: string[] = []): Promise { console.log(`${TEXT}Checking for skill updates...${RESET}`); console.log(); @@ -685,6 +770,9 @@ async function main(): Promise { case 'ls': await runList(restArgs); break; + case 'verify': + await runVerify(restArgs); + break; case 'check': runCheck(restArgs); break; diff --git a/src/signature.ts b/src/signature.ts new file mode 100644 index 00000000..802025b1 --- /dev/null +++ b/src/signature.ts @@ -0,0 +1,363 @@ +/** + * Skill signature verification module. + * + * Implements the signature verification RFC (#617): + * - Parses signature blocks from SKILL.md YAML frontmatter + * - Verifies ed25519 signatures over skill content + * - Fetches and caches public keys from `.well-known/skills-pubkey` + * + * @see https://github.com/vercel-labs/skills/issues/617 + */ + +import { createHash, createVerify, verify as cryptoVerify } from 'crypto'; +import { readFile, writeFile, mkdir, stat } from 'fs/promises'; +import { join } from 'path'; +import { homedir } from 'os'; + +// ─── Types ─── + +export interface SkillSignature { + /** Signing algorithm (e.g., "ed25519-sha256") */ + algorithm: string; + /** Signer domain (e.g., "skills.sh", "skills.mycompany.io") */ + signer: string; + /** Content hash in "algo:hex" format (e.g., "sha256:a1b2c3d4...") */ + content_hash: string; + /** ISO timestamp when signed */ + signed_at: string; + /** Base64-encoded signature */ + sig: string; + /** Optional key ID for key rotation support */ + kid?: string; +} + +export type VerificationResult = + | { status: 'verified'; signer: string; signed_at: string } + | { status: 'no-signature' } + | { status: 'invalid-signature'; reason: string } + | { status: 'hash-mismatch'; expected: string; actual: string } + | { status: 'key-fetch-failed'; signer: string; error: string } + | { status: 'unsupported-algorithm'; algorithm: string } + | { status: 'error'; error: string }; + +export interface PublicKeyEntry { + /** Key ID for matching with signature kid field */ + kid: string; + /** PEM-encoded public key */ + public_key: string; + /** Key algorithm (e.g., "ed25519") */ + algorithm: string; + /** ISO timestamp when key was created */ + created_at: string; + /** ISO timestamp when key expires (optional) */ + expires_at?: string; +} + +interface CachedKeys { + signer: string; + keys: PublicKeyEntry[]; + fetched_at: string; + ttl_seconds: number; +} + +// ─── Constants ─── + +const SUPPORTED_ALGORITHMS = ['ed25519-sha256']; +const KEY_CACHE_TTL_SECONDS = 3600; // 1 hour +const KEY_CACHE_DIR = join(homedir(), '.agents', '.key-cache'); +const WELL_KNOWN_PATH = '/.well-known/skills-pubkey'; +const FETCH_TIMEOUT_MS = 10_000; + +// ─── Frontmatter Parsing ─── + +/** + * Extract signature block from SKILL.md content. + * Returns null if no signature block is found. + */ +export function parseSignature(skillContent: string): SkillSignature | null { + // Match YAML frontmatter between --- markers + const frontmatterMatch = skillContent.match(/^---\s*\n([\s\S]*?)\n---/); + if (!frontmatterMatch) return null; + + const frontmatter = frontmatterMatch[1]!; + + // Find signature block in frontmatter (simple YAML parsing) + const sigMatch = frontmatter.match(/signature:\s*\n((?:\s{2,}.+\n?)*)/); + if (!sigMatch) return null; + + const sigBlock = sigMatch[1]!; + const fields: Record = {}; + + for (const line of sigBlock.split('\n')) { + const fieldMatch = line.match(/^\s+(\w+):\s*(.+?)\s*$/); + if (fieldMatch) { + fields[fieldMatch[1]!] = fieldMatch[2]!; + } + } + + if (!fields.algorithm || !fields.signer || !fields.content_hash || !fields.sig) { + return null; + } + + return { + algorithm: fields.algorithm, + signer: fields.signer, + content_hash: fields.content_hash, + signed_at: fields.signed_at || '', + sig: fields.sig, + kid: fields.kid, + }; +} + +/** + * Extract the content below the frontmatter (the part that's signed). + */ +export function extractSignedContent(skillContent: string): string { + const match = skillContent.match(/^---\s*\n[\s\S]*?\n---\s*\n([\s\S]*)$/); + return match ? match[1]!.trim() : skillContent.trim(); +} + +// ─── Hash Computation ─── + +/** + * Compute SHA-256 hash of the signed content. + */ +export function computeContentHash(content: string): string { + const hash = createHash('sha256').update(content, 'utf-8').digest('hex'); + return `sha256:${hash}`; +} + +// ─── Key Management ─── + +/** + * Get the cache file path for a signer domain. + */ +function getCachePath(signer: string): string { + // Sanitize signer domain for filesystem use + const safeName = signer.replace(/[^a-zA-Z0-9.-]/g, '_'); + return join(KEY_CACHE_DIR, `${safeName}.json`); +} + +/** + * Read cached public keys for a signer. + * Returns null if cache is missing or expired. + */ +async function readCachedKeys(signer: string): Promise { + try { + const cachePath = getCachePath(signer); + const content = await readFile(cachePath, 'utf-8'); + const cached: CachedKeys = JSON.parse(content); + + // Check TTL + const fetchedAt = new Date(cached.fetched_at).getTime(); + const expiresAt = fetchedAt + cached.ttl_seconds * 1000; + if (Date.now() > expiresAt) return null; + + return cached.keys; + } catch { + return null; + } +} + +/** + * Write public keys to cache. + */ +async function writeCachedKeys(signer: string, keys: PublicKeyEntry[]): Promise { + try { + await mkdir(KEY_CACHE_DIR, { recursive: true }); + const cached: CachedKeys = { + signer, + keys, + fetched_at: new Date().toISOString(), + ttl_seconds: KEY_CACHE_TTL_SECONDS, + }; + await writeFile(getCachePath(signer), JSON.stringify(cached, null, 2), 'utf-8'); + } catch { + // Cache write failure is non-fatal + } +} + +/** + * Fetch public keys from a signer's .well-known endpoint. + */ +export async function fetchPublicKeys(signer: string): Promise { + // Try HTTPS first, then HTTP for local development + const urls = [`https://${signer}${WELL_KNOWN_PATH}`, `http://${signer}${WELL_KNOWN_PATH}`]; + + for (const url of urls) { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + const response = await fetch(url, { + signal: controller.signal, + headers: { Accept: 'application/json' }, + }); + + clearTimeout(timeout); + + if (!response.ok) continue; + + const data = (await response.json()) as { keys: PublicKeyEntry[] }; + if (data.keys && Array.isArray(data.keys)) { + return data.keys; + } + } catch { + continue; + } + } + + throw new Error(`Could not fetch public keys from ${signer}`); +} + +/** + * Get public keys for a signer, using cache when available. + */ +export async function getPublicKeys(signer: string): Promise { + // Try cache first + const cached = await readCachedKeys(signer); + if (cached) return cached; + + // Fetch from network + const keys = await fetchPublicKeys(signer); + + // Cache the result + await writeCachedKeys(signer, keys); + + return keys; +} + +/** + * Find the matching key for a signature. + */ +function findMatchingKey(keys: PublicKeyEntry[], signature: SkillSignature): PublicKeyEntry | null { + // If kid is specified, match by kid + if (signature.kid) { + return keys.find((k) => k.kid === signature.kid) ?? null; + } + + // Otherwise, find any ed25519 key that hasn't expired + const now = Date.now(); + return ( + keys.find((k) => { + if (k.algorithm !== 'ed25519') return false; + if (k.expires_at && new Date(k.expires_at).getTime() < now) return false; + return true; + }) ?? null + ); +} + +// ─── Verification ─── + +/** + * Verify a skill's signature. + * + * @param skillContent - Full SKILL.md content including frontmatter + * @returns Verification result + */ +export async function verifySkillSignature(skillContent: string): Promise { + try { + // 1. Parse signature + const signature = parseSignature(skillContent); + if (!signature) { + return { status: 'no-signature' }; + } + + // 2. Check algorithm support + if (!SUPPORTED_ALGORITHMS.includes(signature.algorithm)) { + return { status: 'unsupported-algorithm', algorithm: signature.algorithm }; + } + + // 3. Verify content hash + const content = extractSignedContent(skillContent); + const actualHash = computeContentHash(content); + + if (actualHash !== signature.content_hash) { + return { + status: 'hash-mismatch', + expected: signature.content_hash, + actual: actualHash, + }; + } + + // 4. Fetch public key + let keys: PublicKeyEntry[]; + try { + keys = await getPublicKeys(signature.signer); + } catch (err) { + return { + status: 'key-fetch-failed', + signer: signature.signer, + error: err instanceof Error ? err.message : String(err), + }; + } + + // 5. Find matching key + const key = findMatchingKey(keys, signature); + if (!key) { + return { + status: 'invalid-signature', + reason: signature.kid + ? `No key found with kid="${signature.kid}"` + : 'No valid ed25519 key found for signer', + }; + } + + // 6. Verify ed25519 signature + const sigBuffer = Buffer.from(signature.sig, 'base64'); + const contentBuffer = Buffer.from(content, 'utf-8'); + + // Node.js ed25519 verification + const isValid = cryptoVerify( + null, // ed25519 doesn't use a separate hash algorithm + contentBuffer, + { + key: key.public_key, + format: 'pem', + }, + sigBuffer + ); + + if (!isValid) { + return { + status: 'invalid-signature', + reason: 'Signature verification failed', + }; + } + + return { + status: 'verified', + signer: signature.signer, + signed_at: signature.signed_at, + }; + } catch (err) { + return { + status: 'error', + error: err instanceof Error ? err.message : String(err), + }; + } +} + +// ─── Display Helpers ─── + +/** + * Format a verification result for terminal display. + */ +export function formatVerificationResult(result: VerificationResult): string { + switch (result.status) { + case 'verified': + return `✓ Verified (signed by ${result.signer}${result.signed_at ? `, ${result.signed_at}` : ''})`; + case 'no-signature': + return '○ No signature'; + case 'invalid-signature': + return `✗ Invalid signature: ${result.reason}`; + case 'hash-mismatch': + return `✗ Content tampered (hash mismatch)`; + case 'key-fetch-failed': + return `⚠ Could not verify (failed to fetch keys from ${result.signer})`; + case 'unsupported-algorithm': + return `⚠ Unsupported algorithm: ${result.algorithm}`; + case 'error': + return `⚠ Verification error: ${result.error}`; + } +} diff --git a/tests/signature.test.ts b/tests/signature.test.ts new file mode 100644 index 00000000..900940e2 --- /dev/null +++ b/tests/signature.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest'; +import { + parseSignature, + extractSignedContent, + computeContentHash, + formatVerificationResult, + type VerificationResult, +} from '../src/signature.ts'; + +const SKILL_WITH_SIGNATURE = `--- +name: test-skill +description: A test skill + +signature: + algorithm: ed25519-sha256 + signer: skills.sh + content_hash: sha256:abc123 + signed_at: 2026-03-14T10:00:00Z + sig: dGVzdHNpZw== + kid: key-2026-01 +--- + +# Test Skill + +Do something awesome. +`; + +const SKILL_WITHOUT_SIGNATURE = `--- +name: test-skill +description: A test skill +--- + +# Test Skill + +Do something awesome. +`; + +const SKILL_NO_FRONTMATTER = `# Test Skill + +Do something awesome. +`; + +describe('parseSignature', () => { + it('parses a valid signature block', () => { + const sig = parseSignature(SKILL_WITH_SIGNATURE); + expect(sig).not.toBeNull(); + expect(sig!.algorithm).toBe('ed25519-sha256'); + expect(sig!.signer).toBe('skills.sh'); + expect(sig!.content_hash).toBe('sha256:abc123'); + expect(sig!.signed_at).toBe('2026-03-14T10:00:00Z'); + expect(sig!.sig).toBe('dGVzdHNpZw=='); + expect(sig!.kid).toBe('key-2026-01'); + }); + + it('returns null when no signature block', () => { + expect(parseSignature(SKILL_WITHOUT_SIGNATURE)).toBeNull(); + }); + + it('returns null when no frontmatter', () => { + expect(parseSignature(SKILL_NO_FRONTMATTER)).toBeNull(); + }); +}); + +describe('extractSignedContent', () => { + it('extracts content below frontmatter', () => { + const content = extractSignedContent(SKILL_WITH_SIGNATURE); + expect(content).toBe('# Test Skill\n\nDo something awesome.'); + }); + + it('returns full content when no frontmatter', () => { + const content = extractSignedContent(SKILL_NO_FRONTMATTER); + expect(content).toBe('# Test Skill\n\nDo something awesome.'); + }); +}); + +describe('computeContentHash', () => { + it('computes sha256 hash with prefix', () => { + const hash = computeContentHash('hello world'); + expect(hash).toMatch(/^sha256:[a-f0-9]{64}$/); + }); + + it('produces deterministic hashes', () => { + const a = computeContentHash('test content'); + const b = computeContentHash('test content'); + expect(a).toBe(b); + }); + + it('produces different hashes for different content', () => { + const a = computeContentHash('content a'); + const b = computeContentHash('content b'); + expect(a).not.toBe(b); + }); +}); + +describe('formatVerificationResult', () => { + it('formats verified result', () => { + const result: VerificationResult = { + status: 'verified', + signer: 'skills.sh', + signed_at: '2026-03-14T10:00:00Z', + }; + expect(formatVerificationResult(result)).toContain('Verified'); + expect(formatVerificationResult(result)).toContain('skills.sh'); + }); + + it('formats no-signature result', () => { + const result: VerificationResult = { status: 'no-signature' }; + expect(formatVerificationResult(result)).toContain('No signature'); + }); + + it('formats hash-mismatch result', () => { + const result: VerificationResult = { + status: 'hash-mismatch', + expected: 'sha256:aaa', + actual: 'sha256:bbb', + }; + expect(formatVerificationResult(result)).toContain('tampered'); + }); + + it('formats invalid-signature result', () => { + const result: VerificationResult = { + status: 'invalid-signature', + reason: 'bad sig', + }; + expect(formatVerificationResult(result)).toContain('Invalid'); + }); + + it('formats key-fetch-failed result', () => { + const result: VerificationResult = { + status: 'key-fetch-failed', + signer: 'example.com', + error: 'timeout', + }; + expect(formatVerificationResult(result)).toContain('example.com'); + }); +});