--- 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](ownership.md) 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](ownership.md), [policy-store](policy-store.md), [e-stop](e-stop.md), [alerts](alerts.md) and [vouch](vouch.md) 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. - [ ] **The unauthenticated status endpoint this leans on.** [ownership](ownership.md)'s "three statements" item names it — *"the server answers its controlling DID on an unauthenticated status endpoint"* — and it is still open there. This epic's command is the first concrete consumer; build it once, in `ownership`, rather than inventing a second answer here. ## 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](agent-accounts.md) 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](policy-store.md)'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](policy-store.md) 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. - [ ] **The alarm goes through [alerts](alerts.md).** Not built: `alerts.md` itself has no lexicon, no writer and nothing closed yet, so there is no channel to write into. `OwnershipPoll` logs a `tracing::warn` on the transition into a lapse instead, which is not this item — an operator not watching this process's own logs sees nothing. Left open, honestly, rather than approximated with a record shape invented here for an epic that has not designed one yet. ## 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](node.md) 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](node.md) 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. - [ ] **Point [node](node.md) at this pattern once the operator poll exists**, rather than either epic inventing its own version. ### 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. - [ ] **What the operator matches on scales with what the host can show.** At a laptop, the name is on the console in front of them. On a cloud instance with no shell access, the reservation carries the platform's own signed identity document, so the pending entry names an identifier the operator reads off that platform's console. - [ ] **The lexicon change is a description, not a shape.** A reserved host has a DID and therefore a hostname, so the record key stays a hostname and `subject` and `subjectKey` both stay required and unchanged. What needs editing is `subject`'s prose, which says the service DID of a server. - [ ] **Approving one host at a time is the whole design for now.** A record that vouched for a *class* — any host presenting a valid platform identity for one account and region — is what a fleet would need, and it turns the record from a relationship into a predicate. Manual, once per host, is fine until something autoscales. - [ ] **The same reservation serves an agent context.** [subagents](subagents.md) wants an identity that exists before it is used, for the same reason a host does: it moves the checking off the moment of the write. One state, one transition, two subjects. ## Open questions, recorded rather than answered - [ ] **What happens when the operator's own PDS is down during first run.** The command in this epic's exit criterion is a one-time operation and it blocks on exactly the server it has the least control over. There is no answer here yet for what an operator does in that window beyond "wait and retry." - [ ] **Operator rotation, and whether more than one operator is possible.** Every lexicon and every mechanism above assumes one operator DID. What replacing it looks like — and whether the design ever wants two simultaneous vouches to be valid — is undecided. ## Done - [x] **`bot.did.operator`**, a fresh lexicon (`lexicons/bot/did/operator.json`) rather than a rename of the retired `bot.did.vouch`: `subject`, `subjectKey`, `createdAt`, `expiresAt`, keyed by the PDS's own hostname so one claim exists per server and a direct `getRecord` verifies it with no listing. See its own doc comment for the field-by-field justification, and `plan/ownership.md`'s Done list for the correction to what that file already claimed was built. - [x] **The server-side half of provisioning, before any handshake.** `Provisioner::ensure_server_account` self-provisions a `bot.did.registration` `#service` record (not `#host` — see `plan/fleet.md`) with `owner` absent, alongside its existing `bsky` profile, at every boot. - [x] **What gates provisioning, and what does not.** A server is launched with its operator already configured (`--owner`); the `bot.did.operator` record is a public statement to third parties that the operator maintains as a standing obligation, not an authorization this server waits on. A fresh server whose operator has not run `didbot-claim` is `Transition::StillUnowned`, which is ordinary, and it provisions. What gates provisioning is `didbot_pds::ServerPolicy::provisions_accounts` — false until the three external readiness gates in `docs/server-lifecycle.md` clear — and nothing is thrown at startup: an emergency stop halts something that is *running*, so `ServerPolicy::may_halt` is false in every state before the operational one and the admin socket's `PAUSE`/`REVOKE` are refused there. - [x] **The pause a lapse causes.** `didbot_pds::Estop::throw_self(Mode::Pause)` and the existing `bot.did.provisionAgent` estop check refuse provisioning exactly as an operator's own pause would — no new account state, no second gate — and `Estop::Cause::OperatorMissing` keeps the two distinguishable at the source. `crates/didbot-serve/src/tests.rs`'s `an_unowned_server_refuses_to_provision_but_still_serves_its_identity` proves the refusal over real HTTP, including that `describeServer` keeps answering; `crates/didbot/tests/handshake.rs` proves it is reached only by a claim that lapses, and never by a server that was simply never claimed. This is the legitimate use `may_halt` protects rather than forbids: by the time a claim can lapse, something has been running. - [x] **The poll, the grace window, and reversibility.** `didbot_pds::ownership::Ownership` plus `didbot-serve::ownership_poll`: fetches the claim, compares DID *and* current public key (not DID alone — the module doc is explicit that the key check, not the DID match, is what does the work, and *why* it does the work rests on DNS delegation, ACME DNS-01 and TLS, none of which this module can see or re-prove), pauses past a six-hour grace window, resumes on its own when the claim reappears. `Estop::Cause` distinguishes this from an operator's own pause. - [x] **The command itself**, `didbot-claim` (`crates/didbot-claim`, binary `didbot-claim `) — a new crate rather than folding into `didbot-setup`, which wires a *machine's harness* to a stack and never authenticates as anyone's atproto account; see the crate's own top-level doc for the full case. It signs into the operator's own account with the narrow scope `atproto repo:bot.did.operator?action=create,update`, built from `didbot-serve::oauth::scope`'s grammar rather than hand-written (this answers the write-scope open question this list previously carried: the granular grammar expresses it exactly, no `rpc:`/`blob:` needed); waits for TLS; resolves the PDS's `did:web` document; cross-checks `describeServer`'s own `did` against it (see the correction above to "What the claim actually rests on" — this is a self-consistency check on the server's identity, not a check against an operator DID the server has no way to report before it is claimed); and writes `bot.did.operator` via `com.atproto.repo.putRecord`, keyed at the hostname, so a re-run after a key rotation updates the claim in place rather than conflicting with it. Every seam (`TlsProbe`, `DocumentFetcher`, `ServerDescriber`, `RecordWriter`) is exercised against a fake in the crate's own tests, including all three refusal paths — a missing signing key, a `describeServer` mismatch, an unresolvable DID document — proving nothing is written until every check passes. Authentication itself (`jacquard_oauth::client::OAuthClient::login_with_local_server`) is not exercised end to end: there is no real operator PDS reachable from where this was built. - [x] **The selector is the name the server chose, never a digest of the key.** A short fingerprint compared by a person bounds nothing on its own: it commits to no fresh transcript, so an attacker who can see one grinds keypairs offline until a truncation collides. A name drawn from the server's own pool cannot be ground toward, because no key produces it. The operator selects by name; a fingerprint is there so they can tell they approved the machine they meant, and this epic should say that rather than let a reader infer a second proof. `Registry::reserve` draws the name seeded by a key the server generated, never by anything the caller sent; `bot.did.listReservations` and `didbot-claim --server ` both select by it. - [x] **Decide who may read what is pending.** Unauthenticated exposes reserved names to anybody; scoping the list by an operator DID the reservation names is public information and narrows it without proving anything; requiring the operator's own service auth is the strict form. The middle one: `bot.did.listReservations?operator=` answers only for the operator a reservation named (the deployment's own when it named nobody), has no unscoped form, and is a disclosure route an operator can close. - [x] **`Provisioning` now means minutes rather than milliseconds.** Its own documentation treats an account stuck there as a crash artifact for the sweep to reap. A reservation is legitimately long-lived, so it carries its own expiry rather than leaning on staleness, and the transition it gains is a lifecycle change [account-types](account-types.md) should review with this. The expiry is the account's own `Retention::Until`, measured from `createdAt` like every retention, so the sweep reaps it with no special case and the state machine gains nothing: a reservation is `Provisioning` with a `#node` key, admitted through the same `Provisioning → Active` edge provisioning takes. - [x] **The claim command holds a session between runs.** The operator signs in once and enrolls hosts afterwards without a browser, so its tokens persist and refresh rather than being acquired per run. `didbot_claim::session_store::FileAuthStore`, `0600` under the operator's config directory; a run resumes what the last one left and opens a browser only when nothing resumes. **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).