Freeze IFC publications and simplify session checks

Signed-off-by: Jordan Mecom <jm@squareup.com>
This commit is contained in:
Jordan Mecom
2026-09-08 13:05:38 -07:00
parent c205aba5a7
commit 71278efb57
4 changed files with 176 additions and 126 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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<T> {
operation: String,
target: PublicationTarget,
payload: T,
}
impl<T> PublicationRequest<T> {
/// Construct a request after the broker has resolved its IFC target.
pub fn new(operation: impl Into<String>, 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<T> {
pub struct AuthorizedPublication {
operation: String,
target: PublicationTarget,
payload: T,
destination: ConfidentialityLabel,
payload: Vec<u8>,
}
impl<T> AuthorizedPublication<T> {
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<u8>) {
(self.operation, self.destination, self.payload)
}
}
@@ -113,27 +86,26 @@ impl<T> AuthorizedPublication<T> {
/// 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<Vec<u8>>) {}
/// # 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<u8>,
/// # ) -> 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<T>(
///
/// 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<T>,
) -> Result<AuthorizedPublication<T>, IfcError> {
match self.domain.capabilities.effect(&request.operation) {
operation: &str,
destination: &ConfidentialityLabel,
payload: Vec<u8>,
) -> Result<AuthorizedPublication, IfcError> {
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.

View File

@@ -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<String>,
@@ -107,37 +109,78 @@ fn deliver_to_agent(
}
fn execute_publication(
authorization: AuthorizedPublication<String>,
sink_log: &mut Vec<(String, String)>,
authorization: AuthorizedPublication,
sink_log: &mut Vec<(String, ConfidentialityLabel, Vec<u8>)>,
) {
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