mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
Add reusable IFC policy engine
Signed-off-by: Jordan Mecom <jm@squareup.com>
This commit is contained in:
12
Cargo.lock
generated
12
Cargo.lock
generated
@@ -1126,6 +1126,18 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "buzz-ifc"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"nostr 0.44.7",
|
||||
"serde",
|
||||
"sha2 0.11.0",
|
||||
"thiserror 2.0.18",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "buzz-media"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -10,6 +10,7 @@ members = [
|
||||
"crates/buzz-search",
|
||||
"crates/buzz-audit",
|
||||
"crates/buzz-acp",
|
||||
"crates/buzz-ifc",
|
||||
"crates/buzz-agent",
|
||||
"crates/sprig",
|
||||
"crates/buzz-test-client",
|
||||
@@ -137,6 +138,7 @@ schemars = { version = "1", default-features = false }
|
||||
|
||||
# Internal crates
|
||||
buzz-core = { path = "crates/buzz-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" }
|
||||
|
||||
16
crates/buzz-ifc/Cargo.toml
Normal file
16
crates/buzz-ifc/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[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 }
|
||||
nostr = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
136
crates/buzz-ifc/src/declassification.rs
Normal file
136
crates/buzz-ifc/src/declassification.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use crate::domain::DomainContext;
|
||||
use crate::label::{ConfidentialityLabel, Principal};
|
||||
|
||||
/// Typestate marker for a grant whose owner signature has not been checked.
|
||||
pub struct PendingGrant;
|
||||
|
||||
/// Typestate marker for a grant authenticated by an external verifier.
|
||||
pub struct VerifiedGrant;
|
||||
|
||||
/// Paper: "Declassification." A content- and destination-specific grant.
|
||||
///
|
||||
/// A verified value is consumed by its first matching publication decision.
|
||||
/// The broker remains responsible for durable replay and expiry enforcement
|
||||
/// before verification.
|
||||
pub struct DeclassificationGrant<State> {
|
||||
approver: Principal,
|
||||
source_domain_id: String,
|
||||
destination: ConfidentialityLabel,
|
||||
destination_context: DomainContext,
|
||||
content_digest: [u8; 32],
|
||||
consumed: bool,
|
||||
_state: PhantomData<State>,
|
||||
}
|
||||
|
||||
impl<State> DeclassificationGrant<State> {
|
||||
/// Return the principal that approved this release.
|
||||
pub fn approver(&self) -> &Principal {
|
||||
&self.approver
|
||||
}
|
||||
|
||||
/// Return the exact execution domain from which the content came.
|
||||
pub fn source_domain_id(&self) -> &str {
|
||||
&self.source_domain_id
|
||||
}
|
||||
|
||||
/// Return the audience approved to receive the content.
|
||||
pub fn destination(&self) -> &ConfidentialityLabel {
|
||||
&self.destination
|
||||
}
|
||||
|
||||
/// Return the exact destination context approved for the release.
|
||||
pub fn destination_context(&self) -> &DomainContext {
|
||||
&self.destination_context
|
||||
}
|
||||
|
||||
/// Return the digest of the exact content approved for release.
|
||||
pub fn content_digest(&self) -> &[u8; 32] {
|
||||
&self.content_digest
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies the owner signature over a pending grant's canonical payload.
|
||||
pub trait GrantSignatureVerifier {
|
||||
/// Return true only when the grant bears an authentic owner signature and
|
||||
/// its externally stored expiry or replay policy still permits use.
|
||||
fn verifies(&self, grant: &DeclassificationGrant<PendingGrant>) -> bool;
|
||||
}
|
||||
|
||||
impl DeclassificationGrant<PendingGrant> {
|
||||
/// Construct an unverified grant from signed-event fields.
|
||||
pub fn pending(
|
||||
approver: Principal,
|
||||
source_domain_id: String,
|
||||
destination: ConfidentialityLabel,
|
||||
destination_context: DomainContext,
|
||||
content_digest: [u8; 32],
|
||||
) -> Self {
|
||||
Self {
|
||||
approver,
|
||||
source_domain_id,
|
||||
destination,
|
||||
destination_context,
|
||||
content_digest,
|
||||
consumed: false,
|
||||
_state: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate the owner and move the grant into the verified typestate.
|
||||
pub fn verify<V: GrantSignatureVerifier>(
|
||||
self,
|
||||
expected_owner: &Principal,
|
||||
verifier: &V,
|
||||
) -> Result<DeclassificationGrant<VerifiedGrant>, GrantError> {
|
||||
if &self.approver != expected_owner {
|
||||
return Err(GrantError::WrongApprover);
|
||||
}
|
||||
if !verifier.verifies(&self) {
|
||||
return Err(GrantError::InvalidSignature);
|
||||
}
|
||||
Ok(DeclassificationGrant {
|
||||
approver: self.approver,
|
||||
source_domain_id: self.source_domain_id,
|
||||
destination: self.destination,
|
||||
destination_context: self.destination_context,
|
||||
content_digest: self.content_digest,
|
||||
consumed: false,
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl DeclassificationGrant<VerifiedGrant> {
|
||||
pub(crate) fn matches(
|
||||
&mut self,
|
||||
source_domain_id: &str,
|
||||
destination: &ConfidentialityLabel,
|
||||
destination_context: &DomainContext,
|
||||
content_digest: &[u8; 32],
|
||||
) -> bool {
|
||||
if self.consumed {
|
||||
return false;
|
||||
}
|
||||
let matches = self.source_domain_id == source_domain_id
|
||||
&& &self.destination == destination
|
||||
&& &self.destination_context == destination_context
|
||||
&& &self.content_digest == content_digest;
|
||||
if matches {
|
||||
self.consumed = true;
|
||||
}
|
||||
matches
|
||||
}
|
||||
}
|
||||
|
||||
/// A declassification grant failed owner authentication.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
|
||||
pub enum GrantError {
|
||||
/// The signer is not the expected bot owner.
|
||||
#[error("declassification grant was signed by the wrong principal")]
|
||||
WrongApprover,
|
||||
/// The supplied signature did not authenticate the grant.
|
||||
#[error("declassification grant signature is invalid")]
|
||||
InvalidSignature,
|
||||
}
|
||||
592
crates/buzz-ifc/src/domain.rs
Normal file
592
crates/buzz-ifc/src/domain.rs
Normal file
@@ -0,0 +1,592 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::hash::{hash_field, short_fingerprint};
|
||||
use crate::label::{ConfidentialityLabel, LabelError, Principal, ReaderSet, RealmId};
|
||||
|
||||
/// Which retained context a worker belongs to.
|
||||
///
|
||||
/// Paper: "Execution domains." Context remains separate from audience: two
|
||||
/// conversations may have identical participants without implicitly sharing
|
||||
/// memory.
|
||||
#[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
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn stable_hash(&self, hasher: &mut Sha256) {
|
||||
match self {
|
||||
Self::RealmPublic(realm) => {
|
||||
hasher.update(b"realm-public");
|
||||
realm.stable_hash(hasher);
|
||||
}
|
||||
Self::Conversation { realm, channel_id } => {
|
||||
hasher.update(b"conversation");
|
||||
realm.stable_hash(hasher);
|
||||
hasher.update(channel_id.as_bytes());
|
||||
}
|
||||
Self::OwnerPrivate { realm, owner } => {
|
||||
hasher.update(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 },
|
||||
}
|
||||
|
||||
/// An operation that a worker may request.
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
struct Capability(String);
|
||||
|
||||
/// The complete set of operations admitted for one execution domain.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct CapabilitySet(BTreeSet<Capability>);
|
||||
|
||||
impl CapabilitySet {
|
||||
/// Build a set from stable operation names.
|
||||
pub fn from_names<I, S>(names: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
Self(
|
||||
names
|
||||
.into_iter()
|
||||
.map(|name| Capability(name.into()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Paper: "Broker behavior — Enforcement rules." Compute
|
||||
/// `C(bot) ∩ C(requester) ∩ C(domain)`.
|
||||
pub fn effective(bot: &Self, requester: &Self, domain: &Self) -> Self {
|
||||
let bot_and_requester: BTreeSet<_> = bot.0.intersection(&requester.0).cloned().collect();
|
||||
Self(bot_and_requester.intersection(&domain.0).cloned().collect())
|
||||
}
|
||||
|
||||
/// Whether this set admits an operation.
|
||||
pub fn contains(&self, operation: &str) -> bool {
|
||||
self.0.contains(&Capability(operation.to_string()))
|
||||
}
|
||||
|
||||
fn stable_hash(&self, hasher: &mut Sha256) {
|
||||
for capability in &self.0 {
|
||||
hash_field(hasher, capability.0.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, PartialEq)]
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Paper: "Execution domains." Derive
|
||||
/// `D = (Agent, Audience, Context, Epoch, Capabilities)` from verified Buzz
|
||||
/// facts.
|
||||
/// This is the mapping 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.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,
|
||||
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)`.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ExecutionDomain {
|
||||
agent: Principal,
|
||||
pub(crate) audience: ConfidentialityLabel,
|
||||
pub(crate) context: DomainContext,
|
||||
epoch: MembershipEpoch,
|
||||
pub(crate) capabilities: CapabilitySet,
|
||||
}
|
||||
|
||||
impl ExecutionDomain {
|
||||
/// Construct a domain after the trusted adapter has resolved its inputs.
|
||||
pub fn new(
|
||||
agent: Principal,
|
||||
audience: ConfidentialityLabel,
|
||||
context: DomainContext,
|
||||
epoch: MembershipEpoch,
|
||||
capabilities: CapabilitySet,
|
||||
) -> Result<Self, DomainError> {
|
||||
if audience.realm() != context.realm() {
|
||||
return Err(DomainError::ContextRealmMismatch);
|
||||
}
|
||||
match (&audience.readers, &context) {
|
||||
(ReaderSet::Everyone, DomainContext::RealmPublic(_)) => {}
|
||||
(ReaderSet::Only(readers), DomainContext::Conversation { .. })
|
||||
if !readers.is_empty() => {}
|
||||
(ReaderSet::Only(readers), DomainContext::OwnerPrivate { owner, .. })
|
||||
if readers.len() == 1 && readers.contains(owner) => {}
|
||||
_ => return Err(DomainError::AudienceContextMismatch),
|
||||
}
|
||||
Ok(Self {
|
||||
agent,
|
||||
audience,
|
||||
context,
|
||||
epoch,
|
||||
capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
/// Construct the realm-wide public domain.
|
||||
pub fn public(
|
||||
agent: Principal,
|
||||
realm: RealmId,
|
||||
epoch: MembershipEpoch,
|
||||
capabilities: CapabilitySet,
|
||||
) -> Self {
|
||||
Self {
|
||||
agent,
|
||||
audience: ConfidentialityLabel::public(realm.clone()),
|
||||
context: DomainContext::RealmPublic(realm),
|
||||
epoch,
|
||||
capabilities,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct the exact owner-private domain.
|
||||
pub fn owner_private(
|
||||
agent: Principal,
|
||||
realm: RealmId,
|
||||
owner: Principal,
|
||||
epoch: MembershipEpoch,
|
||||
capabilities: CapabilitySet,
|
||||
) -> Self {
|
||||
let readers = BTreeSet::from([owner.clone()]);
|
||||
Self {
|
||||
agent,
|
||||
audience: ConfidentialityLabel {
|
||||
realm: realm.clone(),
|
||||
readers: ReaderSet::Only(readers),
|
||||
},
|
||||
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 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 the effective capability set.
|
||||
pub fn capabilities(&self) -> &CapabilitySet {
|
||||
&self.capabilities
|
||||
}
|
||||
|
||||
/// 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-v1");
|
||||
hash_field(&mut hasher, self.agent.0.as_bytes());
|
||||
self.audience.realm.stable_hash(&mut hasher);
|
||||
self.audience.readers.stable_hash(&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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) -> Self {
|
||||
Self {
|
||||
confidentiality: ConfidentialityLabel::public(realm.clone()),
|
||||
context: ResourceContext::RealmPublic(realm),
|
||||
}
|
||||
}
|
||||
|
||||
/// Label information belonging to one restricted conversation.
|
||||
pub fn conversation(
|
||||
realm: RealmId,
|
||||
channel_id: Uuid,
|
||||
readers: BTreeSet<Principal>,
|
||||
) -> Result<Self, LabelError> {
|
||||
Ok(Self {
|
||||
confidentiality: ConfidentialityLabel::restricted(realm.clone(), readers)?,
|
||||
context: ResourceContext::Conversation { realm, channel_id },
|
||||
})
|
||||
}
|
||||
|
||||
/// Label owner-private information such as personal memory.
|
||||
pub fn owner_private(realm: RealmId, owner: Principal) -> Self {
|
||||
let readers = BTreeSet::from([owner.clone()]);
|
||||
Self {
|
||||
confidentiality: ConfidentialityLabel {
|
||||
realm: realm.clone(),
|
||||
readers: ReaderSet::Only(readers),
|
||||
},
|
||||
context: ResourceContext::OwnerPrivate { realm, owner },
|
||||
}
|
||||
}
|
||||
}
|
||||
12
crates/buzz-ifc/src/hash.rs
Normal file
12
crates/buzz-ifc/src/hash.rs
Normal 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])
|
||||
}
|
||||
202
crates/buzz-ifc/src/label.rs
Normal file
202
crates/buzz-ifc/src/label.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use nostr::PublicKey;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
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 relay URL selected by Buzz.
|
||||
pub fn from_relay_url(relay_url: &str) -> Self {
|
||||
Self(Sha256::digest(relay_url.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);
|
||||
}
|
||||
}
|
||||
|
||||
/// The authorized readers of a value.
|
||||
///
|
||||
/// Paper: "Appendix: Security labels as a lattice — Labels and ordering."
|
||||
/// `Everyone` is the public lattice element. An explicit set becomes more
|
||||
/// restrictive as principals are removed.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ReaderSet {
|
||||
Everyone,
|
||||
Only(BTreeSet<Principal>),
|
||||
}
|
||||
|
||||
impl ReaderSet {
|
||||
/// The IFC ordering is reverse set inclusion: every reader at the
|
||||
/// destination must already be authorized to read the source.
|
||||
fn can_flow_to(&self, destination: &Self) -> bool {
|
||||
match (self, destination) {
|
||||
(Self::Everyone, _) => true,
|
||||
(Self::Only(_), Self::Everyone) => false,
|
||||
(Self::Only(source), Self::Only(destination)) => destination.is_subset(source),
|
||||
}
|
||||
}
|
||||
|
||||
/// Paper: "Combining information." Inputs are joined by intersecting their
|
||||
/// authorized reader sets.
|
||||
pub(crate) fn join(&self, other: &Self) -> Self {
|
||||
match (self, other) {
|
||||
(Self::Everyone, value) | (value, Self::Everyone) => value.clone(),
|
||||
(Self::Only(left), Self::Only(right)) => {
|
||||
Self::Only(left.intersection(right).cloned().collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn meet(&self, other: &Self) -> Self {
|
||||
match (self, other) {
|
||||
(Self::Everyone, _) | (_, Self::Everyone) => Self::Everyone,
|
||||
(Self::Only(left), Self::Only(right)) => {
|
||||
Self::Only(left.union(right).cloned().collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn explicit_count(&self) -> Option<usize> {
|
||||
match self {
|
||||
Self::Everyone => None,
|
||||
Self::Only(readers) => Some(readers.len()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stable_hash(&self, hasher: &mut Sha256) {
|
||||
match self {
|
||||
Self::Everyone => hasher.update(b"everyone"),
|
||||
Self::Only(readers) => {
|
||||
hasher.update(b"only");
|
||||
for reader in readers {
|
||||
hash_field(hasher, reader.0.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A reader-set label within one Buzz community.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ConfidentialityLabel {
|
||||
pub(crate) realm: RealmId,
|
||||
pub(crate) readers: ReaderSet,
|
||||
}
|
||||
|
||||
impl ConfidentialityLabel {
|
||||
/// Label data that every member of the realm may read.
|
||||
pub fn public(realm: RealmId) -> Self {
|
||||
Self {
|
||||
realm,
|
||||
readers: ReaderSet::Everyone,
|
||||
}
|
||||
}
|
||||
|
||||
/// Label data with an explicit non-empty authorized reader set.
|
||||
pub fn restricted(realm: RealmId, readers: BTreeSet<Principal>) -> Result<Self, LabelError> {
|
||||
if readers.is_empty() {
|
||||
return Err(LabelError::EmptyReaderSet);
|
||||
}
|
||||
Ok(Self {
|
||||
realm,
|
||||
readers: ReaderSet::Only(readers),
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the realm in which this label is meaningful.
|
||||
pub fn realm(&self) -> &RealmId {
|
||||
&self.realm
|
||||
}
|
||||
|
||||
/// Whether the label permits every member of the realm to read the value.
|
||||
pub fn is_public(&self) -> bool {
|
||||
matches!(self.readers, ReaderSet::Everyone)
|
||||
}
|
||||
|
||||
/// Return the explicit number of readers, or `None` for public data.
|
||||
pub fn reader_count(&self) -> Option<usize> {
|
||||
self.readers.explicit_count()
|
||||
}
|
||||
|
||||
/// Whether information with this label may flow to `destination`.
|
||||
pub fn can_flow_to(&self, destination: &Self) -> bool {
|
||||
self.realm == destination.realm && self.readers.can_flow_to(&destination.readers)
|
||||
}
|
||||
|
||||
/// Combine the influence of two inputs.
|
||||
pub fn join(&self, other: &Self) -> Result<Self, LabelError> {
|
||||
if self.realm != other.realm {
|
||||
return Err(LabelError::CrossRealm);
|
||||
}
|
||||
Ok(Self {
|
||||
realm: self.realm.clone(),
|
||||
readers: self.readers.join(&other.readers),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A confidentiality label violates the reader-set lattice invariants.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
|
||||
pub enum LabelError {
|
||||
/// Restricted information must name at least one authorized reader.
|
||||
#[error("restricted label has no authorized readers")]
|
||||
EmptyReaderSet,
|
||||
/// Labels from different Buzz communities cannot be combined.
|
||||
#[error("labels belong to different Buzz realms")]
|
||||
CrossRealm,
|
||||
}
|
||||
491
crates/buzz-ifc/src/lib.rs
Normal file
491
crates/buzz-ifc/src/lib.rs
Normal file
@@ -0,0 +1,491 @@
|
||||
//! 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`], and uses
|
||||
//! these rules to derive an [`ExecutionDomain`] and decide whether a worker may
|
||||
//! be reused, read a resource, call an operation, or publish a result. Keeping
|
||||
//! the policy pure lets local ACP and remote harnesses apply the same rules
|
||||
//! without sharing an agent implementation.
|
||||
//!
|
||||
//! Comments prefixed with `Paper:` identify the matching section of
|
||||
//! "Practical information-flow for Buzz agents."
|
||||
|
||||
mod declassification;
|
||||
mod domain;
|
||||
mod hash;
|
||||
mod label;
|
||||
mod policy;
|
||||
|
||||
pub use declassification::{
|
||||
DeclassificationGrant, GrantError, GrantSignatureVerifier, PendingGrant, VerifiedGrant,
|
||||
};
|
||||
pub use domain::{
|
||||
derive_execution_domain, CapabilityPolicy, CapabilitySet, CompartmentProfile, ConversationKind,
|
||||
DerivationError, DomainContext, DomainError, DomainFacts, DomainKey, ExecutionDomain,
|
||||
MembershipEpoch, ResourceLabel,
|
||||
};
|
||||
pub use label::{ConfidentialityLabel, LabelError, Principal, PrincipalError, RealmId};
|
||||
pub use policy::{ProcessState, RuleDecision, RuleEvaluator};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use nostr::Keys;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::*;
|
||||
use crate::label::ReaderSet;
|
||||
|
||||
fn principal(value: u8) -> Principal {
|
||||
let secret_key = format!("{value:064x}");
|
||||
let keys = Keys::parse(&secret_key).expect("test secret key");
|
||||
Principal::from_public_key(&keys.public_key()).expect("test principal")
|
||||
}
|
||||
|
||||
fn readers(values: &[u8]) -> BTreeSet<Principal> {
|
||||
values.iter().copied().map(principal).collect()
|
||||
}
|
||||
|
||||
fn realm() -> RealmId {
|
||||
RealmId::from_relay_url("wss://buzz.example")
|
||||
}
|
||||
|
||||
fn label(values: &[u8]) -> ConfidentialityLabel {
|
||||
ConfidentialityLabel::restricted(realm(), readers(values)).expect("non-empty readers")
|
||||
}
|
||||
|
||||
#[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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn conversation(values: &[u8], channel_id: Uuid, epoch: &str) -> ExecutionDomain {
|
||||
ExecutionDomain::new(
|
||||
principal(9),
|
||||
label(values),
|
||||
DomainContext::Conversation {
|
||||
realm: realm(),
|
||||
channel_id,
|
||||
},
|
||||
MembershipEpoch::new(epoch),
|
||||
CapabilitySet::from_names(["buzz.read.current"]),
|
||||
)
|
||||
.expect("same realm")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reader_sets_obey_lattice_laws() {
|
||||
let values = [
|
||||
ReaderSet::Everyone,
|
||||
ReaderSet::Only(readers(&[1])),
|
||||
ReaderSet::Only(readers(&[2])),
|
||||
ReaderSet::Only(readers(&[1, 2])),
|
||||
];
|
||||
|
||||
for a in &values {
|
||||
assert_eq!(a.join(a), *a);
|
||||
assert_eq!(a.meet(a), *a);
|
||||
for b in &values {
|
||||
assert_eq!(a.join(b), b.join(a));
|
||||
assert_eq!(a.meet(b), b.meet(a));
|
||||
assert_eq!(a.join(&a.meet(b)), *a);
|
||||
assert_eq!(a.meet(&a.join(b)), *a);
|
||||
for c in &values {
|
||||
assert_eq!(a.join(&b.join(c)), a.join(b).join(c));
|
||||
assert_eq!(a.meet(&b.meet(c)), a.meet(b).meet(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combining_inputs_intersects_authorized_readers() {
|
||||
let left = label(&[1, 2]);
|
||||
let right = label(&[1, 3]);
|
||||
let combined = left.join(&right).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"));
|
||||
let second = ConfidentialityLabel::public(RealmId::from_relay_url("wss://two"));
|
||||
|
||||
assert!(!first.can_flow_to(&second));
|
||||
assert_eq!(first.join(&second), Err(LabelError::CrossRealm));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_requires_both_audience_and_context() {
|
||||
let channel = Uuid::from_u128(1);
|
||||
let domain = conversation(&[1, 2], channel, "v1");
|
||||
let wrong_audience = ResourceLabel::conversation(realm(), channel, readers(&[1]))
|
||||
.expect("non-empty readers");
|
||||
let wrong_context =
|
||||
ResourceLabel::conversation(realm(), Uuid::from_u128(2), readers(&[1, 2]))
|
||||
.expect("non-empty readers");
|
||||
|
||||
assert!(!RuleEvaluator::read(&domain, &wrong_audience).allowed());
|
||||
assert!(!RuleEvaluator::read(&domain, &wrong_context).allowed());
|
||||
assert!(RuleEvaluator::read(&domain, &domain.resource_label()).allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_private_context_aggregates_only_owner_readable_conversations() {
|
||||
let owner = principal(1);
|
||||
let domain = ExecutionDomain::owner_private(
|
||||
principal(9),
|
||||
realm(),
|
||||
owner,
|
||||
MembershipEpoch::new("owner-v1"),
|
||||
CapabilitySet::default(),
|
||||
);
|
||||
let readable = ResourceLabel::conversation(realm(), Uuid::from_u128(1), readers(&[1, 2]))
|
||||
.expect("non-empty readers");
|
||||
let unreadable = ResourceLabel::conversation(realm(), Uuid::from_u128(2), readers(&[2, 3]))
|
||||
.expect("non-empty readers");
|
||||
|
||||
assert!(RuleEvaluator::read(&domain, &readable).allowed());
|
||||
assert!(!RuleEvaluator::read(&domain, &unreadable).allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_capabilities_are_the_three_way_intersection() {
|
||||
let bot = CapabilitySet::from_names(["buzz.read.current", "email.read", "drive.read"]);
|
||||
let requester = CapabilitySet::from_names(["buzz.read.current", "email.read"]);
|
||||
let domain = CapabilitySet::from_names(["buzz.read.current", "drive.read"]);
|
||||
|
||||
assert_eq!(
|
||||
CapabilitySet::effective(&bot, &requester, &domain),
|
||||
CapabilitySet::from_names(["buzz.read.current"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_derivation_grants_personal_tools_only_in_owner_private_work() {
|
||||
let owner = principal(1);
|
||||
let agent = principal(9);
|
||||
let policy = CapabilityPolicy::new(
|
||||
CapabilitySet::from_names(["buzz.read.current", "email.read"]),
|
||||
CapabilitySet::from_names(["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));
|
||||
assert!(owner_dm.capabilities().contains("email.read"));
|
||||
|
||||
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");
|
||||
assert!(!public.capabilities().contains("email.read"));
|
||||
}
|
||||
|
||||
#[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(
|
||||
CapabilitySet::from_names(["buzz.read.current"]),
|
||||
CapabilitySet::from_names(["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 process_reuse_requires_the_complete_domain() {
|
||||
let first = conversation(&[1, 2], Uuid::from_u128(1), "v1");
|
||||
let same = first.clone();
|
||||
let changed_epoch = conversation(&[1, 2], Uuid::from_u128(1), "v2");
|
||||
let mut state = ProcessState::default();
|
||||
|
||||
assert!(state.enter(&first).allowed());
|
||||
assert!(state.enter(&same).allowed());
|
||||
assert!(!state.enter(&changed_epoch).allowed());
|
||||
assert!(!state.enter(&first).allowed());
|
||||
state.observe(&first.resource_label());
|
||||
assert!(!state
|
||||
.publish(&first, first.audience(), first.context(), &[1; 32], None)
|
||||
.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(),
|
||||
"2ada28a5b33888f827a5845fd6e84b37e954a31dd4d0652d2468015d68e9080a"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_ids_bind_the_managed_agent_identity() {
|
||||
let first = conversation(&[1, 2], Uuid::from_u128(1), "membership:event-1");
|
||||
let second = ExecutionDomain::new(
|
||||
principal(8),
|
||||
first.audience().clone(),
|
||||
first.context().clone(),
|
||||
MembershipEpoch::new("membership:event-1"),
|
||||
first.capabilities().clone(),
|
||||
)
|
||||
.expect("same realm");
|
||||
|
||||
assert_ne!(first.id(), second.id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_shape_rejects_a_public_audience_for_a_private_context() {
|
||||
let result = ExecutionDomain::new(
|
||||
principal(9),
|
||||
ConfidentialityLabel::public(realm()),
|
||||
DomainContext::Conversation {
|
||||
realm: realm(),
|
||||
channel_id: Uuid::from_u128(1),
|
||||
},
|
||||
MembershipEpoch::new("v1"),
|
||||
CapabilitySet::default(),
|
||||
);
|
||||
|
||||
assert_eq!(result, Err(DomainError::AudienceContextMismatch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_domain_ids_do_not_depend_on_the_triggering_channel() {
|
||||
let first = ExecutionDomain::public(
|
||||
principal(9),
|
||||
realm(),
|
||||
MembershipEpoch::new("community"),
|
||||
CapabilitySet::from_names(["buzz.read.current"]),
|
||||
);
|
||||
let second = ExecutionDomain::public(
|
||||
principal(9),
|
||||
realm(),
|
||||
MembershipEpoch::new("community"),
|
||||
CapabilitySet::from_names(["buzz.read.current"]),
|
||||
);
|
||||
|
||||
assert_eq!(first.id(), second.id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compartment_profile_is_asymmetric() {
|
||||
let public = ExecutionDomain::public(
|
||||
principal(9),
|
||||
realm(),
|
||||
MembershipEpoch::new("community"),
|
||||
CapabilitySet::default(),
|
||||
);
|
||||
let restricted = conversation(&[1, 2], Uuid::from_u128(1), "membership:v1");
|
||||
let owner_private = ExecutionDomain::owner_private(
|
||||
principal(9),
|
||||
realm(),
|
||||
principal(1),
|
||||
MembershipEpoch::new("owner:v1"),
|
||||
CapabilitySet::default(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
public.compartment_profile(),
|
||||
CompartmentProfile::SharedPublic
|
||||
);
|
||||
assert_eq!(
|
||||
restricted.compartment_profile(),
|
||||
CompartmentProfile::DomainConfined
|
||||
);
|
||||
assert_eq!(
|
||||
owner_private.compartment_profile(),
|
||||
CompartmentProfile::DomainConfined
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denied_input_still_taints_an_audit_only_process() {
|
||||
let domain = conversation(&[1, 2], Uuid::from_u128(1), "v1");
|
||||
let private = ResourceLabel::owner_private(realm(), principal(1));
|
||||
assert!(!RuleEvaluator::read(&domain, &private).allowed());
|
||||
|
||||
let mut state = ProcessState::default();
|
||||
state.enter(&domain);
|
||||
state.observe(&domain.resource_label());
|
||||
state.observe(&private);
|
||||
|
||||
assert!(!state
|
||||
.publish(&domain, domain.audience(), domain.context(), &[7; 32], None,)
|
||||
.allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_publication_stays_in_the_source_context() {
|
||||
let domain = conversation(&[1, 2], Uuid::from_u128(1), "v1");
|
||||
let other_context = DomainContext::Conversation {
|
||||
realm: realm(),
|
||||
channel_id: Uuid::from_u128(2),
|
||||
};
|
||||
let mut state = ProcessState::default();
|
||||
state.enter(&domain);
|
||||
state.observe(&domain.resource_label());
|
||||
|
||||
assert!(!state
|
||||
.publish(&domain, domain.audience(), &other_context, &[7; 32], None)
|
||||
.allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_reader_sets_do_not_hide_cross_context_input() {
|
||||
let domain = conversation(&[1, 2], Uuid::from_u128(1), "v1");
|
||||
let other = ResourceLabel::conversation(realm(), Uuid::from_u128(2), readers(&[1, 2]))
|
||||
.expect("non-empty readers");
|
||||
let mut state = ProcessState::default();
|
||||
state.enter(&domain);
|
||||
state.observe(&domain.resource_label());
|
||||
state.observe(&other);
|
||||
|
||||
assert!(!state
|
||||
.publish(&domain, domain.audience(), domain.context(), &[7; 32], None)
|
||||
.allowed());
|
||||
}
|
||||
|
||||
struct AlwaysValid;
|
||||
|
||||
impl GrantSignatureVerifier for AlwaysValid {
|
||||
fn verifies(&self, _grant: &DeclassificationGrant<PendingGrant>) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declassification_is_owner_verified_and_exact() {
|
||||
let owner = principal(1);
|
||||
let domain = conversation(&[1], Uuid::from_u128(1), "v1");
|
||||
let destination = ConfidentialityLabel::public(realm());
|
||||
let destination_context = DomainContext::RealmPublic(realm());
|
||||
let content = [9; 32];
|
||||
let mut grant = DeclassificationGrant::pending(
|
||||
owner.clone(),
|
||||
domain.id(),
|
||||
destination.clone(),
|
||||
destination_context.clone(),
|
||||
content,
|
||||
)
|
||||
.verify(&owner, &AlwaysValid)
|
||||
.expect("owner-authenticated grant");
|
||||
let mut state = ProcessState::default();
|
||||
state.enter(&domain);
|
||||
state.observe(&domain.resource_label());
|
||||
|
||||
assert!(!state
|
||||
.publish(
|
||||
&domain,
|
||||
&destination,
|
||||
&destination_context,
|
||||
&[8; 32],
|
||||
Some(&mut grant),
|
||||
)
|
||||
.allowed());
|
||||
assert!(state
|
||||
.publish(
|
||||
&domain,
|
||||
&destination,
|
||||
&destination_context,
|
||||
&content,
|
||||
Some(&mut grant),
|
||||
)
|
||||
.allowed());
|
||||
assert!(!state
|
||||
.publish(
|
||||
&domain,
|
||||
&destination,
|
||||
&destination_context,
|
||||
&content,
|
||||
Some(&mut grant),
|
||||
)
|
||||
.allowed());
|
||||
state.mark_unknown();
|
||||
assert!(!state
|
||||
.publish(
|
||||
&domain,
|
||||
&destination,
|
||||
&destination_context,
|
||||
&content,
|
||||
Some(&mut grant),
|
||||
)
|
||||
.allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_input_prevents_publication() {
|
||||
let domain = conversation(&[1, 2], Uuid::from_u128(1), "v1");
|
||||
let mut state = ProcessState::default();
|
||||
state.enter(&domain);
|
||||
state.observe(&domain.resource_label());
|
||||
state.mark_unknown();
|
||||
|
||||
assert!(!state
|
||||
.publish(&domain, domain.audience(), domain.context(), &[7; 32], None,)
|
||||
.allowed());
|
||||
}
|
||||
}
|
||||
210
crates/buzz-ifc/src/policy.rs
Normal file
210
crates/buzz-ifc/src/policy.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::declassification::{DeclassificationGrant, VerifiedGrant};
|
||||
use crate::domain::{DomainContext, ExecutionDomain, ResourceContext, ResourceLabel};
|
||||
use crate::label::{ConfidentialityLabel, LabelError};
|
||||
|
||||
/// The result of evaluating one IFC rule.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub 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 fn allowed(&self) -> bool {
|
||||
self.allowed
|
||||
}
|
||||
|
||||
/// Stable explanation intended for logs and operator diagnostics.
|
||||
pub fn reason(&self) -> &'static str {
|
||||
self.reason
|
||||
}
|
||||
|
||||
/// Return `allow` or `deny` for structured logs.
|
||||
pub fn result(&self) -> &'static str {
|
||||
if self.allowed {
|
||||
"allow"
|
||||
} else {
|
||||
"deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure IFC rule evaluation.
|
||||
pub struct RuleEvaluator;
|
||||
|
||||
impl RuleEvaluator {
|
||||
/// Evaluate `read(D, x) ⇔ A(D) ⊆ R(x) ∧ ContextPolicy(D, x)`.
|
||||
pub fn read(domain: &ExecutionDomain, resource: &ResourceLabel) -> RuleDecision {
|
||||
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 and context both permit the read")
|
||||
}
|
||||
|
||||
/// Evaluate `call(D, op) ⇔ op ∈ C(D)`.
|
||||
pub fn call(domain: &ExecutionDomain, operation: &str) -> RuleDecision {
|
||||
if domain.capabilities.contains(operation) {
|
||||
RuleDecision::allow("operation is in the effective capability set")
|
||||
} else {
|
||||
RuleDecision::deny("operation is absent from the effective capability set")
|
||||
}
|
||||
}
|
||||
|
||||
/// Require every component of the execution domain to match for reuse.
|
||||
pub fn reuse(existing: &ExecutionDomain, requested: &ExecutionDomain) -> RuleDecision {
|
||||
if existing == requested {
|
||||
RuleDecision::allow("complete execution domain matches")
|
||||
} else {
|
||||
RuleDecision::deny("agent process has already entered a different domain")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct ConfinementState {
|
||||
label: Option<ConfidentialityLabel>,
|
||||
contexts: BTreeSet<ResourceContext>,
|
||||
unknown_input: bool,
|
||||
cross_realm: bool,
|
||||
}
|
||||
|
||||
impl ConfinementState {
|
||||
fn observe(&mut self, resource: &ResourceLabel) {
|
||||
self.contexts.insert(resource.context.clone());
|
||||
self.label = match self.label.take() {
|
||||
None => Some(resource.confidentiality.clone()),
|
||||
Some(existing) => match existing.join(&resource.confidentiality) {
|
||||
Ok(combined) => Some(combined),
|
||||
Err(LabelError::CrossRealm) => {
|
||||
self.cross_realm = true;
|
||||
Some(existing)
|
||||
}
|
||||
Err(LabelError::EmptyReaderSet) => Some(existing),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Conservative policy state for one actual agent process.
|
||||
///
|
||||
/// Paper: "Confinement invariant." The accumulated label and observed contexts
|
||||
/// cover every input that actually entered the process.
|
||||
///
|
||||
/// This state intentionally survives model-session invalidation. A new session
|
||||
/// does not make the surrounding process forget information it has observed.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ProcessState {
|
||||
entered_domains: Vec<ExecutionDomain>,
|
||||
confinement: ConfinementState,
|
||||
}
|
||||
|
||||
impl ProcessState {
|
||||
/// Record entry into a domain and decide whether this process is reusable.
|
||||
pub fn enter(&mut self, requested: &ExecutionDomain) -> RuleDecision {
|
||||
let decision = match self.entered_domains.as_slice() {
|
||||
[] => RuleDecision::allow("fresh process has no prior execution domain"),
|
||||
[existing] => RuleEvaluator::reuse(existing, requested),
|
||||
_ => 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 fn observe(&mut self, resource: &ResourceLabel) {
|
||||
self.confinement.observe(resource);
|
||||
}
|
||||
|
||||
/// Record input whose provenance could not be established.
|
||||
pub fn mark_unknown(&mut self) {
|
||||
self.confinement.unknown_input = true;
|
||||
}
|
||||
|
||||
/// Return how many distinct domains have entered this process.
|
||||
pub fn entered_domain_count(&self) -> usize {
|
||||
self.entered_domains.len()
|
||||
}
|
||||
|
||||
/// Paper: "Confinement invariant" and "Declassification." Evaluate
|
||||
/// publication under the process's accumulated label.
|
||||
pub fn publish(
|
||||
&self,
|
||||
source_domain: &ExecutionDomain,
|
||||
destination: &ConfidentialityLabel,
|
||||
destination_context: &DomainContext,
|
||||
content_digest: &[u8; 32],
|
||||
grant: Option<&mut DeclassificationGrant<VerifiedGrant>>,
|
||||
) -> RuleDecision {
|
||||
if self.entered_domains.len() != 1 || self.entered_domains.first() != Some(source_domain) {
|
||||
return RuleDecision::deny(
|
||||
"process state is not confined to the claimed source domain",
|
||||
);
|
||||
}
|
||||
if self.confinement.unknown_input || self.confinement.cross_realm {
|
||||
return RuleDecision::deny("process state contains unresolved input provenance");
|
||||
}
|
||||
if self
|
||||
.confinement
|
||||
.contexts
|
||||
.iter()
|
||||
.any(|context| !source_domain.context.permits(context))
|
||||
{
|
||||
return RuleDecision::deny("process state contains input from another context");
|
||||
}
|
||||
if grant.is_some_and(|grant| {
|
||||
grant.matches(
|
||||
&source_domain.id(),
|
||||
destination,
|
||||
destination_context,
|
||||
content_digest,
|
||||
)
|
||||
}) {
|
||||
return RuleDecision::allow("exact owner-authorized declassification grant matches");
|
||||
}
|
||||
if &source_domain.context != destination_context {
|
||||
return RuleDecision::deny(
|
||||
"publishing to a different context requires declassification",
|
||||
);
|
||||
}
|
||||
if !source_domain.audience.can_flow_to(destination) {
|
||||
return RuleDecision::deny("destination is broader than the execution domain");
|
||||
}
|
||||
if self
|
||||
.confinement
|
||||
.label
|
||||
.as_ref()
|
||||
.is_some_and(|label| label.can_flow_to(destination))
|
||||
{
|
||||
return RuleDecision::allow("destination is no broader than accumulated state");
|
||||
}
|
||||
RuleDecision::deny("output would widen the accumulated reader set")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user