mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
Add Buzz IFC execution domains
Signed-off-by: Jordan Mecom <jm@squareup.com>
This commit is contained in:
14
Cargo.lock
generated
14
Cargo.lock
generated
@@ -1269,6 +1269,20 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "buzz-ifc"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"buzz-core",
|
||||
"hex",
|
||||
"ifc-core",
|
||||
"nostr 0.44.7",
|
||||
"serde",
|
||||
"sha2 0.11.0",
|
||||
"thiserror 2.0.18",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "buzz-media"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -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",
|
||||
@@ -140,6 +141,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" }
|
||||
|
||||
18
crates/buzz-ifc/Cargo.toml
Normal file
18
crates/buzz-ifc/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "buzz-ifc"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Buzz execution-domain derivation for information-flow control"
|
||||
|
||||
[dependencies]
|
||||
buzz-core = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
ifc-core = { workspace = true }
|
||||
nostr = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
478
crates/buzz-ifc/src/domain.rs
Normal file
478
crates/buzz-ifc/src/domain.rs
Normal file
@@ -0,0 +1,478 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::label::{CommunityId, ConfidentialityLabel, Principal, ReaderSet};
|
||||
|
||||
/// Defines where an agent's retained state may be reused.
|
||||
///
|
||||
/// After a turn, an agent may retain conversation history, files, or cached
|
||||
/// data. Changing this context must select different retained state. Matching
|
||||
/// contexts alone do not permit reuse: the broker must match the complete
|
||||
/// [`DomainKey`], which also covers the agent, audience, membership epoch, and
|
||||
/// capabilities.
|
||||
///
|
||||
/// Audience answers who may read information; context answers which
|
||||
/// conversation history and managed memory may carry into later turns. These
|
||||
/// are independent boundaries. Two conversations with identical participants
|
||||
/// do not implicitly share state, while public conversations deliberately use
|
||||
/// one community-wide public context. Owner-private state is likewise distinct
|
||||
/// from ordinary conversation state, even within the same community.
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub(crate) enum DomainContext {
|
||||
/// Shared state for public conversations in one community.
|
||||
CommunityPublic(CommunityId),
|
||||
/// State retained for one specific restricted conversation.
|
||||
Conversation {
|
||||
/// The Buzz community containing the conversation.
|
||||
community: CommunityId,
|
||||
/// 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.
|
||||
community: CommunityId,
|
||||
/// The bot owner.
|
||||
owner: Principal,
|
||||
},
|
||||
}
|
||||
|
||||
impl DomainContext {
|
||||
/// Return the community containing this context.
|
||||
fn community(&self) -> CommunityId {
|
||||
match self {
|
||||
Self::CommunityPublic(community)
|
||||
| Self::Conversation { community, .. }
|
||||
| Self::OwnerPrivate { community, .. } => *community,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stable_hash(&self, hasher: &mut Sha256) {
|
||||
match self {
|
||||
Self::CommunityPublic(community) => {
|
||||
hash_field(hasher, b"community-public");
|
||||
hash_field(hasher, community.as_uuid().as_bytes());
|
||||
}
|
||||
Self::Conversation {
|
||||
community,
|
||||
channel_id,
|
||||
} => {
|
||||
hash_field(hasher, b"conversation");
|
||||
hash_field(hasher, community.as_uuid().as_bytes());
|
||||
hash_field(hasher, channel_id.as_bytes());
|
||||
}
|
||||
Self::OwnerPrivate { community, owner } => {
|
||||
hash_field(hasher, b"owner-private");
|
||||
hash_field(hasher, community.as_uuid().as_bytes());
|
||||
hash_field(hasher, &owner.to_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether invoking an operation can publish information outside the current
|
||||
/// execution boundary.
|
||||
///
|
||||
/// This classification belongs to trusted policy configuration, not to the
|
||||
/// agent's call request. Publication operations require both capability
|
||||
/// admission and an information-flow decision before the broker may execute
|
||||
/// them. Concrete destination policy remains the broker's responsibility.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OperationEffect {
|
||||
/// The operation cannot publish information outside the execution boundary.
|
||||
NonEgressing,
|
||||
/// The operation publishes information to a broker-resolved destination.
|
||||
Publication,
|
||||
}
|
||||
|
||||
impl OperationEffect {
|
||||
fn most_restrictive(self, other: Self) -> Self {
|
||||
match (self, other) {
|
||||
(Self::NonEgressing, Self::NonEgressing) => Self::NonEgressing,
|
||||
(Self::Publication, _) | (_, Self::Publication) => Self::Publication,
|
||||
}
|
||||
}
|
||||
|
||||
fn stable_hash(self, hasher: &mut Sha256) {
|
||||
match self {
|
||||
Self::NonEgressing => hash_field(hasher, b"non-egressing"),
|
||||
Self::Publication => hash_field(hasher, b"publication"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 classification that requires more checking: publication wins over
|
||||
/// non-egressing. 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)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
/// Buzz conversation classification after signed metadata verification.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ConversationKind {
|
||||
/// Community-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.
|
||||
///
|
||||
/// The trusted broker constructs this only after checking trigger signatures,
|
||||
/// channel binding, and the relay signature on metadata and membership.
|
||||
pub struct DomainFacts {
|
||||
/// Community selected by the trusted broker's resolved tenant context.
|
||||
pub community: CommunityId,
|
||||
/// 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 broker'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 trusted broker supplies authenticated requesters, verified conversation
|
||||
/// membership, and a relay-controlled membership epoch; the agent cannot
|
||||
/// assert any of these values. Public conversations receive the community-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 to select retained agent state. A change to any
|
||||
/// component therefore selects a different domain rather than silently reusing
|
||||
/// state that may contain information admitted under older authority. A broker
|
||||
/// can use the resulting [`DomainKey`] when it selects an ACP or remote-agent
|
||||
/// session.
|
||||
pub fn derive_execution_domain(
|
||||
facts: DomainFacts,
|
||||
policy: &CapabilityPolicy,
|
||||
) -> Result<ExecutionDomain, DerivationError> {
|
||||
if facts.requesters.is_empty() {
|
||||
return Err(DerivationError::EmptyRequesters);
|
||||
}
|
||||
|
||||
let (audience, context) = match facts.kind {
|
||||
ConversationKind::Public => (
|
||||
ConfidentialityLabel::public(facts.community),
|
||||
DomainContext::CommunityPublic(facts.community),
|
||||
),
|
||||
ConversationKind::Restricted | ConversationKind::DirectMessage => {
|
||||
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);
|
||||
let context = match &facts.owner {
|
||||
Some(owner)
|
||||
if facts.kind == ConversationKind::DirectMessage
|
||||
&& readers.len() == 1
|
||||
&& readers.contains(owner) =>
|
||||
{
|
||||
DomainContext::OwnerPrivate {
|
||||
community: facts.community,
|
||||
owner: *owner,
|
||||
}
|
||||
}
|
||||
_ => DomainContext::Conversation {
|
||||
community: facts.community,
|
||||
channel_id: facts.channel_id,
|
||||
},
|
||||
};
|
||||
let audience = ConfidentialityLabel::restricted(facts.community, readers)
|
||||
.map_err(|_| DerivationError::EmptyRestrictedAudience)?;
|
||||
(audience, context)
|
||||
}
|
||||
};
|
||||
let capabilities = effective_capabilities(&context, &facts, policy);
|
||||
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 the full stable identifier used to route turns to retained state.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// `D = (Agent, Owner, 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,
|
||||
}
|
||||
|
||||
impl ExecutionDomain {
|
||||
/// Construct a domain after the trusted broker 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.community() {
|
||||
return Err(DomainError::ContextCommunityMismatch);
|
||||
}
|
||||
if !audience_context_shape_matches(&audience, &context) {
|
||||
return Err(DomainError::AudienceContextMismatch);
|
||||
}
|
||||
Ok(Self {
|
||||
agent,
|
||||
owner,
|
||||
audience,
|
||||
context,
|
||||
epoch,
|
||||
capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the opaque key used to route turns to retained state.
|
||||
pub fn key(&self) -> DomainKey {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"buzz-ifc-domain-v6");
|
||||
hash_field(&mut hasher, &self.agent.to_bytes());
|
||||
match &self.owner {
|
||||
Some(owner) => {
|
||||
hash_field(&mut hasher, b"owner");
|
||||
hash_field(&mut hasher, &owner.to_bytes());
|
||||
}
|
||||
None => hash_field(&mut hasher, b"no-owner"),
|
||||
}
|
||||
hash_field(&mut hasher, self.audience.universe().as_uuid().as_bytes());
|
||||
hash_reader_set(self.audience.reader_set(), &mut hasher);
|
||||
self.context.stable_hash(&mut hasher);
|
||||
hash_field(&mut hasher, self.epoch.0.as_bytes());
|
||||
self.capabilities.stable_hash(&mut hasher);
|
||||
DomainKey(hex::encode(hasher.finalize()))
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_reader_set(readers: &ReaderSet, hasher: &mut Sha256) {
|
||||
match readers {
|
||||
ReaderSet::Everyone => hash_field(hasher, b"everyone"),
|
||||
ReaderSet::Only(readers) => {
|
||||
hash_field(hasher, b"only");
|
||||
hash_field(hasher, &(readers.len() as u64).to_be_bytes());
|
||||
for reader in readers {
|
||||
hash_field(hasher, &reader.to_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_field(hasher: &mut Sha256, value: &[u8]) {
|
||||
let length = u64::try_from(value.len()).unwrap_or(u64::MAX);
|
||||
hasher.update(length.to_be_bytes());
|
||||
hasher.update(value);
|
||||
}
|
||||
|
||||
fn audience_context_shape_matches(
|
||||
audience: &ConfidentialityLabel,
|
||||
context: &DomainContext,
|
||||
) -> bool {
|
||||
match (audience.reader_set(), context) {
|
||||
(ReaderSet::Everyone, DomainContext::CommunityPublic(_)) => 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 communities.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
|
||||
pub(crate) enum DomainError {
|
||||
/// The audience and retained context belong to different Buzz communities.
|
||||
#[error("execution-domain audience and context belong to different communities")]
|
||||
ContextCommunityMismatch,
|
||||
/// Public, conversation, and owner-private contexts require their
|
||||
/// corresponding audience shape.
|
||||
#[error("execution-domain audience does not match its context")]
|
||||
AudienceContextMismatch,
|
||||
}
|
||||
53
crates/buzz-ifc/src/label.rs
Normal file
53
crates/buzz-ifc/src/label.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
pub use buzz_core::CommunityId;
|
||||
pub use ifc_core::LabelError;
|
||||
use nostr::{secp256k1::XOnlyPublicKey, PublicKey};
|
||||
use serde::Serialize;
|
||||
|
||||
/// A Buzz principal represented by a validated, normalized Nostr public key.
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Principal(XOnlyPublicKey);
|
||||
|
||||
impl Principal {
|
||||
/// Parse and normalize a hexadecimal Nostr public key.
|
||||
pub fn from_hex(value: &str) -> Result<Self, PrincipalError> {
|
||||
value
|
||||
.parse::<XOnlyPublicKey>()
|
||||
.map(Self)
|
||||
.map_err(|_| PrincipalError::InvalidPublicKey)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let key = value
|
||||
.xonly()
|
||||
.map_err(|_| PrincipalError::InvalidPublicKey)?;
|
||||
Ok(Self(key))
|
||||
}
|
||||
|
||||
/// Return the normalized hexadecimal public key.
|
||||
pub fn to_hex(&self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn to_bytes(self) -> [u8; 32] {
|
||||
self.0.serialize()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 reader-set confidentiality label within one Buzz community.
|
||||
pub type ConfidentialityLabel = ifc_core::ConfidentialityLabel<CommunityId, Principal>;
|
||||
|
||||
pub(crate) type ReaderSet = ifc_core::ReaderSet<Principal>;
|
||||
19
crates/buzz-ifc/src/lib.rs
Normal file
19
crates/buzz-ifc/src/lib.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
//! Buzz-specific execution domains built on `ifc-core`.
|
||||
//!
|
||||
//! The trusted broker verifies events, conversation metadata, membership, and
|
||||
//! requester identities before constructing [`DomainFacts`]. This crate
|
||||
//! deterministically derives an [`ExecutionDomain`] from those facts. The
|
||||
//! domain binds the executing agent, authorized audience, retained context,
|
||||
//! membership epoch, and effective capabilities into one stable routing key.
|
||||
|
||||
mod domain;
|
||||
mod label;
|
||||
|
||||
pub use domain::{
|
||||
derive_execution_domain, CapabilityPolicy, CapabilitySet, ConversationKind, DerivationError,
|
||||
DomainFacts, DomainKey, ExecutionDomain, MembershipEpoch, OperationEffect,
|
||||
};
|
||||
pub use label::{CommunityId, ConfidentialityLabel, LabelError, Principal, PrincipalError};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
345
crates/buzz-ifc/src/tests.rs
Normal file
345
crates/buzz-ifc/src/tests.rs
Normal file
@@ -0,0 +1,345 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use nostr::Keys;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::*;
|
||||
use crate::domain::{DomainContext, DomainError};
|
||||
|
||||
fn principal(value: u8) -> Principal {
|
||||
let keys = Keys::parse(&format!("{value:064x}")).expect("test secret key");
|
||||
Principal::from_public_key(&keys.public_key()).expect("valid test principal")
|
||||
}
|
||||
|
||||
fn readers(values: &[u8]) -> BTreeSet<Principal> {
|
||||
values.iter().copied().map(principal).collect()
|
||||
}
|
||||
|
||||
fn community() -> CommunityId {
|
||||
CommunityId::from_uuid(Uuid::from_u128(1))
|
||||
}
|
||||
|
||||
fn other_community() -> CommunityId {
|
||||
CommunityId::from_uuid(Uuid::from_u128(2))
|
||||
}
|
||||
|
||||
fn label(values: &[u8]) -> ConfidentialityLabel {
|
||||
ConfidentialityLabel::restricted(community(), 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),
|
||||
("buzz.reply", OperationEffect::Publication),
|
||||
])
|
||||
}
|
||||
|
||||
fn conversation(values: &[u8], channel_id: Uuid, epoch: &str) -> ExecutionDomain {
|
||||
ExecutionDomain::new(
|
||||
principal(9),
|
||||
Some(principal(1)),
|
||||
label(values),
|
||||
DomainContext::Conversation {
|
||||
community: community(),
|
||||
channel_id,
|
||||
},
|
||||
MembershipEpoch::new(epoch),
|
||||
conversation_capabilities(),
|
||||
)
|
||||
.expect("coherent conversation domain")
|
||||
}
|
||||
|
||||
/// Reader identities enter the IFC lattice and domain key, so accepting an
|
||||
/// arbitrary 32-byte string would let malformed identities become distinct
|
||||
/// policy principals.
|
||||
#[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)
|
||||
);
|
||||
}
|
||||
|
||||
let lowercase = principal(1).to_hex();
|
||||
assert_eq!(
|
||||
Principal::from_hex(&lowercase.to_ascii_uppercase())
|
||||
.expect("uppercase hexadecimal principal")
|
||||
.to_hex(),
|
||||
lowercase
|
||||
);
|
||||
}
|
||||
|
||||
/// The Buzz specialization must preserve the generic reader-set lattice: the
|
||||
/// combined label may flow only to readers authorized for every input, and it
|
||||
/// must never cross communities.
|
||||
#[test]
|
||||
fn buzz_labels_intersect_readers_and_never_cross_communities() {
|
||||
let combined = label(&[1, 2])
|
||||
.join(&label(&[1, 3]))
|
||||
.expect("same community");
|
||||
assert!(combined.can_flow_to(&label(&[1])));
|
||||
assert!(!combined.can_flow_to(&label(&[1, 2])));
|
||||
|
||||
let other = ConfidentialityLabel::public(other_community());
|
||||
assert!(!ConfidentialityLabel::public(community()).can_flow_to(&other));
|
||||
assert_eq!(
|
||||
ConfidentialityLabel::public(community()).join(&other),
|
||||
Err(LabelError::CrossUniverse)
|
||||
);
|
||||
}
|
||||
|
||||
/// Every capability ceiling must admit an operation. Conflicting effect
|
||||
/// declarations keep the classification that requires more checking, so an
|
||||
/// egressing operation cannot become an ordinary call.
|
||||
#[test]
|
||||
fn capability_intersection_keeps_the_most_restrictive_effect() {
|
||||
let bot = CapabilitySet::from_operations([
|
||||
("buzz.post", OperationEffect::Publication),
|
||||
("email.read", OperationEffect::NonEgressing),
|
||||
]);
|
||||
let requester = CapabilitySet::from_operations([("buzz.post", OperationEffect::Publication)]);
|
||||
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)])
|
||||
);
|
||||
}
|
||||
|
||||
/// Appendix B's domain mapping gives personal capabilities only to a genuine
|
||||
/// two-party owner/agent DM. Shared conversations must use the narrower
|
||||
/// conversation ceiling even when the owner initiated the work.
|
||||
#[test]
|
||||
fn derivation_grants_personal_capabilities_only_to_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 {
|
||||
community: community(),
|
||||
channel_id: Uuid::from_u128(1),
|
||||
kind: ConversationKind::DirectMessage,
|
||||
epoch: MembershipEpoch::new("membership:v1"),
|
||||
members: BTreeSet::from([agent, owner]),
|
||||
executing_agent: agent,
|
||||
requesters: BTreeSet::from([owner]),
|
||||
system_principal: None,
|
||||
owner: Some(owner),
|
||||
},
|
||||
&policy,
|
||||
)
|
||||
.expect("owner DM domain");
|
||||
assert!(matches!(
|
||||
&owner_dm.context,
|
||||
DomainContext::OwnerPrivate {
|
||||
owner: candidate,
|
||||
..
|
||||
} if candidate == &owner
|
||||
));
|
||||
assert_eq!(
|
||||
owner_dm.capabilities,
|
||||
non_egressing(["buzz.read.current", "email.read"])
|
||||
);
|
||||
assert_eq!(owner_dm.audience, label(&[1]));
|
||||
|
||||
let public = derive_execution_domain(
|
||||
DomainFacts {
|
||||
community: community(),
|
||||
channel_id: Uuid::from_u128(2),
|
||||
kind: ConversationKind::Public,
|
||||
epoch: MembershipEpoch::new("community:v1"),
|
||||
members: BTreeSet::new(),
|
||||
executing_agent: agent,
|
||||
requesters: BTreeSet::from([owner]),
|
||||
system_principal: None,
|
||||
owner: Some(owner),
|
||||
},
|
||||
&policy,
|
||||
)
|
||||
.expect("public domain");
|
||||
assert_eq!(
|
||||
&public.context,
|
||||
&DomainContext::CommunityPublic(community())
|
||||
);
|
||||
assert!(public.audience.is_public());
|
||||
assert_eq!(public.capabilities, non_egressing(["buzz.read.current"]));
|
||||
}
|
||||
|
||||
/// Restricted-domain derivation fails closed when authenticated requesters or
|
||||
/// the executing agent are absent from the verified membership snapshot.
|
||||
#[test]
|
||||
fn restricted_derivation_requires_verified_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 {
|
||||
community: community(),
|
||||
channel_id: Uuid::from_u128(1),
|
||||
kind: ConversationKind::Restricted,
|
||||
epoch: MembershipEpoch::new("membership:v1"),
|
||||
members,
|
||||
executing_agent: agent,
|
||||
requesters,
|
||||
system_principal: None,
|
||||
owner: Some(owner),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
derive_execution_domain(facts(BTreeSet::new(), BTreeSet::new()), &policy),
|
||||
Err(DerivationError::EmptyRequesters)
|
||||
);
|
||||
assert_eq!(
|
||||
derive_execution_domain(
|
||||
facts(BTreeSet::from([owner]), BTreeSet::from([owner]),),
|
||||
&policy,
|
||||
),
|
||||
Err(DerivationError::AgentNotMember)
|
||||
);
|
||||
assert_eq!(
|
||||
derive_execution_domain(
|
||||
facts(BTreeSet::from([owner, agent]), BTreeSet::from([outsider]),),
|
||||
&policy,
|
||||
),
|
||||
Err(DerivationError::RequesterNotMember)
|
||||
);
|
||||
}
|
||||
|
||||
/// The domain identifier is a security boundary for retained-state reuse. Every
|
||||
/// field in `D = (agent, owner, audience, context, epoch, capabilities)` must
|
||||
/// change the routing key independently.
|
||||
#[test]
|
||||
fn every_domain_component_changes_the_routing_key() {
|
||||
let base = conversation(&[1, 2], Uuid::from_u128(1), "v1");
|
||||
let build = |agent, owner, audience, channel_id, epoch, capabilities| {
|
||||
ExecutionDomain::new(
|
||||
agent,
|
||||
owner,
|
||||
audience,
|
||||
DomainContext::Conversation {
|
||||
community: community(),
|
||||
channel_id,
|
||||
},
|
||||
MembershipEpoch::new(epoch),
|
||||
capabilities,
|
||||
)
|
||||
.expect("coherent domain")
|
||||
};
|
||||
let ids = [
|
||||
base.key(),
|
||||
build(
|
||||
principal(8),
|
||||
Some(principal(1)),
|
||||
label(&[1, 2]),
|
||||
Uuid::from_u128(1),
|
||||
"v1",
|
||||
conversation_capabilities(),
|
||||
)
|
||||
.key(),
|
||||
build(
|
||||
principal(9),
|
||||
None,
|
||||
label(&[1, 2]),
|
||||
Uuid::from_u128(1),
|
||||
"v1",
|
||||
conversation_capabilities(),
|
||||
)
|
||||
.key(),
|
||||
build(
|
||||
principal(9),
|
||||
Some(principal(1)),
|
||||
label(&[1, 3]),
|
||||
Uuid::from_u128(1),
|
||||
"v1",
|
||||
conversation_capabilities(),
|
||||
)
|
||||
.key(),
|
||||
build(
|
||||
principal(9),
|
||||
Some(principal(1)),
|
||||
label(&[1, 2]),
|
||||
Uuid::from_u128(2),
|
||||
"v1",
|
||||
conversation_capabilities(),
|
||||
)
|
||||
.key(),
|
||||
build(
|
||||
principal(9),
|
||||
Some(principal(1)),
|
||||
label(&[1, 2]),
|
||||
Uuid::from_u128(1),
|
||||
"v2",
|
||||
conversation_capabilities(),
|
||||
)
|
||||
.key(),
|
||||
build(
|
||||
principal(9),
|
||||
Some(principal(1)),
|
||||
label(&[1, 2]),
|
||||
Uuid::from_u128(1),
|
||||
"v1",
|
||||
non_egressing(["buzz.read.current"]),
|
||||
)
|
||||
.key(),
|
||||
];
|
||||
|
||||
assert_eq!(ids.iter().collect::<BTreeSet<_>>().len(), ids.len());
|
||||
}
|
||||
|
||||
/// This fixed value detects accidental changes to the canonical domain-key
|
||||
/// encoding, which would otherwise strand or incorrectly reuse retained state.
|
||||
#[test]
|
||||
fn domain_key_has_a_canonical_golden_value() {
|
||||
let domain = conversation(&[1, 2], Uuid::from_u128(1), "membership:event-1");
|
||||
assert_eq!(
|
||||
domain.key().as_str(),
|
||||
"c83fb1c188109526fca87b73297eb00f9bf0b6545e36c6f0b8d3fb2771ce7962"
|
||||
);
|
||||
}
|
||||
|
||||
/// Audience shape and retained context are independent inputs but must remain
|
||||
/// coherent: public labels select public context, while restricted labels
|
||||
/// select a conversation or exact owner-private context.
|
||||
#[test]
|
||||
fn domains_reject_incoherent_audience_context_pairs() {
|
||||
assert_eq!(
|
||||
ExecutionDomain::new(
|
||||
principal(9),
|
||||
Some(principal(1)),
|
||||
ConfidentialityLabel::public(community()),
|
||||
DomainContext::CommunityPublic(other_community()),
|
||||
MembershipEpoch::new("v1"),
|
||||
CapabilitySet::default(),
|
||||
),
|
||||
Err(DomainError::ContextCommunityMismatch)
|
||||
);
|
||||
assert_eq!(
|
||||
ExecutionDomain::new(
|
||||
principal(9),
|
||||
Some(principal(1)),
|
||||
ConfidentialityLabel::public(community()),
|
||||
DomainContext::Conversation {
|
||||
community: community(),
|
||||
channel_id: Uuid::from_u128(2),
|
||||
},
|
||||
MembershipEpoch::new("v1"),
|
||||
CapabilitySet::default(),
|
||||
),
|
||||
Err(DomainError::AudienceContextMismatch)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user