Skip to main content

Module attestation

Module attestation 

Source
Expand description

§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

partykeywhere it liveswho writes it
the humantheir own atproto account’stheir own machine and their own PDSthe human
the serverone repository signing key per account it hosts, and its ownthe deployment’s storethe server
a host#key-1, secp256k1one file on the host, 0600 in a 0700 directory, under a flock for the daemon’s lifedidbot register host, once
a service or pipelinean OIDC identity, or #key-1the issuer’s, or keys/<name>.key under $DIDBOT_STATEthe issuer, or didbot register
an agent contextnone: a sessionthe daemon’s memorythe 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.

situationwhat happenswho recovers it
a lock on a hostcreates beneath it are refused; the contexts beneath it keep their sessions and have their writes refusedthe party that hung the tag lifts it
the human deletes an operator recordat the next poll after the grace window the edge lapses, the account and its subtree are quarantined, and every create beneath them is refusedthe human writes the record again; the next pass releases the subtree
the human’s repository is unreachableevery 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 Haltedthe human’s PDS comes back
a host’s key is lostthe account keeps the key its document names and nothing can sign for it; its contexts keep the sessions they holddidbot 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 windowproofs 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 attemptapps sign in again
the server restartsit 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 serverthe 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.