UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

187 lines 7.3 kB
// JWS Compact (RFC 7515) verification with EdDSA / Ed25519 support. // TypeScript port of agentic-sandbox-conformance/internal/spec/jws.go. // // The Rust signer (`agent_card::sign_agent_card` in agentic-sandbox) produces // non-detached JWS: the middle segment IS the base64url-encoded canonical // payload bytes. `verifyJwsCompact` cross-checks that against an // independently-canonicalized `expectedPayload` when provided. import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto'; import { canonicalizeJson } from './jcs.js'; export function findJwkByKid(set, kid) { if (!set) return null; for (const k of set.keys) if (k.kid === kid) return k; return null; } // RFC 7638 thumbprint for OKP keys: SHA-256 over the canonical // { crv, kty, x } object, base64url-encoded. export function jwkThumbprint(jwk) { if (jwk.kty !== 'OKP') { throw new Error(`thumbprint: unsupported kty ${jwk.kty}`); } if (!jwk.crv || !jwk.x) throw new Error('thumbprint: OKP key missing crv/x'); const canon = canonicalizeJson({ crv: jwk.crv, kty: jwk.kty, x: jwk.x }); const sum = createHash('sha256').update(canon).digest(); return base64UrlEncode(sum); } export function loadJwkSet(json) { const parsed = JSON.parse(json); if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.keys)) { throw new Error('JWKS: missing keys array'); } const set = parsed; if (set.keys.length === 0) throw new Error('JWKS contains no keys'); return set; } // VerifyJwsCompact verifies a JWS Compact serialization. // // If `expectedPayload` is provided, the embedded payload bytes must match it // (constant-time compare). This is how the AgentCard verifier guarantees that // the JCS-canonicalized bytes the harness computed match what was signed. // // Throws on any verification failure with a descriptive message. export function verifyJwsCompact(jws, expectedPayload, jwk) { if (!jwk) throw new Error('verifyJwsCompact: nil JWK'); const parts = jws.split('.'); if (parts.length !== 3) { throw new Error(`verifyJwsCompact: expected 3 segments, got ${parts.length}`); } const [headerB64, payloadB64, sigB64] = parts; let header; try { const headerRaw = base64UrlDecode(headerB64); header = JSON.parse(new TextDecoder('utf-8').decode(headerRaw)); } catch (err) { throw new Error(`verifyJwsCompact: parse header: ${err.message}`); } let payload; let sig; try { payload = base64UrlDecode(payloadB64); sig = base64UrlDecode(sigB64); } catch (err) { throw new Error(`verifyJwsCompact: decode segments: ${err.message}`); } if (expectedPayload !== null) { if (!constantTimeEqual(payload, expectedPayload)) { throw new Error(`JWS payload does not match expected canonical bytes (got ${payload.length} bytes, want ${expectedPayload.length})`); } } const signingInput = new TextEncoder().encode(headerB64 + '.' + payloadB64); if (header.alg !== 'EdDSA') { throw new Error(`unsupported JWS alg ${header.alg} (only EdDSA supported)`); } if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') { throw new Error(`EdDSA: JWK is not OKP/Ed25519 (kty=${jwk.kty} crv=${jwk.crv ?? '?'})`); } if (!jwk.x) throw new Error('EdDSA: JWK missing x'); const pubRaw = base64UrlDecode(jwk.x); if (pubRaw.length !== 32) { throw new Error(`EdDSA: public key wrong length ${pubRaw.length} (want 32)`); } // Node's crypto requires SPKI-wrapped Ed25519 keys. Build one from the raw // 32-byte X coordinate. const spki = wrapEd25519Spki(pubRaw); const key = createPublicKey({ key: spki, format: 'der', type: 'spki' }); const ok = cryptoVerify(null, signingInput, key, sig); if (!ok) throw new Error('EdDSA signature verification failed'); } // VerifyAgentCardSignature is the high-level helper: extracts signatures[0], // strips the `signatures` field from a deep clone, JCS-canonicalizes the rest, // looks up the JWK by kid, and verifies via verifyJwsCompact. // // `cardJson` is the raw JSON bytes (or string) of the AgentCard as served. export function verifyAgentCardSignature(cardJson, jwks) { const text = typeof cardJson === 'string' ? cardJson : new TextDecoder('utf-8').decode(cardJson); if (!text || text.length === 0) { throw new Error('verifyAgentCardSignature: empty card body'); } let card; try { card = JSON.parse(text); } catch (err) { throw new Error(`parse card JSON: ${err.message}`); } const sigsRaw = card['signatures']; if (sigsRaw === undefined) throw new Error("agent card has no 'signatures' field"); if (!Array.isArray(sigsRaw) || sigsRaw.length === 0) { throw new Error("agent card 'signatures' is empty or not an array"); } const first = sigsRaw[0]; if (!first || typeof first !== 'object') { throw new Error('signatures[0] is not an object'); } const compact = first['signature']; if (typeof compact !== 'string' || !compact) { throw new Error('signatures[0].signature missing or not a string'); } const hdr = first['header']; if (!hdr || typeof hdr !== 'object' || Array.isArray(hdr)) { throw new Error('signatures[0].header missing'); } const kid = hdr['kid']; if (typeof kid !== 'string' || !kid) { throw new Error('signatures[0].header.kid missing'); } const jwk = findJwkByKid(jwks, kid); if (!jwk) throw new Error(`no JWK matches kid=${kid}`); // Build the unsigned form. const unsigned = {}; for (const [k, v] of Object.entries(card)) { if (k === 'signatures') continue; unsigned[k] = v; } const canonical = canonicalizeJson(unsigned); verifyJwsCompact(compact, canonical, jwk); } // ---------- internal helpers ---------- function base64UrlDecode(input) { // Convert base64url -> base64 and pad. const b64 = input.replace(/-/g, '+').replace(/_/g, '/'); const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4)); return new Uint8Array(Buffer.from(b64 + pad, 'base64')); } function base64UrlEncode(bytes) { return Buffer.from(bytes) .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } function constantTimeEqual(a, b) { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) { diff |= a[i] ^ b[i]; } return diff === 0; } // SPKI wrapper for raw 32-byte Ed25519 public keys. // SubjectPublicKeyInfo ::= SEQUENCE { // algorithm AlgorithmIdentifier (id-Ed25519 = 1.3.101.112), // subjectPublicKey BIT STRING (raw 32 bytes), // } // Pre-computed prefix (12 bytes): 30 2a 30 05 06 03 2b 65 70 03 21 00 const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); function wrapEd25519Spki(raw) { if (raw.length !== 32) throw new Error('Ed25519 raw key must be 32 bytes'); return Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(raw)]); } //# sourceMappingURL=jws.js.map