From 17aa27bc92d4fa8bfcd2ca5af4376c7597ae3a1b Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Tue, 29 Sep 2026 10:03:45 -0400 Subject: [PATCH] feat(operator): add didbot account credentials It prints an account's bot.did.credential records, and with --key, --fingerprint or --oidc, and --remove, makes the operator's change through bot.did.updateCredentials with the didbot login session. Co-Authored-By: Claude Opus 5.5 Change-Id: I85ddb5f388d9f962081fcb58d7c88c747c6482ea --- crates/didbot-agentd/src/node.rs | 11 +- .../src/bin/didbot-operator.rs | 101 ++++++++++- crates/didbot-operator/src/credentials.rs | 164 ++++++++++++++++++ crates/didbot-operator/src/lib.rs | 1 + crates/didbot-operator/tests/cli.rs | 1 + crates/didbot/tests/scenarios/sessions.rs | 120 +++++++++++++ docs/cli.md | 15 +- docs/ownership.md | 1 + plan/credential-rotation.md | 21 +-- plan/ownership.md | 7 +- 10 files changed, 416 insertions(+), 26 deletions(-) create mode 100644 crates/didbot-operator/src/credentials.rs diff --git a/crates/didbot-agentd/src/node.rs b/crates/didbot-agentd/src/node.rs index 17a454a8..79502d68 100644 --- a/crates/didbot-agentd/src/node.rs +++ b/crates/didbot-agentd/src/node.rs @@ -104,10 +104,10 @@ pub enum NodeError { /// The identity file survived and the key it was registered under did /// not. /// - /// The key is what a token is signed with and what the host's document - /// publishes the public half of, so an identity beside a key that is - /// not the one it was registered under can never sign in again: every - /// token verifies against the wrong key. Refused here, at the one moment + /// The key is what a token is signed with and what the host's + /// `bot.did.credential` record holds the public half of, so an identity + /// beside a key that is not the one it was registered under can never + /// sign in again: every token verifies against the wrong key. Refused here, at the one moment /// it is knowable, rather than at every provisioning for the life of /// the daemon. #[error( @@ -276,7 +276,8 @@ impl Node { /// server `aud` — and nothing else. /// /// The issuer is this host's DID, so a server that finds this key in - /// that DID's document admits it and one that finds any other does not. + /// that DID's `bot.did.credential` records admits it and one that finds + /// any other does not. /// Refused while the host has no identity. pub fn sign(&self, aud: &str, lxm: &str, now: OffsetDateTime) -> Result { let host = self.host().ok_or(NodeError::NoIdentity)?; diff --git a/crates/didbot-operator/src/bin/didbot-operator.rs b/crates/didbot-operator/src/bin/didbot-operator.rs index 01372f07..f514cdb3 100644 --- a/crates/didbot-operator/src/bin/didbot-operator.rs +++ b/crates/didbot-operator/src/bin/didbot-operator.rs @@ -33,9 +33,10 @@ use std::process::ExitCode; use std::io::{BufRead, Write}; use clap::{Parser, Subcommand}; -use didbot_claim_check::{Allowance, Scope}; +use didbot_claim_check::{Allowance, Credential, Scope}; use didbot_cli::{finish, Json, Refusal, Server}; use didbot_identity::document::OidcIdentity; +use didbot_operator::credentials::{self, Add, CredentialsError}; use didbot_operator::operate::account::Ask; use didbot_operator::operate::{self, Intent, Target}; use didbot_operator::{call, default_session_path, load, login, store, OperatorError}; @@ -144,12 +145,11 @@ enum Verb { release: bool, }, /// Act on one account this deployment hosts - #[command( - long_about = "Each verb is one call to a /dashboard/api/accounts route, carrying the \ - session `didbot login` kept, and what it does lands in that account's \ + #[command(long_about = "Each verb carries the session `didbot login` kept to a \ + /dashboard/api/accounts route, or for credentials to \ + bot.did.updateCredentials, and what it does lands in that account's \ ledger with this deployment's operator as the actor. is the \ - account's handle or its did:." - )] + account's handle or its did:.")] Account { #[command(subcommand)] action: AccountVerb, @@ -210,6 +210,30 @@ enum AccountVerb { /// The account's handle, or its did: name: String, }, + /// Print what logs in as the account, or change it + #[command( + long_about = "Prints the account's bot.did.credential records. With --key, \ + --fingerprint or --oidc, and --remove, it adds a credential, removes \ + one, or both in one commit, through bot.did.updateCredentials with the \ + session `didbot login` kept. A change ends the account's sessions." + )] + Credentials { + /// The account's handle, or its did: + name: String, + /// Add this public key, multibase + #[arg(long, value_name = "MULTIBASE", group = "add")] + key: Option, + /// Add the key `didbot register` parked under the account's name with this + /// fingerprint + #[arg(long, value_name = "SHA256:...", group = "add")] + fingerprint: Option, + /// Add this OpenID Connect identity: an issuer, then claim=value pairs + #[arg(long, num_args = 2.., value_names = ["ISSUER", "CLAIM=VALUE"], group = "add")] + oidc: Vec, + /// Remove the record with this key + #[arg(long, value_name = "RKEY")] + remove: Option, + }, } impl AccountVerb { @@ -229,6 +253,7 @@ impl AccountVerb { serde_json::json!({ "recursive": recursive }), ), AccountVerb::RevokeTokens { name } => ("revoke-tokens", name, serde_json::json!({})), + AccountVerb::Credentials { .. } => unreachable!("credentials calls no dashboard route"), } } } @@ -442,6 +467,24 @@ async fn run(cli: Cli) -> Result<(), Refusal> { }; dashboard(&cli.server, json, path, body).await } + Verb::Account { + action: + AccountVerb::Credentials { + name, + key, + fingerprint, + oidc, + remove, + }, + } => { + let add = match (key, fingerprint, parse_oidc(&oidc)?) { + (Some(key), _, _) => Some(Add::Credential(Credential::key(&key))), + (_, Some(fingerprint), _) => Some(Add::Parked(fingerprint)), + (_, _, Some(identity)) => Some(Add::Credential(Credential::OidcIdentity(identity))), + _ => None, + }; + account_credentials(&cli.server, json, &name, add, remove.as_deref()).await + } Verb::Account { action } => { let (verb, name, body) = action.call(); let path = format!("/dashboard/api/accounts/{name}/{verb}"); @@ -731,6 +774,52 @@ async fn check_walk(name: &str, json: Json) -> Result<(), Refusal> { } } +/// `didbot account credentials`: the change, when one is asked for, then +/// the records as they stand. +async fn account_credentials( + server: &Server, + json: Json, + name: &str, + add: Option, + remove: Option<&str>, +) -> Result<(), Refusal> { + let server = server.hostname()?; + let session = load(&session_path()?, server).map_err(Refusal::failed)?; + let refused = |error: CredentialsError| match error { + CredentialsError::Call(OperatorError::Refused { status: 401, .. }) => { + Refusal::failed(format!( + "the session for {server} is no longer accepted; run `didbot login --server \ + {server}` again" + )) + } + error => Refusal::failed(error), + }; + let did = credentials::did_of(&session, name).await.map_err(refused)?; + let mut answer = serde_json::json!({ "did": did }); + if add.is_some() || remove.is_some() { + answer = credentials::change(&session, &did, add.as_ref(), remove) + .await + .map_err(refused)?; + } + let held = credentials::read(&session, &did).await.map_err(refused)?; + answer["records"] = held.iter().map(credentials::Held::to_json).collect(); + json.print(&answer, |_| { + let mut text = String::new(); + for (field, verb) in [("added", "added"), ("removed", "removed")] { + let uri = answer[field]["uri"].as_str().or(answer[field].as_str()); + if let Some(uri) = uri { + text.push_str(&format!("{verb} {uri}\n")); + } + } + text.push_str(&format!("{did} logs in with:")); + for record in &held { + text.push_str(&format!("\n {}", credentials::describe(record))); + } + text + }); + Ok(()) +} + fn session_path() -> Result { default_session_path().ok_or_else(|| { Refusal::failed("neither XDG_CONFIG_HOME nor HOME is set; nowhere to keep a session") diff --git a/crates/didbot-operator/src/credentials.rs b/crates/didbot-operator/src/credentials.rs new file mode 100644 index 00000000..57d6b07a --- /dev/null +++ b/crates/didbot-operator/src/credentials.rs @@ -0,0 +1,164 @@ +//! `didbot account credentials`: what logs in as one hosted account, and +//! the operator's change to it. +//! +//! The records are read from the account's repository, where anyone can +//! read them. The change is `bot.did.updateCredentials` carrying the +//! operator session, the same route and session the dashboard uses. The +//! server commits it once, ends the account's sessions, and writes +//! `credentialAdded` and `credentialRemoved` into its ledger naming the +//! operator. + +use didbot_claim_check::{Credential, CREDENTIAL_COLLECTION}; +use serde_json::{json, Value}; + +use crate::{call, OperatorError, Session}; + +/// The records one read asks for: every credential an account can hold. +const LIMIT: usize = 100; + +/// What can go wrong. +#[derive(Debug, thiserror::Error)] +pub enum CredentialsError { + /// The call failed, or the deployment refused it. + #[error(transparent)] + Call(#[from] OperatorError), + /// The deployment answered without what the method names. + #[error("{server}'s {method} answered without {what}")] + Unexpected { + /// Which deployment. + server: String, + /// The method. + method: &'static str, + /// The field it lacked. + what: &'static str, + }, +} + +/// One `bot.did.credential` record. +#[derive(Debug, Clone)] +pub struct Held { + /// The record key, which `--remove` names. + pub rkey: String, + /// The credential, or `None` for a kind this command does not know. + pub credential: Option, + /// When the server wrote the record. + pub created_at: String, +} + +impl Held { + /// The record as `--json` prints it. + #[must_use] + pub fn to_json(&self) -> Value { + json!({ + "rkey": self.rkey, + "credential": self.credential.as_ref().map(Credential::to_json), + "createdAt": self.created_at, + }) + } +} + +/// What the operator adds. +#[derive(Debug, Clone)] +pub enum Add { + /// A key or an identity, given whole. + Credential(Credential), + /// A key `didbot register` parked under the account's name, by the + /// fingerprint it printed. + Parked(String), +} + +/// The DID behind `name`, a handle or a `did:`, as the server resolves it. +pub async fn did_of(session: &Session, name: &str) -> Result { + let answer = call( + session, + &format!("/xrpc/com.atproto.repo.describeRepo?repo={name}"), + None, + ) + .await?; + answer["did"] + .as_str() + .map(str::to_owned) + .ok_or_else(|| unexpected(session, "describeRepo", "did")) +} + +/// The account's credential records, oldest first. +pub async fn read(session: &Session, did: &str) -> Result, CredentialsError> { + let answer = call( + session, + &format!( + "/xrpc/com.atproto.repo.listRecords?repo={did}&collection={CREDENTIAL_COLLECTION}\ + &limit={LIMIT}&reverse=true" + ), + None, + ) + .await?; + let records = answer["records"] + .as_array() + .ok_or_else(|| unexpected(session, "listRecords", "records"))?; + Ok(records + .iter() + .map(|record| Held { + rkey: record["uri"] + .as_str() + .and_then(|uri| uri.rsplit('/').next()) + .unwrap_or_default() + .to_owned(), + credential: Credential::from_record(&record["value"]), + created_at: record["value"]["createdAt"] + .as_str() + .unwrap_or_default() + .to_owned(), + }) + .collect()) +} + +/// Adds `add`, removes the record at `remove`, or both, in one commit. +/// Answers what the server answered. +pub async fn change( + session: &Session, + did: &str, + add: Option<&Add>, + remove: Option<&str>, +) -> Result { + let mut body = json!({ "did": did }); + match add { + Some(Add::Credential(credential)) => body["add"] = credential.to_json(), + Some(Add::Parked(fingerprint)) => body["parkedKey"] = json!(fingerprint), + None => {} + } + if let Some(remove) = remove { + body["remove"] = json!(remove); + } + Ok(call(session, "/xrpc/bot.did.updateCredentials", Some(body)).await?) +} + +/// A record as a person reads it: its key, what it is, and since when. +#[must_use] +pub fn describe(held: &Held) -> String { + let what = match &held.credential { + Some(Credential::PublicKey { + public_key_multibase, + }) => format!( + "key {}", + didbot_claim_check::key_fingerprint(public_key_multibase) + ), + Some(Credential::OidcIdentity(identity)) => { + let claims: Vec = identity + .claims + .iter() + .map(|claim| format!("{}={}", claim.name, claim.value)) + .collect(); + format!("oidc {} {}", identity.issuer, claims.join(" ")) + } + None => "a kind this command does not read".to_owned(), + }; + format!("{} {what} since {}", held.rkey, held.created_at) +} + +fn unexpected(session: &Session, method: &'static str, what: &'static str) -> CredentialsError { + CredentialsError::Unexpected { + server: session.server.clone(), + method, + what, + } +} diff --git a/crates/didbot-operator/src/lib.rs b/crates/didbot-operator/src/lib.rs index 273fc351..a96cfa9c 100644 --- a/crates/didbot-operator/src/lib.rs +++ b/crates/didbot-operator/src/lib.rs @@ -22,6 +22,7 @@ //! The server refuses to redirect anywhere but loopback; see //! `didbot_serve::operator`. +pub mod credentials; pub mod operate; use std::io::{BufRead, BufReader, Write}; diff --git a/crates/didbot-operator/tests/cli.rs b/crates/didbot-operator/tests/cli.rs index 87cc9f0f..946de49e 100644 --- a/crates/didbot-operator/tests/cli.rs +++ b/crates/didbot-operator/tests/cli.rs @@ -188,6 +188,7 @@ fn an_account_verb_with_no_session_reaches_no_deployment() { "erase", "delete", "revoke-tokens", + "credentials", ] { let output = operator(&[ "account", diff --git a/crates/didbot/tests/scenarios/sessions.rs b/crates/didbot/tests/scenarios/sessions.rs index fd8695a9..328e31ca 100644 --- a/crates/didbot/tests/scenarios/sessions.rs +++ b/crates/didbot/tests/scenarios/sessions.rs @@ -360,3 +360,123 @@ async fn a_leaked_session_ends_with_revoke_tokens_and_stays_ended() { let (status, body) = write(fresh).await; assert_eq!(status, 200, "a fresh proof's session is refused: {body}"); } + +/// A pipeline moves to another repository, and the operator moves its login +/// with it. `didbot account credentials` adds the new identity and removes +/// the old in one commit: the old repository's token takes no session, the +/// session taken before the change ends, the new repository's token takes +/// one, and the ledger ends in `credentialAdded` and `credentialRemoved` +/// naming the operator. +#[tokio::test(flavor = "multi_thread")] +async fn the_operator_moves_a_pipelines_login_in_one_change() { + let stack = Stack::start_with(&["move"], Server::CONFIG, true).await; + let (host, origin, audience) = { + let server = stack.server(); + ( + server.host.clone(), + server.origin.clone(), + server.did.clone(), + ) + }; + let deploy = format!("deploy.{}", stack.server().zone()); + let deploy_did = did_of(&deploy); + stack + .laptop + .didbot( + &[ + "operate", + &deploy, + stack.human.did(), + "--kind", + "pipeline", + "--oidc", + &stack.issuer.base, + "repository_id=456789", + "--json", + ], + &[], + ) + .await + .ok(); + let session = |repository: &'static str| { + let url = format!("{origin}/xrpc/bot.did.createSession?did={deploy_did}"); + let proof = stack + .issuer + .id_token(&audience, json!({ "repository_id": repository })); + async move { post_json(&url, Some(&proof), json!({})).await } + }; + let credentials = |extra: &[&str]| { + let mut args = vec!["account", "credentials", deploy.as_str()]; + args.extend_from_slice(extra); + args.extend_from_slice(&["--server", host.as_str(), "--json"]); + let args: Vec = args.into_iter().map(str::to_owned).collect(); + let laptop = &stack.laptop; + async move { + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + laptop.didbot(&args, &[]).await.ok() + } + }; + + let before = credentials(&[]).await; + assert_eq!(before["did"], deploy_did, "{before}"); + let records = before["records"].as_array().expect("records"); + assert_eq!(records.len(), 1, "{before}"); + let old = records[0]["rkey"].as_str().expect("an rkey").to_owned(); + let (status, body) = session("456789").await; + assert_eq!(status, 200, "the first repository signs in: {body}"); + let taken = body["accountToken"].as_str().expect("a session").to_owned(); + + let moved = credentials(&[ + "--oidc", + &stack.issuer.base, + "repository_id=987654", + "--remove", + &old, + ]) + .await; + assert!(moved["added"]["uri"].is_string(), "{moved}"); + assert_eq!( + moved["removed"], + format!("at://{deploy_did}/bot.did.credential/{old}"), + "{moved}" + ); + let records = moved["records"].as_array().expect("records"); + assert_eq!(records.len(), 1, "{moved}"); + assert_eq!( + records[0]["credential"]["claims"][0]["value"], "987654", + "{moved}" + ); + + let (status, body) = session("456789").await; + assert_eq!(status, 401, "the old repository still signs in: {body}"); + assert_eq!(body["error"], "InvalidToken", "{body}"); + let url = format!("{origin}/xrpc/com.atproto.repo.createRecord"); + let write = json!({ + "repo": deploy_did, + "collection": "com.example.thing", + "record": { "text": "x", "emoji": "\u{1f9ed}", "createdAt": now_rfc3339() }, + }); + let (status, body) = post_json(&url, Some(&taken), write).await; + assert_eq!( + status, 401, + "a session from before the change wrote: {body}" + ); + let (status, body) = session("987654").await; + assert_eq!(status, 200, "the new repository signs in: {body}"); + + let response = http() + .get(format!("{origin}/xrpc/bot.did.getAccountLedger")) + .query(&[("did", deploy_did.as_str())]) + .send() + .await + .expect("getAccountLedger answers"); + let ledger: Value = response.json().await.expect("a ledger"); + let entries = ledger["entries"].as_array().expect("entries"); + let tail = &entries[entries.len() - 2..]; + assert_eq!(tail[0]["event"], "credentialAdded", "{ledger}"); + assert_eq!(tail[1]["event"], "credentialRemoved", "{ledger}"); + for entry in tail { + assert_eq!(entry["party"], "operator", "{ledger}"); + assert_eq!(entry["operator"], stack.human.did(), "{ledger}"); + } +} diff --git a/docs/cli.md b/docs/cli.md index 8c040a48..0397fd6e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -296,8 +296,9 @@ deployment, and `--resume` calls the deprecated `notifyOfUpdate` instead. `didbot account --server ` acts on one account the deployment hosts, carrying the same session. `` is the account's -handle or its `did:`. Each verb prints the account it reached and the ledger -entry it made, and `--json` prints the route's own answer: +handle or its `did:`. Each verb but `credentials` prints the account it +reached and the ledger entry it made, and `--json` prints the route's own +answer: - `lock` refuses the account's writes and leaves its repository readable. `unlock` lifts that same lock. @@ -315,11 +316,21 @@ entry it made, and `--json` prints the route's own answer: many account tokens and app logins ended. When nothing was live, the ledger gains no entry. An account that logs in with a key or an OpenID Connect identity takes a new session with a fresh proof afterwards. +- `credentials` prints the account's `bot.did.credential` records, each by + its record key. With `--key `, `--fingerprint ` + for a key `didbot register` parked under the name, or `--oidc + ...`, and with `--remove `, it adds a credential, + removes one, or both in one commit through `bot.did.updateCredentials`, + then prints the records as they stand. A change ends the account's + sessions, and the ledger gains `credentialAdded` and `credentialRemoved`. ```sh didbot account lock kestrel.agents.example --server pds.example didbot account delete laptop.pds.example --recursive --server pds.example didbot account revoke-tokens deploy.pds.example --server pds.example +didbot account credentials deploy.pds.example --server pds.example \ + --oidc https://token.actions.githubusercontent.com repository_id=987654 \ + --remove 3lz2qm6e2dc2h ``` `didbot app end-logins --client --server ` ends every diff --git a/docs/ownership.md b/docs/ownership.md index ab560ac6..1c923a18 100644 --- a/docs/ownership.md +++ b/docs/ownership.md @@ -341,6 +341,7 @@ each verb does lands in that account's ledger with the operator as the actor: | `erase` | Takes the repository, its history, its blobs and its credentials, and burns the name permanently, whatever locks are hung. The DID document keeps resolving, so signatures the account made stay checkable. | | `delete` | Takes the row too; the ledger is what is left, and the name goes back on the ordinary hold. Refused while accounts are beneath it, unless `--recursive` takes the subtree, deepest first. | | `revoke-tokens` | Ends every token that authenticates as the account: its account tokens, every app's login on it and its unredeemed authorization codes. Its state and locks stay as they are. When nothing was live, the ledger gains no entry. | +| `credentials` | Prints the account's credential records, and with `--key`, `--fingerprint` or `--oidc`, and `--remove`, adds one, removes one, or both in one commit through `bot.did.updateCredentials`. The change ends the account's sessions. | ## The scenarios diff --git a/plan/credential-rotation.md b/plan/credential-rotation.md index 42f700b8..3bfc2cc9 100644 --- a/plan/credential-rotation.md +++ b/plan/credential-rotation.md @@ -599,16 +599,9 @@ change also ends the account's sessions: ## Work -- [ ] `didbot account credentials` in `didbot-operator`, and the - dashboard's control, both on the one route. -- [ ] Docs: the credential section and the verbs table in - [ownership](../docs/ownership.md), [the command line](../docs/cli.md) - and [the daemon's page](../docs/agentd.md). Four places say the server - checks a host's JWT against its DID document, where it reads the - credential records: `docs/agentd.md`, `docs/cli.md`, `node.rs`'s - module doc and `tree::create_session`'s doc. They change with it. -- [ ] The tests above. -- [ ] Tick [ownership](ownership.md)'s rotation item. +- [ ] The dashboard's control for an account's credentials, on the route + `didbot account credentials` calls, once the dashboard has an account + view. ## Done @@ -634,3 +627,11 @@ change also ends the account's sessions: `DIDBOT_KEY_ROTATE_DAYS`. `didbot register --rotate` asks the daemon, or runs the step under the key's lock, and recovers a lost key through a park; `KeyGone` names it. + +- [x] **The operator's side, and the docs.** `didbot account credentials` + prints an account's records and makes the operator's change through + `bot.did.updateCredentials` with the `didbot login` session + (`didbot_operator::credentials`). The scenario + `the_operator_moves_a_pipelines_login_in_one_change` moves a + pipeline's identity in one commit. The docs say a host's JWT is + checked against its credential records, and list the verb. diff --git a/plan/ownership.md b/plan/ownership.md index 49426a4e..7e75e646 100644 --- a/plan/ownership.md +++ b/plan/ownership.md @@ -48,9 +48,6 @@ new account is the authorization. parked key as the account's first `bot.did.credential`. A record carrying the multibase key would let the server write the credential from the record alone. -- [ ] **Key rotation as replace or overlap, and whether a rotation ends - live sessions.** [credential-rotation](credential-rotation.md) - proposes a replace in one commit that ends no session. - [ ] **Delegation bounds, or an explicit no.** An allowance says what may be created; nothing yet says what a hosted account may say about the accounts beneath it. @@ -711,3 +708,7 @@ The owner took every recommendation: listing to decide, without a read, for `TOP_EDGE_TTL`. The operator's own `AccountAlreadyExists` for a name a hosted creator took names that creator. + +- [x] **Key rotation replaces in one commit and ends the account's + sessions.** `bot.did.updateCredentials`, built by + [credential-rotation](credential-rotation.md). -- 2.51.2