Protocol & specs

Spec: agent signer v1

Code: packages/agent (@txpass/agent: library, txpass-agent CLI, MCP stdio server) · facilitator addition GET /v1/delegations?role=delegate (§8) · Imprint contract ../adopters/imprint-contract.md.

1. What it is

The product pitch for non-BSV developers is let AI agents call paid APIs without ever holding or leaking an API key. @txpass/agent is the piece that runs next to the agent:

  • It generates and holds agent key A on the user's machine. Txpass never sees it.
  • A human (identity I) delegates to A with their own wallet (delegation v1 cert). The human's wallet key never touches the agent.
  • The agent mints short-lived delegated session JWTs (sub = I, act.sub = A, dlg = cert id) over BRC-103 signed by A, keeps them in memory only, and injects them into requests itself.
  • It exposes MCP tools so any MCP client (Claude Desktop, Cursor, …) can call the paid API without the model ever seeing a token or key.

Non-custodial, honestly: there is no Txpass-held key and no Txpass-side agent account. What the agent holds is a delegable, revocable, time-boxed, scoped capability bound to a key on the user's disk. The user controls both ends: their wallet signs the delegation, their machine holds A. Abstraction stops at that user-controlled signer (no custodial fallback).

2. Threat model

Actors and trust

PartyHoldsTrusted for
Human I (wallet: Yours / BRC-100)identity key I, entitlementsigning and revoking delegations
Agent host (user's machine)agent key A file, cert, in-memory session JWTrunning txpass-agent honestly
Model / MCP clientnothing secretnothing: treated as untrusted input
Facilitator (txpass.app)session signing key, delegation + revocation storeminting sessions, recording revocations
Publisher (e.g. Imprint)JWKS cacheverifying JWTs, enforcing its own quota

What the agent process can do

  • Mint delegated sessions for exactly the certs that name A, within each cert's limits: one product, one facilitator audience, optional route scope, optional receipt/plan pins, max_session_ttl ≤ 3600 s, cert lifetime ≤ 90 days.
  • Spend I's quota for that product (one unit per publisher call, publisher-defined), up to I's plan limits and the advisory quota_share.
  • Act as I toward the publisher (sub = I), visibly marked as delegated (act.sub = A, dlg).

What it cannot do

  • Sign as I, create or widen delegations, or re-delegate (v1 has no chains; the facilitator only accepts certs signed by cert.issuer).
  • Pay, spend BSV, create entitlements (delegation never auto-enrolls), revoke or touch receipts.
  • Use a cert for another facilitator (exact aud), another product, or (at publishers that enforce scope) other routes.
  • Outlive a revocation for new sessions; already-minted JWTs live ≤ max_session_ttl at local-JWKS publishers (§7).

What the model (via MCP) can and cannot do

The model can only ask the agent to make HTTP requests that pass the guardrails (§6): allowlisted origins, in-scope method/path, JSON bodies, capped sizes, no redirects. It cannot read the token, the key, or the key file through any tool; cannot set Authorization, Cookie, Host, X-API-Key or x-bsv-*; cannot point the token at another host; and every tool result is redacted (§6.4). A prompt-injected model can still misuse the capability within those bounds (e.g. write junk records to Imprint on I's quota). That is inherent to handing an agent any capability; the bounds, the per-minute brake and revocation are the mitigations.

If the machine is compromised

Attacker getsConsequenceBound
Read access to the home dir, key not encryptedA and the cert: can mint sessions as A for I until the cert expires or I revokescert scope, product, aud, expiry (default 7 d); revocation stops it at once for new sessions
Same, key encrypted (--encrypt)ciphertext only, unless the passphrase is also on disk/in the MCP configscrypt N=2¹⁵ offline guessing
Code execution as the user while the agent runseverything the agent can do, plus the in-memory JWTsame as row 1; the JWT alone is good for ≤ 15 min
The in-memory JWT only (e.g. a crash dump)publisher calls as I until exp≤ max_session_ttl (default 900 s); introspecting publishers stop at revocation
The cert onlynothing: a cert is public and useless without A–

Never leaks: the human's wallet key (never on the agent machine), anything on the Txpass side (Txpass never has A), other users' data. Recovery: the human revokes (POST /v1/delegations/:id/revoke); the user runs txpass-agent init --force for a new A and the human delegates again.

3. Key storage

  • txpass-agent init generates A with PrivateKey.fromRandom() (@bsv/sdk, CSPRNG) into ~/.txpass-agent/agent-key.json (override: --home / TXPASS_AGENT_HOME). Directory 0700, file 0600, written via temp file + rename (never world-readable in between). Refuses to overwrite without --force.
  • Format txpass-agent-key/v1: { v, pubkey, created_at, enc: "none", private_key_hex } or, with --encrypt, { …, enc: "scrypt-aes-256-gcm", kdf: { name: scrypt, N: 32768, r: 8, p: 1, salt }, iv, tag, ciphertext }. AES-GCM AAD = txpass-agent-key/v1:<pubkey>, and the decrypted key must match pubkey.
  • Passphrase comes from TXPASS_AGENT_PASSPHRASE or TXPASS_AGENT_PASSPHRASE_FILE (prefer the file in MCP configs, since client config files are plaintext). No interactive prompt (MCP servers have no TTY).
  • Load refuses a key file with any group/other permission bits (POSIX; like ssh). No check on Windows.
  • The private key is never printed, logged, returned by a tool, or sent anywhere. Only A's public key and BRC-103 signatures leave the machine.
  • Decision: no OS-keychain integration in v1 (portable, dependency-free, auditable). Encryption at rest is opt-in because an unattended MCP server needs the passphrase next to it anyway; the honest protection is file permissions plus short-lived, revocable, scoped certs.

4. Enrollment

  1. txpass-agent init [--facilitator URL] [--product SLUG] [--api-base URL]... [--issuer I] [--encrypt] generates A, writes config.json (no secrets) and prints A plus the exact delegation parameters: delegate = A, product, aud (read from GET {facilitator}/v1/protocol → delegation.audience; older facilitators: GET /health), suggested narrow scope and short expiry.
  2. The human signs the cert with their wallet. Today the only UI is the local dev page (pnpm dev:delegation-test, with the production relay); see §11 Q1.
  3. The agent gets the cert, which is public data:
    • txpass-agent delegation import <file|-> (raw cert, { delegation } or a dev-page report), or
    • txpass-agent delegation fetch --issuer <I> if the human registered it with POST /v1/delegations (uses §8).
  4. Local validation before saving: strict v1 shape, issuer signature (public, counterparty anyone), time window (a not-yet-valid cert is accepted with a note), delegate = A, aud = the facilitator's advertised audience (skippable with --offline), product = configured product.
  5. Issuer pin (decision): the first imported cert pins I in config.json (expected_issuer; or set it up front with --issuer). Later imports/fetches from another issuer are refused without --allow-new-issuer. Reason: anyone can sign a cert naming A, and silently accepting one would make the agent act (and write data) in a stranger's account.
  6. txpass-agent config --api-base https://<publisher> sets where the token may be sent (§6.1). txpass-agent status --probe proves the whole chain. txpass-agent mcp-config prints the MCP client entry.

One home = one agent key = one current cert (one product). Several products or identities → several homes (TXPASS_AGENT_HOME).

5. Session lifecycle

  • Mint: POST {facilitator}/v1/session { product, delegation, ttlSeconds? } over @bsv/sdk AuthFetch with a ProtoWallet(A), via @txpass/client requestSession (which also works around the SDK 2.7.1 non-payment-402 throw). Timeout 15 s.
  • Check the grant before using it: sub = cert.issuer, act.sub = A, dlg = cert.id, iss = cert.aud, and a full verifySessionJwt against {facilitator}/v1/session/jwks (createSessionJwks: cached, refetches once on an unknown kid). A mismatch is session_invalid (wrong facilitator URL, or a hostile one).
  • Cache in memory only. Never written to disk, never returned by a tool or the CLI. Concurrent callers share one in-flight mint.
  • Refresh before exp: at exp − clamp(20% of TTL, 5 s, 60 s).
  • Revocation check (decision, revised 2026-09-26): a cached token is re-checked with POST /v1/session/introspect

    • before every write (any method other than GET/HEAD/OPTIONS): write_revocation_check_seconds, default 0. The write is sent only if introspect answers active: true. If the facilitator cannot answer (network error, non-2xx, 429), the token is dropped and re-minted before sending; the mint is authoritative (a revoked delegation gets 403 delegation_revoked), so writes fail closed;
    • for reads at most every revocation_check_seconds (default 30 s). Introspect failures fail open for reads (the publisher still decides; a read spends no write quota).
    • active: false, reason: "delegation_revoked" | "delegation_expired" (facilitator ≥ agent-signer v1) is terminal and sticky at once, with no extra mint; a bare active: false (older facilitators) drops the token and re-mints, which surfaces delegation_revoked.
    • SessionManager.checkNow() forces a check regardless of the cache window (test harnesses use it after the human revokes) and returns active | inactive | unknown | no_session.

    Why: in an early production test, a write retried after the revoke was sent and accepted (HTTP 202). The facilitator side was correct: introspect already reported revoked delegations inactive. The agent side had two gaps. It failed open when introspect did not answer, and nothing forced a check right before a write. A write costs quota, and a mainnet record cannot be taken back, so it is worth one introspect round-trip (about 100 ms; the introspect bucket is 120/min/IP, and the per-minute brake is 30). Reads stay cached. This stops this agent before its next write, even at local-JWKS publishers. It is cooperative (a compromised agent ignores it), which is why §7's window still applies.

  • Publisher 401 (expired, unknown_kid from a stale publisher JWKS, revoked at an introspecting publisher): drop the token, re-mint once, retry once. A second 401 is returned with a hint (unknown_kid → publisher must refetch JWKS; otherwise the publisher may reject delegated tokens).
  • Error mapping (all AgentError { error, message, hint?, redelegate?, detail? }, no secrets):
Facilitator answerAgent errorredelegateSticky
403 delegation_revokeddelegation_revokedyesyes
403 delegation_expired / ttl_exhausted, or expired locallydelegation_expiredyesyes
403 delegation_audience_mismatchaudience_mismatchyesyes
other delegation_* (bad sig, malformed, delegate/product mismatch, scope invalid)delegation_invalidyesyes
403 delegation_not_yet_validdelegation_not_yet_validnono
403 delegation_not_alloweddelegation_not_allowed (paid-only product)nono
402 payment_requiredissuer_no_entitlement (renew; no re-delegation needed)nono
429 / handshake 429facilitator_rate_limitednono
network / timeoutfacilitator_unreachablenono

Every redelegate error carries the same hint: ask the human to sign a new delegation for agent key A, then txpass-agent delegation import (or fetch); the MCP server picks it up without a restart. Sticky = the agent stops asking the facilitator for that exact cert (id + sig) until a different cert is present. delegation.json is re-read on change, so re-delegation needs no restart.

6. Guardrails

6.1 Host allowlist

The delegation cert names the facilitator (aud) and the product, not the publisher's host, so the host half of the allowlist cannot come from the cert. Decision: api_bases in config.json (or TXPASS_AGENT_API_BASES), set by the user, e.g. https://imprintai.link. A base is a bare origin (scheme://host[:port]): a value with a path, query or fragment is refused (config_invalid, naming the flag and the origin to use). Silently keeping https://host/beta/wallet narrowed matching in confusing ways, and routes are the cert scope's job. Exact origin match (scheme, host, port); no wildcards. txpass-agent config --api-base <origin> repairs an older config.json that stored a path. With no base configured the agent sends its token nowhere. The facilitator is not implicitly allowed. See §11 Q2 for binding hosts cryptographically later.

6.2 Route scope

Method + path must match the cert scope, or the product default scope from @txpass/protocol when the cert has none (:param matches one segment, scopeAllows from @txpass/verify). An unknown product without cert scope has no route caveat (host allowlist only; shown in txpass_status as scope_source: none). enforce_scope: false disables it (not recommended).

6.3 Request/response limits

https only (http only for loopback, or allow_insecure_http); no URL credentials; no encoded . or /, and no `, in paths; methods GET/POST/PUT/PATCH/DELETE/HEAD; caller headers may not include authorization, proxy-, cookie, host, x-api-key, api-key, x-bsv-or hop-by-hop headers (≤ 32 headers / 8 KiB). JSON body ≤ 64 KiB. **Redirects are never followed** (returned as-is) so a 3xx cannot carry the token elsewhere. Response read ≤ 256 KiB (then truncated and flagged); binary bodies are summarised, not returned. Only a small allowlist of response headers is returned (neverSet-Cookie). Timeouts: 30 s per publisher call, 15 s per facilitator call. Local brake: 30 publisher calls per rolling minute. All tunable in config.json limits`.

6.4 Redaction

Every MCP result, CLI output and error passes a Redactor that replaces (1) every token this process minted, exactly, (2) any JWT-shaped string, and (3) Bearer <x>. The MCP layer then refuses to emit any text that still contains a minted token. So a publisher that echoes Authorization back (the e2e mock does) cannot leak it through the agent.

7. Revocation behaviour

  • The human revokes with POST /v1/delegations/:id/revoke (AuthFetch as I; dev page section 6 today).
  • New sessions: refused at once (delegation_revoked); the agent turns that into a sticky re-delegate error, and txpass_status shows state: revoked.
  • This agent's cached session: checked before every write (default), so no write goes out after the facilitator has the revoke; reads may reuse the token for up to revocation_check_seconds (default 30 s). Introspect answers { active: false, reason: "delegation_revoked" } for sessions of a revoked delegation, and reason: "delegation_expired" once the recorded cert window has passed.
  • Worst case (compromised agent that ignores the check): a token minted before the revoke works at local-JWKS publishers until exp ≤ max_session_ttl (default 900 s). Publishers that call introspect see revocation immediately. Human can lower max_session_ttl per cert.
  • Entitlement lapse (receipt revoked or expired) is not a revocation: the agent reports issuer_no_entitlement, and the same cert works again once I renews.

8. Facilitator addition: GET /v1/delegations?role=delegate

Gap found: delegation v1 could list delegations only by issuer, so an agent had no way to discover a cert the human registered. Added (backwards compatible, tested):

  • GET /v1/delegations?role=delegate[&issuer=<I>], AuthFetch as A → { role: "delegate", delegate, delegations: [{ id, issuer, product, not_before, expires_at, created_at, last_used_at, status, revoked_at, cert }], limit: 50 }, newest first.
  • No role, or role=issuer: unchanged response, byte-for-byte (test pins the member list). Unknown role or malformed issuer → 400 invalid_request.
  • Store: DelegationStore.listByDelegate(delegate, { limit, issuer? }), memory + Postgres (uses the existing delegations_delegate_idx; no schema change).
  • Anyone can sign a cert naming A and record it (as its issuer), so the list may contain strangers' certs and could be padded. Hence the issuer filter and the client-side issuer pin (§4.5); the agent re-verifies whatever it picks.
  • Old facilitators ignore role and return the issuer listing; the agent detects the missing role member and says "import the cert file instead".

9. MCP tool surface

Server txpass-agent 0.1.0, stdio (txpass-agent mcp). stdout carries only MCP frames; logs go to stderr.

ToolInputOutputNotes
txpass_status{ probe?: boolean }agent pubkey A (public), key file path + encrypted flag, facilitator, product; delegation state (missing / active / expired / not_yet_valid / invalid / revoked), id, issuer abbreviated (02abcdef12…9f0e1d), aud, scope + source, quota_share, max_session_ttl, validity; session cached, plan, expiry; guardrails; next_stepread-only. probe mints/reuses a session to detect revocation. Never a token or key.
txpass_request{ method, url? | path?, query?, headers?, json? }{ request: { method, url }, response: { status, ok, headers (allowlisted), body, body_bytes, truncated, duration_ms }, session: { dlg, sub (abbrev.), act_sub (abbrev.), plan, expires_at }, retried_after_401, hint? }token injected server-side; guardrails §6; redacted. Errors come back as isError: true with the AgentError JSON.

Decision: no other tools in v1. Not added on purpose: a "get token" tool (defeats the design), a "sign arbitrary data with A" tool (A's signature is the credential), a "delegate/revoke" tool (those are the human's wallet actions). Imprint helpers (imprintMinimalBody, pollImprintJob) are library exports, not tools: the model polls with txpass_request GET /api/status/<job_id>.

10. Configuration reference

config.json (v: 1): facilitator (default https://txpass.app), product, api_bases[], expected_issuer, session_ttl_seconds, enforce_scope (true), allow_insecure_http (false), limits { request_timeout_ms 30000, facilitator_timeout_ms 15000, max_request_bytes 65536, max_response_bytes 262144, max_requests_per_minute 30, revocation_check_seconds 30, write_revocation_check_seconds 0 }. facilitator and api_bases entries must be bare origins. Env overrides: TXPASS_AGENT_HOME, TXPASS_FACILITATOR_URL, TXPASS_AGENT_API_BASES, TXPASS_AGENT_REVOCATION_CHECK_SECONDS, TXPASS_AGENT_WRITE_REVOCATION_CHECK_SECONDS, TXPASS_AGENT_MAX_REQUESTS_PER_MINUTE, TXPASS_AGENT_PASSPHRASE(_FILE). Paths given to the CLI expand a literal leading ~ (PowerShell does not); on Windows the home defaults to $HOME.txpass-agent, and the POSIX key-file mode check is skipped (NTFS ACLs apply; Node reports 0o666 for every file).