Identities for entities did.bot
agent llm did
didbot docs attestation.md
9.0 kB
Markdown
at main

Attestation #

Attestation is the check bot.did.createAccount makes before it mints an account: the proof the caller presents is verified against the document of the account it claims to be, at the moment of creation. It runs once. What it establishes is written as the two records of one edge — bot.did.operator/<name> in the creator's repository and bot.did.registration/self in the new account's — and those records, not the proof, are what anyone reads afterwards. Who operates an account is the model; this page is the keys each party holds, the check, what the credentials last, and what a lapse does.

crates/didbot-agentd signs the proofs a host and a registering machine present, crates/didbot-operator mints the human's, crates/didbot-pds holds the accounts and the policy tree, and crates/didbot-serve serves the routes and runs the polls. The daemon on an agent host draws the process that signs; the trust model says what a signature is worth.

The parties, and the keys each one holds #

party key where it lives who writes it
the human their own atproto account's their own machine and their own PDS the human
the server one repository signing key per account it hosts, and its own the deployment's store the server
a host #key-1, secp256k1 one file on the host, 0600 in a 0700 directory, under a flock for the daemon's life didbot register host, once
a service or pipeline an OIDC identity, or #key-1 the issuer's, or keys/<name>.key under $DIDBOT_STATE the issuer, or didbot register
an agent context none: a session the daemon's memory the server issues it

The human is a DID with a repository this deployment holds no credential for. didbot operate writes their bot.did.operator records there — one keyed by the server's hostname, and one per account they create — and proves each create to the server with the operator session the server minted when they signed in to it.

The server signs every repository it hosts. A hosted account's operator records are written by the server as that account, so every edge below the human is the server's word.

A host holds its key itself. didbot register host <name> mints it as the daemon's own, in $XDG_STATE_HOME/didbot/agentd or wherever DIDBOT_STATE names (crates/didbot-agentd/src/node.rs), and the server publishes the public half as #key-1 in the host's document. The private half crosses neither the daemon's socket nor the network; what leaves the machine is a public key and signatures. It is a file, and a copy of it is the host.

A service or pipeline is an OpenID Connect identity when the human gives --oidc: an ID token from that issuer carrying those claims is the account. A machine that runs didbot register service or didbot register pipeline holds a key under keys/<name>.key instead.

An agent context holds nothing. The daemon creates one account per context and keeps its session in memory.

The check #

bot.did.createAccount reads the parent off Authorization: Bearer and verifies it one way per shape; who operates an account tabulates the four. The JWT shape, which didbot-agentd signs (crates/didbot-agentd/src/jwt.rs), is ES256K over {iss, aud, lxm, exp, iat, jti}: iss the account, aud the server's DID, lxm the one method the token is for, exp five minutes out, jti sixteen random bytes the server spends once. bot.did.createSession takes the same token with lxm bot.did.createSession and answers a session.

Four gates close the route before the proof is read: Halted (the e-stop, which also fires when the server's own operator record has lapsed), ServerNotReady (the lifecycle — see the server lifecycle), RateLimitExceeded and AccountCapReached. The first three close bot.did.createSession too, and its calls spend the same per-address budget as createAccount's: reading its proof can also fetch an issuer's keys or a DID document. A proof that fails is refused with the reason in the message: the wrong audience, an expired or spent token, a signature no key in the document verifies, a claim the creator's OIDC identity names with another value. A create no allowance in the operator's repository covers is refused as NotAllowedToCreate.

A parked key is a public key and a fingerprint held at the server for minutes. bot.did.parkKey is rate-limited per address, lists nothing and mints nothing until didbot operate from the human's side names it, and a record older than the park does not admit it. Parks are held per key, so several may wait under one name and the operator admits one by fingerprint.

What the credentials last #

The account's session. DEFAULT_AGENT_TOKEN_TTL is 365 days from issue (crates/didbot-pds/src/credential.rs). One live token per account; issuing another revokes the first; the server keeps a SHA-256 digest and sends the bytes once. Nothing extends it and nothing shortens it: using it daily and leaving it idle for a year come to the same expiry, after which every route that takes it answers ExpiredToken. The daemon holds each context's in memory for as long as the context lasts; didbot register and didbot operate keep one under $DIDBOT_STATE/accounts/<name>.token. An account with a key or an OIDC identity mints a fresh one through bot.did.createSession whenever it needs one.

OAuth. ACCESS_TTL is 5 minutes and REFRESH_TTL is 14 days (crates/didbot-serve/src/oauth/token.rs). Every refresh rotates both tokens and sets the refresh expiry to fourteen days from that refresh (rotate in crates/didbot-pds/src/oauth.rs), so an app that refreshes at least fortnightly keeps one login indefinitely. Presenting a refresh token that has been rotated away, or one whose fourteen days have passed, ends the whole family. The app then signs in again, which makes a fresh decision record for the agent to answer, and the daemon approves it with that account's own session. Grants are durable across a restart for a --data run; the scope ceiling is asked again at every refresh and every write, so a login never outlives what current policy allows.

Erasing an account ends every credential it has at once — its session, its OAuth grants and its unredeemed authorization codes. A lock ends none of them; it refuses the write.

Withdrawals and losses #

A lock hung on an account is hung on everything live beneath it as Party::Parent, and comes off below only when it comes off above — see the account lifecycle. An account under any lock mints nothing new beneath itself, and keeps its key.

situation what happens who recovers it
a lock on a host creates beneath it are refused; the contexts beneath it keep their sessions and have their writes refused the party that hung the tag lifts it
the human deletes an operator record at the next poll after the grace window the edge lapses, the account and its subtree are quarantined, and every create beneath them is refused the human writes the record again; the next pass releases the subtree
the human's repository is unreachable every edge stays as it is; the server's own record has DEFAULT_GRACE_WINDOW, six hours plus one poll interval, before the server pauses itself and every route here answers Halted the human's PDS comes back
a host's key is lost the account keeps the key its document names and nothing can sign for it; its contexts keep the sessions they hold didbot register host under a new name and didbot operate for it; the human deletes the old host's record to stop what it created
the server is down longer than a refresh window proofs are unaffected, each made fresh for one call; sessions are unaffected; every OAuth refresh token last rotated more than fourteen days ago ends its family at the next attempt apps sign in again
the server restarts it comes back unclaimed: record writes, blob uploads, creates and bot.did.createSession answer ServerNotReady until the first poll finds the human's record for the server the poll, on its own

The confinement poll (crates/didbot-serve/src/confinement_poll.rs) re-derives every inherited tag from the tree on the same five-minute clock the operator poll runs on, so a lapsed edge and a lapsed top edge are noticed at the same rate.

The .localhost development exception #

Under localhost and any name beneath .localhost, bot.did.createAccount mints for a request carrying no proof; a request that does carry a proof is checked exactly as it is anywhere else. The server starts claimed, and no operator poll runs while --operator is the placeholder, which names no repository. Naming an operator runs the poll against that operator's repository, as anywhere else. Running locally is that stack.