//! 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 `")] 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>, /// 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 } }; 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 { 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 { 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 { 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"); } 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}"); } }