Identities for entities did.bot
agent llm did
didbot plan handshake.md
23 kB
Markdown
at commit 18ba4fe0


id: handshake title: A server and its operator establish each other, with no shared secret status: open crates: [didbot-pds, didbot-serve, didbot-lexicon, didbot-attest, didbot-claim] dependsOn: [ownership, policy-store, alerts] exitCriterion: > An operator points a local command at a freshly booted PDS that vouches for nobody and is vouched for by nobody; running it writes a vouch in the operator's own repository, and the PDS's next poll finds it and considers itself owned. Deleting that record and waiting past the grace window pauses the PDS in a way an operator can tell apart from a deliberate pause, and restoring the record resumes it without anyone touching the halted server. #

handshake #

ownership builds the agent's half of bidirectional ownership and stops at "a command that writes a vouch" as open work. This epic is that command, plus the other side nothing describes yet: how the PDS itself decides it has been claimed, and what it does when the claim disappears. Read ownership, policy-store, e-stop, alerts and vouch first — this file points at each of them rather than restating what they already say.

A server with nobody vouching for it is not a lesser version of an owned server. It is the honest state of a fresh did:web identity, and a reader resolving it — over TLS the server obtained itself — should see exactly that: an empty repository, no operator claim, nothing to trust yet.

What the claim actually rests on #

Once the PDS is reachable, the operator runs a command locally, naming its hostname. The command waits for the server to answer over TLS, resolves its did:web document the ordinary way to read its public key, and cross-checks that unauthenticated read against a second one — com.atproto.server.describeServer's own did — before trusting either. Read on its own, that cross-check is weak: it only confirms the server agrees with itself across two routes, not that it is who the operator means to claim. It proves nothing about who actually controls the machine on the other end of the connection.

What makes the handshake sound is not that check. It is everything beneath it: the operator is the one who delegated this hostname's zone, didbot-tls proved control of that zone by passing ACME DNS-01, and the certificate that DNS-01 win produced is what TLS is now proving belongs to whoever answered the connection. The operator typing a hostname they themselves delegated is the root of trust; the DID-matches check the command performs is a consistency test on top of it, not the proof itself. A reader of this file who assumes the reported-operator-DID check is where the security lives has the load-bearing part backwards, which is exactly why it needs saying plainly rather than left to be inferred from the code.

Having passed both, the command writes the claim — bot.did.operator, the lexicon this epic ships — into the operator's own repository, naming the PDS's service DID and its public key. At no point does the operator hand the PDS a secret, or download the PDS's keys. The whole exchange is attestation read from two places neither side controls alone: the zone's DNS and the operator's own repository.

Provisioning, before any of this #

The PDS generates its own keypair and its own did:web DID before any handshake happens, as part of coming up: Provisioner::ensure_server_account in crates/didbot-pds/src/provision.rs already does this on every boot, idempotently, refusing to replace an existing key so a restart never produces an account whose document no longer matches signatures it already made. Its document is served at /.well-known/did.json immediately, over TLS the server obtained through the same ACME DNS-01 orchestration agent-accounts built.

Provisioner::ensure_server_account now also opens a ledger entry and publishes a bot.did.registration for the server's own account, alongside its bsky profile, under a new #service union member rather than #host: a PDS is software that may run on more than one physical host over its life (a redeploy, a fleet behind one hostname), so it is not "a machine" in the sense #host means it — see plan/fleet.md. owner is populated once Provisioner::confirm_ownership is called and left absent before then; the lexicon's top-level owner field is now optional for exactly this case. See this file's own Done list below.

Becoming owned, and un-becoming it #

The PDS polls for its operator record on an interval independent of policy-store's own (that epic has not shipped a poll to share yet), for the same reason policy will need one: no per-repository subscription to a server this deployment does not run. didbot-serve's OwnershipPoll fetches bot.did.operator directly — the record is keyed by this PDS's own hostname, so this is one getRecord, not a scan — and when it finds a claim naming this PDS's DID and current public key, didbot_pds::Ownership considers itself owned and Provisioner::confirm_ownership writes the owner field above.

Revocation runs on the same poll, and it is not decorative. When policy-store ships its own poll of the operator's repository, the two should likely share one fetch rather than each holding an independent one against the same server — left as that epic's problem to solve when it exists; this epic's poll does not yet coordinate with it, because there is nothing to coordinate with.

See this file's Done list for the grace window, PAUSE-not-REVOKE and the Cause distinction, all shipped together with the poll itself.

The claim is not an authorization gate. The operator is configuration — didbot-pds's --owner, known at launch — and this server never discovers who runs it from the network. bot.did.operator is the operator's public statement, for third parties, that they own this PDS; what the poll enforces is that they keep it standing. A server nobody has ever claimed is Transition::StillUnowned, the ordinary state of a fresh identity: the grace window does not apply to it, because there is nothing yet to lapse from.

Watching, and why poll comes first #

The owner's stated preference is watching over polling, and it is worth being accurate about what atproto actually offers for it. com.atproto.sync.subscribeRepos is a whole-host firehose: it streams every repository the source PDS holds, with no per-repository filter. An operator's own account typically lives on a PDS somebody else runs, serving many other people's repositories, so subscribing to all of it in order to watch one repository is not a viable default for every deployment this project ships. A filtered stream from a relay — Jetstream supports filtering by DID and by collection — is the real "watch," and it depends on infrastructure this deployment does not control existing and staying up.

The decision is poll-first, with a filtered relay stream as a later optimisation layered on top rather than a replacement for it. The poll has to exist regardless of whether the stream ever lands: it is the fallback for when the relay is down, and a mechanism that only works when a third party's infrastructure is healthy is not the one an emergency-halt path can depend on alone.

The poll shipped, as didbot-serve::ownership_poll::OwnershipPoll — see this file's Done list. A filtered relay subscription is explicitly not — left open, for whenever it is worth the added dependency.

The same pattern, for a node's admission #

The same shape — a keypair generated where nothing can read it off a shared secret, its public half written into the operator's own repository, the server reading the allowlist from a place it already reads other things from — retires the shared secret for node admission too, not just for the operator relationship. A laptop generates a keypair, the operator writes its public half into their own repository, and the PDS reads the node allowlist from the same poll that reads the operator vouch and the policy record.

NodeCredentialBackend in crates/didbot-attest/src/node_credential.rs already takes (node_id, VerifyingKey) pairs and verifies against the public half alone — it needs no new backend to do this, only a source for the list that is not a file an operator edits by hand on the server's own box. That would put the node allowlist in the tier node already wants it in: one an on-box attacker cannot reach, because writing it requires the operator's own key rather than anything reachable from the server's host.

This belongs to node to build, not to this epic: node bootstrap has its own lifecycle, install story and privilege boundary that this file has no reason to duplicate. What this epic contributes is the pattern and the poll it already has to build for the operator vouch — a second thing to read off the same repository on the same schedule is a small addition once the first exists, and a much larger one built from nothing.

A host arrives before anybody has vouched for it #

The shape above assumes the operator already holds the host's public key. On a laptop they do, because they are sitting at it. On a machine that comes up on its own they do not, and the gap is where a design starts asking a person to transcribe a key.

A third leg closes it. The host mints its keypair, presents the public half, and the server reserves an identity for it: a name from the pool, an account in AccountState::Provisioning, and a document that resolves and publishes that key. That state already refuses external writes, serves no repository and reaches no relay, so a reservation is something a stranger can look up and nothing can act as. The operator approves it afterwards by writing the ordinary bot.did.operator record naming that DID.

The sequence, end to end:

  1. Something on the host runs didbot-become-host. It mints a keypair and asks the server to reserve an identity.
  2. The server admits the request if the pending queue has room, and leaves the reservation sitting there.
  3. The host receives its DID and writes it where that host writes things — a log file, a console, the platform's own output.
  4. The operator reads it there, or from a list of what is pending.
  5. On their own machine they run the claim command with that DID, already signed in. It resolves the document and reads the key the reservation published, exactly as identify already does for a server.
  6. It writes bot.did.operator into the operator's own repository, naming that DID and that key.
  7. The server's next poll finds the record and finishes the lifecycle.
  8. Every poll after that reads the record again. The server keeps a mirror of it in its own repository, and the mirror follows the record: deleted, re-keyed, pointed elsewhere or expired at the operator, it stops vouching here at the next poll, and the confinement poll quarantines the host and everything beneath it at its next pass. Written again, it lifts them the same way. A poll that cannot read the operator's repository changes nothing.

Nothing in that sequence transcribes key material, and steps 1 through 3 need neither the operator nor a channel to them.

Open questions, recorded rather than answered #

Done #

Not done, and not claimed above: the unauthenticated status endpoint (left to ownership.md, per this file's own instruction); the alert record (alerts.md has no channel yet to write into); coordinating this poll's interval with policy-store's, which does not exist yet; wiring the grace window and poll interval to configuration rather than compiled-in constants; verification of either the poll or didbot-claim's login step against a real operator PDS (only exercised against fakes and a loopback server in this epic's own tests, of which crates/didbot/tests/handshake.rs is now the widest: the operator's writer and the server's reader in one process, against a loopback repository, across the whole grace window).