diff --git a/crates/buzz-ifc/Cargo.toml b/crates/buzz-ifc/Cargo.toml index d34b57059ae..e1c10204fb6 100644 --- a/crates/buzz-ifc/Cargo.toml +++ b/crates/buzz-ifc/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "Buzz execution-domain derivation for information-flow control" +description = "Buzz execution domains and broker-side information-flow checks" [dependencies] buzz-core = { workspace = true } diff --git a/crates/buzz-ifc/src/domain.rs b/crates/buzz-ifc/src/domain.rs index 16b1a0b7267..d179a0a3ae3 100644 --- a/crates/buzz-ifc/src/domain.rs +++ b/crates/buzz-ifc/src/domain.rs @@ -45,6 +45,26 @@ impl DomainContext { } } + /// Whether this retained-state context may admit a resource from `source`. + /// + /// Public community data may enter any context in that community. A + /// conversation admits only its own retained data. Owner-private work may + /// also narrow conversation data to the owner when the separate audience + /// check permits that flow; it never admits another owner's private state. + pub(crate) fn permits(&self, source: &Self) -> bool { + if self.community() != source.community() { + return false; + } + + match source { + Self::CommunityPublic(_) => true, + Self::Conversation { .. } => { + self == source || matches!(self, Self::OwnerPrivate { .. }) + } + Self::OwnerPrivate { .. } => self == source, + } + } + pub(crate) fn stable_hash(&self, hasher: &mut Sha256) { match self { Self::CommunityPublic(community) => { @@ -72,7 +92,8 @@ impl DomainContext { /// /// The broker configures this; the agent does not get to classify its own calls. /// For a publication, permission to call the operation is not enough: the broker -/// must also check whether the information may flow to the destination's readers. +/// must use [`crate::IfcSession::publish`] to check whether the information may +/// flow to the destination's readers. #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum OperationEffect { @@ -153,6 +174,10 @@ impl CapabilitySet { Self(effective) } + pub(crate) fn effect(&self, operation: &str) -> Option { + self.0.get(operation).copied() + } + fn stable_hash(&self, hasher: &mut Sha256) { hash_field(hasher, &(self.0.len() as u64).to_be_bytes()); for (name, effect) in &self.0 { @@ -416,6 +441,11 @@ impl ExecutionDomain { }) } + /// Return the label describing who may receive this domain's output. + pub fn audience(&self) -> &ConfidentialityLabel { + &self.audience + } + /// Hash every domain field into a stable key for session lookup. /// /// Changing any field changes the key. The broker must use this complete key diff --git a/crates/buzz-ifc/src/lib.rs b/crates/buzz-ifc/src/lib.rs index 2d164049e0f..17940de9d1f 100644 --- a/crates/buzz-ifc/src/lib.rs +++ b/crates/buzz-ifc/src/lib.rs @@ -1,4 +1,4 @@ -//! Derives an agent's execution domain from Buzz membership and capability policy. +//! Derives Buzz execution domains and checks broker reads, calls, and publications. //! //! An [`ExecutionDomain`] records which agent is running, who may receive its //! output, which conversations may share its saved state, and which operations @@ -6,18 +6,29 @@ //! and membership version, so a change produces a different key. //! //! The broker must verify events and membership before supplying [`DomainFacts`]. -//! It must also use the resulting key when selecting a session. This crate -//! computes the domain; it does not verify signatures, manage sessions, or -//! enforce tool calls. +//! It uses the resulting key to select both the agent's saved state and its +//! [`IfcSession`]. Keep that session across turns: recreating it would forget +//! whether unlabeled input had reached the agent. +//! +//! Check reads before delivering data, calls before executing them, and +//! publications with [`IfcSession::publish`] before sending them to a sink. +//! This crate does not verify signatures, check live membership, load saved +//! sessions, isolate agent processes, or execute operations itself. + +#![forbid(unsafe_code)] mod domain; mod label; +mod session; pub use domain::{ derive_execution_domain, CapabilityPolicy, CapabilitySet, ConversationKind, DerivationError, DomainFacts, DomainKey, ExecutionDomain, MembershipEpoch, OperationEffect, }; pub use label::{CommunityId, ConfidentialityLabel, LabelError, Principal, PrincipalError}; +pub use session::{AuthorizedPublication, IfcError, IfcSession, ResourceLabel}; +#[cfg(test)] +mod session_tests; #[cfg(test)] mod tests; diff --git a/crates/buzz-ifc/src/session.rs b/crates/buzz-ifc/src/session.rs new file mode 100644 index 00000000000..bf9180173d6 --- /dev/null +++ b/crates/buzz-ifc/src/session.rs @@ -0,0 +1,254 @@ +use ifc_core::{EgressError, FlowState}; + +use crate::domain::{DomainContext, DomainKey, ExecutionDomain, MembershipEpoch, OperationEffect}; +use crate::label::{CommunityId, ConfidentialityLabel, Principal}; + +/// The security metadata the broker checks before exposing a resource to an +/// agent session. +/// +/// Constructing this from the domain where the resource originated keeps its +/// audience, retained-state context, and membership epoch together. Public +/// community data does not carry an epoch because any context in that +/// community may read it. Restricted data retains the epoch under which its +/// source domain was authorized. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResourceLabel { + audience: ConfidentialityLabel, + context: DomainContext, + epoch: Option, +} + +impl ResourceLabel { + /// Label a resource with the security metadata of its source domain. + pub fn from_domain(domain: &ExecutionDomain) -> Self { + let epoch = (!matches!(domain.context, DomainContext::CommunityPublic(_))) + .then(|| domain.epoch.clone()); + Self { + audience: domain.audience.clone(), + context: domain.context.clone(), + epoch, + } + } +} + +/// An outbound request that passed the session's capability and audience checks. +/// +/// The request owns its serialized bytes and destination label. Neither can +/// change through a shared reference after authorization. A broker sink should +/// accept this type and execute those bytes, without substituting a new payload +/// or resolving destination fields from mutable agent state. +/// +/// Only [`IfcSession::publish`] can construct this value. It cannot be cloned. +/// +/// ```compile_fail +/// # fn forge(destination: buzz_ifc::ConfidentialityLabel) { +/// let forged = buzz_ifc::AuthorizedPublication { +/// operation: "buzz.reply".to_owned(), +/// destination, +/// payload: b"unchecked".to_vec(), +/// }; +/// # } +/// ``` +/// +/// Payload access is read-only until the sink consumes the authorization: +/// +/// ```compile_fail +/// # fn change(mut authorization: buzz_ifc::AuthorizedPublication) { +/// authorization.payload()[0] = b'!'; +/// # } +/// ``` +#[must_use = "the authorization must be consumed by the publication sink"] +pub struct AuthorizedPublication { + operation: String, + destination: ConfidentialityLabel, + payload: Vec, +} + +impl AuthorizedPublication { + /// Return the checked operation name. + pub fn operation(&self) -> &str { + &self.operation + } + + /// Return the exact payload covered by the information-flow decision. + pub fn payload(&self) -> &[u8] { + &self.payload + } + + /// Consume the authorization and return the checked sink inputs. + pub fn into_parts(self) -> (String, ConfidentialityLabel, Vec) { + (self.operation, self.destination, self.payload) + } +} + +/// Information-flow state for one retained agent execution domain. +/// +/// The broker calls [`read`](Self::read) before delivering data to the agent, +/// [`call`](Self::call) before executing operations that cannot publish, and +/// [`publish`](Self::publish) before handing an exact outbound payload to a +/// sink. The audience checks follow the flow ordering in +/// [Appendix G of the design paper](../../../docs/practical-information-flow-for-buzz-agents.md#appendix-g-security-labels-as-a-lattice). +/// +/// The broker keeps this value for as long as it keeps the agent's history, +/// files, or other state. The same domain key selects both. This example uses a +/// broker-owned pool so a later turn cannot reset the session's restrictions: +/// +/// ``` +/// # use std::collections::HashMap; +/// # use buzz_ifc::{AuthorizedPublication, ConfidentialityLabel, ExecutionDomain, +/// # DomainKey, IfcError, IfcSession, ResourceLabel}; +/// # fn broker_sink(_: AuthorizedPublication) {} +/// # fn run_turn( +/// # sessions: &mut HashMap, +/// # domain: ExecutionDomain, +/// # resource: &ResourceLabel, +/// # destination: &ConfidentialityLabel, +/// # request_bytes: Vec, +/// # ) -> Result<(), IfcError> { +/// let session = sessions +/// .entry(domain.key()) +/// .or_insert_with(|| IfcSession::enter(domain)); +/// session.call("buzz.read.current")?; +/// session.read(resource)?; +/// +/// let authorization = session.publish("buzz.reply", destination, request_bytes)?; +/// broker_sink(authorization); +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug)] +pub struct IfcSession { + domain: ExecutionDomain, + flow: FlowState, +} + +impl IfcSession { + /// Enter an execution domain selected by the trusted broker. + /// + /// The domain's audience is observed immediately because retained agent + /// state and broker-provided instructions may already influence the next + /// output before the first explicit resource read. + /// + /// Do not replace an existing session while retaining the agent's state. + /// A fresh session would forget any earlier [`Self::mark_unknown_input`]. + pub fn enter(domain: ExecutionDomain) -> Self { + let mut flow = FlowState::default(); + flow.observe(&domain.audience); + Self { domain, flow } + } + + /// Return the key the broker uses to route later turns to this session. + pub fn domain_key(&self) -> DomainKey { + self.domain.key() + } + + /// Check a labeled resource before exposing it to the agent. + /// + /// The broker must not deliver a rejected resource. Every admitted resource + /// is readable by the domain's entire audience, so reading it does not + /// further restrict output: `enter` already applied that audience. + /// The broker must check current resource permissions separately; matching + /// stored membership epochs does not prove that membership is still current. + pub fn read(&self, resource: &ResourceLabel) -> Result<(), IfcError> { + if !resource.audience.can_flow_to(&self.domain.audience) { + return Err(IfcError::ReadAudienceDenied); + } + if !self.domain.context.permits(&resource.context) { + return Err(IfcError::ReadContextDenied); + } + if resource.context == self.domain.context + && resource + .epoch + .as_ref() + .is_some_and(|epoch| epoch != &self.domain.epoch) + { + return Err(IfcError::StaleResourceEpoch); + } + + Ok(()) + } + + /// Permanently record that unlabeled input reached the agent. + /// + /// No new publication can be authorized for the rest of the session. + /// Already authorized requests still contain only their earlier, frozen + /// bytes. They cannot be updated to include the unknown input. + pub fn mark_unknown_input(&mut self) { + self.flow.mark_unknown(); + } + + /// Authorize an admitted operation that cannot publish information. + pub fn call(&self, operation: &str) -> Result<(), IfcError> { + match self.domain.capabilities.effect(operation) { + Some(OperationEffect::NonEgressing) => Ok(()), + Some(OperationEffect::Publication) => Err(IfcError::PublicationRequiresPublish), + None => Err(IfcError::CapabilityDenied), + } + } + + /// Authorize an exact outbound payload for a checked broker sink. + /// + /// The broker must serialize the complete request, including its concrete + /// destination, and resolve `destination` from that request before calling + /// this method. The sink must execute the returned bytes as checked. This + /// method does not parse the request or check current destination policy. + /// + /// Owned bytes prevent a caller from changing the payload through a shared + /// mutable value after it passes the checks: + /// + /// ```compile_fail + /// # use buzz_ifc::{ConfidentialityLabel, IfcSession}; + /// # use std::{cell::RefCell, rc::Rc}; + /// # fn publish_shared(session: &IfcSession, destination: &ConfidentialityLabel) { + /// let payload = Rc::new(RefCell::new(b"hello".to_vec())); + /// session.publish("buzz.reply", destination, payload); + /// # } + /// ``` + pub fn publish( + &self, + operation: &str, + destination: &ConfidentialityLabel, + payload: Vec, + ) -> Result { + match self.domain.capabilities.effect(operation) { + Some(OperationEffect::Publication) => {} + Some(OperationEffect::NonEgressing) => { + return Err(IfcError::NonEgressingRequiresCall); + } + None => return Err(IfcError::CapabilityDenied), + } + + self.flow.check_egress(destination)?; + Ok(AuthorizedPublication { + operation: operation.to_owned(), + destination: destination.clone(), + payload, + }) + } +} + +/// Why an IFC session refused a broker action. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum IfcError { + /// Some session readers are not allowed to read the resource. + #[error("resource audience is not safe for this execution domain")] + ReadAudienceDenied, + /// The resource belongs to retained state this domain may not reuse. + #[error("resource belongs to a different retained-state context")] + ReadContextDenied, + /// Restricted state was created under a different membership epoch. + #[error("resource membership epoch does not match the execution domain")] + StaleResourceEpoch, + /// The execution domain does not admit the requested operation. + #[error("operation is not admitted by this execution domain")] + CapabilityDenied, + /// An egressing operation was presented to the unchecked call path. + #[error("publication operation must use IfcSession::publish")] + PublicationRequiresPublish, + /// A non-egressing operation was presented to the publication path. + #[error("non-egressing operation must use IfcSession::call")] + NonEgressingRequiresCall, + /// Accumulated information cannot flow to the requested audience. + #[error("information-flow check failed: {0}")] + InformationFlow(#[from] EgressError), +} diff --git a/crates/buzz-ifc/src/session_tests.rs b/crates/buzz-ifc/src/session_tests.rs new file mode 100644 index 00000000000..6513c31456f --- /dev/null +++ b/crates/buzz-ifc/src/session_tests.rs @@ -0,0 +1,483 @@ +use std::cell::RefCell; +use std::collections::{BTreeSet, HashMap}; +use std::rc::Rc; + +use nostr::Keys; +use uuid::Uuid; + +use super::*; + +const READ: &str = "buzz.read.current"; +const REPLY: &str = "buzz.reply"; + +fn principal(value: u8) -> Principal { + let keys = Keys::parse(&format!("{value:064x}")).expect("test secret key"); + Principal::from_public_key(&keys.public_key()).expect("valid test principal") +} + +fn community(value: u128) -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(value)) +} + +fn policy() -> CapabilityPolicy { + let operations = || { + CapabilitySet::from_operations([ + (READ, OperationEffect::NonEgressing), + (REPLY, OperationEffect::Publication), + ]) + }; + CapabilityPolicy::new(operations(), operations()) +} + +fn public_domain(community: CommunityId, channel: u128, epoch: &str) -> ExecutionDomain { + derive_execution_domain( + DomainFacts { + community, + channel_id: Uuid::from_u128(channel), + kind: ConversationKind::Public, + epoch: MembershipEpoch::new(epoch), + members: BTreeSet::new(), + executing_agent: principal(9), + requesters: BTreeSet::from([principal(1)]), + system_principal: None, + owner: Some(principal(1)), + }, + &policy(), + ) + .expect("valid public domain") +} + +fn restricted_domain( + community: CommunityId, + channel: u128, + epoch: &str, + readers: &[u8], +) -> ExecutionDomain { + let agent = principal(9); + let members = readers + .iter() + .copied() + .map(principal) + .chain([agent]) + .collect(); + derive_execution_domain( + DomainFacts { + community, + channel_id: Uuid::from_u128(channel), + kind: ConversationKind::Restricted, + epoch: MembershipEpoch::new(epoch), + members, + executing_agent: agent, + requesters: BTreeSet::from([principal(readers[0])]), + system_principal: None, + owner: Some(principal(1)), + }, + &policy(), + ) + .expect("valid restricted domain") +} + +fn owner_private_domain(community: CommunityId, channel: u128, epoch: &str) -> ExecutionDomain { + let owner = principal(1); + let agent = principal(9); + derive_execution_domain( + DomainFacts { + community, + channel_id: Uuid::from_u128(channel), + kind: ConversationKind::DirectMessage, + epoch: MembershipEpoch::new(epoch), + members: BTreeSet::from([agent, owner]), + executing_agent: agent, + requesters: BTreeSet::from([owner]), + system_principal: None, + owner: Some(owner), + }, + &policy(), + ) + .expect("valid owner-private domain") +} + +fn deliver_to_agent( + session: &IfcSession, + label: &ResourceLabel, + value: &str, + inbox: &mut Vec, +) -> Result<(), IfcError> { + session.read(label)?; + inbox.push(value.to_owned()); + Ok(()) +} + +fn execute_publication( + authorization: AuthorizedPublication, + sink_log: &mut Vec<(String, ConfidentialityLabel, Vec)>, +) { + sink_log.push(authorization.into_parts()); +} + +/// The broker checks a resource before delivery. The sink receives the checked +/// operation, audience, and bytes together, including the concrete destination. +#[test] +fn broker_turn_uses_one_small_checked_surface() { + let domain = restricted_domain(community(1), 10, "membership:v1", &[1, 2]); + let resource = ResourceLabel::from_domain(&domain); + let destination = domain.audience().clone(); + let session = IfcSession::enter(domain); + let mut inbox = Vec::new(); + let mut sink_log = Vec::new(); + let request = br#"{"channel":10,"text":"answer"}"#.to_vec(); + + session + .call(READ) + .expect("read operation cannot publish information"); + deliver_to_agent(&session, &resource, "question", &mut inbox) + .expect("broker may deliver the current conversation"); + let authorization = session + .publish(REPLY, &destination, request.clone()) + .expect("reply may flow to the current audience"); + execute_publication(authorization, &mut sink_log); + + assert_eq!(inbox, ["question"]); + assert_eq!(sink_log, [(REPLY.to_owned(), destination, request)]); +} + +/// Shared request state can change while a publication waits for its sink. +/// Those changes must not alter an existing authorization, even if the session +/// has since received unknown input and can no longer authorize new requests. +#[test] +fn publication_keeps_the_checked_bytes_operation_and_destination() { + let domain = public_domain(community(1), 10, "community:v1"); + let mut destination = domain.audience().clone(); + let checked_destination = destination.clone(); + let mut session = IfcSession::enter(domain); + let mut operation = REPLY.to_owned(); + let original = br#"{"channel":10,"text":"public answer"}"#.to_vec(); + let shared_request = Rc::new(RefCell::new(original.clone())); + let writer = Rc::clone(&shared_request); + let authorization = session + .publish(&operation, &destination, shared_request.borrow().clone()) + .expect("authorize an owned snapshot of the request"); + + session.mark_unknown_input(); + *writer.borrow_mut() = br#"{"channel":20,"text":"unknown secret"}"#.to_vec(); + operation.clear(); + destination = ConfidentialityLabel::public(community(2)); + + assert_eq!( + session + .publish(REPLY, &checked_destination, shared_request.borrow().clone()) + .err(), + Some(IfcError::InformationFlow( + ifc_core::EgressError::UnresolvedInput + )) + ); + assert_ne!(destination, checked_destination); + assert_ne!(operation, authorization.operation()); + assert_eq!(authorization.operation(), REPLY); + assert_eq!(authorization.payload(), original); + let mut sink_log = Vec::new(); + execute_publication(authorization, &mut sink_log); + assert_eq!( + sink_log, + [(REPLY.to_owned(), checked_destination, original)] + ); +} + +/// A rejected read must never reach the agent and must not taint the session. +/// This binds the test to the broker seam: `read` runs before the value is +/// appended to the simulated agent inbox. +#[test] +fn broker_does_not_deliver_a_resource_with_a_narrower_audience() { + let group = restricted_domain(community(1), 10, "membership:v1", &[1, 2]); + let alice_only = restricted_domain(community(1), 20, "membership:v1", &[1]); + let resource = ResourceLabel::from_domain(&alice_only); + let destination = group.audience().clone(); + let session = IfcSession::enter(group); + let mut inbox = Vec::new(); + + assert_eq!( + deliver_to_agent(&session, &resource, "alice secret", &mut inbox), + Err(IfcError::ReadAudienceDenied) + ); + assert!(inbox.is_empty()); + assert!(session + .publish(REPLY, &destination, b"safe".to_vec()) + .is_ok()); +} + +/// Publication operations must not enter through `call`, which performs no +/// destination-flow check. Misclassifying this path would bypass IFC entirely. +#[test] +fn egressing_operation_cannot_use_the_call_path() { + let session = IfcSession::enter(public_domain(community(1), 10, "community:v1")); + + assert_eq!( + session.call(REPLY), + Err(IfcError::PublicationRequiresPublish) + ); +} + +/// The inverse mismatch is also rejected so every operation has one obvious +/// broker API and policy cannot silently change how a call is executed. +#[test] +fn non_egressing_operation_cannot_use_the_publish_path() { + let domain = public_domain(community(1), 10, "community:v1"); + let destination = domain.audience().clone(); + let session = IfcSession::enter(domain); + + assert!(matches!( + session.publish(READ, &destination, b"payload".to_vec()), + Err(IfcError::NonEgressingRequiresCall) + )); +} + +/// Capability admission fails closed on both paths. An operation name supplied +/// by an agent cannot become authority merely because the broker recognizes +/// how to execute it. +#[test] +fn operation_absent_from_the_domain_is_denied() { + let domain = public_domain(community(1), 10, "community:v1"); + let destination = domain.audience().clone(); + let session = IfcSession::enter(domain); + + assert_eq!(session.call("email.send"), Err(IfcError::CapabilityDenied)); + assert!(matches!( + session.publish("email.send", &destination, b"payload".to_vec()), + Err(IfcError::CapabilityDenied) + )); +} + +/// Accumulated private state must not be widened to a public audience. This is +/// the central no-write-down confidentiality invariant at the checked sink. +#[test] +fn private_session_cannot_publish_to_a_public_audience() { + let private = restricted_domain(community(1), 10, "membership:v1", &[1, 2]); + let public = public_domain(community(1), 20, "community:v1"); + let destination = public.audience(); + let session = IfcSession::enter(private); + + assert_eq!( + session + .publish(REPLY, destination, b"secret".to_vec()) + .err(), + Some(IfcError::InformationFlow( + ifc_core::EgressError::DestinationWidensReaders + )) + ); +} + +/// Public information may be sent to fewer readers. Reversing this ordering is +/// an easy reader-set lattice bug that would reject safe confidentiality +/// narrowing while potentially allowing the unsafe direction above. +#[test] +fn public_session_may_publish_to_a_private_audience() { + let public = public_domain(community(1), 10, "community:v1"); + // A destination needs an audience, not an agent execution domain. + let destination = ConfidentialityLabel::restricted_to(community(1), principal(1)); + let session = IfcSession::enter(public); + + assert!(session + .publish(REPLY, &destination, b"public data".to_vec()) + .is_ok()); +} + +/// An unknown input permanently poisons ordinary egress. A later labeled read +/// must not reset the flag and accidentally launder unknown data. +#[test] +fn unknown_input_permanently_blocks_publication() { + let domain = public_domain(community(1), 10, "community:v1"); + let resource = ResourceLabel::from_domain(&domain); + let destination = domain.audience().clone(); + let mut session = IfcSession::enter(domain); + + session.mark_unknown_input(); + session + .read(&resource) + .expect("a later labeled read is still admissible"); + assert_eq!( + session + .publish(REPLY, &destination, b"output".to_vec()) + .err(), + Some(IfcError::InformationFlow( + ifc_core::EgressError::UnresolvedInput + )) + ); +} + +/// Reusing agent history also reuses its IFC state. Two public channels share +/// a domain, so routing the next turn through another channel must not clear +/// the unknown-input flag. This exercises the pool lookup used in the example. +#[test] +fn unknown_input_survives_reusing_a_session_in_another_public_channel() { + let first_turn = public_domain(community(1), 10, "community:v1"); + let next_turn = public_domain(community(1), 20, "community:v1"); + let resource = ResourceLabel::from_domain(&next_turn); + let destination = next_turn.audience().clone(); + let mut pool = HashMap::new(); + + pool.entry(first_turn.key()) + .or_insert_with(|| IfcSession::enter(first_turn)) + .mark_unknown_input(); + + let session = pool + .entry(next_turn.key()) + .or_insert_with(|| IfcSession::enter(next_turn)); + session + .call(READ) + .expect("the read operation remains allowed"); + session.read(&resource).expect("public input is admissible"); + assert_eq!( + session + .publish(REPLY, &destination, b"output".to_vec()) + .err(), + Some(IfcError::InformationFlow( + ifc_core::EgressError::UnresolvedInput + )) + ); + assert_eq!(pool.len(), 1); +} + +/// Retained restricted state from an older membership snapshot cannot enter a +/// newly routed session for the same conversation. +#[test] +fn same_conversation_rejects_a_stale_membership_epoch() { + let old = restricted_domain(community(1), 10, "membership:v1", &[1, 2]); + let current = restricted_domain(community(1), 10, "membership:v2", &[1, 2]); + let resource = ResourceLabel::from_domain(&old); + let session = IfcSession::enter(current); + + assert_eq!(session.read(&resource), Err(IfcError::StaleResourceEpoch)); +} + +/// Public community data intentionally has no conversation membership epoch, +/// so a restricted session may read it without comparing unrelated epochs. +/// This catches the earlier design bug where public data inherited an epoch +/// and was rejected by every private domain with a different epoch. +/// Reading that data must not make the session's private state public. +#[test] +fn private_session_may_read_public_data_from_its_community() { + let public = public_domain(community(1), 20, "community:v7"); + let private = restricted_domain(community(1), 10, "membership:v2", &[1, 2]); + let resource = ResourceLabel::from_domain(&public); + let private_audience = private.audience().clone(); + let session = IfcSession::enter(private); + + assert_eq!(session.read(&resource), Ok(())); + assert_eq!( + session + .publish(REPLY, public.audience(), b"private state".to_vec()) + .err(), + Some(IfcError::InformationFlow( + ifc_core::EgressError::DestinationWidensReaders + )) + ); + assert!(session + .publish(REPLY, &private_audience, b"private state".to_vec()) + .is_ok()); +} + +/// Owner-private work may explicitly import conversation data when the owner +/// is an authorized reader. Its output is narrowed to the owner, while another +/// owner's private state remains protected by the exact-context rule. +#[test] +fn owner_private_session_may_read_conversation_data_safe_for_its_owner() { + let conversation = restricted_domain(community(1), 20, "membership:v7", &[1, 2]); + let owner_private = owner_private_domain(community(1), 10, "membership:v2"); + let resource = ResourceLabel::from_domain(&conversation); + let session = IfcSession::enter(owner_private); + + assert_eq!(session.read(&resource), Ok(())); +} + +/// Equal reader sets do not make two conversations the same retained-state +/// context. Otherwise one private channel could inject history into another. +#[test] +fn equal_audiences_do_not_merge_restricted_conversation_contexts() { + let source = restricted_domain(community(1), 10, "membership:v1", &[1, 2]); + let destination = restricted_domain(community(1), 20, "membership:v1", &[1, 2]); + let resource = ResourceLabel::from_domain(&source); + let session = IfcSession::enter(destination); + + assert_eq!(session.read(&resource), Err(IfcError::ReadContextDenied)); +} + +/// The owner may bring conversation data into owner-private work, but not the +/// reverse. Equal audiences must not let an ordinary conversation import the +/// owner's private history or memory. +#[test] +fn owner_private_state_cannot_enter_a_conversation_with_the_same_audience() { + let owner_private = owner_private_domain(community(1), 10, "membership:v1"); + let conversation = restricted_domain(community(1), 20, "membership:v1", &[1]); + assert_eq!(owner_private.audience(), conversation.audience()); + let resource = ResourceLabel::from_domain(&owner_private); + let session = IfcSession::enter(conversation); + + assert_eq!(session.read(&resource), Err(IfcError::ReadContextDenied)); +} + +/// Public means public within one community, not readable across all of them. +/// A cross-community resource must be rejected before any data is delivered. +#[test] +fn public_resource_cannot_cross_communities() { + let source = public_domain(community(1), 10, "community:v1"); + let destination = public_domain(community(2), 10, "community:v1"); + let resource = ResourceLabel::from_domain(&source); + let session = IfcSession::enter(destination); + let mut inbox = Vec::new(); + + assert_eq!( + deliver_to_agent(&session, &resource, "other community", &mut inbox), + Err(IfcError::ReadAudienceDenied) + ); + assert!(inbox.is_empty()); +} + +/// Universes are isolated even when two communities happen to contain the +/// same principals. A target in another community is never a valid IFC sink. +#[test] +fn publication_cannot_cross_communities() { + let source = public_domain(community(1), 10, "community:v1"); + let destination = public_domain(community(2), 10, "community:v1"); + let session = IfcSession::enter(source); + + assert_eq!( + session + .publish(REPLY, destination.audience(), b"output".to_vec()) + .err(), + Some(IfcError::InformationFlow( + ifc_core::EgressError::DestinationUniverseMismatch + )) + ); +} + +/// This models the broker's retained-session pool. Public channels share one +/// community domain, while restricted channel identity and membership epoch +/// each select different state. +#[test] +fn broker_routes_retained_sessions_by_complete_domain_key() { + let public_a = public_domain(community(1), 10, "community:v1"); + let public_b = public_domain(community(1), 20, "community:v1"); + let restricted_a = restricted_domain(community(1), 10, "membership:v1", &[1, 2]); + let restricted_b = restricted_domain(community(1), 20, "membership:v1", &[1, 2]); + let restricted_new_epoch = restricted_domain(community(1), 10, "membership:v2", &[1, 2]); + let public_key = public_a.key(); + let domains = [ + public_a, + public_b, + restricted_a, + restricted_b, + restricted_new_epoch, + ]; + let mut pool = HashMap::new(); + + for domain in domains { + pool.entry(domain.key()) + .or_insert_with(|| IfcSession::enter(domain)); + } + + assert_eq!(pool.len(), 4); + assert_eq!( + pool.get(&public_key).map(IfcSession::domain_key), + Some(public_key) + ); +}