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:
- Something on the host runs
didbot-become-host. It mints a keypair and asks the server to reserve an identity. - The server admits the request if the pending queue has room, and leaves the reservation sitting there.
- The host receives its DID and writes it where that host writes things — a log file, a console, the platform's own output.
- The operator reads it there, or from a list of what is pending.
- 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
identifyalready does for a server. - It writes
bot.did.operatorinto the operator's own repository, naming that DID and that key. - The server's next poll finds the record and finishes the lifecycle.
- 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).