From b6cdfd1f303b2e43f18626ede7df0e8e3cc55245 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 11 Sep 2026 21:01:47 -0400 Subject: [PATCH] fix(agentd)!: refuse an identity whose key is gone, and say what a dead one means MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A key minted beside an existing host.json is an identity this daemon can never attest under, so `Node::open` refuses it there rather than at every provisioning for the rest of its life. A repeated `AttestationRefused` — what a reservation reaped before its vouch looks like — now says so and names both halves of the remedy. Change-Id: I99e3fd30c4d8b411b118559ade788c3eabcdbe51 --- crates/didbot-agentd/src/node.rs | 80 ++++++++++++++++++++++++++++--- crates/didbot-agentd/src/serve.rs | 18 ++++++- 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/crates/didbot-agentd/src/node.rs b/crates/didbot-agentd/src/node.rs index bd2a1a02..f24f3287 100644 --- a/crates/didbot-agentd/src/node.rs +++ b/crates/didbot-agentd/src/node.rs @@ -92,6 +92,25 @@ pub enum NodeError { /// nothing for a claim to name. #[error("this host has no identity yet; a `become` over the daemon's socket reserves one")] NoIdentity, + /// The identity file survived and the key it was reserved under did not. + /// + /// The key is what a claim is signed with and what the server holds the + /// public half of, so an identity beside a key that is not the one it + /// was reserved under can never attest again: every claim verifies + /// against the wrong key and comes back `AttestationRefused`. 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 attest under: the key it was reserved \ + with is gone. Remove {did}'s host record at the operator, then delete {dir} and \ + reserve again" + )] + KeyGone { + /// The state directory. + dir: PathBuf, + /// The identity that can no longer attest. + did: String, + }, /// The claim's fields could not be put into the signing format. #[error("could not sign a claim: {0}")] Unsignable(#[from] AttestError), @@ -159,10 +178,31 @@ impl Node { } } + // 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 = read_shut(&host_path)? + .map(|text| { + serde_json::from_str(&text).map_err(|_| NodeError::Corrupt { + path: host_path.clone(), + }) + }) + .transpose()?; + 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 => { + // A key is minted only when there is none, and an identity + // was reserved under a key. Both at once means the key that + // identity belongs to is gone, and a fresh one can never + // attest under it. See [`NodeError::KeyGone`]. + if let Some(held) = &host { + return Err(NodeError::KeyGone { + dir, + did: held.did.clone(), + }); + } let key = SigningKey::generate(); let mut text = hex::encode(key.to_bytes()); text.push('\n'); @@ -176,13 +216,6 @@ 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, @@ -385,6 +418,39 @@ mod tests { } } + /// An identity that survives the key it was reserved under can never + /// attest again: the server holds the public half of a key this daemon + /// no longer has, so every claim comes back `AttestationRefused` 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("reserved"); + } + 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("reserve 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"); diff --git a/crates/didbot-agentd/src/serve.rs b/crates/didbot-agentd/src/serve.rs index a2965864..770a9c55 100644 --- a/crates/didbot-agentd/src/serve.rs +++ b/crates/didbot-agentd/src/serve.rs @@ -28,7 +28,7 @@ use crate::protocol::{ Answer, Approve, Become, DecisionForAgent, Decline, HostAnswer, Message, Observed, Report, MAX_LINE, VERSION, }; -use crate::registrar::{Registrar, Wanted}; +use crate::registrar::{Registrar, Trouble, Wanted}; use crate::socket::Listener; /// The daemon's state: what it has seen, where names come from, and the key @@ -451,6 +451,22 @@ impl Daemon { Answer::identity(identity.did) } Err(err) => { + // A server that does not know this host's node is a server + // that never will: a reservation reaped before its vouch + // takes its name back, and this daemon goes on signing under + // a DID nothing answers for. Say what that is and what ends + // it, because the refusal itself reads as one bad claim. + if let Trouble::Refused(refusal) = &err { + if refusal.contains("AttestationRefused") { + if let Some(held) = self.node.host() { + warn!( + did = %held.did, + dir = %self.node.dir().display(), + "this host's identity is not one the server will attest: if it keeps saying so, the reservation was reaped before it was vouched for. Remove its host record at the operator, then delete the daemon's state directory and reserve again" + ); + } + } + } warn!(error = %err, "could not provision an identity"); Answer::trouble(err.to_string()) } -- 2.51.2