// src/proof/attestor.ts // // Adapter over @reclaimprotocol/attestor-core's createClaimOnAttestor. // // responseMatches are the regex/contains checks the attestor runs on the // revealed slice; jsonPath/xPath live on responseRedactions (what the prover // reveals), never on responseMatches. import { bytesToHex } from '@noble/hashes/utils.js' import { createClaimOnAttestor, type Logger, type proto, } from '@reclaimprotocol/attestor-core' // Side-effect: make external domhandler nodes DOM-like so attestor-core's // bundled `xpath` accepts them (see patch-domhandler.ts). Must load before any // proof runs its response-redaction extraction. import './patch-domhandler.ts' import { agentTraceEnabled } from '../consts.ts' import { resolveEthKey } from '../mcp/tools/authenticate/eth-key.ts' import type { ReclaimProvider } from '../provider/schema.ts' import { installResponseDump } from './debug-response-dump.ts' import { buildHttpParams, ensureAttestorCrypto, parseClaim, resolveAttestorUrl } from './proof-core.ts' import { ensureZkFiles } from './zk-files.ts' export interface AttestorLogEntry { level: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' msg: string ctx?: Record } /** The attestor's signed claim data + signature, for building the exact legacy * Proof a Consumer verifies against the attestor's key. Populated when * `includeAttestorClaim` is set (the app's verification runner needs it; the * MCP `run_proof` tool ignores it). */ export interface AttestorClaim { /** ProviderClaimData: provider, parameters, context (JSON string), owner, * timestampS, epoch, identifier. */ claim: Record /** Hex (0x) attestor ETH address that signed the claim (the nested `kid`). */ attestorAddress: string /** Hex (0x) attestor claim signature (EIP-191 over the claim sign-data). */ claimSignature: string } /** Options for {@link runAttestorProof}. */ export interface RunAttestorProofOptions { attestorUrl?: string /** Optional session-bound authorization generated by the trusted backend. */ authRequest?: proto.AuthenticationRequest /** When resolving the key from env/.env/file, the address the caller expects * to sign with (sanity check). Ignored when `ownerPrivateKey` is given. */ ownerAddress?: string /** Sign with this key directly (for example, the app or VC's ephemeral * per-session key) instead of resolving from env/.env/file. */ ownerPrivateKey?: string /** ZK engine for `createClaimOnAttestor`. Defaults to `'stwo'` — a pure-WASM * prover that runs everywhere (no native shared library, so it works on * Windows where `'gnark'` fails to load its `.dll`). Server-side paths that * want gnark's speed pin it explicitly (for example, the verify runner). */ zkEngine?: 'gnark' | 'snarkjs' | 'stwo' /** Extract the structured {@link AttestorClaim} (claim + attestor signature) * onto the result for legacy Proof construction. */ includeAttestorClaim?: boolean } export interface ProofResult { proof: unknown extractedValue: string extractedParameters: Record identifier?: string owner?: string verified: boolean error?: string /** Structured claim + signature for legacy Proof verification. Set only when * `includeAttestorClaim` was requested and the claim carries signatures. */ attestorClaim?: AttestorClaim /** Set to `'missing-owner-key'` when no credential was available to * sign with — distinguishes this recoverable case from other proof * errors so the skill can point the dev at `issue_credentials` / * `list_cached_credentials` rather than guessing. */ errorKind?: 'missing-owner-key' /** Pino-style log entries captured from the attestor SDK during the * proof attempt. Full stream on the result; the MCP layer trims it * for the LLM-visible response and dumps the whole thing to disk * via `runLogPath`. */ attestorLogs?: AttestorLogEntry[] /** The exact `params` object passed to `createClaimOnAttestor` for * this attempt. Only included on error so the LLM/dev can confirm * what the attestor actually saw vs what they thought they sent * (catches mutation bugs / template-substitution surprises). Never * carries the captured secrets — those live in `secretParams` which * is not echoed here. */ sentParams?: unknown } /** Trim a captured log stream to the last N entries — for shrinking the * LLM-visible response while the full stream stays in the on-disk dump. */ export function tailLogs( entries: AttestorLogEntry[], n = LOG_TAIL_LINES, ): AttestorLogEntry[] { if(entries.length <= n) { return entries } return entries.slice(entries.length - n) } /** Filter to just warn/error/fatal — for the LLM-visible response on * successful proofs, where chatty trace/info would just waste context. */ export function warnsAndErrors( entries: AttestorLogEntry[], ): AttestorLogEntry[] { return entries.filter( (e) => e.level === 'warn' || e.level === 'error' || e.level === 'fatal', ) } /** How many log entries to keep from the attestor SDK on an error. The * early ones describe TLS setup; the late ones (right before the * throw) usually pinpoint the actual failure. Keeping the tail covers * the latter; the cap stops a chatty session from blowing the LLM's * context window. */ const LOG_TAIL_LINES = 80 /** Build a pino-compatible capturing logger. Each `trace`/`debug`/… * call records an entry; `child` returns a derived capturer bound to * the parent's entries array so nested SDK loggers all flow into one * ordered list. */ function makeCaptureLogger(): { logger: unknown entries: AttestorLogEntry[] } { const entries: AttestorLogEntry[] = [] const make = (baseCtx: Record): unknown => { const log = (level: AttestorLogEntry['level']) => (...args: unknown[]) => { if(args.length === 0) { return } let msg = '' let ctx: Record | undefined if(args[0] instanceof Error) { // pino-style `logger.error(err, 'msg')` — Error objects // don't JSON-serialize on their own, so extract the // useful fields manually. const err = args[0] ctx = { error: { name: err.name, message: err.message, stack: err.stack }, } msg = typeof args[1] === 'string' ? args[1] : err.message } else if(typeof args[0] === 'string') { msg = args[0] } else if(args[0] && typeof args[0] === 'object') { ctx = args[0] as Record msg = typeof args[1] === 'string' ? args[1] : '' } const merged = Object.keys(baseCtx).length || ctx ? { ...baseCtx, ...(ctx ?? {}) } : undefined const entry: AttestorLogEntry = { level, msg } if(merged) { entry.ctx = merged } entries.push(entry) } return { trace: log('trace'), debug: log('debug'), info: log('info'), warn: log('warn'), error: log('error'), fatal: log('fatal'), child: (childCtx: Record) => { return make({ ...baseCtx, ...childCtx }) }, } } return { logger: make({}), entries } } // --------------------------------------------------------------------------- // Main entry point // --------------------------------------------------------------------------- export async function runAttestorProof( provider: ReclaimProvider, secrets: Record, opts: RunAttestorProofOptions = {}, ): Promise { // Sign with the caller-supplied key (the app/VC's ephemeral per-session // key) when given; otherwise resolve env/.env/file. `ownerAddress` is a // sanity check on the resolved key (ignored when a key is supplied). let ownerPrivateKey: string if(opts.ownerPrivateKey) { ownerPrivateKey = opts.ownerPrivateKey } else { const resolved = resolveEthKey() if(!resolved) { return { proof: undefined, extractedValue: '', extractedParameters: {}, verified: false, error: 'No eth proof-owner key available (no RECLAIM_PRIVATE_KEY in env, ' + '.env, or RECLAIM_PRIVATE_KEY_FILE). Call resolve_owner_key, then ' + 'issue_credentials (generate) or import_credentials (provide one), ' + 'and retry run_proof.', errorKind: 'missing-owner-key', } } if( opts.ownerAddress && opts.ownerAddress.toLowerCase() !== resolved.address ) { return { proof: undefined, extractedValue: '', extractedParameters: {}, verified: false, error: `Requested ownerAddress "${opts.ownerAddress}" does not match ` + `the resolved eth key (${resolved.address}, source: ` + `${resolved.source}). ` + 'Call resolve_owner_key to get the current address, or update the ' + 'configured key.', errorKind: 'missing-owner-key', } } ownerPrivateKey = resolved.privateKey } try { // Init the TLS crypto backend (idempotent), then fetch ZK circuits // lazily on the first proof (not at install time). ensureAttestorCrypto() await ensureZkFiles() const params = buildHttpParams(provider) // Diagnostic logger capture is gated behind RECLAIM_AGENT_TRACE so the // happy path doesn't pay the cost (the attestor SDK emits a *lot* of // trace/debug entries during ZK setup). Flip RECLAIM_AGENT_TRACE=1 to // turn it back on when something's broken; logs flow into the proof // response and into the on-disk proof-runs dump. const traceEnabled = agentTraceEnabled() const capture = traceEnabled ? makeCaptureLogger() : undefined const attestorLogs = capture?.entries // Debug (RECLAIM_AGENT_DUMP_RESPONSE): dump the exact response the // attestor validates against, so "Response does not contain X" is // diagnosable against the attestor's real server-side fetch. installResponseDump() let claim: unknown try { claim = await createClaimOnAttestor({ name: 'http', params, secretParams: { headers: secrets, }, ownerPrivateKey, client: { url: resolveAttestorUrl(opts.attestorUrl), ...(opts.authRequest ? { authRequest: opts.authRequest } : {}), }, zkEngine: opts.zkEngine ?? 'stwo', ...(capture !== undefined && { logger: capture.logger as Logger }), }) } catch(err) { const errorResult: ProofResult = { proof: undefined, extractedValue: '', extractedParameters: {}, verified: false, error: err instanceof Error ? err.message : String(err), sentParams: params, } if(attestorLogs !== undefined) { errorResult.attestorLogs = attestorLogs } return errorResult } const { extractedValue, extractedParameters, identifier, owner, claimData } = parseClaim(claim) const result: ProofResult = { proof: claim, extractedValue, extractedParameters, verified: true, } if(identifier !== undefined) { result.identifier = identifier } if(owner !== undefined) { result.owner = owner } // Structured claim + signature so the result JWS can carry the exact // legacy Proof the Consumer verifies independently. if(opts.includeAttestorClaim) { const sigs = (claim as { signatures?: { attestorAddress?: unknown, claimSignature?: unknown } }).signatures if( claimData && typeof sigs?.attestorAddress === 'string' && sigs.claimSignature instanceof Uint8Array ) { result.attestorClaim = { claim: claimData, attestorAddress: sigs.attestorAddress, claimSignature: '0x' + bytesToHex(sigs.claimSignature), } } } // Only set when RECLAIM_AGENT_TRACE=1. Full stream — the MCP layer // trims for the LLM-visible response; the on-disk dump gets it all. if(attestorLogs !== undefined && attestorLogs.length > 0) { result.attestorLogs = attestorLogs } return result } catch(err) { return { proof: undefined, extractedValue: '', extractedParameters: {}, verified: false, error: err instanceof Error ? err.message : String(err), } } }