From b675bb08be3f3e44cce36fd4fbb809180d023cd6 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Thu, 3 Sep 2026 12:25:07 -0700 Subject: [PATCH] Add Buzz IFC session adapter Signed-off-by: Jordan Mecom --- Cargo.lock | 15 + Cargo.toml | 3 + crates/buzz-ifc/Cargo.toml | 21 + .../security_property_tests.txt | 7 + crates/buzz-ifc/src/declassification.rs | 231 +++++ crates/buzz-ifc/src/domain.rs | 860 ++++++++++++++++ crates/buzz-ifc/src/hash.rs | 12 + crates/buzz-ifc/src/label.rs | 136 +++ crates/buzz-ifc/src/lib.rs | 62 ++ crates/buzz-ifc/src/policy.rs | 507 ++++++++++ .../buzz-ifc/src/security_property_tests.rs | 654 ++++++++++++ crates/buzz-ifc/src/session.rs | 289 ++++++ crates/buzz-ifc/src/session_tests.rs | 241 +++++ crates/buzz-ifc/src/test_support.rs | 80 ++ crates/buzz-ifc/src/tests.rs | 939 ++++++++++++++++++ 15 files changed, 4057 insertions(+) create mode 100644 crates/buzz-ifc/Cargo.toml create mode 100644 crates/buzz-ifc/proptest-regressions/security_property_tests.txt create mode 100644 crates/buzz-ifc/src/declassification.rs create mode 100644 crates/buzz-ifc/src/domain.rs create mode 100644 crates/buzz-ifc/src/hash.rs create mode 100644 crates/buzz-ifc/src/label.rs create mode 100644 crates/buzz-ifc/src/lib.rs create mode 100644 crates/buzz-ifc/src/policy.rs create mode 100644 crates/buzz-ifc/src/security_property_tests.rs create mode 100644 crates/buzz-ifc/src/session.rs create mode 100644 crates/buzz-ifc/src/session_tests.rs create mode 100644 crates/buzz-ifc/src/test_support.rs create mode 100644 crates/buzz-ifc/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index d5f80a5fc8..082e2a3c18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1129,6 +1129,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "buzz-ifc" +version = "0.1.0" +dependencies = [ + "hex", + "ifc-core", + "nostr 0.44.7", + "proptest", + "serde", + "sha2 0.11.0", + "thiserror 2.0.18", + "url", + "uuid", +] + [[package]] name = "buzz-media" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3e47708e37..fe73d9c66f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/buzz-relay", "crates/buzz-core", "crates/ifc-core", + "crates/buzz-ifc", "crates/buzz-conformance", "crates/buzz-push-gateway", "crates/buzz-db", @@ -138,6 +139,8 @@ schemars = { version = "1", default-features = false } # Internal crates buzz-core = { path = "crates/buzz-core" } +ifc-core = { path = "crates/ifc-core" } +buzz-ifc = { path = "crates/buzz-ifc" } buzz-conformance = { path = "crates/buzz-conformance" } buzz-db = { path = "crates/buzz-db" } buzz-deletion = { path = "crates/buzz-deletion" } diff --git a/crates/buzz-ifc/Cargo.toml b/crates/buzz-ifc/Cargo.toml new file mode 100644 index 0000000000..6f788c05c4 --- /dev/null +++ b/crates/buzz-ifc/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "buzz-ifc" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Audience-scoped information-flow policy for Buzz agents" + +[dependencies] +hex = { workspace = true } +ifc-core = { workspace = true } +nostr = { workspace = true } +serde = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/buzz-ifc/proptest-regressions/security_property_tests.txt b/crates/buzz-ifc/proptest-regressions/security_property_tests.txt new file mode 100644 index 0000000000..af4c52ef37 --- /dev/null +++ b/crates/buzz-ifc/proptest-regressions/security_property_tests.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 3290a105c4a35a034766c3c4def1275c2c8326b3996f861acb33c885cb08a3d6 # shrinks to actions = [Refresh(Agent), Refresh(Same), Read(TrustedConfiguration)] diff --git a/crates/buzz-ifc/src/declassification.rs b/crates/buzz-ifc/src/declassification.rs new file mode 100644 index 0000000000..734a804be0 --- /dev/null +++ b/crates/buzz-ifc/src/declassification.rs @@ -0,0 +1,231 @@ +use nostr::secp256k1::schnorr::Signature; +use nostr::secp256k1::Message; +use nostr::{PublicKey, SECP256K1}; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::domain::PublicationTarget; +use crate::hash::hash_field; +use crate::label::Principal; + +/// Stable nonce identifying one declassification authorization. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct GrantId([u8; 32]); + +impl GrantId { + /// Construct a grant identifier from an unpredictable broker-issued nonce. + pub fn from_bytes(value: [u8; 32]) -> Self { + Self(value) + } + + /// Return the nonce bytes stored by durable replay protection. + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +/// Versioned, canonical payload approved by a bot owner. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeclassificationGrantPayload { + approver: Principal, + grant_id: GrantId, + operation: String, + source_domain_id: String, + destination: PublicationTarget, + content_digest: [u8; 32], + expires_at: u64, +} + +impl DeclassificationGrantPayload { + /// Construct every field covered by the owner's signature. + pub fn new( + approver: Principal, + grant_id: GrantId, + operation: impl Into, + source_domain_id: impl Into, + destination: PublicationTarget, + content_digest: [u8; 32], + expires_at: u64, + ) -> Self { + Self { + approver, + grant_id, + operation: operation.into(), + source_domain_id: source_domain_id.into(), + destination, + content_digest, + expires_at, + } + } + + /// Return the principal whose signature is required. + pub fn approver(&self) -> &Principal { + &self.approver + } + + /// Return the durable replay-protection identifier. + pub fn grant_id(&self) -> &GrantId { + &self.grant_id + } + + /// Return the exact publication operation approved by the owner. + pub fn operation(&self) -> &str { + &self.operation + } + + /// Return the exact execution domain from which the content came. + pub fn source_domain_id(&self) -> &str { + &self.source_domain_id + } + + /// Return the exact destination and membership epoch approved for release. + pub fn destination(&self) -> &PublicationTarget { + &self.destination + } + + /// Return the digest of the exact content approved for release. + pub fn content_digest(&self) -> &[u8; 32] { + &self.content_digest + } + + /// Return the Unix timestamp at which this approval stops being valid. + pub fn expires_at(&self) -> u64 { + self.expires_at + } + + /// Return the BIP-340 message digest covering every grant field. + /// + /// Owners sign this digest directly. The domain separator versions the + /// canonical encoding, and every variable-length field is length-prefixed. + pub fn signing_digest(&self) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"buzz-ifc-declassification-v2"); + hash_field(&mut hasher, self.approver.as_hex().as_bytes()); + hash_field(&mut hasher, self.grant_id.as_bytes()); + hash_field(&mut hasher, self.operation.as_bytes()); + hash_field(&mut hasher, self.source_domain_id.as_bytes()); + self.destination.stable_hash(&mut hasher); + hash_field(&mut hasher, &self.content_digest); + hash_field(&mut hasher, &self.expires_at.to_be_bytes()); + hasher.finalize().into() + } + + pub(crate) fn matches( + &self, + operation: &str, + source_domain_id: &str, + destination: &PublicationTarget, + content_digest: &[u8; 32], + ) -> bool { + self.operation == operation + && self.source_domain_id == source_domain_id + && &self.destination == destination + && &self.content_digest == content_digest + } +} + +/// One deliberate, owner-approved release to a broader audience. +/// +/// The signed payload binds the approver, an unpredictable nonce, the exact +/// publication operation, the complete source domain, the destination and its +/// membership epoch, the content digest, and an expiry. Verifying the signature +/// inside this crate prevents an adapter from authenticating an envelope while +/// trusting different decoded fields. [`crate::IfcSession::publish`] also +/// requires the approver to be the source domain's owner and rejects a grant +/// that differs from the requested publication in any bound field. +/// +/// Durable replay consumption happens at publication commit, immediately +/// before the broker signs or submits the authorized bytes. The approval is +/// therefore exact, expiring, and single-use; it does not grant the worker a +/// general capability to declassify other content or future output. +/// This implements the explicit release described under declassification in +/// [Appendix G of the design +/// paper](../../../docs/practical-information-flow-for-buzz-agents.md#appendix-g-security-labels-as-a-lattice). +pub struct DeclassificationGrant { + payload: DeclassificationGrantPayload, + signature: [u8; 64], +} + +impl DeclassificationGrant { + /// Return the signed canonical payload. + pub fn payload(&self) -> &DeclassificationGrantPayload { + &self.payload + } + + /// Construct a grant from its decoded signed payload. + pub fn new(payload: DeclassificationGrantPayload, signature: [u8; 64]) -> Self { + Self { payload, signature } + } + + /// Verify expiration and the approver's BIP-340 signature over canonical + /// bytes. [`crate::IfcSession::publish`] separately requires the approver + /// to be the owner of the source execution domain. + pub fn verify(self, now: u64) -> Result { + if now >= self.payload.expires_at() { + return Err(GrantError::Expired); + } + + let signature = + Signature::from_slice(&self.signature).map_err(|_| GrantError::InvalidSignature)?; + let public_key = PublicKey::from_hex(self.payload.approver().as_hex()) + .map_err(|_| GrantError::InvalidSignature)?; + let xonly = public_key + .xonly() + .map_err(|_| GrantError::InvalidSignature)?; + let message = Message::from_digest(self.payload.signing_digest()); + SECP256K1 + .verify_schnorr(&signature, &message, &xonly) + .map_err(|_| GrantError::InvalidSignature)?; + + Ok(VerifiedDeclassificationGrant { + payload: self.payload, + }) + } +} + +/// A declassification grant whose payload signature and expiry were checked. +pub struct VerifiedDeclassificationGrant { + payload: DeclassificationGrantPayload, +} + +impl VerifiedDeclassificationGrant { + /// Return the authenticated canonical payload. + pub fn payload(&self) -> &DeclassificationGrantPayload { + &self.payload + } + + pub(crate) fn matches( + &self, + operation: &str, + source_domain_id: &str, + destination: &PublicationTarget, + content_digest: &[u8; 32], + ) -> bool { + self.payload + .matches(operation, source_domain_id, destination, content_digest) + } +} + +/// Durable, atomic replay protection used when committing declassification. +pub trait GrantReplayStore { + /// Atomically record a signed grant digest if it has never been consumed. + /// + /// The digest binds the nonce, approver, source, operation, destination, + /// content, and expiry. Return `true` only for the first durable + /// consumption. Implementations may use `expires_at` to garbage-collect + /// records after the grant can no longer pass commit-time expiry + /// validation. + fn consume_if_unused(&mut self, grant_digest: &[u8; 32], expires_at: u64) -> bool; +} + +/// A declassification grant failed authentication. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum GrantError { + /// The signed grant has reached its expiration time. + #[error("declassification grant has expired")] + Expired, + /// The BIP-340 signature does not cover the canonical grant payload. + #[error("declassification grant signature is invalid")] + InvalidSignature, +} diff --git a/crates/buzz-ifc/src/domain.rs b/crates/buzz-ifc/src/domain.rs new file mode 100644 index 0000000000..e2b631c244 --- /dev/null +++ b/crates/buzz-ifc/src/domain.rs @@ -0,0 +1,860 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::hash::{hash_field, short_fingerprint}; +use crate::label::{ + stable_hash_label, stable_hash_readers, ConfidentialityLabel, LabelError, Principal, ReaderSet, + RealmId, +}; + +/// Which retained context a worker belongs to. +/// +/// Audience answers who may read information; context answers which +/// conversation history and managed memory a worker may retain. These are +/// independent boundaries. Two conversations with identical participants do +/// not implicitly share state, while public conversations deliberately use one +/// realm-wide public context. Owner-private state is likewise distinct from +/// ordinary conversation state, even within the same realm. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum DomainContext { + /// Shared state for public conversations in one realm. + RealmPublic(RealmId), + /// State retained for one specific restricted conversation. + Conversation { + /// The Buzz community containing the conversation. + realm: RealmId, + /// The channel, DM, or group-DM identifier. + channel_id: Uuid, + }, + /// State visible only to the bot owner. + OwnerPrivate { + /// The Buzz community containing the owner relationship. + realm: RealmId, + /// The bot owner. + owner: Principal, + }, +} + +/// Runtime placement required by an execution domain. +/// +/// The IFC rules do not implement an OS sandbox. They tell the harness whether +/// a worker may use the shared public runtime or must be placed in a compartment +/// dedicated to one complete execution domain. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CompartmentProfile { + /// Realm-public conversations may share a worker, public memory, and public + /// tools. The worker must still be unable to reach broker secrets or private + /// compartments. + SharedPublic, + /// A restricted conversation or owner-private task requires a worker whose + /// writable state and output paths are confined to the exact domain. + DomainConfined, +} + +impl CompartmentProfile { + /// Return the stable wire and log representation. + pub fn as_str(self) -> &'static str { + match self { + Self::SharedPublic => "shared_public", + Self::DomainConfined => "domain_confined", + } + } +} + +impl DomainContext { + /// Return the realm containing this context. + pub fn realm(&self) -> &RealmId { + match self { + Self::RealmPublic(realm) + | Self::Conversation { realm, .. } + | Self::OwnerPrivate { realm, .. } => realm, + } + } + + /// Return a stable context category for logs and protocol responses. + pub fn kind(&self) -> &'static str { + match self { + Self::RealmPublic(_) => "public", + Self::Conversation { .. } => "conversation", + Self::OwnerPrivate { .. } => "owner_private", + } + } + + /// Whether this is the private aggregation context for `owner`. + pub fn is_owner_private_for(&self, owner: &Principal) -> bool { + matches!(self, Self::OwnerPrivate { owner: candidate, .. } if candidate == owner) + } + + fn resource_context(&self) -> ResourceContext { + match self { + Self::RealmPublic(realm) => ResourceContext::RealmPublic(realm.clone()), + Self::Conversation { realm, channel_id } => ResourceContext::Conversation { + realm: realm.clone(), + channel_id: *channel_id, + }, + Self::OwnerPrivate { realm, owner } => ResourceContext::OwnerPrivate { + realm: realm.clone(), + owner: owner.clone(), + }, + } + } + + pub(crate) fn permits(&self, resource: &ResourceContext) -> bool { + match resource { + ResourceContext::TrustedConfiguration => true, + ResourceContext::RealmPublic(resource_realm) => self.realm() == resource_realm, + ResourceContext::Conversation { + realm: resource_realm, + channel_id: resource_channel, + } => match self { + Self::Conversation { realm, channel_id } => { + realm == resource_realm && channel_id == resource_channel + } + Self::OwnerPrivate { realm, .. } => realm == resource_realm, + Self::RealmPublic(_) => false, + }, + ResourceContext::OwnerPrivate { + realm: resource_realm, + owner: resource_owner, + } => matches!( + self, + Self::OwnerPrivate { realm, owner } + if realm == resource_realm && owner == resource_owner + ), + } + } + + pub(crate) fn stable_hash(&self, hasher: &mut Sha256) { + match self { + Self::RealmPublic(realm) => { + hash_field(hasher, b"realm-public"); + realm.stable_hash(hasher); + } + Self::Conversation { realm, channel_id } => { + hash_field(hasher, b"conversation"); + realm.stable_hash(hasher); + hash_field(hasher, channel_id.as_bytes()); + } + Self::OwnerPrivate { realm, owner } => { + hash_field(hasher, b"owner-private"); + realm.stable_hash(hasher); + hash_field(hasher, owner.0.as_bytes()); + } + } + } +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(crate) enum ResourceContext { + TrustedConfiguration, + RealmPublic(RealmId), + Conversation { realm: RealmId, channel_id: Uuid }, + OwnerPrivate { realm: RealmId, owner: Principal }, +} + +/// Whether invoking an operation can publish information outside the worker. +/// +/// This classification belongs to trusted policy configuration, not to the +/// agent's call request. Publication operations always require both capability +/// admission and an information-flow decision before they may execute. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PublicationScope { + /// The operation may publish only to its source context. + SameContext, + /// The operation may select another context in the same realm, subject to + /// the ordinary audience-flow or declassification rules. + WithinRealm, +} + +impl PublicationScope { + fn most_restrictive(self, other: Self) -> Self { + if self == Self::SameContext || other == Self::SameContext { + Self::SameContext + } else { + Self::WithinRealm + } + } + + fn stable_name(self) -> &'static [u8] { + match self { + Self::SameContext => b"same-context", + Self::WithinRealm => b"within-realm", + } + } +} + +/// Whether invoking an operation can publish information outside the worker. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OperationEffect { + /// The operation cannot publish information outside the worker boundary. + NonEgressing, + /// The operation publishes information to a broker-resolved destination. + Publication(PublicationScope), +} + +impl OperationEffect { + fn most_restrictive(self, other: Self) -> Self { + match (self, other) { + (Self::NonEgressing, Self::NonEgressing) => Self::NonEgressing, + (Self::Publication(left), Self::Publication(right)) => { + Self::Publication(left.most_restrictive(right)) + } + (Self::Publication(scope), Self::NonEgressing) + | (Self::NonEgressing, Self::Publication(scope)) => Self::Publication(scope), + } + } + + fn stable_hash(self, hasher: &mut Sha256) { + match self { + Self::NonEgressing => hash_field(hasher, b"non-egressing"), + Self::Publication(scope) => { + hash_field(hasher, b"publication"); + hash_field(hasher, scope.stable_name()); + } + } + } +} + +/// The complete set of operations admitted for one execution domain. +/// +/// Raw membership is intentionally private because it is not an authorization +/// decision: +/// +/// ```compile_fail +/// let capabilities = buzz_ifc::CapabilitySet::default(); +/// let _ = capabilities.contains("buzz.post"); +/// ``` +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CapabilitySet(BTreeMap); + +impl CapabilitySet { + /// Build a set from stable operation names and trusted effect classes. + /// + /// If an operation is repeated with different effects, publication wins so + /// conflicting configuration cannot downgrade an egressing operation. + pub fn from_operations(operations: I) -> Self + where + I: IntoIterator, + S: Into, + { + let mut capabilities: BTreeMap = BTreeMap::new(); + for (name, effect) in operations { + capabilities + .entry(name.into()) + .and_modify(|existing| *existing = existing.most_restrictive(effect)) + .or_insert(effect); + } + Self(capabilities) + } + + /// Compute the operations admitted by every independent capability + /// ceiling: the bot, the authenticated requester, and the execution + /// domain. + /// + /// An operation missing from any ceiling is denied. When the same + /// operation has conflicting effect classifications, the result preserves + /// the most restrictive classification: publication wins over + /// non-egressing, and same-context publication wins over within-realm + /// publication. No policy layer can accidentally downgrade an egressing + /// operation into an unchecked call. + pub(crate) fn effective(bot: &Self, requester: &Self, domain: &Self) -> Self { + let mut effective = BTreeMap::new(); + for (name, bot_effect) in &bot.0 { + let (Some(requester_effect), Some(domain_effect)) = + (requester.0.get(name), domain.0.get(name)) + else { + continue; + }; + effective.insert( + name.clone(), + bot_effect + .most_restrictive(*requester_effect) + .most_restrictive(*domain_effect), + ); + } + Self(effective) + } + + 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); + } + } +} + +/// Capability ceilings used while deriving an invocation's effective set. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CapabilityPolicy { + bot: CapabilitySet, + conversation: CapabilitySet, +} + +impl CapabilityPolicy { + /// Construct policy from the bot's full ceiling and the ceiling permitted + /// in shared Buzz conversations. + pub fn new(bot: CapabilitySet, conversation: CapabilitySet) -> Self { + Self { bot, conversation } + } +} + +/// The membership or policy version under which state was created. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct MembershipEpoch(String); + +impl MembershipEpoch { + /// Construct an epoch from a stable, verifier-controlled identifier. + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Return a short identifier suitable for logs. + pub fn fingerprint(&self) -> String { + short_fingerprint(&self.0) + } + + pub(crate) fn stable_hash(&self, hasher: &mut Sha256) { + hash_field(hasher, self.0.as_bytes()); + } +} + +/// Buzz conversation classification after signed metadata verification. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ConversationKind { + /// Realm-wide public channel. All public channels intentionally share one + /// execution domain. + Public, + /// Invite-only channel or group DM with conversation-specific state. + Restricted, + /// A DM. A two-party owner/bot DM becomes owner-private; group DMs remain + /// conversation-specific. + DirectMessage, +} + +/// Verified Buzz facts from which the shared policy derives an execution +/// domain. +/// +/// A trusted adapter constructs this only after checking trigger signatures, +/// channel binding, and the relay signature on metadata and membership. +pub struct DomainFacts { + /// Community realm selected by the trusted Buzz connection. + pub realm: RealmId, + /// Channel, DM, or group-DM identifier that triggered the invocation. + pub channel_id: Uuid, + /// Verified conversation classification. + pub kind: ConversationKind, + /// Relay-controlled membership or community policy version. + pub epoch: MembershipEpoch, + /// Complete verified roster. Public derivation does not consume this set. + pub members: BTreeSet, + /// Managed Buzz identity whose work the execution domain contains. + pub executing_agent: Principal, + /// Authors whose events are included in this invocation. + pub requesters: BTreeSet, + /// Optional relay principal allowed to author trusted workflow events. + pub system_principal: Option, + /// Optional human owner of the executing agent. + pub owner: Option, +} + +/// Domain derivation failed despite the adapter's claim that its facts were +/// already verified. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum DerivationError { + /// Every invocation must contain at least one authenticated requester. + #[error("invocation has no authenticated requester")] + EmptyRequesters, + /// Restricted conversations must include the executing agent in their + /// verified roster. + #[error("executing agent is absent from channel membership")] + AgentNotMember, + /// A non-system requester is absent from restricted membership. + #[error("requester is absent from channel membership")] + RequesterNotMember, + /// Removing the executing processor left no authorized recipient. + #[error("restricted conversation has no recipient audience")] + EmptyRestrictedAudience, + /// The derived audience and context violated a domain invariant. + #[error("derived execution domain is inconsistent")] + InvalidDomain, +} + +/// Derive a complete execution domain from verified Buzz facts. +/// +/// The adapter supplies authenticated requesters, verified conversation +/// membership, and a relay-controlled membership epoch; the agent cannot +/// assert any of these values. Public conversations receive the realm-wide +/// audience and shared public context. Restricted conversations receive the +/// verified member audience, excluding the executing agent, and retain state +/// only for their channel. A two-party owner/agent DM instead receives the +/// owner's private context. Effective capabilities are the intersection of +/// the bot, requester, and context ceilings. +/// +/// Agent identity, owner, audience, context, epoch, and capabilities all feed +/// the domain identifier used for worker placement. A change to any component +/// therefore selects a different domain rather than silently reusing a worker +/// that may remember information admitted under older authority. This mapping +/// is shared by local ACP and remote agent harnesses. +pub fn derive_execution_domain( + facts: DomainFacts, + policy: &CapabilityPolicy, +) -> Result { + if facts.requesters.is_empty() { + return Err(DerivationError::EmptyRequesters); + } + + if facts.kind == ConversationKind::Public { + let context = DomainContext::RealmPublic(facts.realm.clone()); + let capabilities = effective_capabilities(&context, &facts, policy); + return Ok(ExecutionDomain::public( + facts.executing_agent, + facts.owner, + facts.realm, + facts.epoch, + capabilities, + )); + } + + if !facts.members.contains(&facts.executing_agent) { + return Err(DerivationError::AgentNotMember); + } + if facts.requesters.iter().any(|requester| { + facts.system_principal.as_ref() != Some(requester) && !facts.members.contains(requester) + }) { + return Err(DerivationError::RequesterNotMember); + } + + let mut readers = facts.members.clone(); + readers.remove(&facts.executing_agent); + if readers.is_empty() { + return Err(DerivationError::EmptyRestrictedAudience); + } + + let context = match (&facts.owner, facts.kind) { + (Some(owner), ConversationKind::DirectMessage) + if readers.len() == 1 && readers.contains(owner) => + { + DomainContext::OwnerPrivate { + realm: facts.realm.clone(), + owner: owner.clone(), + } + } + _ => DomainContext::Conversation { + realm: facts.realm.clone(), + channel_id: facts.channel_id, + }, + }; + let capabilities = effective_capabilities(&context, &facts, policy); + let audience = ConfidentialityLabel::restricted(facts.realm, readers) + .map_err(|_| DerivationError::EmptyRestrictedAudience)?; + ExecutionDomain::new( + facts.executing_agent, + facts.owner, + audience, + context, + facts.epoch, + capabilities, + ) + .map_err(|_| DerivationError::InvalidDomain) +} + +fn effective_capabilities( + context: &DomainContext, + facts: &DomainFacts, + policy: &CapabilityPolicy, +) -> CapabilitySet { + let requester_is_owner = facts + .owner + .as_ref() + .is_some_and(|owner| facts.requesters.iter().all(|requester| requester == owner)); + let requester = if requester_is_owner { + &policy.bot + } else { + &policy.conversation + }; + let domain = if matches!(context, DomainContext::OwnerPrivate { .. }) { + &policy.bot + } else { + &policy.conversation + }; + CapabilitySet::effective(&policy.bot, requester, domain) +} + +/// Opaque routing key for one complete execution domain. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct DomainKey(String); + +impl DomainKey { + /// Return a short identifier suitable for logs. + pub fn fingerprint(&self) -> String { + short_fingerprint(&self.0) + } + + /// Return the full stable identifier used as a worker-pool routing key. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// `D = (Agent, Audience, Context, Epoch, Capabilities)`. +/// +/// This is the executable form of the domain model in [Appendix B of the +/// design paper](../../../docs/practical-information-flow-for-buzz-agents.md#appendix-b-formal-execution-domains). +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExecutionDomain { + agent: Principal, + owner: Option, + pub(crate) audience: ConfidentialityLabel, + pub(crate) context: DomainContext, + pub(crate) epoch: MembershipEpoch, + pub(crate) capabilities: CapabilitySet, +} + +/// Trusted holder of the currently verified execution domain. +/// +/// The adapter replaces this value whenever membership or policy changes. +/// Authorization APIs accept only a borrowed [`CurrentDomain`], preventing an +/// arbitrary stored `ExecutionDomain` from being passed as its own freshness +/// proof. +#[derive(Debug)] +pub(crate) struct DomainAuthority { + current: ExecutionDomain, +} + +impl DomainAuthority { + /// Install the domain derived from the adapter's current verified state. + pub(crate) fn new(current: ExecutionDomain) -> Self { + Self { current } + } + + /// Replace the current domain after verified membership or policy changes. + /// + /// Rust's borrow rules prevent replacement while a `CurrentDomain` token + /// from this authority remains usable. + pub(crate) fn replace(&mut self, current: ExecutionDomain) { + self.current = current; + } + + /// Borrow an authority-minted proof of the current domain. + pub(crate) fn current(&self) -> CurrentDomain<'_> { + CurrentDomain { + domain: &self.current, + } + } +} + +/// Unforgeable-by-construction borrow of a [`DomainAuthority`]'s current value. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct CurrentDomain<'a> { + domain: &'a ExecutionDomain, +} + +impl<'a> CurrentDomain<'a> { + /// Return the currently verified execution domain. + pub(crate) fn domain(self) -> &'a ExecutionDomain { + self.domain + } +} + +/// A broker-resolved publication destination and its current membership epoch. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PublicationTarget { + audience: ConfidentialityLabel, + context: DomainContext, + epoch: MembershipEpoch, +} + +impl PublicationTarget { + /// Construct a current destination after the broker resolves membership. + pub fn new( + audience: ConfidentialityLabel, + context: DomainContext, + epoch: MembershipEpoch, + ) -> Result { + if audience.universe() != context.realm() { + return Err(PublicationTargetError::ContextRealmMismatch); + } + if !audience_context_shape_matches(&audience, &context) { + return Err(PublicationTargetError::AudienceContextMismatch); + } + Ok(Self { + audience, + context, + epoch, + }) + } + + /// Resolve a target from a currently verified execution domain. + pub(crate) fn from_current(current: CurrentDomain<'_>) -> Self { + Self { + audience: current.domain.audience.clone(), + context: current.domain.context.clone(), + epoch: current.domain.epoch.clone(), + } + } + + /// Return the destination audience. + pub fn audience(&self) -> &ConfidentialityLabel { + &self.audience + } + + /// Return the destination context. + pub fn context(&self) -> &DomainContext { + &self.context + } + + /// Return the verified destination membership epoch. + pub fn epoch(&self) -> &MembershipEpoch { + &self.epoch + } + + pub(crate) fn stable_hash(&self, hasher: &mut Sha256) { + stable_hash_label(&self.audience, hasher); + self.context.stable_hash(hasher); + self.epoch.stable_hash(hasher); + } +} + +/// A publication target contains inconsistent audience or context data. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum PublicationTargetError { + /// The audience and context belong to different Buzz realms. + #[error("publication target audience and context belong to different realms")] + ContextRealmMismatch, + /// The audience shape is invalid for the selected context kind. + #[error("publication target audience does not match its context")] + AudienceContextMismatch, +} + +impl ExecutionDomain { + /// Construct a domain after the trusted adapter has resolved its inputs. + pub(crate) fn new( + agent: Principal, + owner: Option, + audience: ConfidentialityLabel, + context: DomainContext, + epoch: MembershipEpoch, + capabilities: CapabilitySet, + ) -> Result { + if audience.universe() != context.realm() { + return Err(DomainError::ContextRealmMismatch); + } + if !audience_context_shape_matches(&audience, &context) { + return Err(DomainError::AudienceContextMismatch); + } + Ok(Self { + agent, + owner, + audience, + context, + epoch, + capabilities, + }) + } + + /// Construct the realm-wide public domain. + pub(crate) fn public( + agent: Principal, + owner: Option, + realm: RealmId, + epoch: MembershipEpoch, + capabilities: CapabilitySet, + ) -> Self { + Self { + agent, + owner, + audience: ConfidentialityLabel::public(realm.clone()), + context: DomainContext::RealmPublic(realm), + epoch, + capabilities, + } + } + + /// Construct the exact owner-private domain. + #[cfg(test)] + pub(crate) fn owner_private( + agent: Principal, + realm: RealmId, + owner: Principal, + epoch: MembershipEpoch, + capabilities: CapabilitySet, + ) -> Self { + Self { + agent, + owner: Some(owner.clone()), + audience: ConfidentialityLabel::restricted_to(realm.clone(), owner.clone()), + context: DomainContext::OwnerPrivate { realm, owner }, + epoch, + capabilities, + } + } + + /// Return the managed Buzz identity whose work this domain contains. + pub fn agent(&self) -> &Principal { + &self.agent + } + + /// Return the human principal allowed to declassify this agent's data. + pub fn owner(&self) -> Option<&Principal> { + self.owner.as_ref() + } + + /// Return the authorized audience. + pub fn audience(&self) -> &ConfidentialityLabel { + &self.audience + } + + /// Return the retained-state context. + pub fn context(&self) -> &DomainContext { + &self.context + } + + /// Return the runtime placement required to preserve this domain. + pub fn compartment_profile(&self) -> CompartmentProfile { + if self.audience.is_public() { + CompartmentProfile::SharedPublic + } else { + CompartmentProfile::DomainConfined + } + } + + /// Return a short fingerprint of the membership or policy epoch. + pub fn epoch_fingerprint(&self) -> String { + self.epoch.fingerprint() + } + + /// Return the canonical identifier for the complete domain tuple. + pub fn id(&self) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"buzz-ifc-domain-v3"); + hash_field(&mut hasher, self.agent.0.as_bytes()); + match &self.owner { + Some(owner) => { + hash_field(&mut hasher, b"owner"); + hash_field(&mut hasher, owner.0.as_bytes()); + } + None => hash_field(&mut hasher, b"no-owner"), + } + self.audience.universe().stable_hash(&mut hasher); + stable_hash_readers(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); + hex::encode(hasher.finalize()) + } + + /// Return the opaque worker-pool routing key. + pub fn key(&self) -> DomainKey { + DomainKey(self.id()) + } + + /// Label information whose provenance is this domain itself. + pub fn resource_label(&self) -> ResourceLabel { + ResourceLabel { + confidentiality: self.audience.clone(), + context: self.context.resource_context(), + epoch: Some(self.epoch.clone()), + } + } +} + +pub(crate) fn audience_context_shape_matches( + audience: &ConfidentialityLabel, + context: &DomainContext, +) -> bool { + match (audience.reader_set(), context) { + (ReaderSet::Everyone, DomainContext::RealmPublic(_)) => true, + (ReaderSet::Only(readers), DomainContext::Conversation { .. }) => !readers.is_empty(), + (ReaderSet::Only(readers), DomainContext::OwnerPrivate { owner, .. }) => { + readers.len() == 1 && readers.contains(owner) + } + _ => false, + } +} + +/// An execution domain contains inconsistent realms. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum DomainError { + /// The audience and retained context belong to different Buzz realms. + #[error("execution-domain audience and context belong to different realms")] + ContextRealmMismatch, + /// Public, conversation, and owner-private contexts require their + /// corresponding audience shape. + #[error("execution-domain audience does not match its context")] + AudienceContextMismatch, +} + +/// The confidentiality and context assigned to one input resource. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResourceLabel { + pub(crate) confidentiality: ConfidentialityLabel, + pub(crate) context: ResourceContext, + pub(crate) epoch: Option, +} + +impl ResourceLabel { + /// Label immutable configuration that is public in the supplied realm. + pub fn trusted_configuration(realm: RealmId) -> Self { + Self { + confidentiality: ConfidentialityLabel::public(realm), + context: ResourceContext::TrustedConfiguration, + epoch: None, + } + } + + /// Label information already scoped to an execution domain. + pub fn domain(domain: &ExecutionDomain) -> Self { + domain.resource_label() + } + + /// Label information public to every member of one realm. + pub fn realm_public(realm: RealmId, epoch: MembershipEpoch) -> Self { + Self { + confidentiality: ConfidentialityLabel::public(realm.clone()), + context: ResourceContext::RealmPublic(realm), + epoch: Some(epoch), + } + } + + /// Label information belonging to one restricted conversation. + pub fn conversation( + realm: RealmId, + channel_id: Uuid, + readers: BTreeSet, + epoch: MembershipEpoch, + ) -> Result { + Ok(Self { + confidentiality: ConfidentialityLabel::restricted(realm.clone(), readers)?, + context: ResourceContext::Conversation { realm, channel_id }, + epoch: Some(epoch), + }) + } + + /// Label owner-private information such as personal memory. + pub fn owner_private(realm: RealmId, owner: Principal, epoch: MembershipEpoch) -> Self { + Self { + confidentiality: ConfidentialityLabel::restricted_to(realm.clone(), owner.clone()), + context: ResourceContext::OwnerPrivate { realm, owner }, + epoch: Some(epoch), + } + } + + pub(crate) fn is_current_for(&self, domain: &ExecutionDomain) -> bool { + self.epoch + .as_ref() + .is_none_or(|epoch| epoch == &domain.epoch) + } +} diff --git a/crates/buzz-ifc/src/hash.rs b/crates/buzz-ifc/src/hash.rs new file mode 100644 index 0000000000..f33096d4f7 --- /dev/null +++ b/crates/buzz-ifc/src/hash.rs @@ -0,0 +1,12 @@ +use sha2::{Digest, Sha256}; + +pub(crate) 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); +} + +pub(crate) fn short_fingerprint(value: &str) -> String { + let digest = Sha256::digest(value.as_bytes()); + hex::encode(&digest[..6]) +} diff --git a/crates/buzz-ifc/src/label.rs b/crates/buzz-ifc/src/label.rs new file mode 100644 index 0000000000..51860088d6 --- /dev/null +++ b/crates/buzz-ifc/src/label.rs @@ -0,0 +1,136 @@ +pub use ifc_core::LabelError; +use nostr::PublicKey; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use url::Url; + +use crate::hash::hash_field; + +/// A Buzz principal represented by a validated, normalized Nostr public key. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct Principal(pub(crate) String); + +impl Principal { + /// Parse and normalize a hexadecimal Nostr public key. + pub fn from_hex(value: &str) -> Result { + let key = PublicKey::from_hex(value).map_err(|_| PrincipalError::InvalidPublicKey)?; + Self::from_public_key(&key) + } + + /// Validate and convert a Nostr public key. + /// + /// PublicKey can hold any 32-byte value, including values that are not + /// valid x-only secp256k1 points. IFC identities must reject those values + /// before they enter reader sets or domain keys. + pub fn from_public_key(value: &PublicKey) -> Result { + value + .xonly() + .map_err(|_| PrincipalError::InvalidPublicKey)?; + Ok(Self(value.to_hex().to_ascii_lowercase())) + } + + /// Return the normalized hexadecimal public key. + pub fn as_hex(&self) -> &str { + &self.0 + } +} + +/// A principal could not be constructed from the supplied key. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum PrincipalError { + /// The value is not a valid Nostr public key. + #[error("invalid Nostr public key")] + InvalidPublicKey, +} + +/// A confidentiality universe derived from one canonical Buzz relay URL. +/// +/// Public data in one community is not public in another, so labels from +/// different realms never flow to one another. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct RealmId(pub(crate) [u8; 32]); + +impl RealmId { + /// Derive a realm identifier from the canonical form of a Buzz relay URL. + /// + /// Scheme and host case, a default port, and a root trailing slash do not + /// create distinct realms. Credentials, query parameters, and fragments + /// are rejected because they are connection details, not community + /// identity. + pub fn from_relay_url(relay_url: &str) -> Result { + let parsed = Url::parse(relay_url).map_err(|_| RealmError::InvalidUrl)?; + if !matches!(parsed.scheme(), "ws" | "wss") { + return Err(RealmError::UnsupportedScheme); + } + if parsed.host().is_none() { + return Err(RealmError::MissingHost); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(RealmError::Credentials); + } + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err(RealmError::QueryOrFragment); + } + + let mut canonical = parsed.origin().ascii_serialization(); + let path = parsed.path().trim_end_matches('/'); + if !path.is_empty() { + canonical.push_str(path); + } + Ok(Self(Sha256::digest(canonical.as_bytes()).into())) + } + + /// Return a short identifier suitable for structured logs. + pub fn fingerprint(&self) -> String { + hex::encode(&self.0[..6]) + } + + pub(crate) fn stable_hash(&self, hasher: &mut Sha256) { + hasher.update(self.0); + } +} + +/// A relay URL cannot safely identify a Buzz confidentiality realm. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum RealmError { + /// The value is not an absolute URL. + #[error("invalid Buzz relay URL")] + InvalidUrl, + /// Buzz relay realms use WebSocket URLs. + #[error("Buzz relay URL must use ws or wss")] + UnsupportedScheme, + /// A realm URL must identify a host. + #[error("Buzz relay URL has no host")] + MissingHost, + /// Credentials are connection state and cannot participate in realm identity. + #[error("Buzz relay URL must not contain credentials")] + Credentials, + /// Query parameters and fragments are not stable community identity. + #[error("Buzz relay URL must not contain a query or fragment")] + QueryOrFragment, +} + +/// A reader-set confidentiality label within one Buzz community. +pub type ConfidentialityLabel = ifc_core::ConfidentialityLabel; + +pub(crate) type ReaderSet = ifc_core::ReaderSet; + +pub(crate) fn stable_hash_label(label: &ConfidentialityLabel, hasher: &mut Sha256) { + label.universe().stable_hash(hasher); + stable_hash_readers(label.reader_set(), hasher); +} + +pub(crate) fn stable_hash_readers(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.0.as_bytes()); + } + } + } +} diff --git a/crates/buzz-ifc/src/lib.rs b/crates/buzz-ifc/src/lib.rs new file mode 100644 index 0000000000..11f5273925 --- /dev/null +++ b/crates/buzz-ifc/src/lib.rs @@ -0,0 +1,62 @@ +//! Deterministic information-flow policy for Buzz agent execution. +//! +//! This crate contains no relay, ACP, process, or storage code. A trusted Buzz +//! adapter verifies events and membership, supplies [`DomainFacts`], derives an +//! [`ExecutionDomain`], and enters one [`IfcSession`] per worker. The session's +//! `read`, `call`, and `publish` methods are the broker enforcement boundary. +//! Keeping the policy pure lets local ACP and remote harnesses apply the same +//! rules without sharing an agent implementation. +//! +//! The generic reader-set lattice and monotonic flow state live in the +//! dependency-free `ifc-core` crate. This crate specializes those primitives +//! with Buzz realms, Nostr principals, conversation contexts, membership +//! epochs, capabilities, publication targets, and signed declassification. +//! +//! The broker, not the agent, derives every label and execution domain from +//! verified Buzz state. A session accumulates the restrictions of every input +//! admitted to its worker. It may publish normally only when every recipient +//! at the destination was already authorized to read both the domain's data +//! and all accumulated inputs. Audience, retained conversation context, +//! membership epoch, and effective capabilities are all part of the domain, +//! so changing any one of them requires a different worker. +//! +//! An owner may make a deliberate exception with a signed declassification +//! grant. The grant approves one exact operation, source domain, destination, +//! content digest, and expiry, and durable replay protection makes that +//! approval single-use. It does not give the worker a signing key or standing +//! authority to release later output. +//! +//! The complete threat model, broker design, and formal information-flow model +//! are in [Practical information-flow for Buzz agents]. +//! +//! [Practical information-flow for Buzz agents]: ../../../docs/practical-information-flow-for-buzz-agents.md + +mod declassification; +mod domain; +mod hash; +mod label; +mod policy; +mod session; + +pub use declassification::{ + DeclassificationGrant, DeclassificationGrantPayload, GrantError, GrantId, GrantReplayStore, + VerifiedDeclassificationGrant, +}; +pub use domain::{ + derive_execution_domain, CapabilityPolicy, CapabilitySet, CompartmentProfile, ConversationKind, + DerivationError, DomainContext, DomainFacts, DomainKey, ExecutionDomain, MembershipEpoch, + OperationEffect, PublicationScope, PublicationTarget, PublicationTargetError, ResourceLabel, +}; +pub use label::{ConfidentialityLabel, LabelError, Principal, PrincipalError, RealmError, RealmId}; +pub use session::{ + publication_digest, AuthorizedPublication, IfcError, IfcSession, PublicationRequest, +}; + +#[cfg(test)] +mod security_property_tests; +#[cfg(test)] +mod session_tests; +#[cfg(test)] +mod test_support; +#[cfg(test)] +mod tests; diff --git a/crates/buzz-ifc/src/policy.rs b/crates/buzz-ifc/src/policy.rs new file mode 100644 index 0000000000..3afdca0005 --- /dev/null +++ b/crates/buzz-ifc/src/policy.rs @@ -0,0 +1,507 @@ +use std::collections::BTreeSet; +use std::marker::PhantomData; + +use ifc_core::{EgressError, FlowSnapshot, FlowState}; +use serde::Serialize; + +use crate::declassification::{GrantReplayStore, VerifiedDeclassificationGrant}; +use crate::domain::{ + CurrentDomain, ExecutionDomain, OperationEffect, PublicationScope, PublicationTarget, + ResourceContext, ResourceLabel, +}; +use crate::label::{Principal, RealmId}; + +/// The result of evaluating one IFC rule. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[must_use = "an IFC decision must be enforced"] +pub(crate) struct RuleDecision { + allowed: bool, + reason: &'static str, +} + +impl RuleDecision { + fn allow(reason: &'static str) -> Self { + Self { + allowed: true, + reason, + } + } + + fn deny(reason: &'static str) -> Self { + Self { + allowed: false, + reason, + } + } + + /// Whether the operation is admitted by policy. + pub(crate) fn allowed(&self) -> bool { + self.allowed + } + + /// Stable explanation intended for logs and operator diagnostics. + pub(crate) fn reason(&self) -> &'static str { + self.reason + } +} + +/// Pure IFC rule evaluation. +pub(crate) struct RuleEvaluator; + +impl RuleEvaluator { + /// Evaluate `read(D, x) ⇔ A(D) ⊆ R(x) ∧ ContextPolicy(D, x)`. + /// + /// This is the read rule specified in [Appendix D of the design + /// paper](../../../docs/practical-information-flow-for-buzz-agents.md#appendix-d-broker-interface-and-enforcement). + pub(crate) fn read( + bound_domain: &ExecutionDomain, + current: CurrentDomain<'_>, + resource: &ResourceLabel, + ) -> RuleDecision { + let domain = current.domain(); + if bound_domain != domain { + return RuleDecision::deny("execution domain is no longer current"); + } + if !resource.is_current_for(domain) { + return RuleDecision::deny("resource was labeled under a different membership epoch"); + } + if !resource.confidentiality.can_flow_to(&domain.audience) { + return RuleDecision::deny("destination audience includes an unauthorized reader"); + } + if !domain.context.permits(&resource.context) { + return RuleDecision::deny("resource belongs to a different context"); + } + RuleDecision::allow("audience, context, and epoch permit the read") + } + + /// Require a worker's bound domain to match the authority's current value. + pub(crate) fn reuse(existing: &ExecutionDomain, current: CurrentDomain<'_>) -> RuleDecision { + if existing == current.domain() { + RuleDecision::allow("complete execution domain matches") + } else { + RuleDecision::deny("agent process has already entered a different domain") + } + } +} + +#[derive(Debug, Default, Eq, PartialEq)] +struct ConfinementState { + flow: FlowState, + contexts: BTreeSet, + epochs: BTreeSet, +} + +impl ConfinementState { + fn observe(&mut self, resource: &ResourceLabel) { + self.contexts.insert(resource.context.clone()); + self.epochs.extend(resource.epoch.iter().cloned()); + self.flow.observe(&resource.confidentiality); + } + + fn snapshot(&self) -> ConfinementSnapshot { + ConfinementSnapshot { + flow: self.flow.snapshot(), + contexts: self.contexts.clone(), + epochs: self.epochs.clone(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ConfinementSnapshot { + flow: FlowSnapshot, + contexts: BTreeSet, + epochs: BTreeSet, +} + +/// Conservative policy state for one actual agent process. +/// +/// This type is deliberately not `Clone`: there must be exactly one monotonic +/// taint record for the corresponding worker process. Session invalidation +/// does not reset information the surrounding process has observed. +/// This preserves the confinement invariant described in [Appendix G of the +/// design paper](../../../docs/practical-information-flow-for-buzz-agents.md#confinement-invariant). +/// +/// ```compile_fail +/// fn fork_taint(state: buzz_ifc::ProcessState) { +/// let _stale_copy = state.clone(); +/// } +/// ``` +#[derive(Debug, Default)] +pub(crate) struct ProcessState { + entered_domains: Vec, + confinement: ConfinementState, +} + +impl ProcessState { + pub(crate) fn for_domain(current: CurrentDomain<'_>) -> Self { + Self { + entered_domains: vec![current.domain().clone()], + confinement: ConfinementState::default(), + } + } + + /// Record entry into the authority's current domain and decide whether this + /// process is reusable. + pub(crate) fn enter(&mut self, current: CurrentDomain<'_>) -> RuleDecision { + let requested = current.domain(); + let decision = match self.entered_domains.as_slice() { + [] => RuleDecision::allow("fresh process has no prior execution domain"), + [existing] => RuleEvaluator::reuse(existing, current), + _ => RuleDecision::deny("agent process has entered multiple execution domains"), + }; + if !self + .entered_domains + .iter() + .any(|domain| domain == requested) + { + self.entered_domains.push(requested.clone()); + } + decision + } + + /// Record an input that actually entered the process. + pub(crate) fn observe(&mut self, resource: &ResourceLabel) { + self.confinement.observe(resource); + } + + /// Record input whose provenance could not be established. + pub(crate) fn mark_unknown(&mut self) { + self.confinement.flow.mark_unknown(); + } + + #[cfg(test)] + pub(crate) fn has_observed_input(&self) -> bool { + self.confinement.flow.has_observed_input() + } + + /// Authorize a read only while this process remains confined to its one + /// original execution domain. + pub(crate) fn authorize_read( + &self, + bound_domain: &ExecutionDomain, + current: CurrentDomain<'_>, + resource: &ResourceLabel, + ) -> RuleDecision { + if let Err(decision) = self.active_domain(current) { + return decision; + } + RuleEvaluator::read(bound_domain, current, resource) + } + + /// Authorize an operation that trusted policy classifies as non-egressing. + pub(crate) fn authorize_non_egressing_call( + &self, + current: CurrentDomain<'_>, + operation: &str, + ) -> RuleDecision { + let source_domain = match self.active_domain(current) { + Ok(domain) => domain, + Err(decision) => return decision, + }; + match source_domain.capabilities.effect(operation) { + Some(OperationEffect::NonEgressing) => { + RuleDecision::allow("non-egressing operation is in the effective capability set") + } + Some(OperationEffect::Publication(_)) => { + RuleDecision::deny("publication operation requires an information-flow permit") + } + None => RuleDecision::deny("operation is absent from the effective capability set"), + } + } + + /// Authorize a publication and return a permit bound to every checked input. + /// + /// A permit is not sufficient to publish until + /// [`ProcessState::commit_publication`] rechecks process taint plus source + /// and destination freshness immediately before execution. + pub(crate) fn authorize_publication( + &self, + current: CurrentDomain<'_>, + operation: &str, + destination: &PublicationTarget, + content_digest: &[u8; 32], + grant: Option<&VerifiedDeclassificationGrant>, + ) -> Result { + let source_domain = self.active_domain(current)?; + let scope = match source_domain.capabilities.effect(operation) { + Some(OperationEffect::Publication(scope)) => scope, + Some(OperationEffect::NonEgressing) => { + return Err(RuleDecision::deny( + "operation is not classified for publication", + )); + } + None => { + return Err(RuleDecision::deny( + "operation is absent from the effective capability set", + )); + } + }; + if source_domain.context.realm() != destination.audience().universe() { + return Err(RuleDecision::deny("publication cannot cross Buzz realms")); + } + if scope == PublicationScope::SameContext && &source_domain.context != destination.context() + { + return Err(RuleDecision::deny( + "operation may publish only to its source context", + )); + } + if self.confinement.flow.has_unresolved_input() { + return Err(RuleDecision::deny( + "process state contains unresolved input provenance", + )); + } + if self + .confinement + .contexts + .iter() + .any(|context| !source_domain.context.permits(context)) + { + return Err(RuleDecision::deny( + "process state contains input from another context", + )); + } + if self + .confinement + .epochs + .iter() + .any(|epoch| epoch != &source_domain.epoch) + { + return Err(RuleDecision::deny( + "process state contains input from another membership epoch", + )); + } + + let grant_use = match grant { + Some(grant) => { + if source_domain.owner() != Some(grant.payload().approver()) { + return Err(RuleDecision::deny( + "declassification grant was not approved by the agent owner", + )); + } + if !grant.matches(operation, &source_domain.id(), destination, content_digest) { + return Err(RuleDecision::deny( + "declassification grant does not match this publication", + )); + } + Some(GrantUse { + digest: grant.payload().signing_digest(), + expires_at: grant.payload().expires_at(), + }) + } + None => None, + }; + + if grant_use.is_none() { + if !source_domain.audience.can_flow_to(destination.audience()) { + return Err(RuleDecision::deny( + "destination is broader than the execution domain", + )); + } + if !self.confinement.flow.has_observed_input() { + return Err(RuleDecision::deny( + "output would widen the accumulated reader set", + )); + } + match self.confinement.flow.check_egress(destination.audience()) { + Ok(()) => {} + Err(EgressError::UnresolvedInput) => { + return Err(RuleDecision::deny( + "process state contains unresolved input provenance", + )); + } + Err(EgressError::DestinationWidensReaders) => { + return Err(RuleDecision::deny( + "output would widen the accumulated reader set", + )); + } + } + } + + Ok(PublicationPermit { + source_domain_id: source_domain.id(), + operation: operation.to_owned(), + destination: destination.clone(), + content_digest: *content_digest, + grant: grant_use, + confinement: self.confinement.snapshot(), + }) + } + + /// Commit a permit against the process's current taint and broker state. + /// + /// Any intervening change to the process's IFC state invalidates the + /// permit. + pub(crate) fn commit_publication<'a, R: GrantReplayStore>( + &'a self, + permit: PublicationPermit, + commit: PublicationCommit<'a>, + replay_store: &mut R, + ) -> Result, PublicationCommitError> { + if self.active_domain(commit.current).is_err() + || permit.source_domain_id != commit.current.domain().id() + { + return Err(PublicationCommitError::SourceChanged); + } + if permit.confinement != self.confinement.snapshot() { + return Err(PublicationCommitError::ProcessStateChanged); + } + permit.commit(commit, replay_store) + } + + fn active_domain(&self, current: CurrentDomain<'_>) -> Result<&ExecutionDomain, RuleDecision> { + let [source_domain] = self.entered_domains.as_slice() else { + return Err(RuleDecision::deny( + "process state is not confined to one execution domain", + )); + }; + if source_domain != current.domain() { + return Err(RuleDecision::deny("execution domain is no longer current")); + } + Ok(source_domain) + } +} + +#[derive(Debug)] +struct GrantUse { + digest: [u8; 32], + expires_at: u64, +} + +/// Broker state and exact output presented for final publication commit. +#[derive(Clone, Copy, Debug)] +pub(crate) struct PublicationCommit<'a> { + current: CurrentDomain<'a>, + operation: &'a str, + destination: &'a PublicationTarget, + content_digest: [u8; 32], + now: u64, +} + +impl<'a> PublicationCommit<'a> { + /// Bundle the values revalidated immediately before publication. + pub(crate) fn new( + current: CurrentDomain<'a>, + operation: &'a str, + destination: &'a PublicationTarget, + content_digest: [u8; 32], + now: u64, + ) -> Self { + Self { + current, + operation, + destination, + content_digest, + now, + } + } +} + +/// One pending publication bound to the exact authorization inputs. +#[derive(Debug)] +#[must_use = "a publication permit must be committed against fresh state"] +pub(crate) struct PublicationPermit { + source_domain_id: String, + operation: String, + destination: PublicationTarget, + content_digest: [u8; 32], + grant: Option, + confinement: ConfinementSnapshot, +} + +impl PublicationPermit { + fn commit<'a, R: GrantReplayStore>( + self, + commit: PublicationCommit<'a>, + replay_store: &mut R, + ) -> Result, PublicationCommitError> { + if self.operation != commit.operation { + return Err(PublicationCommitError::OperationChanged); + } + if &self.destination != commit.destination { + return Err(PublicationCommitError::DestinationChanged); + } + if self.content_digest != commit.content_digest { + return Err(PublicationCommitError::ContentChanged); + } + if let Some(grant) = &self.grant { + if commit.now >= grant.expires_at { + return Err(PublicationCommitError::GrantExpired); + } + if !replay_store.consume_if_unused(&grant.digest, grant.expires_at) { + return Err(PublicationCommitError::GrantReplayed); + } + } + Ok(CommittedPublication { + source_domain_id: self.source_domain_id, + operation: commit.operation, + destination: commit.destination, + content_digest: self.content_digest, + _source_guard: commit.current, + _process_guard: PhantomData, + }) + } +} + +/// Final, non-cloneable authorization passed to the broker's publication sink. +/// +/// The token borrows the process state and current source-domain proof, so +/// neither can change before the sink consumes it. +#[derive(Debug, Eq, PartialEq)] +#[must_use = "the authorization must be consumed by the publication sink"] +pub(crate) struct CommittedPublication<'a> { + source_domain_id: String, + operation: &'a str, + destination: &'a PublicationTarget, + content_digest: [u8; 32], + _source_guard: CurrentDomain<'a>, + _process_guard: PhantomData<&'a ProcessState>, +} + +impl CommittedPublication<'_> { + /// Return the freshly revalidated source domain identifier. + pub(crate) fn source_domain_id(&self) -> &str { + &self.source_domain_id + } + + /// Return the authorized operation. + pub(crate) fn operation(&self) -> &str { + self.operation + } + + /// Return the freshly revalidated destination. + pub(crate) fn destination(&self) -> &PublicationTarget { + self.destination + } + + /// Return the digest the publication sink must match. + pub(crate) fn content_digest(&self) -> &[u8; 32] { + &self.content_digest + } +} + +/// A publication changed after its initial IFC authorization. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub(crate) enum PublicationCommitError { + /// Membership or policy changed for the source worker. + #[error("publication source domain changed before commit")] + SourceChanged, + /// The worker observed additional input after authorization. + #[error("process information-flow state changed before commit")] + ProcessStateChanged, + /// A different operation was presented at commit time. + #[error("publication operation changed before commit")] + OperationChanged, + /// Destination membership, audience, or context changed before commit. + #[error("publication destination changed before commit")] + DestinationChanged, + /// The payload digest changed after authorization. + #[error("publication content changed before commit")] + ContentChanged, + /// The declassification grant expired before commit. + #[error("declassification grant expired before publication commit")] + GrantExpired, + /// Durable replay protection reports that this grant was already consumed. + #[error("declassification grant was already consumed")] + GrantReplayed, +} diff --git a/crates/buzz-ifc/src/security_property_tests.rs b/crates/buzz-ifc/src/security_property_tests.rs new file mode 100644 index 0000000000..a3ddbccc37 --- /dev/null +++ b/crates/buzz-ifc/src/security_property_tests.rs @@ -0,0 +1,654 @@ +//! Generated tests for the IFC invariants that must hold across combinations +//! and operation sequences, rather than only for individual examples. + +use std::collections::BTreeSet; + +use proptest::prelude::*; +use uuid::Uuid; + +use crate::test_support::{principal, MemoryReplayStore, NOW}; +use crate::*; + +const SOURCE_CHANNEL: u128 = 1; +const OTHER_CHANNEL: u128 = 2; +const CURRENT_EPOCH: &str = "v1"; +const SOURCE_READERS: Audience = Audience::Restricted(0b0000_0011); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Audience { + Public, + Restricted(u8), +} + +impl Audience { + fn label(self, realm: RealmId) -> ConfidentialityLabel { + match self { + Self::Public => ConfidentialityLabel::public(realm), + Self::Restricted(mask) => ConfidentialityLabel::restricted(realm, principals(mask)) + .expect("generated restricted audiences are non-empty"), + } + } + + 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 join(self, other: Self) -> Self { + match (self, other) { + (Self::Public, value) | (value, Self::Public) => value, + (Self::Restricted(left), Self::Restricted(right)) => Self::Restricted(left & right), + } + } +} + +fn realm() -> RealmId { + RealmId::from_relay_url("wss://buzz.example").expect("canonical test realm") +} + +fn other_realm() -> RealmId { + RealmId::from_relay_url("wss://other.example").expect("canonical test realm") +} + +fn principals(mask: u8) -> BTreeSet { + (0..6) + .filter(|bit| mask & (1 << bit) != 0) + .map(|bit| principal(bit + 1)) + .collect() +} + +fn capabilities() -> CapabilitySet { + CapabilitySet::from_operations([ + ("buzz.read.current", OperationEffect::NonEgressing), + ( + "buzz.post", + OperationEffect::Publication(PublicationScope::WithinRealm), + ), + ( + "buzz.reply", + OperationEffect::Publication(PublicationScope::SameContext), + ), + ]) +} + +fn domain_with( + agent: u8, + owner: u8, + audience: Audience, + channel: u128, + epoch: &str, + capabilities: CapabilitySet, +) -> ExecutionDomain { + ExecutionDomain::new( + principal(agent), + Some(principal(owner)), + audience.label(realm()), + DomainContext::Conversation { + realm: realm(), + channel_id: Uuid::from_u128(channel), + }, + MembershipEpoch::new(epoch), + capabilities, + ) + .expect("coherent generated domain") +} + +fn base_domain() -> ExecutionDomain { + domain_with( + 9, + 1, + SOURCE_READERS, + SOURCE_CHANNEL, + CURRENT_EPOCH, + capabilities(), + ) +} + +#[derive(Clone, Copy, Debug)] +enum ReadCase { + TrustedConfiguration, + Public, + ExactAudience, + WiderSourceAudience, + TooNarrow, + WrongContext, + StaleEpoch, + CrossRealm, + OwnerPrivate, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ResourceContextModel { + TrustedConfiguration, + RealmPublic, + Conversation(u128), + OwnerPrivate, +} + +#[derive(Clone, Copy, Debug)] +struct ResourceModel { + same_realm: bool, + audience: Audience, + context: ResourceContextModel, + current_epoch: bool, +} + +impl ReadCase { + fn model(self) -> ResourceModel { + match self { + Self::TrustedConfiguration => ResourceModel { + same_realm: true, + audience: Audience::Public, + context: ResourceContextModel::TrustedConfiguration, + current_epoch: true, + }, + Self::Public => ResourceModel { + same_realm: true, + audience: Audience::Public, + context: ResourceContextModel::RealmPublic, + current_epoch: true, + }, + Self::ExactAudience => ResourceModel { + same_realm: true, + audience: SOURCE_READERS, + context: ResourceContextModel::Conversation(SOURCE_CHANNEL), + current_epoch: true, + }, + Self::WiderSourceAudience => ResourceModel { + same_realm: true, + audience: Audience::Restricted(0b0000_0111), + context: ResourceContextModel::Conversation(SOURCE_CHANNEL), + current_epoch: true, + }, + Self::TooNarrow => ResourceModel { + same_realm: true, + audience: Audience::Restricted(0b0000_0001), + context: ResourceContextModel::Conversation(SOURCE_CHANNEL), + current_epoch: true, + }, + Self::WrongContext => ResourceModel { + same_realm: true, + audience: SOURCE_READERS, + context: ResourceContextModel::Conversation(OTHER_CHANNEL), + current_epoch: true, + }, + Self::StaleEpoch => ResourceModel { + same_realm: true, + audience: SOURCE_READERS, + context: ResourceContextModel::Conversation(SOURCE_CHANNEL), + current_epoch: false, + }, + Self::CrossRealm => ResourceModel { + same_realm: false, + audience: Audience::Public, + context: ResourceContextModel::RealmPublic, + current_epoch: true, + }, + Self::OwnerPrivate => ResourceModel { + same_realm: true, + audience: Audience::Restricted(0b0000_0001), + context: ResourceContextModel::OwnerPrivate, + current_epoch: true, + }, + } + } + + fn resource(self) -> ResourceLabel { + let model = self.model(); + let resource_realm = if model.same_realm { + realm() + } else { + other_realm() + }; + let epoch = MembershipEpoch::new(if model.current_epoch { + CURRENT_EPOCH + } else { + "v2" + }); + match model.context { + ResourceContextModel::TrustedConfiguration => { + ResourceLabel::trusted_configuration(resource_realm) + } + ResourceContextModel::RealmPublic => ResourceLabel::realm_public(resource_realm, epoch), + ResourceContextModel::Conversation(channel) => ResourceLabel::conversation( + resource_realm, + Uuid::from_u128(channel), + match model.audience { + Audience::Restricted(mask) => principals(mask), + Audience::Public => unreachable!("conversation resources are restricted"), + }, + epoch, + ) + .expect("generated conversation audience is non-empty"), + ResourceContextModel::OwnerPrivate => { + ResourceLabel::owner_private(resource_realm, principal(1), epoch) + } + } + } +} + +fn read_case_strategy() -> impl Strategy { + prop_oneof![ + Just(ReadCase::TrustedConfiguration), + Just(ReadCase::Public), + Just(ReadCase::ExactAudience), + Just(ReadCase::WiderSourceAudience), + Just(ReadCase::TooNarrow), + Just(ReadCase::WrongContext), + Just(ReadCase::StaleEpoch), + Just(ReadCase::CrossRealm), + Just(ReadCase::OwnerPrivate), + ] +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DomainVariant { + Same, + Agent, + Owner, + Audience, + Context, + Epoch, + Capabilities, +} + +fn domain_variant_strategy() -> impl Strategy { + prop_oneof![ + 3 => Just(DomainVariant::Same), + 1 => Just(DomainVariant::Agent), + 1 => Just(DomainVariant::Owner), + 1 => Just(DomainVariant::Audience), + 1 => Just(DomainVariant::Context), + 1 => Just(DomainVariant::Epoch), + 1 => Just(DomainVariant::Capabilities), + ] +} + +fn domain_variant(variant: DomainVariant) -> ExecutionDomain { + match variant { + DomainVariant::Same => base_domain(), + DomainVariant::Agent => domain_with( + 8, + 1, + SOURCE_READERS, + SOURCE_CHANNEL, + CURRENT_EPOCH, + capabilities(), + ), + DomainVariant::Owner => domain_with( + 9, + 2, + SOURCE_READERS, + SOURCE_CHANNEL, + CURRENT_EPOCH, + capabilities(), + ), + DomainVariant::Audience => domain_with( + 9, + 1, + Audience::Restricted(0b0000_0001), + SOURCE_CHANNEL, + CURRENT_EPOCH, + capabilities(), + ), + DomainVariant::Context => domain_with( + 9, + 1, + SOURCE_READERS, + OTHER_CHANNEL, + CURRENT_EPOCH, + capabilities(), + ), + DomainVariant::Epoch => { + domain_with(9, 1, SOURCE_READERS, SOURCE_CHANNEL, "v2", capabilities()) + } + DomainVariant::Capabilities => domain_with( + 9, + 1, + SOURCE_READERS, + SOURCE_CHANNEL, + CURRENT_EPOCH, + CapabilitySet::from_operations([("buzz.read.current", OperationEffect::NonEgressing)]), + ), + } +} + +#[derive(Clone, Copy, Debug)] +enum PublishCase { + SameContext, + NarrowerAudience, + WiderAudience, + OtherContextPost, + OtherContextReply, + PublicDestination, + CrossRealm, + NonEgressingOperation, + UnknownOperation, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TargetContextModel { + SourceConversation, + OtherConversation, + RealmPublic, +} + +#[derive(Clone, Copy, Debug)] +struct PublicationModel { + operation: &'static str, + same_realm: bool, + audience: Audience, + context: TargetContextModel, +} + +impl PublishCase { + fn model(self) -> PublicationModel { + match self { + Self::SameContext => PublicationModel { + operation: "buzz.reply", + same_realm: true, + audience: SOURCE_READERS, + context: TargetContextModel::SourceConversation, + }, + Self::NarrowerAudience => PublicationModel { + operation: "buzz.reply", + same_realm: true, + audience: Audience::Restricted(0b0000_0001), + context: TargetContextModel::SourceConversation, + }, + Self::WiderAudience => PublicationModel { + operation: "buzz.reply", + same_realm: true, + audience: Audience::Restricted(0b0000_0111), + context: TargetContextModel::SourceConversation, + }, + Self::OtherContextPost => PublicationModel { + operation: "buzz.post", + same_realm: true, + audience: SOURCE_READERS, + context: TargetContextModel::OtherConversation, + }, + Self::OtherContextReply => PublicationModel { + operation: "buzz.reply", + same_realm: true, + audience: SOURCE_READERS, + context: TargetContextModel::OtherConversation, + }, + Self::PublicDestination => PublicationModel { + operation: "buzz.post", + same_realm: true, + audience: Audience::Public, + context: TargetContextModel::RealmPublic, + }, + Self::CrossRealm => PublicationModel { + operation: "buzz.post", + same_realm: false, + audience: Audience::Public, + context: TargetContextModel::RealmPublic, + }, + Self::NonEgressingOperation => PublicationModel { + operation: "buzz.read.current", + same_realm: true, + audience: SOURCE_READERS, + context: TargetContextModel::SourceConversation, + }, + Self::UnknownOperation => PublicationModel { + operation: "buzz.unknown", + same_realm: true, + audience: SOURCE_READERS, + context: TargetContextModel::SourceConversation, + }, + } + } + + fn target(self) -> PublicationTarget { + let model = self.model(); + let destination_realm = if model.same_realm { + realm() + } else { + other_realm() + }; + let context = match model.context { + TargetContextModel::SourceConversation => DomainContext::Conversation { + realm: destination_realm.clone(), + channel_id: Uuid::from_u128(SOURCE_CHANNEL), + }, + TargetContextModel::OtherConversation => DomainContext::Conversation { + realm: destination_realm.clone(), + channel_id: Uuid::from_u128(OTHER_CHANNEL), + }, + TargetContextModel::RealmPublic => { + DomainContext::RealmPublic(destination_realm.clone()) + } + }; + PublicationTarget::new( + model.audience.label(destination_realm), + context, + MembershipEpoch::new(CURRENT_EPOCH), + ) + .expect("coherent generated publication target") + } +} + +fn publish_case_strategy() -> impl Strategy { + prop_oneof![ + Just(PublishCase::SameContext), + Just(PublishCase::NarrowerAudience), + Just(PublishCase::WiderAudience), + Just(PublishCase::OtherContextPost), + Just(PublishCase::OtherContextReply), + Just(PublishCase::PublicDestination), + Just(PublishCase::CrossRealm), + Just(PublishCase::NonEgressingOperation), + Just(PublishCase::UnknownOperation), + ] +} + +#[derive(Clone, Debug)] +enum TraceAction { + Read(ReadCase), + Refresh(DomainVariant), + MarkUnknown, + Publish(PublishCase), +} + +fn trace_action_strategy() -> impl Strategy { + prop_oneof![ + 4 => read_case_strategy().prop_map(TraceAction::Read), + 2 => domain_variant_strategy().prop_map(TraceAction::Refresh), + 1 => Just(TraceAction::MarkUnknown), + 4 => publish_case_strategy().prop_map(TraceAction::Publish), + ] +} + +#[derive(Debug)] +struct SessionModel { + valid_domain: bool, + observed: Option, + unknown_input: bool, +} + +impl Default for SessionModel { + fn default() -> Self { + Self { + valid_domain: true, + observed: None, + unknown_input: false, + } + } +} + +impl SessionModel { + fn read(&mut self, resource: ResourceModel) -> bool { + let context_allowed = matches!( + resource.context, + ResourceContextModel::TrustedConfiguration + | ResourceContextModel::RealmPublic + | ResourceContextModel::Conversation(SOURCE_CHANNEL) + ); + let allowed = self.valid_domain + && resource.same_realm + && resource.audience.can_flow_to(SOURCE_READERS) + && context_allowed + && resource.current_epoch; + if allowed { + self.observed = Some(match self.observed { + Some(existing) => existing.join(resource.audience), + None => resource.audience, + }); + } + allowed + } + + fn refresh(&mut self, variant: DomainVariant) -> bool { + let allowed = self.valid_domain && variant == DomainVariant::Same; + if variant != DomainVariant::Same { + self.valid_domain = false; + } + allowed + } + + fn publish(&self, publication: PublicationModel) -> bool { + let Some(observed) = self.observed else { + return false; + }; + let operation_allowed = match publication.operation { + "buzz.post" => true, + "buzz.reply" => publication.context == TargetContextModel::SourceConversation, + _ => false, + }; + self.valid_domain + && !self.unknown_input + && operation_allowed + && publication.same_realm + && SOURCE_READERS.can_flow_to(publication.audience) + && observed.can_flow_to(publication.audience) + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Compares arbitrary session histories with the small reference model + /// above. The generated histories mix reads, domain refreshes, unknown + /// input, and publication attempts instead of checking each operation only + /// from a freshly created session. + /// + /// The invariant is that every operation accepted by the implementation is + /// also accepted by a model containing only reader-set inclusion, context, + /// realm, membership epoch, capability scope, monotonic taint, and + /// permanent domain invalidation. Rejected reads must not change taint, + /// unknown input can never be forgotten, and a session that has seen a + /// different domain can never regain authority by returning to its first + /// domain. + /// + /// This catches order-dependent state bugs: a failed refresh becoming + /// recoverable, rejected input changing later decisions, same-context + /// operations crossing contexts, or publication succeeding without any + /// admitted input. In particular, this test found the regression where a + /// restored domain remained blocked for calls and publications but could + /// read again. + /// The modeled operations correspond to the enforcement rules in Appendix + /// D of `docs/practical-information-flow-for-buzz-agents.md`. + #[test] + fn generated_session_traces_match_the_reference_model( + actions in prop::collection::vec(trace_action_strategy(), 1..80), + ) { + let mut session = IfcSession::enter(base_domain()); + let mut model = SessionModel::default(); + let mut replay_store = MemoryReplayStore::default(); + + for (index, action) in actions.iter().enumerate() { + match *action { + TraceAction::Read(case) => { + let expected = model.read(case.model()); + let actual = session.read(&case.resource()).is_ok(); + prop_assert_eq!(actual, expected, "step {}: {:?}", index, action); + } + TraceAction::Refresh(variant) => { + let expected = model.refresh(variant); + let actual = session.refresh(domain_variant(variant)).is_ok(); + prop_assert_eq!(actual, expected, "step {}: {:?}", index, action); + } + TraceAction::MarkUnknown => { + model.unknown_input = true; + session.mark_unknown_input(); + } + TraceAction::Publish(case) => { + let publication = case.model(); + let target = case.target(); + let content = (index as u64).to_be_bytes(); + let expected = model.publish(publication); + let actual = session + .publish( + PublicationRequest::new( + publication.operation, + &target, + &content, + NOW, + ), + None, + &mut replay_store, + ) + .is_ok(); + prop_assert_eq!(actual, expected, "step {}: {:?}", index, action); + } + } + } + } +} + +/// Changes each security-relevant domain component independently: agent, +/// owner, audience, context, membership epoch, and capabilities. +/// +/// The invariant is that every component contributes to the domain identifier, +/// changing any one component prevents worker reuse, and restoring the old +/// value cannot make that session valid again. Once a process may have observed +/// information under two domains, the broker must discard it rather than infer +/// that its current domain describes everything it has seen. +/// +/// This catches an omitted identity component and any lifecycle path that lets +/// a process regain authority after crossing a domain boundary. +/// The component list comes from the execution-domain model in Appendix B of +/// `docs/practical-information-flow-for-buzz-agents.md`. +#[test] +fn every_domain_component_changes_identity_and_invalidates_reuse() { + let original = base_domain(); + let variants = [ + (DomainVariant::Agent, domain_variant(DomainVariant::Agent)), + (DomainVariant::Owner, domain_variant(DomainVariant::Owner)), + ( + DomainVariant::Audience, + domain_variant(DomainVariant::Audience), + ), + ( + DomainVariant::Context, + domain_variant(DomainVariant::Context), + ), + (DomainVariant::Epoch, domain_variant(DomainVariant::Epoch)), + ( + DomainVariant::Capabilities, + domain_variant(DomainVariant::Capabilities), + ), + ]; + + for (component, changed) in variants { + assert_ne!( + original.id(), + changed.id(), + "{component:?} must affect the domain identifier" + ); + let mut session = IfcSession::enter(original.clone()); + assert!( + session.refresh(changed).is_err(), + "{component:?} must prevent worker reuse" + ); + assert!( + session.refresh(original.clone()).is_err(), + "{component:?} change must invalidate the session permanently" + ); + } +} diff --git a/crates/buzz-ifc/src/session.rs b/crates/buzz-ifc/src/session.rs new file mode 100644 index 0000000000..4d1d757f2f --- /dev/null +++ b/crates/buzz-ifc/src/session.rs @@ -0,0 +1,289 @@ +use crate::declassification::{GrantReplayStore, VerifiedDeclassificationGrant}; +use crate::domain::{DomainAuthority, ExecutionDomain, PublicationTarget, ResourceLabel}; +use crate::hash::hash_field; +use crate::policy::{ + CommittedPublication, ProcessState, PublicationCommit, PublicationCommitError, RuleDecision, +}; +use sha2::{Digest, Sha256}; + +/// The broker-facing IFC state for one actual worker process. +/// +/// A session owns the worker's execution domain and monotonically accumulates +/// every admitted input label. The broker should create one session per worker, +/// call [`IfcSession::read`] before delivering an input, and require +/// [`IfcSession::publish`] to succeed before invoking an egressing sink. +/// These are the broker enforcement points described in [Appendix D of the +/// design paper](../../../docs/practical-information-flow-for-buzz-agents.md#appendix-d-broker-interface-and-enforcement). +/// +/// ``` +/// # use buzz_ifc::{AuthorizedPublication, ExecutionDomain, +/// # GrantReplayStore, IfcError, IfcSession, PublicationRequest, +/// # PublicationTarget, ResourceLabel, VerifiedDeclassificationGrant}; +/// # fn consume(_: AuthorizedPublication<'_>) {} +/// # fn broker_flow( +/// # domain: ExecutionDomain, +/// # resource: &ResourceLabel, +/// # destination: &PublicationTarget, +/// # content: &[u8], +/// # grant: Option<&VerifiedDeclassificationGrant>, +/// # replay_store: &mut R, +/// # ) -> Result<(), IfcError> { +/// let mut session = IfcSession::enter(domain); +/// session.read(resource)?; +/// session.call("buzz.read.current")?; +/// let authorization = session.publish( +/// PublicationRequest::new("buzz.post", destination, content, 1_000), +/// grant, +/// replay_store, +/// )?; +/// consume(authorization); +/// # Ok(()) +/// # } +/// ``` +/// +/// Low-level policy machinery is deliberately not part of the crate API: +/// +/// ```compile_fail +/// use buzz_ifc::{ProcessState, RuleEvaluator}; +/// ``` +#[derive(Debug)] +pub struct IfcSession { + worker_domain: ExecutionDomain, + authority: DomainAuthority, + process: ProcessState, +} + +impl IfcSession { + /// Bind a fresh worker process to one verified execution domain. + pub fn enter(domain: ExecutionDomain) -> Self { + let worker_domain = domain.clone(); + let authority = DomainAuthority::new(domain); + let process = ProcessState::for_domain(authority.current()); + Self { + worker_domain, + authority, + process, + } + } + + /// Apply the latest verified domain for this worker. + /// + /// A membership, audience, context, epoch, or capability change + /// permanently makes this session unusable. The broker must discard the + /// worker rather than refreshing it back to an older domain. + pub fn refresh(&mut self, current: ExecutionDomain) -> Result<(), IfcError> { + self.authority.replace(current); + enforce(self.process.enter(self.authority.current())) + } + + /// Return the immutable domain to which this worker was initially bound. + pub fn domain(&self) -> &ExecutionDomain { + &self.worker_domain + } + + /// Return a current publication target for this worker's own context. + pub fn current_target(&self) -> PublicationTarget { + PublicationTarget::from_current(self.authority.current()) + } + + /// Admit one labeled input and add its restrictions to this process. + /// + /// On success the taint is recorded before control returns, so the broker + /// cannot authorize a read and forget the corresponding observation. On + /// failure the broker must not deliver the input to the worker. + pub fn read(&mut self, resource: &ResourceLabel) -> Result<(), IfcError> { + enforce(self.process.authorize_read( + &self.worker_domain, + self.authority.current(), + resource, + ))?; + self.process.observe(resource); + Ok(()) + } + + /// Conservatively record input whose provenance could not be established. + /// + /// This permanently prevents the session from publishing. + pub fn mark_unknown_input(&mut self) { + self.process.mark_unknown(); + } + + #[cfg(test)] + pub(crate) fn has_observed_input(&self) -> bool { + self.process.has_observed_input() + } + + /// Authorize an operation classified by trusted policy as non-egressing. + pub fn call(&self, operation: &str) -> Result<(), IfcError> { + enforce( + self.process + .authorize_non_egressing_call(self.authority.current(), operation), + ) + } + + /// Authorize one exact publication against current domain and process state. + /// + /// Authorization and commit happen in this call. A matching + /// declassification grant is consumed atomically before the returned token + /// can be passed to the broker's publication sink. + pub fn publish<'a, R: GrantReplayStore>( + &'a self, + request: PublicationRequest<'a>, + grant: Option<&VerifiedDeclassificationGrant>, + replay_store: &mut R, + ) -> Result, IfcError> { + let current = self.authority.current(); + let content_digest = publication_digest(request.content); + let permit = self + .process + .authorize_publication( + current, + request.operation, + request.destination, + &content_digest, + grant, + ) + .map_err(IfcError::from_decision)?; + let commit = PublicationCommit::new( + current, + request.operation, + request.destination, + content_digest, + request.now, + ); + let committed = self + .process + .commit_publication(permit, commit, replay_store) + .map_err(IfcError::from_commit)?; + Ok(AuthorizedPublication { + committed, + content: request.content, + }) + } +} + +/// The exact output presented for publication. +#[derive(Clone, Copy, Debug)] +pub struct PublicationRequest<'a> { + operation: &'a str, + destination: &'a PublicationTarget, + content: &'a [u8], + now: u64, +} + +impl<'a> PublicationRequest<'a> { + /// Bind the operation, destination, content, and current time checked by + /// [`IfcSession::publish`]. + pub fn new( + operation: &'a str, + destination: &'a PublicationTarget, + content: &'a [u8], + now: u64, + ) -> Self { + Self { + operation, + destination, + content, + now, + } + } +} + +/// A final publication token passed to the broker's egress sink. +/// +/// The token exposes the same content bytes whose digest was authorized, so +/// the sink does not need to reconnect a detached digest to a payload. +#[derive(Debug, Eq, PartialEq)] +#[must_use = "the authorization must be consumed by the publication sink"] +pub struct AuthorizedPublication<'a> { + committed: CommittedPublication<'a>, + content: &'a [u8], +} + +impl AuthorizedPublication<'_> { + /// Return the freshly revalidated source domain identifier. + pub fn source_domain_id(&self) -> &str { + self.committed.source_domain_id() + } + + /// Return the authorized operation. + pub fn operation(&self) -> &str { + self.committed.operation() + } + + /// Return the freshly revalidated destination. + pub fn destination(&self) -> &PublicationTarget { + self.committed.destination() + } + + /// Return the exact content bytes authorized for publication. + pub fn content(&self) -> &[u8] { + self.content + } + + /// Return the canonical digest covered by any declassification grant. + pub fn content_digest(&self) -> &[u8; 32] { + self.committed.content_digest() + } +} + +/// Compute the canonical digest used to bind publication content to a grant. +pub fn publication_digest(content: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"buzz-ifc-publication-content-v1"); + hash_field(&mut hasher, content); + hasher.finalize().into() +} + +/// A broker-facing IFC operation was denied. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum IfcError { + /// A policy check rejected the operation. + #[error("IFC policy denied the operation: {0}")] + Denied(&'static str), + /// State changed between authorization and commit. + #[error("IFC publication authorization was invalidated: {0}")] + PublicationInvalidated(&'static str), + /// A signed declassification grant expired before commit. + #[error("declassification grant expired before publication")] + GrantExpired, + /// Durable replay protection reports that the grant was already consumed. + #[error("declassification grant was already consumed")] + GrantReplayed, +} + +impl IfcError { + fn from_decision(decision: RuleDecision) -> Self { + Self::Denied(decision.reason()) + } + + fn from_commit(error: PublicationCommitError) -> Self { + match error { + PublicationCommitError::SourceChanged => { + Self::PublicationInvalidated("source domain changed") + } + PublicationCommitError::ProcessStateChanged => { + Self::PublicationInvalidated("process state changed") + } + PublicationCommitError::OperationChanged => { + Self::PublicationInvalidated("operation changed") + } + PublicationCommitError::DestinationChanged => { + Self::PublicationInvalidated("destination changed") + } + PublicationCommitError::ContentChanged => { + Self::PublicationInvalidated("content changed") + } + PublicationCommitError::GrantExpired => Self::GrantExpired, + PublicationCommitError::GrantReplayed => Self::GrantReplayed, + } + } +} + +fn enforce(decision: RuleDecision) -> Result<(), IfcError> { + if decision.allowed() { + Ok(()) + } else { + Err(IfcError::from_decision(decision)) + } +} diff --git a/crates/buzz-ifc/src/session_tests.rs b/crates/buzz-ifc/src/session_tests.rs new file mode 100644 index 0000000000..40f4cea6c6 --- /dev/null +++ b/crates/buzz-ifc/src/session_tests.rs @@ -0,0 +1,241 @@ +use std::collections::BTreeSet; + +use uuid::Uuid; + +use crate::test_support::{principal, verified_grant, MemoryReplayStore, NOW}; +use crate::*; + +fn realm() -> RealmId { + RealmId::from_relay_url("wss://buzz.example").expect("canonical test realm") +} + +fn readers(values: &[u8]) -> BTreeSet { + values.iter().copied().map(principal).collect() +} + +fn capabilities() -> CapabilitySet { + CapabilitySet::from_operations([ + ("buzz.read.current", OperationEffect::NonEgressing), + ( + "buzz.post", + OperationEffect::Publication(PublicationScope::WithinRealm), + ), + ( + "buzz.reply", + OperationEffect::Publication(PublicationScope::SameContext), + ), + ]) +} + +fn domain(owner: u8, audience: &[u8], channel: u128, epoch: &str) -> ExecutionDomain { + ExecutionDomain::new( + principal(9), + Some(principal(owner)), + ConfidentialityLabel::restricted(realm(), readers(audience)).expect("non-empty audience"), + DomainContext::Conversation { + realm: realm(), + channel_id: Uuid::from_u128(channel), + }, + MembershipEpoch::new(epoch), + capabilities(), + ) + .expect("coherent domain") +} + +fn public_target(epoch: &str) -> PublicationTarget { + PublicationTarget::new( + ConfidentialityLabel::public(realm()), + DomainContext::RealmPublic(realm()), + MembershipEpoch::new(epoch), + ) + .expect("coherent public target") +} + +#[test] +fn broker_workflow_is_session_read_call_publish() { + let source = domain(1, &[1, 2], 1, "v1"); + let input = source.resource_label(); + let mut session = IfcSession::enter(source); + + session.read(&input).expect("input is readable"); + assert!(session.has_observed_input()); + session + .call("buzz.read.current") + .expect("non-egressing capability is allowed"); + assert!(session.call("buzz.post").is_err()); + + let destination = session.current_target(); + let content = b"same-context reply"; + let authorization = session + .publish( + PublicationRequest::new("buzz.reply", &destination, content, NOW), + None, + &mut MemoryReplayStore::default(), + ) + .expect("same-context publication is allowed"); + + assert_eq!(authorization.operation(), "buzz.reply"); + assert_eq!(authorization.destination(), &destination); + assert_eq!(authorization.content(), content); + assert_eq!(authorization.content_digest(), &publication_digest(content)); +} + +#[test] +fn domain_refresh_permanently_invalidates_a_changed_session() { + let original = domain(1, &[1, 2], 1, "v1"); + let original_input = original.resource_label(); + let changed = domain(1, &[1], 1, "v2"); + let mut session = IfcSession::enter(original.clone()); + + assert_eq!( + session.refresh(changed), + Err(IfcError::Denied( + "agent process has already entered a different domain" + )) + ); + assert!(session.refresh(original).is_err()); + assert!(session.read(&original_input).is_err()); + assert!(session.call("buzz.read.current").is_err()); +} + +#[test] +fn supplied_grant_must_match_even_when_ordinary_flow_would_allow() { + let source = domain(1, &[1, 2], 1, "v1"); + let source_id = source.id(); + let input = source.resource_label(); + let mut session = IfcSession::enter(source); + session.read(&input).expect("input is readable"); + let destination = session.current_target(); + let signed_content = b"approved output"; + let presented_content = b"different output"; + let grant = verified_grant( + 1, + 1, + "buzz.reply", + &source_id, + destination.clone(), + publication_digest(signed_content), + ); + + assert_eq!( + session.publish( + PublicationRequest::new("buzz.reply", &destination, presented_content, NOW), + Some(&grant), + &mut MemoryReplayStore::default(), + ), + Err(IfcError::Denied( + "declassification grant does not match this publication" + )) + ); +} + +#[test] +fn only_the_domain_owner_can_declassify() { + let source = domain(1, &[1], 1, "v1"); + let source_id = source.id(); + let input = source.resource_label(); + let mut session = IfcSession::enter(source); + session.read(&input).expect("input is readable"); + let destination = public_target("v1"); + let content = b"owner-approved output"; + let requester_grant = verified_grant( + 2, + 1, + "buzz.post", + &source_id, + destination.clone(), + publication_digest(content), + ); + + assert_eq!( + session.publish( + PublicationRequest::new("buzz.post", &destination, content, NOW), + Some(&requester_grant), + &mut MemoryReplayStore::default(), + ), + Err(IfcError::Denied( + "declassification grant was not approved by the agent owner" + )) + ); +} + +#[test] +fn replay_consumption_is_scoped_to_the_complete_signed_grant() { + let first_source = domain(1, &[1], 1, "v1"); + let second_source = domain(2, &[2], 2, "v1"); + let first_input = first_source.resource_label(); + let second_input = second_source.resource_label(); + let first_source_id = first_source.id(); + let second_source_id = second_source.id(); + let mut first_session = IfcSession::enter(first_source); + let mut second_session = IfcSession::enter(second_source); + first_session + .read(&first_input) + .expect("first input is readable"); + second_session + .read(&second_input) + .expect("second input is readable"); + let destination = public_target("v1"); + let content = b"independently approved output"; + let first_grant = verified_grant( + 1, + 7, + "buzz.post", + &first_source_id, + destination.clone(), + publication_digest(content), + ); + let second_grant = verified_grant( + 2, + 7, + "buzz.post", + &second_source_id, + destination.clone(), + publication_digest(content), + ); + let mut replay = MemoryReplayStore::default(); + + let first_authorization = first_session + .publish( + PublicationRequest::new("buzz.post", &destination, content, NOW), + Some(&first_grant), + &mut replay, + ) + .expect("first signed grant is unused"); + assert_eq!(first_authorization.source_domain_id(), first_source_id); + let second_authorization = second_session + .publish( + PublicationRequest::new("buzz.post", &destination, content, NOW), + Some(&second_grant), + &mut replay, + ) + .expect("a different signed grant may reuse the nonce"); + assert_eq!(second_authorization.source_domain_id(), second_source_id); + assert_eq!( + first_session.publish( + PublicationRequest::new("buzz.post", &destination, content, NOW), + Some(&first_grant), + &mut replay, + ), + Err(IfcError::GrantReplayed) + ); +} + +#[test] +fn unknown_input_permanently_blocks_publication() { + let source = domain(1, &[1], 1, "v1"); + let input = source.resource_label(); + let mut session = IfcSession::enter(source); + session.read(&input).expect("input is readable"); + session.mark_unknown_input(); + let destination = session.current_target(); + let content = b"blocked output"; + + assert!(session + .publish( + PublicationRequest::new("buzz.reply", &destination, content, NOW), + None, + &mut MemoryReplayStore::default(), + ) + .is_err()); +} diff --git a/crates/buzz-ifc/src/test_support.rs b/crates/buzz-ifc/src/test_support.rs new file mode 100644 index 0000000000..151a60420d --- /dev/null +++ b/crates/buzz-ifc/src/test_support.rs @@ -0,0 +1,80 @@ +use std::collections::BTreeSet; + +use nostr::secp256k1::Message; +use nostr::Keys; + +use crate::{ + DeclassificationGrant, DeclassificationGrantPayload, GrantId, GrantReplayStore, Principal, + PublicationTarget, VerifiedDeclassificationGrant, +}; + +pub(crate) const NOW: u64 = 1_000; +pub(crate) const EXPIRES_AT: u64 = 2_000; + +pub(crate) fn keys(value: u8) -> Keys { + Keys::parse(&format!("{value:064x}")).expect("test secret key") +} + +pub(crate) fn principal(value: u8) -> Principal { + Principal::from_public_key(&keys(value).public_key()).expect("test principal") +} + +pub(crate) fn grant_payload( + approver: Principal, + nonce: u8, + operation: &str, + source_domain_id: &str, + destination: PublicationTarget, + content_digest: [u8; 32], + expires_at: u64, +) -> DeclassificationGrantPayload { + DeclassificationGrantPayload::new( + approver, + GrantId::from_bytes([nonce; 32]), + operation, + source_domain_id, + destination, + content_digest, + expires_at, + ) +} + +pub(crate) fn sign_payload(payload: &DeclassificationGrantPayload, signer: &Keys) -> [u8; 64] { + signer + .sign_schnorr(&Message::from_digest(payload.signing_digest())) + .serialize() +} + +pub(crate) fn verified_grant( + owner_value: u8, + nonce: u8, + operation: &str, + source_domain_id: &str, + destination: PublicationTarget, + content_digest: [u8; 32], +) -> VerifiedDeclassificationGrant { + let owner_keys = keys(owner_value); + let owner = principal(owner_value); + let payload = grant_payload( + owner.clone(), + nonce, + operation, + source_domain_id, + destination, + content_digest, + EXPIRES_AT, + ); + let signature = sign_payload(&payload, &owner_keys); + DeclassificationGrant::new(payload, signature) + .verify(NOW) + .expect("valid owner grant") +} + +#[derive(Default)] +pub(crate) struct MemoryReplayStore(BTreeSet<[u8; 32]>); + +impl GrantReplayStore for MemoryReplayStore { + fn consume_if_unused(&mut self, grant_digest: &[u8; 32], _expires_at: u64) -> bool { + self.0.insert(*grant_digest) + } +} diff --git a/crates/buzz-ifc/src/tests.rs b/crates/buzz-ifc/src/tests.rs new file mode 100644 index 0000000000..fd6dda8f8f --- /dev/null +++ b/crates/buzz-ifc/src/tests.rs @@ -0,0 +1,939 @@ +use std::collections::BTreeSet; + +use uuid::Uuid; + +use super::*; +use crate::domain::{DomainAuthority, DomainError}; +use crate::policy::{ProcessState, PublicationCommit, PublicationCommitError, RuleEvaluator}; +use crate::test_support::*; + +fn readers(values: &[u8]) -> BTreeSet { + values.iter().copied().map(principal).collect() +} + +fn realm() -> RealmId { + RealmId::from_relay_url("wss://buzz.example").expect("canonical test realm") +} + +fn label(values: &[u8]) -> ConfidentialityLabel { + ConfidentialityLabel::restricted(realm(), readers(values)).expect("non-empty readers") +} + +fn non_egressing(names: [&str; N]) -> CapabilitySet { + CapabilitySet::from_operations(names.map(|name| (name, OperationEffect::NonEgressing))) +} + +fn conversation_capabilities() -> CapabilitySet { + CapabilitySet::from_operations([ + ("buzz.read.current", OperationEffect::NonEgressing), + ( + "buzz.post", + OperationEffect::Publication(PublicationScope::WithinRealm), + ), + ( + "buzz.reply", + OperationEffect::Publication(PublicationScope::SameContext), + ), + ]) +} + +fn conversation(values: &[u8], channel_id: Uuid, epoch: &str) -> ExecutionDomain { + ExecutionDomain::new( + principal(9), + Some(principal(1)), + label(values), + DomainContext::Conversation { + realm: realm(), + channel_id, + }, + MembershipEpoch::new(epoch), + conversation_capabilities(), + ) + .expect("coherent conversation domain") +} + +fn conversation_target(values: &[u8], channel_id: Uuid, epoch: &str) -> PublicationTarget { + PublicationTarget::new( + label(values), + DomainContext::Conversation { + realm: realm(), + channel_id, + }, + MembershipEpoch::new(epoch), + ) + .expect("coherent conversation target") +} + +fn public_target(realm: RealmId, epoch: &str) -> PublicationTarget { + PublicationTarget::new( + ConfidentialityLabel::public(realm.clone()), + DomainContext::RealmPublic(realm), + MembershipEpoch::new(epoch), + ) + .expect("coherent public target") +} + +fn process_for(domain: ExecutionDomain) -> (DomainAuthority, ProcessState) { + let authority = DomainAuthority::new(domain); + let mut state = ProcessState::default(); + assert!(state.enter(authority.current()).allowed()); + (authority, state) +} + +#[test] +fn principals_must_be_valid_x_only_secp256k1_points() { + for invalid in ["00".repeat(32), "ff".repeat(32), format!("{:064x}", 5)] { + assert_eq!( + Principal::from_hex(&invalid), + Err(PrincipalError::InvalidPublicKey) + ); + } +} + +#[test] +fn realm_ids_canonicalize_equivalent_relay_urls() { + let canonical = RealmId::from_relay_url("wss://buzz.example").expect("valid URL"); + let equivalent = RealmId::from_relay_url("WSS://BUZZ.EXAMPLE:443/").expect("valid URL"); + let path = RealmId::from_relay_url("wss://buzz.example/relay/").expect("valid URL"); + let path_without_slash = + RealmId::from_relay_url("wss://buzz.example/relay").expect("valid URL"); + + assert_eq!(canonical, equivalent); + assert_eq!(path, path_without_slash); + assert_ne!( + canonical, + RealmId::from_relay_url("wss://other.example").expect("valid URL") + ); +} + +#[test] +fn realm_ids_reject_connection_details_and_non_websocket_urls() { + assert_eq!( + RealmId::from_relay_url("https://buzz.example"), + Err(RealmError::UnsupportedScheme) + ); + assert_eq!( + RealmId::from_relay_url("wss://user@buzz.example"), + Err(RealmError::Credentials) + ); + assert_eq!( + RealmId::from_relay_url("wss://buzz.example?token=secret"), + Err(RealmError::QueryOrFragment) + ); +} + +#[test] +fn combining_inputs_intersects_authorized_readers() { + let combined = label(&[1, 2]).join(&label(&[1, 3])).expect("same realm"); + assert!(combined.can_flow_to(&label(&[1]))); + assert!(!combined.can_flow_to(&label(&[1, 2]))); +} + +#[test] +fn labels_never_flow_across_realms() { + let first = ConfidentialityLabel::public( + RealmId::from_relay_url("wss://one.example").expect("valid URL"), + ); + let second = ConfidentialityLabel::public( + RealmId::from_relay_url("wss://two.example").expect("valid URL"), + ); + assert!(!first.can_flow_to(&second)); + assert_eq!(first.join(&second), Err(LabelError::CrossUniverse)); +} + +#[test] +fn read_requires_current_domain_audience_context_and_epoch() { + let channel = Uuid::from_u128(1); + let domain = conversation(&[1, 2], channel, "v2"); + let authority = DomainAuthority::new(domain.clone()); + let wrong_audience = + ResourceLabel::conversation(realm(), channel, readers(&[1]), MembershipEpoch::new("v2")) + .expect("non-empty readers"); + let wrong_context = ResourceLabel::conversation( + realm(), + Uuid::from_u128(2), + readers(&[1, 2]), + MembershipEpoch::new("v2"), + ) + .expect("non-empty readers"); + let stale = ResourceLabel::conversation( + realm(), + channel, + readers(&[1, 2]), + MembershipEpoch::new("v1"), + ) + .expect("non-empty readers"); + + assert!(!RuleEvaluator::read(&domain, authority.current(), &wrong_audience).allowed()); + assert!(!RuleEvaluator::read(&domain, authority.current(), &wrong_context).allowed()); + assert!(!RuleEvaluator::read(&domain, authority.current(), &stale).allowed()); + assert!(RuleEvaluator::read(&domain, authority.current(), &domain.resource_label()).allowed()); +} + +#[test] +fn owner_private_context_aggregates_only_current_owner_readable_inputs() { + let owner = principal(1); + let domain = ExecutionDomain::owner_private( + principal(9), + realm(), + owner, + MembershipEpoch::new("owner-v1"), + CapabilitySet::default(), + ); + let authority = DomainAuthority::new(domain.clone()); + let readable = ResourceLabel::conversation( + realm(), + Uuid::from_u128(1), + readers(&[1, 2]), + MembershipEpoch::new("owner-v1"), + ) + .expect("non-empty readers"); + let unreadable = ResourceLabel::conversation( + realm(), + Uuid::from_u128(2), + readers(&[2, 3]), + MembershipEpoch::new("owner-v1"), + ) + .expect("non-empty readers"); + + assert!(RuleEvaluator::read(&domain, authority.current(), &readable).allowed()); + assert!(!RuleEvaluator::read(&domain, authority.current(), &unreadable).allowed()); +} + +#[test] +fn capability_intersection_keeps_names_and_narrowest_publication_scope() { + let bot = CapabilitySet::from_operations([ + ( + "buzz.post", + OperationEffect::Publication(PublicationScope::WithinRealm), + ), + ("email.read", OperationEffect::NonEgressing), + ]); + let requester = CapabilitySet::from_operations([( + "buzz.post", + OperationEffect::Publication(PublicationScope::SameContext), + )]); + let domain = CapabilitySet::from_operations([ + ("buzz.post", OperationEffect::NonEgressing), + ("drive.read", OperationEffect::NonEgressing), + ]); + + assert_eq!( + CapabilitySet::effective(&bot, &requester, &domain), + CapabilitySet::from_operations([( + "buzz.post", + OperationEffect::Publication(PublicationScope::SameContext), + )]) + ); +} + +#[test] +fn domain_derivation_grants_personal_tools_only_in_owner_private_work() { + let owner = principal(1); + let agent = principal(9); + let policy = CapabilityPolicy::new( + non_egressing(["buzz.read.current", "email.read"]), + non_egressing(["buzz.read.current"]), + ); + let owner_dm = derive_execution_domain( + DomainFacts { + realm: realm(), + channel_id: Uuid::from_u128(1), + kind: ConversationKind::DirectMessage, + epoch: MembershipEpoch::new("membership:v1"), + members: BTreeSet::from([agent.clone(), owner.clone()]), + executing_agent: agent.clone(), + requesters: BTreeSet::from([owner.clone()]), + system_principal: None, + owner: Some(owner.clone()), + }, + &policy, + ) + .expect("owner DM domain"); + assert!(owner_dm.context().is_owner_private_for(&owner)); + let (owner_authority, owner_state) = process_for(owner_dm); + assert!(owner_state + .authorize_non_egressing_call(owner_authority.current(), "email.read") + .allowed()); + + let public = derive_execution_domain( + DomainFacts { + realm: realm(), + channel_id: Uuid::from_u128(2), + kind: ConversationKind::Public, + epoch: MembershipEpoch::new("community:v1"), + members: BTreeSet::new(), + executing_agent: agent, + requesters: BTreeSet::from([owner.clone()]), + system_principal: None, + owner: Some(owner), + }, + &policy, + ) + .expect("public domain"); + let (public_authority, public_state) = process_for(public); + assert!(!public_state + .authorize_non_egressing_call(public_authority.current(), "email.read") + .allowed()); +} + +#[test] +fn restricted_domain_derivation_checks_agent_and_requester_membership() { + let owner = principal(1); + let agent = principal(9); + let outsider = principal(8); + let policy = CapabilityPolicy::new( + non_egressing(["buzz.read.current"]), + non_egressing(["buzz.read.current"]), + ); + let facts = |members, requesters| DomainFacts { + realm: realm(), + channel_id: Uuid::from_u128(1), + kind: ConversationKind::Restricted, + epoch: MembershipEpoch::new("membership:v1"), + members, + executing_agent: agent.clone(), + requesters, + system_principal: None, + owner: Some(owner.clone()), + }; + + assert_eq!( + derive_execution_domain( + facts( + BTreeSet::from([owner.clone()]), + BTreeSet::from([owner.clone()]), + ), + &policy, + ), + Err(DerivationError::AgentNotMember) + ); + assert_eq!( + derive_execution_domain( + facts( + BTreeSet::from([owner.clone(), agent.clone()]), + BTreeSet::from([outsider]), + ), + &policy, + ), + Err(DerivationError::RequesterNotMember) + ); +} + +#[test] +fn authority_change_prevents_stale_reuse_reads_and_calls() { + let stale = conversation(&[1, 2], Uuid::from_u128(1), "v1"); + let current = conversation(&[1, 2], Uuid::from_u128(1), "v2"); + let mut authority = DomainAuthority::new(stale.clone()); + let mut state = ProcessState::default(); + assert!(state.enter(authority.current()).allowed()); + authority.replace(current); + + assert!(!RuleEvaluator::reuse(&stale, authority.current()).allowed()); + assert!(!RuleEvaluator::read(&stale, authority.current(), &stale.resource_label()).allowed()); + assert!(!state + .authorize_non_egressing_call(authority.current(), "buzz.read.current") + .allowed()); + assert!(!state.enter(authority.current()).allowed()); +} + +#[test] +fn domain_id_has_a_canonical_golden_value() { + let domain = conversation(&[1, 2], Uuid::from_u128(1), "membership:event-1"); + assert_eq!( + domain.id(), + "28eb67f09769771c78666c0085468632c500cea713f399a29880f4b6b232ca9d" + ); +} + +#[test] +fn domain_shape_and_compartment_profile_are_consistent() { + let result = ExecutionDomain::new( + principal(9), + Some(principal(1)), + ConfidentialityLabel::public(realm()), + DomainContext::Conversation { + realm: realm(), + channel_id: Uuid::from_u128(1), + }, + MembershipEpoch::new("v1"), + CapabilitySet::default(), + ); + assert_eq!(result, Err(DomainError::AudienceContextMismatch)); + + let public = ExecutionDomain::public( + principal(9), + Some(principal(1)), + realm(), + MembershipEpoch::new("v1"), + CapabilitySet::default(), + ); + assert_eq!( + public.compartment_profile(), + CompartmentProfile::SharedPublic + ); + assert_eq!( + conversation(&[1], Uuid::from_u128(1), "v1").compartment_profile(), + CompartmentProfile::DomainConfined + ); +} + +#[test] +fn publication_target_rejects_incoherent_audience_and_context() { + let other_realm = RealmId::from_relay_url("wss://other.example").expect("valid URL"); + assert_eq!( + PublicationTarget::new( + ConfidentialityLabel::public(realm()), + DomainContext::RealmPublic(other_realm), + MembershipEpoch::new("v1"), + ), + Err(PublicationTargetError::ContextRealmMismatch) + ); + assert_eq!( + PublicationTarget::new( + ConfidentialityLabel::public(realm()), + DomainContext::Conversation { + realm: realm(), + channel_id: Uuid::from_u128(2), + }, + MembershipEpoch::new("v1"), + ), + Err(PublicationTargetError::AudienceContextMismatch) + ); +} + +#[test] +fn post_can_cross_contexts_but_reply_cannot() { + let domain = conversation(&[1, 2], Uuid::from_u128(1), "v1"); + let destination = conversation_target(&[1, 2], Uuid::from_u128(2), "v1"); + let (authority, mut state) = process_for(domain.clone()); + state.observe(&domain.resource_label()); + + assert!(state + .authorize_publication( + authority.current(), + "buzz.post", + &destination, + &[7; 32], + None, + ) + .is_ok()); + assert!(state + .authorize_publication( + authority.current(), + "buzz.reply", + &destination, + &[7; 32], + None, + ) + .is_err()); +} + +#[test] +fn publication_capability_cannot_use_non_egressing_path_or_widen_audience() { + let domain = conversation(&[1], Uuid::from_u128(1), "v1"); + let destination = conversation_target(&[1, 2], Uuid::from_u128(2), "target-v1"); + let (authority, mut state) = process_for(domain.clone()); + state.observe(&domain.resource_label()); + + assert!(state + .authorize_non_egressing_call(authority.current(), "buzz.read.current") + .allowed()); + assert!(!state + .authorize_non_egressing_call(authority.current(), "buzz.post") + .allowed()); + assert!(state + .authorize_publication( + authority.current(), + "buzz.read.current", + &destination, + &[7; 32], + None, + ) + .is_err()); + assert!(state + .authorize_publication( + authority.current(), + "buzz.post", + &destination, + &[7; 32], + None, + ) + .is_err()); +} + +#[test] +fn cross_context_or_unknown_input_taints_publication() { + let domain = conversation(&[1, 2], Uuid::from_u128(1), "v1"); + let destination = + PublicationTarget::from_current(DomainAuthority::new(domain.clone()).current()); + let (authority, mut state) = process_for(domain); + let other = ResourceLabel::conversation( + realm(), + Uuid::from_u128(2), + readers(&[1, 2]), + MembershipEpoch::new("v1"), + ) + .expect("non-empty readers"); + state.observe(&other); + + assert!(state + .authorize_publication( + authority.current(), + "buzz.post", + &destination, + &[7; 32], + None, + ) + .is_err()); + state.mark_unknown(); + assert!(state + .authorize_publication( + authority.current(), + "buzz.post", + &destination, + &[7; 32], + None, + ) + .is_err()); +} + +#[test] +fn stale_epoch_input_taints_publication_even_after_a_denied_read() { + let domain = conversation(&[1, 2], Uuid::from_u128(1), "v2"); + let destination = conversation_target(&[1, 2], Uuid::from_u128(1), "v2"); + let stale = ResourceLabel::conversation( + realm(), + Uuid::from_u128(1), + readers(&[1, 2]), + MembershipEpoch::new("v1"), + ) + .expect("non-empty readers"); + let (authority, mut state) = process_for(domain.clone()); + + assert!(!RuleEvaluator::read(&domain, authority.current(), &stale).allowed()); + state.observe(&stale); + assert!(state + .authorize_publication( + authority.current(), + "buzz.post", + &destination, + &[7; 32], + None, + ) + .is_err()); +} + +#[test] +fn publication_commit_rejects_operation_destination_and_content_substitution() { + let domain = conversation(&[1, 2], Uuid::from_u128(1), "v1"); + let destination = conversation_target(&[1, 2], Uuid::from_u128(2), "v1"); + let changed_destination = conversation_target(&[1, 2], Uuid::from_u128(2), "v2"); + let (authority, mut state) = process_for(domain.clone()); + state.observe(&domain.resource_label()); + let mut replay = MemoryReplayStore::default(); + + let permit = state + .authorize_publication( + authority.current(), + "buzz.post", + &destination, + &[7; 32], + None, + ) + .expect("authorized publication"); + assert_eq!( + state.commit_publication( + permit, + PublicationCommit::new( + authority.current(), + "buzz.reply", + &destination, + [7; 32], + NOW, + ), + &mut replay, + ), + Err(PublicationCommitError::OperationChanged) + ); + + let permit = state + .authorize_publication( + authority.current(), + "buzz.post", + &destination, + &[7; 32], + None, + ) + .expect("authorized publication"); + assert_eq!( + state.commit_publication( + permit, + PublicationCommit::new( + authority.current(), + "buzz.post", + &changed_destination, + [7; 32], + NOW, + ), + &mut replay, + ), + Err(PublicationCommitError::DestinationChanged) + ); + + let permit = state + .authorize_publication( + authority.current(), + "buzz.post", + &destination, + &[7; 32], + None, + ) + .expect("authorized publication"); + assert_eq!( + state.commit_publication( + permit, + PublicationCommit::new(authority.current(), "buzz.post", &destination, [8; 32], NOW,), + &mut replay, + ), + Err(PublicationCommitError::ContentChanged) + ); +} + +#[test] +fn publication_commit_rejects_source_membership_change() { + let stale = conversation(&[1, 2], Uuid::from_u128(1), "v1"); + let current = conversation(&[1, 2], Uuid::from_u128(1), "v2"); + let destination = conversation_target(&[1, 2], Uuid::from_u128(2), "v1"); + let (mut authority, mut state) = process_for(stale.clone()); + state.observe(&stale.resource_label()); + let permit = state + .authorize_publication( + authority.current(), + "buzz.post", + &destination, + &[7; 32], + None, + ) + .expect("authorized publication"); + authority.replace(current); + + assert_eq!( + state.commit_publication( + permit, + PublicationCommit::new(authority.current(), "buzz.post", &destination, [7; 32], NOW,), + &mut MemoryReplayStore::default(), + ), + Err(PublicationCommitError::SourceChanged) + ); +} + +#[test] +fn publication_commit_rejects_new_process_taint() { + let domain = conversation(&[1, 2], Uuid::from_u128(1), "v1"); + let destination = conversation_target(&[1, 2], Uuid::from_u128(2), "v1"); + let (authority, mut state) = process_for(domain.clone()); + state.observe(&domain.resource_label()); + let permit = state + .authorize_publication( + authority.current(), + "buzz.post", + &destination, + &[7; 32], + None, + ) + .expect("authorized publication"); + state.mark_unknown(); + + assert_eq!( + state.commit_publication( + permit, + PublicationCommit::new(authority.current(), "buzz.post", &destination, [7; 32], NOW,), + &mut MemoryReplayStore::default(), + ), + Err(PublicationCommitError::ProcessStateChanged) + ); +} + +#[test] +fn canonical_grant_signature_rejects_every_mutable_field() { + let owner_keys = keys(1); + let owner = principal(1); + let source = conversation(&[1], Uuid::from_u128(1), "v1"); + let destination = conversation_target(&[1, 2], Uuid::from_u128(2), "target-v1"); + let original = grant_payload( + owner.clone(), + 1, + "buzz.post", + &source.id(), + destination.clone(), + [9; 32], + EXPIRES_AT, + ); + let signature = sign_payload(&original, &owner_keys); + + let mutations = [ + grant_payload( + owner.clone(), + 2, + "buzz.post", + &source.id(), + destination.clone(), + [9; 32], + EXPIRES_AT, + ), + grant_payload( + owner.clone(), + 1, + "buzz.reply", + &source.id(), + destination.clone(), + [9; 32], + EXPIRES_AT, + ), + grant_payload( + owner.clone(), + 1, + "buzz.post", + "different-source", + destination.clone(), + [9; 32], + EXPIRES_AT, + ), + grant_payload( + owner.clone(), + 1, + "buzz.post", + &source.id(), + conversation_target(&[1], Uuid::from_u128(2), "target-v1"), + [9; 32], + EXPIRES_AT, + ), + grant_payload( + owner.clone(), + 1, + "buzz.post", + &source.id(), + conversation_target(&[1, 2], Uuid::from_u128(3), "target-v1"), + [9; 32], + EXPIRES_AT, + ), + grant_payload( + owner.clone(), + 1, + "buzz.post", + &source.id(), + conversation_target(&[1, 2], Uuid::from_u128(2), "target-v2"), + [9; 32], + EXPIRES_AT, + ), + grant_payload( + owner.clone(), + 1, + "buzz.post", + &source.id(), + destination.clone(), + [8; 32], + EXPIRES_AT, + ), + grant_payload( + owner.clone(), + 1, + "buzz.post", + &source.id(), + destination.clone(), + [9; 32], + EXPIRES_AT + 1, + ), + ]; + for mutation in mutations { + assert!(matches!( + DeclassificationGrant::new(mutation, signature).verify(NOW), + Err(GrantError::InvalidSignature) + )); + } + + let other_owner = principal(2); + let changed_approver = grant_payload( + other_owner.clone(), + 1, + "buzz.post", + &source.id(), + destination, + [9; 32], + EXPIRES_AT, + ); + assert!(matches!( + DeclassificationGrant::new(changed_approver, signature).verify(NOW), + Err(GrantError::InvalidSignature) + )); +} + +#[test] +fn declassification_is_exact_and_cannot_cross_realms() { + let domain = conversation(&[1], Uuid::from_u128(1), "v1"); + let destination = public_target(realm(), "v1"); + let grant = verified_grant( + 1, + 1, + "buzz.post", + &domain.id(), + destination.clone(), + [9; 32], + ); + let (authority, mut state) = process_for(domain.clone()); + state.observe(&domain.resource_label()); + + assert!(state + .authorize_publication( + authority.current(), + "buzz.post", + &destination, + &[8; 32], + Some(&grant) + ) + .is_err()); + assert!(state + .authorize_publication( + authority.current(), + "buzz.post", + &destination, + &[9; 32], + Some(&grant) + ) + .is_ok()); + + let other_realm = RealmId::from_relay_url("wss://other.example").expect("valid URL"); + let cross_realm = public_target(other_realm, "v1"); + let cross_grant = verified_grant( + 1, + 2, + "buzz.post", + &domain.id(), + cross_realm.clone(), + [9; 32], + ); + assert!(state + .authorize_publication( + authority.current(), + "buzz.post", + &cross_realm, + &[9; 32], + Some(&cross_grant) + ) + .is_err()); +} + +#[test] +fn grant_expiration_is_checked_at_verification_and_commit() { + let owner_keys = keys(1); + let owner = principal(1); + let domain = conversation(&[1], Uuid::from_u128(1), "v1"); + let destination = public_target(realm(), "v1"); + let payload = grant_payload( + owner.clone(), + 1, + "buzz.post", + &domain.id(), + destination.clone(), + [9; 32], + EXPIRES_AT, + ); + let signature = sign_payload(&payload, &owner_keys); + assert!(matches!( + DeclassificationGrant::new(payload.clone(), signature).verify(EXPIRES_AT), + Err(GrantError::Expired) + )); + let grant = DeclassificationGrant::new(payload, signature) + .verify(NOW) + .expect("not expired yet"); + let (authority, mut state) = process_for(domain.clone()); + state.observe(&domain.resource_label()); + let permit = state + .authorize_publication( + authority.current(), + "buzz.post", + &destination, + &[9; 32], + Some(&grant), + ) + .expect("grant authorizes widening"); + assert_eq!( + state.commit_publication( + permit, + PublicationCommit::new( + authority.current(), + "buzz.post", + &destination, + [9; 32], + EXPIRES_AT, + ), + &mut MemoryReplayStore::default(), + ), + Err(PublicationCommitError::GrantExpired) + ); +} + +#[test] +fn reconstructed_grant_is_still_single_use_via_durable_replay_store() { + let owner_keys = keys(1); + let owner = principal(1); + let domain = conversation(&[1], Uuid::from_u128(1), "v1"); + let destination = public_target(realm(), "v1"); + let payload = grant_payload( + owner.clone(), + 1, + "buzz.post", + &domain.id(), + destination.clone(), + [9; 32], + EXPIRES_AT, + ); + let signature = sign_payload(&payload, &owner_keys); + let first = DeclassificationGrant::new(payload.clone(), signature) + .verify(NOW) + .expect("valid grant"); + let reconstructed = DeclassificationGrant::new(payload, signature) + .verify(NOW) + .expect("same valid signed grant"); + let (authority, mut state) = process_for(domain.clone()); + state.observe(&domain.resource_label()); + let first_permit = state + .authorize_publication( + authority.current(), + "buzz.post", + &destination, + &[9; 32], + Some(&first), + ) + .expect("first permit"); + let replayed_permit = state + .authorize_publication( + authority.current(), + "buzz.post", + &destination, + &[9; 32], + Some(&reconstructed), + ) + .expect("authorization is side-effect free"); + let mut replay = MemoryReplayStore::default(); + + let authorized = state + .commit_publication( + first_permit, + PublicationCommit::new(authority.current(), "buzz.post", &destination, [9; 32], NOW), + &mut replay, + ) + .expect("first durable consumption"); + assert_eq!(authorized.operation(), "buzz.post"); + assert_eq!(authorized.destination(), &destination); + assert_eq!(authorized.content_digest(), &[9; 32]); + assert_eq!( + state.commit_publication( + replayed_permit, + PublicationCommit::new(authority.current(), "buzz.post", &destination, [9; 32], NOW,), + &mut replay, + ), + Err(PublicationCommitError::GrantReplayed) + ); +}