diff --git a/Cargo.lock b/Cargo.lock index 078d41b990..1a3324efe4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1274,11 +1274,9 @@ name = "buzz-ifc" version = "0.1.0" dependencies = [ "buzz-core", - "hex", "ifc-core", "nostr 0.44.7", "serde", - "sha2 0.11.0", "thiserror 2.0.18", "uuid", ] diff --git a/crates/buzz-ifc/Cargo.toml b/crates/buzz-ifc/Cargo.toml index e1c10204fb..339c80b62d 100644 --- a/crates/buzz-ifc/Cargo.toml +++ b/crates/buzz-ifc/Cargo.toml @@ -9,10 +9,8 @@ description = "Buzz execution domains and broker-side information-flow checks" [dependencies] buzz-core = { workspace = true } -hex = { workspace = true } ifc-core = { workspace = true } nostr = { workspace = true } serde = { workspace = true } -sha2 = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } diff --git a/crates/buzz-ifc/src/domain.rs b/crates/buzz-ifc/src/domain.rs index d179a0a3ae..b2d9da4ad9 100644 --- a/crates/buzz-ifc/src/domain.rs +++ b/crates/buzz-ifc/src/domain.rs @@ -1,7 +1,6 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::Serialize; -use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::label::{CommunityId, ConfidentialityLabel, Principal, ReaderSet}; @@ -13,9 +12,9 @@ use crate::label::{CommunityId, ConfidentialityLabel, Principal, ReaderSet}; /// its owner has a separate owner-private context. /// /// A matching context is not enough to reuse state. The broker must compare the -/// full [`DomainKey`], so a change in agent, owner, audience, membership version, -/// or capabilities also prevents reuse. -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +/// full [`ExecutionDomain`], so a change in agent, owner, audience, or +/// capabilities also prevents reuse. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) enum DomainContext { /// Shared state for public conversations in one community. CommunityPublic(CommunityId), @@ -64,28 +63,6 @@ impl DomainContext { Self::OwnerPrivate { .. } => self == source, } } - - pub(crate) fn stable_hash(&self, hasher: &mut Sha256) { - match self { - Self::CommunityPublic(community) => { - hash_field(hasher, b"community-public"); - hash_field(hasher, community.as_uuid().as_bytes()); - } - Self::Conversation { - community, - channel_id, - } => { - hash_field(hasher, b"conversation"); - hash_field(hasher, community.as_uuid().as_bytes()); - hash_field(hasher, channel_id.as_bytes()); - } - Self::OwnerPrivate { community, owner } => { - hash_field(hasher, b"owner-private"); - hash_field(hasher, community.as_uuid().as_bytes()); - hash_field(hasher, &owner.to_bytes()); - } - } - } } /// Whether an operation can send information out of the agent's environment. @@ -94,7 +71,7 @@ impl DomainContext { /// For a publication, permission to call the operation is not enough: the broker /// must use [`crate::IfcSession::publish`] to check whether the information may /// flow to the destination's readers. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum OperationEffect { /// Does not expose information outside the agent's environment. @@ -110,13 +87,6 @@ impl OperationEffect { (Self::Publication, _) | (_, Self::Publication) => Self::Publication, } } - - fn stable_hash(self, hasher: &mut Sha256) { - match self { - Self::NonEgressing => hash_field(hasher, b"non-egressing"), - Self::Publication => hash_field(hasher, b"publication"), - } - } } /// Operations a policy allows, with each operation's publication behavior. @@ -129,7 +99,7 @@ impl OperationEffect { /// let capabilities = buzz_ifc::CapabilitySet::default(); /// let _ = capabilities.contains("buzz.post"); /// ``` -#[derive(Clone, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)] pub struct CapabilitySet(BTreeMap); impl CapabilitySet { @@ -177,14 +147,6 @@ impl CapabilitySet { 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 { - hash_field(hasher, name.as_bytes()); - effect.stable_hash(hasher); - } - } } /// Limits on the operations available to an agent. @@ -207,22 +169,6 @@ impl CapabilityPolicy { } } -/// Version of the membership or access policy used to derive a domain. -/// -/// The broker must change this value when the relevant membership or policy -/// changes. It becomes part of the domain key, preventing the broker from -/// selecting old state with the new key. This crate does not check freshness. -#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] -#[serde(transparent)] -pub struct MembershipEpoch(String); - -impl MembershipEpoch { - /// Use a broker-verified version, such as a membership event ID. - pub fn new(value: impl Into) -> Self { - Self(value.into()) - } -} - /// Conversation type read from metadata the broker has verified. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ConversationKind { @@ -248,8 +194,6 @@ pub struct DomainFacts { pub channel_id: Uuid, /// Conversation type from verified metadata. pub kind: ConversationKind, - /// Current membership or community policy version supplied by the relay. - pub epoch: MembershipEpoch, /// Complete verified member list, including the agent. Ignored for public /// channels, whose audience is the whole community. pub members: BTreeSet, @@ -297,8 +241,8 @@ pub enum DerivationError { /// and conversation policies, even if the owner made the request. /// /// The broker must supply verified [`DomainFacts`] and use the resulting -/// [`DomainKey`] to select saved state. This function does not load or clear -/// sessions itself. +/// [`ExecutionDomain`] to select saved state. This function does not load or +/// clear sessions itself. pub fn derive_execution_domain( facts: DomainFacts, policy: &CapabilityPolicy, @@ -352,7 +296,6 @@ pub fn derive_execution_domain( facts.owner, audience, context, - facts.epoch, capabilities, ) .map_err(|_| DerivationError::InvalidDomain) @@ -380,38 +323,31 @@ fn effective_capabilities( CapabilitySet::effective(&policy.bot, requester, domain) } -/// Key the broker uses to select an agent's saved state. -/// -/// Compare the full key when deciding whether to reuse a session. Matching only -/// a channel or member list would miss changes in owner, policy, or capabilities. -#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] -#[serde(transparent)] -pub struct DomainKey(String); - -impl DomainKey { - /// Return the key as a hexadecimal string for storage or lookup. - pub fn as_str(&self) -> &str { - &self.0 - } -} - /// The identity, readers, context, and policy for an agent invocation. /// -/// The audience says who may receive information. The context says which -/// conversations may share saved state. The epoch records the membership or -/// policy version, and the capabilities list the allowed operations. Agent and -/// owner identities keep different agents and ownership relationships separate. +/// Conceptually, `D = (community, agent, owner, audience, context, capabilities)`. +/// The community is encoded in both the audience and context; the constructor +/// requires them to agree. The audience says who may receive information, the +/// context says which conversations may share saved state, and capabilities +/// list the allowed operations. Agent and owner identities keep different +/// agents and ownership relationships separate. /// -/// This extends the domain model in +/// Restricted audiences contain the verified member set minus the executing +/// agent. Public audiences represent everyone in the community without listing +/// members. Event IDs, timestamps, names, and topics do not participate in +/// equality. Compare the complete structure before reusing retained state. +/// +/// This represents current policy state. Unlike the temporal epochs in /// [Appendix B of the design paper](../../../docs/practical-information-flow-for-buzz-agents.md#appendix-b-formal-execution-domains) -/// by including the owner and capabilities in the key as well. -#[derive(Clone, Debug, Eq, PartialEq)] +/// it does not distinguish two occurrences of the same policy separated by a +/// membership change. The broker must verify current access separately and +/// preserve session restrictions when reusing retained state. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct ExecutionDomain { agent: Principal, owner: Option, pub(crate) audience: ConfidentialityLabel, pub(crate) context: DomainContext, - pub(crate) epoch: MembershipEpoch, pub(crate) capabilities: CapabilitySet, } @@ -422,13 +358,12 @@ impl ExecutionDomain { owner: Option, audience: ConfidentialityLabel, context: DomainContext, - epoch: MembershipEpoch, capabilities: CapabilitySet, ) -> Result { if *audience.universe() != context.community() { return Err(DomainError::ContextCommunityMismatch); } - if !audience_context_shape_matches(&audience, &context) { + if !audience_matches_context(&audience, &context) { return Err(DomainError::AudienceContextMismatch); } Ok(Self { @@ -436,7 +371,6 @@ impl ExecutionDomain { owner, audience, context, - epoch, capabilities, }) } @@ -445,54 +379,9 @@ impl ExecutionDomain { 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 - /// so it cannot reuse a session that has seen data under a different policy. - pub fn key(&self) -> DomainKey { - let mut hasher = Sha256::new(); - hasher.update(b"buzz-ifc-domain-v6"); - hash_field(&mut hasher, &self.agent.to_bytes()); - match &self.owner { - Some(owner) => { - hash_field(&mut hasher, b"owner"); - hash_field(&mut hasher, &owner.to_bytes()); - } - None => hash_field(&mut hasher, b"no-owner"), - } - hash_field(&mut hasher, self.audience.universe().as_uuid().as_bytes()); - hash_reader_set(self.audience.reader_set(), &mut hasher); - self.context.stable_hash(&mut hasher); - hash_field(&mut hasher, self.epoch.0.as_bytes()); - self.capabilities.stable_hash(&mut hasher); - DomainKey(hex::encode(hasher.finalize())) - } } -fn hash_reader_set(readers: &ReaderSet, hasher: &mut Sha256) { - match readers { - ReaderSet::Everyone => hash_field(hasher, b"everyone"), - ReaderSet::Only(readers) => { - hash_field(hasher, b"only"); - hash_field(hasher, &(readers.len() as u64).to_be_bytes()); - for reader in readers { - hash_field(hasher, &reader.to_bytes()); - } - } - } -} - -fn hash_field(hasher: &mut Sha256, value: &[u8]) { - let length = u64::try_from(value.len()).unwrap_or(u64::MAX); - hasher.update(length.to_be_bytes()); - hasher.update(value); -} - -fn audience_context_shape_matches( - audience: &ConfidentialityLabel, - context: &DomainContext, -) -> bool { +fn audience_matches_context(audience: &ConfidentialityLabel, context: &DomainContext) -> bool { match (audience.reader_set(), context) { (ReaderSet::Everyone, DomainContext::CommunityPublic(_)) => true, (ReaderSet::Only(readers), DomainContext::Conversation { .. }) => !readers.is_empty(), diff --git a/crates/buzz-ifc/src/label.rs b/crates/buzz-ifc/src/label.rs index 566b708643..91a1f60175 100644 --- a/crates/buzz-ifc/src/label.rs +++ b/crates/buzz-ifc/src/label.rs @@ -6,8 +6,8 @@ use serde::Serialize; /// A person, agent, or relay identified by a valid Nostr public key. /// /// The key is validated and stored in binary form. Hexadecimal case does not -/// affect equality or domain keys. -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +/// affect equality of principals or execution domains. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] #[serde(transparent)] pub struct Principal(XOnlyPublicKey); @@ -35,10 +35,6 @@ impl Principal { pub fn to_hex(&self) -> String { self.0.to_string() } - - pub(crate) fn to_bytes(self) -> [u8; 32] { - self.0.serialize() - } } /// Invalid Nostr public-key input. diff --git a/crates/buzz-ifc/src/lib.rs b/crates/buzz-ifc/src/lib.rs index 17940de9d1..971b8a0db7 100644 --- a/crates/buzz-ifc/src/lib.rs +++ b/crates/buzz-ifc/src/lib.rs @@ -2,11 +2,12 @@ //! //! An [`ExecutionDomain`] records which agent is running, who may receive its //! output, which conversations may share its saved state, and which operations -//! it may use. Its [`DomainKey`] includes all of those decisions, plus the owner -//! and membership version, so a change produces a different key. +//! it may use. Structural equality compares all of those decisions, plus the +//! owner and community. Membership determines restricted audiences; reissuing +//! the same policy in a different event does not change the domain. //! //! The broker must verify events and membership before supplying [`DomainFacts`]. -//! It uses the resulting key to select both the agent's saved state and its +//! It uses the complete domain 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. //! @@ -23,7 +24,7 @@ mod session; pub use domain::{ derive_execution_domain, CapabilityPolicy, CapabilitySet, ConversationKind, DerivationError, - DomainFacts, DomainKey, ExecutionDomain, MembershipEpoch, OperationEffect, + DomainFacts, ExecutionDomain, OperationEffect, }; pub use label::{CommunityId, ConfidentialityLabel, LabelError, Principal, PrincipalError}; pub use session::{AuthorizedPublication, IfcError, IfcSession, ResourceLabel}; diff --git a/crates/buzz-ifc/src/session.rs b/crates/buzz-ifc/src/session.rs index bf9180173d..a4f17d8c5c 100644 --- a/crates/buzz-ifc/src/session.rs +++ b/crates/buzz-ifc/src/session.rs @@ -1,32 +1,27 @@ use ifc_core::{EgressError, FlowState}; -use crate::domain::{DomainContext, DomainKey, ExecutionDomain, MembershipEpoch, OperationEffect}; +use crate::domain::{DomainContext, ExecutionDomain, 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. +/// audience and retained-state context together. These describe which readers +/// and contexts may receive the resource; the broker checks current access +/// separately. #[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, } } } @@ -90,23 +85,23 @@ impl AuthorizedPublication { /// [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 +/// files, or other state. The same domain 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}; +/// # IfcError, IfcSession, ResourceLabel}; /// # fn broker_sink(_: AuthorizedPublication) {} /// # fn run_turn( -/// # sessions: &mut HashMap, +/// # sessions: &mut HashMap, /// # domain: ExecutionDomain, /// # resource: &ResourceLabel, /// # destination: &ConfidentialityLabel, /// # request_bytes: Vec, /// # ) -> Result<(), IfcError> { /// let session = sessions -/// .entry(domain.key()) +/// .entry(domain.clone()) /// .or_insert_with(|| IfcSession::enter(domain)); /// session.call("buzz.read.current")?; /// session.read(resource)?; @@ -137,9 +132,9 @@ impl IfcSession { 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() + /// Return the complete domain the broker compares before reusing this session. + pub fn domain(&self) -> &ExecutionDomain { + &self.domain } /// Check a labeled resource before exposing it to the agent. @@ -148,7 +143,7 @@ impl IfcSession { /// 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. + /// audience and context 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); @@ -156,15 +151,6 @@ impl IfcSession { 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(()) } @@ -236,9 +222,6 @@ pub enum IfcError { /// 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, diff --git a/crates/buzz-ifc/src/session_tests.rs b/crates/buzz-ifc/src/session_tests.rs index 6513c31456..f5c8aa7fcf 100644 --- a/crates/buzz-ifc/src/session_tests.rs +++ b/crates/buzz-ifc/src/session_tests.rs @@ -29,13 +29,12 @@ fn policy() -> CapabilityPolicy { CapabilityPolicy::new(operations(), operations()) } -fn public_domain(community: CommunityId, channel: u128, epoch: &str) -> ExecutionDomain { +fn public_domain(community: CommunityId, channel: u128) -> 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)]), @@ -47,12 +46,7 @@ fn public_domain(community: CommunityId, channel: u128, epoch: &str) -> Executio .expect("valid public domain") } -fn restricted_domain( - community: CommunityId, - channel: u128, - epoch: &str, - readers: &[u8], -) -> ExecutionDomain { +fn restricted_domain(community: CommunityId, channel: u128, readers: &[u8]) -> ExecutionDomain { let agent = principal(9); let members = readers .iter() @@ -65,7 +59,6 @@ fn restricted_domain( community, channel_id: Uuid::from_u128(channel), kind: ConversationKind::Restricted, - epoch: MembershipEpoch::new(epoch), members, executing_agent: agent, requesters: BTreeSet::from([principal(readers[0])]), @@ -77,7 +70,7 @@ fn restricted_domain( .expect("valid restricted domain") } -fn owner_private_domain(community: CommunityId, channel: u128, epoch: &str) -> ExecutionDomain { +fn owner_private_domain(community: CommunityId, channel: u128) -> ExecutionDomain { let owner = principal(1); let agent = principal(9); derive_execution_domain( @@ -85,7 +78,6 @@ fn owner_private_domain(community: CommunityId, channel: u128, epoch: &str) -> E 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]), @@ -119,7 +111,7 @@ fn execute_publication( /// 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 domain = restricted_domain(community(1), 10, &[1, 2]); let resource = ResourceLabel::from_domain(&domain); let destination = domain.audience().clone(); let session = IfcSession::enter(domain); @@ -146,7 +138,7 @@ fn broker_turn_uses_one_small_checked_surface() { /// 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 domain = public_domain(community(1), 10); let mut destination = domain.audience().clone(); let checked_destination = destination.clone(); let mut session = IfcSession::enter(domain); @@ -188,8 +180,8 @@ fn publication_keeps_the_checked_bytes_operation_and_destination() { /// 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 group = restricted_domain(community(1), 10, &[1, 2]); + let alice_only = restricted_domain(community(1), 20, &[1]); let resource = ResourceLabel::from_domain(&alice_only); let destination = group.audience().clone(); let session = IfcSession::enter(group); @@ -209,7 +201,7 @@ fn broker_does_not_deliver_a_resource_with_a_narrower_audience() { /// 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")); + let session = IfcSession::enter(public_domain(community(1), 10)); assert_eq!( session.call(REPLY), @@ -221,7 +213,7 @@ fn egressing_operation_cannot_use_the_call_path() { /// 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 domain = public_domain(community(1), 10); let destination = domain.audience().clone(); let session = IfcSession::enter(domain); @@ -236,7 +228,7 @@ fn non_egressing_operation_cannot_use_the_publish_path() { /// how to execute it. #[test] fn operation_absent_from_the_domain_is_denied() { - let domain = public_domain(community(1), 10, "community:v1"); + let domain = public_domain(community(1), 10); let destination = domain.audience().clone(); let session = IfcSession::enter(domain); @@ -251,8 +243,8 @@ fn operation_absent_from_the_domain_is_denied() { /// 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 private = restricted_domain(community(1), 10, &[1, 2]); + let public = public_domain(community(1), 20); let destination = public.audience(); let session = IfcSession::enter(private); @@ -271,7 +263,7 @@ fn private_session_cannot_publish_to_a_public_audience() { /// 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"); + let public = public_domain(community(1), 10); // A destination needs an audience, not an agent execution domain. let destination = ConfidentialityLabel::restricted_to(community(1), principal(1)); let session = IfcSession::enter(public); @@ -285,7 +277,7 @@ fn public_session_may_publish_to_a_private_audience() { /// 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 domain = public_domain(community(1), 10); let resource = ResourceLabel::from_domain(&domain); let destination = domain.audience().clone(); let mut session = IfcSession::enter(domain); @@ -309,18 +301,18 @@ fn unknown_input_permanently_blocks_publication() { /// 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 first_turn = public_domain(community(1), 10); + let next_turn = public_domain(community(1), 20); let resource = ResourceLabel::from_domain(&next_turn); let destination = next_turn.audience().clone(); let mut pool = HashMap::new(); - pool.entry(first_turn.key()) + pool.entry(first_turn.clone()) .or_insert_with(|| IfcSession::enter(first_turn)) .mark_unknown_input(); let session = pool - .entry(next_turn.key()) + .entry(next_turn.clone()) .or_insert_with(|| IfcSession::enter(next_turn)); session .call(READ) @@ -337,27 +329,25 @@ fn unknown_input_survives_reusing_a_session_in_another_public_channel() { assert_eq!(pool.len(), 1); } -/// Retained restricted state from an older membership snapshot cannot enter a -/// newly routed session for the same conversation. +/// Adding a member does not make older restricted state readable by them. +/// Its original audience must cover everyone in the new session's audience. #[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]); +fn same_conversation_rejects_a_resource_with_a_narrower_audience() { + let old = restricted_domain(community(1), 10, &[1, 2]); + let current = restricted_domain(community(1), 10, &[1, 2, 3]); let resource = ResourceLabel::from_domain(&old); let session = IfcSession::enter(current); - assert_eq!(session.read(&resource), Err(IfcError::StaleResourceEpoch)); + assert_eq!(session.read(&resource), Err(IfcError::ReadAudienceDenied)); } -/// 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. +/// Public community data is readable by the restricted session's audience. +/// Its public context may enter any retained-state context in that community. /// 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 public = public_domain(community(1), 20); + let private = restricted_domain(community(1), 10, &[1, 2]); let resource = ResourceLabel::from_domain(&public); let private_audience = private.audience().clone(); let session = IfcSession::enter(private); @@ -381,8 +371,8 @@ fn private_session_may_read_public_data_from_its_community() { /// 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 conversation = restricted_domain(community(1), 20, &[1, 2]); + let owner_private = owner_private_domain(community(1), 10); let resource = ResourceLabel::from_domain(&conversation); let session = IfcSession::enter(owner_private); @@ -393,8 +383,8 @@ fn owner_private_session_may_read_conversation_data_safe_for_its_owner() { /// 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 source = restricted_domain(community(1), 10, &[1, 2]); + let destination = restricted_domain(community(1), 20, &[1, 2]); let resource = ResourceLabel::from_domain(&source); let session = IfcSession::enter(destination); @@ -406,8 +396,8 @@ fn equal_audiences_do_not_merge_restricted_conversation_contexts() { /// 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]); + let owner_private = owner_private_domain(community(1), 10); + let conversation = restricted_domain(community(1), 20, &[1]); assert_eq!(owner_private.audience(), conversation.audience()); let resource = ResourceLabel::from_domain(&owner_private); let session = IfcSession::enter(conversation); @@ -419,8 +409,8 @@ fn owner_private_state_cannot_enter_a_conversation_with_the_same_audience() { /// 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 source = public_domain(community(1), 10); + let destination = public_domain(community(2), 10); let resource = ResourceLabel::from_domain(&source); let session = IfcSession::enter(destination); let mut inbox = Vec::new(); @@ -436,8 +426,8 @@ fn public_resource_cannot_cross_communities() { /// 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 source = public_domain(community(1), 10); + let destination = public_domain(community(2), 10); let session = IfcSession::enter(source); assert_eq!( @@ -451,33 +441,33 @@ fn publication_cannot_cross_communities() { } /// This models the broker's retained-session pool. Public channels share one -/// community domain, while restricted channel identity and membership epoch +/// community domain, while restricted channel identity and audience /// 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(); +fn broker_routes_retained_sessions_by_complete_domain_structure() { + let public_a = public_domain(community(1), 10); + let public_b = public_domain(community(1), 20); + let restricted_a = restricted_domain(community(1), 10, &[1, 2]); + let restricted_b = restricted_domain(community(1), 20, &[1, 2]); + let restricted_new_audience = restricted_domain(community(1), 10, &[1, 2, 3]); + let public_domain = public_a.clone(); let domains = [ public_a, public_b, restricted_a, restricted_b, - restricted_new_epoch, + restricted_new_audience, ]; let mut pool = HashMap::new(); for domain in domains { - pool.entry(domain.key()) + pool.entry(domain.clone()) .or_insert_with(|| IfcSession::enter(domain)); } assert_eq!(pool.len(), 4); assert_eq!( - pool.get(&public_key).map(IfcSession::domain_key), - Some(public_key) + pool.get(&public_domain).map(IfcSession::domain), + Some(&public_domain) ); } diff --git a/crates/buzz-ifc/src/tests.rs b/crates/buzz-ifc/src/tests.rs index c6e8fbe112..cd36bf0a79 100644 --- a/crates/buzz-ifc/src/tests.rs +++ b/crates/buzz-ifc/src/tests.rs @@ -39,7 +39,7 @@ fn conversation_capabilities() -> CapabilitySet { ]) } -fn conversation(values: &[u8], channel_id: Uuid, epoch: &str) -> ExecutionDomain { +fn conversation(values: &[u8], channel_id: Uuid) -> ExecutionDomain { ExecutionDomain::new( principal(9), Some(principal(1)), @@ -48,7 +48,6 @@ fn conversation(values: &[u8], channel_id: Uuid, epoch: &str) -> ExecutionDomain community: community(), channel_id, }, - MembershipEpoch::new(epoch), conversation_capabilities(), ) .expect("coherent conversation domain") @@ -128,7 +127,6 @@ fn derivation_grants_personal_capabilities_only_to_owner_private_work() { community: community(), channel_id: Uuid::from_u128(1), kind: ConversationKind::DirectMessage, - epoch: MembershipEpoch::new("membership:v1"), members: BTreeSet::from([agent, owner]), executing_agent: agent, requesters: BTreeSet::from([owner]), @@ -156,7 +154,6 @@ fn derivation_grants_personal_capabilities_only_to_owner_private_work() { community: community(), channel_id: Uuid::from_u128(2), kind: ConversationKind::Public, - epoch: MembershipEpoch::new("community:v1"), members: BTreeSet::new(), executing_agent: agent, requesters: BTreeSet::from([owner]), @@ -189,7 +186,6 @@ fn restricted_derivation_requires_verified_membership() { community: community(), channel_id: Uuid::from_u128(1), kind: ConversationKind::Restricted, - epoch: MembershipEpoch::new("membership:v1"), members, executing_agent: agent, requesters, @@ -217,96 +213,96 @@ fn restricted_derivation_requires_verified_membership() { ); } -/// Change each of the six domain fields separately and require a different key. -/// Omitting one from the hash could reuse history from a different agent, owner, -/// audience, conversation, membership version, or capability set. +/// Change each domain component separately and require a different domain. +/// Omitting one from equality could reuse history from a different agent, owner, +/// audience, conversation, community, or capability set. #[test] -fn every_domain_component_changes_the_routing_key() { - let base = conversation(&[1, 2], Uuid::from_u128(1), "v1"); - let build = |agent, owner, audience, channel_id, epoch, capabilities| { +fn structural_domain_equality_compares_every_component() { + let base = conversation(&[1, 2], Uuid::from_u128(1)); + let build = |agent, owner, audience: ConfidentialityLabel, channel_id, capabilities| { + let community = *audience.universe(); ExecutionDomain::new( agent, owner, audience, DomainContext::Conversation { - community: community(), + community, channel_id, }, - MembershipEpoch::new(epoch), capabilities, ) .expect("coherent domain") }; - let ids = [ - base.key(), + let domains = [ + base, build( principal(8), Some(principal(1)), label(&[1, 2]), Uuid::from_u128(1), - "v1", conversation_capabilities(), - ) - .key(), + ), build( principal(9), None, label(&[1, 2]), Uuid::from_u128(1), - "v1", conversation_capabilities(), - ) - .key(), + ), build( principal(9), Some(principal(1)), label(&[1, 3]), Uuid::from_u128(1), - "v1", conversation_capabilities(), - ) - .key(), + ), build( principal(9), Some(principal(1)), label(&[1, 2]), Uuid::from_u128(2), - "v1", conversation_capabilities(), - ) - .key(), + ), + build( + principal(9), + Some(principal(1)), + ConfidentialityLabel::restricted(other_community(), readers(&[1, 2])) + .expect("non-empty readers"), + Uuid::from_u128(1), + conversation_capabilities(), + ), build( principal(9), Some(principal(1)), label(&[1, 2]), Uuid::from_u128(1), - "v2", - conversation_capabilities(), - ) - .key(), - build( - principal(9), - Some(principal(1)), - label(&[1, 2]), - Uuid::from_u128(1), - "v1", non_egressing(["buzz.read.current"]), - ) - .key(), + ), ]; - assert_eq!(ids.iter().collect::>().len(), ids.len()); + for (index, domain) in domains.iter().enumerate() { + for previous in &domains[..index] { + assert_ne!(domain, previous); + } + } } -/// Keep the same key for the same domain across code changes. An accidental -/// encoding change would break lookups of state saved under the old key. +/// Reader and operation ordering, and duplicate entries, do not change a domain. +/// Equivalent structures must also select the same entry in a retained-state pool. #[test] -fn domain_key_has_a_canonical_golden_value() { - let domain = conversation(&[1, 2], Uuid::from_u128(1), "membership:event-1"); - assert_eq!( - domain.key().as_str(), - "c83fb1c188109526fca87b73297eb00f9bf0b6545e36c6f0b8d3fb2771ce7962" - ); +fn equivalent_domains_ignore_reader_and_capability_ordering() { + let domain = conversation(&[1, 2], Uuid::from_u128(1)); + let mut reordered = conversation(&[2, 1, 2], Uuid::from_u128(1)); + reordered.capabilities = CapabilitySet::from_operations([ + ("buzz.reply", OperationEffect::Publication), + ("buzz.post", OperationEffect::Publication), + ("buzz.read.current", OperationEffect::NonEgressing), + ("buzz.reply", OperationEffect::Publication), + ]); + + assert_eq!(domain, reordered); + let pool = std::collections::HashMap::from([(domain, "retained state")]); + assert_eq!(pool.get(&reordered), Some(&"retained state")); } /// A public audience cannot be paired with another community's context or with @@ -319,7 +315,6 @@ fn domains_reject_incoherent_audience_context_pairs() { Some(principal(1)), ConfidentialityLabel::public(community()), DomainContext::CommunityPublic(other_community()), - MembershipEpoch::new("v1"), CapabilitySet::default(), ), Err(DomainError::ContextCommunityMismatch) @@ -333,7 +328,6 @@ fn domains_reject_incoherent_audience_context_pairs() { community: community(), channel_id: Uuid::from_u128(2), }, - MembershipEpoch::new("v1"), CapabilitySet::default(), ), Err(DomainError::AudienceContextMismatch) diff --git a/crates/ifc-core/src/lib.rs b/crates/ifc-core/src/lib.rs index a95241a0f3..bfcb300048 100644 --- a/crates/ifc-core/src/lib.rs +++ b/crates/ifc-core/src/lib.rs @@ -40,7 +40,7 @@ use std::fmt::{Display, Formatter}; /// /// 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)] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] pub enum ReaderSet { /// Every principal in the universe may read the value. Everyone, @@ -103,7 +103,7 @@ impl ReaderSet { } /// A reader-set confidentiality label inside one isolated universe. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct ConfidentialityLabel { universe: Universe, readers: ReaderSet,