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.