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