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
| Party | Holds | Trusted for |
|---|---|---|
| Human I (wallet: Yours / BRC-100) | identity key I, entitlement | signing and revoking delegations |
| Agent host (user's machine) | agent key A file, cert, in-memory session JWT | running txpass-agent honestly |
| Model / MCP client | nothing secret | nothing: treated as untrusted input |
| Facilitator (txpass.app) | session signing key, delegation + revocation store | minting sessions, recording revocations |
| Publisher (e.g. Imprint) | JWKS cache | verifying 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 enforcescope) other routes. - Outlive a revocation for new sessions; already-minted JWTs live ≤
max_session_ttlat 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 gets | Consequence | Bound |
|---|---|---|
| Read access to the home dir, key not encrypted | A and the cert: can mint sessions as A for I until the cert expires or I revokes | cert 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 config | scrypt N=2¹⁵ offline guessing |
| Code execution as the user while the agent runs | everything the agent can do, plus the in-memory JWT | same 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 only | nothing: 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 initgenerates A withPrivateKey.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 matchpubkey. - Passphrase comes from
TXPASS_AGENT_PASSPHRASEorTXPASS_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
txpass-agent init [--facilitator URL] [--product SLUG] [--api-base URL]... [--issuer I] [--encrypt]generates A, writesconfig.json(no secrets) and prints A plus the exact delegation parameters:delegate = A,product,aud(read fromGET {facilitator}/v1/protocol→delegation.audience; older facilitators:GET /health), suggested narrow scope and short expiry.- 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. - The agent gets the cert, which is public data:
txpass-agent delegation import <file|->(raw cert,{ delegation }or a dev-page report), ortxpass-agent delegation fetch --issuer <I>if the human registered it withPOST /v1/delegations(uses §8).
- 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. - 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. txpass-agent config --api-base https://<publisher>sets where the token may be sent (§6.1).txpass-agent status --probeproves the whole chain.txpass-agent mcp-configprints 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/sdkAuthFetch with aProtoWallet(A), via@txpass/clientrequestSession(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 fullverifySessionJwtagainst{facilitator}/v1/session/jwks(createSessionJwks: cached, refetches once on an unknownkid). A mismatch issession_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: atexp − 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 answersactive: 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 403delegation_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 bareactive: false(older facilitators) drops the token and re-mints, which surfacesdelegation_revoked.SessionManager.checkNow()forces a check regardless of the cache window (test harnesses use it after the human revokes) and returnsactive | 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.
- before every write (any method other than GET/HEAD/OPTIONS):
- Publisher 401 (expired,
unknown_kidfrom 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 answer | Agent error | redelegate | Sticky |
|---|---|---|---|
403 delegation_revoked | delegation_revoked | yes | yes |
403 delegation_expired / ttl_exhausted, or expired locally | delegation_expired | yes | yes |
403 delegation_audience_mismatch | audience_mismatch | yes | yes |
other delegation_* (bad sig, malformed, delegate/product mismatch, scope invalid) | delegation_invalid | yes | yes |
403 delegation_not_yet_valid | delegation_not_yet_valid | no | no |
403 delegation_not_allowed | delegation_not_allowed (paid-only product) | no | no |
402 payment_required | issuer_no_entitlement (renew; no re-delegation needed) | no | no |
| 429 / handshake 429 | facilitator_rate_limited | no | no |
| network / timeout | facilitator_unreachable | no | no |
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, andtxpass_statusshowsstate: 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, andreason: "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 lowermax_session_ttlper 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, orrole=issuer: unchanged response, byte-for-byte (test pins the member list). Unknownroleor malformedissuer→ 400invalid_request. - Store:
DelegationStore.listByDelegate(delegate, { limit, issuer? }), memory + Postgres (uses the existingdelegations_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
issuerfilter and the client-side issuer pin (§4.5); the agent re-verifies whatever it picks. - Old facilitators ignore
roleand return the issuer listing; the agent detects the missingrolemember 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.
| Tool | Input | Output | Notes |
|---|---|---|---|
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_step | read-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).