diff --git a/Cargo.lock b/Cargo.lock index 09c159b..3ca2ee1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1733,6 +1733,7 @@ dependencies = [ "jsonschema", "serde", "serde_json", + "tempfile", "tracing", ] @@ -4137,12 +4138,15 @@ name = "sim" version = "0.32.0" dependencies = [ "anyhow", + "ed25519-dalek 2.2.0", "ensemble", + "hex", "hum-identity", "hum-mcp", "hum-paths", "humd", "parking_lot", + "rand 0.8.6", "serde_json", "thrum-core", "tokio", diff --git a/ensemble/src/handshake.rs b/ensemble/src/handshake.rs index f6fe3c8..d57256c 100644 --- a/ensemble/src/handshake.rs +++ b/ensemble/src/handshake.rs @@ -7,7 +7,7 @@ use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; -use super::{HANDSHAKE_DOMAIN, HANDSHAKE_SKEW_MS, Tone, headroom, now_ms}; +use super::{CAPS_HANDSHAKE_DOMAIN, HANDSHAKE_DOMAIN, HANDSHAKE_SKEW_MS, Tone, headroom, now_ms}; pub use hum_identity::{Hid, HidParseError, HidPrefix}; @@ -39,6 +39,25 @@ fn handshake_message(humd_id: &Hid, signed_at_ms: i64) -> Vec { format!("{}:{}:{}", HANDSHAKE_DOMAIN, humd_id.to_hex(), signed_at_ms).into_bytes() } +/// What the claims signature covers: identity, timestamp, and the claims +/// themselves. Fixed field order so verification is deterministic. +fn caps_message(humd_id: &Hid, caps: &PeerCapabilities, signed_at_ms: i64) -> Vec { + let nests = caps.nests.join(","); + let hosts = caps.hosts.join(","); + let free_slots = caps.free_slots.unwrap_or(0); + format!( + "{}:{}:{}:{}:{}:{}:{}", + CAPS_HANDSHAKE_DOMAIN, + humd_id.to_hex(), + signed_at_ms, + nests, + hosts, + caps.can_relay, + free_slots + ) + .into_bytes() +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HumdAddr { pub id: Hid, @@ -72,6 +91,11 @@ pub struct PeerCapabilities { pub free_slots: Option, #[serde(default)] pub headroom: headroom::CellHeadroom, + /// Whether the claims in this hello were covered by a signature the + /// receiver verified. False for unsigned hellos and for hellos from a + /// peer too old to send one. + #[serde(skip)] + pub caps_signed: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -105,6 +129,8 @@ pub fn hello_tone(me: &Hid, key: &HumdKey, caps: &PeerCapabilities) -> Tone { let signed_at = now_ms(); let msg = handshake_message(me, signed_at); let sig: Signature = key.0.sign(&msg); + let caps_msg = caps_message(me, caps, signed_at); + let caps_sig: Signature = key.0.sign(&caps_msg); serde_json::json!({ "chi": "hello", "rid": hum_identity::HumId::mint().to_string(), @@ -118,6 +144,7 @@ pub fn hello_tone(me: &Hid, key: &HumdKey, caps: &PeerCapabilities) -> Tone { "free_slots": caps.free_slots, "signed_at": signed_at, "signature": hex::encode(sig.to_bytes()), + "capsSignature": hex::encode(caps_sig.to_bytes()), }) } @@ -203,7 +230,7 @@ pub fn parse_hello_caps(tone: &Tone) -> Option<(Hid, PeerCapabilities)> { return None; } - let nests = tone + let nests: Vec = tone .get("nests") .and_then(|v| v.as_array()) .map(|a| { @@ -212,7 +239,7 @@ pub fn parse_hello_caps(tone: &Tone) -> Option<(Hid, PeerCapabilities)> { .collect() }) .unwrap_or_default(); - let hosts = tone + let hosts: Vec = tone .get("hosts") .and_then(|v| v.as_array()) .map(|a| { @@ -236,6 +263,49 @@ pub fn parse_hello_caps(tone: &Tone) -> Option<(Hid, PeerCapabilities)> { .get("headroom") .and_then(|v| serde_json::from_value::(v.clone()).ok()) .unwrap_or_default(); + let mut caps_signed = false; + match tone.get("capsSignature").and_then(|v| v.as_str()) { + None => { + tracing::warn!( + target: "ensemble", + humd_id = %claimed_id.short(), + "hello.claims.unsigned — nests/hosts/can_relay/free_slots are self-declared" + ); + } + Some(caps_hex) => { + let Ok(caps_bytes) = hex::decode(caps_hex) else { + tracing::warn!(target: "ensemble", "hello.rejected: capsSignature unparseable"); + return None; + }; + if caps_bytes.len() != 64 { + tracing::warn!(target: "ensemble", "hello.rejected: capsSignature wrong length"); + return None; + } + let mut caps_arr = [0u8; 64]; + caps_arr.copy_from_slice(&caps_bytes); + let caps_signature = Signature::from_bytes(&caps_arr); + let claims = PeerCapabilities { + proto_version: proto_version.clone(), + nests: nests.clone(), + hosts: hosts.clone(), + can_relay, + free_slots, + headroom: headroom.clone(), + caps_signed: false, + }; + let caps_msg = caps_message(&claimed_id, &claims, signed_at); + if verifying_key.verify(&caps_msg, &caps_signature).is_err() { + tracing::warn!( + target: "ensemble", + humd_id = %claimed_id.short(), + "hello.rejected: capsSignature verification failed — claims are not what this peer signed" + ); + return None; + } + caps_signed = true; + } + } + Some(( claimed_id, PeerCapabilities { @@ -245,6 +315,7 @@ pub fn parse_hello_caps(tone: &Tone) -> Option<(Hid, PeerCapabilities)> { can_relay, free_slots, headroom, + caps_signed, }, )) } @@ -270,7 +341,7 @@ pub fn parse_hello(tone: &Tone) -> HelloParse { let Ok(claimed_id) = Hid::from_hex(humd_hex) else { return HelloParse::Invalid; }; - let nests = tone + let nests: Vec = tone .get("nests") .and_then(|v| v.as_array()) .map(|a| { @@ -279,7 +350,7 @@ pub fn parse_hello(tone: &Tone) -> HelloParse { .collect() }) .unwrap_or_default(); - let hosts = tone + let hosts: Vec = tone .get("hosts") .and_then(|v| v.as_array()) .map(|a| { @@ -312,7 +383,344 @@ pub fn parse_hello(tone: &Tone) -> HelloParse { can_relay, free_slots, headroom, + caps_signed: false, }, ) } } + +/// Outcome of checking a socket hello's identity claim. A bee declares +/// `hid` as a bare string; this decides whether humd can trust it. +/// +/// - `Verified` — the bee sent `pubkey`/`signedAt`/`signature` and they +/// check out. The declared `hid` is provably sha256 of that pubkey. +/// - `Unsigned` — no `pubkey` field. Admitted as before, but downstream +/// code now knows the hid is self-declared rather than proven. +/// - `Invalid` — a signature was sent and is wrong. Refuse the bee; it +/// is either broken or lying, and neither is worth a second hello. +pub enum SocketIdentity { + Verified(Hid), + Unsigned(Option), + Invalid(&'static str), +} + +/// Verify a socket hello's identity claim against the peer path's rules. +/// +/// Wire naming stays socket-flavoured — `hid`, `pubkey`, `signedAt`, +/// `signature` — because this is a different transport from the iroh +/// peer hello and renaming would churn every hive for no gain. +pub fn parse_socket_identity(tone: &Tone) -> SocketIdentity { + let Some(pubkey_hex) = tone.get("pubkey").and_then(|v| v.as_str()) else { + let hid = tone + .get("hid") + .and_then(|v| v.as_str()) + .and_then(|s| Hid::from_hex(s).ok()); + return SocketIdentity::Unsigned(hid); + }; + + let Some(hid_hex) = tone.get("hid").and_then(|v| v.as_str()) else { + return SocketIdentity::Invalid("pubkey sent without hid"); + }; + let Ok(claimed) = Hid::from_hex(hid_hex) else { + return SocketIdentity::Invalid("hid unparseable"); + }; + if !matches!(claimed.prefix, HidPrefix::Wbee | HidPrefix::Fbee) { + return SocketIdentity::Invalid("hid prefix is not a bee role"); + } + + let Ok(pubkey_bytes) = hex::decode(pubkey_hex) else { + return SocketIdentity::Invalid("pubkey unparseable"); + }; + if pubkey_bytes.len() != 32 { + return SocketIdentity::Invalid("pubkey wrong length"); + } + let mut pubkey = [0u8; 32]; + pubkey.copy_from_slice(&pubkey_bytes); + if Hid::from_pubkey(claimed.prefix, &pubkey) != claimed { + tracing::warn!( + target: "ensemble", + hid = %claimed.short(), + "socket.hello.rejected: hid does not match sha256(pubkey)" + ); + return SocketIdentity::Invalid("hid does not match pubkey"); + } + + let Some(signed_at) = tone.get("signedAt").and_then(|v| v.as_i64()) else { + return SocketIdentity::Invalid("signedAt missing"); + }; + let drift = (now_ms() - signed_at).abs(); + if drift > HANDSHAKE_SKEW_MS { + tracing::warn!( + target: "ensemble", + hid = %claimed.short(), + drift_ms = drift, + "socket.hello.rejected: signedAt outside skew window" + ); + return SocketIdentity::Invalid("signedAt outside skew window"); + } + + let Some(sig_hex) = tone.get("signature").and_then(|v| v.as_str()) else { + return SocketIdentity::Invalid("signature missing"); + }; + let Ok(sig_bytes) = hex::decode(sig_hex) else { + return SocketIdentity::Invalid("signature unparseable"); + }; + if sig_bytes.len() != 64 { + return SocketIdentity::Invalid("signature wrong length"); + } + let mut sig_arr = [0u8; 64]; + sig_arr.copy_from_slice(&sig_bytes); + let signature = Signature::from_bytes(&sig_arr); + + let Ok(verifying_key) = VerifyingKey::from_bytes(&pubkey) else { + return SocketIdentity::Invalid("pubkey not a valid curve point"); + }; + let msg = hum_identity::socket_hello_message(&claimed, signed_at); + if verifying_key.verify(&msg, &signature).is_err() { + tracing::warn!( + target: "ensemble", + hid = %claimed.short(), + "socket.hello.rejected: signature verification failed" + ); + return SocketIdentity::Invalid("signature verification failed"); + } + + SocketIdentity::Verified(claimed) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn signed_hello(caps: &PeerCapabilities) -> (Hid, HumdKey, Tone) { + let key = HumdKey::generate(); + let id = key.hid(); + let tone = hello_tone(&id, &key, caps); + (id, key, tone) + } + + fn caps() -> PeerCapabilities { + PeerCapabilities { + proto_version: "0.7.0".into(), + nests: vec!["claude-cli".into()], + hosts: vec!["alice".into()], + can_relay: false, + free_slots: Some(4), + ..Default::default() + } + } + + #[test] + fn a_signed_hello_carries_a_verified_claims_signature() { + let (id, _key, tone) = signed_hello(&caps()); + let parsed = parse_hello_caps(&tone).expect("hello parses"); + assert_eq!(parsed.0, id); + assert!( + parsed.1.caps_signed, + "a hello from a current peer must be marked as having signed claims" + ); + } + + #[test] + fn tampering_with_a_claim_after_signing_fails_verification() { + let (_id, _key, mut tone) = signed_hello(&caps()); + tone["can_relay"] = serde_json::json!(true); + assert!( + parse_hello_caps(&tone).is_none(), + "a claim the peer did not sign must not be admitted" + ); + } + + #[test] + fn widening_the_hosts_list_after_signing_fails_verification() { + let (_id, _key, mut tone) = signed_hello(&caps()); + tone["hosts"] = serde_json::json!(["alice", "mallory"]); + assert!(parse_hello_caps(&tone).is_none()); + } + + #[test] + fn a_forged_caps_signature_is_rejected() { + let (_id, _key, mut tone) = signed_hello(&caps()); + let other = HumdKey::generate(); + let id = other.hid(); + let claims = PeerCapabilities { + proto_version: "0.7.0".into(), + nests: vec!["claude-cli".into()], + hosts: vec!["alice".into()], + can_relay: true, + free_slots: Some(4), + ..Default::default() + }; + let sig = other.0.sign(&caps_message(&id, &claims, now_ms())); + tone["capsSignature"] = serde_json::json!(hex::encode(sig.to_bytes())); + assert!(parse_hello_caps(&tone).is_none()); + } + + #[test] + fn a_hello_without_a_claims_signature_still_admits_as_unsigned() { + let (_id, _key, mut tone) = signed_hello(&caps()); + tone.as_object_mut() + .expect("tone object") + .remove("capsSignature"); + let parsed = parse_hello_caps(&tone).expect("still parses"); + assert!( + !parsed.1.caps_signed, + "an unsigned hello must not be marked as signed" + ); + } + + #[test] + fn a_replayed_claims_signature_from_another_peer_is_rejected() { + let (_id_a, _key_a, tone_a) = signed_hello(&caps()); + let (id_b, _key_b, mut tone_b) = signed_hello(&caps()); + tone_b["capsSignature"] = tone_a["capsSignature"].clone(); + assert!(parse_hello_caps(&tone_b).is_none(), "B={}", id_b.short()); + } + + fn signed_socket_hello() -> (Hid, SigningKey, Tone) { + let signing = SigningKey::generate(&mut rand::thread_rng()); + let hid = Hid::from_pubkey(HidPrefix::Wbee, &signing.verifying_key().to_bytes()); + let signed_at = now_ms(); + let msg = hum_identity::socket_hello_message(&hid, signed_at); + let sig = signing.sign(&msg); + let tone = serde_json::json!({ + "chi": "hello", + "protoVersion": "0.7.0", + "hive": "claude-cli", + "hid": hid.to_hex(), + "pubkey": hex::encode(signing.verifying_key().to_bytes()), + "signedAt": signed_at, + "signature": hex::encode(sig.to_bytes()), + }); + (hid, signing, tone) + } + + #[test] + fn a_signed_socket_hello_proves_the_declared_hid() { + let (hid, _signing, tone) = signed_socket_hello(); + match parse_socket_identity(&tone) { + SocketIdentity::Verified(id) => assert_eq!(id, hid), + other => panic!("expected Verified, got {:?}", socket_dbg(&other)), + } + } + + #[test] + fn a_socket_hello_without_a_signature_is_admitted_as_unsigned() { + let (hid, _signing, mut tone) = signed_socket_hello(); + tone.as_object_mut().expect("tone object").remove("pubkey"); + tone.as_object_mut() + .expect("tone object") + .remove("signedAt"); + tone.as_object_mut() + .expect("tone object") + .remove("signature"); + match parse_socket_identity(&tone) { + SocketIdentity::Unsigned(Some(id)) => assert_eq!(id, hid), + other => panic!("expected Unsigned(Some), got {:?}", socket_dbg(&other)), + } + } + + #[test] + fn claiming_another_bees_hid_fails_verification() { + let (_hid, _signing, mut tone) = signed_socket_hello(); + let impostor = Hid::random(HidPrefix::Wbee); + tone["hid"] = serde_json::json!(impostor.to_hex()); + assert!( + matches!(parse_socket_identity(&tone), SocketIdentity::Invalid(_)), + "a hid the signer did not sign must be rejected" + ); + } + + #[test] + fn a_tampered_hid_with_a_rehashed_pubkey_is_rejected() { + let (_hid, _signing, mut tone) = signed_socket_hello(); + let mallory = SigningKey::generate(&mut rand::thread_rng()); + let mallory_hid = Hid::from_pubkey(HidPrefix::Wbee, &mallory.verifying_key().to_bytes()); + tone["hid"] = serde_json::json!(mallory_hid.to_hex()); + tone["pubkey"] = serde_json::json!(hex::encode(mallory.verifying_key().to_bytes())); + assert!( + matches!(parse_socket_identity(&tone), SocketIdentity::Invalid(_)), + "swapping in a consistent keypair must not bypass the signature" + ); + } + + #[test] + fn a_stale_timestamp_is_rejected() { + let (_hid, _signing, mut tone) = signed_socket_hello(); + tone["signedAt"] = serde_json::json!(now_ms() - HANDSHAKE_SKEW_MS - 1_000); + assert!( + matches!(parse_socket_identity(&tone), SocketIdentity::Invalid(_)), + "a hello outside the skew window is a replay and must be rejected" + ); + } + + #[test] + fn a_humd_key_claiming_to_be_a_bee_is_rejected() { + let signing = SigningKey::generate(&mut rand::thread_rng()); + let hid = Hid::from_pubkey(HidPrefix::Humd, &signing.verifying_key().to_bytes()); + let signed_at = now_ms(); + let msg = hum_identity::socket_hello_message(&hid, signed_at); + let sig = signing.sign(&msg); + let tone = serde_json::json!({ + "hid": hid.to_hex(), + "pubkey": hex::encode(signing.verifying_key().to_bytes()), + "signedAt": signed_at, + "signature": hex::encode(sig.to_bytes()), + }); + assert!( + matches!(parse_socket_identity(&tone), SocketIdentity::Invalid(_)), + "a daemon key must not register as a bee" + ); + } + + #[test] + fn a_signature_over_a_different_domain_is_rejected() { + let signing = SigningKey::generate(&mut rand::thread_rng()); + let hid = Hid::from_pubkey(HidPrefix::Wbee, &signing.verifying_key().to_bytes()); + let signed_at = now_ms(); + let peer_msg = format!("{}:{}:{}", HANDSHAKE_DOMAIN, hid.to_hex(), signed_at); + let sig = signing.sign(peer_msg.as_bytes()); + let tone = serde_json::json!({ + "hid": hid.to_hex(), + "pubkey": hex::encode(signing.verifying_key().to_bytes()), + "signedAt": signed_at, + "signature": hex::encode(sig.to_bytes()), + }); + assert!( + matches!(parse_socket_identity(&tone), SocketIdentity::Invalid(_)), + "a signature under the peer domain must not verify on the socket path" + ); + } + + #[test] + fn a_signature_over_someone_elses_hid_is_rejected() { + let mallory = SigningKey::generate(&mut rand::thread_rng()); + let victim = SigningKey::generate(&mut rand::thread_rng()); + let victim_hid = Hid::from_pubkey(HidPrefix::Wbee, &victim.verifying_key().to_bytes()); + + let signed_at = now_ms(); + let msg = hum_identity::socket_hello_message(&victim_hid, signed_at); + let sig = mallory.sign(&msg); + + let tone = serde_json::json!({ + "hid": victim_hid.to_hex(), + "pubkey": hex::encode(mallory.verifying_key().to_bytes()), + "signedAt": signed_at, + "signature": hex::encode(sig.to_bytes()), + }); + assert!( + matches!(parse_socket_identity(&tone), SocketIdentity::Invalid(_)), + "a valid signature over a hid the signing key does not own must be rejected" + ); + } + + /// `SocketIdentity` has no Debug; this lets a failing test say which + /// variant it got without derive-noise on the public type. + fn socket_dbg(id: &SocketIdentity) -> &'static str { + match id { + SocketIdentity::Verified(_) => "Verified", + SocketIdentity::Unsigned(_) => "Unsigned", + SocketIdentity::Invalid(_) => "Invalid", + } + } +} diff --git a/ensemble/src/hives.rs b/ensemble/src/hives.rs index e3d8ece..951d9ce 100644 --- a/ensemble/src/hives.rs +++ b/ensemble/src/hives.rs @@ -85,6 +85,14 @@ pub struct HiveManifest { /// when a bee drops + comes back. #[serde(default, skip_serializing_if = "Option::is_none")] pub hid: Option, + /// Whether the `hid` above was proven against a signed hello rather + /// than self-declared. A bee that signs its socket hello with the + /// key its hid derives from gets `true`; one that sends no signature + /// gets `false` and is admitted as it always was. False is not a + /// defect — it is an older or simpler bee — but callers routing on + /// identity should know which they have. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub hid_verified: bool, /// Model ids this entry can serve. Meaningful when /// `bee.contains("worker")`; foragers may carry an empty list. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -171,6 +179,7 @@ impl HiveManifest { provides: Vec::new(), nestler_id: None, hid: None, + hid_verified: false, } } diff --git a/ensemble/src/lib.rs b/ensemble/src/lib.rs index 56854b6..e13fecd 100644 --- a/ensemble/src/lib.rs +++ b/ensemble/src/lib.rs @@ -12,7 +12,8 @@ use tokio::task::JoinSet; pub mod handshake; pub use handshake::{ EnsembleHello, HelloParse, Hid, HidParseError, HidPrefix, HumdAddr, HumdKey, PeerCapabilities, - PeerConnection, Transport, hello_tone, hello_tone_unsigned, parse_hello, parse_hello_caps, + PeerConnection, SocketIdentity, Transport, hello_tone, hello_tone_unsigned, parse_hello, + parse_hello_caps, parse_socket_identity, }; pub mod link; @@ -69,6 +70,11 @@ pub mod headroom; pub mod onchain; const HANDSHAKE_DOMAIN: &str = "hum-ensemble-handshake-v1"; +/// Separate domain for the claims signature. Kept apart from the identity +/// signature so an old peer verifying `signature` sees an unchanged message +/// and a new peer can tell the two apart. Adding fields here is a minor +/// bump; changing `HANDSHAKE_DOMAIN` is a hard break. +const CAPS_HANDSHAKE_DOMAIN: &str = "hum-ensemble-caps-v1"; const HANDSHAKE_SKEW_MS: i64 = 60_000; @@ -1354,16 +1360,14 @@ mod tests { nests: vec!["claude-cli".into()], hosts: vec!["alice".into()], can_relay: true, - free_slots: None, - headroom: headroom::CellHeadroom::default(), + ..Default::default() }; let b_caps = PeerCapabilities { proto_version: "0.2.0".into(), nests: vec!["claude-repl".into()], hosts: vec!["bob".into()], can_relay: false, - free_slots: None, - headroom: headroom::CellHeadroom::default(), + ..Default::default() }; let (a_side, b_side) = InMemoryEndpoint::pair(a_id, b_caps.clone(), b_id, a_caps.clone()); diff --git a/hum-identity/src/lib.rs b/hum-identity/src/lib.rs index f0e0c0c..da1cf3a 100644 --- a/hum-identity/src/lib.rs +++ b/hum-identity/src/lib.rs @@ -20,8 +20,8 @@ use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; -use anyhow::{anyhow, Context, Result}; -use ed25519_dalek::SigningKey; +use anyhow::{Context, Result, anyhow}; +use ed25519_dalek::{Signer, SigningKey}; mod ids; pub use ids::*; use rand::RngCore; @@ -40,6 +40,34 @@ impl BeeKey { pub fn pubkey_bytes(&self) -> [u8; 32] { self.signing.verifying_key().to_bytes() } + + /// Sign a socket hello so the local humd can bind the declared `hid` + /// to a key instead of trusting the string. Returns + /// `(pubkey_hex, signed_at_ms, signature_hex)` for the three + /// `pubkey`/`signedAt`/`signature` fields. + /// + /// Covers the hid and the timestamp only — nothing else on the + /// socket hello. Timestamp binds freshness, so a recorded hello + /// cannot be replayed after the skew window. + pub fn sign_socket_hello(&self, signed_at_ms: i64) -> (String, i64, String) { + let msg = socket_hello_message(&self.hid, signed_at_ms); + let sig = self.signing.sign(&msg); + ( + hex::encode(self.pubkey_bytes()), + signed_at_ms, + hex::encode(sig.to_bytes()), + ) + } +} + +/// Domain-separate socket-hello identity from the ensemble peer +/// handshake. Different wire (a bee talking to its own humd over a unix +/// socket, not two humds over iroh) and a different signing key, so +/// sharing a domain would only let one be confused for the other. +pub const BEE_SOCKET_DOMAIN: &str = "hum-bee-socket-v1"; + +pub fn socket_hello_message(hid: &Hid, signed_at_ms: i64) -> Vec { + format!("{}:{}:{}", BEE_SOCKET_DOMAIN, hid.to_hex(), signed_at_ms).into_bytes() } /// Path to a bee kind's identity seed: `$XDG_STATE_HOME/hum/bees/.key`. @@ -121,12 +149,9 @@ fn persist(path: &Path, seed: &[u8; 32]) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use ed25519_dalek::Verifier; use std::sync::Mutex; use tempfile::TempDir; - - // Serialize tests because they share the XDG_STATE_HOME env var. - // Without this lock, parallel cargo-test threads race the env and - // the second test sees the first's tempdir (or an empty value). static ENV_LOCK: Mutex<()> = Mutex::new(()); #[test] @@ -159,4 +184,77 @@ mod tests { assert!(path.ends_with("hum/bees/foo.key")); unsafe { std::env::remove_var("XDG_STATE_HOME") }; } + + #[test] + fn a_signature_binds_the_hid_it_was_signed_for() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = TempDir::new().unwrap(); + unsafe { std::env::set_var("XDG_STATE_HOME", tmp.path()) }; + + let key = load_or_mint_bee_key("sig-bind", HidPrefix::Wbee).expect("mint"); + let (pubkey_hex, signed_at, sig_hex) = key.sign_socket_hello(1_700_000_000_000); + assert_eq!(pubkey_hex, hex::encode(key.pubkey_bytes())); + + let msg = socket_hello_message(&key.hid, signed_at); + let sig_bytes = hex::decode(&sig_hex).expect("sig hex"); + assert_eq!(sig_bytes.len(), 64, "ed25519 signature is 64 bytes"); + + let mut sig_arr = [0u8; 64]; + sig_arr.copy_from_slice(&sig_bytes); + let verifying = key.signing.verifying_key(); + verifying + .verify(&msg, &ed25519_dalek::Signature::from_bytes(&sig_arr)) + .expect("signature must verify against the signing key"); + + unsafe { std::env::remove_var("XDG_STATE_HOME") }; + } + + #[test] + fn a_signature_does_not_verify_for_a_different_hid() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = TempDir::new().unwrap(); + unsafe { std::env::set_var("XDG_STATE_HOME", tmp.path()) }; + + let key = load_or_mint_bee_key("sig-rebind", HidPrefix::Wbee).expect("mint"); + let other = Hid::random(HidPrefix::Wbee); + + let (_pubkey_hex, signed_at, sig_hex) = key.sign_socket_hello(1_700_000_000_000); + let msg = socket_hello_message(&other, signed_at); + let sig_bytes = hex::decode(&sig_hex).expect("sig hex"); + let mut sig_arr = [0u8; 64]; + sig_arr.copy_from_slice(&sig_bytes); + let verifying = key.signing.verifying_key(); + assert!( + verifying + .verify(&msg, &ed25519_dalek::Signature::from_bytes(&sig_arr)) + .is_err(), + "signature bound to one hid must not verify for another" + ); + + unsafe { std::env::remove_var("XDG_STATE_HOME") }; + } + + #[test] + fn the_timestamp_is_part_of_what_is_signed() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = TempDir::new().unwrap(); + unsafe { std::env::set_var("XDG_STATE_HOME", tmp.path()) }; + + let key = load_or_mint_bee_key("sig-replay", HidPrefix::Wbee).expect("mint"); + let (_pubkey_hex, signed_at, sig_hex) = key.sign_socket_hello(1_700_000_000_000); + let sig_bytes = hex::decode(&sig_hex).expect("sig hex"); + let mut sig_arr = [0u8; 64]; + sig_arr.copy_from_slice(&sig_bytes); + let signature = ed25519_dalek::Signature::from_bytes(&sig_arr); + let verifying = key.signing.verifying_key(); + for other in [1_700_000_000_001, 1_600_000_000_000, 0] { + let msg = socket_hello_message(&key.hid, other); + assert!( + verifying.verify(&msg, &signature).is_err(), + "a signature at {signed_at} must not verify at {other}" + ); + } + + unsafe { std::env::remove_var("XDG_STATE_HOME") }; + } } diff --git a/hum-paths/Cargo.toml b/hum-paths/Cargo.toml index 392c5df..4fa7159 100644 --- a/hum-paths/Cargo.toml +++ b/hum-paths/Cargo.toml @@ -10,3 +10,6 @@ serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } jsonschema = "0.46.5" + +[dev-dependencies] +tempfile = "3" diff --git a/hum-paths/src/lib.rs b/hum-paths/src/lib.rs index f508b16..b259078 100644 --- a/hum-paths/src/lib.rs +++ b/hum-paths/src/lib.rs @@ -16,17 +16,19 @@ use serde::{Deserialize, Serialize}; /// Panics if `HOME` is unset, which is always a configuration error. pub fn init() { let home = home(); - xdg_default("XDG_STATE_HOME", home.join(".local/state")); + xdg_default("XDG_STATE_HOME", home.join(".local/state")); xdg_default("XDG_CONFIG_HOME", home.join(".config")); - xdg_default("XDG_DATA_HOME", home.join(".local/share")); - xdg_default("XDG_CACHE_HOME", home.join(".cache")); + xdg_default("XDG_DATA_HOME", home.join(".local/share")); + xdg_default("XDG_CACHE_HOME", home.join(".cache")); xdg_default("XDG_RUNTIME_DIR", home.join(".local/state/run")); } fn xdg_default(var: &str, default: PathBuf) { if std::env::var_os(var).is_none() { // Safety: single-threaded startup; no other threads reading env yet. - unsafe { std::env::set_var(var, default); } + unsafe { + std::env::set_var(var, default); + } } } @@ -69,6 +71,7 @@ pub const PENNY_BASENAME: &str = "penny.json"; pub const HUMD_KEY_BASENAME: &str = "humd.key"; pub const BEES_SNAPSHOT_BASENAME: &str = "bees.json"; pub const RUNTIME_INFO_BASENAME: &str = "runtime.json"; +pub const REVOKED_BEES_BASENAME: &str = "revoked.json"; pub const HUM_JSON_BASENAME: &str = "hum.json"; pub const PEERS_JSON_BASENAME: &str = "peers.json"; pub const ORCHFILE_BASENAME: &str = "Orchfile"; @@ -93,22 +96,34 @@ pub fn claude_session_dir(cwd_hash: &str) -> PathBuf { /// [`thrum_sock_resolved`] instead so they honor whatever path humd /// actually published in `runtime.json`. pub fn thrum_sock() -> PathBuf { - if let Some(p) = std::env::var_os("HUM_THRUM_SOCK") { return PathBuf::from(p); } - if let Some(p) = std::env::var_os("HUM_SOCKET") { return PathBuf::from(p); } + if let Some(p) = std::env::var_os("HUM_THRUM_SOCK") { + return PathBuf::from(p); + } + if let Some(p) = std::env::var_os("HUM_SOCKET") { + return PathBuf::from(p); + } state_dir().join(THRUM_SOCK_BASENAME) } /// What clients (bees, CLI) should connect to. Honors humd's /// rendezvous file first, then env overrides, then the default. pub fn thrum_sock_resolved() -> PathBuf { - if let Some(p) = std::env::var_os("HUM_THRUM_SOCK") { return PathBuf::from(p); } - if let Some(p) = std::env::var_os("HUM_SOCKET") { return PathBuf::from(p); } - if let Some(rt) = RuntimeInfo::read() { return rt.socket; } + if let Some(p) = std::env::var_os("HUM_THRUM_SOCK") { + return PathBuf::from(p); + } + if let Some(p) = std::env::var_os("HUM_SOCKET") { + return PathBuf::from(p); + } + if let Some(rt) = RuntimeInfo::read() { + return rt.socket; + } state_dir().join(THRUM_SOCK_BASENAME) } /// humd HTTP control socket. -pub fn http_sock() -> PathBuf { runtime_dir().join(HTTP_SOCK_BASENAME) } +pub fn http_sock() -> PathBuf { + runtime_dir().join(HTTP_SOCK_BASENAME) +} /// Kademlia routing table, restored on boot. pub fn routing_json() -> PathBuf { @@ -116,13 +131,19 @@ pub fn routing_json() -> PathBuf { } /// Penny lifetime counters. -pub fn penny() -> PathBuf { runtime_dir().join(PENNY_BASENAME) } +pub fn penny() -> PathBuf { + runtime_dir().join(PENNY_BASENAME) +} /// humd ed25519 identity seed. -pub fn humd_key() -> PathBuf { state_dir().join(HUMD_KEY_BASENAME) } +pub fn humd_key() -> PathBuf { + state_dir().join(HUMD_KEY_BASENAME) +} /// Directory holding per-bee ed25519 identity seeds. -pub fn bees_dir() -> PathBuf { state_dir().join("bees") } +pub fn bees_dir() -> PathBuf { + state_dir().join("bees") +} /// Per-bee ed25519 identity seed; one file per hive kind. pub fn bee_key(kind: &str) -> PathBuf { @@ -130,10 +151,103 @@ pub fn bee_key(kind: &str) -> PathBuf { } /// Live bee manifest snapshot (written by daemon on every register/disconnect). -pub fn bees_snapshot() -> PathBuf { state_dir().join(BEES_SNAPSHOT_BASENAME) } +pub fn bees_snapshot() -> PathBuf { + state_dir().join(BEES_SNAPSHOT_BASENAME) +} /// Rendezvous file: running daemon publishes its socket path, pid, and version here. -pub fn runtime_info() -> PathBuf { state_dir().join(RUNTIME_INFO_BASENAME) } +pub fn runtime_info() -> PathBuf { + state_dir().join(RUNTIME_INFO_BASENAME) +} + +/// Bee identities the operator has withdrawn. Checked at hello time, so a +/// revoked bee is refused the socket rather than merely hidden from +/// discovery. Lives here rather than in humd so `humctl` can edit the list +/// without the daemon's dependency tree. +pub fn revoked_bees() -> PathBuf { + state_dir().join(REVOKED_BEES_BASENAME) +} + +/// Withdrawn bee identities, as stored on disk. +/// +/// Shared between humd (which refuses a revoked hid at hello) and humctl +/// (which edits the list). humd reads it once at boot, so a change here +/// takes effect on the next start — see [`Revocations`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Revocations { + #[serde(default)] + pub version: u32, + #[serde(default)] + pub revoked: Vec, +} + +impl Revocations { + /// Read the list at `path`. + /// + /// An absent or unparseable file yields an empty list rather than an + /// error. Failing closed would lock every bee out of the socket, and a + /// daemon that admits nobody is a worse failure than one that admits a + /// revoked bee — the operator can act on the parse warning. + pub fn read_from(path: &std::path::Path) -> Self { + match std::fs::read(path) { + Ok(bytes) if bytes.is_empty() => Self::default(), + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(list) => list, + Err(e) => { + tracing::warn!( + target: "revoke", + path = %path.display(), + err = %e, + "revoke.load.parse_failed — treating as empty" + ); + Self::default() + } + }, + Err(_) => Self::default(), + } + } + + pub fn read() -> Self { + Self::read_from(&revoked_bees()) + } + + pub fn write_to(&self, path: &std::path::Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("json.tmp"); + let mut body = self.clone(); + body.version = 1; + let body = serde_json::to_string_pretty(&body) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + std::fs::write(&tmp, body)?; + std::fs::rename(tmp, path) + } + + pub fn write(&self) -> std::io::Result<()> { + self.write_to(&revoked_bees()) + } + + pub fn contains(&self, hid_hex: &str) -> bool { + self.revoked.iter().any(|h| h == hid_hex) + } + + /// Add `hid`, returning false if it was already listed. + pub fn revoke(&mut self, hid: &str) -> bool { + if self.contains(hid) { + return false; + } + self.revoked.push(hid.to_string()); + true + } + + /// Remove `hid`, returning false if it was not listed. + pub fn unrevoke(&mut self, hid: &str) -> bool { + let before = self.revoked.len(); + self.revoked.retain(|h| h != hid); + self.revoked.len() != before + } +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RuntimeInfo { @@ -170,16 +284,24 @@ impl RuntimeInfo { } /// `hum.json` (daemon policy). -pub fn hum_json() -> PathBuf { config_dir().join(HUM_JSON_BASENAME) } +pub fn hum_json() -> PathBuf { + config_dir().join(HUM_JSON_BASENAME) +} /// `peers.json` (ensemble peer list). -pub fn peers_json() -> PathBuf { config_dir().join(PEERS_JSON_BASENAME) } +pub fn peers_json() -> PathBuf { + config_dir().join(PEERS_JSON_BASENAME) +} /// Drift rings directory (`drift/YYYY-MM-DD.ndjson`). -pub fn drift_dir() -> PathBuf { state_dir().join("drift") } +pub fn drift_dir() -> PathBuf { + state_dir().join("drift") +} /// thehum chi-log directory (`thehum/YYYY-MM-DD.ndjson` + seq.bin + snapshots/). -pub fn thehum_dir() -> PathBuf { state_dir().join("thehum") } +pub fn thehum_dir() -> PathBuf { + state_dir().join("thehum") +} pub const THEHUM_SEQ_BASENAME: &str = "seq.bin"; pub const THEHUM_SNAPSHOTS_SUBDIR: &str = "snapshots"; @@ -187,46 +309,72 @@ pub const THEHUM_ROOT_BASENAME: &str = "root.txt"; pub const THEHUM_NDJSON_EXT: &str = "ndjson"; /// `/seq.bin` — last-persisted seq counter for a thehum at `dir`. -pub fn thehum_seq_file(dir: &std::path::Path) -> PathBuf { dir.join(THEHUM_SEQ_BASENAME) } +pub fn thehum_seq_file(dir: &std::path::Path) -> PathBuf { + dir.join(THEHUM_SEQ_BASENAME) +} /// `/snapshots` — snapshot store inside a thehum at `dir`. -pub fn thehum_snapshots_dir(dir: &std::path::Path) -> PathBuf { dir.join(THEHUM_SNAPSHOTS_SUBDIR) } +pub fn thehum_snapshots_dir(dir: &std::path::Path) -> PathBuf { + dir.join(THEHUM_SNAPSHOTS_SUBDIR) +} /// `/root.txt` — most recent state-root hex. -pub fn thehum_root_file(dir: &std::path::Path) -> PathBuf { dir.join(THEHUM_ROOT_BASENAME) } +pub fn thehum_root_file(dir: &std::path::Path) -> PathBuf { + dir.join(THEHUM_ROOT_BASENAME) +} /// Cloned hum source tree (recipes + hive installers). -pub fn src_dir() -> PathBuf { data_dir().join("src") } +pub fn src_dir() -> PathBuf { + data_dir().join("src") +} /// `$HOME/.local` — the base for HOME-anchored installs that aren't /// resolved through XDG (cargo install --root, .local/bin, etc). -pub fn local_dir() -> PathBuf { home().join(".local") } +pub fn local_dir() -> PathBuf { + home().join(".local") +} /// `$HOME/.local/bin` — where installed hum binaries live. -pub fn local_bin_dir() -> PathBuf { local_dir().join("bin") } +pub fn local_bin_dir() -> PathBuf { + local_dir().join("bin") +} /// Installed humd binary location. -pub fn humd_bin() -> PathBuf { local_bin_dir().join("humd") } +pub fn humd_bin() -> PathBuf { + local_bin_dir().join("humd") +} /// fnm-managed node binary (when fnm is the user's node manager). /// Probed as a fallback after PATH + /usr/local/bin/node. -pub fn fnm_node_bin() -> PathBuf { local_dir().join("share/fnm/aliases/default/bin/node") } +pub fn fnm_node_bin() -> PathBuf { + local_dir().join("share/fnm/aliases/default/bin/node") +} /// `~/.claude` — Claude CLI's data dir. Read by the claude-cli graft for /// transcript replay; we don't write here. -pub fn claude_data_dir() -> PathBuf { home().join(".claude") } +pub fn claude_data_dir() -> PathBuf { + home().join(".claude") +} /// Installed-binary location for a given hum binary name. -pub fn hum_bin(name: &str) -> PathBuf { local_bin_dir().join(name) } +pub fn hum_bin(name: &str) -> PathBuf { + local_bin_dir().join(name) +} /// Cache dir for a foreign hive clone (org/repo/branch). pub fn foreign_hive_cache(org: &str, repo: &str, branch: &str) -> PathBuf { - cache_dir().join("hives").join(format!("{org}-{repo}-{branch}")) + cache_dir() + .join("hives") + .join(format!("{org}-{repo}-{branch}")) } /// orch.d directory — one .orch file per registered hive. -pub fn orch_d_dir() -> PathBuf { config_dir().join("orch.d") } +pub fn orch_d_dir() -> PathBuf { + config_dir().join("orch.d") +} /// Aggregate Orchfile — orchd reads this; we rebuild it from orch.d. -pub fn orchfile() -> PathBuf { config_dir().join(ORCHFILE_BASENAME) } +pub fn orchfile() -> PathBuf { + config_dir().join(ORCHFILE_BASENAME) +} /// Per-bee config file for a given hive kind (e.g. `ollama-server.json`). pub fn bee_config(kind: &str) -> PathBuf { @@ -249,7 +397,9 @@ pub fn daemon_logs(name: &str) -> DaemonLogs { } } #[cfg(not(target_os = "macos"))] - DaemonLogs::Journald { unit: name.to_string() } + DaemonLogs::Journald { + unit: name.to_string(), + } } // ── helpers ─────────────────────────────────────────────────────────────────── @@ -273,9 +423,70 @@ pub fn home() -> PathBuf { /// expansion across the workspace. pub fn expand_tilde(p: &std::path::Path) -> PathBuf { let s = p.to_string_lossy(); - if let Some(rest) = s.strip_prefix("~/") { return home().join(rest); } - if s == "~" { return home(); } + if let Some(rest) = s.strip_prefix("~/") { + return home().join(rest); + } + if s == "~" { + return home(); + } p.to_path_buf() } pub mod config; + +#[cfg(test)] +mod revocation_tests { + use super::*; + + #[test] + fn an_absent_file_revokes_nothing() { + let tmp = tempfile::TempDir::new().unwrap(); + let list = Revocations::read_from(&tmp.path().join("nope.json")); + assert!(!list.contains("wbee_abc")); + } + + #[test] + fn a_listed_hid_is_contained() { + let mut list = Revocations::default(); + assert!(list.revoke("wbee_dead")); + assert!(list.contains("wbee_dead")); + assert!(!list.contains("wbee_other")); + } + + #[test] + fn revoking_twice_is_not_a_duplicate() { + let mut list = Revocations::default(); + assert!(list.revoke("wbee_dead")); + assert!(!list.revoke("wbee_dead"), "already listed"); + assert_eq!(list.revoked.len(), 1); + } + + #[test] + fn unrevoking_removes_only_that_hid() { + let mut list = Revocations::default(); + list.revoke("wbee_dead"); + list.revoke("wbee_gone"); + assert!(list.unrevoke("wbee_dead")); + assert!(!list.contains("wbee_dead")); + assert!(list.contains("wbee_gone")); + assert!(!list.unrevoke("wbee_dead"), "nothing left to remove"); + } + + #[test] + fn a_list_survives_a_round_trip() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("revoked.json"); + let mut list = Revocations::default(); + list.revoke("wbee_dead"); + list.write_to(&path).unwrap(); + assert!(Revocations::read_from(&path).contains("wbee_dead")); + } + + #[test] + fn a_corrupt_file_revokes_nothing_rather_than_everything() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("revoked.json"); + std::fs::write(&path, b"{ not json").unwrap(); + assert!(!Revocations::read_from(&path).contains("wbee_abc")); + } +} diff --git a/humctl/src/main.rs b/humctl/src/main.rs index b12a566..ee5afdb 100644 --- a/humctl/src/main.rs +++ b/humctl/src/main.rs @@ -4,7 +4,7 @@ use std::process::{Command, ExitCode}; -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::{Context, Result, anyhow, bail}; use service_manager::{ ServiceLabel, ServiceLevel, ServiceManager, ServiceStartCtx, ServiceStopCtx, }; @@ -20,6 +20,12 @@ Usage: humctl logs [-n LINES] humctl health humctl thehum + humctl revoke ... + humctl unrevoke ... + humctl revocations + +A revoked hid is refused the socket at hello. The list is read when humd +starts, so restart after revoking. "; fn main() -> ExitCode { @@ -50,6 +56,9 @@ fn run() -> Result<()> { "logs" => logs(parse_lines(args.collect::>())), "health" => health(), "thehum" => thehum(), + "revoke" => revoke(args.collect::>()), + "unrevoke" => unrevoke(args.collect::>()), + "revocations" => revocations(), other => bail!("unknown verb '{other}'\n{USAGE}"), } } @@ -254,3 +263,58 @@ fn thehum() -> Result<()> { unsafe extern "C" { fn geteuid() -> u32; } + +fn revoke(hids: Vec) -> Result<()> { + if hids.is_empty() { + bail!("revoke needs at least one hid\n{USAGE}"); + } + let mut list = hum_paths::Revocations::read(); + let mut added = 0usize; + for hid in &hids { + if list.revoke(hid) { + println!("revoked {hid}"); + added += 1; + } else { + println!("already revoked {hid}"); + } + } + list.write().context("write revocation list")?; + if added > 0 { + println!("restart humd for this to take effect"); + } + Ok(()) +} + +fn unrevoke(hids: Vec) -> Result<()> { + if hids.is_empty() { + bail!("unrevoke needs at least one hid\n{USAGE}"); + } + let mut list = hum_paths::Revocations::read(); + let mut removed = 0usize; + for hid in &hids { + if list.unrevoke(hid) { + println!("unrevoked {hid}"); + removed += 1; + } else { + println!("not revoked {hid}"); + } + } + list.write().context("write revocation list")?; + if removed > 0 { + println!("restart humd for this to take effect"); + } + Ok(()) +} + +fn revocations() -> Result<()> { + let list = hum_paths::Revocations::read(); + if list.revoked.is_empty() { + println!("no revoked bees"); + return Ok(()); + } + println!("{} revoked:", list.revoked.len()); + for hid in &list.revoked { + println!(" {hid}"); + } + Ok(()) +} diff --git a/humd/src/lib.rs b/humd/src/lib.rs index 1ab057e..5e40483 100644 --- a/humd/src/lib.rs +++ b/humd/src/lib.rs @@ -1,4 +1,3 @@ - use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -8,22 +7,23 @@ use anyhow::Result; use ensemble::{Ensemble, Hid, HumdKey, PeerCapabilities}; use parking_lot::RwLock; use serde_json::Value; -use thrumd::{serve_with_hook as thrum_serve_with_hook, Thrum, Tone, ToneSink}; use thrum_core::{Chi, WaneTracker}; +use thrumd::{Thrum, Tone, ToneSink, serve_with_hook as thrum_serve_with_hook}; use tracing::{error, info, trace, warn}; -mod drone; mod drift; +mod drone; mod identity; mod peer_transport; pub mod peers; mod penny; pub mod redial; +pub mod revoked; mod routing_store; pub mod supervisor; pub mod thrumd; pub use identity::{key_path, load_or_mint_key, read_key}; -pub use peers::{peers_path, PeerConfig}; +pub use peers::{PeerConfig, peers_path}; type Observers = Arc>>>; @@ -47,6 +47,7 @@ pub struct DaemonConfig { pub routing_persist_interval: Duration, pub thrum_override: Option, pub ensemble: Option>, + pub revoked_path: PathBuf, pub bind_mcp: bool, pub capacity: LocalCapacity, pub waneman: Option>, @@ -84,6 +85,7 @@ impl DaemonConfig { routing_persist_interval: Duration::from_secs(30), thrum_override: None, ensemble: None, + revoked_path: hum_paths::revoked_bees(), bind_mcp: true, capacity: LocalCapacity::default(), waneman: None, @@ -114,7 +116,12 @@ where "humd.sockets" ); - if let Ok(addr) = cfg.hum_cfg.humd.metrics_addr.parse::() { + if let Ok(addr) = cfg + .hum_cfg + .humd + .metrics_addr + .parse::() + { match metrics_exporter_prometheus::PrometheusBuilder::new() .with_http_listener(addr) .install() @@ -127,9 +134,14 @@ where } let penny = penny::Penny::load(&cfg.penny_path); - penny.clone().spawn_persister(cfg.penny_path.clone(), cfg.penny_persist_interval); + penny + .clone() + .spawn_persister(cfg.penny_path.clone(), cfg.penny_persist_interval); - let waneman = cfg.waneman.clone().unwrap_or_else(|| Arc::new(WaneTracker::new())); + let waneman = cfg + .waneman + .clone() + .unwrap_or_else(|| Arc::new(WaneTracker::new())); let _drift = drift::Drift::with_store_dir(hum_paths::drift_dir()); let _drone = drone::Drone::new(); @@ -161,7 +173,11 @@ where if let Some(ens) = &ensemble_opt { routing_store::restore_on_boot(ens, &cfg.routing_path); - routing_store::spawn_persister(ens.clone(), cfg.routing_path.clone(), cfg.routing_persist_interval); + routing_store::spawn_persister( + ens.clone(), + cfg.routing_path.clone(), + cfg.routing_persist_interval, + ); } let mut peer_reach: Vec = Vec::new(); @@ -173,7 +189,14 @@ where match peer_transport::iroh::bind(key).await { Ok((transport, hints)) => { let transport = Arc::new(transport); - peer_transport::iroh::dial_all(&transport, ens, key, &cfg.bootstrap_peers, &my_caps).await; + peer_transport::iroh::dial_all( + &transport, + ens, + key, + &cfg.bootstrap_peers, + &my_caps, + ) + .await; peer_transport::iroh::spawn_listener( transport.clone(), ens.clone(), @@ -186,8 +209,21 @@ where Err(e) => warn!(err = %e, "peer.iroh.bind_failed"), } - if let Some(addr) = cfg.hum_cfg.humd.tcp_listen.as_deref().filter(|s| !s.is_empty()) { - match peer_transport::tcp::spawn_listener(addr, ens.clone(), key.clone(), my_caps.clone()).await { + if let Some(addr) = cfg + .hum_cfg + .humd + .tcp_listen + .as_deref() + .filter(|s| !s.is_empty()) + { + match peer_transport::tcp::spawn_listener( + addr, + ens.clone(), + key.clone(), + my_caps.clone(), + ) + .await + { Ok((_bound, hints)) => peer_reach.extend(hints), Err(e) => warn!(addr, err = %e, "peer.tcp.bind_failed"), } @@ -226,37 +262,65 @@ where let observers: Observers = Arc::new(RwLock::new(HashMap::new())); let hive_tag = cfg.hum_cfg.nest.default.clone(); let manifests: Manifests = Arc::new(parking_lot::RwLock::new(HashMap::new())); - let remote_hives: RemoteHives = - Arc::new(parking_lot::RwLock::new(HashMap::new())); + let remote_hives: RemoteHives = Arc::new(parking_lot::RwLock::new(HashMap::new())); if let Some(thehum) = thehum_handle.as_ref() { let manifests_for_replay = manifests.clone(); if let Err(e) = thehum.replay(|event| { let body = &event.body; - let client_id = body.get("client_id").and_then(Value::as_str) + let client_id = body + .get("client_id") + .and_then(Value::as_str) .or_else(|| body.get("nestlerId").and_then(Value::as_str)) .or_else(|| body.get("from").and_then(Value::as_str)) .map(str::to_string); let Some(client_id) = client_id else { return }; match event.chi.as_str() { "hello" => { - let name = body.get("hive").and_then(Value::as_str) + let name = body + .get("hive") + .and_then(Value::as_str) .or_else(|| body.get("from").and_then(Value::as_str)) .unwrap_or(&client_id) .to_string(); - let version = body.get("version").and_then(Value::as_str) - .unwrap_or("0.0.0").to_string(); - let proto = body.get("protoVersion").and_then(Value::as_str) - .unwrap_or(thrum_core::THRUM_VERSION).to_string(); + let version = body + .get("version") + .and_then(Value::as_str) + .unwrap_or("0.0.0") + .to_string(); + let proto = body + .get("protoVersion") + .and_then(Value::as_str) + .unwrap_or(thrum_core::THRUM_VERSION) + .to_string(); let mut manifest = ensemble::HiveManifest::new(name, version, proto); - manifest.bee = body.get("bee").and_then(Value::as_array) - .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + manifest.bee = body + .get("bee") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) .unwrap_or_default(); - manifest.models = body.get("models").and_then(Value::as_array) - .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + manifest.models = body + .get("models") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) .unwrap_or_default(); - manifest.hid = body.get("hid").and_then(Value::as_str) + manifest.hid = body + .get("hid") + .and_then(Value::as_str) .and_then(|s| ensemble::Hid::from_hex(s).ok()); - manifest.nestler_id = body.get("nestlerId").and_then(Value::as_str).map(str::to_string); + manifest.nestler_id = body + .get("nestlerId") + .and_then(Value::as_str) + .map(str::to_string); manifests_for_replay.write().insert(client_id, manifest); } "disconnect" | "forget" => { @@ -299,6 +363,7 @@ where incoming_tool_calls: incoming_tool_calls.clone(), thehum: thehum_handle.clone(), trust_remote_workers: cfg.trust_remote_workers, + revoked: revoked::Revocations::load(&cfg.revoked_path), }); thrum.set_sink(sink); if let Some(ens) = &ensemble_for_sink { @@ -308,7 +373,11 @@ where let mut seen = ens.hive_discover_all(); while let Some((humd_id, manifest)) = seen.recv().await { let key = bee_key(&manifest); - remote.write().entry(humd_id).or_default().insert(key, manifest); + remote + .write() + .entry(humd_id) + .or_default() + .insert(key, manifest); } }); } @@ -388,7 +457,11 @@ where } if now_ms.saturating_sub(last_retention_ms) >= 3_600_000 { match thehum.enforce_retention() { - Ok(r) => trace!(removed = r.removed_files, kept = r.kept_files, "thehum.retention.ok"), + Ok(r) => trace!( + removed = r.removed_files, + kept = r.kept_files, + "thehum.retention.ok" + ), Err(e) => warn!(err = %e, "thehum.retention.failed"), } last_retention_ms = now_ms; @@ -433,7 +506,8 @@ async fn autoupdate_check_once() -> Result { let body = tokio::process::Command::new("curl") .args([ "-fsSL", - "-H", "Accept: application/vnd.github+json", + "-H", + "Accept: application/vnd.github+json", "https://api.github.com/repos/adiled/hum/releases/latest", ]) .output() @@ -442,7 +516,8 @@ async fn autoupdate_check_once() -> Result { anyhow::bail!("github releases fetch failed: {}", body.status); } let body = String::from_utf8(body.stdout)?; - let upstream = parse_tag_name(&body).ok_or_else(|| anyhow::anyhow!("no tag_name in response"))?; + let upstream = + parse_tag_name(&body).ok_or_else(|| anyhow::anyhow!("no tag_name in response"))?; let upstream_trim = upstream.trim_start_matches('v'); if upstream_trim == local { return Ok(false); @@ -486,6 +561,7 @@ struct HumdSink { incoming_tool_calls: Arc>>, thehum: Option>, trust_remote_workers: bool, + revoked: revoked::Revocations, } pub struct PeersAliasResolver { @@ -559,9 +635,14 @@ impl HumdSink { serde_json::to_vec_pretty(&*m).unwrap_or_default() }; let path = &self.bees_snapshot_path; - if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } let tmp = path.with_extension("json.tmp"); - if std::fs::write(&tmp, &json).and_then(|_| std::fs::rename(&tmp, path)).is_err() { + if std::fs::write(&tmp, &json) + .and_then(|_| std::fs::rename(&tmp, path)) + .is_err() + { trace!(path = %path.display(), "bees.snapshot.write.failed"); } } @@ -570,9 +651,13 @@ impl HumdSink { #[async_trait::async_trait] impl ToneSink for HumdSink { async fn forget(&self, client_id: &str) { - if client_id == "ensemble" { return; } + if client_id == "ensemble" { + return; + } let had_manifest = self.manifests.write().remove(client_id).is_some(); - self.tool_routes.write().retain(|_, originator| originator != client_id); + self.tool_routes + .write() + .retain(|_, originator| originator != client_id); if had_manifest { trace!(client_id, "manifest.evict.disconnect"); self.snapshot_bees(); @@ -589,10 +674,17 @@ impl ToneSink for HumdSink { if let Some(thehum) = self.thehum.as_ref() { if client_id != "ensemble" { let sid = tone.get("sid").and_then(Value::as_str).and_then(|s| { - hum_identity::HumId::parse(s).ok().or_else(|| Some(hum_identity::HumId::from_foreign(s))) + hum_identity::HumId::parse(s) + .ok() + .or_else(|| Some(hum_identity::HumId::from_foreign(s))) }); - let rid = tone.get("rid").and_then(Value::as_str) - .map(|s| hum_identity::HumId::parse(s).unwrap_or_else(|_| hum_identity::HumId::from_foreign(s))) + let rid = tone + .get("rid") + .and_then(Value::as_str) + .map(|s| { + hum_identity::HumId::parse(s) + .unwrap_or_else(|_| hum_identity::HumId::from_foreign(s)) + }) .unwrap_or_else(hum_identity::HumId::mint); let body = serde_json::to_value(&tone).unwrap_or_default(); if let Err(e) = thehum.append(chi_str, sid, rid, body).await { @@ -602,26 +694,34 @@ impl ToneSink for HumdSink { } if client_id == "ensemble" && matches!(chi, Some(Chi::ToolCall)) { - let tool_name = tone.get("toolName").and_then(Value::as_str) + let tool_name = tone + .get("toolName") + .and_then(Value::as_str) .or_else(|| tone.get("name").and_then(Value::as_str)) .map(str::to_string); if let Some(tool_name) = tool_name { let forager_cid = { let m = self.manifests.read(); - m.iter().find(|(_, man)| { - man.bee.iter().any(|b| b == "forager") - && man.tools.iter().any(|t| t.name == tool_name) - }).map(|(cid, _)| cid.clone()) + m.iter() + .find(|(_, man)| { + man.bee.iter().any(|b| b == "forager") + && man.tools.iter().any(|t| t.name == tool_name) + }) + .map(|(cid, _)| cid.clone()) }; if let Some(fcid) = forager_cid { - let call_id = tone.get("callId").and_then(Value::as_str) - .unwrap_or("").to_string(); - let from_peer = tone.get("from").and_then(Value::as_str) + let call_id = tone + .get("callId") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let from_peer = tone + .get("from") + .and_then(Value::as_str) .and_then(parse_humd_id); - if let (Some(call_id), Some(from_peer)) = ( - (!call_id.is_empty()).then_some(call_id.clone()), - from_peer, - ) { + if let (Some(call_id), Some(from_peer)) = + ((!call_id.is_empty()).then_some(call_id.clone()), from_peer) + { self.incoming_tool_calls.write().insert(call_id, from_peer); } trace!( @@ -640,33 +740,48 @@ impl ToneSink for HumdSink { if !matches!(chi, Some(Chi::Hello)) { let bee_kind: Vec = { let m = self.manifests.read(); - m.get(client_id).map(|man| man.bee.clone()).unwrap_or_default() + m.get(client_id) + .map(|man| man.bee.clone()) + .unwrap_or_default() }; let is_worker = bee_kind.iter().any(|b| b == "worker"); let _ = &is_worker; - if !is_worker - && matches!(chi, Some(Chi::ToolCall)) - && client_id != "ensemble" - { - let sid = tone.get("sid").and_then(Value::as_str).map(str::to_string).unwrap_or_default(); - let tool_name = tone.get("toolName").and_then(Value::as_str) + if !is_worker && matches!(chi, Some(Chi::ToolCall)) && client_id != "ensemble" { + let sid = tone + .get("sid") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_default(); + let tool_name = tone + .get("toolName") + .and_then(Value::as_str) .or_else(|| tone.get("name").and_then(Value::as_str)) .map(str::to_string); if !sid.is_empty() { - self.thrum.claim_sigil(client_id, &thrum_core::sigil(&sid, &self.hive_tag)); + self.thrum + .claim_sigil(client_id, &thrum_core::sigil(&sid, &self.hive_tag)); self.thrum.claim_sigil(client_id, &sid); } if let Some(tn) = tool_name.as_ref() { - let forager_cid: Option = self.manifests.read() + let forager_cid: Option = self + .manifests + .read() .iter() - .find(|(_, m)| m.bee.iter().any(|b| b == "forager") - && m.tools.iter().any(|t| &t.name == tn)) + .find(|(_, m)| { + m.bee.iter().any(|b| b == "forager") + && m.tools.iter().any(|t| &t.name == tn) + }) .map(|(cid, _)| cid.clone()); if let Some(fcid) = forager_cid { - let call_id = tone.get("callId").and_then(Value::as_str) - .unwrap_or("").to_string(); + let call_id = tone + .get("callId") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); if !call_id.is_empty() { - self.tool_routes.write().insert(call_id.clone(), client_id.to_string()); + self.tool_routes + .write() + .insert(call_id.clone(), client_id.to_string()); } trace!(from = client_id, to = %fcid, %tn, %call_id, "tool-call.route.from-asker"); self.thrum.thrum_to(&fcid, tone); @@ -676,13 +791,21 @@ impl ToneSink for HumdSink { } if is_worker { if matches!(chi, Some(Chi::ToolCall)) { - let sid_for_lookup = tone.get("sid").and_then(Value::as_str) - .map(str::to_string).unwrap_or_default(); + let sid_for_lookup = tone + .get("sid") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_default(); if !sid_for_lookup.is_empty() { - self.thrum.claim_sigil(client_id, &thrum_core::sigil(&sid_for_lookup, &self.hive_tag)); + self.thrum.claim_sigil( + client_id, + &thrum_core::sigil(&sid_for_lookup, &self.hive_tag), + ); self.thrum.claim_sigil(client_id, &sid_for_lookup); } - let tool_name = tone.get("toolName").and_then(Value::as_str) + let tool_name = tone + .get("toolName") + .and_then(Value::as_str) .or_else(|| tone.get("name").and_then(Value::as_str)) .map(str::to_string); let pinned_fs = self.sid_fs.read().get(&sid_for_lookup).copied(); @@ -691,15 +814,18 @@ impl ToneSink for HumdSink { (pinned_fs, &self.ensemble, tool_name.as_ref()) { if fs_hid != ens.me() { - let call_id = tone.get("callId").and_then(Value::as_str) - .unwrap_or("").to_string(); + let call_id = tone + .get("callId") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); if !call_id.is_empty() { - self.tool_routes.write().insert( - call_id.clone(), client_id.to_string() - ); - self.tool_routes_peer.write().insert( - call_id.clone(), fs_hid - ); + self.tool_routes + .write() + .insert(call_id.clone(), client_id.to_string()); + self.tool_routes_peer + .write() + .insert(call_id.clone(), fs_hid); } let mut routed = tone.clone(); if let Some(obj) = routed.as_object_mut() { @@ -721,18 +847,23 @@ impl ToneSink for HumdSink { if let Some(tool_name) = tool_name { let forager_cid = { let m = self.manifests.read(); - m.iter().find(|(_, man)| { - man.bee.iter().any(|b| b == "forager") - && man.tools.iter().any(|t| t.name == tool_name) - }).map(|(cid, _)| cid.clone()) + m.iter() + .find(|(_, man)| { + man.bee.iter().any(|b| b == "forager") + && man.tools.iter().any(|t| t.name == tool_name) + }) + .map(|(cid, _)| cid.clone()) }; if let Some(fcid) = forager_cid { - let call_id = tone.get("callId").and_then(Value::as_str) - .unwrap_or("").to_string(); + let call_id = tone + .get("callId") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); if !call_id.is_empty() { - self.tool_routes.write().insert( - call_id.clone(), client_id.to_string() - ); + self.tool_routes + .write() + .insert(call_id.clone(), client_id.to_string()); } trace!( from = client_id, to = %fcid, %tool_name, %call_id, @@ -744,10 +875,16 @@ impl ToneSink for HumdSink { } } if let Some(sid) = tone.get("sid").and_then(Value::as_str).map(str::to_string) { - if matches!(chi, - Some(Chi::Chunk) | Some(Chi::Finish) | Some(Chi::Error) - | Some(Chi::ToolCall) | Some(Chi::ToolInfo) | Some(Chi::SessionReady) - | Some(Chi::Pulse) | Some(Chi::Breath) + if matches!( + chi, + Some(Chi::Chunk) + | Some(Chi::Finish) + | Some(Chi::Error) + | Some(Chi::ToolCall) + | Some(Chi::ToolInfo) + | Some(Chi::SessionReady) + | Some(Chi::Pulse) + | Some(Chi::Breath) ) { if let Some(ens) = &self.ensemble { let obs = self.observers.read().get(&sid).cloned().unwrap_or_default(); @@ -773,7 +910,8 @@ impl ToneSink for HumdSink { warn!(err = %e, "worker.reply.ensemble.failed"); } } - self.thrum.thrum_broadcast(&sid, &self.hive_tag, tone.clone()); + self.thrum + .thrum_broadcast(&sid, &self.hive_tag, tone.clone()); return; } } @@ -783,9 +921,13 @@ impl ToneSink for HumdSink { if matches!(chi, Some(Chi::Attach)) && client_id != "ensemble" { if let Some(sid) = tone.get("sid").and_then(Value::as_str) { if !sid.is_empty() { - self.thrum.claim_sigil(client_id, thrum_core::sigil(sid, &self.hive_tag)); + self.thrum + .claim_sigil(client_id, thrum_core::sigil(sid, &self.hive_tag)); self.thrum.claim_sigil(client_id, sid.to_string()); - let hear_only = tone.get("hearOnly").and_then(Value::as_bool).unwrap_or(false); + let hear_only = tone + .get("hearOnly") + .and_then(Value::as_bool) + .unwrap_or(false); trace!(client_id, sid, hear_only, "attach.local.claimed"); } } @@ -869,7 +1011,11 @@ impl ToneSink for HumdSink { ); self.thrum.thrum_to( client_id, - thrumd::echo_tone(&rid, false, Some("protoVersion incompatible")), + thrumd::echo_tone( + &rid, + false, + Some("protoVersion incompatible"), + ), ); self.thrum.thrum_close(client_id); return; @@ -882,37 +1028,65 @@ impl ToneSink for HumdSink { self.thrum.thrum_to(client_id, breath); let bee: Vec = match tone.get("bee") { - Some(Value::Array(arr)) => arr.iter() + Some(Value::Array(arr)) => arr + .iter() .filter_map(Value::as_str) .map(str::to_string) .collect(), Some(Value::String(s)) => vec![s.clone()], _ => Vec::new(), }; - let models: Vec = tone.get("models") + let models: Vec = tone + .get("models") .and_then(Value::as_array) - .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .map(|a| { + a.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) .unwrap_or_default(); - let name = tone.get("hive").and_then(Value::as_str) + let name = tone + .get("hive") + .and_then(Value::as_str) .map(str::to_string) .or_else(|| tone.get("from").and_then(Value::as_str).map(str::to_string)); if let Some(name) = name { - let proto = tone.get("protoVersion").and_then(Value::as_str) - .unwrap_or(thrum_core::THRUM_VERSION).to_string(); - let version = tone.get("version").and_then(Value::as_str) - .unwrap_or("0.0.0").to_string(); - let propensity = tone.get("propensity") + let proto = tone + .get("protoVersion") + .and_then(Value::as_str) + .unwrap_or(thrum_core::THRUM_VERSION) + .to_string(); + let version = tone + .get("version") + .and_then(Value::as_str) + .unwrap_or("0.0.0") + .to_string(); + let propensity = tone + .get("propensity") .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_default(); - let chis: Vec = tone.get("chis") + let chis: Vec = tone + .get("chis") .or_else(|| tone.get("chi")) .and_then(|v| v.as_array()) - .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) .unwrap_or_default(); - let source = tone.get("source").and_then(Value::as_str).map(str::to_string); - let bind: Option = tone.get("bind") + self.thrum.claim_chis(client_id, chis.clone()); + let source = tone + .get("source") + .and_then(Value::as_str) + .map(str::to_string); + let bind: Option = tone + .get("bind") .and_then(|v| serde_json::from_value(v.clone()).ok()); - let nestler_id = tone.get("nestlerId").and_then(Value::as_str) + let nestler_id = tone + .get("nestlerId") + .and_then(Value::as_str) .map(str::to_string) .unwrap_or_else(|| client_id.to_string()); let mut manifest = ensemble::HiveManifest::new(name, version, proto); @@ -924,33 +1098,85 @@ impl ToneSink for HumdSink { manifest.bee = bee.clone(); manifest.models = models.clone(); let raw_hid = tone.get("hid").and_then(Value::as_str); - manifest.hid = raw_hid.and_then(|s| ensemble::Hid::from_hex(s).ok()); - match (raw_hid, manifest.hid) { - (Some(_), Some(hid)) => { - trace!(client_id, hid = %hid.short(), "bee.hid.registered"); + match ensemble::parse_socket_identity(&tone) { + ensemble::SocketIdentity::Verified(hid) => { + manifest.hid = Some(hid); + manifest.hid_verified = true; + trace!(client_id, hid = %hid.short(), + "bee.hid.verified — declared hid proven against a signed hello"); } - (Some(bad), None) => { - warn!(client_id, bad_hid = %bad, - "bee.hid.invalid — not a canonical Hid; reconnect dedup disabled for this bee. \ - Derive a stable hid from a persisted key (see hives/common identity)."); + ensemble::SocketIdentity::Unsigned(declared) => { + manifest.hid = declared; + match (raw_hid, declared) { + (Some(_), Some(hid)) => { + trace!(client_id, hid = %hid.short(), + "bee.hid.registered — self-declared, not signed"); + } + (Some(bad), None) => { + warn!(client_id, bad_hid = %bad, + "bee.hid.invalid — not a canonical Hid; reconnect dedup disabled for this bee. \ + Derive a stable hid from a persisted key (see hum-identity)."); + } + (None, _) => { + warn!( + client_id, + "bee.hid.missing — hello has no hid; reconnect dedup disabled. \ + Ghost manifests will accumulate on reconnect." + ); + } + } + } + ensemble::SocketIdentity::Invalid(why) => { + error!( + client_id, + reason = why, + "bee.hid.bad-signature — hello signed its hid and the signature failed. \ + Not registered, not announced." + ); + self.thrum.thrum_to( + client_id, + thrumd::echo_tone(&rid, false, Some("hid signature invalid")), + ); + self.thrum.thrum_close(client_id); + return; } - (None, _) => { - warn!(client_id, - "bee.hid.missing — hello has no hid; reconnect dedup disabled. \ - Ghost manifests will accumulate on reconnect."); + } + if let Some(hid) = manifest.hid { + if self.revoked.is_revoked(&hid.to_hex()) { + error!(client_id, hid = %hid.short(), + "bee.revoked — hid is on the operator's revocation list. \ + Not registered, not announced."); + self.thrum.thrum_to( + client_id, + thrumd::echo_tone(&rid, false, Some("bee identity revoked")), + ); + self.thrum.thrum_close(client_id); + return; } } if let Some(arr) = tone.get("tools").and_then(Value::as_array) { - manifest.tools = arr.iter().filter_map(|v| { - let name = v.get("name").and_then(Value::as_str)?.to_string(); - let description = v.get("description").and_then(Value::as_str) - .unwrap_or("").to_string(); - let input_schema = v.get("inputSchema").cloned().unwrap_or(Value::Null); - Some(ensemble::ToolEntry { name, description, input_schema }) - }).collect(); + manifest.tools = arr + .iter() + .filter_map(|v| { + let name = v.get("name").and_then(Value::as_str)?.to_string(); + let description = v + .get("description") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let input_schema = + v.get("inputSchema").cloned().unwrap_or(Value::Null); + Some(ensemble::ToolEntry { + name, + description, + input_schema, + }) + }) + .collect(); } if let Some(arr) = tone.get("provides").and_then(Value::as_array) { - manifest.provides = arr.iter() + manifest.provides = arr + .iter() .filter_map(Value::as_str) .map(str::to_string) .collect(); @@ -973,9 +1199,12 @@ impl ToneSink for HumdSink { if client_id != "ensemble" { let mut m = self.manifests.write(); if let Some(new_hid) = manifest.hid { - let stale: Vec = m.iter() + let stale: Vec = m + .iter() .filter_map(|(cid, mf)| match mf.hid { - Some(h) if h == new_hid && cid != client_id => Some(cid.clone()), + Some(h) if h == new_hid && cid != client_id => { + Some(cid.clone()) + } _ => None, }) .collect(); @@ -1004,21 +1233,25 @@ impl ToneSink for HumdSink { } } Some(Chi::Prompt) => { - let sid = tone.get("sid").and_then(Value::as_str).unwrap_or("").to_string(); + let sid = tone + .get("sid") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); if sid.is_empty() { warn!(client_id, "prompt.no-sid"); return; } - if client_id != "ensemble" - && self.capacity == LocalCapacity::OverflowAlways - { + if client_id != "ensemble" && self.capacity == LocalCapacity::OverflowAlways { if let Some(ensemble) = &self.ensemble { let target = pick_overflow_peer(ensemble, &self.hive_tag); if let Some(peer) = target { - self.thrum.claim_sigil(client_id, &thrum_core::sigil(&sid, &self.hive_tag)); + self.thrum + .claim_sigil(client_id, &thrum_core::sigil(&sid, &self.hive_tag)); self.thrum.claim_sigil(client_id, &sid); if let Some(rid) = tone.get("rid").and_then(Value::as_str) { - self.thrum.thrum_to(client_id, thrumd::echo_tone(rid, true, None)); + self.thrum + .thrum_to(client_id, thrumd::echo_tone(rid, true, None)); } let mut forward = tone.clone(); if let Some(obj) = forward.as_object_mut() { @@ -1035,8 +1268,14 @@ impl ToneSink for HumdSink { } } } - let model = tone.get("modelId").and_then(Value::as_str).unwrap_or("sonnet").to_string(); - let cwd_raw = tone.get("cwd").and_then(Value::as_str) + let model = tone + .get("modelId") + .and_then(Value::as_str) + .unwrap_or("sonnet") + .to_string(); + let cwd_raw = tone + .get("cwd") + .and_then(Value::as_str) .map(str::to_string) .unwrap_or_else(|| "/".into()); let cwd = if ensemble::HumUri::starts_with_scheme(&cwd_raw) { @@ -1065,14 +1304,26 @@ impl ToneSink for HumdSink { } else { cwd_raw }; - let system_prompt = tone.get("systemPrompt").and_then(Value::as_str).map(str::to_string); - let text = tone.get("text").and_then(Value::as_str).map(str::to_string) - .or_else(|| tone.get("content").and_then(Value::as_str).map(str::to_string)) + let system_prompt = tone + .get("systemPrompt") + .and_then(Value::as_str) + .map(str::to_string); + let text = tone + .get("text") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + tone.get("content") + .and_then(Value::as_str) + .map(str::to_string) + }) .unwrap_or_default(); if let Some(rid) = tone.get("rid").and_then(Value::as_str) { - self.thrum.thrum_to(client_id, thrumd::echo_tone(rid, true, None)); + self.thrum + .thrum_to(client_id, thrumd::echo_tone(rid, true, None)); } - self.thrum.claim_sigil(client_id, &thrum_core::sigil(&sid, &self.hive_tag)); + self.thrum + .claim_sigil(client_id, &thrum_core::sigil(&sid, &self.hive_tag)); self.thrum.claim_sigil(client_id, &sid); let origin = if client_id == "ensemble" { tone.get("from") @@ -1112,7 +1363,9 @@ impl ToneSink for HumdSink { }; if !to_prune.is_empty() { let mut m = self.manifests.write(); - for cid in &to_prune { m.remove(cid); } + for cid in &to_prune { + m.remove(cid); + } } pick }; @@ -1132,7 +1385,10 @@ impl ToneSink for HumdSink { "prompt.forward.remote.failed"), } } - warn!(sid, model, "prompt.no-worker — no bee on this mesh advertises this model"); + warn!( + sid, + model, "prompt.no-worker — no bee on this mesh advertises this model" + ); let err = serde_json::json!({ "chi": "error", "sid": sid, @@ -1171,7 +1427,9 @@ impl ToneSink for HumdSink { let mut sorted: Vec<_> = m.iter().collect(); sorted.sort_by(|a, b| a.0.cmp(b.0)); for (_, man) in sorted { - if !man.bee.iter().any(|b| b == "forager") { continue; } + if !man.bee.iter().any(|b| b == "forager") { + continue; + } for t in &man.tools { tools.push(serde_json::json!({ "name": t.name, @@ -1179,7 +1437,9 @@ impl ToneSink for HumdSink { "inputSchema": t.input_schema, })); } - for c in &man.provides { caps.insert(c.clone()); } + for c in &man.provides { + caps.insert(c.clone()); + } } (tools, caps.into_iter().collect()) }; @@ -1187,18 +1447,29 @@ impl ToneSink for HumdSink { obj.insert("foragerTools".into(), Value::Array(forager_tools_json)); } if !provided_caps.is_empty() { - obj.insert("provided".into(), - Value::Array(provided_caps.iter().cloned().map(Value::String).collect())); + obj.insert( + "provided".into(), + Value::Array( + provided_caps.iter().cloned().map(Value::String).collect(), + ), + ); } - let mut disallowed: std::collections::BTreeSet = - obj.get("disallowedTools") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) - .unwrap_or_default(); + let mut disallowed: std::collections::BTreeSet = obj + .get("disallowedTools") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); for cap in &provided_caps { if let Some(names) = hum_mcp::capability::capability_tools(cap) { - for n in names { disallowed.insert((*n).into()); } + for n in names { + disallowed.insert((*n).into()); + } } } if !disallowed.is_empty() { @@ -1215,7 +1486,9 @@ impl ToneSink for HumdSink { } Some(Chi::Cancel) => { if let Some(sid) = tone.get("sid").and_then(Value::as_str) { - let workers: Vec = self.manifests.read() + let workers: Vec = self + .manifests + .read() .iter() .filter(|(_, m)| m.bee.iter().any(|b| b == "worker")) .map(|(cid, _)| cid.clone()) @@ -1228,7 +1501,9 @@ impl ToneSink for HumdSink { } Some(Chi::Cleanup) => { if let Some(_sid) = tone.get("sid").and_then(Value::as_str) { - let workers: Vec = self.manifests.read() + let workers: Vec = self + .manifests + .read() .iter() .filter(|(_, m)| m.bee.iter().any(|b| b == "worker")) .map(|(cid, _)| cid.clone()) @@ -1239,14 +1514,24 @@ impl ToneSink for HumdSink { } } Some(Chi::Attach) => { - let sid = tone.get("sid").and_then(Value::as_str).unwrap_or("").to_string(); + let sid = tone + .get("sid") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); if sid.is_empty() { warn!(client_id, "attach.no-sid"); return; } - let hear_only = tone.get("hearOnly").and_then(Value::as_bool).unwrap_or(false); + let hear_only = tone + .get("hearOnly") + .and_then(Value::as_bool) + .unwrap_or(false); if client_id == "ensemble" { - let peer = tone.get("from").and_then(Value::as_str).and_then(parse_humd_id); + let peer = tone + .get("from") + .and_then(Value::as_str) + .and_then(parse_humd_id); if let Some(peer) = peer { let mut obs = self.observers.write(); let list = obs.entry(sid.clone()).or_default(); @@ -1258,7 +1543,8 @@ impl ToneSink for HumdSink { warn!(client_id, sid, "attach.bad-from"); } } else { - self.thrum.claim_sigil(client_id, &thrum_core::sigil(&sid, &self.hive_tag)); + self.thrum + .claim_sigil(client_id, &thrum_core::sigil(&sid, &self.hive_tag)); self.thrum.claim_sigil(client_id, &sid); if let Some(ensemble) = &self.ensemble { if let Some(to) = tone.get("to").and_then(Value::as_str) { @@ -1278,18 +1564,27 @@ impl ToneSink for HumdSink { } } Some(Chi::Detach) => { - let sid = tone.get("sid").and_then(Value::as_str).unwrap_or("").to_string(); + let sid = tone + .get("sid") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); if sid.is_empty() { warn!(client_id, "detach.no-sid"); return; } if client_id == "ensemble" { - let peer = tone.get("from").and_then(Value::as_str).and_then(parse_humd_id); + let peer = tone + .get("from") + .and_then(Value::as_str) + .and_then(parse_humd_id); if let Some(peer) = peer { let mut obs = self.observers.write(); if let Some(list) = obs.get_mut(&sid) { list.retain(|p| *p != peer); - if list.is_empty() { obs.remove(&sid); } + if list.is_empty() { + obs.remove(&sid); + } } trace!(client_id, sid, peer = %peer.short(), "observer.removed"); } @@ -1358,7 +1653,10 @@ impl ToneSink for HumdSink { ); } Some(Chi::Error) => { - let call_id = tone.get("callId").and_then(Value::as_str).map(str::to_string); + let call_id = tone + .get("callId") + .and_then(Value::as_str) + .map(str::to_string); if let Some(cid) = call_id.as_deref() { let origin_peer = self.incoming_tool_calls.read().get(cid).copied(); if let (Some(origin_peer), Some(ens)) = (origin_peer, &self.ensemble) { @@ -1420,7 +1718,11 @@ impl ToneSink for HumdSink { trace!(client_id, "backfill.no-thehum"); return; }; - let author = tone.get("author").and_then(Value::as_str).unwrap_or("").to_string(); + let author = tone + .get("author") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); let from = tone.get("from").and_then(Value::as_u64).unwrap_or(0); if author.is_empty() { warn!(client_id, "backfill.no-author"); @@ -1444,7 +1746,9 @@ impl ToneSink for HumdSink { } Some(Chi::Curate) => { if let Some(_sid) = tone.get("sid").and_then(Value::as_str) { - let workers: Vec = self.manifests.read() + let workers: Vec = self + .manifests + .read() .iter() .filter(|(_, m)| m.bee.iter().any(|b| b == "worker")) .map(|(cid, _)| cid.clone()) @@ -1485,6 +1789,7 @@ fn my_capabilities(cfg: &DaemonConfig) -> PeerCapabilities { can_relay: false, free_slots: Some(total_slots as usize), headroom, + ..Default::default() } } @@ -1492,14 +1797,21 @@ fn pick_overflow_peer(ensemble: &Ensemble, nest_kind: &str) -> Option { let peers = ensemble.peers(); let mut fallback: Option = None; for id in peers { - let Some(caps) = ensemble.peer_caps(&id) else { continue }; + let Some(caps) = ensemble.peer_caps(&id) else { + continue; + }; let has_nest = caps.nests.iter().any(|n| n == nest_kind); - if !has_nest { continue; } + if !has_nest { + continue; + } match caps.free_slots { Some(n) if n > 0 => return Some(id), None => return Some(id), - Some(0) => { /* peer is full, skip but remember as fallback */ - if fallback.is_none() { fallback = Some(id); } + Some(0) => { + /* peer is full, skip but remember as fallback */ + if fallback.is_none() { + fallback = Some(id); + } } _ => {} } diff --git a/humd/src/revoked.rs b/humd/src/revoked.rs new file mode 100644 index 0000000..a9db1ce --- /dev/null +++ b/humd/src/revoked.rs @@ -0,0 +1,91 @@ +//! Revoked bee identities, as consulted at hello time. +//! +//! A bee's hid is the hash of its public key. That gives pseudonymity and +//! no accountability — there is no authority behind a name, and nothing +//! to withdraw when one behaves badly. This is the local half of that: +//! an operator lists a hid and the socket refuses it. +//! +//! Local, not global. It binds this daemon, not the mesh. A bee refused +//! here can still reach a different humd. Closing that gap needs the +//! on-chain registry to carry revocations, which is a contract change +//! rather than a code one — see `ensemble/src/onchain.rs`. +//! +//! What revocation does bind to is the identity binding from the socket +//! handshake. A revoked hid is refused whether or not the bee proved the +//! hid, so dropping its signature to slip past the check does not help. +//! +//! The on-disk format lives in [`hum_paths::Revocations`] so `humctl` can +//! edit the list without depending on the daemon. This wrapper is the +//! daemon-side handle. + +use std::path::Path; +use std::sync::Arc; + +use parking_lot::RwLock; + +/// The set of withdrawn hids, read once at boot. +/// +/// Cloning is cheap. The set is deliberately *not* re-read per hello: +/// that would put disk on the admission path, and admission is the one +/// path that must not stall. A revocation therefore takes effect on the +/// next daemon start, which `humctl revoke` says out loud. +#[derive(Clone, Default)] +pub struct Revocations { + inner: Arc>, +} + +impl Revocations { + pub fn load(path: &Path) -> Self { + Self { + inner: Arc::new(RwLock::new(hum_paths::Revocations::read_from(path))), + } + } + + /// Whether `hid_hex` has been withdrawn. + pub fn is_revoked(&self, hid_hex: &str) -> bool { + self.inner.read().contains(hid_hex) + } + + pub fn len(&self) -> usize { + self.inner.read().revoked.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + #[cfg(test)] + pub(crate) fn set_for_test(&self, list: Vec) { + let mut guard = self.inner.write(); + *guard = hum_paths::Revocations { + version: 1, + revoked: list, + }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_loaded_hid_is_revoked() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("revoked.json"); + hum_paths::Revocations { + version: 1, + revoked: vec!["wbee_dead".into()], + } + .write_to(&path) + .unwrap(); + let rev = Revocations::load(&path); + assert!(rev.is_revoked("wbee_dead")); + assert!(!rev.is_revoked("wbee_other")); + } + + #[test] + fn an_absent_file_revokes_nothing() { + let tmp = tempfile::TempDir::new().unwrap(); + assert!(Revocations::load(&tmp.path().join("nope.json")).is_empty()); + } +} diff --git a/humd/src/thrumd.rs b/humd/src/thrumd.rs index 328f796..a90c0c8 100644 --- a/humd/src/thrumd.rs +++ b/humd/src/thrumd.rs @@ -16,10 +16,10 @@ use std::sync::Arc; use anyhow::{Context, Result}; use async_trait::async_trait; +use governor::{Quota, RateLimiter}; use parking_lot::RwLock; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use thrum_core::THRUM_VERSION; -use governor::{Quota, RateLimiter}; use tokio::net::UnixListener; use tracing::{info, trace, warn}; @@ -110,6 +110,10 @@ impl Thrum { /// every unregistered client (no sigils claimed) if nobody owns it. /// `nest` selects which nest-kind namespace to compute the sigil /// under (e.g. "claude-cli", "claude-repl"). + /// + /// A client that declared its `chis` in a hello only receives tones + /// whose chi it declared. One that declared nothing receives + /// everything — see [`registry::Reach::declares_chi`]. pub fn thrum_broadcast(&self, sid: &str, nest: &str, mut tone: Tone) { let sigil = thrum_core::sigil(sid, nest); if let Some(obj) = tone.as_object_mut() { @@ -117,17 +121,18 @@ impl Thrum { obj.entry("sigil").or_insert(json!(sigil)); obj.insert("from".into(), json!("daemon")); } + let chi = chi_of(&tone).unwrap_or("").to_string(); let reg = self.inner.clients.read(); let mut sent = false; for reach in reg.iter() { - if reach.has_sigil(&sigil) { + if reach.has_sigil(&sigil) && reach.declares_chi(&chi) { let _ = reach.send(tone.clone()); sent = true; } } if !sent { for reach in reg.iter() { - if reach.sigil_count() == 0 { + if reach.sigil_count() == 0 && reach.declares_chi(&chi) { let _ = reach.send(tone.clone()); } } @@ -140,11 +145,16 @@ impl Thrum { ); } - /// Send to every connected client, regardless of sigils. + /// Send to every connected client, regardless of sigils. Still respects + /// declared chi scope — a client that declared its chis does not + /// receive everything merely because sigils were bypassed. pub fn thrum_all(&self, tone: Tone) { + let chi = chi_of(&tone).unwrap_or("").to_string(); let reg = self.inner.clients.read(); for reach in reg.iter() { - let _ = reach.send(tone.clone()); + if reach.declares_chi(&chi) { + let _ = reach.send(tone.clone()); + } } } @@ -157,6 +167,16 @@ impl Thrum { } } + /// Record the chi values a client declared in its hello. Broadcasts + /// then reach only the clients whose declared set covers the tone's + /// chi. A client that declared none still receives everything. + pub fn claim_chis(&self, client_id: &str, chis: Vec) { + let reg = self.inner.clients.read(); + if let Some(reach) = reg.get(client_id) { + reach.set_chis(chis); + } + } + pub fn has_client(&self, client_id: &str) -> bool { self.inner.clients.read().get(client_id).is_some() } @@ -169,7 +189,10 @@ impl Thrum { /// drains outbound tones bound for this client; `client_id` becomes /// addressable via [`thrum_to`] and [`thrum_broadcast`]. Used by /// sim to fake a connected nestler without a socket. - pub fn register_synthetic(&self, client_id: impl Into) -> tokio::sync::mpsc::Receiver { + pub fn register_synthetic( + &self, + client_id: impl Into, + ) -> tokio::sync::mpsc::Receiver { let cid: String = client_id.into(); let (reach, rx) = registry::Reach::new(cid); let mut reg = self.inner.clients.write(); @@ -182,7 +205,6 @@ impl Thrum { self.inner.clients.write().remove(client_id); } - /// Drop a client from the registry. Any tones already queued for it /// still drain; once the queue empties the socket handler sees the /// channel close and the connection ends. Used to refuse a bee that @@ -219,7 +241,9 @@ pub async fn serve(thrum: Thrum, path: impl AsRef) -> Result<()> { /// before the accept loop starts. humd uses this to publish its /// `runtime.json` rendezvous file. pub async fn serve_with_hook(thrum: Thrum, path: impl AsRef, on_bound: F) -> Result<()> -where F: FnOnce(&Path) + Send + 'static { +where + F: FnOnce(&Path) + Send + 'static, +{ let path = path.as_ref(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) @@ -228,8 +252,8 @@ where F: FnOnce(&Path) + Send + 'static { if path.exists() { std::fs::remove_file(path).with_context(|| format!("remove stale socket {:?}", path))?; } - let listener = UnixListener::bind(path) - .with_context(|| format!("bind unix socket {:?}", path))?; + let listener = + UnixListener::bind(path).with_context(|| format!("bind unix socket {:?}", path))?; info!(path = %path.display(), version = %THRUM_VERSION, "thrum.listening"); on_bound(path); @@ -320,7 +344,6 @@ pub fn echo_tone(rid: &str, ok: bool, error: Option<&str>) -> Tone { v } - /// How a bee's declared `protoVersion` compares to the one humd speaks. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProtoVerdict { diff --git a/humd/src/thrumd/registry.rs b/humd/src/thrumd/registry.rs index 822f075..c31b968 100644 --- a/humd/src/thrumd/registry.rs +++ b/humd/src/thrumd/registry.rs @@ -19,6 +19,11 @@ pub struct Reach { pub client_id: ClientId, tx: mpsc::Sender, sigils: Mutex>, + /// Chi values this client declared in its hello. Empty means it + /// declared none, which is the same as "unspecified" — such a client + /// receives everything, because every bee predating this field has + /// an empty set and refusing it traffic would be a wire break. + chis: Mutex>, } impl Reach { @@ -29,6 +34,7 @@ impl Reach { client_id, tx, sigils: Mutex::new(HashSet::new()), + chis: Mutex::new(HashSet::new()), }, rx, ) @@ -51,6 +57,23 @@ impl Reach { pub fn sigil_count(&self) -> usize { self.sigils.lock().len() } + + /// Record the chi values a client declared in its hello. Replaces any + /// previous set so a re-hello that narrows scope actually narrows it. + pub fn set_chis(&self, chis: Vec) { + *self.chis.lock() = chis.into_iter().collect(); + } + + /// Whether this client should be handed a tone for `chi`. + /// + /// A client that declared nothing receives everything — that is the + /// backward-compatible answer, and it is the only one available to + /// every bee written before this field existed. A client that declared + /// a list receives only those. + pub fn declares_chi(&self, chi: &str) -> bool { + let chis = self.chis.lock(); + chis.is_empty() || chis.contains(chi) + } } pub(crate) struct Registry { @@ -59,7 +82,9 @@ pub(crate) struct Registry { impl Registry { pub(crate) fn new() -> Self { - Self { by_id: HashMap::new() } + Self { + by_id: HashMap::new(), + } } pub(crate) fn insert(&mut self, reach: std::sync::Arc) { @@ -82,3 +107,49 @@ impl Registry { self.by_id.len() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_client_that_declared_nothing_receives_everything() { + let (reach, _rx) = Reach::new("unspecified".into()); + assert!( + reach.declares_chi("prompt"), + "no declared set = no filtering" + ); + assert!(reach.declares_chi("tool-call")); + } + + #[test] + fn a_client_that_declared_a_list_receives_only_those() { + let (reach, _rx) = Reach::new("scoped".into()); + reach.set_chis(vec!["prompt".into(), "chunk".into()]); + assert!(reach.declares_chi("prompt")); + assert!(reach.declares_chi("chunk")); + assert!( + !reach.declares_chi("tool-call"), + "an undeclared chi must not reach a scoped client" + ); + } + + #[test] + fn declaring_an_empty_list_is_not_scoped() { + let (reach, _rx) = Reach::new("empty".into()); + reach.set_chis(Vec::new()); + assert!(reach.declares_chi("prompt")); + } + + #[test] + fn re_declaring_replaces_the_previous_scope() { + let (reach, _rx) = Reach::new("rehello".into()); + reach.set_chis(vec!["prompt".into(), "chunk".into()]); + reach.set_chis(vec!["prompt".into()]); + assert!(reach.declares_chi("prompt")); + assert!( + !reach.declares_chi("chunk"), + "the earlier, wider scope must not survive a re-hello" + ); + } +} diff --git a/scenarios/README.md b/scenarios/README.md index 0c28e28..46f64da 100644 --- a/scenarios/README.md +++ b/scenarios/README.md @@ -31,11 +31,11 @@ Pairing is the rule, not yet the state of the directory: | `eggs-on-the-hum.md` | `sim/tests/eggs_on_the_hum.rs` | | `find-the-worker.md` | `sim/tests/remote_discovery.rs` | -Tests with no scenario prose yet: `delivery.rs`, `liveness.rs`, -`lossy_link.rs`, `mock_prompt.rs`, `overflow_inference.rs`, -`phone_laptop_roam.rs`, `smoke.rs`, `stalled_peer.rs`, -`tool_catalogue.rs`, `two_humds_ping_pong.rs`. `wifi-p2p-meetup.md` -has prose with no test. +Tests with no scenario prose yet: `admission_gate.rs`, `chi_scoping.rs`, +`delivery.rs`, `liveness.rs`, `lossy_link.rs`, `mock_prompt.rs`, +`remote_routing_trust.rs`, `revocation.rs`, `smoke.rs`, +`socket_identity.rs`, `stalled_peer.rs`, `tool_catalogue.rs`, +`two_humds_ping_pong.rs`. `wifi-p2p-meetup.md` has prose with no test. Each MD covers five sections in the same order: **setup**, **happy path**, **failure modes**, **success criteria**, **what this validates**. diff --git a/sim/Cargo.toml b/sim/Cargo.toml index 28d8481..b302a55 100644 --- a/sim/Cargo.toml +++ b/sim/Cargo.toml @@ -21,3 +21,6 @@ parking_lot = "0.12" tokio = { workspace = true, features = ["test-util"] } tracing-subscriber = { workspace = true } hum-mcp = { path = "../hum-mcp" } +ed25519-dalek = { version = "2", features = ["rand_core"] } +rand = { workspace = true } +hex = { workspace = true } diff --git a/sim/src/lib.rs b/sim/src/lib.rs index 64bc9a7..6b09e35 100644 --- a/sim/src/lib.rs +++ b/sim/src/lib.rs @@ -1,19 +1,18 @@ - use std::collections::HashMap; use std::path::PathBuf; -use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use ensemble::{ - hello_tone, Ensemble, Hid, HumdKey, InMemoryEndpoint, LinkCounters, LinkFaults, - PeerCapabilities, + Ensemble, Hid, HumdKey, InMemoryEndpoint, LinkCounters, LinkFaults, PeerCapabilities, + hello_tone, }; +use humd::thrumd::Thrum; use parking_lot::{Mutex, RwLock}; use serde_json::Value; use thrum_core::WaneTracker; -use humd::thrumd::Thrum; use tokio::sync::{mpsc, oneshot}; const CAPACITY_UNLIMITED: usize = usize::MAX; @@ -67,7 +66,11 @@ struct Link { } fn link_key(x: Hid, y: Hid) -> (Hid, Hid) { - if x.to_hex() <= y.to_hex() { (x, y) } else { (y, x) } + if x.to_hex() <= y.to_hex() { + (x, y) + } else { + (y, x) + } } impl Default for Sim { @@ -103,7 +106,25 @@ impl Sim { self.spawn_humd_inner(id, false).await } + /// A humd whose revocation list already contains these hids. The list + /// is written before the daemon reads it, matching how an operator + /// would have populated it on a previous run. + pub async fn spawn_humd_revoking(&self, id: Hid, revoked: &[&str]) -> Arc { + self.spawn_humd_inner_with_revocations(id, true, revoked) + .await + } + async fn spawn_humd_inner(&self, id: Hid, trust_remote_workers: bool) -> Arc { + self.spawn_humd_inner_with_revocations(id, trust_remote_workers, &[]) + .await + } + + async fn spawn_humd_inner_with_revocations( + &self, + id: Hid, + trust_remote_workers: bool, + revoked: &[&str], + ) -> Arc { let thrum = Thrum::new(); let ensemble = Arc::new(Ensemble::with_strict_auth(id, false)); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); @@ -111,6 +132,17 @@ impl Sim { let tmp = std::env::temp_dir().join(format!("sim-humd-{}", id.short())); let _ = std::fs::create_dir_all(&tmp); let penny_path = tmp.join(hum_paths::PENNY_BASENAME); + let revoked_path = tmp.join(hum_paths::REVOKED_BEES_BASENAME); + let _ = std::fs::remove_file(&revoked_path); + if !revoked.is_empty() { + let list: Vec = revoked.iter().map(|s| s.to_string()).collect(); + hum_paths::Revocations { + version: 1, + revoked: list, + } + .write_to(&revoked_path) + .expect("write revocation list"); + } let initial_capacity = self .pending_capacities @@ -132,6 +164,7 @@ impl Sim { penny_persist_interval: Duration::from_secs(3600), thrum_override: Some(thrum.clone()), ensemble: Some(ensemble.clone()), + revoked_path: revoked_path.clone(), bind_mcp: false, capacity, waneman: Some(waneman.clone()), @@ -162,7 +195,7 @@ impl Sim { self.humds.write().insert(id, sim_humd.clone()); sim_humd - } + } pub async fn await_ready(&self, humd: Hid) -> Result<()> { for _ in 0..200 { @@ -201,12 +234,8 @@ impl Sim { let a_caps = sim_caps(&ha); let b_caps = sim_caps(&hb); - let (a_view, b_view) = InMemoryEndpoint::pair_concrete( - ha.id, - b_caps.clone(), - hb.id, - a_caps.clone(), - ); + let (a_view, b_view) = + InMemoryEndpoint::pair_concrete(ha.id, b_caps.clone(), hb.id, a_caps.clone()); let key = link_key(ha.id, hb.id); self.links.write().insert( key, @@ -254,6 +283,7 @@ impl Sim { penny_persist_interval: Duration::from_secs(3600), thrum_override: Some(thrum.clone()), ensemble: Some(ensemble.clone()), + revoked_path: tmp.join(hum_paths::REVOKED_BEES_BASENAME), bind_mcp: false, capacity, waneman: Some(waneman.clone()), @@ -263,7 +293,9 @@ impl Sim { trust_remote_workers: true, }; - let shutdown_fut = async move { let _ = shutdown_rx.await; }; + let shutdown_fut = async move { + let _ = shutdown_rx.await; + }; let join = tokio::spawn(humd::run(cfg, shutdown_fut)); let sim_humd = Arc::new(SimHumd { @@ -286,26 +318,31 @@ impl Sim { pub fn wire_signed(&self, a: Hid, b: Hid) -> Result<()> { let humds = self.humds.read(); - let ha = humds.get(&a).cloned() + let ha = humds + .get(&a) + .cloned() .ok_or_else(|| anyhow::anyhow!("no humd {}", a.short()))?; - let hb = humds.get(&b).cloned() + let hb = humds + .get(&b) + .cloned() .ok_or_else(|| anyhow::anyhow!("no humd {}", b.short()))?; drop(humds); - let a_key = ha.key.clone().ok_or_else(|| anyhow::anyhow!( - "humd {} has no signing key — spawn via spawn_humd_with_identity", - a.short() - ))?; - let b_key = hb.key.clone().ok_or_else(|| anyhow::anyhow!( - "humd {} has no signing key — spawn via spawn_humd_with_identity", - b.short() - ))?; + let a_key = ha.key.clone().ok_or_else(|| { + anyhow::anyhow!( + "humd {} has no signing key — spawn via spawn_humd_with_identity", + a.short() + ) + })?; + let b_key = hb.key.clone().ok_or_else(|| { + anyhow::anyhow!( + "humd {} has no signing key — spawn via spawn_humd_with_identity", + b.short() + ) + })?; let a_caps = sim_caps(&ha); let b_caps = sim_caps(&hb); - let (a_view, b_view) = InMemoryEndpoint::pair( - ha.id, b_caps.clone(), - hb.id, a_caps.clone(), - ); + let (a_view, b_view) = InMemoryEndpoint::pair(ha.id, b_caps.clone(), hb.id, a_caps.clone()); ha.ensemble.install(a_view, a_caps, &a_key); hb.ensemble.install(b_view, b_caps, &b_key); Ok(()) @@ -313,21 +350,23 @@ impl Sim { pub fn wire_signed_tampered(&self, a: Hid, c: Hid) -> Result<()> { let humds = self.humds.read(); - let ha = humds.get(&a).cloned() + let ha = humds + .get(&a) + .cloned() .ok_or_else(|| anyhow::anyhow!("no humd {}", a.short()))?; - let hc = humds.get(&c).cloned() + let hc = humds + .get(&c) + .cloned() .ok_or_else(|| anyhow::anyhow!("no humd {}", c.short()))?; drop(humds); - let a_key = ha.key.clone().ok_or_else(|| anyhow::anyhow!( - "humd {} has no signing key", a.short() - ))?; + let a_key = ha + .key + .clone() + .ok_or_else(|| anyhow::anyhow!("humd {} has no signing key", a.short()))?; let a_caps = sim_caps(&ha); let c_caps = sim_caps(&hc); - let (a_view, c_view) = InMemoryEndpoint::pair( - ha.id, c_caps.clone(), - hc.id, a_caps.clone(), - ); + let (a_view, c_view) = InMemoryEndpoint::pair(ha.id, c_caps.clone(), hc.id, a_caps.clone()); ha.ensemble.install(a_view, a_caps, &a_key); @@ -387,12 +426,7 @@ impl Sim { }) } - pub fn link_counters_since( - &self, - a: Hid, - b: Hid, - base: &LinkCounters, - ) -> Result { + pub fn link_counters_since(&self, a: Hid, b: Hid, base: &LinkCounters) -> Result { let (ab, _) = self.link_counters(a, b)?; Ok(ab.since(base)) } @@ -451,7 +485,11 @@ impl Sim { .collect()) } - pub fn evict_expired(&self, observer: Hid, ttl: std::time::Duration) -> Result> { + pub fn evict_expired( + &self, + observer: Hid, + ttl: std::time::Duration, + ) -> Result> { let ens = self .humds .read() @@ -462,7 +500,11 @@ impl Sim { } pub fn peer_count(&self, observer: Hid) -> usize { - self.humds.read().get(&observer).map(|h| h.ensemble.peers().len()).unwrap_or(0) + self.humds + .read() + .get(&observer) + .map(|h| h.ensemble.peers().len()) + .unwrap_or(0) } pub fn partition(&self, a: Hid, b: Hid) -> Result<()> { @@ -529,7 +571,9 @@ impl Sim { .cloned() .ok_or_else(|| anyhow::anyhow!("no humd {}", humd.short()))?; for _ in 0..200 { - if h.thrum.has_sink() { break; } + if h.thrum.has_sink() { + break; + } tokio::time::sleep(Duration::from_millis(5)).await; } let client_id = hum_identity::HumId::mint().to_string(); @@ -548,10 +592,22 @@ impl Sim { let cid_for_pump = client_id.clone(); tokio::spawn(async move { while let Some(tone) = rx.recv().await { - let chi = tone.get("chi").and_then(|v| v.as_str()).unwrap_or("").to_string(); - if chi != "prompt" { continue; } - let sid = tone.get("sid").and_then(|v| v.as_str()).unwrap_or("").to_string(); - if sid.is_empty() { continue; } + let chi = tone + .get("chi") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if chi != "prompt" { + continue; + } + let sid = tone + .get("sid") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if sid.is_empty() { + continue; + } let frames = vec![ serde_json::json!({"chi":"chunk","sid":&sid,"chunkType":"text_start","id":0}), serde_json::json!({"chi":"chunk","sid":&sid,"chunkType":"text_delta","delta":"HELLO"}), @@ -584,7 +640,9 @@ impl Sim { .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - if sid.is_empty() { continue; } + if sid.is_empty() { + continue; + } let tx = { let mut senders = h_for_pump.sid_senders.lock(); if let Some(tx) = senders.get(&sid) { @@ -608,12 +666,7 @@ impl Sim { Ok(client_id) } - pub async fn nestler_recv( - &self, - humd: Hid, - sid: &str, - timeout: Duration, - ) -> Option { + pub async fn nestler_recv(&self, humd: Hid, sid: &str, timeout: Duration) -> Option { let h = self.humds.read().get(&humd).cloned()?; { @@ -627,33 +680,36 @@ impl Sim { } { - let mut queues = h.out_queues.lock(); - for (_cid, rx) in queues.iter_mut() { - while let Ok(tone) = rx.try_recv() { - let tone_sid = tone - .get("sid") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - if tone_sid.is_empty() { - continue; - } - let senders = h.sid_senders.lock(); - if let Some(tx) = senders.get(&tone_sid) { - let _ = tx.send(tone); - } else { - drop(senders); - let (tx, rx2) = mpsc::unbounded_channel::(); - let _ = tx.send(tone); - h.sid_senders.lock().insert(tone_sid.clone(), tx); - h.sid_mailboxes.lock().insert(tone_sid, rx2); + let mut queues = h.out_queues.lock(); + for (_cid, rx) in queues.iter_mut() { + while let Ok(tone) = rx.try_recv() { + let tone_sid = tone + .get("sid") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if tone_sid.is_empty() { + continue; + } + let senders = h.sid_senders.lock(); + if let Some(tx) = senders.get(&tone_sid) { + let _ = tx.send(tone); + } else { + drop(senders); + let (tx, rx2) = mpsc::unbounded_channel::(); + let _ = tx.send(tone); + h.sid_senders.lock().insert(tone_sid.clone(), tx); + h.sid_mailboxes.lock().insert(tone_sid, rx2); + } } } } - } let mut rx_opt = h.sid_mailboxes.lock().remove(sid)?; - let result = tokio::time::timeout(timeout, rx_opt.recv()).await.ok().flatten(); + let result = tokio::time::timeout(timeout, rx_opt.recv()) + .await + .ok() + .flatten(); h.sid_mailboxes.lock().insert(sid.to_string(), rx_opt); result } @@ -675,16 +731,23 @@ impl Sim { .cloned() .ok_or_else(|| anyhow::anyhow!("no humd {}", humd.short()))?; for _ in 0..200 { - if h.thrum.has_sink() { break; } + if h.thrum.has_sink() { + break; + } tokio::time::sleep(Duration::from_millis(5)).await; } let client_id = hum_identity::HumId::mint().to_string(); let mut rx = h.thrum.register_synthetic(client_id.clone()); - let tools: Vec = tool_names.iter().map(|name| serde_json::json!({ - "name": name, - "description": format!("mock {name}"), - "inputSchema": { "type": "object", "properties": {}, "required": [] } - })).collect(); + let tools: Vec = tool_names + .iter() + .map(|name| { + serde_json::json!({ + "name": name, + "description": format!("mock {name}"), + "inputSchema": { "type": "object", "properties": {}, "required": [] } + }) + }) + .collect(); let hello = serde_json::json!({ "chi": "hello", "bee": ["forager"], @@ -700,13 +763,30 @@ impl Sim { let responder = std::sync::Arc::new(responder); tokio::spawn(async move { while let Some(tone) = rx.recv().await { - let chi = tone.get("chi").and_then(|v| v.as_str()).unwrap_or("").to_string(); - if chi != "tool-call" { continue; } - let sid = tone.get("sid").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let call_id = tone.get("callId").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let tool_name = tone.get("toolName").and_then(|v| v.as_str()) + let chi = tone + .get("chi") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if chi != "tool-call" { + continue; + } + let sid = tone + .get("sid") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let call_id = tone + .get("callId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let tool_name = tone + .get("toolName") + .and_then(|v| v.as_str()) .or_else(|| tone.get("name").and_then(|v| v.as_str())) - .unwrap_or("").to_string(); + .unwrap_or("") + .to_string(); let args = tone.get("args").cloned().unwrap_or(Value::Null); let result = responder(&tool_name, args); let reply = serde_json::json!({ @@ -722,12 +802,7 @@ impl Sim { Ok(client_id) } - pub fn attach_observer( - &self, - observer_humd: Hid, - host_humd: Hid, - sid: &str, - ) -> Result { + pub fn attach_observer(&self, observer_humd: Hid, host_humd: Hid, sid: &str) -> Result { self.nestler_send( observer_humd, serde_json::json!({ @@ -815,12 +890,7 @@ impl Sim { Ok(cid) } - pub async fn publish( - &self, - from: Hid, - topic: &str, - payload: serde_json::Value, - ) -> Result<()> { + pub async fn publish(&self, from: Hid, topic: &str, payload: serde_json::Value) -> Result<()> { let ens = self .humds .read() @@ -844,7 +914,9 @@ impl Sim { .get(&from) .cloned() .ok_or_else(|| anyhow::anyhow!("no humd {}", from.short()))?; - ens.ensemble.publish_with_dusk(topic, payload, Some(dusk_ms)).await; + ens.ensemble + .publish_with_dusk(topic, payload, Some(dusk_ms)) + .await; Ok(()) } @@ -863,7 +935,10 @@ impl Sim { } pub fn expired_dusk(&self, humd: Hid) -> u64 { - self.humds.read().get(&humd).map_or(0, |h| h.ensemble.expired_dusk()) + self.humds + .read() + .get(&humd) + .map_or(0, |h| h.ensemble.expired_dusk()) } pub async fn send_marks(&self, from: Hid, to: Hid, tag: &str, n: usize) -> Result<()> { @@ -881,11 +956,18 @@ impl Sim { } pub fn rids(tones: &[Value]) -> Vec { - tones.iter().filter_map(|t| t["rid"].as_str().map(String::from)).collect() + tones + .iter() + .filter_map(|t| t["rid"].as_str().map(String::from)) + .collect() } pub fn ensemble_dropped(&self, humd: Hid) -> u64 { - self.humds.read().get(&humd).map(|h| h.ensemble.inbox_dropped()).unwrap_or(0) + self.humds + .read() + .get(&humd) + .map(|h| h.ensemble.inbox_dropped()) + .unwrap_or(0) } pub async fn shutdown(self) { diff --git a/sim/tests/chi_scoping.rs b/sim/tests/chi_scoping.rs new file mode 100644 index 0000000..1db304d --- /dev/null +++ b/sim/tests/chi_scoping.rs @@ -0,0 +1,127 @@ +//! Chi-scoped delivery. +//! +//! A bee that declares `chis` in its hello gets only those from a +//! broadcast. One that declares nothing still gets everything, because +//! every bee written before this field existed sends an empty set. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::Value; + +use sim::{Sim, SimHumd}; + +fn base_hello(chis: Option<&[&str]>) -> Value { + let mut hello = serde_json::json!({ + "chi": "hello", + "bee": ["worker"], + "hive": "claude-cli", + "version": "0.1.0", + "protoVersion": thrum_core::THRUM_VERSION, + }); + if let Some(list) = chis { + hello["chis"] = serde_json::json!(list); + } + hello +} + +/// A reply arriving from a peer humd, which humd fans out to the nestlers +/// attached to that sid. +fn peer_reply(chi: &str, sid: &str) -> Value { + serde_json::json!({ + "chi": chi, + "from": "ensemble", + "sid": sid, + "text": "hello from the other side", + }) +} + +/// Drain everything currently queued on a synthetic client's channel. +fn drain(rx: &mut tokio::sync::mpsc::Receiver) -> Vec { + let mut out = Vec::new(); + while let Ok(tone) = rx.try_recv() { + out.push(tone); + } + out +} + +fn chis_of(tones: &[Value]) -> Vec<&str> { + tones + .iter() + .filter_map(|t| t.get("chi").and_then(Value::as_str)) + .collect() +} + +async fn spawn() -> (Sim, Arc) { + let _ = tracing_subscriber::fmt::try_init(); + let sim = Sim::new(); + let humd = sim.spawn_humd(ensemble::Hid::random_humd()).await; + sim.await_ready(humd.id).await.expect("humd ready"); + (sim, humd) +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_scoped_client_does_not_receive_an_undeclared_chi() { + let (_sim, humd) = spawn().await; + + let cid = hum_identity::HumId::mint().to_string(); + let mut rx = humd.thrum.register_synthetic(cid.clone()); + humd.thrum + .inject_tone(&cid, base_hello(Some(&["prompt"]))) + .await; + humd.thrum + .inject_tone("ensemble", peer_reply("chunk", "sess-1")) + .await; + tokio::time::sleep(Duration::from_millis(150)).await; + + let got = drain(&mut rx); + assert!( + !chis_of(&got).contains(&"chunk"), + "a client that declared only 'prompt' must not receive a chunk, got {:?}", + chis_of(&got) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_scoped_client_receives_a_declared_chi() { + let (_sim, humd) = spawn().await; + + let cid = hum_identity::HumId::mint().to_string(); + let mut rx = humd.thrum.register_synthetic(cid.clone()); + humd.thrum + .inject_tone(&cid, base_hello(Some(&["prompt", "chunk"]))) + .await; + + humd.thrum + .inject_tone("ensemble", peer_reply("chunk", "sess-1")) + .await; + tokio::time::sleep(Duration::from_millis(150)).await; + + let got = drain(&mut rx); + assert!( + chis_of(&got).contains(&"chunk"), + "a client that declared 'chunk' must receive it, got {:?}", + chis_of(&got) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_client_that_declared_nothing_still_receives_everything() { + let (_sim, humd) = spawn().await; + + let cid = hum_identity::HumId::mint().to_string(); + let mut rx = humd.thrum.register_synthetic(cid.clone()); + humd.thrum.inject_tone(&cid, base_hello(None)).await; + + humd.thrum + .inject_tone("ensemble", peer_reply("chunk", "sess-1")) + .await; + tokio::time::sleep(Duration::from_millis(150)).await; + + let got = drain(&mut rx); + assert!( + chis_of(&got).contains(&"chunk"), + "a client that declared no chis must receive a chunk, got {:?}", + chis_of(&got) + ); +} diff --git a/sim/tests/revocation.rs b/sim/tests/revocation.rs new file mode 100644 index 0000000..093effa --- /dev/null +++ b/sim/tests/revocation.rs @@ -0,0 +1,116 @@ +//! Revocation at the socket. +//! +//! A revoked hid is refused the socket, not merely hidden from discovery. +//! The interesting case is the one where revocation would be easy to +//! evade: a bee that drops its signature to slip past the check. + +use std::time::Duration; + +use ed25519_dalek::SigningKey; +use hum_identity::{BeeKey, Hid, HidPrefix}; + +fn fresh_bee() -> BeeKey { + let signing = SigningKey::generate(&mut rand::thread_rng()); + let hid = Hid::from_pubkey(HidPrefix::Wbee, &signing.verifying_key().to_bytes()); + BeeKey { signing, hid } +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn hello_with_identity(bee: &BeeKey) -> serde_json::Value { + let (pubkey, signed_at, signature) = bee.sign_socket_hello(now_ms()); + serde_json::json!({ + "chi": "hello", + "bee": ["worker"], + "hive": "claude-cli", + "version": "0.1.0", + "protoVersion": thrum_core::THRUM_VERSION, + "chis": ["hello", "prompt"], + "hid": bee.hid.to_hex(), + "pubkey": pubkey, + "signedAt": signed_at, + "signature": signature, + }) +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_revoked_bee_is_refused_the_socket() { + let _ = tracing_subscriber::fmt::try_init(); + let sim = sim::Sim::new(); + + let bee = fresh_bee(); + let id = ensemble::Hid::random_humd(); + let humd = sim.spawn_humd_revoking(id, &[&bee.hid.to_hex()]).await; + sim.await_ready(humd.id).await.expect("humd ready"); + + let cid = hum_identity::HumId::mint().to_string(); + let _rx = humd.thrum.register_synthetic(cid.clone()); + humd.thrum + .inject_tone(&cid, hello_with_identity(&bee)) + .await; + + tokio::time::sleep(Duration::from_millis(150)).await; + assert!( + !humd.thrum.is_connected(&cid), + "a correctly signed hello from a revoked bee must still be refused" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn dropping_the_signature_does_not_evade_revocation() { + let _ = tracing_subscriber::fmt::try_init(); + let sim = sim::Sim::new(); + + let bee = fresh_bee(); + let id = ensemble::Hid::random_humd(); + let humd = sim.spawn_humd_revoking(id, &[&bee.hid.to_hex()]).await; + sim.await_ready(humd.id).await.expect("humd ready"); + + let cid = hum_identity::HumId::mint().to_string(); + let _rx = humd.thrum.register_synthetic(cid.clone()); + + let unsigned = serde_json::json!({ + "chi": "hello", + "bee": ["worker"], + "hive": "claude-cli", + "version": "0.1.0", + "protoVersion": thrum_core::THRUM_VERSION, + "hid": bee.hid.to_hex(), + }); + humd.thrum.inject_tone(&cid, unsigned).await; + + tokio::time::sleep(Duration::from_millis(150)).await; + assert!( + !humd.thrum.is_connected(&cid), + "an unsigned hello claiming a revoked hid must be refused too" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_unrevoked_bee_is_unaffected() { + let _ = tracing_subscriber::fmt::try_init(); + let sim = sim::Sim::new(); + + let bee = fresh_bee(); + let other = fresh_bee(); + let id = ensemble::Hid::random_humd(); + let humd = sim.spawn_humd_revoking(id, &[&other.hid.to_hex()]).await; + sim.await_ready(humd.id).await.expect("humd ready"); + + let cid = hum_identity::HumId::mint().to_string(); + let _rx = humd.thrum.register_synthetic(cid.clone()); + humd.thrum + .inject_tone(&cid, hello_with_identity(&bee)) + .await; + + tokio::time::sleep(Duration::from_millis(150)).await; + assert!( + humd.thrum.is_connected(&cid), + "a bee absent from the revocation list must be admitted" + ); +} diff --git a/sim/tests/socket_identity.rs b/sim/tests/socket_identity.rs new file mode 100644 index 0000000..86ca9ec --- /dev/null +++ b/sim/tests/socket_identity.rs @@ -0,0 +1,172 @@ +//! Socket hello identity binding. +//! +//! A bee declares `hid` as a string. When it also signs that hid, humd +//! binds the string to a key before trusting it. These tests drive the +//! Hello arm the same way the admission tests do — through the installed +//! sink — so they cover the decision humd actually makes, not a +//! re-implementation of it. + +use std::time::Duration; + +use ed25519_dalek::{Signer, SigningKey}; +use hum_identity::{BeeKey, Hid, HidPrefix}; + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn fresh_bee() -> BeeKey { + let signing = SigningKey::generate(&mut rand::thread_rng()); + let hid = Hid::from_pubkey(HidPrefix::Wbee, &signing.verifying_key().to_bytes()); + BeeKey { signing, hid } +} + +fn hello_with_identity(tone: &mut serde_json::Value, bee: &BeeKey) { + let (pubkey, signed_at, signature) = bee.sign_socket_hello(now_ms()); + let obj = tone.as_object_mut().expect("object"); + obj.insert("hid".into(), serde_json::json!(bee.hid.to_hex())); + obj.insert("pubkey".into(), serde_json::json!(pubkey)); + obj.insert("signedAt".into(), serde_json::json!(signed_at)); + obj.insert("signature".into(), serde_json::json!(signature)); +} + +fn base_hello() -> serde_json::Value { + serde_json::json!({ + "chi": "hello", + "bee": ["worker"], + "hive": "claude-cli", + "version": "0.1.0", + "protoVersion": thrum_core::THRUM_VERSION, + "chis": ["hello", "prompt"], + }) +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_bee_that_signs_its_hid_stays_connected() { + let _ = tracing_subscriber::fmt::try_init(); + let sim = sim::Sim::new(); + let humd = sim.spawn_humd(ensemble::Hid::random_humd()).await; + sim.await_ready(humd.id).await.expect("humd ready"); + + let cid = hum_identity::HumId::mint().to_string(); + let _rx = humd.thrum.register_synthetic(cid.clone()); + + let bee = fresh_bee(); + let mut hello = base_hello(); + hello_with_identity(&mut hello, &bee); + humd.thrum.inject_tone(&cid, hello).await; + + tokio::time::sleep(Duration::from_millis(120)).await; + assert!( + humd.thrum.is_connected(&cid), + "a correctly signed hello must be admitted" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_bee_with_no_signature_is_still_admitted() { + let _ = tracing_subscriber::fmt::try_init(); + let sim = sim::Sim::new(); + let humd = sim.spawn_humd(ensemble::Hid::random_humd()).await; + sim.await_ready(humd.id).await.expect("humd ready"); + + let cid = hum_identity::HumId::mint().to_string(); + let _rx = humd.thrum.register_synthetic(cid.clone()); + + let bee = fresh_bee(); + let mut hello = base_hello(); + hello["hid"] = serde_json::json!(bee.hid.to_hex()); + humd.thrum.inject_tone(&cid, hello).await; + + tokio::time::sleep(Duration::from_millis(120)).await; + assert!( + humd.thrum.is_connected(&cid), + "an unsigned hid must stay admitted" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_bee_with_a_forged_signature_is_disconnected() { + let _ = tracing_subscriber::fmt::try_init(); + let sim = sim::Sim::new(); + let humd = sim.spawn_humd(ensemble::Hid::random_humd()).await; + sim.await_ready(humd.id).await.expect("humd ready"); + + let cid = hum_identity::HumId::mint().to_string(); + let _rx = humd.thrum.register_synthetic(cid.clone()); + + let bee = fresh_bee(); + let mut hello = base_hello(); + hello_with_identity(&mut hello, &bee); + hello["signature"] = serde_json::json!(hex::encode([0u8; 64])); + + humd.thrum.inject_tone(&cid, hello).await; + + tokio::time::sleep(Duration::from_millis(120)).await; + assert!( + !humd.thrum.is_connected(&cid), + "a hello whose signature does not verify must not stay connected" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_bee_claiming_anothers_hid_is_disconnected() { + let _ = tracing_subscriber::fmt::try_init(); + let sim = sim::Sim::new(); + let humd = sim.spawn_humd(ensemble::Hid::random_humd()).await; + sim.await_ready(humd.id).await.expect("humd ready"); + + let cid = hum_identity::HumId::mint().to_string(); + let _rx = humd.thrum.register_synthetic(cid.clone()); + + let mallory = fresh_bee(); + let victim = fresh_bee(); + let signed_at = now_ms(); + let msg = hum_identity::socket_hello_message(&victim.hid, signed_at); + let sig = mallory.signing.sign(&msg); + + let mut hello = base_hello(); + hello["hid"] = serde_json::json!(victim.hid.to_hex()); + hello["pubkey"] = serde_json::json!(hex::encode(mallory.pubkey_bytes())); + hello["signedAt"] = serde_json::json!(signed_at); + hello["signature"] = serde_json::json!(hex::encode(sig.to_bytes())); + + humd.thrum.inject_tone(&cid, hello).await; + + tokio::time::sleep(Duration::from_millis(120)).await; + assert!( + !humd.thrum.is_connected(&cid), + "a valid signature over a hid the key does not own must be refused" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_replayed_hello_outside_the_skew_window_is_disconnected() { + let _ = tracing_subscriber::fmt::try_init(); + let sim = sim::Sim::new(); + let humd = sim.spawn_humd(ensemble::Hid::random_humd()).await; + sim.await_ready(humd.id).await.expect("humd ready"); + + let cid = hum_identity::HumId::mint().to_string(); + let _rx = humd.thrum.register_synthetic(cid.clone()); + + let bee = fresh_bee(); + let stale = now_ms() - 3_600_000; + let (pubkey, _signed_at, signature) = bee.sign_socket_hello(stale); + let mut hello = base_hello(); + hello["hid"] = serde_json::json!(bee.hid.to_hex()); + hello["pubkey"] = serde_json::json!(pubkey); + hello["signedAt"] = serde_json::json!(stale); + hello["signature"] = serde_json::json!(signature); + + humd.thrum.inject_tone(&cid, hello).await; + + tokio::time::sleep(Duration::from_millis(120)).await; + assert!( + !humd.thrum.is_connected(&cid), + "a hello recorded a hour ago must not be replayable now" + ); +}