diff --git a/AGENTS.md b/AGENTS.md index f9f2bfb..2156245 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,17 @@ discussion in the pull request: control story in full. - **Every call has a deadline.** It cannot be disabled. A call with no deadline reintroduces the hang that motivated the project. +- **A confidential message goes to a loaded, hash-verified module or to + nobody.** Only a module whose artifact the host hashed against its allowlist + before `dlopen` may receive one; a peer reached across a transport never can, + by design rather than by omission. The message is never fanned out to a + subscriber, never printed by `monitor`, and never carried by a signal. This is + admission control, not isolation — a loaded module is already inside the trust + boundary and could read host memory directly; what the rule buys is that the + bus will not be the delivery mechanism for unverified code. `confidential` is + the one header field the broker does not overwrite on ingress, because it can + only ever restrict the sender's own traffic. See + `docs/modules/attest/README.md`. - **A misbehaving peer must not affect another peer.** Bounded per-peer queues, best-effort signal delivery, and an accept loop that survives a bad client are all this invariant. Any change that lets one peer's slowness reach another's diff --git a/crates/tinybus/src/attest.rs b/crates/tinybus/src/attest.rs new file mode 100644 index 0000000..ccca75e --- /dev/null +++ b/crates/tinybus/src/attest.rs @@ -0,0 +1,133 @@ +//! Recipient attestation: what the broker verified before it will carry a secret. +//! +//! # What a confidential message is for +//! +//! Some payloads are the kind whose disclosure *is* the failure: a private key, +//! a recovery phrase, a bearer token. The recipient of one of those is a +//! **module loaded into the host's address space**, whose artifact the host +//! hashed against the operator's `modules.toml` before `dlopen` ever ran. A +//! secret handed to such a module never crosses a transport, never reaches a +//! separate process, and never touches a socket. +//! +//! Everything else on the bus — services in their own processes, CLI clients, +//! monitors — does not receive secrets. Not "receives them less safely": +//! a confidential message addressed to one is refused. That is the rule, not a +//! platform limitation, which is why there is no peer-identification machinery +//! here and no per-OS code to keep working. +//! +//! # What the check actually establishes +//! +//! An [`Attestation`] records that *this* well-known name is owned by a module +//! whose bytes hashed to *this* SHA-256, and that the operator listed that hash +//! as acceptable. The hash is computed by the host over bytes the host read +//! itself; nothing a module says about itself participates. +//! +//! # What it does not establish, and this matters +//! +//! An in-process module shares the host's address space. It can read host +//! memory directly, so a *malicious loaded module* is not contained by any +//! routing rule — it never needed the bus to reach a secret in the first place. +//! This is the invariant CLAUDE.md already states: in-process modules are +//! inside the trust boundary. +//! +//! What attestation buys is therefore **admission control**, not isolation: +//! only code whose hash an operator allowlisted is loaded at all, and only such +//! code is handed a secret through the bus. The bus's job is to refuse to be +//! the delivery mechanism for anything else. An integration whose compromise +//! must not reach the kernel's secrets belongs in a separate process — where it +//! is, by this design, ineligible to receive them. +//! +//! # Not a signature, yet +//! +//! `modules.toml` is a list of hashes an operator put on disk, so an +//! attestation means "this is the artifact the operator allowlisted", not "a +//! release key vouched for it". Signed release manifests are the natural next +//! layer: verification would produce this same [`Attestation`] and needs no +//! wire-format change. +//! +//! # Feature gating +//! +//! The type and the routing rule that consumes it are always compiled. A slim +//! `--no-default-features` broker still refuses confidential delivery to +//! everything, which is the correct answer for a build that cannot load a +//! module at all — and a silent downgrade is exactly what must not happen. + +use serde::{Deserialize, Serialize}; + +use crate::name::BusName; + +/// The host's own record of a recipient it verified. +/// +/// Held by the router against the peer, served by the bus's `GetAttestation`, +/// and checked on every confidential delivery. It carries no path: the hash and +/// the name it was verified for are enough to audit the decision, and a +/// filesystem layout is not something to publish on a bus. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Attestation { + /// The well-known name this artifact was verified *for*. + /// + /// Bound to the name rather than floating free, because "some allowlisted + /// artifact is loaded" is not the question a sender is asking. The question + /// is whether the code answering to `…Wallet` is what the operator + /// allowlisted for `…Wallet`. + pub name: BusName, + /// Lowercase hex SHA-256 of the artifact the host read at load time. + pub sha256: String, +} + +/// Whether `value` is exactly 64 hex digits. +#[cfg(feature = "modules")] +pub(crate) fn is_hex_sha256(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +/// Parse the flat `key = "value"` subset that `modules.toml` is written in. +/// +/// Deliberately not a TOML parser. The file is two columns of ASCII that an +/// operator hand-edits, and pulling a parser into the kernel's dependency graph +/// to read it would be precisely the absorption this project exists to stop. +/// Section headers are skipped rather than rejected, so the allowlist can live +/// inside a larger file. +#[cfg(feature = "modules")] +pub(crate) fn parse_allowlist(source: &str) -> impl Iterator + '_ { + source.lines().filter_map(|line| { + let line = line.split('#').next()?.trim(); + if line.is_empty() || line.starts_with('[') { + return None; + } + let (key, value) = line.split_once('=')?; + Some(( + key.trim().trim_matches(['"', '\'']).to_string(), + value.trim().trim_matches(['"', '\'']).to_ascii_lowercase(), + )) + }) +} + +#[cfg(all(test, feature = "modules"))] +mod tests { + use super::*; + + #[test] + fn an_allowlist_reads_entries_and_ignores_comments_and_sections() { + let source = "# a comment\n[section]\n\"clock.so\" = \"AABB\" # trailing\n\n"; + let entries: Vec<_> = parse_allowlist(source).collect(); + assert_eq!(entries, vec![("clock.so".to_string(), "aabb".to_string())]); + } + + #[test] + fn an_allowlist_line_without_an_assignment_is_skipped_rather_than_guessed_at() { + assert_eq!(parse_allowlist("garbage\n").count(), 0); + } + + #[test] + fn only_a_full_length_hex_digest_counts_as_a_hash() { + assert!(is_hex_sha256( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + )); + // Too short, and a plausible-looking typo that must not be accepted as + // a digest — the allowlist is the only thing standing between an + // arbitrary artifact and a private key. + assert!(!is_hex_sha256("e3b0c442")); + assert!(!is_hex_sha256(&"z".repeat(64))); + } +} diff --git a/crates/tinybus/src/bin/tinybus.rs b/crates/tinybus/src/bin/tinybus.rs index 9dd4b4e..a1e39af 100644 --- a/crates/tinybus/src/bin/tinybus.rs +++ b/crates/tinybus/src/bin/tinybus.rs @@ -53,6 +53,10 @@ enum Command { /// Positional arguments as a JSON array. Defaults to `[]`. #[arg(default_value = "[]")] args: String, + /// Send the body confidentially: the bus refuses to deliver it unless + /// it has verified the destination's artifact itself. + #[arg(long)] + confidential: bool, }, /// Emit a signal. @@ -222,13 +226,18 @@ async fn run(cli: Cli) -> Result<()> { interface, member, args, + confidential, } => { let connection = connect(&address).await?; let args: serde_json::Value = serde_json::from_str(&args)?; let proxy = connection .proxy(&destination, &path, &interface)? .with_timeout(timeout); - let reply: serde_json::Value = proxy.call(&member, args).await?; + let reply: serde_json::Value = if confidential { + proxy.call_confidential(&member, args).await? + } else { + proxy.call(&member, args).await? + }; println!("{}", serde_json::to_string_pretty(&reply)?); Ok(()) } @@ -499,10 +508,16 @@ fn render(message: &tinybus::Message) -> String { .map(|i| i.to_string()) .unwrap_or_default(); let member = h.member.as_ref().map(|m| m.to_string()).unwrap_or_default(); - format!( - "{kind:<6} {sender:<10} {path} {interface}.{member} {}", - message.body - ) + // The monitor is a terminal, a scrollback buffer and often a pasted bug + // report. A confidential body must not reach any of them, and the routing + // rules mean one should never arrive here in the first place — so this is + // the second lock on a door that is already shut. + let body = if h.confidential { + "".to_string() + } else { + message.body.to_string() + }; + format!("{kind:<6} {sender:<10} {path} {interface}.{member} {body}") } #[cfg(test)] @@ -651,6 +666,7 @@ mod tests { address: Some(address.clone()), timeout: 1, command: Command::Call { + confidential: false, destination: DESTINATION.into(), path: PATH.into(), interface: INTERFACE.into(), @@ -781,6 +797,7 @@ mod tests { interface: INTERFACE.into(), member: "Echo".into(), args: "not json".into(), + confidential: false, }, Command::Emit { path: PATH.into(), diff --git a/crates/tinybus/src/broker.rs b/crates/tinybus/src/broker.rs index ccf649a..124111d 100644 --- a/crates/tinybus/src/broker.rs +++ b/crates/tinybus/src/broker.rs @@ -136,6 +136,19 @@ impl Broker { .request_name_for_unique(unique, name) } + /// Record that a loaded module's artifact matched the module allowlist. + /// + /// This is the *only* way a peer becomes eligible to receive a confidential + /// message: the artifact was hashed against `modules.toml` before `dlopen`, + /// and nothing reached across a transport to establish it. + #[cfg(feature = "modules")] + pub(crate) fn attest_module(&self, unique: &BusName, attestation: crate::attest::Attestation) { + self.router + .lock() + .expect("router lock") + .set_attestation_for_unique(unique, attestation); + } + /// Route one inbound message from peer `id`. async fn route(&self, from: u64, from_name: &BusName, mut message: Message) -> Result<()> { message.validate()?; @@ -166,16 +179,61 @@ impl Broker { .ok_or_else(|| Error::protocol("message has no destination"))?; if destination.as_str() == crate::BUS_NAME { + if message.header.confidential && message.header.kind == MessageKind::MethodCall + { + // The bus's own service is not a loaded, hash-verified + // module and can never be an attested recipient. + // `handle_bus_call` ends in `bus_method`, which + // deserializes the body — reaching that with a + // confidential payload would both break "the broker + // never parses a body" and risk a `BadArguments` built + // from secret material. Refuse before dispatch, not + // after. + return Err(Error::not_attested( + destination.clone(), + "the bus itself is never an attested recipient", + )); + } return self.handle_bus_call(from, from_name, message).await; } - let target = self - .router - .lock() - .expect("router lock") - .resolve(&destination); + // A confidential *call* may only go to a well-known name the + // broker has verified an artifact for. A confidential *reply* + // goes back to the unique name the broker itself minted for the + // peer that made the call — that peer already chose to take + // part in the exchange, and unique names are never reused, so + // there is no one else the reply could reach. + let confidential_call = + message.header.confidential && message.header.kind == MessageKind::MethodCall; + let target = if confidential_call { + if destination.is_unique() { + // The broker knows *which connection* a unique name is, + // but not what binary is behind it. A sender that needs + // that answer has to address the well-known name. + Err(Error::not_attested( + destination.clone(), + "a confidential call must address a well-known name", + )) + } else { + self.router + .lock() + .expect("router lock") + .resolve_attested(&destination) + } + } else { + self.router + .lock() + .expect("router lock") + .resolve(&destination) + }; #[cfg(feature = "modules")] let target = target.map_err(|error| { + // A refused attestation is the more specific answer and + // must survive: rewriting it as "the module is unavailable" + // would send an operator to fix the wrong thing. + if matches!(error, Error::NotAttested { .. }) { + return error; + } let control = self .modules .lock() @@ -289,6 +347,16 @@ impl Broker { Ok(serde_json::to_value(router.manifest_of(&name))?) } "ListPeers" => Ok(serde_json::to_value(router.peer_records())?), + // What the broker verified about a prospective recipient, so a + // sender can find out *before* it builds a message around a + // secret rather than after the refusal. Returns null for an + // unattested name; the answer is deliberately not a bare bool, + // because an operator debugging this needs the hash that + // matched. + "GetAttestation" => { + let (name,): (BusName,) = parse_args(member, body)?; + Ok(serde_json::to_value(router.attestation_of(&name))?) + } "GetNameOwner" => { let (name,): (BusName,) = parse_args(member, body)?; Ok(serde_json::to_value(router.owner_of(&name))?) @@ -514,6 +582,8 @@ impl Broker { ), member: Some(MemberName::new(member).expect("literal is a valid member")), error_name: None, + // The bus's own announcements are broadcasts by construction. + confidential: false, }, body, }; @@ -1050,4 +1120,217 @@ mod tests { let transcript: String = voice.call("Transcribe", ("/tmp/clip.wav",)).await.unwrap(); assert_eq!(transcript, "transcript of /tmp/clip.wav"); } + + /// A bus with a service that the host has attested, as a module load would. + /// + /// `attest_module` is the same call the module host makes after hashing an + /// artifact against `modules.toml`; driving it directly keeps the test on + /// the in-memory transport instead of requiring a built `cdylib` on disk. + #[cfg(feature = "modules")] + async fn attested_bus() -> (MemoryBus, Broker, Connection, Connection) { + let bus = MemoryBus::new(); + let broker = Broker::new(); + broker.spawn(bus.clone()); + + let service = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + service + .serve_at(ObjectPath::new(VOICE_PATH).unwrap(), Voice) + .await + .unwrap(); + service.request_name(VOICE_NAME).await.unwrap(); + broker.attest_module( + &service.unique_name().unwrap(), + crate::attest::Attestation { + name: BusName::new(VOICE_NAME).unwrap(), + sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + .to_string(), + }, + ); + + let client = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + (bus, broker, service, client) + } + + #[tokio::test] + async fn a_confidential_call_to_an_unattested_recipient_is_refused() { + // The default bus has no trust store, so nothing is attested — and the + // service is reachable by an ordinary call, which is what makes the + // refusal meaningful rather than incidental. + let (_bus, _service, client) = bus().await; + let voice = client.proxy(VOICE_NAME, VOICE_PATH, VOICE_NAME).unwrap(); + assert!( + voice + .call::("Transcribe", ("/tmp/a.wav",)) + .await + .is_ok() + ); + + let error = voice + .call_confidential::("Transcribe", ("/tmp/secret.wav",)) + .await + .unwrap_err(); + assert_eq!(error.wire_name(), Error::NOT_ATTESTED); + assert_eq!(voice.attestation().await.unwrap(), None); + } + + #[tokio::test] + async fn a_confidential_call_may_not_address_a_unique_name() { + // The broker knows which connection `:1.n` is, but not what binary is + // behind it, so it cannot answer the question the sender is asking. + let (_bus, service, client) = bus().await; + let unique = service.unique_name().unwrap(); + let proxy = client + .proxy(unique.as_str(), VOICE_PATH, VOICE_NAME) + .unwrap(); + let error = proxy + .call_confidential::("Transcribe", ("/tmp/secret.wav",)) + .await + .unwrap_err(); + assert_eq!(error.wire_name(), Error::NOT_ATTESTED); + } + + #[tokio::test] + async fn a_confidential_call_to_the_bus_itself_is_refused_before_its_body_is_parsed() { + // The bus's own service is never a loaded, hash-verified module, so it + // can never be an attested recipient. Before the fix this dispatch + // reached `handle_bus_call` -> `bus_method` -> `parse_args`, which + // deserializes the body — breaking "the broker never parses a body" + // for exactly the messages that must never be parsed. An ordinary + // (non-confidential) bus call must keep working. + let (_bus, _service, client) = bus().await; + let bus_proxy = client + .proxy(crate::BUS_NAME, crate::BUS_PATH, crate::BUS_INTERFACE) + .unwrap(); + let error = bus_proxy + .call_confidential::("GetId", ()) + .await + .unwrap_err(); + assert_eq!(error.wire_name(), Error::NOT_ATTESTED); + let id: String = bus_proxy.call("GetId", ()).await.unwrap(); + assert!(id.starts_with("tinybus-"), "{id}"); + } + + #[cfg(feature = "modules")] + #[tokio::test] + async fn a_confidential_call_reaches_a_module_the_host_verified() { + let (_bus, _broker, _service, client) = attested_bus().await; + let voice = client.proxy(VOICE_NAME, VOICE_PATH, VOICE_NAME).unwrap(); + + let attestation = voice.attestation().await.unwrap().expect("attested"); + assert_eq!(attestation.name.as_str(), VOICE_NAME); + + let transcript: String = voice + .call_confidential("Transcribe", ("/tmp/secret.wav",)) + .await + .unwrap(); + assert_eq!(transcript, "transcript of /tmp/secret.wav"); + } + + #[cfg(feature = "modules")] + #[tokio::test] + async fn a_name_handed_on_to_another_peer_does_not_hand_on_its_attestation() { + // The property under test is whether trust is attached to the *name* or + // to the *peer*. If it were the name, any process that grabbed it after + // the real module released it would inherit the right to be handed + // secrets without a single byte having been hashed. + let (bus, _broker, service, client) = attested_bus().await; + let voice = client.proxy(VOICE_NAME, VOICE_PATH, VOICE_NAME).unwrap(); + assert!(voice.attestation().await.unwrap().is_some()); + + service.release_name(VOICE_NAME).await.unwrap(); + let impostor = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + impostor + .serve_at(ObjectPath::new(VOICE_PATH).unwrap(), Voice) + .await + .unwrap(); + impostor.request_name(VOICE_NAME).await.unwrap(); + + // The impostor owns the name and answers ordinary calls... + assert!( + voice + .call::("Transcribe", ("/tmp/a.wav",)) + .await + .is_ok() + ); + // ...and is refused the secret, because nothing verified its artifact. + assert_eq!(voice.attestation().await.unwrap(), None); + let error = voice + .call_confidential::("Transcribe", ("/tmp/secret.wav",)) + .await + .unwrap_err(); + assert_eq!(error.wire_name(), Error::NOT_ATTESTED); + } + + #[tokio::test] + async fn a_confidential_body_is_never_fanned_out_to_a_monitor() { + let (_bus, _service, client) = bus().await; + let watcher = Connection::connect(_bus.connect().await.unwrap()) + .await + .unwrap(); + // The broadest possible subscription: if anything could see a secret, + // this would. + let mut seen = watcher.add_match(MatchRule::new()).await.unwrap(); + + let voice = client.proxy(VOICE_NAME, VOICE_PATH, VOICE_NAME).unwrap(); + let _ = voice + .call_confidential::("Transcribe", ("/tmp/secret.wav",)) + .await; + + // Nothing arrives at all, rather than something arriving redacted. + assert!( + tokio::time::timeout(Duration::from_millis(100), seen.recv()) + .await + .is_err() + ); + } + + #[tokio::test] + async fn get_attestation_answers_for_a_name_nobody_owns() { + let (_bus, _service, client) = bus().await; + let missing = BusName::new("ai.tinyhumans.openhuman.Absent").unwrap(); + assert_eq!(client.attestation(missing).await.unwrap(), None); + } + + #[tokio::test] + async fn the_stream_interface_gets_no_exemption_from_attestation() { + // Bulk payloads travel as `Stream.Write` calls rather than in a body, + // which makes the stream interface the one place a second delivery path + // could have grown. It did not: a chunk is an ordinary method call and + // `route` reaches it through the same check as everything else. Pinned + // as a test because the cost of the stream path ever being special-cased + // is every secret on the bus, and nothing else would notice. + let (_bus, service, client) = bus().await; + let stream = client + .proxy( + VOICE_NAME, + crate::stream::STREAM_PATH, + crate::stream::STREAM_INTERFACE, + ) + .unwrap(); + + // The peer is reachable on the stream interface by an ordinary call — + // it answers `UnknownStream`, not `NotAttested` — so the refusal below + // is the attestation check firing and not the name failing to resolve. + let ordinary = stream + .call::("Abort", ("no-such-stream",)) + .await + .unwrap_err(); + assert_eq!( + ordinary.wire_name(), + "ai.tinyhumans.tinybus.Error.UnknownStream" + ); + + let refused = stream + .call_confidential::("Abort", ("no-such-stream",)) + .await + .unwrap_err(); + assert_eq!(refused.wire_name(), Error::NOT_ATTESTED); + drop(service); + } } diff --git a/crates/tinybus/src/connection.rs b/crates/tinybus/src/connection.rs index 079dcac..fa4f9a4 100644 --- a/crates/tinybus/src/connection.rs +++ b/crates/tinybus/src/connection.rs @@ -269,6 +269,18 @@ impl Connection { Ok(serde_json::from_value(value)?) } + /// What the broker has verified about whoever owns `name`. + /// + /// The check a sender makes before it commits to handing over a secret. + /// `None` means the bus will refuse a confidential message to that name — + /// nothing owns it, or the operator never allowlisted an artifact for it. + pub async fn attestation(&self, name: BusName) -> Result> { + let value = self + .call_bus("GetAttestation", serde_json::json!([name])) + .await?; + Ok(serde_json::from_value(value)?) + } + /// Tell the broker what this peer speaks and accepts. /// /// Announcing is optional and additive: a peer that never calls this stays @@ -854,7 +866,7 @@ impl Connection { /// `["/tmp/a.wav"]` mean the same thing; a caller writing a bare `"/tmp/a.wav"` /// almost certainly also does. Wrapping a scalar rather than rejecting it makes /// the one-argument case — by far the most common — pleasant to write. -fn to_body(value: &impl Serialize) -> Result { +pub(crate) fn to_body(value: &impl Serialize) -> Result { let value = serde_json::to_value(value)?; Ok(match value { Value::Array(_) => value, diff --git a/crates/tinybus/src/error.rs b/crates/tinybus/src/error.rs index b2c3a0a..18d2fdb 100644 --- a/crates/tinybus/src/error.rs +++ b/crates/tinybus/src/error.rs @@ -66,6 +66,24 @@ pub enum Error { #[error("no peer owns the name `{0}`")] NameHasNoOwner(BusName), + /// A confidential message was refused because the broker could not + /// establish what binary is behind the destination name. + /// + /// Carries the name and a fixed operator-facing reason, never the body it + /// was protecting — the whole point of the refusal is that the payload goes + /// nowhere, including into a log line. + #[error("`{name}` is not an attested recipient: {reason}")] + NotAttested { + /// The destination that failed attestation. + name: BusName, + /// Why the broker would not vouch for it. + /// + /// Fixed text, not caller-composed: this error travels back across + /// the bus, and a `String` here would be a standing invitation for a + /// future call site to interpolate something it shouldn't. + reason: &'static str, + }, + /// `RequestName` lost: another peer already owns it and did not allow /// replacement. #[error("`{name}` is already owned by {owner}")] @@ -251,6 +269,12 @@ impl Error { pub const UNKNOWN_METHOD: &'static str = "ai.tinyhumans.tinybus.Error.UnknownMethod"; /// The dotted error name a failing method body gets by default. pub const FAILED: &'static str = "ai.tinyhumans.tinybus.Error.Failed"; + /// The dotted error name for a refused confidential delivery. + /// + /// Callers match on this to tell "the recipient is not trusted" from "the + /// call failed", which are different problems with different fixes: one is + /// an operator's trust store, the other is the service. + pub const NOT_ATTESTED: &'static str = "ai.tinyhumans.tinybus.Error.NotAttested"; /// Build an [`Error::Protocol`] from anything displayable. pub fn protocol(message: impl std::fmt::Display) -> Self { @@ -262,6 +286,16 @@ impl Error { Self::Transport(message.to_string()) } + /// Build an [`Error::NotAttested`] for `name`. + /// + /// `reason` is a fixed `&'static str`, not `impl Into`: this error + /// travels back to a caller that just failed to send a secret, and the + /// type itself is what stops a future call site from composing it out of + /// peer input. + pub fn not_attested(name: BusName, reason: &'static str) -> Self { + Self::NotAttested { name, reason } + } + /// Build an [`Error::Path`] for `path`. pub fn path(path: impl Into, message: impl std::fmt::Display) -> Self { Self::Path { @@ -347,6 +381,7 @@ impl Error { Self::ConnectionClosed => "ai.tinyhumans.tinybus.Error.ConnectionClosed", Self::Backpressure => "ai.tinyhumans.tinybus.Error.Backpressure", Self::NameHasNoOwner(_) => "ai.tinyhumans.tinybus.Error.NameHasNoOwner", + Self::NotAttested { .. } => Self::NOT_ATTESTED, Self::NameTaken { .. } => "ai.tinyhumans.tinybus.Error.NameTaken", Self::UnknownObject { .. } => "ai.tinyhumans.tinybus.Error.UnknownObject", Self::UnknownInterface { .. } => "ai.tinyhumans.tinybus.Error.UnknownInterface", @@ -566,6 +601,7 @@ mod tests { Error::StreamTooLarge { limit: 1 }, Error::TooManyStreams { limit: 1 }, Error::Json(serde_json::from_str::("{").unwrap_err()), + Error::not_attested(BusName::new("ai.tinyhumans.Example").unwrap(), "bad"), ]; for error in errors { assert!(error.wire_name().starts_with("ai.tinyhumans.")); diff --git a/crates/tinybus/src/lib.rs b/crates/tinybus/src/lib.rs index 7d92da3..1eb63b6 100644 --- a/crates/tinybus/src/lib.rs +++ b/crates/tinybus/src/lib.rs @@ -65,6 +65,7 @@ // likely to break silently, so it has to be covered here. extern crate self as tinybus; +pub mod attest; pub mod broker; pub mod build_info; pub mod connection; @@ -87,6 +88,7 @@ pub mod version; #[path = "private.rs"] pub mod __private; +pub use crate::attest::Attestation; pub use crate::connection::Connection; pub use crate::error::{Error, Result}; pub use crate::events::{ diff --git a/crates/tinybus/src/message/mod.rs b/crates/tinybus/src/message/mod.rs index 0d98bfa..bb7a1a0 100644 --- a/crates/tinybus/src/message/mod.rs +++ b/crates/tinybus/src/message/mod.rs @@ -78,6 +78,26 @@ pub struct Header { /// For [`MessageKind::Error`]: the stable dotted error name. #[serde(default, skip_serializing_if = "Option::is_none")] pub error_name: Option, + /// This body is a secret: deliver it to the attested destination or to + /// nobody. + /// + /// Set by the sender and *not* overwritten on ingress, unlike `sender`. The + /// asymmetry is deliberate and safe in this direction: the flag only ever + /// causes the broker to apply more restrictions, so a peer that forges it + /// can restrict its own traffic and nothing else. A flag the broker + /// controlled would instead need a rule for who may ask for confidentiality, + /// and there is no such rule worth having — everyone may. + /// + /// An older broker that does not know this field routes the message + /// normally, which is why a sender must not assume the guarantee holds + /// without checking `GetAttestation` first. See [`crate::attest`]. + #[serde(default, skip_serializing_if = "is_false")] + pub confidential: bool, +} + +/// `skip_serializing_if` needs a path, and `bool::not` takes `self` by value. +fn is_false(value: &bool) -> bool { + !*value } /// A framed message: header plus a JSON body. @@ -119,11 +139,33 @@ impl Message { interface: Some(interface), member: Some(member), error_name: None, + confidential: false, }, body, } } + /// Build a method call whose body the broker must not let anyone but the + /// attested destination see. + /// + /// Use it for the payloads whose disclosure is the failure — a private key, + /// a recovery phrase, a bearer token. The broker will refuse to deliver it + /// unless it has itself verified the destination's artifact against the + /// operator's trust store, so a send that would have gone to an + /// impersonator fails instead of succeeding quietly. See [`crate::attest`] + /// for exactly what "verified" covers. + pub fn confidential_call( + destination: BusName, + path: ObjectPath, + interface: InterfaceName, + member: MemberName, + body: Value, + ) -> Self { + let mut message = Self::method_call(destination, path, interface, member, body); + message.header.confidential = true; + message + } + /// Build the successful reply to `call`. pub fn method_return(call: &Header, body: Value) -> Self { Self { @@ -140,6 +182,11 @@ impl Message { interface: None, member: None, error_name: None, + // A reply inherits the call's confidentiality. A secret asked + // for confidentially is usually answered with another one — a + // key derivation returns a key — and a reply that quietly lost + // the flag would be the leak the call avoided, one hop later. + confidential: call.confidential, }, body, } @@ -161,6 +208,11 @@ impl Message { interface: None, member: None, error_name: Some(error.wire_name().to_string()), + // Not inherited. Errors never carry the value that caused them, + // so an error reply has no secret to protect — and marking it + // confidential would make it undeliverable exactly when the + // recipient failed attestation, swallowing the diagnosis. + confidential: false, }, body: Value::String(error.wire_message()), } @@ -185,6 +237,9 @@ impl Message { interface: Some(interface), member: Some(member), error_name: None, + // Structurally impossible to set: a signal is a broadcast, and + // `validate` refuses the combination on ingress. + confidential: false, }, body, } @@ -224,6 +279,23 @@ impl Message { /// without re-checking. A call with no destination would otherwise sit in /// the router as an unroutable message with a caller blocked on it forever. pub fn validate(&self) -> Result<()> { + // Checked before the per-kind rules, and checked on ingress rather than + // at delivery: a confidential signal has no destination, so there is no + // one recipient to attest and fan-out is the only thing it could mean. + // Refusing it here means no later stage has to ask whether a broadcast + // might be a secret. + if self.header.confidential { + if self.header.kind == MessageKind::Signal { + return Err(Error::protocol( + "a signal cannot be confidential: it is a broadcast", + )); + } + if self.header.destination.is_none() { + return Err(Error::protocol( + "a confidential message needs a destination", + )); + } + } match self.header.kind { MessageKind::MethodCall => { if self.header.destination.is_none() { @@ -330,6 +402,81 @@ mod tests { assert!(!json.contains("destination"), "{json}"); } + #[test] + fn a_signal_cannot_be_confidential_because_it_is_a_broadcast() { + let mut sig = Message::signal( + ObjectPath::root(), + InterfaceName::new("ai.tinyhumans.Test").unwrap(), + MemberName::new("Tick").unwrap(), + Value::Null, + ); + sig.header.confidential = true; + let err = sig.validate().unwrap_err(); + assert!(err.to_string().contains("broadcast"), "{err}"); + } + + #[test] + fn a_confidential_message_without_a_destination_is_refused_on_ingress() { + let mut c = call(); + c.header.confidential = true; + c.header.destination = None; + assert!(c.validate().is_err()); + } + + #[test] + fn a_reply_inherits_confidentiality_and_an_error_reply_never_does() { + // A key-derivation call answers with a key. A reply that quietly lost + // the flag would leak on the way back what the call protected on the + // way out. + let mut c = Message::confidential_call( + BusName::new("ai.tinyhumans.openhuman.Wallet").unwrap(), + ObjectPath::new("/ai/tinyhumans/openhuman/Wallet").unwrap(), + InterfaceName::new("ai.tinyhumans.openhuman.Wallet").unwrap(), + MemberName::new("DeriveKey").unwrap(), + serde_json::json!([]), + ); + c.header.sender = Some(BusName::new(":1.3").unwrap()); + assert!(c.header.confidential); + c.validate().unwrap(); + + assert!( + Message::method_return(&c.header, Value::Null) + .header + .confidential + ); + // The error path stays deliverable: it carries no value, and a + // confidential error to an unattested caller would swallow the reason + // the call failed. + assert!( + !Message::error_reply(&c.header, &Error::failed("no")) + .header + .confidential + ); + } + + #[test] + fn an_ordinary_message_does_not_pay_for_the_confidential_flag() { + let json = serde_json::to_string(&call()).unwrap(); + assert!(!json.contains("confidential"), "{json}"); + } + + #[test] + fn a_header_from_a_peer_that_predates_the_flag_reads_as_not_confidential() { + // Adding an optional field is a compatible change only if the old wire + // form still parses. This is that guarantee, asserted rather than + // assumed. + let old = serde_json::json!({ + "kind": "method_call", + "serial": 1, + "destination": "ai.tinyhumans.openhuman.Voice", + "path": "/ai/tinyhumans/openhuman/Voice", + "interface": "ai.tinyhumans.openhuman.Voice", + "member": "Transcribe" + }); + let header: Header = serde_json::from_value(old).unwrap(); + assert!(!header.confidential); + } + #[test] fn messages_round_trip_through_json() { let c = call(); diff --git a/crates/tinybus/src/module/hash.rs b/crates/tinybus/src/module/hash.rs index 73a6f28..9967e1b 100644 --- a/crates/tinybus/src/module/hash.rs +++ b/crates/tinybus/src/module/hash.rs @@ -1,4 +1,11 @@ //! Dependency-free SHA-256 used by module artifact verification. +//! +//! Verification at load is what an attestation later stands on: a recipient +//! earns the right to be handed a confidential body by having its artifact +//! digested here and matched against the operator's allowlist. Compiled +//! unconditionally rather than behind `modules`, because `module::sha256_file` +//! offers the same digest to callers that publish an asset without ever +//! loading one. use std::io::{self, Read}; diff --git a/crates/tinybus/src/module/host.rs b/crates/tinybus/src/module/host.rs index 8ba3074..4b33f43 100644 --- a/crates/tinybus/src/module/host.rs +++ b/crates/tinybus/src/module/host.rs @@ -607,6 +607,25 @@ impl ModuleHost { return Err(error); } }; + // A module that matched `modules.toml` is an attested recipient: the + // host hashed its artifact against a list the operator installed, which + // is the same fact the trust store asserts about an out-of-process peer. + // Re-read rather than plumbed down from the gate, and fails closed — + // an artifact that changed underneath us no longer matches, so it does + // not become attested. + if let Ok(Some(sha256)) = std::fs::File::open(path) + .map_err(Error::from) + .and_then(|file| allowlisted_hash(path, file)) + { + self.inner.broker.attest_module( + &unique, + crate::attest::Attestation { + name: admitted.manifest.bus_name.clone(), + sha256, + }, + ); + } + let broker = self.inner.broker.clone(); let ready_transport = transport.clone(); let module_name = admitted.name.clone(); @@ -1171,12 +1190,23 @@ fn check_file(path: &Path) -> Result<()> { } fn check_allowlist(path: &Path, file: std::fs::File) -> Result<()> { + allowlisted_hash(path, file).map(|_| ()) +} + +/// The artifact's verified SHA-256, or `None` where the directory carries no +/// allowlist at all. +/// +/// Splitting the value out of the gate is what lets a loaded module become an +/// attested recipient: the hash the operator vouched for is exactly the fact a +/// confidential sender needs, and recomputing it later from a file that may +/// since have changed would attest something nobody checked. +fn allowlisted_hash(path: &Path, file: std::fs::File) -> Result> { let Some(directory) = path.parent() else { - return Ok(()); + return Ok(None); }; let allowlist = directory.join("modules.toml"); if !allowlist.exists() { - return Ok(()); + return Ok(None); } let source = std::fs::read_to_string(&allowlist) .map_err(|_| Error::module_refused(path, "module allowlist is unreadable"))?; @@ -1188,23 +1218,16 @@ fn check_allowlist(path: &Path, file: std::fs::File) -> Result<()> { .file_stem() .and_then(|value| value.to_str()) .unwrap_or(""); - let expected = source.lines().find_map(|line| { - let line = line.split('#').next()?.trim(); - if line.is_empty() || line.starts_with('[') { - return None; - } - let (key, value) = line.split_once('=')?; - let key = key.trim().trim_matches(['"', '\'']); - (key == file_name || key == file_stem) - .then(|| value.trim().trim_matches(['"', '\'']).to_ascii_lowercase()) - }); + let expected = crate::attest::parse_allowlist(&source) + .find(|(key, _)| key == file_name || key == file_stem) + .map(|(_, value)| value); let Some(expected) = expected else { return Err(Error::module_refused( path, "artifact is absent from the module allowlist", )); }; - if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) { + if !crate::attest::is_hex_sha256(&expected) { return Err(Error::module_refused( path, "module allowlist contains an invalid hash", @@ -1218,7 +1241,7 @@ fn check_allowlist(path: &Path, file: std::fs::File) -> Result<()> { "artifact hash does not match the module allowlist", )); } - Ok(()) + Ok(Some(actual)) } fn has_library_extension(path: &Path) -> bool { diff --git a/crates/tinybus/src/module/host_test.rs b/crates/tinybus/src/module/host_test.rs index 011294c..fdd4a21 100644 --- a/crates/tinybus/src/module/host_test.rs +++ b/crates/tinybus/src/module/host_test.rs @@ -1178,3 +1178,83 @@ async fn one_refused_module_does_not_stop_the_others_in_the_directory_from_loadi 1 ); } + +/// Copy `artifact` into a fresh directory beside a `modules.toml` listing +/// `hash` for it, so a load can be driven against a real allowlist. +/// +/// Unix-only, and the reason is the loader's own admission check rather than +/// anything about the code under test. On Windows that check trusts exactly +/// three SIDs on a module directory: its owner, `LocalSystem`, and +/// `BUILTIN\Administrators`. A CI runner's account is an administrator, so a +/// directory a *test* creates is owned by `BUILTIN\Administrators` while the +/// ACE it inherits names the user SID — neither the owner nor well-known +/// trusted — and the load is refused. That is why the Windows workflow calls +/// `SetOwner` on the directories it provisions; a test cannot do the same +/// without Win32 calls of its own. +/// +/// Gating here costs little: what these two tests exercise is the hash +/// comparison and the attestation record it produces, which is identical on +/// every platform. The Windows-specific directory policy is covered by the +/// existing loader tests that run against the CI-provisioned directories. +#[cfg(unix)] +fn staged_module(artifact: &Path, hash: &str) -> (tempfile::TempDir, PathBuf) { + // Staged beside the artifact rather than in `/tmp`: the loader refuses a + // directory another user could write to, and `/tmp` is exactly that. The + // artifact's own directory is user-owned (CI builds into + // `target/debug/examples`), so it already satisfies the check that `/tmp` + // fails — which is the admission check doing its job, not an obstacle to + // route around. + let root = artifact.parent().expect("artifact has a parent directory"); + let dir = tempfile::tempdir_in(root).unwrap(); + let file_name = artifact.file_name().unwrap(); + let staged = dir.path().join(file_name); + std::fs::copy(artifact, &staged).unwrap(); + std::fs::write( + dir.path().join("modules.toml"), + format!("{:?} = {hash:?}\n", file_name.to_str().unwrap()), + ) + .unwrap(); + (dir, staged) +} + +#[cfg(unix)] +#[tokio::test] +#[ignore = "requires TINYBUS_TEST_MODULE to point at the built cdylib"] +async fn a_module_loaded_from_an_allowlisted_artifact_becomes_an_attested_recipient() { + // The one seam the in-memory fixtures cannot reach: a real artifact, hashed + // off the disk by the host, becoming eligible to receive a secret. + let artifact = PathBuf::from(std::env::var_os("TINYBUS_TEST_MODULE").unwrap()); + let hash = crate::module::hash::file_hex(std::fs::File::open(&artifact).unwrap()).unwrap(); + let (_dir, staged) = staged_module(&artifact, &hash); + + let bus = MemoryBus::new(); + let broker = Broker::new(); + broker.spawn(bus.clone()); + let host = ModuleHost::new(broker.clone()); + let info = host.load_file(&staged).unwrap(); + + let client = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let attestation = client + .attestation(info.manifest.bus_name.clone()) + .await + .unwrap() + .expect("an allowlisted module is attested"); + assert_eq!(attestation.sha256, hash); + assert_eq!(attestation.name, info.manifest.bus_name); +} + +#[cfg(unix)] +#[tokio::test] +#[ignore = "requires TINYBUS_TEST_MODULE to point at the built cdylib"] +async fn a_module_whose_artifact_does_not_match_the_allowlist_never_loads_at_all() { + // The refusal happens before `dlopen`, so the question of attestation never + // arises: unverified code is not admitted, let alone handed a secret. + let artifact = PathBuf::from(std::env::var_os("TINYBUS_TEST_MODULE").unwrap()); + let (_dir, staged) = staged_module(&artifact, &"a".repeat(64)); + + let host = ModuleHost::new(Broker::new()); + let error = host.load_file(&staged).unwrap_err(); + assert!(error.to_string().contains("allowlist"), "{error}"); +} diff --git a/crates/tinybus/src/proxy.rs b/crates/tinybus/src/proxy.rs index 5a91469..a958889 100644 --- a/crates/tinybus/src/proxy.rs +++ b/crates/tinybus/src/proxy.rs @@ -103,6 +103,41 @@ impl Proxy { .await } + /// Call `member` with a body the bus must not show to anyone else. + /// + /// The call fails with [`crate::Error::NotAttested`] unless the broker has + /// itself verified the destination's artifact against the operator's trust + /// store. That refusal is the feature: handing a private key to whoever + /// happened to claim the name first is the outcome this exists to prevent, + /// and it is better to fail a deploy than to succeed at that. + /// + /// Use [`Proxy::attestation`] first if the caller wants to distinguish + /// "not installed" from "not trusted" before it assembles the secret. + pub async fn call_confidential( + &self, + member: &str, + args: impl Serialize, + ) -> Result { + let message = crate::message::Message::confidential_call( + self.destination.clone(), + self.path.clone(), + self.interface.clone(), + MemberName::new(member)?, + crate::connection::to_body(&args)?, + ); + let reply = self.connection.call_raw(message, self.timeout).await?; + Ok(serde_json::from_value(reply)?) + } + + /// What the broker has verified about this proxy's destination, if + /// anything. + /// + /// `None` means no secret may be sent here — either nothing owns the name + /// or the operator never allowlisted an artifact for it. + pub async fn attestation(&self) -> Result> { + self.connection.attestation(self.destination.clone()).await + } + /// Whether a peer currently owns this proxy's destination. /// /// Worth checking before a first call in a startup path: the difference diff --git a/crates/tinybus/src/router.rs b/crates/tinybus/src/router.rs index c56ac48..4d712f1 100644 --- a/crates/tinybus/src/router.rs +++ b/crates/tinybus/src/router.rs @@ -27,6 +27,7 @@ use std::collections::HashMap; use tokio::sync::mpsc; +use crate::attest::Attestation; use crate::error::{Error, Result}; use crate::message::{Message, MessageKind}; use crate::name::{BusName, InterfaceName, MemberName, ObjectPath}; @@ -175,6 +176,14 @@ struct Peer { /// and a peer that never announces stays routable, so manifests can be /// adopted one service at a time rather than as a flag day. manifest: Option, + /// What the host verified about this peer, per name it owns. + /// + /// Keyed by name rather than one per peer because a peer may hold several + /// well-known names and the operator allowlists an artifact *for a name*. + /// Empty for every peer until a module load actually verifies one — the + /// absence of an entry is what refuses a confidential delivery, so an + /// ordinary out-of-process peer is ineligible by construction. + attestations: HashMap, } /// Who is attached, what they are called, and what they want to hear. @@ -211,6 +220,7 @@ impl Router { outbox, matches: Vec::new(), manifest: None, + attestations: HashMap::new(), }, ); self.names.insert(unique.clone(), id); @@ -382,6 +392,70 @@ impl Router { self.peers.get(id).map(|p| p.unique.clone()) } + /// Record what the host verified about the peer owning `name`. + /// + /// Gated with module loading, because that is the only thing that can + /// produce an attestation. Without it nothing is ever attested and every + /// confidential delivery is refused, which is the correct behaviour for a + /// build that cannot load a module in the first place. + #[cfg(feature = "modules")] + /// + /// Stored against the peer, so it dies with the peer: a service that exits + /// takes its attestation with it, and the next process to claim the name + /// has to earn its own. Nothing here is ever copied forward on a name + /// handover, which is what stops a released name carrying its predecessor's + /// trust to whoever grabs it next. + pub fn set_attestation(&mut self, id: u64, attestation: Attestation) { + if let Some(peer) = self.peers.get_mut(&id) { + peer.attestations + .insert(attestation.name.clone(), attestation); + } + } + + /// [`Router::set_attestation`] addressed by the peer's unique name, for the + /// module host, which holds that rather than the internal peer id. + #[cfg(feature = "modules")] + pub(crate) fn set_attestation_for_unique( + &mut self, + unique: &BusName, + attestation: Attestation, + ) { + if let Some(id) = self.names.get(unique).copied() { + self.set_attestation(id, attestation); + } + } + + /// What the broker verified about whoever owns `name`, if anything. + pub fn attestation_of(&self, name: &BusName) -> Option { + let id = self.names.get(name)?; + self.peers.get(id)?.attestations.get(name).cloned() + } + + /// The outbox of whoever owns `destination`, but only if the host has + /// verified that peer's artifact *for that name*. + /// + /// The lookup and the check are one operation on purpose. Resolving first + /// and checking after would leave a window in which a caller could hold a + /// sender for an unattested peer, and every such window eventually becomes + /// a delivery. + pub fn resolve_attested(&self, destination: &BusName) -> Result> { + let id = self + .names + .get(destination) + .ok_or_else(|| Error::NameHasNoOwner(destination.clone()))?; + let peer = self + .peers + .get(id) + .ok_or_else(|| Error::NameHasNoOwner(destination.clone()))?; + if !peer.attestations.contains_key(destination) { + return Err(Error::not_attested( + destination.clone(), + "only a loaded module with a verified artifact may receive a secret", + )); + } + Ok(peer.outbox.clone()) + } + /// The outbox of whoever owns `destination`. pub fn resolve(&self, destination: &BusName) -> Result> { let id = self @@ -399,7 +473,15 @@ impl Router { /// Excluding the sender is not an optimisation: a service that both emits /// and subscribes on the same interface would otherwise hear its own /// signal and, if it re-emits in response, loop. + /// A confidential message has no subscribers, whatever anyone matched. + /// `validate` already refuses confidential signals on ingress, so this can + /// only fire if some future path builds one internally — and the cost of + /// being wrong here is a secret delivered to every peer holding a match + /// rule, so it is checked twice rather than reasoned about once. pub fn subscribers(&self, signal: &Message, from: u64) -> Vec> { + if signal.header.confidential { + return Vec::new(); + } self.peers .iter() .filter(|(id, _)| **id != from) @@ -411,6 +493,9 @@ impl Router { /// Every attached peer's outbox. Used for bus-generated announcements that /// still go through match filtering at the call site. pub fn broadcast_targets(&self, signal: &Message) -> Vec> { + if signal.header.confidential { + return Vec::new(); + } self.peers .values() .filter(|peer| peer.matches.iter().any(|rule| rule.matches(signal))) @@ -581,4 +666,94 @@ mod tests { router.remove_match(b, &rule); assert!(router.subscribers(&sig, a).is_empty()); } + + #[cfg(feature = "modules")] + fn attestation(name: &str) -> Attestation { + Attestation { + name: BusName::new(name).unwrap(), + sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(), + } + } + + #[test] + fn a_confidential_message_reaches_no_subscriber_however_broad_the_rule() { + let mut router = Router::default(); + let (a, _) = router.attach(outbox()); + router.attach(outbox()); + // An empty rule matches everything, which is the worst case: if any + // rule could pull in a secret, this one would. + router.add_match(a, MatchRule::new()); + + let mut sig = signal("ai.tinyhumans.Test", "Tick", "/"); + assert_eq!(router.subscribers(&sig, 99).len(), 1); + assert_eq!(router.broadcast_targets(&sig).len(), 1); + + sig.header.confidential = true; + assert!(router.subscribers(&sig, 99).is_empty()); + assert!(router.broadcast_targets(&sig).is_empty()); + } + + #[test] + fn an_unattested_owner_routes_normally_but_never_confidentially() { + let mut router = Router::default(); + let (id, _) = router.attach(outbox()); + let name = BusName::new("ai.tinyhumans.openhuman.Wallet").unwrap(); + router.request_name(id, name.clone()).unwrap(); + + assert!(router.resolve(&name).is_ok()); + assert_eq!(router.attestation_of(&name), None); + let error = router.resolve_attested(&name).unwrap_err(); + assert_eq!(error.wire_name(), Error::NOT_ATTESTED); + } + + #[cfg(feature = "modules")] + #[test] + fn an_attested_owner_can_receive_a_confidential_message() { + let mut router = Router::default(); + let (id, _) = router.attach(outbox()); + let name = BusName::new("ai.tinyhumans.openhuman.Wallet").unwrap(); + router.request_name(id, name.clone()).unwrap(); + router.set_attestation(id, attestation(name.as_str())); + + assert!(router.resolve_attested(&name).is_ok()); + assert_eq!( + router.attestation_of(&name), + Some(attestation(name.as_str())) + ); + } + + #[cfg(feature = "modules")] + #[test] + fn an_attestation_is_bound_to_the_name_it_was_verified_for() { + // Holding two names must not let trust earned for one carry to the + // other: the operator allowlisted an artifact *as the wallet*, not as + // everything that process might also answer to. + let mut router = Router::default(); + let (id, _) = router.attach(outbox()); + let wallet = BusName::new("ai.tinyhumans.openhuman.Wallet").unwrap(); + let voice = BusName::new("ai.tinyhumans.openhuman.Voice").unwrap(); + router.request_name(id, wallet.clone()).unwrap(); + router.request_name(id, voice.clone()).unwrap(); + router.set_attestation(id, attestation(wallet.as_str())); + + assert!(router.resolve_attested(&wallet).is_ok()); + assert!(router.resolve_attested(&voice).is_err()); + } + + #[cfg(feature = "modules")] + #[test] + fn a_dead_peers_attestation_does_not_survive_it() { + let mut router = Router::default(); + let (id, _) = router.attach(outbox()); + let name = BusName::new("ai.tinyhumans.openhuman.Wallet").unwrap(); + router.request_name(id, name.clone()).unwrap(); + router.set_attestation(id, attestation(name.as_str())); + router.detach(id); + + // Whoever claims the name next inherits nothing and must earn its own. + let (next, _) = router.attach(outbox()); + router.request_name(next, name.clone()).unwrap(); + assert_eq!(router.attestation_of(&name), None); + assert!(router.resolve_attested(&name).is_err()); + } } diff --git a/docs/modules/README.md b/docs/modules/README.md index c5c718c..0435733 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -13,6 +13,7 @@ behaviours other modules rely on. | `transport` | [transport/README.md](transport/README.md) | | `router` | [router/README.md](router/README.md) | | `broker` | [broker/README.md](broker/README.md) | +| `attest` | [attest/README.md](attest/README.md) | | `connection` | [connection/README.md](connection/README.md) | | `proxy` | [proxy/README.md](proxy/README.md) | | `service` | [service/README.md](service/README.md) | diff --git a/docs/modules/attest/README.md b/docs/modules/attest/README.md new file mode 100644 index 0000000..ef560c1 --- /dev/null +++ b/docs/modules/attest/README.md @@ -0,0 +1,125 @@ +# `attest` + +Confidential messages, and the recipient check that has to hold before one is +delivered. + +## Who receives a secret + +A **module loaded into the host's address space**, and nothing else. + +That is the whole rule, and it is a deliberate narrowing rather than a +limitation. A secret handed to a loaded module never crosses a transport, never +reaches a separate process, and never touches a socket — so there is no peer to +identify, no process credential to read, and no per-OS code to keep working. + +Services in their own processes, CLI clients and monitors do not receive +secrets. A confidential message addressed to one is refused. + +## What the check establishes + +Before `dlopen` runs, the module host hashes the artifact with SHA-256 and +compares it against the operator's `modules.toml`. A module that fails is not +loaded at all. A module that passes becomes an **attested recipient**: the host +records that this well-known name is owned by code whose bytes hashed to this +digest, and that the operator listed that digest as acceptable. + +The hash is computed by the host over bytes the host read itself. Nothing a +module claims about itself participates. + +```toml +# modules.toml, beside the artifact — the same file the loader already uses +"libwallet.so" = "41edece42d63e8d9bf515a9ba6932e1c20cbc9f5a5d134645adb5db1b9737ea3" +``` + +## What it does not establish, and this matters + +An in-process module shares the host's address space. It can read host memory +directly, so a *malicious loaded module* is not contained by any routing rule — +it never needed the bus to reach a secret in the first place. This is the +invariant CLAUDE.md already states: in-process modules are inside the trust +boundary. + +So what attestation buys is **admission control, not isolation**. Only code +whose hash an operator allowlisted is loaded at all, and only such code is handed +a secret through the bus. The bus's job is to refuse to be the delivery +mechanism for anything else. An integration whose compromise must not reach the +kernel's secrets belongs in a separate process — where it is, by this design, +ineligible to receive them. + +A second thing it does not cover: **bulk streams**. A stream's bytes move as +their own `Stream.Write` calls, which carry no `confidential` flag and so are +routed without this check. Putting a `StreamRef` in a confidential call attests +the recipient of the *handle*, not of the payload — so a secret large enough to +want a stream currently has no attested way to travel. See +[the protocol's `confidential` section](../../protocol.md#confidential). + +A third case worth naming explicitly: a module loaded from a GitHub release +extracts into a fresh temporary directory that holds no `modules.toml`, so +`allowlisted_hash` finds nothing to compare against and the module is never +attested — this is the fail-closed default working as intended, not a bug, but +it means a GitHub-loaded module can never be a confidential recipient until the +operator also places its digest in the local allowlist beside it. + +## Not a signature, yet + +`modules.toml` is a list of hashes an operator put on disk, so an attestation +means "this is the artifact the operator allowlisted", not "a release key +vouched for it". Signed release manifests — an org key in CI signing each +release's checksums — are the natural next layer: verification would produce +this same `Attestation` record and needs no wire-format change. + +## Using it + +```rust,ignore +let wallet = connection.proxy(WALLET, WALLET_PATH, WALLET)?; + +// Ask what the host verified before assembling the secret. `None` means the +// send will be refused: nothing owns the name, or it is not a verified module. +if wallet.attestation().await?.is_none() { + return Err(Error::failed("wallet is not an attested recipient")); +} + +let stored: bool = wallet.call_confidential("StoreKey", (key,)).await?; +``` + +A refusal arrives as `Error::NotAttested`, dotted name +`ai.tinyhumans.tinybus.Error.NotAttested`. It is deliberately distinct from +`NameHasNoOwner`: "not installed" and "not eligible for secrets" are different +problems with different fixes. + +## Rules that are load-bearing + +- **Attestation is bound to one name and held against one peer.** It dies with + the peer. A module that exits takes its attestation with it, and the next + process to claim the name inherits nothing — it answers ordinary calls and is + refused secrets. +- **Two names on one peer do not share trust.** The operator allowlisted an + artifact *as the wallet*, not as everything that module also answers to. +- A confidential **signal** is refused on ingress. A broadcast has no single + recipient to attest, so there is nothing the flag could mean. +- A confidential **call** must address a well-known name. A unique name + identifies a connection, not an artifact. +- A confidential **reply** inherits the flag and goes back to the caller. A key + derivation answers with a key, and a reply that quietly lost the flag would + leak on the way back what the call protected on the way out. +- An **error reply** never inherits it. Errors carry no value, and a + confidential error to a peer that just failed attestation would swallow the + reason it failed. +- The broker never fans a confidential message out to a match rule, and + `tinybus monitor` prints `` rather than the body. + +## Tests + +The enforcement rules are covered on the in-memory transport in `router.rs` and +`broker.rs`. The load-time seam — a real artifact, hashed off disk, becoming +attested — is covered by two opt-in tests that need a built `cdylib`: + +```sh +cargo build --example module_clock --all-features +TINYBUS_TEST_MODULE="$PWD/target/debug/examples/libmodule_clock.so" \ + cargo test --all-features -- --ignored +``` + +The artifact name above is Linux's (`libmodule_clock.so`); on macOS Cargo +builds `libmodule_clock.dylib` instead, so point `TINYBUS_TEST_MODULE` at that +file when running the same command there. diff --git a/docs/protocol.md b/docs/protocol.md index ba7c84a..e8a0e69 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -55,6 +55,7 @@ omitted field and a `null` field identically. | `interface` | interface name | `method_call`, `signal` | | `member` | member name | `method_call`, `signal` | | `error_name` | dotted string | `error` | +| `confidential` | bool, omitted when false | `method_call`, `method_return` | `body` is a positional JSON array for calls and signals, and a single JSON value for returns. `error` bodies are a string: the human-readable message, without @@ -63,6 +64,40 @@ the error name, which travels in `error_name`. A peer **may** set `sender`; the broker overwrites it unconditionally. Nothing downstream may trust a `sender` that did not come from the broker. +### `confidential` + +A peer **may** set `confidential`, and the broker does **not** overwrite it. The +asymmetry with `sender` is deliberate: the flag can only cause more +restrictions, so a peer that sets it restricts its own traffic and nobody +else's. + +A broker that sees it **must**: + +- refuse a `signal` carrying it, and refuse any message carrying it without a + `destination`; +- refuse a `method_call` carrying it unless the destination is a well-known name + owned by a recipient the host has attested, replying + `ai.tinyhumans.tinybus.Error.NotAttested`; +- never deliver the message to a match-rule subscriber, and never log its body. + +A recipient is attested only by being an in-process module whose artifact the +host hashed against its allowlist before loading it. A peer reached across a +transport is never attested and so never receives a confidential message. + +The field is optional and defaults to false, so an older broker parses the +message and routes it as an ordinary call. A sender that needs the guarantee +must therefore confirm it first, by calling `GetAttestation` and requiring a +non-null answer — a `null` answer, or an `UnknownMethod` error from a broker too +old to have the method, both mean the guarantee is unavailable. + +`confidential` covers the body of the message carrying it, and a bulk stream is +not that body. A stream's bytes travel as separate `Stream.Write` calls (see +[Bulk streams](#bulk-streams)) which carry no `confidential` flag and are +therefore routed without an attestation check — putting a `StreamRef` in a +confidential call protects the handle, not the payload it names. There is +currently no confidential stream; a secret that must be attested has to fit in +the body of the call itself. + ## Names | Kind | Grammar | @@ -102,6 +137,7 @@ interface `ai.tinyhumans.tinybus.Bus`. | `GetManifest` | `[name]` | that peer's manifest, or `null` | | `ListPeers` | `[]` | unique names, owned names, and peer manifests | | `GetNameOwner` | `[name]` | the owner's unique name, or `null` | +| `GetAttestation` | `[name]` | what the host verified about that owner, or `null` | | `AddMatch` | `[rule]` | `null` | | `RemoveMatch` | `[rule]` | `null` | | `ListModules` | `[]` | every module known to the embedded host |