Protocol & specs

Spec: agent delegation v1

Code: @txpass/protocol (packages/protocol/src/delegation.ts: types, canonicalize, createDelegation, buildDelegationPayload, delegationSignatureArgs) · @txpass/verify (verifyDelegation — also browser-safe via @txpass/verify/delegation — isDelegatedSession, scopeAllows, quotaShareLimits, quotaShareAllows) · @txpass/client (signDelegation, requestSession, safeAuthFetch) · apps/facilitator (POST /v1/session, /v1/delegations*, the delegation store) · proof: pnpm e2e:delegation.

1. What it does

A human (or an org key) holding identity key I and a live Txpass entitlement signs a delegation cert that lets an agent key A mint short-lived session JWTs on I's entitlement. The agent never holds I or the publisher's API key. The facilitator authenticates the agent as A over BRC-103 AuthFetch, checks the cert and I's entitlement, and mints a JWT with sub = I (so existing publisher code keeps working) and act.sub = A.

The cert is a public, self-contained authorization. It is not a secret: anyone who sees it can verify it, but only the holder of A can use it, because the facilitator requires the AuthFetch identity to equal delegate.

2. Cert format

A JSON object. Every field listed is the complete v1 set; unknown fields are rejected (delegation_malformed), so a v1 verifier never silently ignores a caveat it does not understand. null is never allowed; omit optional fields instead.

FieldReqType / formatMeaning
vyes"txpass/v1/delegation"Version tag. Frozen for v1.
idyes32 lowercase hex chars (16 random bytes)Delegation id. Unique per issuer; becomes the JWT dlg.
issueryes66-char lowercase hex, compressed secp256k1 (02/03 prefix)I, the entitlement owner (BRC-100 identity key).
issuer_typeyes"k1"Only k1 (secp256k1) is implemented. "p256-webauthn" is reserved for passkey-native identity (P2) and answers delegation_unsupported_issuer_type. Anything else is malformed.
delegateyes66-char lowercase hex, compressed secp256k1A, the agent's AuthFetch identity key. Must differ from issuer.
productyesnon-empty string, ≤128 charsProduct slug (e.g. imprintai-api).
receipt_txidnonon-empty stringReceipt pin: only this entitlement may back sessions.
plansnonon-empty array of non-empty stringsPlan pin: I's live entitlement must be on one of these plans.
scopenonon-empty array of { method, pathPattern }Route caveat. method uppercase; pathPattern starts with /, :param segments match one path segment. Must be a subset of the product's default scope.
quota_shareno{ per_day?, per_month? }, non-negative integers, at least oneAdvisory quota caveat, publisher-enforced keyed by dlg (§6.3).
max_session_ttlyesinteger seconds in [60, 3600]Upper bound on any session minted from this cert. createDelegation defaults it to 900.
not_beforeyesunix seconds (integer)Start of validity.
expires_atyesunix seconds (integer), > not_before, expires_at - not_before ≤ 90 daysEnd of validity (exclusive). createDelegation / signDelegation default: not_before + 7 days.
audyesnon-empty string, must not contain *The facilitator's JWT issuer string (TXPASS_ISSUER, production https://txpass.app, full URL with no trailing slash), exact. "*" is not allowed in v1 (delegation_audience_wildcard_not_allowed). The facilitator publishes the expected value as delegation.audience in GET /v1/protocol.
sigyeslowercase hex, DER-encoded ECDSA signatureIssuer signature (§3–4).

0.3 extensions (device delegation, ADR 0003; @txpass/protocol / @txpass/verify 0.3). Two additive fields, accepted only by 0.3 verifiers (a 0.2 verifier, or strictV1: true, rejects them as unknown/malformed, which is the safe failure). Certs without them sign exactly the same bytes as before.

FieldReqType / formatMeaning
parentno32 lowercase hexThe id of the issuer's own txpass-account device cert: the issuer is a delegated device D, not the root. Facilitators that keep renewal history accept any id D has held.
scope (admin form)for txpass-account[{ "admin": [ ... ] }], one entry, non-empty, no duplicatesAdmin scopes for a device: admin:account.read, admin:salts.read, admin:pair, admin:agents, admin:receipts.revoke, admin:devices.approve, admin:devices.revoke. Only on the reserved product txpass-account, which takes no route scope, receipt_txid, plans or quota_share.

Device certs (product: "txpass-account") may last up to 731 days (default 730). A chain is verified root first and has at most 3 certs: I → D, I → D → A, I → D1 → D2, I → D1 → D2 → A. Every link after the first carries parent = the previous cert's id and is signed by the previous cert's delegate; all but the leaf are device certs; a device under a device needs admin:devices.approve on its parent and a scope within its parent's; an agent under a device needs admin:agents; no key appears twice. Sessions minted through a chain carry sub = I (the root), act.sub = the agent, dlg = the agent cert id, so publishers verify them exactly as before. Device certs never back product sessions (delegation_account_product_reserved).

receipt_txid is the "receipt/plan pin" from the original proposal, split into two optional fields: pin to one receipt, pin to a set of plans, both, or neither (any live entitlement of I for product).

3. Canonical serialization and the exact signed bytes

Signed bytes = UTF-8 encoding of canonicalize(cert without "sig").

canonicalize is deterministic JSON:

  • Object members sorted by key in UTF-16 code-unit order (JavaScript's default Array.prototype.sort on strings), recursively.
  • No insignificant whitespace.
  • Strings serialized exactly as ECMAScript JSON.stringify does.
  • Numbers: safe integers only (Number.isSafeInteger), serialized as decimal with no exponent, sign or leading zeros. Floats are rejected.
  • Arrays keep their order.
  • null is rejected. Members whose value is undefined are dropped (so an omitted optional field and an undefined one produce the same bytes).

Why this and not something else:

  • For the value domain above (strings, safe integers, booleans, arrays, objects; no floats, no null), this output is byte-identical to RFC 8785 (JCS). Any JCS library in any language reproduces it, but a v1 implementation needs about 20 lines and no dependency, and Txpass stays inside an easy-to-audit subset (JCS's hard part is float formatting, which we forbid).
  • It is plain JSON, so the cert travels as ordinary JSON in a request body and in GET /v1/delegations, with no second encoding (CBOR/base64) to keep in sync.
  • Rejecting unknown fields plus the canonical form means there is exactly one valid byte string per logical cert. That stops "same signature, different interpretation" games.
  • Field values are also normalized by the shape check (lowercase hex, uppercase method), so a verifier never has to case-fold before hashing.

The wallet does the hashing: BRC-100 createSignature({ data }) computes SHA-256 over data and signs the digest with ECDSA (RFC 6979 deterministic nonce, low-S). sig is that DER signature in lowercase hex.

4. Signing and public verification (BRC-42/43, counterparty anyone)

Signing (issuer wallet: Yours, any BRC-100 wallet, @bsv/sdk ProtoWallet, or Txpass ID later):

wallet.createSignature({
  data: Array.from(new TextEncoder().encode(canonicalize(payload))),
  protocolID: [2, 'txpass'],
  keyID: 'v1 delegate',
  counterparty: 'anyone',
}, 'txpass.app')

createDelegation(wallet, input) in @txpass/protocol does exactly this; signDelegation in @txpass/client wraps it.

Verification (anyone, offline, no facilitator needed), exactly as @txpass/verify verifyDelegationSignature does:

new ProtoWallet('anyone').verifySignature({
  data: signedBytes,
  signature: hexToBytes(cert.sig),
  protocolID: [2, 'txpass'],
  keyID: 'v1 delegate',
  counterparty: cert.issuer,
})
// resolves { valid: true }; throws ERR_INVALID_SIGNATURE on mismatch (treat as invalid)

What that means precisely: the BRC-43 invoice number is 2-txpass-v1 delegate. The signer derives its BRC-42 child private key from I's root key with the counterparty public key of anyone (the well-known private key 1, whose public key is the generator G). The verifier, holding the anyone private key, derives the matching child public key from cert.issuer and the same invoice number. Because the anyone private key is public, every party can do this derivation, so the signature is publicly verifiable. It is still bound to I (the child key is a function of I and the invoice number) and domain-separated from every other Txpass key use (v1 auth, v1 reveal) and from other protocols.

Security level 2 in BRC-43 means "per-counterparty" permissioning in wallets. With counterparty anyone that is one permission grant for "Txpass delegations"; wallets may still prompt per signature.

A frozen interop vector lives in §10.

5. Facilitator flow

5.1 Transport: POST /v1/session with body.delegation

The agent calls AuthFetch with key A and adds the cert as a JSON field:

POST /v1/session            (BRC-103 AuthFetch, identity = A)
content-type: application/json

{ "product": "imprintai-api", "delegation": { ...cert... }, "ttlSeconds": 600, "receipt_txid": "…optional…" }

Why the body, not a header: AuthFetch signs the request body as part of the BRC-103 message, so the cert is covered by A's request signature and cannot be swapped by a proxy. A ~700-byte JSON cert is awkward in a header (size limits, needs base64, logging exposure). And it keeps the non-delegated request shape identical: no delegation field means the old code path, byte-for-byte.

5.2 Check order

Each check fails closed with the code shown (§9).

  1. Product policy: allow_delegation === false → delegation_not_allowed.
  2. Public cert check (verifyDelegation): strict shape (including: aud contains no *) → signature (§4) → time window (not_before / expires_at, 30 s clock tolerance) → aud equals this facilitator's issuer string exactly → delegate equals the authenticated AuthFetch identity → product equals body.product.
  3. Revocation: a revocation recorded for (issuer, id) → delegation_revoked.
  4. Scope: if the cert has scope, every entry must appear in the product's default scope (exact method + pathPattern), else delegation_scope_invalid. If the product has no default scope, the cert scope is used as-is. The session scope is the cert scope when present, otherwise the product default scope.
  5. Issuer entitlement: list I's entitlements for product, filter by the cert's receipt_txid, then body.receipt_txid (the agent may narrow, never widen), then plans, and pick the live one. None → 402 payment_required with code: "issuer_no_entitlement". Delegation never auto-enrolls the issuer, even on an auto_enroll product: presenting a cert must not create an entitlement for someone who is not present.
  6. Paid-only policy: allow_delegation === "paid_only" and the live entitlement is free or auto-enrolled → delegation_not_allowed.
  7. Mint with TTL = §5.3. A TTL of 0 means the cert or entitlement is used up → delegation_expired (or ttl_exhausted).
  8. Record the cert in the delegation store (first write wins) and update last_used_at, so GET /v1/delegations can list it.

5.3 Session TTL

ttl = min( requested ttlSeconds ?? 900,   // default delegated TTL
           3600,                          // facilitator max session TTL
           cert.max_session_ttl,
           cert.expires_at − now,
           entitlement.valid_until − now )

Default 900 s (15 min), and why: revocation (§7) stops new sessions immediately, but a JWT already minted stays valid at publishers that verify locally against the JWKS. So the default TTL is the worst-case revocation window for the common publisher setup. 15 minutes is short enough that "I revoked my agent" means something, and long enough that an agent re-mints only a few times an hour (one AuthFetch round-trip; cheap). It equals the existing non-delegated default (900 s) and common OAuth access-token practice, and unlike a non-delegated session the issuer can lower it further per cert with max_session_ttl. Publishers that need tighter revocation call POST /v1/session/introspect, which reflects revocation at once.

5.4 Response

The usual session fields, plus three delegated-only fields:

{ "token_type": "Bearer", "access_token": "…", "expires_in": 900, "expires_at": 1790000900,
  "identity": "<I>", "product": "imprintai-api", "plan": "free", "receipt_txid": "…", "token_hash_sha256": "…",
  "act": { "sub": "<A>" }, "dlg": "<cert id>", "delegation_expires_at": 1790086400 }

identity stays I. Non-delegated responses and JWTs are unchanged: same members, same order (covered by a test).

6. JWT claims and publisher behaviour

6.1 Claims

ClaimDelegated valueNotes
subIUnchanged meaning: the entitlement owner. Publisher code that keys users or quotas on sub needs no change.
act{ "sub": "<A>" }RFC 8693 actor claim: who is actually calling.
dlgcert idLets the publisher log, rate-limit or block one delegation.
scopecert scope, or the product defaultSame claim shape as today.
quota_sharecert quota_share (only if present)Advisory.
iss, aud, product, plan, receipt_txid, iat, exp, jtias todayexp follows §5.3.

act and dlg always appear together. @txpass/verify rejects a token that carries only one of them (missing_claims).

6.2 @txpass/verify

  • verifySessionJwt(token, jwks, opts) returns claims.act, claims.dlg, claims.quota_share and claims.scope on the verified result (TxpassSessionClaims).
  • isDelegatedSession(claims) returns true when act and dlg are both present.
  • opts.allowDelegated (default true): set it to false to refuse delegated sessions (code: "delegation_not_allowed").
  • scopeAllows(claims, method, path) returns true if the request matches the token scope (a token without scope allows everything).
  • verifyDelegation(cert, { audience, delegate, product, now }) is the portable, offline cert check (no revocation or entitlement: those are facilitator state). Browser bundles can import it from @txpass/verify/delegation (no Node built-ins, no jose).
  • quotaShareLimits(claims) returns { key: "<sub>:<dlg>", per_day?, per_month? } or null; quotaShareAllows(claims, { day, month }) returns false once the counts already used in the current UTC day/month reach a limit (§6.3).

6.3 What publishers MAY / SHOULD do

  • Publishers MAY enforce scope with scopeAllows. The facilitator already guarantees it is a subset of the product scope. v1 does not require enforcement, consistent with how the existing scope claim on non-delegated sessions is treated (a hint, not a gate).
  • Publishers SHOULD log act.sub and dlg next to sub for audit.
  • quota_share is advisory in v1 (decided). The facilitator carries it verbatim into the JWT and never enforces it. Meaning: "this delegation may consume at most N of the publisher's own request units per UTC day / month". Recommendation: publishers that care enforce it keyed by dlg (strictly sub + ":" + dlg, since a dlg is only unique per issuer), in addition to charging I's normal quota for sub. It is a cap per delegation, not a share across all of I's delegations. Example:

    import { quotaShareAllows, quotaShareLimits } from '@txpass/verify'
    
    const limits = quotaShareLimits(claims)            // null → no caveat
    if (limits) {
      const used = await counters.get(limits.key)      // { day, month } for this UTC day/month
      if (!quotaShareAllows(claims, used)) return res.status(429).json({ error: 'delegation_quota_exceeded' })
      await counters.incr(limits.key)
    }

7. Revocation (v1: facilitator-recorded)

All endpoints are AuthFetch-authenticated. Revocations are keyed by (authenticated identity, id), so a caller can only ever revoke their own delegations, and a cert id that someone else happens to reuse is unaffected.

RouteWhoBehaviour
POST /v1/delegations/:id/revoke { reason? }issuer IIdempotent. Works before the facilitator has ever seen the cert (revoke-before-use). Returns { ok, id, issuer, revoked_at, known }.
GET /v1/delegationsissuer ILists I's recorded delegations with status (active / expired / revoked), created_at, last_used_at, revoked_at, and the full cert, plus revoked_unseen (ids revoked before first use).
POST /v1/delegations { delegation }issuer or delegateOptional pre-registration so the cert shows in the list before first use. Verifies signature and aud. Anyone else gets delegation_forbidden.

Effects of a revocation:

  • New POST /v1/session with that cert → 403 delegation_revoked immediately.
  • POST /v1/session/introspect returns { active: false, reason: "delegation_revoked", dlg, revoked_at } for already-issued sessions carrying that dlg (and { active: false, reason: "delegation_expired", dlg } once the recorded cert window has passed). reason is additive (agent signer v1); invalid tokens still get a bare { active: false }.
  • Sessions that publishers verify locally stay valid until exp, at most max_session_ttl (default 15 min, §5.3).

Storage: memory, or Postgres tables delegations (issuer, id, delegate, product, not_before, expires_at, cert JSONB, created_at, last_used_at; PK (issuer, id)) and delegation_revocations (issuer, id, revoked_at, reason; PK (issuer, id)). They are created idempotently on first use, the same way the entitlement store creates its tables. They follow DATABASE_URL / TXPASS_STORE, like the entitlement store.

Revoking the underlying entitlement (the on-chain revoke receipt) also stops delegated sessions, because step 5.2.5 finds no live entitlement.

8. Product policy: allow_delegation

true (default) · false · "paid_only"

Precedence: overrides > env TXPASS_ALLOW_DELEGATION=true|false|paid_only > TXPASS_PRODUCT_CONFIG per-product entry > TXPASS_PRODUCT_CONFIG top-level > product table. The value is shown per product in GET /v1/products (delegation.allow_delegation) and on the admin-only GET /v1/admin/health, and advertised in GET /v1/protocol under delegation.

Free plans may delegate by default (decided: allow_delegation: true). Delegation doesn't create any new entitlement or quota: it spends I's existing (free) quota through another key, and a free user running their own agent is the core agent-first use case. Publishers who see abuse, or who want delegation as a paid feature, set paid_only (free and auto-enrolled entitlements are refused) or false. Imprint may switch to paid_only (TXPASS_ALLOW_DELEGATION=paid_only on its facilitator, or a per-product allow_delegation in TXPASS_PRODUCT_CONFIG); no code change needed.

9. Error codes

Body shape: { "error": "<code>", "message": "…", …extra }.

HTTPerrorWhen
400delegation_malformedShape check failed: unknown or missing field, null, bad hex, TTL out of range, lifetime over 90 days, self-delegation, bad revoke id.
400delegation_unsupported_issuer_typeissuer_type: "p256-webauthn" (reserved).
400delegation_audience_wildcard_not_allowedaud is "*" or contains * (v1 requires one exact facilitator issuer). Checked before the signature.
401delegation_bad_signatureSignature does not verify for issuer over the canonical bytes.
403delegation_not_yet_validnow + 30 s < not_before.
403delegation_expirednow − 30 s ≥ expires_at, or no TTL left.
403delegation_audience_mismatchaud is not this facilitator's issuer string.
403delegation_delegate_mismatchAuthenticated AuthFetch identity ≠ delegate.
403delegation_product_mismatchcert.product ≠ body.product.
403delegation_revokedA revocation is recorded for (issuer, id). Extra: dlg, revoked_at.
403delegation_scope_invalidCert scope is not a subset of the product scope. Extra: outside.
403delegation_not_allowedallow_delegation is false, or paid_only with a free/auto-enrolled entitlement.
403delegation_forbiddenPOST /v1/delegations by someone who is neither issuer nor delegate.
402payment_required + code: "issuer_no_entitlement"I has no live entitlement matching the pins. No BRC-105 headers.
401(existing)Missing or invalid AuthFetch identity.
403delegation_chain_invalid / delegation_chain_too_long / delegation_parent_mismatch / delegation_scope_exceeds_parent / delegation_parent_lacks_scope / delegation_root_mismatch0.3 chains: parent does not name a live device cert of the issuer, or the chain breaks a rule above.
403delegation_account_product_reservedA txpass-account cert used for a product session.
402txpass_pro_required (reason: "device_suspended")Agent cert issued by a device while Txpass Pro is lapsed on the root. Resumes on renewal. Agents delegated by the root directly are unaffected.
403device_revoked / device_expiredThe issuing device was removed or its approval expired.

@txpass/verify session failure code added: delegation_not_allowed (when allowDelegated: false).

Client note (AuthFetch 402 bug): @bsv/sdk 2.7.1 AuthFetch throws Unsupported x-bsv-payment-version response header on any 402 without BRC-105 headers and discards the body, so a stock client cannot see issuer_no_entitlement. @txpass/client safeAuthFetch / requestSession map that throw to a synthetic 402 { error: "payment_required", synthetic: true } (header x-txpass-client-synthetic: 1). See adopter-quickstart.md.

10. Test vector

Issuer private key = 1111 (decimal scalar); delegate private key = 2222. Input: product=imprintai-api, aud=https://facilitator.example, id=00112233445566778899aabbccddeeff, not_before=1790000000, expires_at=1790086400, scope=[POST /api/imprint], quota_share={per_day:20}, max_session_ttl defaulted to 900.

Canonical payload (signed bytes are its UTF-8):

{"aud":"https://facilitator.example","delegate":"0271b357df56cfc77291b75e5f550d72687c1bef84ff9eadb4946eb55516a89d6f","expires_at":1790086400,"id":"00112233445566778899aabbccddeeff","issuer":"0228b7f3a019749cce6fc677afa8fae72ec10e811ed4b04e1963143cef87654b75","issuer_type":"k1","max_session_ttl":900,"not_before":1790000000,"product":"imprintai-api","quota_share":{"per_day":20},"scope":[{"method":"POST","pathPattern":"/api/imprint"}],"v":"txpass/v1/delegation"}

sig:

3045022100a7d2d21db5e062cfb864ab9ba79f32340528c2214369786ed4ff0749f1b55d4302206b3398acba63b6b33b7a240e81bfc09fa5e81c95f3a2a66e95ceeca4eea594df

This vector is pinned by a test in packages/verify/test/delegation.test.ts.

11. Security considerations

  • Replay of the session request. Each mint is a fresh BRC-103 AuthFetch exchange (nonces, request signature by A), so a captured request cannot be replayed to get a new JWT. The cert alone is useless without A's private key: step 5.2.2 binds delegate to the authenticated identity.
  • Replay of the cert. The cert is meant to be reused until expires_at (one cert, many sessions). It is public by design; publishing it leaks only who delegated what to whom. Keep lifetimes short: ≤ 90 days is enforced and the library default is 7 days.
  • Delegate key theft. Whoever steals A can mint sessions until the cert is revoked or expires. Mitigations: issuer revocation (instant for new sessions), short max_session_ttl (bounds the tail), narrow scope / pins / quota_share, and per-dlg visibility for publishers. Proof-of-possession sessions (cnf + per-request proof, bsv-abstraction §2) are the next hardening step, so a leaked JWT is useless without A.
  • Issuer key theft. Out of scope for delegation: whoever holds I owns the entitlement anyway. Revoking I's delegations then requires I, which is the same trust model as the entitlement itself.
  • Audience confusion. aud binds a cert to exactly one facilitator issuer string, and the facilitator compares it to its own TXPASS_ISSUER, so a cert for staging cannot be used on production (different issuer strings). Wildcards are rejected ("*" or any *), so no cert is valid on every facilitator; a user who uses two facilitators signs two certs. The product is always bound separately (product must equal the requested product), and the minted JWT's own aud is the product as today.
  • Chains / re-delegation. Not supported in v1. delegate cannot issue a cert on I's behalf: the facilitator only looks up entitlements for cert.issuer, and a cert signed by A lists A as the issuer. There is no parent field, and unknown fields are rejected, so a chain cannot be smuggled in. Self-delegation (issuer == delegate) is rejected as pointless.
  • Cross-issuer id collisions. Ids are only meaningful per issuer: store rows and revocations are keyed by (issuer, id).
  • Signature malleability / canonical confusion. One canonical byte string per cert (§3), strict schema, lowercase-only hex. ECDSA S-malleability does not matter because the cert is identified by (issuer, id), not by a hash of sig.
  • Domain separation. Protocol [2,'txpass'] + key ID v1 delegate is used only for delegation certs, and the v field is inside the signed bytes.
  • Scope escalation. The cert scope must be a subset of the product scope. The facilitator never grants more than the product default.
  • No entitlement creation. Delegation never auto-enrolls I (§5.2.5).
  • Clock skew. 30 s tolerance on not_before / expires_at, matching session JWT verification.