Files
buzz/crates/ifc-core/src/lib.rs
Jordan Mecom 4d447b9c20 Add generic information-flow control core (#7293)
Adds a dependency-free `ifc-core` crate implementing reader-set
confidentiality labels and monotonic per-computation flow state.

- Defines flow ordering, join, and meet over caller-supplied principal
universes.
- Fails closed for unknown or cross-universe input and checks reader
widening at egress.
- Includes property tests for lattice laws and monotonic taint, plus the
design paper that motivates the broker integration.

The crate deliberately contains no Buzz, Nostr, channel, membership, or
grant policy; those remain in the Buzz adapter.

---------

Signed-off-by: Jordan Mecom <jm@squareup.com>
2026-09-04 22:26:44 +00:00

508 lines
17 KiB
Rust

//! Generic information-flow control over reader-set confidentiality labels.
//!
//! This crate knows nothing about Buzz, Nostr, etc., and should stay that way.
//!
//! ```
//! use std::collections::BTreeSet;
//! use ifc_core::{ConfidentialityLabel, EgressError, FlowState};
//!
//! let universe = "example";
//! let private = ConfidentialityLabel::restricted(
//! universe,
//! BTreeSet::from(["alice", "bob"]),
//! )?;
//! let alice_only = ConfidentialityLabel::restricted_to(universe, "alice");
//! let public = ConfidentialityLabel::public(universe);
//! let mut flow = FlowState::default();
//! flow.observe(&private);
//!
//! assert_eq!(flow.check_egress(&alice_only), Ok(()));
//! assert_eq!(
//! flow.check_egress(&public),
//! Err(EgressError::DestinationWidensReaders),
//! );
//! # Ok::<(), ifc_core::LabelError>(())
//! ```
#![forbid(unsafe_code)]
use std::collections::BTreeSet;
use std::error::Error;
use std::fmt::{Display, Formatter};
/// The people or systems allowed to learn a value within one universe.
///
/// `Everyone` is for public information. `Only` holds the set of
/// principals allowed to read restricted information. Sending information from
/// one reader set to another is safe only when the destination adds no new
/// readers. For example, information readable by Alice and Bob may be narrowed
/// to Alice, but it must not be widened to Alice, Bob, and Carol.
///
/// When a computation combines inputs, their reader sets are intersected so
/// its output is restricted to principals allowed to read every input.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ReaderSet<Principal> {
/// Every principal in the universe may read the value.
Everyone,
/// Only these principals may read the value.
Only(BTreeSet<Principal>),
}
impl<Principal: Ord> ReaderSet<Principal> {
/// Whether information readable by `self` may flow to `destination`.
pub fn can_flow_to(&self, destination: &Self) -> bool {
match (self, destination) {
(Self::Everyone, _) => true,
(Self::Only(_), Self::Everyone) => false,
(Self::Only(source), Self::Only(destination)) => destination.is_subset(source),
}
}
/// Combine the restrictions of two contributing inputs.
///
/// A derived value may be read only by principals authorized for both
/// inputs, so explicit reader sets are intersected. `Everyone` adds no
/// restriction.
pub fn join(&self, other: &Self) -> Self
where
Principal: Clone,
{
match (self, other) {
(Self::Everyone, value) | (value, Self::Everyone) => value.clone(),
(Self::Only(left), Self::Only(right)) => {
Self::Only(left.intersection(right).cloned().collect())
}
}
}
/// Return the greatest label that can flow to both inputs.
pub fn meet(&self, other: &Self) -> Self
where
Principal: Clone,
{
match (self, other) {
(Self::Everyone, _) | (_, Self::Everyone) => Self::Everyone,
(Self::Only(left), Self::Only(right)) => {
Self::Only(left.union(right).cloned().collect())
}
}
}
/// Return the explicit readers, or `None` when everyone may read the value.
pub fn explicit_readers(&self) -> Option<&BTreeSet<Principal>> {
match self {
Self::Everyone => None,
Self::Only(readers) => Some(readers),
}
}
/// Return the explicit number of readers, or `None` for public data.
pub fn explicit_count(&self) -> Option<usize> {
self.explicit_readers().map(BTreeSet::len)
}
}
/// A reader-set confidentiality label inside one isolated universe.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfidentialityLabel<Universe, Principal> {
universe: Universe,
readers: ReaderSet<Principal>,
}
impl<Universe, Principal> ConfidentialityLabel<Universe, Principal> {
/// Label a value readable by every principal in `universe`.
pub fn public(universe: Universe) -> Self {
Self {
universe,
readers: ReaderSet::Everyone,
}
}
/// Label a value with an explicit non-empty reader set.
pub fn restricted(
universe: Universe,
readers: BTreeSet<Principal>,
) -> Result<Self, LabelError> {
if readers.is_empty() {
return Err(LabelError::EmptyReaderSet);
}
Ok(Self {
universe,
readers: ReaderSet::Only(readers),
})
}
/// Label a value for exactly one principal.
pub fn restricted_to(universe: Universe, principal: Principal) -> Self
where
Principal: Ord,
{
Self {
universe,
readers: ReaderSet::Only(BTreeSet::from([principal])),
}
}
/// Return the universe in which this label is meaningful.
pub fn universe(&self) -> &Universe {
&self.universe
}
/// Return the authorized reader set.
pub fn reader_set(&self) -> &ReaderSet<Principal> {
&self.readers
}
/// Whether every principal in the universe may read the value.
pub fn is_public(&self) -> bool {
matches!(self.readers, ReaderSet::Everyone)
}
/// Return the explicit number of readers, or `None` for public data.
pub fn reader_count(&self) -> Option<usize>
where
Principal: Ord,
{
self.readers.explicit_count()
}
}
impl<Universe: Clone + Eq, Principal: Clone + Ord> ConfidentialityLabel<Universe, Principal> {
/// Whether information with this label may flow to `destination`.
pub fn can_flow_to(&self, destination: &Self) -> bool {
self.universe == destination.universe && self.readers.can_flow_to(&destination.readers)
}
/// Combine the influence of two inputs.
pub fn join(&self, other: &Self) -> Result<Self, LabelError> {
if self.universe != other.universe {
return Err(LabelError::CrossUniverse);
}
Ok(Self {
universe: self.universe.clone(),
readers: self.readers.join(&other.readers),
})
}
}
/// A confidentiality label violates the reader-set lattice invariants.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LabelError {
/// Restricted information must name at least one authorized reader.
EmptyReaderSet,
/// Labels from different universes cannot be combined.
CrossUniverse,
}
impl Display for LabelError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyReaderSet => {
formatter.write_str("restricted label has no authorized readers")
}
Self::CrossUniverse => formatter.write_str("labels belong to different universes"),
}
}
}
impl Error for LabelError {}
/// Monotonic confidentiality state for one computation boundary.
///
/// Every admitted label is joined into the accumulated label. Unknown or
/// cross-universe input permanently prevents ordinary egress. The state is not
/// cloneable because a caller must not retain a clean copy and later use it to
/// forget observed input.
///
/// ```compile_fail
/// let state = ifc_core::FlowState::<String, String>::default();
/// let _clean_copy = state.clone();
/// ```
#[derive(Debug, Eq, PartialEq)]
pub struct FlowState<Universe, Principal> {
accumulated: Option<ConfidentialityLabel<Universe, Principal>>,
unresolved_input: bool,
}
impl<Universe, Principal> Default for FlowState<Universe, Principal> {
fn default() -> Self {
Self {
accumulated: None,
unresolved_input: false,
}
}
}
impl<Universe: Clone + Eq, Principal: Clone + Ord> FlowState<Universe, Principal> {
/// Record a labeled input that entered the computation.
pub fn observe(&mut self, label: &ConfidentialityLabel<Universe, Principal>) {
self.accumulated = match self.accumulated.take() {
None => Some(label.clone()),
Some(existing) => match existing.join(label) {
Ok(combined) => Some(combined),
Err(LabelError::CrossUniverse | LabelError::EmptyReaderSet) => {
self.unresolved_input = true;
Some(existing)
}
},
};
}
/// Permanently record input whose label could not be established.
pub fn mark_unknown(&mut self) {
self.unresolved_input = true;
}
/// Check whether accumulated information may flow to `destination`.
pub fn check_egress(
&self,
destination: &ConfidentialityLabel<Universe, Principal>,
) -> Result<(), EgressError> {
if self.unresolved_input {
return Err(EgressError::UnresolvedInput);
}
let Some(accumulated) = &self.accumulated else {
return Ok(());
};
if accumulated.universe() != destination.universe() {
return Err(EgressError::DestinationUniverseMismatch);
}
if !accumulated
.reader_set()
.can_flow_to(destination.reader_set())
{
return Err(EgressError::DestinationWidensReaders);
}
Ok(())
}
/// Whether any labeled input has entered the computation.
pub fn has_observed_input(&self) -> bool {
self.accumulated.is_some()
}
/// Return the label accumulated from all observed inputs.
pub fn accumulated_label(&self) -> Option<&ConfidentialityLabel<Universe, Principal>> {
self.accumulated.as_ref()
}
/// Whether unknown or cross-universe input has entered the computation.
pub fn has_unresolved_input(&self) -> bool {
self.unresolved_input
}
/// Capture state for detecting changes before a checked sink executes.
pub fn snapshot(&self) -> FlowSnapshot<Universe, Principal> {
FlowSnapshot {
accumulated: self.accumulated.clone(),
unresolved_input: self.unresolved_input,
}
}
}
/// An inert copy used only to detect changes before sink execution.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FlowSnapshot<Universe, Principal> {
accumulated: Option<ConfidentialityLabel<Universe, Principal>>,
unresolved_input: bool,
}
/// Why accumulated information cannot use an ordinary egress path.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EgressError {
/// Some input had unknown provenance or belonged to another universe.
UnresolvedInput,
/// The destination belongs to a different confidentiality universe.
DestinationUniverseMismatch,
/// The destination introduces readers not authorized for every input.
DestinationWidensReaders,
}
impl Display for EgressError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnresolvedInput => formatter.write_str("input provenance is unresolved"),
Self::DestinationUniverseMismatch => {
formatter.write_str("destination belongs to a different universe")
}
Self::DestinationWidensReaders => {
formatter.write_str("destination widens the accumulated reader set")
}
}
}
}
impl Error for EgressError {}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
fn readers(mask: u8) -> BTreeSet<u8> {
(0..8).filter(|bit| mask & (1 << bit) != 0).collect()
}
fn label(mask: u8) -> ConfidentialityLabel<u8, u8> {
ConfidentialityLabel::restricted(1, readers(mask.max(1))).expect("non-empty readers")
}
#[derive(Clone, Copy, Debug)]
enum Audience {
Public,
Restricted(u8),
}
impl Audience {
fn label(self) -> ConfidentialityLabel<u8, u8> {
match self {
Self::Public => ConfidentialityLabel::public(1),
Self::Restricted(mask) => label(mask),
}
}
fn can_flow_to(self, destination: Self) -> bool {
match (self, destination) {
(Self::Public, _) => true,
(Self::Restricted(_), Self::Public) => false,
(Self::Restricted(source), Self::Restricted(destination)) => {
destination & !source == 0
}
}
}
}
fn audience_strategy() -> impl Strategy<Value = Audience> {
prop_oneof![
Just(Audience::Public),
(1_u8..=u8::MAX).prop_map(Audience::Restricted),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Checks reader-set inclusion against an independent bit-set model and
/// verifies that join is commutative, associative, idempotent, and
/// admits exactly the destinations admitted by both inputs. This catches
/// a reversed subset check, union in place of intersection, and special
/// handling of public data that accidentally widens readers.
#[test]
fn reader_sets_obey_flow_and_join_laws(
a in audience_strategy(),
b in audience_strategy(),
c in audience_strategy(),
destination in audience_strategy(),
) {
let a_label = a.label();
let b_label = b.label();
let c_label = c.label();
let destination_label = destination.label();
prop_assert_eq!(
a_label.can_flow_to(&destination_label),
a.can_flow_to(destination),
);
prop_assert!(ConfidentialityLabel::public(1).can_flow_to(&a_label));
if !a_label.is_public() {
prop_assert!(!a_label.can_flow_to(&ConfidentialityLabel::public(1)));
}
let ab = a_label.join(&b_label).expect("same universe");
prop_assert_eq!(&ab, &b_label.join(&a_label).expect("same universe"));
prop_assert_eq!(
a_label.join(&a_label).expect("same universe"),
a_label.clone(),
);
prop_assert_eq!(
a_label
.join(&b_label.join(&c_label).expect("same universe"))
.expect("same universe"),
ab.join(&c_label).expect("same universe"),
);
prop_assert_eq!(
ab.can_flow_to(&destination_label),
a_label.can_flow_to(&destination_label)
&& b_label.can_flow_to(&destination_label),
);
}
}
/// Checks the two absorption laws linking join and meet. These catch a
/// locally plausible implementation where each operation works alone but
/// they do not form one consistent lattice.
#[test]
fn reader_set_join_and_meet_satisfy_absorption() {
let values = [
ReaderSet::Everyone,
ReaderSet::Only(readers(0b0001)),
ReaderSet::Only(readers(0b0010)),
ReaderSet::Only(readers(0b0011)),
];
for left in &values {
for right in &values {
assert_eq!(left.join(&left.meet(right)), *left);
assert_eq!(left.meet(&left.join(right)), *left);
}
}
}
/// Checks that every observed input permanently restricts later egress and
/// that unknown provenance cannot be cleared. This catches taint rollback
/// and mistakenly replacing an accumulated label instead of joining it.
#[test]
fn flow_state_accumulates_restrictions_and_never_forgets_unknown_input() {
let mut state = FlowState::default();
assert_eq!(state.check_egress(&label(0b0011)), Ok(()));
state.observe(&label(0b0011));
state.observe(&label(0b0110));
assert_eq!(state.accumulated_label(), Some(&label(0b0010)));
assert_eq!(state.check_egress(&label(0b0010)), Ok(()));
assert_eq!(
state.check_egress(&label(0b0011)),
Err(EgressError::DestinationWidensReaders)
);
state.mark_unknown();
assert_eq!(
state.check_egress(&label(0b0010)),
Err(EgressError::UnresolvedInput)
);
}
/// Checks that an egress destination in another universe is distinguished
/// from a destination that widens the reader set.
#[test]
fn cross_universe_destination_reports_universe_mismatch() {
let mut state = FlowState::default();
state.observe(&label(0b0011));
let destination =
ConfidentialityLabel::restricted(2, readers(0b0001)).expect("non-empty readers");
assert_eq!(
state.check_egress(&destination),
Err(EgressError::DestinationUniverseMismatch)
);
}
/// Checks that combining labels from distinct universes fails closed
/// for all later output. This catches accidental comparison of otherwise
/// identical reader identifiers across unrelated confidentiality universes.
#[test]
fn cross_universe_input_permanently_blocks_egress() {
let mut state = FlowState::default();
state.observe(&label(0b0011));
state.observe(
&ConfidentialityLabel::restricted(2, readers(0b0011)).expect("non-empty readers"),
);
assert!(state.has_unresolved_input());
assert_eq!(
state.check_egress(&label(0b0001)),
Err(EgressError::UnresolvedInput)
);
}
}