From 71278efb57ebe522807bf67b883388e360139ef1 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Tue, 8 Sep 2026 13:05:38 -0700 Subject: [PATCH] Freeze IFC publications and simplify session checks Signed-off-by: Jordan Mecom --- crates/buzz-ifc/src/domain.rs | 5 + crates/buzz-ifc/src/lib.rs | 5 +- crates/buzz-ifc/src/session.rs | 157 +++++++++++++-------------- crates/buzz-ifc/src/session_tests.rs | 135 ++++++++++++++++------- 4 files changed, 176 insertions(+), 126 deletions(-) diff --git a/crates/buzz-ifc/src/domain.rs b/crates/buzz-ifc/src/domain.rs index eb6f157de2..d179a0a3ae 100644 --- a/crates/buzz-ifc/src/domain.rs +++ b/crates/buzz-ifc/src/domain.rs @@ -441,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 1cbab2fe03..892181f093 100644 --- a/crates/buzz-ifc/src/lib.rs +++ b/crates/buzz-ifc/src/lib.rs @@ -22,10 +22,7 @@ pub use domain::{ DomainFacts, DomainKey, ExecutionDomain, MembershipEpoch, OperationEffect, }; pub use label::{CommunityId, ConfidentialityLabel, LabelError, Principal, PrincipalError}; -pub use session::{ - AuthorizedPublication, IfcError, IfcSession, PublicationRequest, PublicationTarget, - ResourceLabel, -}; +pub use session::{AuthorizedPublication, IfcError, IfcSession, ResourceLabel}; #[cfg(test)] mod session_tests; diff --git a/crates/buzz-ifc/src/session.rs b/crates/buzz-ifc/src/session.rs index c687bf4ef0..f9f1f579e3 100644 --- a/crates/buzz-ifc/src/session.rs +++ b/crates/buzz-ifc/src/session.rs @@ -31,80 +31,53 @@ impl ResourceLabel { } } -/// The audience receiving a publication. +/// An outbound request that passed the session's capability and audience checks. /// -/// This type describes only the IFC destination. The trusted broker remains -/// responsible for ordinary product policy, including resolving and checking -/// the channel, message, URL, or other concrete destination named by the -/// operation payload. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PublicationTarget { - audience: ConfidentialityLabel, -} - -impl PublicationTarget { - /// Use an execution domain's authorized audience as a publication target. - pub fn from_domain(domain: &ExecutionDomain) -> Self { - Self { - audience: domain.audience.clone(), - } - } -} - -/// A broker request to execute one publication operation with an exact -/// payload. -pub struct PublicationRequest { - operation: String, - target: PublicationTarget, - payload: T, -} - -impl PublicationRequest { - /// Construct a request after the broker has resolved its IFC target. - pub fn new(operation: impl Into, target: PublicationTarget, payload: T) -> Self { - Self { - operation: operation.into(), - target, - payload, - } - } -} - -/// Proof that an exact publication request passed the session's capability -/// and information-flow 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. /// -/// A broker sink should accept this type rather than [`PublicationRequest`]. -/// Its fields are private and it is not cloneable, so only -/// [`IfcSession::publish`] can create the value that authorizes a sink call. +/// 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(), -/// target: todo!(), -/// payload: "unchecked", +/// 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 { +pub struct AuthorizedPublication { operation: String, - target: PublicationTarget, - payload: T, + destination: ConfidentialityLabel, + payload: Vec, } -impl AuthorizedPublication { +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) -> &T { + pub fn payload(&self) -> &[u8] { &self.payload } /// Consume the authorization and return the checked sink inputs. - pub fn into_parts(self) -> (String, PublicationTarget, T) { - (self.operation, self.target, self.payload) + pub fn into_parts(self) -> (String, ConfidentialityLabel, Vec) { + (self.operation, self.destination, self.payload) } } @@ -113,27 +86,26 @@ impl AuthorizedPublication { /// 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. These are the ordinary-flow checks described in Appendices F and G of -/// the [design paper](../../../docs/practical-information-flow-for-buzz-agents.md#appendix-f-reference-monitor-pseudocode). +/// 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 supplies the labels and serialized outbound request: /// /// ``` -/// # use buzz_ifc::{AuthorizedPublication, ExecutionDomain, IfcError, IfcSession, -/// # PublicationRequest, PublicationTarget, ResourceLabel}; -/// # fn broker_sink(_: AuthorizedPublication>) {} +/// # use buzz_ifc::{AuthorizedPublication, ConfidentialityLabel, ExecutionDomain, +/// # IfcError, IfcSession, ResourceLabel}; +/// # fn broker_sink(_: AuthorizedPublication) {} /// # fn run_turn( /// # domain: ExecutionDomain, /// # resource: &ResourceLabel, -/// # target: PublicationTarget, +/// # destination: &ConfidentialityLabel, +/// # request_bytes: Vec, /// # ) -> Result<(), IfcError> { -/// let mut session = IfcSession::enter(domain); -/// session.read(resource)?; +/// let session = IfcSession::enter(domain); /// session.call("buzz.read.current")?; +/// session.read(resource)?; /// -/// let authorization = session.publish(PublicationRequest::new( -/// "buzz.reply", -/// target, -/// b"hello".to_vec(), -/// ))?; +/// let authorization = session.publish("buzz.reply", destination, request_bytes)?; /// broker_sink(authorization); /// # Ok(()) /// # } @@ -161,13 +133,12 @@ impl IfcSession { self.domain.key() } - /// Check and record a labeled resource before exposing it to the agent. + /// Check a labeled resource before exposing it to the agent. /// - /// A failed read does not taint the session because the broker must not - /// deliver the rejected resource. Successful reads are recorded - /// monotonically; the domain audience already conservatively bounds output - /// in this coarse-grained Buzz model. - pub fn read(&mut self, resource: &ResourceLabel) -> Result<(), IfcError> { + /// 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. + pub fn read(&self, resource: &ResourceLabel) -> Result<(), IfcError> { if !resource.audience.can_flow_to(&self.domain.audience) { return Err(IfcError::ReadAudienceDenied); } @@ -183,15 +154,14 @@ impl IfcSession { return Err(IfcError::StaleResourceEpoch); } - self.flow.observe(&resource.audience); Ok(()) } /// Permanently record that unlabeled input reached the agent. /// - /// Ordinary publication remains blocked for the rest of the session. A - /// future declassification API may authorize a specific exceptional flow, - /// but this small wrapper deliberately provides no bypass. + /// 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(); } @@ -206,11 +176,30 @@ impl IfcSession { } /// Authorize an exact outbound payload for a checked broker sink. - pub fn publish( + /// + /// 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, - request: PublicationRequest, - ) -> Result, IfcError> { - match self.domain.capabilities.effect(&request.operation) { + operation: &str, + destination: &ConfidentialityLabel, + payload: Vec, + ) -> Result { + match self.domain.capabilities.effect(operation) { Some(OperationEffect::Publication) => {} Some(OperationEffect::NonEgressing) => { return Err(IfcError::NonEgressingRequiresCall); @@ -218,11 +207,11 @@ impl IfcSession { None => return Err(IfcError::CapabilityDenied), } - self.flow.check_egress(&request.target.audience)?; + self.flow.check_egress(destination)?; Ok(AuthorizedPublication { - operation: request.operation, - target: request.target, - payload: request.payload, + operation: operation.to_owned(), + destination: destination.clone(), + payload, }) } } @@ -230,7 +219,7 @@ impl IfcSession { /// Why an IFC session refused a broker action. #[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] pub enum IfcError { - /// The resource could be read by principals outside the session audience. + /// 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. diff --git a/crates/buzz-ifc/src/session_tests.rs b/crates/buzz-ifc/src/session_tests.rs index 51a8caf7e2..7ac7ecf318 100644 --- a/crates/buzz-ifc/src/session_tests.rs +++ b/crates/buzz-ifc/src/session_tests.rs @@ -1,4 +1,6 @@ +use std::cell::RefCell; use std::collections::{BTreeSet, HashMap}; +use std::rc::Rc; use nostr::Keys; use uuid::Uuid; @@ -96,7 +98,7 @@ fn owner_private_domain(community: CommunityId, channel: u128, epoch: &str) -> E } fn deliver_to_agent( - session: &mut IfcSession, + session: &IfcSession, label: &ResourceLabel, value: &str, inbox: &mut Vec, @@ -107,37 +109,78 @@ fn deliver_to_agent( } fn execute_publication( - authorization: AuthorizedPublication, - sink_log: &mut Vec<(String, String)>, + authorization: AuthorizedPublication, + sink_log: &mut Vec<(String, ConfidentialityLabel, Vec)>, ) { - let (operation, _checked_target, payload) = authorization.into_parts(); - sink_log.push((operation, payload)); + sink_log.push(authorization.into_parts()); } -/// This is the intended broker-facing happy path. The broker checks a resource -/// before delivery and gives its sink only the unforgeable result returned by -/// `publish`, rather than executing an unchecked `PublicationRequest`. +/// 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 target = PublicationTarget::from_domain(&domain); - let mut session = IfcSession::enter(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(); - deliver_to_agent(&mut session, &resource, "question", &mut inbox) - .expect("broker may deliver the current conversation"); 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(PublicationRequest::new(REPLY, target, "answer".to_owned())) + .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(), "answer".to_owned())]); + 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. @@ -148,17 +191,17 @@ 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 target = PublicationTarget::from_domain(&group); - let mut session = IfcSession::enter(group); + let destination = group.audience().clone(); + let session = IfcSession::enter(group); let mut inbox = Vec::new(); assert_eq!( - deliver_to_agent(&mut session, &resource, "alice secret", &mut inbox), + deliver_to_agent(&session, &resource, "alice secret", &mut inbox), Err(IfcError::ReadAudienceDenied) ); assert!(inbox.is_empty()); assert!(session - .publish(PublicationRequest::new(REPLY, target, "safe")) + .publish(REPLY, &destination, b"safe".to_vec()) .is_ok()); } @@ -179,11 +222,11 @@ fn egressing_operation_cannot_use_the_call_path() { #[test] fn non_egressing_operation_cannot_use_the_publish_path() { let domain = public_domain(community(1), 10, "community:v1"); - let target = PublicationTarget::from_domain(&domain); + let destination = domain.audience().clone(); let session = IfcSession::enter(domain); assert!(matches!( - session.publish(PublicationRequest::new(READ, target, "payload")), + session.publish(READ, &destination, b"payload".to_vec()), Err(IfcError::NonEgressingRequiresCall) )); } @@ -194,12 +237,12 @@ fn non_egressing_operation_cannot_use_the_publish_path() { #[test] fn operation_absent_from_the_domain_is_denied() { let domain = public_domain(community(1), 10, "community:v1"); - let target = PublicationTarget::from_domain(&domain); + let destination = domain.audience().clone(); let session = IfcSession::enter(domain); assert_eq!(session.call("email.send"), Err(IfcError::CapabilityDenied)); assert!(matches!( - session.publish(PublicationRequest::new("email.send", target, "payload")), + session.publish("email.send", &destination, b"payload".to_vec()), Err(IfcError::CapabilityDenied) )); } @@ -210,12 +253,12 @@ fn operation_absent_from_the_domain_is_denied() { 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 target = PublicationTarget::from_domain(&public); + let destination = public.audience(); let session = IfcSession::enter(private); assert_eq!( session - .publish(PublicationRequest::new(REPLY, target, "secret")) + .publish(REPLY, destination, b"secret".to_vec()) .err(), Some(IfcError::InformationFlow( ifc_core::EgressError::DestinationWidensReaders @@ -229,12 +272,12 @@ fn private_session_cannot_publish_to_a_public_audience() { #[test] fn public_session_may_publish_to_a_private_audience() { let public = public_domain(community(1), 10, "community:v1"); - let private = restricted_domain(community(1), 20, "membership:v1", &[1]); - let target = PublicationTarget::from_domain(&private); + // 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(PublicationRequest::new(REPLY, target, "public data")) + .publish(REPLY, &destination, b"public data".to_vec()) .is_ok()); } @@ -244,7 +287,7 @@ fn public_session_may_publish_to_a_private_audience() { fn unknown_input_permanently_blocks_publication() { let domain = public_domain(community(1), 10, "community:v1"); let resource = ResourceLabel::from_domain(&domain); - let target = PublicationTarget::from_domain(&domain); + let destination = domain.audience().clone(); let mut session = IfcSession::enter(domain); session.mark_unknown_input(); @@ -253,7 +296,7 @@ fn unknown_input_permanently_blocks_publication() { .expect("a later labeled read is still admissible"); assert_eq!( session - .publish(PublicationRequest::new(REPLY, target, "output")) + .publish(REPLY, &destination, b"output".to_vec()) .err(), Some(IfcError::InformationFlow( ifc_core::EgressError::UnresolvedInput @@ -268,7 +311,7 @@ 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 mut session = IfcSession::enter(current); + let session = IfcSession::enter(current); assert_eq!(session.read(&resource), Err(IfcError::StaleResourceEpoch)); } @@ -277,14 +320,27 @@ fn same_conversation_rejects_a_stale_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 mut session = IfcSession::enter(private); + 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 @@ -295,7 +351,7 @@ 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 mut session = IfcSession::enter(owner_private); + let session = IfcSession::enter(owner_private); assert_eq!(session.read(&resource), Ok(())); } @@ -307,7 +363,7 @@ 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 mut session = IfcSession::enter(destination); + let session = IfcSession::enter(destination); assert_eq!(session.read(&resource), Err(IfcError::ReadContextDenied)); } @@ -318,13 +374,16 @@ fn equal_audiences_do_not_merge_restricted_conversation_contexts() { fn publication_cannot_cross_communities() { let source = public_domain(community(1), 10, "community:v1"); let destination = public_domain(community(2), 10, "community:v1"); - let target = PublicationTarget::from_domain(&destination); let session = IfcSession::enter(source); - assert!(matches!( - session.publish(PublicationRequest::new(REPLY, target, "output")), - Err(IfcError::InformationFlow(_)) - )); + 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