From dcf2aed22860201ef4beacb7daa143aac5d2c111 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Tue, 22 Sep 2026 16:03:56 -0400 Subject: [PATCH] docs: say what didbot operate asks for and what it presents The operator's own PDS grants the bot.did.operator repo scope and nothing wider; the create at the server carries the operator session. Co-Authored-By: Claude Opus 5.5 (1M context) Change-Id: I310a33d1fb692f9bd97e695b47827ff25152e90c --- crates/didbot-agentd/src/register.rs | 2 +- crates/didbot-agentd/src/registrar.rs | 3 +- docs/agentd.md | 4 +-- docs/attestation.md | 4 +-- docs/cli.md | 36 +++++++++++++----------- docs/ownership.md | 40 ++++++++++++++------------- docs/running-locally.md | 4 +-- plan/cli.md | 23 ++++++++------- plan/handshake.md | 10 ++++++- plan/ownership.md | 12 ++++---- site/src/data/architecture.ts | 2 +- 11 files changed, 76 insertions(+), 64 deletions(-) diff --git a/crates/didbot-agentd/src/register.rs b/crates/didbot-agentd/src/register.rs index 42a2045d..905e1297 100644 --- a/crates/didbot-agentd/src/register.rs +++ b/crates/didbot-agentd/src/register.rs @@ -350,7 +350,7 @@ mod tests { parked_key: Some(fake::fingerprint(&parked.2)), ..Default::default() }, - &Secret::new("service-auth"), + &Secret::new("operator-admits"), ) .await .unwrap(); diff --git a/crates/didbot-agentd/src/registrar.rs b/crates/didbot-agentd/src/registrar.rs index 96789e97..2df5712c 100644 --- a/crates/didbot-agentd/src/registrar.rs +++ b/crates/didbot-agentd/src/registrar.rs @@ -4,8 +4,7 @@ //! Every call that makes or opens an account carries one credential in //! `Authorization: Bearer`, and the server reads who the parent is off it: //! a session token, a JWT signed by a key in the parent's document -//! ([`crate::jwt`]), a service-auth token from the parent's own PDS, or an -//! OIDC ID token for the parent's issuer. Nothing here tells the server who +//! ([`crate::jwt`]), or an OIDC ID token for the parent's issuer. Nothing here tells the server who //! is asking in any other way. //! //! [`Registrar`] is the seam the daemon is tested through without a server; diff --git a/docs/agentd.md b/docs/agentd.md index aeb2f733..3456e6bd 100644 --- a/docs/agentd.md +++ b/docs/agentd.md @@ -478,8 +478,8 @@ Two records per edge, each written where the party below it cannot write. **The human operates the host.** They write `bot.did.operator/` into -their own repository and create the host with a service-auth token their -own PDS mints. The server reads that repository on a poll and holds no +their own repository and create the host with the operator session the +server minted when they signed in to it. The server reads that repository on a poll and holds no credential for it, so a compromised server or host can lose the relationship and cannot forge one; `crates/didbot-serve/src/operator_poll.rs` is the reader. A host that comes up before the human has run `didbot diff --git a/docs/attestation.md b/docs/attestation.md index 089acd41..32950f4b 100644 --- a/docs/attestation.md +++ b/docs/attestation.md @@ -30,8 +30,8 @@ a signature is worth. **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 a service-auth token -their own PDS mints for that one call. +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 diff --git a/docs/cli.md b/docs/cli.md index ed00b6a4..1d1adb33 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -120,14 +120,15 @@ help` alone prints the dispatcher's own help. ## The operator's commands -`didbot-operator` holds two sign-ins that never meet. `operate` authenticates -to the operator's *own* atproto account and writes `bot.did.operator` into -their own repository; the session it keeps is checked by the operator's own -PDS, and it is the only sign-in that admits anything. `login` starts the -deployment's own sign-in and keeps the session the deployment minted; -`estop`, `account`, `app` and `announce` present that session to the same -`/dashboard/api/*` routes the dashboard's buttons call, and admit nothing. -Neither sign-in's session works for the other. +`didbot-operator` holds two sign-ins. `operate` authenticates to the +operator's *own* atproto account and writes `bot.did.operator` into their +own repository; the session it keeps is checked by the operator's own PDS. +`login` starts the deployment's own sign-in and keeps the session the +deployment minted; `estop`, `account`, `app` and `announce` present that +session to the same `/dashboard/api/*` routes the dashboard's buttons call. +Admitting an account uses both: the record goes in with the first, and the +create at the deployment carries the second. Neither session works where +the other does.
@@ -194,8 +195,9 @@ Neither sign-in's session works for the other.
`didbot operate ` signs in to the operator's own account -through OAuth, with a scope no wider than writing `bot.did.operator` and, -for an account, one service-auth call at its server. A browser opens for the +through OAuth. It asks for one scope, +`repo:bot.did.operator?action=create&action=update&action=delete`, beside +`atproto`, for a server and an account alike. A browser opens for the consent screen once; the session is kept in `$XDG_CONFIG_HOME/didbot-operator/sessions.json` and resumed on the next run while its grant covers what the run asks for. What follows depends on the @@ -213,12 +215,14 @@ is read from the operator's own repository, with no credential: the name must end in `.` for a server that repository holds `bot.did.operator/` for, and `--server ` picks one when it sits under more than one. Nothing that answers at the name has a say. The -sign-in asks for `rpc:bot.did.createAccount?aud=did:web:` beside the -write, the operator's own PDS mints a service-auth token for that one call -first, then the record naming the account goes into the operator's -repository, keyed by the name, and the server creates `did:web:` with -the token: the operator's credential never reaches the server. A server that -refuses the create has the record taken back — deleted, or put back as it +record naming the account goes into the operator's repository, keyed by the +name. Then the server is asked to create `did:web:`, carrying the +operator session `didbot login --server ` kept. With no session kept +for that server, the command signs in the way `didbot login` does first, +and keeps the session. The server checks the session and reads the record; +it creates only when both stand. The credential for the operator's own +account never reaches the server. A server that refuses the create, +including for a session that has aged out, has the record taken back — deleted, or put back as it stood before the run — and the refusal says which. A key `didbot register` parked under the name is shown with its kind and fingerprint and admitted on a `y`; `--fingerprint SHA256:...`, copied from what `register` printed, diff --git a/docs/ownership.md b/docs/ownership.md index b39aad15..a3267eb4 100644 --- a/docs/ownership.md +++ b/docs/ownership.md @@ -147,33 +147,37 @@ server issued. `POST /xrpc/bot.did.createAccount` takes `{name, kind, key?, oidc?, parkedKey?}` — the child's hostname, its kind, and how the child will log -in afterwards — with the creator's proof in `Authorization: Bearer`. The -server checks, in this order: +in afterwards — with the creator's proof. A hosted creator's proof is in +`Authorization: Bearer`. The human's is the operator session `didbot login` +keeps, sent as the dashboard's cookie with its CSRF header. The server +checks, in this order: 1. It is taking accounts: not stopped, past its readiness gates, under the caller's provisioning rate limit and under its account cap. 2. The body: `kind` is one of the four, `name` sits under a zone it serves and is not reserved, and `key` and `parkedKey` are not both given. -3. The bearer resolves to a DID, by the table below. -4. Who that DID is. A hosted account must stand — active and unlocked — - and the human's record for the root above it must still be readable; - that record is re-read at most once a minute per root, and a repository - that cannot be read leaves the poll's last verdict standing. A DID not - hosted here must be the human this server was launched with, and the - human's repository must hold `bot.did.operator/`, or the create - is refused as `OperatorRecordMissing`. -5. A parked key was parked for this name and kind, and before the human's +3. Who is asking. A request with the operator session cookie is the human + this server was launched with, when the session is live and the CSRF + header matches it; any other session is refused as `NotSignedIn`. The + human's repository must then hold `bot.did.operator/`, or the + create is refused as `OperatorRecordMissing`. Otherwise the bearer + resolves to a hosted account, by the table below. That account must + stand — active and unlocked — and the human's record for the root above + it must still be readable; that record is re-read at most once a minute + per root, and a repository that cannot be read leaves the poll's last + verdict standing. A bearer naming a DID not hosted here is refused. +4. A parked key was parked for this name and kind, and before the human's record was written. -6. The DID is minted under the zone, the human's record names it as +5. The DID is minted under the zone, the human's record names it as `subject`, it does not already exist, and the deployment's own bounds hold: `[tree] max_depth`, `max_children` and `creates_per_hour`. -7. For a hosted creator, an allowance in the human's repository covers it: +6. For a hosted creator, an allowance in the human's repository covers it: `scope: subject` on the creator's own record, or `scope: descendants` on any ancestor's, with `kinds` absent or naming the kind. None does, and the create is refused as `NotAllowedToCreate`. The human creating from their own repository needs none: the record naming the new account is the authorization. -8. For a hosted creator, an `oidc` identity names claims of its own: an +7. For a hosted creator, an `oidc` identity names claims of its own: an identity a live account here already logs in with, or one carrying every claim of such an identity and more, is refused as `OidcIdentityTaken`. Claims compare by the claim they select, so `/repository` is @@ -192,7 +196,6 @@ operator}`. |---|---|---| | a session as the creator | the session store | live, and the creator's | | a JWT signed by a key the creator's credential records hold | the creator's credential records | `iss` the creator, `aud` this server's DID, `lxm` `bot.did.createAccount`, `exp` in the future, `jti` unspent | -| a service-auth token from the human's own PDS | `did:plc:human`'s document | the same claims, signed by the key that document names; the human's `bot.did.operator/` naming the child already stands | | an ID token from an issuer the creator's credential records name | the issuer's JWKS | `aud` this server's DID, `exp`, `jti` unspent, and every claim the identity lists equal to its value | Allowances compose by union across the human's records, and nothing else @@ -231,9 +234,8 @@ One deployment, `pds.example`, run by `did:plc:human` from another PDS: - `pds.example`, where every repository is signed with keys the server holds: - `did:web:pds.example`, the server's own account. - - `kestrel`, `laptop`, `pool` and `deploy`, each created with a - service-auth token from the human's own PDS after the human's record - was written; each registration names the human and no lineage. + - `kestrel`, `laptop`, `pool` and `deploy`, each created with the + human's operator session after the human's record was written; each registration names the human and no lineage. `laptop`'s repository holds a `bot.did.credential` for its key, `pool`'s and `deploy`'s one for their identity, and `kestrel`'s none. - Beneath `laptop`: `7f3a9c`, an agent the daemon created with a JWT @@ -329,7 +331,7 @@ it. | Scenario | On the human's machine | On the account's machine | The check | |---|---|---|---| -| One agent, by hand | `didbot operate kestrel.pds.example operator.example --kind agent` — one browser sign-in at the human's PDS; the record; a create with a service-auth token; the session lands where `didbot oauth` reads it | nothing | agent → the human's record for it | +| One agent, by hand | `didbot operate kestrel.pds.example operator.example --kind agent` — one browser sign-in at the human's PDS; the record; a create carrying the operator session `didbot login` kept; the session lands where `didbot oauth` reads it | nothing | agent → the human's record for it | | One agent host, automatic | `didbot operate laptop.pds.example operator.example --creates agent` — shows the parked key's fingerprint, writes the record, creates with `parkedKey` | `didbot register host laptop.pds.example` first: mints the daemon's key, parks its public half, prints the fingerprint, waits, then signs in with the key. `didbot-agentd` then creates one agent per context with a JWT the key signs | agent → the human's record for laptop | | Two servers, hosts I registered | `didbot operate h1.pds-a.example operator.example --creates agent`, once per host per server | `didbot register host h1.pds-a.example` on each host; the daemon as above | the same, on each server | | An autoscaling pool | `didbot operate pool.pds.example operator.example --kind service --oidc https://accounts.google.com sub=106589273465000000000 --creates host --creates-beneath agent`, once | at boot, `didbot register host i-0a9f.pds.example --under pool.pds.example --token-file /run/instance-id-token` (the token's audience is `did:web:pds.example`); the daemon as above | agent → the human's record for pool | diff --git a/docs/running-locally.md b/docs/running-locally.md index 4e108e1e..57d5dee2 100644 --- a/docs/running-locally.md +++ b/docs/running-locally.md @@ -894,9 +894,7 @@ lets create. `bot.did.createAccount` takes - a JWT `{iss: , aud: , lxm: "bot.did.createAccount", exp, jti}` signed ES256K or ES256 by a key the creator logs in with — one of a hosted account's `bot.did.credential` - records, or the `#atproto` key of the operator's own document, whose PDS - mints it as a service-auth token. `exp` is at most ten minutes out and - each `jti` is spent once; + records. `exp` is at most ten minutes out and each `jti` is spent once; - an OpenID Connect ID token whose `aud` is this server's DID, from an issuer a hosted account's `bot.did.credential` records name, and carrying every claim that identity lists. Only public diff --git a/plan/cli.md b/plan/cli.md index 5550fe55..b09e5320 100644 --- a/plan/cli.md +++ b/plan/cli.md @@ -73,16 +73,15 @@ table is the mechanism; there are no links between binaries. ## Two credentials in one binary, kept apart -`didbot-operator` holds two sign-ins that never meet. `operate` is the -claim: it authenticates to the operator's *own* atproto account through +`didbot-operator` holds two sign-ins. `operate` is the claim: it +authenticates to the operator's *own* atproto account through `jacquard-oauth` and writes `bot.did.operator` into their own repository, -keeping that session in `didbot-authstore`'s file. For a name beneath a -server the same session has the operator's PDS mint a service-auth token, -and that token — never the session — is what the server sees. `login` -starts the deployment's own sign-in and keeps the session the deployment -minted, which `estop` and `announce` present. One is checked by the -operator's PDS, the other by the deployment; neither verb can use the -other's, and only the first admits anything. +keeping that session in `didbot-authstore`'s file. `login` starts the +deployment's own sign-in and keeps the session the deployment minted, which +`estop`, `account` and `announce` present. For a name beneath a server, +`operate` writes the record with the first and presents the second to the +server's create. One is checked by the operator's PDS, the other by the +deployment. `didbot register` and the daemon hold a third thing that is not a credential at all: a key that never leaves the machine. The server sees @@ -105,9 +104,9 @@ its public half, and tokens it signs. - [x] **`didbot operate`.** `didbot-operator`'s `operate` verb, with `--check` for a server and for an account, and the account form beneath a server: `didbot_operator::operate::under` reads which - server from the operator's own records, `::account` mints the - service-auth token, writes the record and creates, taking the record - back if the create is refused; `::walk` is the check. The session it + server from the operator's own records, `::account` writes the record + and creates with the operator session, taking the record back if the + create is refused; `::walk` is the check. The session it keeps is `didbot_authstore::FileAuthStore`'s file. - [x] **`didbot register`.** `didbot-agentd`'s `didbot-register` binary over `didbot_agentd::register`: a parked key admitted from the diff --git a/plan/handshake.md b/plan/handshake.md index b9a916a7..56fe8cdc 100644 --- a/plan/handshake.md +++ b/plan/handshake.md @@ -119,7 +119,7 @@ is ever worth the dependency. operator's own machine, in a binary that links no server code. It signs in to the operator's own account with the scope `atproto repo:bot.did.operator?action=create&action=update&action=delete`, built - from `didbot-scope`'s grammar + from `didbot-scope`'s grammar, and nothing wider (`crates/didbot-operator/src/operate/scope.rs`); waits for TLS; resolves the server's `did:web` document; cross-checks `describeServer`; and writes the record through @@ -138,3 +138,11 @@ is ever worth the dependency. allowlist from the repository it already polls: `didbot register` and `didbot operate ` are that sequence, and [ownership](ownership.md) owns it. +- [x] **An admission asks the operator's own PDS for no `rpc:` scope.** + `didbot operate ` asks it for the `repo:bot.did.operator` scope + above and writes the record with it. The create at the server carries + the operator session `didbot login` keeps, the same identity-only + sign-in the dashboard uses; with none kept, the command signs in that + way first. The server accepts that session as the caller and still + requires the record to name the account. It refuses a service-auth + token from the operator's own PDS. diff --git a/plan/ownership.md b/plan/ownership.md index 5e5dce28..aa79475b 100644 --- a/plan/ownership.md +++ b/plan/ownership.md @@ -83,8 +83,9 @@ new account is the authorization. answers an account's `bot.did.credential` records from the record store. `tree::proof::prove_by_key` verifies a hosted account's JWT against the keys they hold, and `tree::oidc` matches an ID token - against the identities they name; the one document a proof is checked - against is the human's own, for their service-auth token. + against the identities they name. The human creates with the operator + session, not a proof, so no proof is checked against a foreign + document. - [x] **The allowance gate.** `didbot_pds::allowance::Allowances::permits` answers whether the creator, or any ancestor with scope @@ -145,9 +146,10 @@ new account is the authorization. - [x] **A command that writes the human's record.** `didbot operate` writes `bot.did.operator` into the human's own repository through `com.atproto.repo.putRecord` under the scope - `atproto repo:bot.did.operator?action=create,update`, with - `--creates` / `--creates-beneath` under `creates`, then creates with a - service-auth token, the parked key or the OIDC identity in the body. + `atproto repo:bot.did.operator?action=create&action=update&action=delete`, + with `--creates` / `--creates-beneath` under `creates`, then creates + with the operator session, the parked key or the OIDC identity in the + body. Nothing is written until every check passes. - [x] **A command that takes the human's record back.** `didbot operate diff --git a/site/src/data/architecture.ts b/site/src/data/architecture.ts index aa400345..cfea95c4 100644 --- a/site/src/data/architecture.ts +++ b/site/src/data/architecture.ts @@ -111,7 +111,7 @@ export const architectureSection: Section = { body: [ "What logs in as an account is its own `bot.did.credential` records, which the server writes and the account cannot: a key the account signs with, or an OpenID Connect identity, naming an issuer and the claims a token from it must carry. A credential is how a process logs in to the server as the account; it is not the DID document, which the network reads, and not an atproto session or OAuth grant, which the server issues on top of it. An account with no record is reached only through a session the server issued.", "An account creates beneath itself only where the operator's own repository allows it: a `creates` entry on one of the operator's `bot.did.operator` records, covering the account or everything beneath an ancestor, and naming the kinds it may create. `bot.did.createAccount` takes the new account's hostname, its kind, and how it will log in afterwards, with the caller's proof in an `Authorization` header; the server checks that proof against the caller's own credential records and the caller against the operator's allowances before it mints anything, and answers with a session for what it made.", - "A proof is one of four things: a live session as the caller, a JWT signed by a key the caller's credential records hold, an ID token matching an identity those records name, or — for the human at the top of the tree — a service-auth token from the human's own PDS, who needs no allowance because the record naming the new account is the authorization. `bot.did.createSession` turns a JWT a held key signs into a session later on.", + "A proof is one of four things: a live session as the caller, a JWT signed by a key the caller's credential records hold, an ID token matching an identity those records name, or — for the human at the top of the tree — the operator session the server minted when the human signed in to it, and the human needs no allowance because the record naming the new account is the authorization. `bot.did.createSession` turns a JWT a held key signs into a session later on.", "The check runs once, at creation. What it establishes is written as records — `bot.did.operator/` in the creator's repository, and in the new account's `bot.did.registration/self`, naming the human at the top of the tree and every account between, and a `bot.did.credential` for what logs in — and those records, not the proof, are what anyone reads afterwards.", ] }, -- 2.51.2