Something went wrong. Try again.
Identities for entities did.bot
agent llm did
Something went wrong. Try again.
19 kB · 525 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526//! 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.//!//! A token is signed here, for a context's provisioning, under the identity//! this host holds: a JWT ([`crate::jwt`]) with the host's DID as its//! issuer, which the server verifies against the `#key-1` in the host's//! document. A host with no identity signs nothing, because a token with//! nothing to name is a token no verifier can look up.//!//! The file gets the treatment this crate's own//! [`socket`](crate::socket) gives its own: 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: the//! key is a file, and anything that can read this user's disk can copy it.
use std::fs::File;use std::io;use std::path::{Path, PathBuf};use std::sync::Mutex;
use didbot_key::{SigningKey, VerifyingKey};use serde::{Deserialize, Serialize};use time::OffsetDateTime;use tracing::info;
use crate::shut::{self, open_shut};use crate::socket::secure_dir;
/// Where the daemon keeps its state unless `DIDBOT_STATE` says otherwise:/// `$XDG_STATE_HOME/didbot/agentd`, or `~/.local/state/didbot/agentd` when/// the variable is unset or empty.////// State rather than configuration: nothing here is hand-edited, and losing/// it costs this host its node key and the identity registered under it./// The current directory is the last resort for a process with neither/// variable — a container, a cron job — because a relative path there/// beats one rooted at `/`.#[must_use]pub fn default_state_dir() -> PathBuf { didbot_cli::tokens::default_state_dir()}
/// 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 identity this key was registered under, once it has been. See/// [`Node::adopt`].const HOST_FILE: &str = "host.json";
/// 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 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, }, /// Nothing has registered an identity for this host yet, so there is /// nothing for a token to name. #[error("this host has no identity yet; `didbot register host <name>` gives it one")] NoIdentity, /// 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 /// it is knowable, rather than at every provisioning for the life of /// the daemon. #[error( "{dir} holds an identity ({did}) and no key to sign under: the key it was registered \ with is gone. Remove {did}'s operator record, then delete {dir} and register again" )] KeyGone { /// The state directory. dir: PathBuf, /// The identity that can no longer sign in. did: String, },}
impl NodeError { fn io(path: &Path) -> impl FnOnce(io::Error) -> Self + '_ { move |source| Self::Io { path: path.to_path_buf(), source, } }}
/// The identity this host's key was registered under.#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]pub struct Host { /// The account this host is. pub did: String, /// The name the operator's record is keyed 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/// public one, and signing happens through methods on this type.pub struct Node { dir: PathBuf, key: SigningKey, host: Mutex<Option<Host>>, /// 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<PathBuf>) -> Result<Self, NodeError> { 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, }) } }
// The identity first, so that a key about to be minted can be // checked against whether one is already held. let host_path = dir.join(HOST_FILE); let host: Option<Host> = read_shut(&host_path)? .map(|text| { serde_json::from_str(&text).map_err(|_| NodeError::Corrupt { path: host_path.clone(), }) }) .transpose()?;
// A key is minted only when there is none, and an identity was // registered under a key. Both at once means the key that identity // belongs to is gone, and a fresh one can never sign under it. See // [`NodeError::KeyGone`]. let key_path = dir.join(KEY_FILE); if let (Some(held), false) = (&host, key_path.exists()) { return Err(NodeError::KeyGone { dir, did: held.did.clone(), }); } let key = key_at(&key_path)?;
Ok(Self { dir, key, host: Mutex::new(host), _lock: lock, }) }
/// The identity this host holds, once one has been registered. pub fn host(&self) -> Option<Host> { 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's /// record names, and a daemon that could be talked into another would be /// one whose key could be admitted 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() }
/// Signs a token, as of `now`, that this host is calling `lxm` at the /// 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. /// Refused while the host has no identity. pub fn sign(&self, aud: &str, lxm: &str, now: OffsetDateTime) -> Result<String, NodeError> { let host = self.host().ok_or(NodeError::NoIdentity)?; Ok(self.sign_as(&host.did, aud, lxm, now)) }
/// Signs a token as `iss`, whatever identity this host holds. /// /// For `didbot register`, which signs in as the name it is registering /// before the daemon has adopted it. The private half stays here. pub fn sign_as(&self, iss: &str, aud: &str, lxm: &str, now: OffsetDateTime) -> String { crate::jwt::sign(&self.key, iss, aud, lxm, now) }}
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()) .field("host", &self.host()) .finish_non_exhaustive() }}
/// The directory, under a state directory, that keys registered by name/// live in. See [`key_in`].const KEYS_DIR: &str = "keys";
/// The key registered under `name` in `dir`'s state, minted when there is/// none.////// A host's own key is [`Node`]'s; this is for every other kind of account/// `didbot register` makes on a machine, one file per name, kept as shut as/// the node key is.pub fn key_in(dir: &Path, name: &str) -> Result<SigningKey, NodeError> { let keys = dir.join(KEYS_DIR); secure_dir(&keys).map_err(NodeError::io(&keys))?; key_at(&keys.join(format!("{name}.key")))}
/// The key in the file at `path`, minted and written there when there is/// none.fn key_at(path: &Path) -> Result<SigningKey, NodeError> { if let Some(text) = read_shut(path)? { return parse_key(&text).ok_or_else(|| NodeError::Corrupt { path: path.to_path_buf(), }); } let key = SigningKey::generate(); let mut text = hex::encode(key.to_bytes()); text.push('\n'); write_shut(path, text.as_bytes())?; info!( path = %path.display(), key = %key.verifying_key().to_did_key(), "minted a key" ); Ok(key)}
/// Sixty-four hex digits to a key, or nothing for anything else.fn parse_key(text: &str) -> Option<SigningKey> { let bytes: [u8; 32] = hex::decode(text.trim()).ok()?.try_into().ok()?; SigningKey::from_bytes(&bytes).ok()}
/// [`shut::read`], with this module's error on the way out.fn read_shut(path: &Path) -> Result<Option<String>, NodeError> { shut::read(path).map_err(NodeError::io(path))}
/// Writes a file this process keeps shut, whole or not at all. See/// [`crate::shut`].fn write_shut(path: &Path, bytes: &[u8]) -> Result<(), NodeError> { shut::write(path, bytes).map_err(NodeError::io(path))}
#[cfg(test)]mod tests { use super::*; use crate::scratch::Scratch; use std::os::unix::fs::PermissionsExt;
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"); }
fn host() -> Host { Host { did: "did:web:one.example".into(), hostname: "one".into(), } }
/// An identity that survives the key it was registered under can never /// sign in again: its document publishes the public half of a key this /// daemon no longer has, so every token is refused and nothing says why. /// Refused at the one moment it is knowable, with both halves of the /// remedy in the message. #[test] fn an_identity_beside_a_lost_key_is_refused_rather_than_kept() { let scratch = Scratch::new("key-gone"); { let node = Node::open(&scratch.0).expect("a fresh state directory"); node.adopt(host()).expect("adopted"); } std::fs::remove_file(scratch.0.join(KEY_FILE)).expect("the key is lost");
let refused = Node::open(&scratch.0).unwrap_err(); assert!( matches!(&refused, NodeError::KeyGone { did, .. } if did == &host().did), "{refused:?}" ); let said = refused.to_string(); assert!(said.contains(&host().did), "{said}"); assert!(said.contains("register again"), "{said}"); assert!( !scratch.0.join(KEY_FILE).exists(), "nothing was minted over the refusal" );
// The remedy works: the whole directory, not half of it. std::fs::remove_file(scratch.0.join(HOST_FILE)).expect("the identity goes too"); let node = Node::open(&scratch.0).expect("a directory with neither opens"); assert_eq!(node.host(), None); }
#[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 a_token_names_this_host_and_verifies_under_its_key_alone() { let scratch = Scratch::new("token"); let node = Node::open(&scratch.0).unwrap(); let now = OffsetDateTime::now_utc();
let refused = node .sign("did:web:pds.example", "bot.did.createAccount", now) .unwrap_err(); assert!(matches!(refused, NodeError::NoIdentity), "{refused:?}");
node.adopt(host()).unwrap(); let token = node .sign("did:web:pds.example", "bot.did.createAccount", now) .unwrap();
let claims = crate::jwt::verify(&token, &node.verifying_key()) .expect("admitted under this host's key"); assert_eq!(claims.iss, "did:web:one.example"); assert_eq!(claims.aud, "did:web:pds.example"); assert!( crate::jwt::verify(&token, &SigningKey::generate().verifying_key()).is_err(), "refused under any other key" ); }
#[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}"); }}