From 900fcf33d60186738d78fdfca8572dc4afb2feff Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Tue, 22 Sep 2026 12:05:37 -0400 Subject: [PATCH] docs(local-tls): say the two one-time steps a machine needs Trusting the authority the server signs, and letting the binary bind 443, are the two things a checkout cannot do for itself; running-locally.md now leads with both and every request on the page goes to the zone over https. first-hour.md follows the same sequence, testing.md gains the local TLS rows, and the dev scripts set the authority, the origin and DIDBOT_RESOLVE_PORTS in one place. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I365608e202ddbd2ec2a7c7bb369bdc990405c256 --- crates/didbot-config/src/sections.rs | 11 +- crates/didbot-operator/src/operate/account.rs | 3 +- crates/didbot-operator/src/operate/walk.rs | 2 +- crates/didbot-tls/src/lib.rs | 4 +- crates/didbot-tls/src/test_support.rs | 2 + docs/cli.md | 6 +- docs/first-hour.md | 63 ++++---- docs/running-locally.md | 137 ++++++++++++------ docs/testing.md | 2 + plan/dev-setup.md | 51 +++---- plan/tls-sources.md | 2 +- 11 files changed, 170 insertions(+), 113 deletions(-) diff --git a/crates/didbot-config/src/sections.rs b/crates/didbot-config/src/sections.rs index c2427ca8..17e4bdd0 100644 --- a/crates/didbot-config/src/sections.rs +++ b/crates/didbot-config/src/sections.rs @@ -66,12 +66,11 @@ pub struct ZoneSection { /// written into every DID document this server publishes. No flag /// mirrors this, only this key. /// - /// Unset, it is derived from the primary zone: `https://` for a - /// real zone, and `http://:` under `.localhost`, where a - /// developer's browser has to reach the bound port over plain HTTP. - /// Set it when the address agents reach is not the address this process - /// binds — a deployment behind a proxy that terminates TLS on a - /// different hostname, for instance. + /// Unset, it is derived from the primary zone: `https://`, plus + /// the bound port when this run did not take 443. Set it when the + /// address agents reach is not the address this process binds — a + /// deployment behind a proxy that terminates TLS on a different + /// hostname, for instance. /// /// It is published, not just used: an endpoint that does not reach this /// server makes every account this run mints unreachable, and the DID diff --git a/crates/didbot-operator/src/operate/account.rs b/crates/didbot-operator/src/operate/account.rs index fefc3b20..ec95b331 100644 --- a/crates/didbot-operator/src/operate/account.rs +++ b/crates/didbot-operator/src/operate/account.rs @@ -18,8 +18,7 @@ //! [`admit`] is the whole flow after sign-in, over two seams: the human's //! PDS as [`RecordWriter`], [`RecordReader`], [`RecordDeleter`] and //! [`ServiceAuthMinter`], and the server as -//! [`AccountServer`], which is plain HTTP and is tested against an -//! in-process router. +//! [`AccountServer`], which is tested against an in-process router. use std::fmt; diff --git a/crates/didbot-operator/src/operate/walk.rs b/crates/didbot-operator/src/operate/walk.rs index 9b11d8a0..41f3aa18 100644 --- a/crates/didbot-operator/src/operate/walk.rs +++ b/crates/didbot-operator/src/operate/walk.rs @@ -4,7 +4,7 @@ //! Each edge is two records that agree: the account's own //! `bot.did.registration/self` names an operator, and that operator's //! repository holds `bot.did.operator/` whose subject is the account. -//! The walk reads both, over plain HTTP, and climbs to the operator; it +//! The walk reads both and climbs to the operator; it //! stops the first time the operator's repository is not on the server the //! account is, and refuses to climb more than [`MAX_EDGES`]. Which server //! that is comes from each document's own `#atproto_pds` endpoint, never diff --git a/crates/didbot-tls/src/lib.rs b/crates/didbot-tls/src/lib.rs index 19548224..c721b50a 100644 --- a/crates/didbot-tls/src/lib.rs +++ b/crates/didbot-tls/src/lib.rs @@ -21,6 +21,8 @@ //! disk, `0600` inside `0700`. //! * [`source`] -- which authority a zone's certificate comes from. One //! table, so ACME and the local authority can never both apply to a zone. +//! * [`test_support`] -- certificates for a test standing up a server over +//! TLS, behind the `test-support` feature so nothing ships it. //! * [`local`] -- the authority a developer's machine trusts, for a zone no //! public authority can issue for. Read this module for what bounds a //! certificate that sits in a trust store. @@ -59,7 +61,5 @@ pub mod resolver; pub mod source; pub mod storage; -/// Certificates for a test standing up a server over TLS. Behind the -/// `test-support` feature, so nothing ships it. #[cfg(any(test, feature = "test-support"))] pub mod test_support; diff --git a/crates/didbot-tls/src/test_support.rs b/crates/didbot-tls/src/test_support.rs index 37d45731..dc1ee566 100644 --- a/crates/didbot-tls/src/test_support.rs +++ b/crates/didbot-tls/src/test_support.rs @@ -1,5 +1,7 @@ //! Certificates for a test that needs a server a real client can verify. //! +//! Behind the `test-support` feature, so nothing ships it. +//! //! Every client in this workspace verifies what it connects to, and there is //! no way to switch that off — so a test that stands up a server over TLS //! has to hand its client an authority to trust. [`issue_for`] is that: a diff --git a/docs/cli.md b/docs/cli.md index 7700d250..2a01dae7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -531,6 +531,6 @@ Both read the same before or after the verb. `didbot register` takes `--server ` or reads `DIDBOT_PDS`; `didbot oauth` names its server through the daemon, or through `DIDBOT_PDS` when run by name. A server is spelled the same way in both, a hostname such as `pds.example` or a URL -such as `http://nc.localhost:3413`; a `.localhost` name is reached over -plain HTTP. `DIDBOT_PDS` is not a credential, and the dispatcher passes it -through. +such as `https://nc.localhost:3413`. A hostname with no port is reached on +443, unless `DIDBOT_RESOLVE_PORTS` says that zone answers somewhere else. +`DIDBOT_PDS` is not a credential, and the dispatcher passes it through. diff --git a/docs/first-hour.md b/docs/first-hour.md index ef47c6ce..412715a1 100644 --- a/docs/first-hour.md +++ b/docs/first-hour.md @@ -6,6 +6,17 @@ pipeline whose runs sign in, and a revocation, in the order they work. [Who operates an account](ownership.md) is what each step means and [the command line](cli.md) is every flag; this page is the sequence. +Every server here serves TLS, so before anything else do the two one-time +steps in [running the stack locally](running-locally.md#two-one-time-steps): +trust the local certificate authority, and let `didbot-pds` bind 443. This +page puts its server on 3413 instead, which is what `DIDBOT_RESOLVE_PORTS` +below is for — a `did:web` carries no port, so every tool has to be told +where the zone answers. + +```sh +export DIDBOT_RESOLVE_PORTS=nc.localhost=3413 +``` + ## Two stand-ins `didbot operate` signs in to your own atproto account in a browser, and @@ -16,23 +27,25 @@ are the harness's, left running on fixed ports with the session ```sh export NEWCOMER_CONFIG=$HOME/first-hour/human/config -NEWCOMER_SERVERS='did:web:nc.localhost%3A3413' \ +export DIDBOT_LOCAL_CA=$HOME/.local/state/didbot/local-ca +NEWCOMER_SERVERS='did:web:nc.localhost' \ cargo test -p didbot --test scenarios the_stand_ins_by_hand -- --ignored --nocapture ``` -It prints the human's DID, `did:web:human.localhost%3A3415`, the issuer, -`http://127.0.0.1:3416`, and a mint at `http://127.0.0.1:3417/` that -answers an ID token for `?aud=&=`. Every -`didbot operate` below runs with `XDG_CONFIG_HOME=$NEWCOMER_CONFIG`. On a -real deployment the two are your own PDS and your platform's issuer, and -the browser opens once. +It prints the human's DID, `did:web:localhost%3A3415` — a bare loopback +hostname is the one place a `did:web` may carry a port — the issuer at +`http://127.0.0.1:3416`, a mint at `http://127.0.0.1:3417/` that answers an +ID token for `?aud=&=`, and the authority it +signed the human's certificate with. Every `didbot operate` below runs with +`XDG_CONFIG_HOME=$NEWCOMER_CONFIG`. On a real deployment the two are your +own PDS and your platform's issuer, and the browser opens once. ## The server ```sh printf '[operator]\ngrace_window_hours = 0\n' > nc.toml didbot-pds --port 3413 --zone nc.localhost \ - --operator did:web:human.localhost%3A3415 --config nc.toml --data ./nc-data + --operator did:web:localhost%3A3415 --config nc.toml --data ./nc-data ``` The grace window is how long a deleted operator record keeps writing; @@ -40,8 +53,8 @@ zero makes the revocation at the end land at the next poll. Then, as the human: ```sh -didbot operate nc.localhost:3413 did:web:human.localhost%3A3415 -curl -X POST localhost:3413/xrpc/bot.did.pollOperatorClaim # look now, not in a minute +didbot operate nc.localhost did:web:localhost%3A3415 +curl -X POST https://nc.localhost:3413/xrpc/bot.did.pollOperatorClaim # look now ``` The server's log says `found the operator's record; the claim stands`. @@ -50,14 +63,14 @@ Until it does, every write is refused as `ServerNotReady`. ## An agent by hand ```sh -didbot operate kestrel.nc.localhost:3413 did:web:human.localhost%3A3415 --kind agent -didbot operate --check kestrel.nc.localhost:3413 +didbot operate kestrel.nc.localhost did:web:localhost%3A3415 --kind agent +didbot operate --check kestrel.nc.localhost ``` The first prints the token file for `didbot-oauth`; the second walks one edge and exits 0. `--check` on the server's own name needs DNS delegation -a `.localhost` zone has none of; `--check nc.localhost:3413 --server -nc.localhost:3413` walks the server's own edge instead. +a `.localhost` zone has none of; `--check nc.localhost --server +nc.localhost` walks the server's own edge instead. ## A laptop host and its daemon @@ -66,14 +79,14 @@ the daemon binds the path the hook connects to: ```sh export DIDBOT_STATE=$HOME/first-hour/laptop -didbot register host laptop.nc.localhost:3413 --server nc.localhost:3413 +didbot register host laptop.nc.localhost --server nc.localhost ``` It prints a fingerprint and waits. On the human's machine, with the fingerprint it printed: ```sh -didbot operate laptop.nc.localhost:3413 did:web:human.localhost%3A3415 --fingerprint SHA256:... --creates agent +didbot operate laptop.nc.localhost did:web:localhost%3A3415 --fingerprint SHA256:... --creates agent ``` `register` then signs in and exits 0. `--creates agent` is the allowance @@ -82,7 +95,7 @@ is refused. Only then start the daemon: it holds the same key, and `register` refuses while a daemon holds it. ```sh -DIDBOT_PDS=nc.localhost:3413 didbot-agentd +DIDBOT_PDS=nc.localhost didbot-agentd echo '{"hook_event_name":"SessionStart","session_id":"sess-1","cwd":"/tmp"}' | didbot-hook didbot operate --check ``` @@ -95,23 +108,23 @@ walk climbs two edges. ## A pipeline and its runs ```sh -didbot operate deploy.nc.localhost:3413 did:web:human.localhost%3A3415 \ +didbot operate deploy.nc.localhost did:web:localhost%3A3415 \ --kind pipeline --oidc http://127.0.0.1:3416 repository_id=456789 -curl -s 'http://127.0.0.1:3417/?aud=did%3Aweb%3Anc.localhost%253A3413&repository_id=456789' > id-token -DIDBOT_PDS=nc.localhost:3413 didbot oauth pending --token-file id-token +curl -s 'http://127.0.0.1:3417/?aud=did%3Aweb%3Anc.localhost&repository_id=456789' > id-token +DIDBOT_PDS=nc.localhost didbot oauth pending --token-file id-token ``` -The token's `aud` is the server's DID with its port as `%3A`, encoded -once more for the URL. Each token is a session once: presenting it again -is refused as already presented. +The token's `aud` is the server's DID, encoded once for the URL. Each +token is a session once: presenting it again is refused as already +presented. ## Revoking the host -The human deletes `bot.did.operator/laptop.nc.localhost:3413` from their +The human deletes `bot.did.operator/laptop.nc.localhost` from their own repository, and nudges the poll: ```sh -curl -X POST localhost:3413/xrpc/bot.did.pollOperatorClaim +curl -X POST https://nc.localhost:3413/xrpc/bot.did.pollOperatorClaim didbot operate --check # exit 1 ``` diff --git a/docs/running-locally.md b/docs/running-locally.md index 876a616d..72feeb5d 100644 --- a/docs/running-locally.md +++ b/docs/running-locally.md @@ -27,6 +27,50 @@ second checkout. Each script rebuilds its own half, runs that half's tests, and then runs in the foreground so the terminal *is* the log. +### Two one-time steps + +The server always serves TLS, here as much as anywhere, because an account's +`did:web` resolves over `https` and the atproto libraries a browser runs +accept nothing else. Two things a checkout cannot do for itself: + +**Trust the authority.** The first run signs a certificate authority into +`$XDG_STATE_HOME/didbot/local-ca` (`~/.local/state/didbot/local-ca`) and +prints where it is. It can vouch for `localhost` and what sits under it and +for nothing else — the certificate carries a name constraint saying so — so a +machine that trusts it has trusted exactly this. The scripts point the Rust +and Node tools at it through `DIDBOT_EXTRA_CA_CERTS` and +`NODE_EXTRA_CA_CERTS`; `curl` and everything else reading the system store +want it installed there, and a browser keeps a store of its own: + +```sh +CA=~/.local/state/didbot/local-ca/ca.pem +sudo cp "$CA" /usr/local/share/ca-certificates/didbot-local.crt +sudo update-ca-certificates +certutil -d sql:$HOME/.pki/nssdb -A -t C,, -n didbot-local -i "$CA" # Chrome and Firefox +``` + +`certutil` comes from `libnss3-tools` on Debian and Ubuntu. + +**Let the server bind 443.** A `did:web` carries no port, so every account +this stack mints resolves at `https://.agents.localhost/` — port 443, +which a process does not get for free: + +```sh +cargo build --bin didbot-pds +sudo setcap cap_net_bind_service=+ep "$(cargo metadata --format-version 1 \ + | jq -r .target_directory)/debug/didbot-pds" +``` + +Rebuilding the binary drops the capability, so this is one command to keep +somewhere rather than one to forget. The deployed image runs with exactly that +capability and nothing else (`infra/pds/`). + +A second stack on one machine cannot also have 443. It takes a port and says +so: `DIDBOT_PDS_PORT=3100 DIDBOT_ZONE=beta.localhost ./scripts/dev-pds.sh` +sets `DIDBOT_RESOLVE_PORTS=beta.localhost=3100`, which every tool here reads +when it resolves a `did:web` under that zone. A browser reads its own +equivalent instead — Chrome's `--host-resolver-rules`. + For the server alone: ```sh @@ -231,7 +275,7 @@ before anything can read the repository at all. The server writes it, from its own bookkeeping, as part of provisioning: ```sh -curl -s "localhost:3000/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=bot.did.registration" | jq '.records[0].value' +curl -s "https://agents.localhost/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=bot.did.registration" | jq '.records[0].value' ``` It says the DID, when the server provisioned it, the human who answers @@ -245,7 +289,7 @@ The account cannot destroy it. All four routes that could — on the way to the store: ```sh -curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:3000/xrpc/com.atproto.repo.createRecord \ +curl -s -o /dev/null -w '%{http_code}\n' -X POST https://agents.localhost/xrpc/com.atproto.repo.createRecord \ -H 'content-type: application/json' \ -d "{\"repo\":\"$DID\",\"collection\":\"bot.did.registration\",\"record\":{}}" ``` @@ -271,9 +315,9 @@ picture. The profile is what an ordinary atproto client reads — it carries the announced. ```sh -curl -s "localhost:3000/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.actor.profile&rkey=self" | jq '.value' -CID=$(curl -s "localhost:3000/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.actor.profile&rkey=self" | jq -r '.value.avatar.ref["$link"]') -curl -s "localhost:3000/xrpc/com.atproto.sync.getBlob?did=$DID&cid=$CID" -o avatar.png +curl -s "https://agents.localhost/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.actor.profile&rkey=self" | jq '.value' +CID=$(curl -s "https://agents.localhost/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.actor.profile&rkey=self" | jq -r '.value.avatar.ref["$link"]') +curl -s "https://agents.localhost/xrpc/com.atproto.sync.getBlob?did=$DID&cid=$CID" -o avatar.png ``` The picture is drawn from the DID and from nothing else: the same account is @@ -309,8 +353,8 @@ DID — provisioned, named, released, pinned, unpinned, deprovisioned — writte beside the account and kept after it: ```sh -curl -s "localhost:3000/xrpc/bot.did.getAccountLedger?did=$DID" | jq . -curl -s localhost:3000/xrpc/bot.did.listAccountLedgers | jq '.ledgers | length' +curl -s "https://agents.localhost/xrpc/bot.did.getAccountLedger?did=$DID" | jq . +curl -s https://agents.localhost/xrpc/bot.did.listAccountLedgers | jq '.ledgers | length' ``` Delete the agent and ask again. `listAccounts` no longer has it and the ledger @@ -332,8 +376,8 @@ handle's hostname answers with the DID, and the DID's own hostname answers with a document claiming that handle back: ```sh -curl -s -H 'Host: basalt-otter.agents.localhost' localhost:3000/.well-known/atproto-did -curl -s -H 'Host: kestrel.agents.localhost' localhost:3000/.well-known/did.json | jq .alsoKnownAs +curl -s https://basalt-otter.agents.localhost/.well-known/atproto-did +curl -s https://kestrel.agents.localhost/.well-known/did.json | jq .alsoKnownAs ``` A name nobody holds is a 404. `bot.did.listAccounts` is how to do both halves @@ -346,7 +390,7 @@ Two terminals for the write side. Each script rebuilds and tests its own part and then runs in the foreground, so the terminal *is* that part's log. ```sh -./scripts/dev-pds.sh # 3000 the personal data server +./scripts/dev-pds.sh # 443 the personal data server ./scripts/dev-swarm.sh # a population of synthetic agents, talking ``` @@ -396,11 +440,15 @@ Under `.localhost` it is the difference between a server that never polls and one that does, so the whole policy path — an operator's records, the poll, the digest, an enforced refusal — runs on one machine. +Two servers at once means at most one of them has 443, so both say where +they answer and every tool here resolves accordingly: + ```sh DIDBOT_PDS_DATA= DIDBOT_PDS_PORT=3100 DIDBOT_ZONE=opzone.localhost \ ./scripts/dev-pds.sh # the operator's own server DIDBOT_PDS_DATA= DIDBOT_PDS_PORT=3200 DIDBOT_ZONE=govzone.localhost \ - ./scripts/dev-pds.sh --operator did:web:operator.opzone.localhost%3A3100 + DIDBOT_RESOLVE_PORTS=opzone.localhost=3100,govzone.localhost=3200 \ + ./scripts/dev-pds.sh --operator did:web:operator.opzone.localhost ``` A server whose operator has written no claim yet is `unclaimed` and serves no @@ -459,7 +507,7 @@ waiting is the server's poll finding the claim after a nudge. ## Whether it is working ```sh -curl -s localhost:3000/health +curl -s https://agents.localhost/health ``` answers with what the personal data server holds and whether it is minting. @@ -479,6 +527,9 @@ write against a zone the server controls. Locally that requirement disappears: `*.localhost` resolves to loopback by RFC 6761, so `a1b2c3.agents.localhost` already points at this machine with nothing configured. +No public authority would issue for `*.agents.localhost` either, which is why +the server signs its own; see "Two one-time steps" above. + The development DNS backend therefore publishes nothing. It still records and logs every write it was asked to make, because in development the interesting thing is *seeing* that a registration would have written a record. It refuses @@ -513,7 +564,7 @@ reads through one. can — `websocat` is the usual one: ```sh -websocat "ws://localhost:3000/xrpc/com.atproto.sync.subscribeRepos?cursor=0" --binary +websocat "wss://agents.localhost/xrpc/com.atproto.sync.subscribeRepos?cursor=0" --binary ``` The frames are DAG-CBOR and will not print as text. `docs/conformance.md` says @@ -527,14 +578,13 @@ CARv1 file: a version 3 commit, signed by the account's key, over the root of a Merkle search tree keyed by every record it holds. ```sh -did=$(curl -s localhost:3000/xrpc/bot.did.listAccounts | jq -r '.accounts[0].did') -curl -sG localhost:3000/xrpc/com.atproto.sync.getRepo \ +did=$(curl -s https://agents.localhost/xrpc/bot.did.listAccounts | jq -r '.accounts[0].did') +curl -sG https://agents.localhost/xrpc/com.atproto.sync.getRepo \ --data-urlencode "did=$did" -o repo.car ``` -`--data-urlencode` rather than a plain query string, because an agent's DID -carries its port as `%3A` and pasting that into a URL hands the server a DID -with a colon in it, which is a different identifier and a 404. +`--data-urlencode` rather than a plain query string, because a DID is a +value in a query and a client that builds URLs properly encodes it. The `com.atproto.sync` reads — `getRepo`, `getBlocks`, `getRecord`, `getLatestCommit`, `getRepoStatus` and `listRepos` — each rebuild and sign a @@ -562,8 +612,8 @@ The other half is the signature. Take the account's public key out of the DID document this same server serves and check the commit against it: ```sh -curl -s -H "Host: $(echo "$did" | sed 's/^did:web://; s/%3A/:/')" \ - localhost:3000/.well-known/did.json | jq -r '.verificationMethod[0].publicKeyMultibase' +curl -s "https://$(echo "$did" | sed 's/^did:web://')/.well-known/did.json" \ + | jq -r '.verificationMethod[0].publicKeyMultibase' ``` That string is what `plan/pds-writes.md`'s exit criterion is about, and the @@ -582,7 +632,7 @@ rather than refused: you asked what you are missing, and all of it is true. The bytes cannot tell you which of the two happened, so the response says: ```sh -curl -sG localhost:3000/xrpc/com.atproto.sync.getRepo \ +curl -sG https://agents.localhost/xrpc/com.atproto.sync.getRepo \ --data-urlencode "did=$did" --data-urlencode "since=$rev" \ -o repo.car -D - | grep -i x-didbot-since ``` @@ -598,18 +648,17 @@ between compactions. ## Driving it by hand ```sh -curl -s localhost:3000/health +curl -s https://agents.localhost/health # Under .localhost a request with no credential creates beneath the # server's own account; see "Creating accounts" below for a real zone. -curl -s -X POST localhost:3000/xrpc/bot.did.createAccount \ +curl -s -X POST https://agents.localhost/xrpc/bot.did.createAccount \ -H 'content-type: application/json' \ -d '{"name":"scratch.agents.localhost","kind":"agent"}' -curl -s --resolve 'scratch.agents.localhost:3000:[::1]' \ - http://scratch.agents.localhost:3000/.well-known/did.json +curl -s https://scratch.agents.localhost/.well-known/did.json -curl -s localhost:3000/xrpc/bot.did.listAccounts +curl -s https://agents.localhost/xrpc/bot.did.listAccounts ``` ### The repository, over `com.atproto.repo.*` @@ -618,25 +667,25 @@ Seven routes, with the request and response bodies the upstream lexicons define. `repo` is a DID or a handle on every one of them. ```sh -REPO=did:web:kestrel.agents.localhost%3A3000 # or kestrel.agents.localhost +REPO=did:web:kestrel.agents.localhost -curl -s -X POST localhost:3000/xrpc/com.atproto.repo.createRecord \ +curl -s -X POST https://agents.localhost/xrpc/com.atproto.repo.createRecord \ -H 'content-type: application/json' \ -d "{\"repo\":\"$REPO\",\"collection\":\"com.example.thing\", \"record\":{\"text\":\"driving it by hand\",\"emoji\":\"🔍\", \"createdAt\":\"2026-08-26T10:00:00Z\"}}" # `rkey` is required here, and the lexicon still decides -- see below -curl -s -X POST localhost:3000/xrpc/com.atproto.repo.putRecord \ +curl -s -X POST https://agents.localhost/xrpc/com.atproto.repo.putRecord \ -d '{…,"collection":"app.bsky.actor.profile","rkey":"self",…}' -curl -s -X POST localhost:3000/xrpc/com.atproto.repo.deleteRecord -d '{…,"rkey":""}' -curl -s -X POST localhost:3000/xrpc/com.atproto.repo.applyWrites -d '{"repo":…,"writes":[…]}' +curl -s -X POST https://agents.localhost/xrpc/com.atproto.repo.deleteRecord -d '{…,"rkey":""}' +curl -s -X POST https://agents.localhost/xrpc/com.atproto.repo.applyWrites -d '{"repo":…,"writes":[…]}' -curl -s "localhost:3000/xrpc/com.atproto.repo.getRecord?repo=$REPO\ +curl -s "https://agents.localhost/xrpc/com.atproto.repo.getRecord?repo=$REPO\ &collection=app.bsky.actor.profile&rkey=self" -curl -s "localhost:3000/xrpc/com.atproto.repo.listRecords?repo=$REPO\ +curl -s "https://agents.localhost/xrpc/com.atproto.repo.listRecords?repo=$REPO\ &collection=com.example.thing&limit=5" -curl -s "localhost:3000/xrpc/com.atproto.repo.describeRepo?repo=$REPO" +curl -s "https://agents.localhost/xrpc/com.atproto.repo.describeRepo?repo=$REPO" ``` Every failure is `{"error":…,"message":…}` with a name a client can switch on: @@ -664,7 +713,7 @@ its CAR export. A precondition that does not hold refuses the write with re-reading and retrying works: ```sh -curl -s -X POST localhost:3000/xrpc/com.atproto.repo.putRecord \ +curl -s -X POST https://agents.localhost/xrpc/com.atproto.repo.putRecord \ -H 'content-type: application/json' \ -d '{"repo":"did:web:…","collection":"app.bsky.actor.profile","rkey":"self", "swapRecord":"bafyrei…","record":{…}}' @@ -692,7 +741,7 @@ account has — leaves the key to the caller, which is why the writes below name it: ```sh -curl -s -X POST localhost:3000/xrpc/com.atproto.repo.createRecord \ +curl -s -X POST https://agents.localhost/xrpc/com.atproto.repo.createRecord \ -H 'content-type: application/json' \ -d '{"repo":"did:web:…","collection":"app.bsky.actor.profile", "record":{"displayName":"scratch","createdAt":"2026-01-01T00:00:00Z"}}' @@ -719,10 +768,10 @@ blocks that prove that one: the commit, the tree nodes from its root down to where the key sits, and the record itself. ```sh -uri=$(curl -sG localhost:3000/xrpc/com.atproto.repo.listRecords \ +uri=$(curl -sG https://agents.localhost/xrpc/com.atproto.repo.listRecords \ --data-urlencode "repo=$did" --data-urlencode collection=com.example.thing \ --data-urlencode limit=1 | jq -r '.records[0].uri') -curl -sG localhost:3000/xrpc/com.atproto.sync.getRecord \ +curl -sG https://agents.localhost/xrpc/com.atproto.sync.getRecord \ --data-urlencode "did=$did" --data-urlencode collection=com.example.thing \ --data-urlencode "rkey=${uri##*/}" -o proof.car goat repo inspect proof.car # the same commit as the export, same tree root @@ -744,11 +793,11 @@ The other three sync methods answer the questions a consumer asks around all this: ```sh -curl -sG localhost:3000/xrpc/com.atproto.sync.getLatestCommit \ +curl -sG https://agents.localhost/xrpc/com.atproto.sync.getLatestCommit \ --data-urlencode "did=$did" | jq # {"cid": …, "rev": …} -curl -sG localhost:3000/xrpc/com.atproto.sync.getRepoStatus \ +curl -sG https://agents.localhost/xrpc/com.atproto.sync.getRepoStatus \ --data-urlencode "did=$did" | jq # {"did": …, "active": true, "rev": …} -curl -s "localhost:3000/xrpc/com.atproto.sync.listRepos?limit=10" | jq +curl -s "https://agents.localhost/xrpc/com.atproto.sync.listRepos?limit=10" | jq ``` `getLatestCommit` is the cheap way to ask "am I caught up": its `cid` is the @@ -765,7 +814,7 @@ anything a lexicon declares as a `blob`. Uploading one gives back the reference a record puts in a field: ```sh -curl -s -X POST localhost:3000/xrpc/com.atproto.repo.uploadBlob \ +curl -s -X POST https://agents.localhost/xrpc/com.atproto.repo.uploadBlob \ -H 'content-type: image/png' \ -H "authorization: Bearer $TOKEN" \ --data-binary @some.png | jq @@ -779,10 +828,10 @@ The CID in that reference is the sha-256 of the bytes, so it can be checked without trusting this server: ```sh -curl -sG localhost:3000/xrpc/com.atproto.sync.getBlob \ +curl -sG https://agents.localhost/xrpc/com.atproto.sync.getBlob \ --data-urlencode "did=$did" --data-urlencode "cid=$cid" -o back.png cmp back.png some.png && echo identical -curl -sG localhost:3000/xrpc/com.atproto.sync.listBlobs \ +curl -sG https://agents.localhost/xrpc/com.atproto.sync.listBlobs \ --data-urlencode "did=$did" | jq ``` diff --git a/docs/testing.md b/docs/testing.md index cbc9e226..d8b2e995 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -27,6 +27,8 @@ command column is what runs it. | --- | --- | --- | | An ecosystem replica in one compose file: the reference PDS, an appview, an indigo relay, `goat`, an ACME directory and a resolver for the zone | none | none | | A local TLS and DNS story a foreign resolver accepts: a local CA the replica trusts, so `did:web` resolves over HTTPS | none | `crates/didbot-tls/tests/acme_conformance.rs` drives ACME against an in-process directory; nothing yet issues into a replica | +| Local TLS: a `.localhost` zone served from an authority kept per machine, reused across starts, refused for any other zone, and verified by a real client | `cargo test -p didbot-tls` | `crates/didbot-tls/tests/local_authority.rs` completes a handshake against the leaf a zone serves and shows the authority's name constraint refusing one outside `.localhost`; `src/local.rs` covers reuse and the refusal | +| Every binary in one scenario over TLS, against one authority, with each zone's port named | `scripts/scenarios.sh` | `crates/didbot/tests/scenarios.rs` starts `didbot-pds` with `--tls local` and drives `didbot`, `didbot-agentd` and a browserless dashboard sign-in over `https` | | Sandboxed agent hosts: several containers, each with the plugin and the daemon | none | none | | A red-team brief handed to agents, with the replica's oracles deciding | none | none | diff --git a/plan/dev-setup.md b/plan/dev-setup.md index ddd360ce..4dd1b88b 100644 --- a/plan/dev-setup.md +++ b/plan/dev-setup.md @@ -54,35 +54,12 @@ session register itself would let a model claim to be any session. ## Setup - [ ] **Name the steps a machine cannot take for you** and stop cleanly on - each: installing a local certificate authority, binding port 443, - choosing a zone, and restarting the harness so new hooks load. The - restart is done. The other three arrive with TLS. -- [ ] **Pick the TLS story and implement one.** Deferred, with two of the three - candidates now answered. - - `mkcert` is declined. It installs a certificate authority into a person's - trust store, which is the most invasive thing this command could do, and - it destroys the value of the test it exists for: a third-party client - either holds that authority, in which case its trust path is not the one - it uses in production, or it does not, in which case it fails for a - reason that teaches nothing. No public authority would issue - `*.agents.localhost` either, so the certificate's shape differs from - production regardless. - - A public wildcard-to-loopback certificate with a published key needs no - trust store change and works in any client, but it forces somebody else's - hostname suffix — so the zone differs from production, so the `did:web` - values differ, and the DID is the field everything downstream keys on. - - That leaves the delegated development subzone, the only one where local - is identical to production, and it needs a DNS credential on a developer - laptop that [deployment](../docs/deployment.md) says should not sit - beside the signing keys. It waits for a zone. - - Nothing is blocked on this. TLS is load-bearing for - [pds-xrpc](pds-xrpc.md), which is `blocked`, and for [oauth](oauth.md). - Plain HTTP over `*.localhost` stays the default, because it works - offline, in seconds, and with no secret. + each: choosing a zone, and restarting the harness so new hooks load. + Both of the TLS ones are named in + [running locally](../docs/running-locally.md#two-one-time-steps) and + neither stops cleanly yet: a server whose authority nobody has trusted, + and one that cannot bind 443, both fail at the first request rather + than at startup with the command to run. ## The parts that are not the server @@ -96,6 +73,22 @@ session register itself would let a model claim to be any session. ## Done +- [x] **The TLS story is a local certificate authority.** The server signs one + into `$XDG_STATE_HOME/didbot/local-ca` on its first run, mints a + `` + `*.` leaf under it, and serves that; `--tls local` is + refused for any zone that is not under `.localhost`, and `--tls acme` + for one that is, from the one table in `didbot_tls::source`. The + authority carries an RFC 5280 name constraint permitting `localhost` + and what sits under it and nothing else, so a machine that trusts it + has trusted exactly this and a leaked key cannot impersonate a real + site — which is what the objection to `mkcert` was about. The zone and + the `did:web` values are the production ones, so nothing diverges. + + A `did:web` carries no port, so accounts answer on 443 and the binary + gets `cap_net_bind_service`. A machine running several servers at once + says where each zone answers in `DIDBOT_RESOLVE_PORTS`, which every + tool here reads and a browser is told through its own resolver rules. + - [x] **An account the configured server already holds is adopted rather than minted again.** `localhost` and `127.0.0.1` are two strings and one machine, and the hook does not guess which spellings of a host mean the diff --git a/plan/tls-sources.md b/plan/tls-sources.md index f169694e..90bc5733 100644 --- a/plan/tls-sources.md +++ b/plan/tls-sources.md @@ -35,7 +35,7 @@ hand over a replacement without interrupting anything. is built on it: `TlsSection::cert_source` (`crates/didbot-config/src/sections.rs:90`) is documented as "`acme` or a path to a fixed pair", and **nothing reads it** — `didbot-pds`'s - `--tls` accepts `plain` or `acme` and nothing else. A deployment that + `--tls` accepts `local` or `acme` and nothing else. A deployment that set `cert_source` to a path today would be silently ignored. Either wire it as part of this item or delete it; a configuration key that does nothing is worse than an absent one. This is the case for -- 2.51.2