Add Buzz IFC session adapter

Signed-off-by: Jordan Mecom <jm@squareup.com>
This commit is contained in:
Jordan Mecom
2026-09-03 12:25:07 -07:00
parent 91ea86e94e
commit b675bb08be
15 changed files with 4057 additions and 0 deletions

15
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String>,
source_domain_id: impl Into<String>,
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<VerifiedDeclassificationGrant, GrantError> {
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,
}

View File

@@ -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<String, OperationEffect>);
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<I, S>(operations: I) -> Self
where
I: IntoIterator<Item = (S, OperationEffect)>,
S: Into<String>,
{
let mut capabilities: BTreeMap<String, OperationEffect> = 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<OperationEffect> {
self.0.get(operation).copied()
}
fn stable_hash(&self, hasher: &mut Sha256) {
hash_field(hasher, &(self.0.len() as u64).to_be_bytes());
for (name, effect) in &self.0 {
hash_field(hasher, name.as_bytes());
effect.stable_hash(hasher);
}
}
}
/// 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<String>) -> 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<Principal>,
/// Managed Buzz identity whose work the execution domain contains.
pub executing_agent: Principal,
/// Authors whose events are included in this invocation.
pub requesters: BTreeSet<Principal>,
/// Optional relay principal allowed to author trusted workflow events.
pub system_principal: Option<Principal>,
/// Optional human owner of the executing agent.
pub owner: Option<Principal>,
}
/// 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<ExecutionDomain, DerivationError> {
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<Principal>,
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<Self, PublicationTargetError> {
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<Principal>,
audience: ConfidentialityLabel,
context: DomainContext,
epoch: MembershipEpoch,
capabilities: CapabilitySet,
) -> Result<Self, DomainError> {
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<Principal>,
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<MembershipEpoch>,
}
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<Principal>,
epoch: MembershipEpoch,
) -> Result<Self, LabelError> {
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)
}
}

View File

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

View File

@@ -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<Self, PrincipalError> {
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<Self, PrincipalError> {
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<Self, RealmError> {
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<RealmId, Principal>;
pub(crate) type ReaderSet = ifc_core::ReaderSet<Principal>;
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());
}
}
}
}

View File

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

View File

@@ -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<RealmId, Principal>,
contexts: BTreeSet<ResourceContext>,
epochs: BTreeSet<crate::domain::MembershipEpoch>,
}
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<RealmId, Principal>,
contexts: BTreeSet<ResourceContext>,
epochs: BTreeSet<crate::domain::MembershipEpoch>,
}
/// 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<ExecutionDomain>,
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<PublicationPermit, RuleDecision> {
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<CommittedPublication<'a>, 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<GrantUse>,
confinement: ConfinementSnapshot,
}
impl PublicationPermit {
fn commit<'a, R: GrantReplayStore>(
self,
commit: PublicationCommit<'a>,
replay_store: &mut R,
) -> Result<CommittedPublication<'a>, 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,
}

View File

@@ -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<Principal> {
(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<Value = ReadCase> {
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<Value = DomainVariant> {
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<Value = PublishCase> {
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<Value = TraceAction> {
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<Audience>,
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"
);
}
}

View File

@@ -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<R: GrantReplayStore>(
/// # 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<AuthorizedPublication<'a>, 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))
}
}

View File

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

View File

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

View File

@@ -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<Principal> {
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<const N: usize>(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)
);
}