Compare IFC domains using current policy state

Compare complete domain structures and derive restricted audiences from normalized membership. Remove event-ID epochs from execution domains and resource labels while preserving retained session restrictions.

Signed-off-by: Jordan Mecom <jm@squareup.com>
This commit is contained in:
Jordan Mecom
2026-10-09 13:15:51 -07:00
parent 4dd75ac4a9
commit cc510df616
9 changed files with 140 additions and 291 deletions

2
Cargo.lock generated
View File

@@ -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",
]

View File

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

View File

@@ -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<String, OperationEffect>);
impl CapabilitySet {
@@ -177,14 +147,6 @@ impl CapabilitySet {
pub(crate) fn effect(&self, operation: &str) -> Option<OperationEffect> {
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<String>) -> 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<Principal>,
@@ -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<Principal>,
pub(crate) audience: ConfidentialityLabel,
pub(crate) context: DomainContext,
pub(crate) epoch: MembershipEpoch,
pub(crate) capabilities: CapabilitySet,
}
@@ -422,13 +358,12 @@ impl ExecutionDomain {
owner: Option<Principal>,
audience: ConfidentialityLabel,
context: DomainContext,
epoch: MembershipEpoch,
capabilities: CapabilitySet,
) -> Result<Self, DomainError> {
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(),

View File

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

View File

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

View File

@@ -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<MembershipEpoch>,
}
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<DomainKey, IfcSession>,
/// # sessions: &mut HashMap<ExecutionDomain, IfcSession>,
/// # domain: ExecutionDomain,
/// # resource: &ResourceLabel,
/// # destination: &ConfidentialityLabel,
/// # request_bytes: Vec<u8>,
/// # ) -> 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,

View File

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

View File

@@ -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::<BTreeSet<_>>().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)

View File

@@ -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<Principal> {
/// Every principal in the universe may read the value.
Everyone,
@@ -103,7 +103,7 @@ impl<Principal: Ord> ReaderSet<Principal> {
}
/// A reader-set confidentiality label inside one isolated universe.
#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct ConfidentialityLabel<Universe, Principal> {
universe: Universe,
readers: ReaderSet<Principal>,