Something went wrong. Try again.
Identities for entities did.bot
agent llm did
Something went wrong. Try again.
18 kB · 496 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497//! 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 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//! 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 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;
/// 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 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;
/// 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 { /// 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 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 <pds-url>`")] 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 { fn io(path: &Path) -> impl FnOnce(io::Error) -> Self + '_ { move |source| Self::Io { path: path.to_path_buf(), source, } }}
/// 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/// 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, }) } }
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 } };
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, }) }
/// The directory the key lives in. pub fn dir(&self) -> &Path { &self.dir }
/// The identity this host holds, once a server has reserved one. 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 /// 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() }
/// 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<AttestationClaim, NodeError> { 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 { 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() }}
/// 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()}
/// 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<Option<String>, 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"); }
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 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"); 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}"); }}