diff --git a/crates/didbot/tests/release_metadata.rs b/crates/didbot/tests/release_metadata.rs deleted file mode 100644 index 6ac063e3..00000000 --- a/crates/didbot/tests/release_metadata.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Guards the one line of the workspace README that the release process rewrites. -//! -//! `release.toml` finds that line by its opening prose and replaces it during -//! a version bump, with `exactly = 1` so a reword fails the release rather -//! than silently leaving a stale version on the front page. This test is the -//! other half: it fails at development time instead of release time, and it -//! also catches the release having been half-applied. - -use std::fs; - -fn crate_version() -> String { - let manifest: toml::Value = - toml::from_str(&fs::read_to_string("../../Cargo.toml").unwrap()).unwrap(); - manifest["workspace"]["package"]["version"] - .as_str() - .unwrap() - .to_string() -} - -fn readme_release_line() -> String { - let readme = fs::read_to_string("../../README.md").unwrap(); - let lines: Vec<&str> = readme - .lines() - .filter(|l| l.starts_with("*Current release: ")) - .collect(); - assert_eq!( - lines.len(), - 1, - "README must carry exactly one '*Current release: ' line; \ - release.toml replaces it with exactly = 1 and fails otherwise" - ); - lines[0].to_string() -} - -#[test] -fn readme_names_the_current_version() { - let version = crate_version(); - let line = readme_release_line(); - assert!( - line.contains(&format!("v{version}")), - "README release line does not name Cargo.toml's version {version}: {line}" - ); -} - -#[test] -fn readme_release_line_links_to_the_matching_tag() { - let version = crate_version(); - let line = readme_release_line(); - assert!( - line.contains(&format!("/tags/v{version}")), - "README release line does not link to the tag for {version}: {line}" - ); -} -- 2.51.2 From af13a5f3189af7df3d12cbd00d3570f725343499 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 9 Sep 2026 10:12:56 -0400 Subject: [PATCH 2/4] feat(agentd): mint and hold one node key in the daemon's state directory The daemon mints a secp256k1 key on first start into `$XDG_STATE_HOME/didbot/agentd`, created shut and tightened on every start, and holds an exclusive lock beside it so a second daemon on the same directory is refused. A `host` question over the socket is answered with the public half as `did:key`; the private half stays in the process. Co-Authored-By: Claude Fable 5.1 Change-Id: I569f0cf63a0d28bf7c2bc79056e882e6edaa4325 --- Cargo.lock | 3 + crates/didbot-agentd/Cargo.toml | 3 + crates/didbot-agentd/src/bin/didbot-agentd.rs | 22 +- crates/didbot-agentd/src/bin/didbot.rs | 35 +- crates/didbot-agentd/src/lib.rs | 6 + crates/didbot-agentd/src/node.rs | 313 ++++++++++++++++++ crates/didbot-agentd/src/protocol.rs | 57 ++++ crates/didbot-agentd/src/scratch.rs | 29 ++ crates/didbot-agentd/src/serve.rs | 92 ++++- crates/didbot-agentd/src/socket.rs | 26 +- docs/agentd.md | 14 + 11 files changed, 557 insertions(+), 43 deletions(-) create mode 100644 crates/didbot-agentd/src/node.rs create mode 100644 crates/didbot-agentd/src/scratch.rs diff --git a/Cargo.lock b/Cargo.lock index a1d66308..a23749ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -966,6 +966,9 @@ name = "didbot-agentd" version = "0.1.0" dependencies = [ "didbot-http", + "didbot-key", + "didbot-stack", + "hex", "reqwest", "serde", "serde_json", diff --git a/crates/didbot-agentd/Cargo.toml b/crates/didbot-agentd/Cargo.toml index 98ba27d3..c17b26ce 100644 --- a/crates/didbot-agentd/Cargo.toml +++ b/crates/didbot-agentd/Cargo.toml @@ -10,6 +10,9 @@ publish.workspace = true [dependencies] didbot-http.workspace = true +didbot-key.workspace = true +didbot-stack.workspace = true +hex.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/didbot-agentd/src/bin/didbot-agentd.rs b/crates/didbot-agentd/src/bin/didbot-agentd.rs index 7389bbbd..eb32acf6 100644 --- a/crates/didbot-agentd/src/bin/didbot-agentd.rs +++ b/crates/didbot-agentd/src/bin/didbot-agentd.rs @@ -1,11 +1,13 @@ //! Run the daemon. //! -//! Configuration is two environment variables and no file, because there is -//! nothing here worth a file yet: where the server is, and where to listen. +//! Configuration is three environment variables and no file, because there +//! is nothing here worth a file yet: where the server is, where to listen, +//! and where the key is kept. use std::process::ExitCode; use std::sync::Arc; +use didbot_agentd::node::{Node, SERVICE}; use didbot_agentd::registrar::Pds; use didbot_agentd::serve::Daemon; use didbot_agentd::socket::{default_socket_path, remove_if_stale, Listener}; @@ -37,6 +39,20 @@ async fn main() -> ExitCode { .map(Into::into) .unwrap_or_else(|_| default_socket_path()); + // The key before the socket: a daemon that cannot hold the key has + // nothing to issue, and taking the socket first would leave callers + // reaching a process that is about to exit. + let state = std::env::var("DIDBOT_STATE") + .map(Into::into) + .unwrap_or_else(|_| didbot_stack::default_data_dir(SERVICE)); + let node = match Node::open(&state) { + Ok(node) => node, + Err(err) => { + error!(dir = %state.display(), error = %err, "could not hold the node key"); + return ExitCode::FAILURE; + } + }; + // Only if nothing is listening on it: a live socket at this path is // another daemon, and unlinking it would take over its callers. remove_if_stale(&path); @@ -49,7 +65,7 @@ async fn main() -> ExitCode { } }; - let daemon = Arc::new(Daemon::new(Pds::new(&server, HARNESS)).confirming_at(&server)); + let daemon = Arc::new(Daemon::new(Pds::new(&server, HARNESS), node).confirming_at(&server)); let err = daemon.run(listener).await; error!(error = %err, "stopped listening"); ExitCode::FAILURE diff --git a/crates/didbot-agentd/src/bin/didbot.rs b/crates/didbot-agentd/src/bin/didbot.rs index bd84f524..9be3a698 100644 --- a/crates/didbot-agentd/src/bin/didbot.rs +++ b/crates/didbot-agentd/src/bin/didbot.rs @@ -1,21 +1,23 @@ //! The command an agent runs. //! -//! One job so far: hand the daemon an authorize URL a client printed, so the -//! authorization is confirmed as the account this command names. -//! -//! It names its own account, and nothing checks that it is that account. See +//! `confirm` hands the daemon an authorize URL a client printed, so the +//! authorization is confirmed as the account this command names. It names +//! its own account, and nothing checks that it is that account. See //! `didbot_agentd::protocol::Confirm` for what that means and why it is //! where this development stack already stands. +//! +//! `host` prints what the daemon says this host is. use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; use std::process::ExitCode; -use didbot_agentd::protocol::{Answer, Confirm, Message, VERSION}; +use didbot_agentd::protocol::{Answer, Confirm, Host, Message, VERSION}; use didbot_agentd::socket::default_socket_path; const USAGE: &str = "\ didbot confirm --as confirm an authorization a client printed +didbot host print this host's public key The client prints its authorize URL instead of opening it; this hands that URL to the local daemon, which confirms it as the account named here. @@ -25,6 +27,7 @@ fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); match args.first().map(String::as_str) { Some("confirm") => confirm(&args[1..]), + Some("host") => host(), Some("--help") | Some("-h") | None => { print!("{USAGE}"); ExitCode::SUCCESS @@ -70,6 +73,28 @@ fn confirm(args: &[String]) -> ExitCode { } } +fn host() -> ExitCode { + let message = Message::Host(Host { version: VERSION }); + match ask(&message) { + Ok(answer) => { + if let Some(trouble) = answer.trouble { + eprintln!("didbot host: {trouble}"); + return ExitCode::FAILURE; + } + let Some(host) = answer.host else { + eprintln!("didbot host: the daemon did not say what this host is"); + return ExitCode::FAILURE; + }; + println!("key {}", host.key); + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("didbot host: {err}"); + ExitCode::FAILURE + } + } +} + /// One line out, one line back. fn ask(message: &Message) -> std::io::Result { let path = std::env::var_os("DIDBOT_SOCK") diff --git a/crates/didbot-agentd/src/lib.rs b/crates/didbot-agentd/src/lib.rs index be013701..d595fed0 100644 --- a/crates/didbot-agentd/src/lib.rs +++ b/crates/didbot-agentd/src/lib.rs @@ -15,12 +15,18 @@ //! [`protocol`] is what crosses it. The harness adapter that speaks it lives //! in another repository and is not written in Rust, so the wire format is //! the contract rather than these types. +//! +//! [`node`] is what the daemon holds that a hook must not: the host's own +//! key, minted on first start and never handed out. #![forbid(unsafe_code)] pub mod confirm; pub mod context; +pub mod node; pub mod protocol; pub mod registrar; +#[cfg(test)] +pub(crate) mod scratch; pub mod serve; pub mod socket; diff --git a/crates/didbot-agentd/src/node.rs b/crates/didbot-agentd/src/node.rs new file mode 100644 index 00000000..1b56da25 --- /dev/null +++ b/crates/didbot-agentd/src/node.rs @@ -0,0 +1,313 @@ +//! The node key: minted once, held by this process, and never handed out. +//! +//! `plan/node.md` sets the two rules this module keeps. Whatever key material +//! the daemon holds, it is the only writer of; and the key never leaves the +//! process. So the key lives in a directory this process creates shut, under +//! a lock the kernel releases when the process dies, and everything a caller +//! can get across the socket is derived from it: the public half, and +//! signatures. +//! +//! The file gets the treatment `crates/didbot-serve/src/estop_admin.rs` and +//! this crate's own [`socket`](crate::socket) give theirs: a `0700` directory +//! tightened on every start, a `0600` file set explicitly rather than left to +//! the umask, and re-tightened when found wider. What that does not give is +//! also written down, in `crates/didbot-attest/src/node_credential.rs`: the +//! key is a file, and anything that can read this user's disk can copy it. + +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; + +use didbot_key::{SigningKey, VerifyingKey}; +use tracing::{info, warn}; + +use crate::socket::secure_dir; + +/// The service name the daemon's state directory is keyed by, under +/// `didbot_stack::default_data_dir`. +pub const SERVICE: &str = "agentd"; + +/// The private half, as sixty-four hex digits of the secp256k1 scalar. +const KEY_FILE: &str = "node.key"; + +/// Held for the daemon's whole life. See [`Node::open`]. +const LOCK_FILE: &str = "node.lock"; + +/// The mode every file in the state directory is created with and kept at. +const FILE_MODE: u32 = 0o600; + +/// Bits a file holding a secret must not carry: anything for group or other. +const LOOSE_MASK: u32 = 0o077; + +/// Why the node key could not be taken up. +#[derive(Debug, thiserror::Error)] +pub enum NodeError { + /// Another process holds the key. Not a race to retry: the holder keeps + /// it for its whole life, so the fix is to stop that daemon or point this + /// one at another directory. + #[error("another daemon already holds the node key in {dir}")] + Held { + /// The state directory that is taken. + dir: PathBuf, + }, + /// The filesystem refused something on the way. + #[error("{path}: {source}")] + Io { + /// The file or directory involved. + path: PathBuf, + /// What the filesystem said. + #[source] + source: io::Error, + }, + /// The file is there and does not hold a key. Refused rather than + /// replaced: a key file that stopped parsing is a key file somebody + /// touched, and minting a fresh key over it would quietly make this a + /// different host. + #[error("{path} does not hold a node key")] + Corrupt { + /// The file that did not parse. + path: PathBuf, + }, +} + +impl NodeError { + fn io(path: &Path) -> impl FnOnce(io::Error) -> Self + '_ { + move |source| Self::Io { + path: path.to_path_buf(), + source, + } + } +} + +/// The key this host signs with, held for the life of the process. +/// +/// Nothing here returns the private half. [`Node::verifying_key`] is the +/// public one, and signing happens through methods on this type. +pub struct Node { + dir: PathBuf, + key: SigningKey, + /// Kept open. The single-writer claim lives on this descriptor, and the + /// kernel drops it with the process, so a crash leaves nothing stale. + _lock: File, +} + +impl Node { + /// Takes up the key in `dir`, minting one when there is none. + /// + /// The directory is created at `0700` or tightened to it, which fails + /// when another user owns it. Then the lock: an exclusive `flock(2)` on a + /// file beside the key, refused at once when another process has it, so a + /// second daemon pointed at the same directory stops here before reading + /// a byte. Only then is the key read or minted. + pub fn open(dir: impl Into) -> Result { + let dir = dir.into(); + secure_dir(&dir).map_err(NodeError::io(&dir))?; + + let lock_path = dir.join(LOCK_FILE); + let lock = open_shut() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(NodeError::io(&lock_path))?; + match lock.try_lock() { + Ok(()) => {} + Err(std::fs::TryLockError::WouldBlock) => return Err(NodeError::Held { dir }), + Err(std::fs::TryLockError::Error(source)) => { + return Err(NodeError::Io { + path: lock_path, + source, + }) + } + } + + let key_path = dir.join(KEY_FILE); + let key = match read_shut(&key_path)? { + Some(text) => parse_key(&text).ok_or(NodeError::Corrupt { path: key_path })?, + None => { + let key = SigningKey::generate(); + let mut text = hex::encode(key.to_bytes()); + text.push('\n'); + write_shut(&key_path, text.as_bytes())?; + info!( + path = %key_path.display(), + key = %key.verifying_key().to_did_key(), + "minted this host's node key" + ); + key + } + }; + + Ok(Self { + dir, + key, + _lock: lock, + }) + } + + /// The directory the key lives in. + pub fn dir(&self) -> &Path { + &self.dir + } + + /// The public half. + pub fn verifying_key(&self) -> VerifyingKey { + self.key.verifying_key() + } +} + +impl std::fmt::Debug for Node { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // By hand, so that no field added later can put the scalar into a + // log line. The public identifier tells two nodes apart. + f.debug_struct("Node") + .field("dir", &self.dir) + .field("key", &self.verifying_key().to_did_key()) + .finish_non_exhaustive() + } +} + +/// Sixty-four hex digits to a key, or nothing for anything else. +fn parse_key(text: &str) -> Option { + let bytes: [u8; 32] = hex::decode(text.trim()).ok()?.try_into().ok()?; + SigningKey::from_bytes(&bytes).ok() +} + +/// Options that create a file at [`FILE_MODE`] rather than at the umask. +fn open_shut() -> OpenOptions { + let mut options = OpenOptions::new(); + options.mode(FILE_MODE); + options +} + +/// Reads a file this process keeps shut, tightening it first when it is found +/// wider than [`FILE_MODE`]. `None` when there is no file. +/// +/// Tightened rather than refused: the daemon is the only writer, so a wider +/// mode is something outside it — a restore, a copy, a `chmod` by hand — and +/// the right answer is to put it back and say so, not to stay down. +pub(crate) fn read_shut(path: &Path) -> Result, NodeError> { + let metadata = match std::fs::metadata(path) { + Ok(metadata) => metadata, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(source) => return Err(NodeError::io(path)(source)), + }; + let mode = metadata.permissions().mode() & 0o777; + if mode & LOOSE_MASK != 0 { + warn!(path = %path.display(), mode = format_args!("{mode:04o}"), "found readable beyond its owner; tightening"); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(FILE_MODE)) + .map_err(NodeError::io(path))?; + } + std::fs::read_to_string(path) + .map(Some) + .map_err(NodeError::io(path)) +} + +/// Writes a file this process keeps shut, whole or not at all. +/// +/// Written beside its destination and renamed into place, so a crash between +/// the two leaves the old file or none rather than half of the new one. The +/// temporary is created at [`FILE_MODE`] and never widens. +pub(crate) fn write_shut(path: &Path, bytes: &[u8]) -> Result<(), NodeError> { + let mut tmp = path.as_os_str().to_owned(); + tmp.push(".tmp"); + let tmp = PathBuf::from(tmp); + let mut file = open_shut() + .write(true) + .create(true) + .truncate(true) + .open(&tmp) + .map_err(NodeError::io(&tmp))?; + // The temporary may be left over from a crash under a looser mode; the + // open above does not change an existing file's mode, so set it. + file.set_permissions(std::fs::Permissions::from_mode(FILE_MODE)) + .map_err(NodeError::io(&tmp))?; + file.write_all(bytes).map_err(NodeError::io(&tmp))?; + file.sync_all().map_err(NodeError::io(&tmp))?; + std::fs::rename(&tmp, path).map_err(NodeError::io(path)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scratch::Scratch; + + fn mode_of(path: &Path) -> u32 { + std::fs::metadata(path).unwrap().permissions().mode() & 0o777 + } + + #[test] + fn a_fresh_key_lands_shut_in_a_shut_directory() { + let scratch = Scratch::new("mint"); + let node = Node::open(&scratch.0).unwrap(); + + assert_eq!(mode_of(&scratch.0), 0o700, "directory"); + assert_eq!(mode_of(&scratch.0.join(KEY_FILE)), 0o600, "key"); + assert_eq!(mode_of(&scratch.0.join(LOCK_FILE)), 0o600, "lock"); + assert!( + !scratch.0.join(format!("{KEY_FILE}.tmp")).exists(), + "nothing half-written is left beside the key" + ); + drop(node); + } + + #[test] + fn the_same_key_comes_back_on_the_next_start() { + let scratch = Scratch::new("again"); + let first = Node::open(&scratch.0).unwrap().verifying_key(); + let second = Node::open(&scratch.0).unwrap().verifying_key(); + assert_eq!(first, second); + } + + #[test] + fn a_key_found_wider_than_its_owner_is_tightened() { + let scratch = Scratch::new("tighten"); + let public = Node::open(&scratch.0).unwrap().verifying_key(); + let key = scratch.0.join(KEY_FILE); + std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o644)).unwrap(); + + let node = Node::open(&scratch.0).unwrap(); + + assert_eq!(mode_of(&key), 0o600); + assert_eq!(node.verifying_key(), public, "tightened, not replaced"); + } + + #[test] + fn a_second_holder_is_refused_until_the_first_lets_go() { + let scratch = Scratch::new("single"); + let held = Node::open(&scratch.0).unwrap(); + + let refused = Node::open(&scratch.0).unwrap_err(); + assert!( + matches!(&refused, NodeError::Held { dir } if dir == &scratch.0), + "{refused:?}" + ); + + drop(held); + Node::open(&scratch.0).expect("free once the holder is gone"); + } + + #[test] + fn a_file_that_is_not_a_key_is_refused_rather_than_replaced() { + let scratch = Scratch::new("corrupt"); + std::fs::create_dir_all(&scratch.0).unwrap(); + let key = scratch.0.join(KEY_FILE); + std::fs::write(&key, "not a key\n").unwrap(); + + let refused = Node::open(&scratch.0).unwrap_err(); + assert!(matches!(&refused, NodeError::Corrupt { path } if path == &key)); + assert_eq!(std::fs::read_to_string(&key).unwrap(), "not a key\n"); + } + + #[test] + fn nothing_but_the_public_half_is_printed() { + let scratch = Scratch::new("debug"); + let node = Node::open(&scratch.0).unwrap(); + let rendered = format!("{node:?}"); + let secret = hex::encode(node.key.to_bytes()); + assert!(rendered.contains("did:key:z"), "{rendered}"); + assert!(!rendered.contains(&secret), "{rendered}"); + } +} diff --git a/crates/didbot-agentd/src/protocol.rs b/crates/didbot-agentd/src/protocol.rs index 5ddc8be4..f1322908 100644 --- a/crates/didbot-agentd/src/protocol.rs +++ b/crates/didbot-agentd/src/protocol.rs @@ -32,6 +32,8 @@ pub enum Message { Report(Report), /// A tool asking for an authorization to be confirmed on its behalf. Confirm(Confirm), + /// Anything asking what this host is. + Host(Host), } impl<'de> Deserialize<'de> for Message { @@ -45,6 +47,9 @@ impl<'de> Deserialize<'de> for Message { Some("confirm") => serde_json::from_value(value) .map(Message::Confirm) .map_err(D::Error::custom), + Some("host") => serde_json::from_value(value) + .map(Message::Host) + .map_err(D::Error::custom), Some(other) => Err(D::Error::custom(format!( "this daemon does not know how to `{other}`" ))), @@ -133,6 +138,24 @@ pub struct Confirm { pub url: String, } +/// A caller asking what this host is: the public half of its key, and the +/// identity it has been given, when it has one. +/// +/// The public half is the only thing about the key that crosses the socket. +/// Signing happens inside the daemon. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Host { + /// The wire version this message was written against. + pub version: u32, +} + +/// What this host is, as far as the daemon knows. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostAnswer { + /// The public half of the node key, as `did:key`. + pub key: String, +} + /// What the daemon says back. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Answer { @@ -148,6 +171,9 @@ pub struct Answer { /// Why there is nothing to report, when there should have been. #[serde(default, skip_serializing_if = "Option::is_none")] pub trouble: Option, + /// What this host is, in answer to a [`Host`] question. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option, } impl Answer { @@ -158,6 +184,7 @@ impl Answer { identity: None, done: None, trouble: None, + host: None, } } @@ -168,6 +195,7 @@ impl Answer { identity: Some(did.into()), done: None, trouble: None, + host: None, } } @@ -178,6 +206,7 @@ impl Answer { identity: None, done: Some(what.into()), trouble: None, + host: None, } } @@ -188,6 +217,18 @@ impl Answer { identity: None, done: None, trouble: Some(why.into()), + host: None, + } + } + + /// What this host is. + pub fn host(host: HostAnswer) -> Self { + Self { + version: VERSION, + identity: None, + done: None, + trouble: None, + host: Some(host), } } } @@ -248,6 +289,22 @@ mod tests { assert!(serde_json::from_str::(line).is_err()); } + #[test] + fn a_host_question_is_read_and_its_answer_carries_only_the_public_half() { + let line = r#"{"asks":"host","version":1}"#; + assert!(matches!( + serde_json::from_str::(line).unwrap(), + Message::Host(_) + )); + let answer = Answer::host(HostAnswer { + key: "did:key:zQ".into(), + }); + assert_eq!( + serde_json::to_string(&answer).unwrap(), + r#"{"version":1,"host":{"key":"did:key:zQ"}}"# + ); + } + #[test] fn a_confirmation_carries_the_account_the_caller_claims() { let line = r#"{"asks":"confirm","version":1,"did":"did:web:a","url":"http://x/authorize"}"#; diff --git a/crates/didbot-agentd/src/scratch.rs b/crates/didbot-agentd/src/scratch.rs new file mode 100644 index 00000000..6c4c96d5 --- /dev/null +++ b/crates/didbot-agentd/src/scratch.rs @@ -0,0 +1,29 @@ +//! A directory the tests can dirty, gone when the test ends. +//! +//! The workspace carries no temporary-directory dependency and the tests in +//! this crate are not reason enough to add one. + +use std::path::PathBuf; + +/// A directory removed when this is dropped, however the test ends. +pub(crate) struct Scratch(pub(crate) PathBuf); + +impl Scratch { + /// A fresh directory under the system's temporary directory, named after + /// the test so two running at once never share one. + pub(crate) fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "didbot-agentd-{label}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&path); + Self(path) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} diff --git a/crates/didbot-agentd/src/serve.rs b/crates/didbot-agentd/src/serve.rs index 7d5804c1..1df702b8 100644 --- a/crates/didbot-agentd/src/serve.rs +++ b/crates/didbot-agentd/src/serve.rs @@ -12,27 +12,43 @@ use tokio::sync::Mutex; use tracing::{debug, info, warn}; use crate::context::{Key, Next, Store}; -use crate::protocol::{Answer, Confirm, Message, Report, VERSION}; +use crate::node::Node; +use crate::protocol::{Answer, Confirm, HostAnswer, Message, Report, VERSION}; use crate::registrar::{Registrar, Wanted}; use crate::socket::Listener; -/// The daemon's state: what it has seen, and where names come from. +/// The daemon's state: what it has seen, where names come from, and the key +/// it holds. pub struct Daemon { contexts: Mutex, registrar: R, confirmer: Option, + node: Node, } impl Daemon { - /// A daemon that has seen nothing yet. - pub fn new(registrar: R) -> Self { + /// A daemon that has seen nothing yet, holding `node`'s key. + pub fn new(registrar: R, node: Node) -> Self { Self { contexts: Mutex::new(Store::new()), registrar, confirmer: None, + node, } } + /// The key this daemon holds. The private half stays inside it. + pub fn node(&self) -> &Node { + &self.node + } + + /// What this host is: the public half of its key. + pub fn host(&self) -> Answer { + Answer::host(HostAnswer { + key: self.node.verifying_key().to_did_key(), + }) + } + /// Give it somewhere to confirm authorizations. pub fn confirming_at(mut self, server: impl Into) -> Self { self.confirmer = Some(crate::confirm::Confirmer::new(server)); @@ -102,6 +118,11 @@ impl Daemon { let answer = match serde_json::from_str::(&line) { Ok(Message::Report(report)) => self.consider(report).await, Ok(Message::Confirm(confirm)) => self.confirm(confirm).await, + Ok(Message::Host(host)) if host.version != VERSION => Answer::trouble(format!( + "this daemon speaks version {VERSION}, the question is version {}", + host.version + )), + Ok(Message::Host(_)) => self.host(), Err(err) => { warn!(error = %err, "unreadable message"); Answer::trouble(format!("unreadable message: {err}")) @@ -206,6 +227,8 @@ mod tests { use super::*; use crate::protocol::Observed; use crate::registrar::{Identity, Trouble}; + use crate::scratch::Scratch; + use didbot_key::VerifyingKey; use std::sync::atomic::{AtomicUsize, Ordering}; /// A registrar that mints without a server, and counts how often it was @@ -231,6 +254,14 @@ mod tests { } } + /// A daemon over a fresh key, and the directory it lives in for as long + /// as the test does. + fn daemon(label: &str, registrar: Counting) -> (Daemon, Scratch) { + let scratch = Scratch::new(label); + let node = Node::open(&scratch.0).unwrap(); + (Daemon::new(registrar, node), scratch) + } + fn report(observed: Observed, context: Option<&str>) -> Report { Report { version: VERSION, @@ -245,7 +276,7 @@ mod tests { #[tokio::test] async fn a_context_is_minted_once_and_told_once() { - let daemon = Daemon::new(Counting::default()); + let (daemon, _scratch) = daemon("once", Counting::default()); let first = daemon.consider(report(Observed::Began, Some("a-1"))).await; assert_eq!(first.identity.as_deref(), Some("did:web:a-1.example")); @@ -262,7 +293,7 @@ mod tests { #[tokio::test] async fn a_context_that_never_announced_itself_is_named_on_its_first_call() { - let daemon = Daemon::new(Counting::default()); + let (daemon, _scratch) = daemon("quiet", Counting::default()); let answer = daemon .consider(report(Observed::Acted, Some("quiet"))) .await; @@ -271,7 +302,7 @@ mod tests { #[tokio::test] async fn a_session_and_its_subagents_get_their_own_identities() { - let daemon = Daemon::new(Counting::default()); + let (daemon, _scratch) = daemon("own", Counting::default()); for context in [None, Some("a-1"), Some("a-2")] { daemon.consider(report(Observed::Began, context)).await; } @@ -281,10 +312,13 @@ mod tests { #[tokio::test] async fn a_server_that_cannot_be_reached_is_reported_and_retried() { - let daemon = Daemon::new(Counting { - broken: true, - ..Default::default() - }); + let (daemon, _scratch) = daemon( + "broken", + Counting { + broken: true, + ..Default::default() + }, + ); let answer = daemon.consider(report(Observed::Began, Some("a-1"))).await; assert!(answer.identity.is_none()); assert!(answer.trouble.is_some()); @@ -297,7 +331,7 @@ mod tests { #[tokio::test] async fn most_of_what_a_harness_says_costs_nothing() { - let daemon = Daemon::new(Counting::default()); + let (daemon, _scratch) = daemon("noted", Counting::default()); // Every event that is not a context beginning or acting arrives as // this, and a machine fires a great many of them. for _ in 0..20 { @@ -311,7 +345,7 @@ mod tests { #[tokio::test] async fn a_report_from_a_version_this_daemon_does_not_know_is_answered() { - let daemon = Daemon::new(Counting::default()); + let (daemon, _scratch) = daemon("version", Counting::default()); let mut ahead = report(Observed::Began, Some("a-1")); ahead.version = VERSION + 99; let answer = daemon.consider(ahead).await; @@ -319,6 +353,38 @@ mod tests { assert_eq!(daemon.registrar.minted.load(Ordering::SeqCst), 0); } + #[tokio::test] + async fn the_public_half_over_the_socket_is_this_daemons_key() { + let (daemon, scratch) = daemon("public", Counting::default()); + let daemon = Arc::new(daemon); + let listener = Listener::bind(scratch.0.join("agent.sock")).unwrap(); + let path = listener.path().to_path_buf(); + let serving = tokio::spawn(Arc::clone(&daemon).run(listener)); + + let mut stream = UnixStream::connect(&path).await.unwrap(); + stream + .write_all(b"{\"asks\":\"host\",\"version\":1}\n") + .await + .unwrap(); + let mut line = String::new(); + BufReader::new(&mut stream) + .read_line(&mut line) + .await + .unwrap(); + serving.abort(); + + let answer: Answer = serde_json::from_str(&line).unwrap(); + let key = answer.host.expect("a host answer").key; + assert_eq!(key, daemon.node().verifying_key().to_did_key()); + assert_eq!( + VerifyingKey::from_did_key(&key).unwrap(), + daemon.node().verifying_key() + ); + // The private half is not on the wire in any spelling. + assert!(!line.contains("node.key"), "{line}"); + assert_eq!(line.matches("did:key:").count(), 1, "{line}"); + } + #[test] fn an_id_the_harness_invents_survives_becoming_a_label() { let key = Key { diff --git a/crates/didbot-agentd/src/socket.rs b/crates/didbot-agentd/src/socket.rs index 74d671b1..dabfc1be 100644 --- a/crates/didbot-agentd/src/socket.rs +++ b/crates/didbot-agentd/src/socket.rs @@ -192,8 +192,9 @@ pub fn remove_if_stale(path: &Path) { /// Creates `dir` at `0700`, or tightens an existing one to `0700`. /// /// Tightening fails when this process does not own the directory, which is -/// the check that closes the pre-creation race. -fn secure_dir(dir: &Path) -> io::Result<()> { +/// the check that closes the pre-creation race. The state directory holding +/// the node key gets the same treatment, from the same function. +pub(crate) fn secure_dir(dir: &Path) -> io::Result<()> { if dir.is_dir() { return std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); } @@ -208,33 +209,14 @@ fn secure_dir(dir: &Path) -> io::Result<()> { mod tests { use super::*; - /// A directory removed when the test ends, however it ends. The - /// workspace carries no temporary-directory dependency and this is not - /// reason enough to add one. - struct Scratch(PathBuf); + use crate::scratch::Scratch; impl Scratch { - fn new(label: &str) -> Self { - let path = std::env::temp_dir().join(format!( - "didbot-agentd-{label}-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - let _ = std::fs::remove_dir_all(&path); - Self(path) - } - fn socket(&self) -> PathBuf { self.0.join(SOCKET_NAME) } } - impl Drop for Scratch { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } - } - fn mode_of(path: &Path) -> u32 { std::fs::metadata(path).unwrap().permissions().mode() & 0o777 } diff --git a/docs/agentd.md b/docs/agentd.md index cfe1931a..5473cdc3 100644 --- a/docs/agentd.md +++ b/docs/agentd.md @@ -164,6 +164,20 @@ it sends once and keeps no copy of. The daemon provisions outside its own lock, because every other hook on the machine would otherwise wait behind a round trip to the server. +### The key it holds + +The daemon mints one secp256k1 key on its first start and keeps it in its +state directory, `$XDG_STATE_HOME/didbot/agentd` unless `DIDBOT_STATE` names +another. The directory is created at `0700` and tightened to it on every +start; the key file is created at `0600` and tightened back to it when found +wider. A lock file beside the key carries an exclusive `flock`, so a second +daemon pointed at the same directory is refused before it reads a byte, and +the kernel drops the lock with the process. `crates/didbot-agentd/src/node.rs` +is the custody. + +The private half never crosses the socket. A `host` question is answered with +the public half as `did:key`, which is what `didbot host` prints. + ### Confirming an authorization An agent signs in to a third-party app the way -- 2.51.2 From e6e2323708cadb3cf0dc8ca36b636b4316f02770 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 9 Sep 2026 10:15:56 -0400 Subject: [PATCH 3/4] feat(agentd): reserve a host identity with `didbot become-host` The verb sends a `become` message naming a server; the daemon posts its public key to `bot.did.reserveIdentity`, writes the DID and hostname it gets back beside the key, and answers with both. A host that already holds an identity answers from it and asks no server, so a second run prints the same thing. Co-Authored-By: Claude Fable 5.1 Change-Id: Ia8d6fe5205e682ceed14d515b317a3b4455bf721 --- Cargo.lock | 1 + crates/didbot-agentd/Cargo.toml | 4 + crates/didbot-agentd/src/bin/didbot.rs | 71 +++++++++--- crates/didbot-agentd/src/lib.rs | 1 + crates/didbot-agentd/src/node.rs | 123 +++++++++++++++++++- crates/didbot-agentd/src/protocol.rs | 36 ++++++ crates/didbot-agentd/src/reserve.rs | 154 +++++++++++++++++++++++++ crates/didbot-agentd/src/serve.rs | 106 ++++++++++++++++- docs/agentd.md | 13 ++- 9 files changed, 483 insertions(+), 26 deletions(-) create mode 100644 crates/didbot-agentd/src/reserve.rs diff --git a/Cargo.lock b/Cargo.lock index a23749ad..f404a8b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -965,6 +965,7 @@ dependencies = [ name = "didbot-agentd" version = "0.1.0" dependencies = [ + "axum", "didbot-http", "didbot-key", "didbot-stack", diff --git a/crates/didbot-agentd/Cargo.toml b/crates/didbot-agentd/Cargo.toml index c17b26ce..940ae510 100644 --- a/crates/didbot-agentd/Cargo.toml +++ b/crates/didbot-agentd/Cargo.toml @@ -24,3 +24,7 @@ url.workspace = true [lints] workspace = true + +[dev-dependencies] +# A loopback `bot.did.reserveIdentity` for the tests to point the daemon at. +axum.workspace = true diff --git a/crates/didbot-agentd/src/bin/didbot.rs b/crates/didbot-agentd/src/bin/didbot.rs index 9be3a698..2ee01aa0 100644 --- a/crates/didbot-agentd/src/bin/didbot.rs +++ b/crates/didbot-agentd/src/bin/didbot.rs @@ -6,27 +6,35 @@ //! `didbot_agentd::protocol::Confirm` for what that means and why it is //! where this development stack already stands. //! -//! `host` prints what the daemon says this host is. +//! `become-host` asks the daemon to reserve an identity for its key at a +//! server, and `host` prints what the daemon says this host is. The operator +//! claims the reservation by name from their own machine; neither verb has +//! anything to do with that step. use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; use std::process::ExitCode; -use didbot_agentd::protocol::{Answer, Confirm, Host, Message, VERSION}; +use didbot_agentd::protocol::{Answer, Become, Confirm, Host, HostAnswer, Message, VERSION}; use didbot_agentd::socket::default_socket_path; const USAGE: &str = "\ didbot confirm --as confirm an authorization a client printed -didbot host print this host's public key +didbot become-host reserve an identity for this host at a server +didbot host print this host's key and identity The client prints its authorize URL instead of opening it; this hands that URL to the local daemon, which confirms it as the account named here. + +Becoming a host happens once: a host that already has an identity prints it +and asks nobody. "; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); match args.first().map(String::as_str) { Some("confirm") => confirm(&args[1..]), + Some("become-host") => become_host(&args[1..]), Some("host") => host(), Some("--help") | Some("-h") | None => { print!("{USAGE}"); @@ -73,26 +81,53 @@ fn confirm(args: &[String]) -> ExitCode { } } +fn become_host(args: &[String]) -> ExitCode { + let Some(server) = args.iter().find(|arg| !arg.starts_with("--")) else { + eprintln!("didbot become-host: needs the server's URL"); + return ExitCode::FAILURE; + }; + let message = Message::Become(Become { + version: VERSION, + server: server.clone(), + }); + print_host("become-host", ask(&message)) +} + fn host() -> ExitCode { - let message = Message::Host(Host { version: VERSION }); - match ask(&message) { - Ok(answer) => { - if let Some(trouble) = answer.trouble { - eprintln!("didbot host: {trouble}"); - return ExitCode::FAILURE; - } - let Some(host) = answer.host else { - eprintln!("didbot host: the daemon did not say what this host is"); - return ExitCode::FAILURE; - }; - println!("key {}", host.key); - ExitCode::SUCCESS + print_host("host", ask(&Message::Host(Host { version: VERSION }))) +} + +/// Prints what the daemon says this host is, one fact per line. +fn print_host(verb: &str, answer: std::io::Result) -> ExitCode { + let host = match answer { + Ok(Answer { + trouble: Some(trouble), + .. + }) => { + eprintln!("didbot {verb}: {trouble}"); + return ExitCode::FAILURE; + } + Ok(Answer { + host: Some(host), .. + }) => host, + Ok(_) => { + eprintln!("didbot {verb}: the daemon did not say what this host is"); + return ExitCode::FAILURE; } Err(err) => { - eprintln!("didbot host: {err}"); - ExitCode::FAILURE + eprintln!("didbot {verb}: {err}"); + return ExitCode::FAILURE; } + }; + let HostAnswer { key, did, hostname } = host; + println!("key {key}"); + if let Some(did) = did { + println!("did {did}"); + } + if let Some(hostname) = hostname { + println!("hostname {hostname}"); } + ExitCode::SUCCESS } /// One line out, one line back. diff --git a/crates/didbot-agentd/src/lib.rs b/crates/didbot-agentd/src/lib.rs index d595fed0..539eb5b8 100644 --- a/crates/didbot-agentd/src/lib.rs +++ b/crates/didbot-agentd/src/lib.rs @@ -26,6 +26,7 @@ pub mod context; pub mod node; pub mod protocol; pub mod registrar; +pub mod reserve; #[cfg(test)] pub(crate) mod scratch; pub mod serve; diff --git a/crates/didbot-agentd/src/node.rs b/crates/didbot-agentd/src/node.rs index 1b56da25..f178cf11 100644 --- a/crates/didbot-agentd/src/node.rs +++ b/crates/didbot-agentd/src/node.rs @@ -18,8 +18,10 @@ use std::fs::{File, OpenOptions}; use std::io::{self, Write}; use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; +use std::sync::Mutex; use didbot_key::{SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; use tracing::{info, warn}; use crate::socket::secure_dir; @@ -34,6 +36,10 @@ const KEY_FILE: &str = "node.key"; /// Held for the daemon's whole life. See [`Node::open`]. const LOCK_FILE: &str = "node.lock"; +/// The identity a server reserved for this key, once one has. See +/// [`Node::adopt`]. +const HOST_FILE: &str = "host.json"; + /// The mode every file in the state directory is created with and kept at. const FILE_MODE: u32 = 0o600; @@ -60,15 +66,21 @@ pub enum NodeError { #[source] source: io::Error, }, - /// The file is there and does not hold a key. Refused rather than - /// replaced: a key file that stopped parsing is a key file somebody - /// touched, and minting a fresh key over it would quietly make this a - /// different host. - #[error("{path} does not hold a node key")] + /// The file is there and does not hold what it should. Refused rather + /// than replaced: a file that stopped parsing is a file somebody touched, + /// and writing a fresh one over it would quietly make this a different + /// host. + #[error("{path} does not hold what this daemon wrote there")] Corrupt { /// The file that did not parse. path: PathBuf, }, + /// This host already has an identity, and it is not the one offered. + #[error("this host is already {did}")] + AlreadyHost { + /// The identity it holds. + did: String, + }, } impl NodeError { @@ -80,6 +92,15 @@ impl NodeError { } } +/// The identity a server reserved for this host's key. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Host { + /// The account this host is. + pub did: String, + /// The name the operator claims it by. + pub hostname: String, +} + /// The key this host signs with, held for the life of the process. /// /// Nothing here returns the private half. [`Node::verifying_key`] is the @@ -87,6 +108,7 @@ impl NodeError { pub struct Node { dir: PathBuf, key: SigningKey, + host: Mutex>, /// Kept open. The single-writer claim lives on this descriptor, and the /// kernel drops it with the process, so a crash leaves nothing stale. _lock: File, @@ -140,9 +162,17 @@ impl Node { } }; + let host_path = dir.join(HOST_FILE); + let host = read_shut(&host_path)? + .map(|text| { + serde_json::from_str(&text).map_err(|_| NodeError::Corrupt { path: host_path }) + }) + .transpose()?; + Ok(Self { dir, key, + host: Mutex::new(host), _lock: lock, }) } @@ -152,6 +182,46 @@ impl Node { &self.dir } + /// The identity this host holds, once a server has reserved one. + pub fn host(&self) -> Option { + self.host + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + /// Takes `host` as this host's identity, and writes it beside the key. + /// + /// Once. A second call with the same identity is a no-op, and one with a + /// different identity is refused: the identity is what the operator + /// vouches for by name, and a daemon that could be talked into another + /// would be one whose key could be vouched for under a name the operator + /// never saw. + pub fn adopt(&self, host: Host) -> Result<(), NodeError> { + let mut held = self + .host + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(current) = held.as_ref() { + return if *current == host { + Ok(()) + } else { + Err(NodeError::AlreadyHost { + did: current.did.clone(), + }) + }; + } + let mut text = serde_json::to_string(&host).map_err(|err| NodeError::Io { + path: self.dir.join(HOST_FILE), + source: io::Error::other(err), + })?; + text.push('\n'); + write_shut(&self.dir.join(HOST_FILE), text.as_bytes())?; + info!(did = %host.did, hostname = %host.hostname, "this host has an identity"); + *held = Some(host); + Ok(()) + } + /// The public half. pub fn verifying_key(&self) -> VerifyingKey { self.key.verifying_key() @@ -165,6 +235,7 @@ impl std::fmt::Debug for Node { f.debug_struct("Node") .field("dir", &self.dir) .field("key", &self.verifying_key().to_did_key()) + .field("host", &self.host()) .finish_non_exhaustive() } } @@ -301,6 +372,48 @@ mod tests { assert_eq!(std::fs::read_to_string(&key).unwrap(), "not a key\n"); } + fn host() -> Host { + Host { + did: "did:web:one.example".into(), + hostname: "one".into(), + } + } + + #[test] + fn an_identity_once_adopted_is_kept_shut_and_comes_back_on_restart() { + let scratch = Scratch::new("adopt"); + let node = Node::open(&scratch.0).unwrap(); + assert_eq!(node.host(), None); + + node.adopt(host()).unwrap(); + assert_eq!(mode_of(&scratch.0.join(HOST_FILE)), 0o600); + drop(node); + + let node = Node::open(&scratch.0).unwrap(); + assert_eq!(node.host(), Some(host())); + } + + #[test] + fn an_identity_is_taken_once() { + let scratch = Scratch::new("once"); + let node = Node::open(&scratch.0).unwrap(); + node.adopt(host()).unwrap(); + + node.adopt(host()) + .expect("the same identity again is nothing"); + + let other = Host { + did: "did:web:two.example".into(), + hostname: "two".into(), + }; + let refused = node.adopt(other).unwrap_err(); + assert!( + matches!(&refused, NodeError::AlreadyHost { did } if did == "did:web:one.example"), + "{refused:?}" + ); + assert_eq!(node.host(), Some(host())); + } + #[test] fn nothing_but_the_public_half_is_printed() { let scratch = Scratch::new("debug"); diff --git a/crates/didbot-agentd/src/protocol.rs b/crates/didbot-agentd/src/protocol.rs index f1322908..7cbde691 100644 --- a/crates/didbot-agentd/src/protocol.rs +++ b/crates/didbot-agentd/src/protocol.rs @@ -34,6 +34,8 @@ pub enum Message { Confirm(Confirm), /// Anything asking what this host is. Host(Host), + /// The operator's command asking this host to take an identity. + Become(Become), } impl<'de> Deserialize<'de> for Message { @@ -50,6 +52,9 @@ impl<'de> Deserialize<'de> for Message { Some("host") => serde_json::from_value(value) .map(Message::Host) .map_err(D::Error::custom), + Some("become") => serde_json::from_value(value) + .map(Message::Become) + .map_err(D::Error::custom), Some(other) => Err(D::Error::custom(format!( "this daemon does not know how to `{other}`" ))), @@ -149,11 +154,31 @@ pub struct Host { pub version: u32, } +/// A caller asking the daemon to reserve an identity for its key at a server. +/// +/// The server is the caller's choice, and reaching the socket is what allows +/// the choice: see [`crate::socket`]. What it cannot do is replace an +/// identity the host already holds; a daemon that holds one answers with it +/// and asks nobody. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Become { + /// The wire version this message was written against. + pub version: u32, + /// The origin of the server to reserve at. + pub server: String, +} + /// What this host is, as far as the daemon knows. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct HostAnswer { /// The public half of the node key, as `did:key`. pub key: String, + /// The account this host is, once a server has reserved one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub did: Option, + /// The name the operator claims it by, present with `did`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hostname: Option, } /// What the daemon says back. @@ -298,6 +323,8 @@ mod tests { )); let answer = Answer::host(HostAnswer { key: "did:key:zQ".into(), + did: None, + hostname: None, }); assert_eq!( serde_json::to_string(&answer).unwrap(), @@ -305,6 +332,15 @@ mod tests { ); } + #[test] + fn becoming_names_the_server() { + let line = r#"{"asks":"become","version":1,"server":"http://pds.example"}"#; + let Message::Become(asked) = serde_json::from_str::(line).unwrap() else { + panic!("a become"); + }; + assert_eq!(asked.server, "http://pds.example"); + } + #[test] fn a_confirmation_carries_the_account_the_caller_claims() { let line = r#"{"asks":"confirm","version":1,"did":"did:web:a","url":"http://x/authorize"}"#; diff --git a/crates/didbot-agentd/src/reserve.rs b/crates/didbot-agentd/src/reserve.rs new file mode 100644 index 00000000..13697130 --- /dev/null +++ b/crates/didbot-agentd/src/reserve.rs @@ -0,0 +1,154 @@ +//! Asking a server to reserve an identity for this host's key. +//! +//! The server is given the public half and answers with the account it set +//! aside and the name it goes by. The operator claims that name from their +//! own machine; this side is done once the answer is written beside the key. + +use serde::Deserialize; + +use crate::node::Host; +use crate::registrar::Trouble; + +/// What the server is told the key is for. +const KIND: &str = "host"; + +/// The fields of the reservation this daemon keeps. +#[derive(Deserialize)] +struct Reserved { + did: String, + hostname: String, +} + +/// Asks the server at `base` to reserve an identity for `key`. +pub async fn reserve(base: &str, key: &str) -> Result { + let url = format!( + "{}/xrpc/bot.did.reserveIdentity", + base.trim_end_matches('/') + ); + let response = didbot_http::client() + .post(&url) + .json(&serde_json::json!({ "key": key, "kind": KIND })) + .send() + .await + .map_err(|err| Trouble::Unreachable(err.to_string()))?; + + let status = response.status(); + let text = response + .text() + .await + .map_err(|err| Trouble::Unreachable(err.to_string()))?; + if !status.is_success() { + // The body, for the same reason the registrar keeps it: the refusal + // names itself, and a summary would send somebody to the server's log. + return Err(Trouble::Refused(format!("{status}: {}", text.trim()))); + } + let reserved: Reserved = serde_json::from_str(&text) + .map_err(|err| Trouble::Unexpected(format!("{err}: {}", text.trim())))?; + Ok(Host { + did: reserved.did, + hostname: reserved.hostname, + }) +} + +#[cfg(test)] +pub(crate) mod fake { + //! A `bot.did.reserveIdentity` on loopback, for tests to point the daemon + //! at. + + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use axum::extract::State; + use axum::http::StatusCode; + use axum::routing::post; + use axum::Json; + + /// What the fake was asked, and how it answers. + #[derive(Default)] + pub(crate) struct Served { + /// How many reservations were asked for. + pub(crate) asked: AtomicUsize, + /// The last key a caller presented. + pub(crate) key: std::sync::Mutex>, + /// Refuse everything with `QueueFull`. + pub(crate) full: bool, + } + + /// Serves the route until the returned handle is dropped, at the origin + /// returned with it. + pub(crate) async fn serve(served: Arc) -> (String, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let app = axum::Router::new() + .route("/xrpc/bot.did.reserveIdentity", post(reserve)) + .with_state(served); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (origin, task) + } + + async fn reserve( + State(served): State>, + Json(body): Json, + ) -> (StatusCode, Json) { + let n = served.asked.fetch_add(1, Ordering::SeqCst); + *served.key.lock().unwrap() = body["key"].as_str().map(str::to_owned); + if served.full { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ "error": "QueueFull", "message": "no room" })), + ); + } + ( + StatusCode::OK, + Json(serde_json::json!({ + "did": format!("did:web:host-{n}.example"), + "hostname": format!("host-{n}"), + })), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::Ordering; + use std::sync::Arc; + + #[tokio::test] + async fn a_reservation_carries_the_key_and_comes_back_named() { + let served = Arc::new(fake::Served::default()); + let (origin, _server) = fake::serve(Arc::clone(&served)).await; + + let host = reserve(&origin, "did:key:zQ").await.unwrap(); + + assert_eq!(host.did, "did:web:host-0.example"); + assert_eq!(host.hostname, "host-0"); + assert_eq!(served.key.lock().unwrap().as_deref(), Some("did:key:zQ")); + assert_eq!(served.asked.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn a_refusal_is_reported_by_name() { + let served = Arc::new(fake::Served { + full: true, + ..Default::default() + }); + let (origin, _server) = fake::serve(served).await; + + let err = reserve(&origin, "did:key:zQ").await.unwrap_err(); + assert!( + matches!(&err, Trouble::Refused(why) if why.contains("QueueFull")), + "{err}" + ); + } + + #[tokio::test] + async fn a_server_that_is_not_there_is_unreachable() { + let err = reserve("http://127.0.0.1:1", "did:key:zQ") + .await + .unwrap_err(); + assert!(matches!(err, Trouble::Unreachable(_)), "{err}"); + } +} diff --git a/crates/didbot-agentd/src/serve.rs b/crates/didbot-agentd/src/serve.rs index 1df702b8..32b704e5 100644 --- a/crates/didbot-agentd/src/serve.rs +++ b/crates/didbot-agentd/src/serve.rs @@ -13,7 +13,7 @@ use tracing::{debug, info, warn}; use crate::context::{Key, Next, Store}; use crate::node::Node; -use crate::protocol::{Answer, Confirm, HostAnswer, Message, Report, VERSION}; +use crate::protocol::{Answer, Become, Confirm, HostAnswer, Message, Report, VERSION}; use crate::registrar::{Registrar, Wanted}; use crate::socket::Listener; @@ -42,13 +42,48 @@ impl Daemon { &self.node } - /// What this host is: the public half of its key. + /// What this host is: the public half of its key, and its identity once + /// it has one. pub fn host(&self) -> Answer { + let host = self.node.host(); Answer::host(HostAnswer { key: self.node.verifying_key().to_did_key(), + did: host.as_ref().map(|host| host.did.clone()), + hostname: host.map(|host| host.hostname), }) } + /// Reserve an identity for this host's key, unless it holds one. + /// + /// Idempotent: a host that has an identity answers with it and asks + /// nobody, whatever server the caller named. + pub async fn become_host(&self, asked: Become) -> Answer { + if asked.version != VERSION { + return Answer::trouble(format!( + "this daemon speaks version {VERSION}, the request is version {}", + asked.version + )); + } + if let Some(host) = self.node.host() { + debug!(did = %host.did, "already a host"); + return self.host(); + } + let key = self.node.verifying_key().to_did_key(); + match crate::reserve::reserve(&asked.server, &key).await { + Ok(host) => match self.node.adopt(host) { + Ok(()) => self.host(), + Err(err) => { + warn!(error = %err, "could not keep the reserved identity"); + Answer::trouble(err.to_string()) + } + }, + Err(err) => { + warn!(server = %asked.server, error = %err, "could not reserve an identity"); + Answer::trouble(err.to_string()) + } + } + } + /// Give it somewhere to confirm authorizations. pub fn confirming_at(mut self, server: impl Into) -> Self { self.confirmer = Some(crate::confirm::Confirmer::new(server)); @@ -123,6 +158,7 @@ impl Daemon { host.version )), Ok(Message::Host(_)) => self.host(), + Ok(Message::Become(asked)) => self.become_host(asked).await, Err(err) => { warn!(error = %err, "unreadable message"); Answer::trouble(format!("unreadable message: {err}")) @@ -385,6 +421,72 @@ mod tests { assert_eq!(line.matches("did:key:").count(), 1, "{line}"); } + #[tokio::test] + async fn becoming_a_host_happens_once_and_survives_a_restart() { + use crate::reserve::fake; + let served = Arc::new(fake::Served::default()); + let (origin, _server) = fake::serve(Arc::clone(&served)).await; + let (daemon, scratch) = daemon("become", Counting::default()); + + let first = daemon + .become_host(Become { + version: VERSION, + server: origin.clone(), + }) + .await; + let host = first.host.expect("an identity"); + assert_eq!(host.did.as_deref(), Some("did:web:host-0.example")); + assert_eq!(host.hostname.as_deref(), Some("host-0")); + assert_eq!( + served.key.lock().unwrap().as_deref(), + Some(daemon.node().verifying_key().to_did_key().as_str()) + ); + + // Again, at a server that is not even there: answered from what is + // held, and the real one is not asked. + let again = daemon + .become_host(Become { + version: VERSION, + server: "http://127.0.0.1:1".into(), + }) + .await; + assert!(again.trouble.is_none(), "{again:?}"); + assert_eq!( + again.host.unwrap().did.as_deref(), + Some("did:web:host-0.example") + ); + assert_eq!(served.asked.load(Ordering::SeqCst), 1); + + // And after a restart the identity is still held. + drop(daemon); + let node = Node::open(&scratch.0).unwrap(); + let restarted = Daemon::new(Counting::default(), node); + assert_eq!( + restarted.host().host.unwrap().did.as_deref(), + Some("did:web:host-0.example") + ); + } + + #[tokio::test] + async fn a_refused_reservation_leaves_the_host_without_an_identity() { + use crate::reserve::fake; + let served = Arc::new(fake::Served { + full: true, + ..Default::default() + }); + let (origin, _server) = fake::serve(served).await; + let (daemon, _scratch) = daemon("refused", Counting::default()); + + let answer = daemon + .become_host(Become { + version: VERSION, + server: origin, + }) + .await; + assert!(answer.trouble.unwrap().contains("QueueFull")); + assert_eq!(daemon.host().host.unwrap().did, None); + } + #[test] fn an_id_the_harness_invents_survives_becoming_a_label() { let key = Key { diff --git a/docs/agentd.md b/docs/agentd.md index 5473cdc3..bf5a1e3c 100644 --- a/docs/agentd.md +++ b/docs/agentd.md @@ -176,7 +176,18 @@ the kernel drops the lock with the process. `crates/didbot-agentd/src/node.rs` is the custody. The private half never crosses the socket. A `host` question is answered with -the public half as `did:key`, which is what `didbot host` prints. +the public half as `did:key` and, once the host has one, its identity; that is +what `didbot host` prints. + +### Becoming a host + +`didbot become-host ` asks the daemon to reserve an identity for its +key. The daemon posts the public half to `bot.did.reserveIdentity` at that +server, writes the DID and hostname it gets back beside the key, and answers +with both; the command prints them. The operator then claims that hostname +from their own machine, which is the vouch [the chain](#where-it-sits-in-the-chain) +below describes. Becoming a host happens once: a daemon that holds an identity +answers with it and asks no server, whichever one the command names. ### Confirming an authorization -- 2.51.2 From a6b674d96b3f81227ea8d77979822b76c52eb7bc Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 9 Sep 2026 10:19:27 -0400 Subject: [PATCH 4/4] feat(agentd): sign each context's provisioning with the node key Every `bot.did.provisionAgent` the daemon sends now carries an attestation claim in `didbot-attest`'s node-credential format, naming the host's DID as the node and as the account's parent. A host with no identity signs nothing, so a context on it is answered with trouble and stays owed a name. `AttestationClaim` gains its wire shape so the node and the server read one definition. Co-Authored-By: Claude Fable 5.1 Change-Id: I0958c20abc23e75281e29ad4392c29d09e75e58a --- Cargo.lock | 6 ++ crates/didbot-agentd/Cargo.toml | 3 + crates/didbot-agentd/src/node.rs | 70 ++++++++++++++++++++++ crates/didbot-agentd/src/registrar.rs | 4 ++ crates/didbot-agentd/src/serve.rs | 83 +++++++++++++++++++++++++-- crates/didbot-attest/Cargo.toml | 2 +- crates/didbot-attest/src/claim.rs | 35 ++++++++++- crates/didbot-attest/src/rfc3339.rs | 3 +- docs/agentd.md | 13 +++-- plan/node.md | 10 ++-- 10 files changed, 212 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f404a8b0..bfea8ac0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -966,14 +966,17 @@ name = "didbot-agentd" version = "0.1.0" dependencies = [ "axum", + "didbot-attest", "didbot-http", "didbot-key", "didbot-stack", "hex", + "rand_core 0.6.4", "reqwest", "serde", "serde_json", "thiserror 2.0.20", + "time", "tokio", "tracing", "tracing-subscriber", @@ -1841,6 +1844,9 @@ name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] [[package]] name = "hkdf" diff --git a/crates/didbot-agentd/Cargo.toml b/crates/didbot-agentd/Cargo.toml index 940ae510..b4ee8dda 100644 --- a/crates/didbot-agentd/Cargo.toml +++ b/crates/didbot-agentd/Cargo.toml @@ -9,14 +9,17 @@ repository.workspace = true publish.workspace = true [dependencies] +didbot-attest.workspace = true didbot-http.workspace = true didbot-key.workspace = true didbot-stack.workspace = true hex.workspace = true +rand_core.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true +time.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/crates/didbot-agentd/src/node.rs b/crates/didbot-agentd/src/node.rs index f178cf11..485dca55 100644 --- a/crates/didbot-agentd/src/node.rs +++ b/crates/didbot-agentd/src/node.rs @@ -7,6 +7,11 @@ //! can get across the socket is derived from it: the public half, and //! signatures. //! +//! A claim is signed here, for a context's provisioning, under the identity +//! this host holds: `didbot-attest`'s node-credential format, with the host's +//! DID as the node it names. A host with no identity signs nothing, because +//! a claim with nothing to name is a claim no verifier can look up. +//! //! The file gets the treatment `crates/didbot-serve/src/estop_admin.rs` and //! this crate's own [`socket`](crate::socket) give theirs: a `0700` directory //! tightened on every start, a `0600` file set explicitly rather than left to @@ -20,8 +25,11 @@ use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::sync::Mutex; +use didbot_attest::{AttestError, AttestationClaim, NodeCredentialBackend}; use didbot_key::{SigningKey, VerifyingKey}; +use rand_core::{OsRng, RngCore}; use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; use tracing::{info, warn}; use crate::socket::secure_dir; @@ -46,6 +54,10 @@ const FILE_MODE: u32 = 0o600; /// Bits a file holding a secret must not carry: anything for group or other. const LOOSE_MASK: u32 = 0o077; +/// Random bytes in a claim's nonce. Sixteen is enough that two claims from +/// this host in one second never collide, which is all the nonce is for. +const NONCE_LEN: usize = 16; + /// Why the node key could not be taken up. #[derive(Debug, thiserror::Error)] pub enum NodeError { @@ -81,6 +93,13 @@ pub enum NodeError { /// The identity it holds. did: String, }, + /// Nothing has reserved an identity for this host yet, so there is + /// nothing for a claim to name. + #[error("this host has no identity yet; run `didbot become-host `")] + NoIdentity, + /// The claim's fields could not be put into the signing format. + #[error("could not sign a claim: {0}")] + Unsignable(#[from] AttestError), } impl NodeError { @@ -226,6 +245,23 @@ impl Node { pub fn verifying_key(&self) -> VerifyingKey { self.key.verifying_key() } + + /// Signs a claim, as of `now`, that this host is asking. + /// + /// The node the claim names is this host's DID, so a verifier that holds + /// the public half under that DID admits it and one that holds any other + /// key does not. Refused while the host has no identity. + pub fn sign_claim(&self, now: OffsetDateTime) -> Result { + let host = self.host().ok_or(NodeError::NoIdentity)?; + let mut nonce = [0u8; NONCE_LEN]; + OsRng.fill_bytes(&mut nonce); + Ok(NodeCredentialBackend::sign_claim( + &self.key, + host.did, + hex::encode(nonce), + now, + )?) + } } impl std::fmt::Debug for Node { @@ -414,6 +450,40 @@ mod tests { assert_eq!(node.host(), Some(host())); } + #[test] + fn a_claim_names_this_host_and_verifies_under_its_key_alone() { + let scratch = Scratch::new("claim"); + let node = Node::open(&scratch.0).unwrap(); + let now = OffsetDateTime::now_utc(); + + let refused = node.sign_claim(now).unwrap_err(); + assert!(matches!(refused, NodeError::NoIdentity), "{refused:?}"); + + node.adopt(host()).unwrap(); + let claim = node.sign_claim(now).unwrap(); + assert_eq!(claim.node_id, "did:web:one.example"); + + let ours = NodeCredentialBackend::new([("did:web:one.example", node.verifying_key())]); + ours.attest_at(&claim, now) + .expect("admitted under this host's key"); + + let other = NodeCredentialBackend::new([( + "did:web:one.example", + SigningKey::generate().verifying_key(), + )]); + assert_eq!( + other.attest_at(&claim, now), + Err(AttestError::InvalidEvidence), + "refused under any other key" + ); + + let second = node.sign_claim(now).unwrap(); + assert_ne!( + claim.nonce, second.nonce, + "two claims in one instant differ" + ); + } + #[test] fn nothing_but_the_public_half_is_printed() { let scratch = Scratch::new("debug"); diff --git a/crates/didbot-agentd/src/registrar.rs b/crates/didbot-agentd/src/registrar.rs index 8fd4267c..7ef3f130 100644 --- a/crates/didbot-agentd/src/registrar.rs +++ b/crates/didbot-agentd/src/registrar.rs @@ -7,6 +7,7 @@ use std::future::Future; +use didbot_attest::AttestationClaim; use serde::Deserialize; /// What is known about a context at the moment it needs a name. @@ -19,6 +20,8 @@ pub struct Wanted { pub kind: Option, /// The identity of whatever spawned it, when it has one. pub parent: Option, + /// The host's word that it is asking, signed with its node key. + pub claim: AttestationClaim, } /// An account, as the server handed it back. @@ -94,6 +97,7 @@ impl Registrar for Pds { "agentType": wanted.kind, "parent": wanted.parent, }, + "attestation": wanted.claim, }); let response = self diff --git a/crates/didbot-agentd/src/serve.rs b/crates/didbot-agentd/src/serve.rs index 32b704e5..08e6d9db 100644 --- a/crates/didbot-agentd/src/serve.rs +++ b/crates/didbot-agentd/src/serve.rs @@ -196,10 +196,21 @@ impl Daemon { Next::Reserve(key) => key, }; + // Signed under this host's identity, which is also the parent the + // account records. A host with none is refused here, and the context + // stays owed a name rather than getting one nobody vouched for. + let claim = match self.node.sign_claim(time::OffsetDateTime::now_utc()) { + Ok(claim) => claim, + Err(err) => { + warn!(error = %err, "could not sign for a context"); + return Answer::trouble(err.to_string()); + } + }; let wanted = Wanted { agent_id: agent_id(&key), kind: report.kind.clone(), - parent: None, + parent: Some(claim.node_id.clone()), + claim, }; // The lock is not held across this: provisioning talks to a server, @@ -261,12 +272,16 @@ fn agent_id(key: &Key) -> String { #[cfg(test)] mod tests { use super::*; + use crate::node::Host; use crate::protocol::Observed; use crate::registrar::{Identity, Trouble}; use crate::scratch::Scratch; - use didbot_key::VerifyingKey; + use didbot_attest::{AttestError, NodeCredentialBackend}; + use didbot_key::{SigningKey, VerifyingKey}; use std::sync::atomic::{AtomicUsize, Ordering}; + const HOST: &str = "did:web:host.example"; + /// A registrar that mints without a server, and counts how often it was /// asked. The count is the point: telling a context its name twice is /// harmless, minting it two accounts is not. @@ -274,10 +289,13 @@ mod tests { struct Counting { minted: AtomicUsize, broken: bool, + /// The last thing it was asked for, for a test to verify. + last: std::sync::Mutex>, } impl Registrar for Counting { async fn provision(&self, wanted: Wanted) -> Result { + *self.last.lock().unwrap() = Some(wanted.clone()); if self.broken { return Err(Trouble::Unreachable("no server here".into())); } @@ -291,8 +309,21 @@ mod tests { } /// A daemon over a fresh key, and the directory it lives in for as long - /// as the test does. + /// as the test does. It already is a host, since that is what issuing + /// to a context takes. fn daemon(label: &str, registrar: Counting) -> (Daemon, Scratch) { + let (daemon, scratch) = daemon_without_identity(label, registrar); + daemon + .node() + .adopt(Host { + did: HOST.into(), + hostname: "host".into(), + }) + .unwrap(); + (daemon, scratch) + } + + fn daemon_without_identity(label: &str, registrar: Counting) -> (Daemon, Scratch) { let scratch = Scratch::new(label); let node = Node::open(&scratch.0).unwrap(); (Daemon::new(registrar, node), scratch) @@ -327,6 +358,48 @@ mod tests { assert_eq!(daemon.registrar.minted.load(Ordering::SeqCst), 1); } + #[tokio::test] + async fn a_context_is_refused_a_name_until_this_host_has_one() { + let (daemon, _scratch) = daemon_without_identity("nohost", Counting::default()); + + let answer = daemon.consider(report(Observed::Began, Some("a-1"))).await; + assert!(answer.identity.is_none()); + assert!(answer.trouble.unwrap().contains("become-host")); + assert_eq!(daemon.registrar.minted.load(Ordering::SeqCst), 0); + + // Given one, the same context is named on its next call. + daemon + .node() + .adopt(Host { + did: HOST.into(), + hostname: "host".into(), + }) + .unwrap(); + let answer = daemon.consider(report(Observed::Acted, Some("a-1"))).await; + assert_eq!(answer.identity.as_deref(), Some("did:web:a-1.example")); + } + + #[tokio::test] + async fn a_contexts_provisioning_carries_a_claim_only_this_host_could_sign() { + let (daemon, _scratch) = daemon("claim", Counting::default()); + daemon.consider(report(Observed::Began, Some("a-1"))).await; + + let wanted = daemon.registrar.last.lock().unwrap().clone().unwrap(); + assert_eq!(wanted.parent.as_deref(), Some(HOST)); + assert_eq!(wanted.claim.node_id, HOST); + + let now = wanted.claim.issued_at; + let ours = NodeCredentialBackend::new([(HOST, daemon.node().verifying_key())]); + ours.attest_at(&wanted.claim, now) + .expect("verifies under the key the daemon publishes"); + + let theirs = NodeCredentialBackend::new([(HOST, SigningKey::generate().verifying_key())]); + assert_eq!( + theirs.attest_at(&wanted.claim, now), + Err(AttestError::InvalidEvidence) + ); + } + #[tokio::test] async fn a_context_that_never_announced_itself_is_named_on_its_first_call() { let (daemon, _scratch) = daemon("quiet", Counting::default()); @@ -426,7 +499,7 @@ mod tests { use crate::reserve::fake; let served = Arc::new(fake::Served::default()); let (origin, _server) = fake::serve(Arc::clone(&served)).await; - let (daemon, scratch) = daemon("become", Counting::default()); + let (daemon, scratch) = daemon_without_identity("become", Counting::default()); let first = daemon .become_host(Become { @@ -475,7 +548,7 @@ mod tests { ..Default::default() }); let (origin, _server) = fake::serve(served).await; - let (daemon, _scratch) = daemon("refused", Counting::default()); + let (daemon, _scratch) = daemon_without_identity("refused", Counting::default()); let answer = daemon .become_host(Become { diff --git a/crates/didbot-attest/Cargo.toml b/crates/didbot-attest/Cargo.toml index c3cd9db9..21ae15d1 100644 --- a/crates/didbot-attest/Cargo.toml +++ b/crates/didbot-attest/Cargo.toml @@ -11,7 +11,7 @@ publish.workspace = true [dependencies] der = { version = "0.7.10", default-features = false, features = ["alloc", "derive", "oid"] } didbot-key.workspace = true -hex.workspace = true +hex = { workspace = true, features = ["serde"] } rsa = { version = "0.9.8", default-features = false, features = ["std", "pem"] } serde.workspace = true serde_json.workspace = true diff --git a/crates/didbot-attest/src/claim.rs b/crates/didbot-attest/src/claim.rs index 6e7d43ba..829a7fc1 100644 --- a/crates/didbot-attest/src/claim.rs +++ b/crates/didbot-attest/src/claim.rs @@ -10,7 +10,13 @@ use crate::rfc3339; /// The claim itself is unauthenticated data — it is whatever arrived over the /// wire. Only a backend that has verified `evidence` may turn it into a /// [`Provenance`]. -#[derive(Debug, Clone, PartialEq, Eq)] +/// +/// On the wire it is camelCase, the timestamp RFC 3339 and the evidence +/// lowercase hex, which is the shape everything this project's server writes +/// down already takes. The node that signs and the server that verifies read +/// one definition. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct AttestationClaim { /// Identifies the machine asking. Compared against a backend's allowlist. pub node_id: String, @@ -18,9 +24,11 @@ pub struct AttestationClaim { /// same node in the same second from being byte-identical. pub nonce: String, /// When the node made this claim, used for the freshness window. + #[serde(with = "rfc3339")] pub issued_at: OffsetDateTime, /// Backend-specific proof. A MAC tag here; an instance identity document /// or a TPM quote under a future backend. + #[serde(with = "hex::serde")] pub evidence: Vec, } @@ -231,3 +239,28 @@ pub enum AttestError { #[error("claim could not be canonicalized: {0}")] Malformed(String), } + +#[cfg(test)] +mod tests { + use super::*; + use time::macros::datetime; + + #[test] + fn a_claim_crosses_the_wire_the_way_the_server_writes_things_down() { + let claim = AttestationClaim::new( + "did:web:host.example", + "n001", + datetime!(2026-08-24 12:00:00 +02:00), + vec![0xab, 0x01], + ); + let wire = serde_json::to_string(&claim).unwrap(); + assert_eq!( + wire, + r#"{"nodeId":"did:web:host.example","nonce":"n001","issuedAt":"2026-08-24T10:00:00Z","evidence":"ab01"}"# + ); + assert_eq!( + serde_json::from_str::(&wire).unwrap(), + claim + ); + } +} diff --git a/crates/didbot-attest/src/rfc3339.rs b/crates/didbot-attest/src/rfc3339.rs index c6a7ad8b..bfbf616b 100644 --- a/crates/didbot-attest/src/rfc3339.rs +++ b/crates/didbot-attest/src/rfc3339.rs @@ -1,4 +1,5 @@ -//! RFC 3339 serialization for the timestamp in [`Provenance`](crate::Provenance). +//! RFC 3339 serialization for the timestamps in [`Provenance`](crate::Provenance) +//! and [`AttestationClaim`](crate::AttestationClaim). //! //! Hand-written because the `time` crate's serde support is not enabled here, //! and because atproto wants RFC 3339 strings rather than whatever a derived diff --git a/docs/agentd.md b/docs/agentd.md index bf5a1e3c..88f4cce2 100644 --- a/docs/agentd.md +++ b/docs/agentd.md @@ -158,11 +158,14 @@ bookkeeping in memory, deciding which context still needs a name. Names come from a registrar. The one that speaks to this project's server calls `bot.did.provisionAgent` with the harness's identifier for the context, -the harness's word for its kind, and its parent. The server answers with the -account's DID, the handle it chose, and the account's write credential, which -it sends once and keeps no copy of. The daemon provisions outside its own -lock, because every other hook on the machine would otherwise wait behind a -round trip to the server. +the harness's word for its kind, the host's DID as its parent, and a claim +signed with the node key: `didbot-attest`'s node-credential format, naming the +host's DID as the node. A host with no identity signs nothing, so a context on +it is answered with trouble naming `become-host` and stays owed a name. The +server answers with the account's DID, the handle it chose, and the account's +write credential, which it sends once and keeps no copy of. The daemon +provisions outside its own lock, because every other hook on the machine would +otherwise wait behind a round trip to the server. ### The key it holds diff --git a/plan/node.md b/plan/node.md index 32c3db60..cad5b3cf 100644 --- a/plan/node.md +++ b/plan/node.md @@ -138,10 +138,6 @@ which is what keeps a credential out of every place the model can read. a fan-out of subagents will produce one. The cap doubles as the bound on how much work an unadmitted caller can ask for, and the depth is a health number rather than a constant nobody can see — see [alerts](alerts.md). -- [ ] **Whatever key material this process holds, it is the only writer of.** - The design this replaces documented a lost-update race it accepted, - because the cost was one unstamped agent. On private keys the cost is a - leaked or twice-issued one. - [ ] **Nothing is prepared in advance.** An identity is reserved when a context appears and a key is generated when one is needed, and neither is kept spare against a future caller. Pre-preparing either is an @@ -183,6 +179,12 @@ which is what keeps a credential out of every place the model can read. ## Done +- [x] **Whatever key material this process holds, it is the only writer of.** + The design this replaces documented a lost-update race it accepted, + because the cost was one unstamped agent. On private keys the cost is a + leaked or twice-issued one. `crates/didbot-agentd/src/node.rs` holds an + exclusive lock beside the key for the daemon's life, and the identity + the key is reserved under is written by the same process. - [x] **New crate, not a role something already running grows.** There is no daemon on an agent host to grow: `didbot-setup` is one-shot and deliberately read-only, `didbot-claim` and `didbot-verify` are one-shot