Spec: standing agent policies v1
Code: @txpass/protocol (policy.ts: types, buildPolicyPayload, buildAdmitPayload, shape checks, reusing canonicalize from delegation.ts) · @txpass/verify (verifyPolicy, verifyAdmit, pol on TxpassSessionClaims) · @txpass/client (signPolicy, signAdmit, generateEnrollmentCode) · @txpass/agent (txpass-agent enroll <string>, policy enroll|show|list|session|leave) · apps/facilitator (src/policy-store.ts, src/policy-routes.ts: /v1/policies*, POST /v1/session with policy, introspect; /account Agent policies section) · pnpm e2e:policy. Agent-initiated connect (§16): @txpass/protocol connect.ts · apps/facilitator src/connect-store.ts, src/connect-routes.ts, /account/connect · @txpass/agent txpass-agent connect (src/connect.ts) · pnpm e2e:connect.
1. What it does
A delegation v1 cert names exactly one agent key, so every new agent needs a new wallet signature and enrollment codes are impossible. A policy is a cert without a delegate: identity I signs it once, and agents join it through admissions:
- keys listed in the policy's
agentsallowlist; - a later signed admit naming a key (
agent); - a signed admit holding only the SHA-256 of an enrollment code (
code_hash), redeemed once by the agent's own key.
Each admission gets its own id, which becomes the JWT dlg. Every session therefore traces back to a document signed by I, and each agent can be capped, removed or confirmed separately. The JWT keeps the v1 delegated shape (sub = I, act.sub = A, dlg), plus an additive pol claim.
Non-goals for v1: open policies (any key may mint; rejected as unsafe, the policy id would become a bearer credential), chained or sub-delegated policies, passkey (p256-webauthn) issuers (reserved), on-chain policy anchoring.
2. Policy document txpass/v1/policy
A JSON object. The fields below are the complete v1 set; unknown fields are rejected (policy_malformed). null is never allowed; optional fields are omitted.
| Field | Req | Type / format | Meaning |
|---|---|---|---|
v | yes | "txpass/v1/policy" | Version tag. A v1 delegation verifier rejects it (different v), so it is never misread as a cert. |
id | yes | 32 lowercase hex (16 random bytes) | Policy id. Unique per issuer. |
issuer | yes | 66 lowercase hex, compressed secp256k1 | I, the entitlement owner. |
issuer_type | yes | "k1" | As delegation v1. "p256-webauthn" reserved → policy_unsupported_issuer_type. |
product | yes | non-empty string ≤ 128 | Product slug (e.g. imprintai-api). |
aud | yes | non-empty string, no * | The facilitator issuer string exactly (https://txpass.app in production; published as delegation.audience in GET /v1/protocol). |
name | yes | string, 1–64 chars after trim, no control characters | Human label shown on /account and in the activity log. Signed so it can't be swapped. |
scope | yes | non-empty array of { method, pathPattern } | Route caveat; must be a subset of the product's declared routes (GET /v1/products). Required here (optional in v1 certs) so the page always states what agents can do. |
plans | no | non-empty array of non-empty strings | Plan pin, as v1. |
receipt_txid | no | non-empty string | Receipt pin, as v1. |
max_session_ttl | yes | integer seconds in [900, 3600] | Cap on any minted session (D2). |
caps | yes | object, below | Facilitator-enforced limits. |
caps.sessions_per_day_per_agent | yes | integer in [1, 10000] | Mints per admission per UTC day. Page default 200. |
caps.max_agents | yes | integer in [1, 50] | Active + pending admissions at once. Page default 5. |
quota_share | no | { per_day?, per_month? }, non-negative integers, at least one | Advisory, publisher-enforced per sub:dlg, i.e. per agent (as v1 §6.3). |
agents | yes | array (may be empty) of distinct 66-hex keys, ≤ caps.max_agents, none equal to issuer | Initial allowlist; admitted active on record. |
enrollment | yes | "none" | "codes" | Whether code admits (§3) are accepted for this policy. |
require_confirm | no | boolean, default false when omitted | If true, code-joined agents start pending (D4). Allowlist and agent admits are always active. createPolicy omits it when false so the bytes stay minimal. |
not_before | yes | unix seconds | Start of validity. |
expires_at | yes | unix seconds, > not_before, expires_at − not_before ≤ 31536000 (365 days) | End of validity, exclusive (D5). Page/SDK default not_before + 90 days. |
sig | yes | lowercase hex DER ECDSA | Issuer signature (§4). |
A policy is immutable. Changing anything (scope, caps, window, require_confirm) means signing a new policy and revoking the old one; the page does this as one "Edit" flow and can carry admitted agents over by listing them in the new agents.
3. Admit document txpass/v1/admit
| Field | Req | Type / format | Meaning |
|---|---|---|---|
v | yes | "txpass/v1/admit" | Version tag. |
id | yes | 32 lowercase hex | Grant id, unique per issuer. |
issuer | yes | 66 hex | Must equal the policy's issuer. |
policy | yes | 32 hex | The policy id. |
agent | one of | 66 hex, ≠ issuer | Approve a known key (admitted active). |
code_hash | one of | 64 lowercase hex = sha256(UTF-8 code) | Enrollment code grant. Policy must have enrollment: "codes". |
expires_at | yes | unix seconds | The grant must be recorded and (for codes) redeemed before this. For codes: default now + 15 min, max now + 24 h at record time. For agent: must be ≤ the policy's expires_at; it bounds recording only, not the admission's life. |
sig | yes | lowercase hex DER ECDSA | Issuer signature (§4). |
Exactly one of agent / code_hash (both or neither → admit_malformed). An admission never outlives its policy.
4. Canonical bytes and signing
Signed bytes = UTF-8 of canonicalize(document without "sig"), with canonicalize exactly as delegation-v1 §3: keys sorted by UTF-16 code unit recursively, no whitespace, JSON.stringify strings, safe integers only, arrays in order, null rejected, undefined dropped. Booleans (require_confirm) serialize as true/false. The output equals RFC 8785 (JCS) for this value domain.
Signing (BRC-100 wallet):
wallet.createSignature({
data: Array.from(new TextEncoder().encode(canonicalize(payload))),
protocolID: [2, 'txpass'],
keyID: 'v1 policy', // or 'v1 admit' for admit documents
counterparty: 'anyone',
}, 'txpass.app')
Verification (anyone, offline): new ProtoWallet('anyone').verifySignature({ data, signature: hexToBytes(sig), protocolID: [2,'txpass'], keyID, counterparty: doc.issuer }).
Separate key ids (v1 policy, v1 admit, distinct from v1 delegate, v1 auth, v1 reveal) give domain separation: a signature over one document type can never verify as another even if bytes were somehow crafted to collide, and a wallet can show a distinct permission prompt ("allow Txpass agent policies"). The v tag gives a second, independent separation.
5. Enrollment codes
- Format:
txp-+ 26 characters of RFC 4648 base32 (lowercase, no padding) from 130 bits of CSPRNG output (≥ 128 bits). Example shape:txp-k3v…(26 chars). Codes are compared exactly after trimming whitespace and lowercasing. - Generated client-side (
generateEnrollmentCode()in@txpass/client, WebCrypto). The wallet signs an admit with onlycode_hash; the facilitator never sees the code until redemption. The page shows the code once. - Enrollment string (what the user pastes into an agent):
txpass:enroll?f=<facilitator base URL, URL-encoded>&p=<policy id>&c=<code>[&i=<issuer key>]e.g.txpass:enroll?f=https%3A%2F%2Ftxpass.app&p=9f…&c=txp-…&i=03…. Onlycis secret. The optionali(added in implementation, §15) lets the agent pin the owner before redeeming; the account page always includes it.txpass-agent enroll '<string>'parses it, redeems, and stores{ facilitator, issuer, policy_id }(issuer learned from the redeem response and pinned). - Redeem:
POST /v1/policies/:id/enroll { "code": "txp-…", "issuer"?: "<I>" }, BRC-103 authenticated as the agent key A. The facilitator:- rate-limits (§10); checks neither the policy (backstop lock) nor this client source (per-source lock) is locked;
- loads the policy (live, not revoked,
enrollment: "codes"); - computes
sha256(code), finds an unredeemed grant for this policy with that hash; - checks grant
expires_at > now(code_expiredotherwise), A ≠ issuer, A not already admitted (agent_already_admitted), admission count <max_agents(policy_cap_reached); - atomically
UPDATE policy_grants SET redeemed_at = now, redeemed_by = A WHERE issuer = … AND id = … AND redeemed_at IS NULLand inserts the admission in the same transaction. Zero rows updated →code_already_used. - admission status =
pendingifrequire_confirm, elseactive; logscode_redeemed; the issuer's/accountshows the "new agent joined" banner with one-click Remove (and Confirm if pending).
- Unknown hash →
code_invalid(same response time as a hit; counts toward the locks). A redeemed code shows up for the legitimate agent ascode_already_used, which is the signal that it leaked.
6. Minting and the JWT
POST /v1/session (BRC-103 as A), body { "product": "imprintai-api", "policy": "<policy id>", "issuer": "<I>", "ttlSeconds": 900 }. policy and delegation are mutually exclusive (session_request_malformed). Absent policy → today's code path byte-for-byte.
Check order (first failure wins):
- body shape;
issuer66 hex; policy exists for (issuer,policy) → elsepolicy_not_found; - stored policy signature re-verified (cached) →
policy_bad_signature; aud= facilitator issuer →policy_audience_mismatch;product= body product →policy_product_mismatch;- window:
now < not_before→policy_not_yet_valid;now ≥ expires_at→policy_expired; - not revoked →
policy_revoked; - admission for (issuer, policy, A): none →
agent_not_admitted;removed→agent_removed;pending→agent_pending_confirmation; - daily cap:
policy_usage_daily.sessions < caps.sessions_per_day_per_agent→policy_cap_reached. One session is reserved atomically with the check and handed back if any later step (or the mint) fails, so only successful mints count toward the cap; scope⊆ product routes →policy_scope_invalid(normally caught at record time; re-checked in case product config shrank);- product
allow_delegation→delegation_not_allowed(same flag as v1); - I's live entitlement for
product, honouringplans/receipt_txid→ the existing entitlement errors (402entitlement_required).
JWT claims: iss, aud, sub = I, act: { sub: A }, dlg = <admission id>, pol = <policy id> (new, additive), scope = policy scope, quota_share if set, iat, exp, jti. TTL = min(requested or 900, 3600, max_session_ttl, policy expires_at − now, entitlement expiry).
- Admission ids are 32 random hex, generated by the facilitator, unique per issuer across both
policy_admissions.idanddelegations.id(checked at insert), sodlgalone identifies the grant for publishers. - Publishers need no change:
verifySessionJwtrequiresact.subanddlgand ignores unknown claims;polis only for display and introspection. Publishers enforcingquota_sharebysub:dlgget a per-agent cap.
7. Revocation, removal, confirmation and introspection
- Revoke policy:
POST /v1/policies/:id/revoke { reason? }(as I). New mints →policy_revoked. Pending grants become unusable. - Remove agent:
POST /v1/policies/:id/agents/:agent/remove(as I). Statusremoved, mints →agent_removed. Re-admitting the same key needs a new admit (a new admission id, so old tokens stay dead in introspection). - Confirm agent:
POST /v1/policies/:id/agents/:agent/confirm(as I), only forpending→active; otherwiseadmission_not_pending. - Introspect (
POST /v1/session/introspect): tokens carryingpolare checked against policy and admission after the existing signature/exp checks. Inactive answers are{ "active": false, "reason": "policy_revoked" | "policy_expired" | "agent_removed" | "agent_pending_confirmation", "dlg": "…", "pol": "…" }. Active answers add"pol". Local verifiers honour tokens untilexp(≤max_session_ttl), as v1.
8. Routes
All BRC-103 authenticated; JSON bodies; errors are { error, message } with the codes in §9. "I" = caller must be the policy issuer.
| Route | Caller | Body / query | Success |
|---|---|---|---|
POST /v1/policies | I | { policy: {…signed doc…} } | 201 { policy, status, agents: [admissions] }; idempotent re-post of the identical doc → 200 |
GET /v1/policies | I | ?product=&include_inactive=1 | 200 { policies: [{ policy, status, agents, counts: { sessions_today, sessions_30d } }] } |
GET /v1/policies?role=agent | A | — | 200 { policies: [{ issuer, policy_id, name, product, scope, max_session_ttl, expires_at, aud, status, admission: { id, status } }] } (no other agents' keys) |
GET /v1/policies/:id | I | — | 200 one issuer view (as in the list) plus open_codes: [{ grant_id, created_at, expires_at }] (added in implementation) |
GET /v1/policies/:id/agents | I | — | 200 { policy_id, agents: [admissions] } (added in implementation) |
POST /v1/policies/:id/admissions | I | { admit: {…signed doc…} } | 201 { grant_id, kind: "agent"|"code", admission? } |
POST /v1/policies/:id/enroll | A | { code, issuer? } | 201 { issuer, policy_id, name, product, scope, max_session_ttl, expires_at, aud, admission: { id, status } } |
POST /v1/policies/:id/agents/:agent/remove | I | — | 200 { admission } |
POST /v1/policies/:id/agents/:agent/confirm | I | — | 200 { admission } |
POST /v1/policies/:id/revoke | I | { reason? } (≤ 200 chars) | 200 { policy_id, revoked_at }; idempotent |
GET /v1/policies/:id/events | I | ?before=&limit= (≤ 200) | 200 { events: [...] } newest first |
POST /v1/session | A | { product, policy, issuer, ttlSeconds? } | as today, plus pol, policy_expires_at, sessions_today, sessions_per_day |
The issuer list and single views also carry created_at, revoked_at?, revoke_reason?, locked_until? (only while locked), and per admission last_used_at? and sessions_today.
Record-time checks for POST /v1/policies: shape, signature, issuer = caller, aud, product configured, scope ⊆ product routes, window (not expired; lifetime ≤ 1 year), allow_delegation on, id unused by this issuer. Allowlisted agents are inserted as active admissions (via 'allowlist'). For POST …/admissions: shape, signature, issuer = caller = policy.issuer, policy live, code_hash only if enrollment: "codes", code expiry ≤ 24 h, agent admits respect max_agents.
A caller who is not the issuer gets 404 policy_not_found (not 403), so policy ids can't be probed for existence.
9. Error codes
| Code | HTTP | When |
|---|---|---|
policy_malformed / admit_malformed | 400 | shape, unknown field, null, bad hex, both/neither agent/code_hash |
policy_bad_signature / admit_bad_signature | 400 | signature does not verify |
policy_unsupported_issuer_type | 400 | p256-webauthn |
policy_forbidden | 403 | signed issuer ≠ caller on record |
policy_audience_mismatch / policy_audience_wildcard_not_allowed | 400 | aud wrong / contains * |
policy_product_mismatch | 400 | body product ≠ policy product |
policy_scope_invalid | 400 | scope not a subset of product routes |
policy_lifetime_too_long | 400 | expires_at − not_before > 31536000 |
policy_not_found | 404 | unknown, or caller is not the issuer |
policy_not_yet_valid / policy_expired | 403 | window |
policy_revoked | 403 | revoked |
policy_cap_reached | 429 | daily session cap, or max_agents on admit/enroll |
policy_locked | 429 | too many failed code redemptions (§10); body scope: "source" (this client source) or "policy" (backstop), plus locked_until, retry_after |
agent_not_admitted | 403 | no admission for A |
agent_removed | 403 | admission removed |
agent_pending_confirmation | 403 | require_confirm and not yet confirmed |
agent_already_admitted | 409 | enroll/admit for a key already active or pending |
admission_not_pending | 409 | confirm on a non-pending admission |
enrollment_disabled | 400 | code admit on enrollment: "none" |
code_invalid | 400 | no grant with that hash |
code_already_used | 409 | grant already redeemed |
code_expired | 410 | grant expires_at passed |
delegation_not_allowed | 403 | product disallows delegation (shared with v1) |
session_request_malformed | 400 | POST /v1/session with both policy and delegation, or issuer not 66 hex (added in implementation) |
policy_id_in_use | 409 | POST /v1/policies with an id this issuer already used for a different document (added) |
admit_id_in_use | 409 | admit id already used by this issuer for a different grant (added) |
admit_policy_mismatch | 400 | admit's issuer/policy ≠ the policy in the URL (added) |
admit_expired | 410 | admit expires_at already passed at record time (added) |
admit_expiry_too_long | 400 | admit expires_at more than 24 h after record time (added) |
code_limit_reached | 429 | 20 unredeemed, unexpired code grants already outstanding (added) |
policy_limit_reached | 429 | 50 live policies for this issuer (added) |
10. Limits
| Limit | Value |
|---|---|
| Policy lifetime | ≤ 365 days (31,536,000 s); default 90 days |
max_session_ttl | 900–3600 s |
caps.max_agents | 1–50 (default 5); counts active + pending |
caps.sessions_per_day_per_agent | 1–10,000 (default 200); UTC day |
| Code grant expiry | default 15 min, max 24 h |
| Agent admit expiry | default 1 h, max 24 h at record time (§15) |
| Code entropy | ≥ 128 bits (130 generated) |
| Outstanding unredeemed code grants per policy | ≤ 20 |
| Live policies per issuer | ≤ 50 |
| Document size | ≤ 8 KiB canonical bytes |
| Enroll rate limit | per IP 10/min and per identity 10/min (new enroll bucket); 30/hour per (policy, client source); 300/hour per policy as a backstop (rate_limited, scope: "policy_source" / "policy") |
| Per-source lock | 10 failed redemptions (code_invalid/code_expired/code_already_used) for one policy from one client source within 1 h → that source gets policy_locked (scope: "source") until the oldest of those 10 is an hour old; other sources are unaffected. Client source = policy id + client IP (IPv6 /64) as resolved by the rate limiter (TXPASS_TRUST_PROXY); stored only as a truncated SHA-256 (detail.src), never returned by the events API |
| Policy lock (backstop) | 100 failed redemptions for one policy from all sources within 1 h → the whole policy is locked for code joins for 1 h (locked_until, scope: "policy") |
| Both locks | logged as code_redeem_failed / policy_locked (detail.scope), shown on /account; admitted agents keep minting |
| Other writes | delegation_write bucket; reads read bucket |
11. Storage (Postgres; the memory store mirrors it for tests)
As implemented (POLICY_SCHEMA_SQL in apps/facilitator/src/policy-store.ts, applied idempotently on start-up; additive, no existing table changes). All timestamps are unix seconds in BIGINT, like every other facilitator table (the draft said timestamptz).
agent_policies (issuer text, id text, product text, aud text, name text, doc jsonb, max_session_ttl int,
not_before bigint, expires_at bigint, created_at bigint, revoked_at bigint, revoke_reason text,
locked_until bigint, PRIMARY KEY (issuer, id))
CREATE INDEX ON agent_policies (id);
policy_admissions (issuer text, id text /* = dlg */, policy_id text, agent text,
via text CHECK (via IN ('allowlist','admit','code')), grant_id text,
status text CHECK (status IN ('active','pending','removed')),
admitted_at bigint, confirmed_at bigint, removed_at bigint, last_used_at bigint,
PRIMARY KEY (issuer, id))
-- one live admission per agent per policy:
CREATE UNIQUE INDEX ON policy_admissions (issuer, policy_id, agent) WHERE status <> 'removed';
CREATE INDEX ON policy_admissions (agent);
policy_grants (issuer text, id text, policy_id text, agent text NULL, code_hash text NULL, doc jsonb,
expires_at bigint, created_at bigint, redeemed_at bigint, redeemed_by text, PRIMARY KEY (issuer, id))
CREATE UNIQUE INDEX ON policy_grants (policy_id, code_hash) WHERE code_hash IS NOT NULL;
policy_usage_daily (issuer text, admission_id text, day date, sessions int, PRIMARY KEY (issuer, admission_id, day))
policy_events (seq bigserial PRIMARY KEY, issuer text, policy_id text, at bigint, type text, agent text, detail jsonb)
CREATE INDEX ON policy_events (issuer, policy_id, at DESC);
-- types: created, agent_admitted, code_created, code_redeemed, code_redeem_failed, policy_locked,
-- agent_confirmed, cap_hit, agent_removed, revoked
Single use is enforced by the conditional UPDATE … WHERE redeemed_at IS NULL in the same transaction as the admission insert. The daily cap uses INSERT … ON CONFLICT DO UPDATE SET sessions = sessions + 1 WHERE sessions < cap RETURNING sessions. max_agents and the outstanding-code limit take SELECT … FOR UPDATE on the policy row; the live-policy limit takes a per-issuer advisory lock. The session_minted_daily rollup event from the draft is not written: policy_usage_daily already holds the counts.
12. Security notes
- Authority and audit, not trustlessness. The facilitator holds the JWT key and is trusted to mint honestly in any design. What policies add: every session traces to a document I signed, any compliant facilitator can re-verify the chain, and Txpass never holds user keys or sees codes before redemption.
- Policy id and URL are not credentials. Minting needs an admitted key and a valid BRC-103 signature by it.
- Leaked code: single use, short-lived, hash-only storage, rate limits and lock; a wrong-key redemption shows as a banner with one-click Remove; the legitimate agent sees
code_already_used. Policies that want no window at all setrequire_confirm(D4). - Stolen agent key: remove the agent; tokens die within
max_session_ttl. Proof-of-possession sessions remain future hardening (delegation-v1 §10). - Page compromise: strict CSP, no third-party scripts, and a plain-words review (agents, scope, window, caps) before every wallet prompt.
- Privacy:
GET /v1/policies?role=agentnever reveals other agents' keys; events are issuer-only.
13. Test vectors (frozen)
Source of truth: packages/verify/test/policy.test.ts (it asserts every value below). Signatures use protocolID [2,'txpass'], counterparty anyone, RFC 6979 nonces, so they are deterministic: createSignature with a ProtoWallet over the listed private key reproduces them. Verification derives the BRC-42 child key from issuer, so the signature does not verify against the raw issuer key.
| Item | Value |
|---|---|
| Issuer private key (I) | 1111 (decimal scalar) → 0228b7f3a019749cce6fc677afa8fae72ec10e811ed4b04e1963143cef87654b75 |
| Agent admitted by key (A) | private 2222 → 0271b357df56cfc77291b75e5f550d72687c1bef84ff9eadb4946eb55516a89d6f |
| Allowlisted agent | private 3333 → 034cecb33a915e2c3b189eb6ee45fa4eb3eddeff09729945c600f893bd4381b294 |
| Enrollment code | txp-abcdefghijklmnopqrstuvwxyz |
sha256(code) | d0fd63c02534836b54643b72c1ebfcaade4b71bf047ebf0e45bb9e8b4872da0e |
Policy (keyID v1 policy); canonical bytes:
{"agents":["034cecb33a915e2c3b189eb6ee45fa4eb3eddeff09729945c600f893bd4381b294"],"aud":"https://facilitator.example","caps":{"max_agents":5,"sessions_per_day_per_agent":200},"enrollment":"codes","expires_at":1797776000,"id":"00112233445566778899aabbccddeeff","issuer":"0228b7f3a019749cce6fc677afa8fae72ec10e811ed4b04e1963143cef87654b75","issuer_type":"k1","max_session_ttl":1800,"name":"My coding agents","not_before":1790000000,"product":"imprintai-api","require_confirm":true,"scope":[{"method":"POST","pathPattern":"/api/imprint"},{"method":"GET","pathPattern":"/api/status/:jobId"}],"v":"txpass/v1/policy"}
- SHA-256:
4e93468ee3a18ca337886d6b84d53f47973021d0cecc730ca477be97d23f8111 sig:30440220743d436b65d42722581a7beadd0aa7b4b85620e8e551bfca37806785858f5bad02203196e21c0e0cd3bf7da8b822e9a16f8b0d8928765fffc94f31e17c47e1a39cdf
Admit, code grant (keyID v1 admit); canonical bytes:
{"code_hash":"d0fd63c02534836b54643b72c1ebfcaade4b71bf047ebf0e45bb9e8b4872da0e","expires_at":1790000900,"id":"0123456789abcdef0123456789abcdef","issuer":"0228b7f3a019749cce6fc677afa8fae72ec10e811ed4b04e1963143cef87654b75","policy":"00112233445566778899aabbccddeeff","v":"txpass/v1/admit"}
- SHA-256:
ff235fdab239d88b2cfb48ff339fe9fd92be106a78c5a955adc92fe6314c1b69 sig:30450221008915dc714b572a16b7ed90e922be8ba42c7050df4ee137c6e429bd6d85056c380220520274a30dffc77bf3056248f24f5a3fb4500fc13e25b3e34a21b3d859e575d6
Admit, agent key (keyID v1 admit); canonical bytes:
{"agent":"0271b357df56cfc77291b75e5f550d72687c1bef84ff9eadb4946eb55516a89d6f","expires_at":1790003600,"id":"fedcba9876543210fedcba9876543210","issuer":"0228b7f3a019749cce6fc677afa8fae72ec10e811ed4b04e1963143cef87654b75","policy":"00112233445566778899aabbccddeeff","v":"txpass/v1/admit"}
- SHA-256:
7090715aa1eecd4b60c4e275e2538239903c5689ba58e9053ca23937d5af9f6b sig:3045022100b8003708932d1129e28d756e5361d581fbee41493b2d7b14513f5e067e54619302203f42db93688917273a5b7916e225f8dfb463f904c0b054a5f721ec0ace5de097
Negative vectors (policy above, one change each): extra field "x":1 → policy_malformed; any field null → policy_malformed; expires_at = not_before + 31536001 → policy_lifetime_too_long; max_session_ttl: 899 → policy_malformed; an admit with both agent and code_hash → admit_malformed; one flipped byte in the policy → policy_bad_signature; the code admit checked against another policy id → admit_policy_mismatch.
15. Implementation notes (gaps this spec left open, and how they were settled)
- Timestamps:
BIGINTunix seconds, nottimestamptz(matches every other facilitator table; §11). - Extra read routes:
GET /v1/policies/:id(withopen_codes) andGET /v1/policies/:id/agents, for the account page and support (§8). - Extra error codes:
session_request_malformed,policy_id_in_use,admit_id_in_use,admit_policy_mismatch,admit_expired,admit_expiry_too_long,code_limit_reached,policy_limit_reached(§9). - Enroll
issuerhint andi=: the enrollment string may carry the issuer key andPOST …/enrollacceptsissuer. With a hint the policy is looked up for that issuer only; a wrong hint is answered404 policy_not_found, the same as an unknown id (guessing a 128-bit policy id is infeasible, so this reveals nothing useful). Without a hint the facilitator finds the policy by id. The agent pins the issuer it learns. - Agent-key admits expire within 24 h of recording (default 1 h), the same bound as code grants. The spec only required ≤ policy expiry; a short bound keeps a signed-but-unsent approval from lingering.
- Agent-key approvals are
activeeven whenrequire_confirmis set, as §3 says: approving a specific key is the confirmation.require_confirmonly affects code joins. - Daily cap counts successful mints only. The check stays at step 7 of §6 and reserves one session atomically (
INSERT … ON CONFLICT … WHERE sessions < cap), so concurrent mints can never exceed the cap; if a later check or the mint fails (e.g. 402issuer_no_entitlement), the reservation is released (sessions = sessions − 1, never below 0).last_used_atis set only on success. - The issuer cannot enroll into their own policy:
POST …/enrollby the issuer key →403 policy_forbidden. - Lock scope: a stranger who knows a policy id must not be able to block code joins. The 10-failures/1-hour lock is per (policy id, client source), using the trusted-proxy client IP the rate limiter already uses; a per-policy backstop locks all sources only after 100 failures in an hour. The per-policy enroll rate limit is split the same way (30/hour per source, 300/hour per policy). A distributed attacker with ≥ 10 addresses can still reach the backstop; that pauses code joins for an hour, while admitted agents and key approvals keep working.
- Code-grant lookup: grants are found by
(policy_id, code_hash); the unique index is global (not per issuer), which is safe because policy ids are random 128-bit values and the admit must also match the policy's issuer. - The admin health (
GET /v1/admin/health) reportspolicyStore("memory"or"postgres"), next tostoreanddelegationStore: the backend name only, no query and no secrets. (PublicGET /healthis only{ ok, version }.) Policy storage followsDATABASE_URLlike the delegation store. - Joining a key-approved policy: an agent whose key was approved by an admit has no code, so
txpass-agent policy enroll --policy <id>readsGET /v1/policies?role=agent(which now also returnsaud) and saves the membership. - Revocation latency: as delegation v1, removal and revocation stop new mints immediately; tokens already issued stay valid until
exp(≤max_session_ttl, 15–60 min) at publishers that verify locally. Txpass agents introspect before every write, so a removed Txpass agent stops at once; publishers that need instant cut-off call introspect.
16. Agent-initiated connect (device-authorization style)
This is the primary way an agent joins. The one-time code (§5) stays as the secondary option; one-off delegation stays under Advanced. Built like the OAuth device flow (RFC 8628) and "sign a TV into Netflix": the agent starts, the human approves in the browser.
16.1 Flow
- Agent:
txpass-agent connect <product> [--scopes "POST /api/imprint,GET /api/status/:jobId"] [--scope S]… [--label L] [--facilitator URL]. The agent creates its key if needed (asenrolldoes) and callsPOST /v1/connect/requests, BRC-103 signed by A. The facilitator answers a short user code and the approval linkhttps://<facilitator>/account/connect?r=<id>, which the CLI prints (it only ever prints a link on the facilitator origin it is talking to). Without--scopesthe CLI first reads the product'srecommended_scopesfromGET /v1/productsand requests exactly those (printing that it did and how to ask for more); only when the product has none (or the catalog can't be read) does it omitscopes, i.e. request all routes, with a warning. - Agent polls
GET /v1/connect/requests/:ideveryintervalseconds (3), longer after a 429 orRetry-After(RFC 8628slow_downbehaviour: +5 s, max 60 s), untilapproved,deniedorexpired. The request lives 15 minutes and is single use. Human opens the link on
/account(connects a wallet if needed) and sees: Agent "‹label›" (‹key fingerprint›) wants to ‹scope labels› on ‹product› for ‹duration›, plus the user code with "approve only if this matches your terminal". They either- create a new policy inline (defaults: requested scopes ∩ product routes, all ticked, may only be narrowed; when the product has no
recommended_scopesand the request covers every route, the screen shows a warning asking the human to untick what the agent doesn't need; 30-minute session window; 90-day expiry; 5 agents; 200 sessions/agent/day;enrollment: "none") whoseagentsallowlist names A → one signature (the policy) creates the policy and admits A (recordedvia "connect", shown as "Connected"; other keys in the same allowlist stayvia "allowlist"); or - add A to an existing live policy for the same product → one signature (a
txpass/v1/admitwithagent = A, §3). The screen says so when the existing policy grants more than the agent asked for.
Deny needs no document signature (it is still a BRC-103 authenticated call).
- create a new policy inline (defaults: requested scopes ∩ product routes, all ticked, may only be narrowed; when the product has no
- On approval the agent's poll returns the membership (the same fields as
GET /v1/policies?role=agent); the CLI savespolicy.json, setscredential = policy, pins the issuer, and can mint at once with the existingPOST /v1/session { product, policy, issuer }path (§6). An approval by a different wallet than an already pinned issuer is refused locally unless--allow-new-issuer.
No new signed document types: approvals are ordinary policy / admit documents, verified by the same code as POST /v1/policies and POST /v1/policies/:id/admissions (same checks, same errors, same events).
16.2 Routes
All BRC-103 authenticated. Errors are { error, message }.
| Route | Caller | Body | Success |
|---|---|---|---|
POST /v1/connect/requests | A | { product, scopes?: [{method, pathPattern}] | ["METHOD /path"], label? } | 201 { id, user_code, verification_uri, verification_uri_complete, product, scope, label?, expires_at, interval } |
GET /v1/connect/requests/:id | A (the requester) | — | 200 { id, status, user_code, expires_at, interval } (+ Retry-After while pending); when approved also membership: { issuer, policy_id, name, product, scope, max_session_ttl, expires_at, aud, status, admission: { id, status } } |
GET /v1/connect/requests/:id | any other wallet | — | pending: 200 { id, status, agent, label?, product, scope, user_code, created_at, expires_at }; resolved: only { id, status } (+ policy_id for the approver) |
POST /v1/connect/requests/:id/approve | I | { policy } (signed, agents ∋ A, same product, scope ⊆ requested) or { policy_id, admit } (I's own live policy, same product, admit agent = A) | 200 { id, status: "approved", policy_id, admission: { id, status }, result } |
POST /v1/connect/requests/:id/deny | any wallet | — | 200 { id, status: "denied" } |
id: 32 hex (128 random bits).user_code: 8 characters from the RFC 8628 consonant alphabetBCDFGHJKLMNPQRSTVWXZ, shownXXXX-XXXX(≈ 34.6 bits).scopesomitted or empty = all product routes; any route outside the product →connect_scope_invalid(the request is refused, not silently intersected, so the agent learns its mistake). At most 20 scopes.label: optional, untrusted display text. The facilitator sanitizes it (NFC; control, format/bidi, zero-width, private-use and separator characters replaced; whitespace collapsed; ≤ 48 code points). The page renders it only withtextContent, quoted, never as a link.
Errors: connect_request_malformed (400), connect_product_unknown (400), connect_scope_invalid (400), connect_pending_limit (429), connect_capacity (503, Retry-After: 60), connect_request_not_found (404; unknown, purged, or bad id), connect_request_expired (410), connect_request_not_pending (409, with status), connect_agent_mismatch (400), connect_product_mismatch (400), connect_scope_exceeds_request (400), connect_self_approval (403), delegation_not_allowed (403), policy_not_found (404); plus every policy/admit error of §9 from the shared verification (policy_bad_signature, policy_forbidden, admit_bad_signature, policy_revoked, policy_cap_reached, …). A failed approval puts the request back to pending, so the human can retry.
16.3 Security
- The link grants nothing. Approval needs a policy or admit signed by the approving wallet; the request id is only a locator. Only A (the key that signed the create call) can read the approved membership.
- User code on both screens. The human matches the code in the terminal with the code on the page, which defeats a phishing link that points at an attacker's request.
- Single use: approval claims the row atomically (
pending → approving, only while unexpired), runs the shared create/admit, then completes (approved) or releases (pending). Two approvals can never both succeed; a claim older than 60 s (crashed approver) may be re-claimed. Deny works only on pending, unexpired rows. - Limits: rate-limit bucket
connecton create (per trusted-proxy client IP 10/min, per identity 5/min; envTXPASS_RATE_LIMIT_CONNECT_PER_IP/_PER_IDENTITY); at most 3 pending requests per agent key and 10,000 pending in total (backstop against key-rotation floods). Polling uses thereadbucket (60/min per IP, 30/min per identity), which a 3-second interval stays under. - Minimal storage: a row holds only the agent key, sanitized label, product, requested scope, user code, status and timestamps, plus issuer / policy id / admission id once approved. No IP address, no user agent. Rows are purged one hour after expiry (opportunistically, at most once a minute, on create).
- Self-approval refused: the agent's own key cannot approve its request (and the policy code already refuses an issuer in its own allowlist).
16.4 Storage
connect_requests (Postgres, created on start-up with CREATE TABLE IF NOT EXISTS, additive like the policy tables; the memory store mirrors it):
id TEXT PRIMARY KEY, agent TEXT NOT NULL, label TEXT, product TEXT NOT NULL, scope JSONB NOT NULL,
user_code TEXT NOT NULL, status TEXT NOT NULL, -- pending | approving | approved | denied
created_at BIGINT NOT NULL, expires_at BIGINT NOT NULL, claimed_at BIGINT, resolved_at BIGINT,
issuer TEXT, policy_id TEXT, admission_id TEXT
-- indexes: (agent, status, expires_at), (expires_at)
expired is never stored: an unresolved row past expires_at reads as expired. Create runs under a per-agent advisory lock so the pending cap holds under concurrency. GET /v1/admin/health reports connectStore ("memory" or "postgres"), backend name only.
16.5 Decisions
- One signature for "approve with a new policy", by putting A in the new policy's
agentsallowlist; one signature (an admit) for "add to existing". No new document type. - No terminal QR code: there is no dependency-free QR encoder in the repo, and the link plus an 8-character code is short enough to type. Can be added later.
- Requested scopes are validated, not intersected, on create; a new policy may only narrow them (
connect_scope_exceeds_request). An existing policy may grant more than requested; the approval screen lists the extra routes before the signature. Default request = the product'srecommended_scopes: the CLI asks for the publisher's recommended set unless--scopesis given; "all routes" is only the fallback for products without a recommendation, and then the approval screen warns. The server-side default for an omittedscopesstays "all product routes" (older CLIs keep working). - New policies from the connect screen use
enrollment: "none"(no codes) and a fixed default name ("Connected agents", editable): the untrusted label never ends up inside a signed document. More agents can join the same policy later through connect ("existing policy") or by key. - Deny is open to any authenticated wallet holding the link (no document signature). Worst case a third party who learned the 128-bit id denies a request, and the agent runs
connectagain. - Poll rate is enforced through
interval/Retry-Afterand the existingreadbucket, not a separateslow_downerror. - Connect reuses the "Connected agents" policy by default, so repeated connects do not pile up identical policies. The approval screen pre-selects "An existing policy" when the issuer has a policy for the request's product that is: named "Connected agents" (case/whitespace-insensitive; other names are never auto-picked),
active(not revoked, expired or not-yet-valid) with at least 7 days left, not full (live agents <max_agents), not already holding this agent live (a removed agent is re-admitted as a new admission), and no wider than the request (every policy route was asked for). Ties go to the one that expires last. Otherwise it falls back to a new policy, named "Connected agents 2", "… 3" if the base name is taken. The user can always switch. Nothing changes on the wire: reuse is the existing{policy_id, admit}approval, and the one wallet signature is an admit that binds exactly thatpolicyid andagentkey. - Policy names are unique per issuer + product among active policies.
POST /v1/policies(and a connect approval with a new policy) answers409 policy_name_in_use(with the clashingpolicy_id) when another active or not-yet-valid policy of the same issuer and product has the same name (trimmed, whitespace collapsed, case-insensitive). Re-posting the same signed policy (same id) stays idempotent; revoked/expired policies free the name. The account page checks first and asks for a different name. Duplicates created before this rule are left as they are.