From 3ae37f3644624af0e43e4dec503878f33ae5e952 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:02:02 -0500 Subject: [PATCH 1/2] feat(auth): expose client status and runtime reachability Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-core/src/client_binding_status.rs | 1289 ++++++++ crates/buzz-core/src/kind.rs | 13 + crates/buzz-core/src/lib.rs | 2 + crates/buzz-db/src/client_status.rs | 710 +++++ crates/buzz-db/src/lib.rs | 192 +- crates/buzz-db/src/migration.rs | 16 +- crates/buzz-db/src/public_projection.rs | 2592 +++++++++++++++++ crates/buzz-db/src/push.rs | 44 +- crates/buzz-relay/src/api/admin/mod.rs | 13 +- crates/buzz-relay/src/api/mod.rs | 11 +- .../src/authorization_runtime/mod.rs | 22 + .../src/authorization_runtime/status.rs | 1713 +++++++++++ .../authorization_runtime/status/postgres.rs | 155 + crates/buzz-relay/src/corporate_identity.rs | 15 +- crates/buzz-relay/src/handlers/auth.rs | 259 +- crates/buzz-relay/src/lib.rs | 5 + crates/buzz-relay/src/main.rs | 70 +- crates/buzz-relay/src/mesh_boot.rs | 50 +- crates/buzz-relay/src/nip11.rs | 332 +++ crates/buzz-relay/src/push_runtime.rs | 15 +- desktop/src-tauri/src/commands/profile.rs | 144 +- .../0044_client_status_fanout_withdrawals.sql | 21 + ..._identity_public_projection_retirement.sql | 80 + 23 files changed, 7646 insertions(+), 117 deletions(-) create mode 100644 crates/buzz-core/src/client_binding_status.rs create mode 100644 crates/buzz-db/src/client_status.rs create mode 100644 crates/buzz-db/src/public_projection.rs create mode 100644 crates/buzz-relay/src/authorization_runtime/mod.rs create mode 100644 crates/buzz-relay/src/authorization_runtime/status.rs create mode 100644 crates/buzz-relay/src/authorization_runtime/status/postgres.rs create mode 100644 migrations/0044_client_status_fanout_withdrawals.sql create mode 100644 migrations/0045_identity_public_projection_retirement.sql diff --git a/crates/buzz-core/src/client_binding_status.rs b/crates/buzz-core/src/client_binding_status.rs new file mode 100644 index 0000000000..6428938d23 --- /dev/null +++ b/crates/buzz-core/src/client_binding_status.rs @@ -0,0 +1,1289 @@ +//! Relay-authenticated client binding status. +//! +//! Kind `24244` is a short-lived, ephemeral envelope whose JSON content names +//! the exact authorization domain and event-author key to which presentation +//! applies. The status is display-only: it is not identity proof, membership, +//! an authorization decision, or an access lease. Consumers must obtain a +//! value through +//! [`validate_client_binding_status_event`](crate::client_binding_status::validate_client_binding_status_event) +//! or [`ClientBindingStatusTracker`](crate::client_binding_status::ClientBindingStatusTracker) +//! rather than mutable profile fields or client-supplied claims. + +use std::fmt; + +use nostr::{Event, EventBuilder, EventId, Keys, Kind, PublicKey, Timestamp}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +use crate::{kind::KIND_CLIENT_BINDING_STATUS, verify_event, CommunityId}; + +/// Wire version accepted by this module. +pub const CLIENT_BINDING_STATUS_VERSION: u64 = 1; + +/// Maximum lifetime of a client binding status, in seconds. +/// +/// Producers may choose a shorter lifetime. A longer lifetime fails closed. +pub const MAX_CLIENT_BINDING_STATUS_LIFETIME_SECS: u64 = 300; + +/// Explicit client-status clock-skew allowance. +/// +/// Status is presentation-only and issued from centrally injected relay time, +/// so the portable profile permits no future issue-time skew. +pub const CLIENT_BINDING_STATUS_CLOCK_SKEW_SECS: u64 = 0; + +/// Maximum encoded payload length. +pub const MAX_CLIENT_BINDING_STATUS_PAYLOAD_BYTES: usize = 4096; + +/// Maximum encoded length of the opaque policy revision. +pub const MAX_CLIENT_BINDING_STATUS_POLICY_VERSION_BYTES: usize = 256; + +/// Maximum encoded length of the optional privacy-approved display label. +pub const MAX_CLIENT_BINDING_STATUS_LABEL_BYTES: usize = 80; + +/// Server-selected, display-only status disposition. +/// +/// V1 deliberately exposes only current verification or an opaque withdrawal. +/// Revocation, rotation, lineage, retirement, and other lifecycle causes are +/// durable server-side history and are never part of the client contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ClientBindingStatusDisposition { + /// The client may display current verification while the envelope is fresh. + DisplayCurrent, + /// Clear current presentation and advance only the scoped replay floor. + Withdrawn, +} + +/// A validated v1 client binding status. +/// +/// Construction proves that one correctly signed event from the expected +/// relay matched the caller's server-resolved authorization domain and exact +/// message-author key at the injected validation time. It does not grant or +/// deny any capability. +#[derive(Clone, PartialEq, Eq)] +pub struct ClientBindingStatusV1 { + event_id: EventId, + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + binding_version: Option, + policy_version: Option, + status_revision: u64, + issued_at: u64, + fresh_until: u64, + disposition: ClientBindingStatusDisposition, + display_label: Option, +} + +impl fmt::Debug for ClientBindingStatusV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientBindingStatusV1") + .field("event_id", &"[redacted]") + .field("authorization_domain", &"[redacted]") + .field("event_author_pubkey", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("status_revision", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("fresh_until", &"[redacted]") + .field("disposition", &self.disposition) + .field( + "display_label", + &self.display_label.as_ref().map(|_| "[redacted]"), + ) + .finish() + } +} + +impl ClientBindingStatusV1 { + /// Signed event identifier used for equal-revision idempotency. + pub const fn event_id(&self) -> EventId { + self.event_id + } + + /// Server-resolved authorization domain for which this status is valid. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Exact event-author key whose messages may consume this status. + pub const fn event_author_pubkey(&self) -> PublicKey { + self.event_author_pubkey + } + + /// Positive version of the identity-to-key binding. + pub const fn binding_version(&self) -> Option { + self.binding_version + } + + /// Opaque provider-neutral policy revision. + pub fn policy_version(&self) -> Option<&str> { + self.policy_version.as_deref() + } + + /// Positive, monotonically increasing revision for this scoped status. + pub const fn status_revision(&self) -> u64 { + self.status_revision + } + + /// Relay issue time as Unix seconds. + pub const fn issued_at(&self) -> u64 { + self.issued_at + } + + /// Exclusive freshness bound as Unix seconds. + pub const fn fresh_until(&self) -> u64 { + self.fresh_until + } + + /// Server-selected, display-only disposition. + pub const fn disposition(&self) -> ClientBindingStatusDisposition { + self.disposition + } + + /// Optional privacy-approved label for current presentation. + pub fn display_label(&self) -> Option<&str> { + self.display_label.as_deref() + } + + /// Returns `true` only for an active, current presentation disposition. + /// + /// Freshness and relay authentication have already been checked by + /// [`validate_client_binding_status_event`]. This method must not be used + /// for access control. + pub const fn displays_current_binding(&self) -> bool { + matches!( + self.disposition, + ClientBindingStatusDisposition::DisplayCurrent + ) + } +} + +/// Validated producer input for one v1 client binding status. +/// +/// This is a serialization/signing input only. It intentionally carries no +/// authorization context, capability set, membership state, or access lease. +pub struct ClientBindingStatusInputV1 { + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + binding_version: Option, + policy_version: Option, + status_revision: u64, + issued_at: u64, + fresh_until: u64, + disposition: ClientBindingStatusDisposition, + display_label: Option, +} + +impl fmt::Debug for ClientBindingStatusInputV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientBindingStatusInputV1") + .field("authorization_domain", &"[redacted]") + .field("event_author_pubkey", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("status_revision", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("fresh_until", &"[redacted]") + .field("disposition", &self.disposition) + .field( + "display_label", + &self.display_label.as_ref().map(|_| "[redacted]"), + ) + .finish() + } +} + +impl ClientBindingStatusInputV1 { + /// Construct bounded, provider-neutral current-status input. + #[allow(clippy::too_many_arguments)] + pub fn current( + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + binding_version: u64, + policy_version: impl Into, + status_revision: u64, + issued_at: u64, + fresh_until: u64, + display_label: Option, + ) -> Result { + let value = Self { + authorization_domain, + event_author_pubkey, + binding_version: Some(binding_version), + policy_version: Some(policy_version.into()), + status_revision, + issued_at, + fresh_until, + disposition: ClientBindingStatusDisposition::DisplayCurrent, + display_label, + }; + validate_payload_fields( + value.authorization_domain, + value.binding_version, + value.policy_version.as_deref(), + value.status_revision, + value.issued_at, + value.fresh_until, + value.disposition, + value.display_label.as_deref(), + )?; + Ok(value) + } + + /// Construct an opaque withdrawal carrying no binding or lifecycle data. + pub fn withdrawn( + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + status_revision: u64, + issued_at: u64, + fresh_until: u64, + ) -> Result { + let value = Self { + authorization_domain, + event_author_pubkey, + binding_version: None, + policy_version: None, + status_revision, + issued_at, + fresh_until, + disposition: ClientBindingStatusDisposition::Withdrawn, + display_label: None, + }; + validate_payload_fields( + value.authorization_domain, + value.binding_version, + value.policy_version.as_deref(), + value.status_revision, + value.issued_at, + value.fresh_until, + value.disposition, + value.display_label.as_deref(), + )?; + Ok(value) + } + + /// Sign this status with the relay key advertised through NIP-11 `self`. + pub fn sign_with_relay_keys( + self, + relay_keys: &Keys, + ) -> Result { + let wire = WireClientBindingStatusV1 { + version: CLIENT_BINDING_STATUS_VERSION, + authorization_domain: self.authorization_domain.as_uuid().to_string(), + event_author_pubkey: self.event_author_pubkey.to_hex(), + status_revision: self.status_revision, + issued_at: self.issued_at, + fresh_until: self.fresh_until, + status: self.disposition, + binding_version: self.binding_version, + policy_version: self.policy_version, + display_label: self.display_label, + }; + let content = serde_json::to_string(&wire) + .map_err(|_| ClientBindingStatusBuildError::Serialization)?; + if content.len() > MAX_CLIENT_BINDING_STATUS_PAYLOAD_BYTES { + return Err(ClientBindingStatusBuildError::PayloadTooLarge); + } + EventBuilder::new(Kind::Custom(KIND_CLIENT_BINDING_STATUS as u16), content) + .tags([]) + .custom_created_at(Timestamp::from(wire.issued_at)) + .sign_with_keys(relay_keys) + .map_err(|_| ClientBindingStatusBuildError::Signing) + } +} + +#[derive(Deserialize)] +struct VersionHeader { + version: u64, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct WireClientBindingStatusV1 { + version: u64, + authorization_domain: String, + event_author_pubkey: String, + status_revision: u64, + issued_at: u64, + fresh_until: u64, + status: ClientBindingStatusDisposition, + #[serde(default, skip_serializing_if = "Option::is_none")] + binding_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + policy_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + display_label: Option, +} + +/// Validate and authenticate a relay-issued v1 client binding status event. +/// +/// `trusted_relay_pubkey`, `expected_authorization_domain`, and +/// `expected_event_author_pubkey` must come from connection or message context, +/// never from the status payload. `now` is injected Unix time; the exclusive +/// expiry rule means `now == fresh_until` is expired. Future-issued statuses +/// are rejected under [`CLIENT_BINDING_STATUS_CLOCK_SKEW_SECS`]. +pub fn validate_client_binding_status_event( + event: &Event, + trusted_relay_pubkey: &PublicKey, + expected_authorization_domain: CommunityId, + expected_event_author_pubkey: &PublicKey, + now: u64, +) -> Result { + if event.kind.as_u16() as u32 != KIND_CLIENT_BINDING_STATUS { + return Err(ClientBindingStatusError::WrongKind); + } + if event.content.len() > MAX_CLIENT_BINDING_STATUS_PAYLOAD_BYTES { + return Err(ClientBindingStatusError::PayloadTooLarge); + } + verify_event(event).map_err(|_| ClientBindingStatusError::UnauthenticatedEvent)?; + if event.pubkey != *trusted_relay_pubkey { + return Err(ClientBindingStatusError::UnexpectedRelay); + } + if !event.tags.is_empty() { + return Err(ClientBindingStatusError::UnexpectedTags); + } + + let header: VersionHeader = serde_json::from_str(&event.content) + .map_err(|_| ClientBindingStatusError::MalformedPayload)?; + if header.version != CLIENT_BINDING_STATUS_VERSION { + return Err(ClientBindingStatusError::UnsupportedVersion); + } + + let wire: WireClientBindingStatusV1 = serde_json::from_str(&event.content) + .map_err(|_| ClientBindingStatusError::MalformedPayload)?; + + let authorization_domain = Uuid::parse_str(&wire.authorization_domain) + .map_err(|_| ClientBindingStatusError::InvalidAuthorizationDomain)?; + if authorization_domain.is_nil() + || authorization_domain.to_string() != wire.authorization_domain + { + return Err(ClientBindingStatusError::InvalidAuthorizationDomain); + } + let authorization_domain = CommunityId::from_uuid(authorization_domain); + if authorization_domain != expected_authorization_domain { + return Err(ClientBindingStatusError::AuthorizationDomainMismatch); + } + + if wire.event_author_pubkey.len() != 64 + || !wire + .event_author_pubkey + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ClientBindingStatusError::InvalidEventAuthorPubkey); + } + let event_author_pubkey = PublicKey::from_hex(&wire.event_author_pubkey) + .map_err(|_| ClientBindingStatusError::InvalidEventAuthorPubkey)?; + if event_author_pubkey != *expected_event_author_pubkey { + return Err(ClientBindingStatusError::EventAuthorMismatch); + } + + let disposition = wire.status; + let binding_version = wire.binding_version; + let policy_version = wire.policy_version; + let display_label = wire.display_label; + + validate_payload_fields( + authorization_domain, + binding_version, + policy_version.as_deref(), + wire.status_revision, + wire.issued_at, + wire.fresh_until, + disposition, + display_label.as_deref(), + )?; + if event.created_at.as_secs() != wire.issued_at { + return Err(ClientBindingStatusError::EventTimeMismatch); + } + if wire.issued_at > now.saturating_add(CLIENT_BINDING_STATUS_CLOCK_SKEW_SECS) { + return Err(ClientBindingStatusError::NotYetValid); + } + if now >= wire.fresh_until { + return Err(ClientBindingStatusError::Expired); + } + + Ok(ClientBindingStatusV1 { + event_id: event.id, + authorization_domain, + event_author_pubkey, + binding_version, + policy_version, + status_revision: wire.status_revision, + issued_at: wire.issued_at, + fresh_until: wire.fresh_until, + disposition, + display_label, + }) +} + +#[allow(clippy::too_many_arguments)] +fn validate_payload_fields( + authorization_domain: CommunityId, + binding_version: Option, + policy_version: Option<&str>, + status_revision: u64, + issued_at: u64, + fresh_until: u64, + disposition: ClientBindingStatusDisposition, + display_label: Option<&str>, +) -> Result<(), ClientBindingStatusError> { + if authorization_domain.as_uuid().is_nil() { + return Err(ClientBindingStatusError::InvalidAuthorizationDomain); + } + match disposition { + ClientBindingStatusDisposition::DisplayCurrent => { + if binding_version.is_none_or(|version| version == 0) { + return Err(ClientBindingStatusError::InvalidBindingVersion); + } + let Some(policy_version) = policy_version else { + return Err(ClientBindingStatusError::InvalidPolicyVersion); + }; + if policy_version.is_empty() + || policy_version.len() > MAX_CLIENT_BINDING_STATUS_POLICY_VERSION_BYTES + || policy_version.trim() != policy_version + || policy_version.chars().any(char::is_control) + { + return Err(ClientBindingStatusError::InvalidPolicyVersion); + } + } + ClientBindingStatusDisposition::Withdrawn => { + if binding_version.is_some() || policy_version.is_some() || display_label.is_some() { + return Err(ClientBindingStatusError::WithdrawalContainsCurrentState); + } + } + } + if status_revision == 0 { + return Err(ClientBindingStatusError::InvalidStatusRevision); + } + if issued_at == 0 { + return Err(ClientBindingStatusError::InvalidIssueTime); + } + if fresh_until <= issued_at { + return Err(ClientBindingStatusError::InvalidFreshnessBound); + } + if fresh_until - issued_at > MAX_CLIENT_BINDING_STATUS_LIFETIME_SECS { + return Err(ClientBindingStatusError::FreshnessWindowTooLong); + } + validate_display_label(disposition, display_label) +} + +fn validate_display_label( + disposition: ClientBindingStatusDisposition, + display_label: Option<&str>, +) -> Result<(), ClientBindingStatusError> { + let Some(label) = display_label else { + return Ok(()); + }; + if disposition != ClientBindingStatusDisposition::DisplayCurrent + || label.is_empty() + || label.len() > MAX_CLIENT_BINDING_STATUS_LABEL_BYTES + || label.trim() != label + || label.chars().any(char::is_control) + { + return Err(ClientBindingStatusError::InvalidDisplayLabel); + } + Ok(()) +} + +/// One accepted high-water update from [`ClientBindingStatusTracker`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientBindingStatusUpdate { + /// A strictly newer revision replaced presentation state. + Accepted, + /// The exact same signed event was observed again. + Duplicate, +} + +#[derive(Clone, Copy)] +struct StatusHighWater { + revision: u64, + event_id: EventId, +} + +/// Client-side, scope-keyed status revision fold. +/// +/// The tracker authenticates every event against one trusted relay/domain/ +/// author tuple. A lower revision or a different event at the same revision is +/// rejected. Expiry and disconnect clear presentation while retaining the +/// high-water mark, so a previously seen envelope cannot restore a badge. +pub struct ClientBindingStatusTracker { + trusted_relay_pubkey: PublicKey, + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + high_water: Option, + status: Option, +} + +impl fmt::Debug for ClientBindingStatusTracker { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientBindingStatusTracker") + .field("trusted_relay_pubkey", &"[redacted]") + .field("authorization_domain", &"[redacted]") + .field("event_author_pubkey", &"[redacted]") + .field("high_water", &self.high_water.map(|_| "[redacted]")) + .field("status", &self.status.as_ref().map(|_| "[redacted]")) + .finish() + } +} + +impl ClientBindingStatusTracker { + /// Start an empty fold for one trusted relay/domain/author scope. + pub const fn new( + trusted_relay_pubkey: PublicKey, + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + ) -> Self { + Self { + trusted_relay_pubkey, + authorization_domain, + event_author_pubkey, + high_water: None, + status: None, + } + } + + /// Authenticate and fold one signed status event at injected time `now`. + pub fn accept( + &mut self, + event: &Event, + now: u64, + ) -> Result { + let status = validate_client_binding_status_event( + event, + &self.trusted_relay_pubkey, + self.authorization_domain, + &self.event_author_pubkey, + now, + )?; + if let Some(high_water) = self.high_water { + if status.status_revision < high_water.revision { + return Err(ClientBindingStatusFoldError::LowerRevisionReplay); + } + if status.status_revision == high_water.revision { + if status.event_id != high_water.event_id { + return Err(ClientBindingStatusFoldError::ConflictingEqualRevision); + } + return Ok(ClientBindingStatusUpdate::Duplicate); + } + } + self.high_water = Some(StatusHighWater { + revision: status.status_revision, + event_id: status.event_id, + }); + self.status = status.displays_current_binding().then_some(status); + Ok(ClientBindingStatusUpdate::Accepted) + } + + /// Return the fresh accepted status, clearing expired presentation. + pub fn status(&mut self, now: u64) -> Option<&ClientBindingStatusV1> { + if self + .status + .as_ref() + .is_some_and(|status| now >= status.fresh_until) + { + self.status = None; + } + self.status.as_ref() + } + + /// Return only a fresh status allowed to display current verification. + pub fn current_presentation(&mut self, now: u64) -> Option<&ClientBindingStatusV1> { + self.status(now) + .filter(|status| status.displays_current_binding()) + } + + /// Clear presentation on relay disconnect while retaining replay defense. + pub fn on_disconnect(&mut self) { + self.status = None; + } + + /// Replace the trusted scope and clear both presentation and revision state. + /// + /// Call this on relay-identity, authorization-domain, or event-author + /// changes. Evidence from the old scope is never carried into the new one. + pub fn change_scope( + &mut self, + trusted_relay_pubkey: PublicKey, + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + ) { + self.trusted_relay_pubkey = trusted_relay_pubkey; + self.authorization_domain = authorization_domain; + self.event_author_pubkey = event_author_pubkey; + self.high_water = None; + self.status = None; + } + + /// Highest revision accepted for the current scope. + pub const fn high_water_revision(&self) -> Option { + match self.high_water { + Some(value) => Some(value.revision), + None => None, + } + } +} + +/// Fail-closed client binding status validation error. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[non_exhaustive] +pub enum ClientBindingStatusError { + /// The event did not use the dedicated ephemeral status kind. + #[error("client binding status event has the wrong kind")] + WrongKind, + /// The event body exceeded the public bound. + #[error("client binding status payload is too large")] + PayloadTooLarge, + /// The event ID or Schnorr signature was invalid. + #[error("client binding status event is not authenticated")] + UnauthenticatedEvent, + /// The signer did not match the relay key established by the connection. + #[error("client binding status signer is not the trusted relay")] + UnexpectedRelay, + /// Status events must not carry tags or private indexing material. + #[error("client binding status event contains unexpected tags")] + UnexpectedTags, + /// The JSON shape, field set, or enum encoding was malformed. + #[error("client binding status payload is malformed")] + MalformedPayload, + /// The payload used a version this client does not understand. + #[error("client binding status version is unsupported")] + UnsupportedVersion, + /// The authorization domain was nil or not a canonical UUID. + #[error("client binding status authorization domain is invalid")] + InvalidAuthorizationDomain, + /// The payload did not match the server-resolved authorization domain. + #[error("client binding status authorization domain does not match")] + AuthorizationDomainMismatch, + /// The event-author key was not canonical lowercase 64-character hex. + #[error("client binding status event-author key is invalid")] + InvalidEventAuthorPubkey, + /// The payload named a key other than the displayed event's author. + #[error("client binding status event-author key does not match")] + EventAuthorMismatch, + /// The binding version was zero. + #[error("client binding status binding version must be positive")] + InvalidBindingVersion, + /// The opaque policy revision was empty, unsafe, or exceeded its bound. + #[error("client binding status policy version is invalid")] + InvalidPolicyVersion, + /// A generic withdrawal attempted to carry current binding state. + #[error("client binding status withdrawal contains current binding state")] + WithdrawalContainsCurrentState, + /// The status revision was zero. + #[error("client binding status revision must be positive")] + InvalidStatusRevision, + /// The issue time was zero. + #[error("client binding status issue time must be positive")] + InvalidIssueTime, + /// Freshness did not strictly follow issue time. + #[error("client binding status freshness bound is invalid")] + InvalidFreshnessBound, + /// The freshness window exceeded the public short-lived maximum. + #[error("client binding status freshness window is too long")] + FreshnessWindowTooLong, + /// The signed Nostr timestamp did not equal the payload issue time. + #[error("client binding status event time does not match its issue time")] + EventTimeMismatch, + /// The payload was issued after the explicit skew allowance. + #[error("client binding status is not yet valid")] + NotYetValid, + /// The exclusive freshness bound was reached. + #[error("client binding status has expired")] + Expired, + /// The optional display label was unsafe, out of bounds, or attached to a + /// non-current disposition. + #[error("client binding status display label is invalid")] + InvalidDisplayLabel, +} + +/// Status-event serialization/signing failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum ClientBindingStatusBuildError { + /// JSON serialization failed. + #[error("client binding status serialization failed")] + Serialization, + /// The serialized payload exceeded its public bound. + #[error("client binding status payload is too large")] + PayloadTooLarge, + /// Nostr event signing failed. + #[error("client binding status signing failed")] + Signing, +} + +/// Status revision-fold failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum ClientBindingStatusFoldError { + /// Cryptographic or wire validation failed. + #[error(transparent)] + InvalidStatus(#[from] ClientBindingStatusError), + /// A lower status revision attempted to restore older presentation. + #[error("client binding status revision is below the accepted high-water mark")] + LowerRevisionReplay, + /// Another signed event reused an accepted revision. + #[error("client binding status revision conflicts with another event")] + ConflictingEqualRevision, +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::JsonUtil; + use serde_json::{json, Value}; + + const DOMAIN: &str = "00000000-0000-4000-8000-000000000123"; + const ISSUED_AT: u64 = 1_800_000_000; + const FRESH_UNTIL: u64 = ISSUED_AT + 120; + + fn domain() -> CommunityId { + CommunityId::from_uuid(Uuid::parse_str(DOMAIN).expect("synthetic domain is valid")) + } + + fn input( + author: PublicKey, + revision: u64, + disposition: ClientBindingStatusDisposition, + ) -> ClientBindingStatusInputV1 { + match disposition { + ClientBindingStatusDisposition::DisplayCurrent => ClientBindingStatusInputV1::current( + domain(), + author, + 7, + "synthetic-policy-v1", + revision, + ISSUED_AT, + FRESH_UNTIL, + Some("Synthetic Example".to_string()), + ), + ClientBindingStatusDisposition::Withdrawn => ClientBindingStatusInputV1::withdrawn( + domain(), + author, + revision, + ISSUED_AT, + FRESH_UNTIL, + ), + } + .expect("synthetic status input is valid") + } + + fn signed_status( + relay: &Keys, + author: PublicKey, + revision: u64, + disposition: ClientBindingStatusDisposition, + ) -> Event { + input(author, revision, disposition) + .sign_with_relay_keys(relay) + .expect("synthetic event signs") + } + + fn validate( + event: &Event, + relay: &Keys, + author: &Keys, + now: u64, + ) -> Result { + validate_client_binding_status_event( + event, + &relay.public_key(), + domain(), + &author.public_key(), + now, + ) + } + + #[test] + fn validates_relay_authenticated_current_status() { + let relay = Keys::generate(); + let author = Keys::generate(); + let event = signed_status( + &relay, + author.public_key(), + 11, + ClientBindingStatusDisposition::DisplayCurrent, + ); + + let status = validate(&event, &relay, &author, ISSUED_AT) + .expect("synthetic current status validates"); + + assert_eq!(status.event_id(), event.id); + assert_eq!(status.authorization_domain(), domain()); + assert_eq!(status.event_author_pubkey(), author.public_key()); + assert_eq!(status.binding_version(), Some(7)); + assert_eq!(status.policy_version(), Some("synthetic-policy-v1")); + assert_eq!(status.status_revision(), 11); + assert_eq!(status.issued_at(), ISSUED_AT); + assert_eq!(status.fresh_until(), FRESH_UNTIL); + assert_eq!( + status.disposition(), + ClientBindingStatusDisposition::DisplayCurrent + ); + assert_eq!(status.display_label(), Some("Synthetic Example")); + assert!(status.displays_current_binding()); + assert!(event.tags.is_empty()); + } + + #[test] + fn wire_is_current_or_opaque_withdrawal() { + let relay = Keys::generate(); + let author = Keys::generate(); + let cases = [ + ( + ClientBindingStatusDisposition::DisplayCurrent, + "display_current", + ), + (ClientBindingStatusDisposition::Withdrawn, "withdrawn"), + ]; + for (disposition, expected) in cases { + let event = signed_status(&relay, author.public_key(), 11, disposition); + let content: Value = serde_json::from_str(&event.content).expect("content parses"); + assert_eq!(content["status"], expected); + if disposition == ClientBindingStatusDisposition::Withdrawn { + for forbidden in [ + "binding_version", + "policy_version", + "display_label", + "reason", + ] { + assert!( + content.get(forbidden).is_none(), + "unexpected field {forbidden}" + ); + } + } + } + } + + #[test] + fn withdrawal_removes_current_presentation_without_lifecycle_data() { + let relay = Keys::generate(); + let author = Keys::generate(); + let event = signed_status( + &relay, + author.public_key(), + 11, + ClientBindingStatusDisposition::Withdrawn, + ); + let status = + validate(&event, &relay, &author, ISSUED_AT).expect("synthetic withdrawal validates"); + assert!(!status.displays_current_binding()); + assert_eq!(status.binding_version(), None); + assert_eq!(status.policy_version(), None); + assert!(status.display_label().is_none()); + } + + #[test] + fn exact_expiry_and_future_issue_fail_closed() { + let relay = Keys::generate(); + let author = Keys::generate(); + let event = signed_status( + &relay, + author.public_key(), + 11, + ClientBindingStatusDisposition::DisplayCurrent, + ); + + assert_eq!( + validate(&event, &relay, &author, FRESH_UNTIL), + Err(ClientBindingStatusError::Expired) + ); + assert_eq!( + validate(&event, &relay, &author, ISSUED_AT - 1), + Err(ClientBindingStatusError::NotYetValid) + ); + assert_eq!(CLIENT_BINDING_STATUS_CLOCK_SKEW_SECS, 0); + } + + #[test] + fn rejects_wrong_signer_tampering_kind_scope_and_tags() { + let relay = Keys::generate(); + let wrong_relay = Keys::generate(); + let author = Keys::generate(); + let other_author = Keys::generate(); + let event = signed_status( + &relay, + author.public_key(), + 11, + ClientBindingStatusDisposition::DisplayCurrent, + ); + + assert_eq!( + validate(&event, &wrong_relay, &author, ISSUED_AT), + Err(ClientBindingStatusError::UnexpectedRelay) + ); + assert_eq!( + validate(&event, &relay, &other_author, ISSUED_AT), + Err(ClientBindingStatusError::EventAuthorMismatch) + ); + assert_eq!( + validate_client_binding_status_event( + &event, + &relay.public_key(), + CommunityId::from_uuid(Uuid::from_u128(999)), + &author.public_key(), + ISSUED_AT, + ), + Err(ClientBindingStatusError::AuthorizationDomainMismatch) + ); + + let wrong_kind = EventBuilder::new(Kind::TextNote, event.content.clone()) + .custom_created_at(Timestamp::from(ISSUED_AT)) + .sign_with_keys(&relay) + .expect("synthetic event signs"); + assert_eq!( + validate(&wrong_kind, &relay, &author, ISSUED_AT), + Err(ClientBindingStatusError::WrongKind) + ); + + let tagged = EventBuilder::new( + Kind::Custom(KIND_CLIENT_BINDING_STATUS as u16), + event.content.clone(), + ) + .tags([nostr::Tag::parse(["p", &author.public_key().to_hex()]) + .expect("synthetic tag is valid")]) + .custom_created_at(Timestamp::from(ISSUED_AT)) + .sign_with_keys(&relay) + .expect("synthetic event signs"); + assert_eq!( + validate(&tagged, &relay, &author, ISSUED_AT), + Err(ClientBindingStatusError::UnexpectedTags) + ); + + let mut json: Value = serde_json::from_str(&event.as_json()).expect("event parses"); + json["content"] = Value::String("{}".to_string()); + let tampered = Event::from_json(json.to_string()).expect("tampered event parses"); + assert_eq!( + validate(&tampered, &relay, &author, ISSUED_AT), + Err(ClientBindingStatusError::UnauthenticatedEvent) + ); + } + + #[test] + fn unknown_version_fields_and_enums_fail_closed() { + let relay = Keys::generate(); + let author = Keys::generate(); + let event = signed_status( + &relay, + author.public_key(), + 11, + ClientBindingStatusDisposition::DisplayCurrent, + ); + let mut payload: Value = serde_json::from_str(&event.content).expect("content parses"); + + payload["version"] = json!(2); + let unknown_version = EventBuilder::new( + Kind::Custom(KIND_CLIENT_BINDING_STATUS as u16), + payload.to_string(), + ) + .custom_created_at(Timestamp::from(ISSUED_AT)) + .sign_with_keys(&relay) + .expect("synthetic event signs"); + assert_eq!( + validate(&unknown_version, &relay, &author, ISSUED_AT), + Err(ClientBindingStatusError::UnsupportedVersion) + ); + + payload["version"] = json!(1); + payload["synthetic_extension"] = json!(true); + let unknown_field = EventBuilder::new( + Kind::Custom(KIND_CLIENT_BINDING_STATUS as u16), + payload.to_string(), + ) + .custom_created_at(Timestamp::from(ISSUED_AT)) + .sign_with_keys(&relay) + .expect("synthetic event signs"); + assert_eq!( + validate(&unknown_field, &relay, &author, ISSUED_AT), + Err(ClientBindingStatusError::MalformedPayload) + ); + } + + #[test] + fn historical_status_and_lifecycle_fields_cannot_reappear() { + let relay = Keys::generate(); + let author = Keys::generate(); + let event = signed_status( + &relay, + author.public_key(), + 11, + ClientBindingStatusDisposition::DisplayCurrent, + ); + let mut payload: Value = serde_json::from_str(&event.content).expect("content parses"); + let current_keys = payload + .as_object() + .expect("current payload is an object") + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!( + current_keys, + std::collections::BTreeSet::from([ + "authorization_domain", + "binding_version", + "display_label", + "event_author_pubkey", + "fresh_until", + "issued_at", + "policy_version", + "status", + "status_revision", + "version", + ]) + ); + let withdrawn = signed_status( + &relay, + author.public_key(), + 12, + ClientBindingStatusDisposition::Withdrawn, + ); + let withdrawn_payload: Value = + serde_json::from_str(&withdrawn.content).expect("withdrawn content parses"); + let withdrawn_keys = withdrawn_payload + .as_object() + .expect("withdrawn payload is an object") + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!( + withdrawn_keys, + std::collections::BTreeSet::from([ + "authorization_domain", + "event_author_pubkey", + "fresh_until", + "issued_at", + "status", + "status_revision", + "version", + ]) + ); + payload["status"] = json!("historical_only"); + payload["reason"] = json!("rotated"); + let historical = EventBuilder::new( + Kind::Custom(KIND_CLIENT_BINDING_STATUS as u16), + payload.to_string(), + ) + .custom_created_at(Timestamp::from(ISSUED_AT)) + .sign_with_keys(&relay) + .expect("synthetic event signs"); + assert_eq!( + validate(&historical, &relay, &author, ISSUED_AT), + Err(ClientBindingStatusError::MalformedPayload) + ); + + for forbidden in [ + "history", + "lineage", + "predecessor", + "replacement", + "tombstone", + "principal", + "issuer", + "subject", + "retirement_reason", + "corporate_history", + "historical_label", + "employment_history", + ] { + let withdrawal = signed_status( + &relay, + author.public_key(), + 12, + ClientBindingStatusDisposition::Withdrawn, + ); + let mut payload: Value = + serde_json::from_str(&withdrawal.content).expect("content parses"); + payload[forbidden] = json!("forbidden"); + let injected = EventBuilder::new( + Kind::Custom(KIND_CLIENT_BINDING_STATUS as u16), + payload.to_string(), + ) + .custom_created_at(Timestamp::from(ISSUED_AT)) + .sign_with_keys(&relay) + .expect("synthetic event signs"); + assert_eq!( + validate(&injected, &relay, &author, ISSUED_AT), + Err(ClientBindingStatusError::MalformedPayload), + "accepted forbidden field {forbidden}" + ); + } + } + + #[test] + fn label_is_privacy_bounded_and_current_only() { + let author = Keys::generate(); + for label in [ + "", + " synthetic.example", + "synthetic.example\n", + &"x".repeat(MAX_CLIENT_BINDING_STATUS_LABEL_BYTES + 1), + ] { + assert!(matches!( + ClientBindingStatusInputV1::current( + domain(), + author.public_key(), + 7, + "synthetic-policy-v1", + 11, + ISSUED_AT, + FRESH_UNTIL, + Some(label.to_string()), + ), + Err(ClientBindingStatusError::InvalidDisplayLabel) + )); + } + let withdrawal = ClientBindingStatusInputV1::withdrawn( + domain(), + author.public_key(), + 11, + ISSUED_AT, + FRESH_UNTIL, + ) + .expect("withdrawal needs no label"); + assert!(!format!("{withdrawal:?}").contains("Synthetic Example")); + } + + #[test] + fn revision_fold_rejects_lower_and_conflicting_equal_replays() { + let relay = Keys::generate(); + let author = Keys::generate(); + let current = signed_status( + &relay, + author.public_key(), + 11, + ClientBindingStatusDisposition::DisplayCurrent, + ); + let withdrawn = signed_status( + &relay, + author.public_key(), + 12, + ClientBindingStatusDisposition::Withdrawn, + ); + let conflicting_equal = ClientBindingStatusInputV1::current( + domain(), + author.public_key(), + 8, + "synthetic-policy-v2", + 12, + ISSUED_AT, + FRESH_UNTIL, + None, + ) + .expect("conflicting current input") + .sign_with_relay_keys(&relay) + .expect("conflicting current signs"); + let mut tracker = + ClientBindingStatusTracker::new(relay.public_key(), domain(), author.public_key()); + + assert_eq!( + tracker.accept(¤t, ISSUED_AT), + Ok(ClientBindingStatusUpdate::Accepted) + ); + assert!(tracker.current_presentation(ISSUED_AT).is_some()); + assert_eq!( + tracker.accept(¤t, ISSUED_AT), + Ok(ClientBindingStatusUpdate::Duplicate) + ); + assert_eq!( + tracker.accept(&withdrawn, ISSUED_AT), + Ok(ClientBindingStatusUpdate::Accepted) + ); + assert!(tracker.current_presentation(ISSUED_AT).is_none()); + assert_eq!( + tracker.accept(¤t, ISSUED_AT), + Err(ClientBindingStatusFoldError::LowerRevisionReplay) + ); + assert_eq!( + tracker.accept(&conflicting_equal, ISSUED_AT), + Err(ClientBindingStatusFoldError::ConflictingEqualRevision) + ); + } + + #[test] + fn expiry_disconnect_and_scope_change_clear_presentation() { + let relay = Keys::generate(); + let other_relay = Keys::generate(); + let author = Keys::generate(); + let other_author = Keys::generate(); + let current = signed_status( + &relay, + author.public_key(), + 11, + ClientBindingStatusDisposition::DisplayCurrent, + ); + let mut tracker = + ClientBindingStatusTracker::new(relay.public_key(), domain(), author.public_key()); + tracker + .accept(¤t, ISSUED_AT) + .expect("current status accepted"); + + assert!(tracker.current_presentation(FRESH_UNTIL).is_none()); + assert_eq!(tracker.high_water_revision(), Some(11)); + assert_eq!( + tracker.accept(¤t, ISSUED_AT), + Ok(ClientBindingStatusUpdate::Duplicate) + ); + assert!(tracker.current_presentation(ISSUED_AT).is_none()); + + let newer = signed_status( + &relay, + author.public_key(), + 12, + ClientBindingStatusDisposition::DisplayCurrent, + ); + tracker + .accept(&newer, ISSUED_AT) + .expect("newer status accepted"); + tracker.on_disconnect(); + assert!(tracker.current_presentation(ISSUED_AT).is_none()); + assert_eq!(tracker.high_water_revision(), Some(12)); + + tracker.change_scope( + other_relay.public_key(), + CommunityId::from_uuid(Uuid::from_u128(999)), + other_author.public_key(), + ); + assert!(tracker.current_presentation(ISSUED_AT).is_none()); + assert_eq!(tracker.high_water_revision(), None); + } + + #[test] + fn debug_output_and_wire_omit_private_identity_material() { + let relay = Keys::generate(); + let author = Keys::generate(); + let event = signed_status( + &relay, + author.public_key(), + 11, + ClientBindingStatusDisposition::DisplayCurrent, + ); + let status = validate(&event, &relay, &author, ISSUED_AT) + .expect("synthetic current status validates"); + + let debug = format!("{status:?}"); + assert!(!debug.contains(DOMAIN)); + assert!(!debug.contains(&author.public_key().to_hex())); + assert!(!debug.contains("synthetic-policy-v1")); + assert!(!debug.contains("Synthetic Example")); + + let payload: Value = serde_json::from_str(&event.content).expect("content parses"); + for forbidden in [ + "iss", + "sub", + "issuer", + "audience", + "email", + "display_name", + "binding_id", + "bearer", + ] { + assert!( + payload.get(forbidden).is_none(), + "unexpected field {forbidden}" + ); + } + } +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 76943c2abf..495fe84654 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -85,6 +85,12 @@ pub const KIND_AUTH: u32 = 22242; pub const KIND_BLOSSOM_AUTH: u32 = 24242; /// Buzz custom one-time identity binding proof (ephemeral, not stored). pub const KIND_NOSTR_IDENTITY_BINDING: u32 = 24243; +/// Buzz relay-authenticated client binding status (ephemeral, not stored). +/// +/// This provisional allocation carries short-lived, display-only status. It +/// is intentionally absent from relay ingest and storage allowlists until the +/// binding lifecycle and client-presentation joins are complete. +pub const KIND_CLIENT_BINDING_STATUS: u32 = 24244; /// NIP-98: HTTP auth event (used in nip98.rs, not stored). pub const KIND_HTTP_AUTH: u32 = 27235; @@ -823,6 +829,7 @@ pub const fn is_relay_only_kind(kind: u32) -> bool { matches!( kind, KIND_NIP43_MEMBERSHIP_LIST + | KIND_CLIENT_BINDING_STATUS | KIND_CHANNEL_SUMMARY | KIND_PRESENCE_SNAPSHOT | KIND_DM_VISIBILITY @@ -904,6 +911,12 @@ mod tests { assert!(!is_relay_only_kind(KIND_NIP43_LEAVE_REQUEST)); } + #[test] + fn client_binding_status_is_relay_only() { + assert!(is_relay_only_kind(KIND_CLIENT_BINDING_STATUS)); + assert!(is_ephemeral(KIND_CLIENT_BINDING_STATUS)); + } + #[test] fn parameterized_replaceable_range() { assert!(!is_parameterized_replaceable(29999)); diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 66b7708f1d..6be3e97c40 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -9,6 +9,8 @@ pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; +/// Relay-authenticated, display-only client binding status contract. +pub mod client_binding_status; /// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation, /// body parse/serialize, envelope build/validate, head selection. pub mod engram; diff --git a/crates/buzz-db/src/client_status.rs b/crates/buzz-db/src/client_status.rs new file mode 100644 index 0000000000..46e70b80e2 --- /dev/null +++ b/crates/buzz-db/src/client_status.rs @@ -0,0 +1,710 @@ +//! Durable, current-only client verification-status revisions. +//! +//! Allocation is transaction-owned: the exact active binding, membership, +//! invalidation generation/floors, database-clock freshness, revision row, +//! authority epoch, and idempotency receipt are committed together. + +use buzz_core::CommunityId; +use sqlx::{Postgres, Row, Transaction}; +use thiserror::Error; +use uuid::Uuid; + +use crate::authorization_invalidation::AuthorizationSelector; +use crate::Db; + +const CURRENT_KIND: &str = "client.status.current.v1"; +const WITHDRAW_KIND: &str = "client.status.withdraw.v1"; + +/// Exact private requirement for one current-status issuance. +pub struct CurrentStatusAllocation<'a> { + /// Server-resolved authorization domain. + pub community_id: CommunityId, + /// Exact event-author key. + pub event_author_pubkey: &'a [u8; 32], + /// Stable active binding ID. + pub binding_id: Uuid, + /// Exact positive binding version. + pub binding_version: u64, + /// Opaque current provider policy version. + pub policy_version: &'a str, + /// Invalidation generation captured before provider evaluation. + pub evaluation_generation: u64, + /// Database-clock freshness boundary. + pub fresh_until: u64, + /// Stable issuance operation ID. + pub operation_id: Uuid, + /// Exact event-input fingerprint. + pub request_fingerprint: [u8; 32], +} + +/// Exact private requirement for an opaque withdrawal. +pub struct WithdrawalStatusAllocation<'a> { + /// Server-resolved authorization domain. + pub community_id: CommunityId, + /// Exact event-author key. + pub event_author_pubkey: &'a [u8; 32], + /// Revision of the actual current issuance being withdrawn. + pub supersedes_revision: u64, + /// Fingerprint of the durable current issuance receipt. + pub issuance_fingerprint: [u8; 32], + /// Stable withdrawal operation ID. + pub operation_id: Uuid, + /// Exact withdrawal-input fingerprint. + pub request_fingerprint: [u8; 32], +} + +/// Result of a transaction-owned revision allocation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AllocatedStatusRevision { + /// Strictly positive revision. + pub revision: u64, + /// Domain-wide durable status floor after this allocation. + pub floor: u64, +} + +/// Allocation failure classified by whether PostgreSQL commit was attempted. +#[derive(Debug, Error)] +pub enum ClientStatusAllocationError { + /// Current private authority did not satisfy the exact requirement. + #[error("client status authority is not current")] + NotCurrent, + /// A stable operation ID was reused for different input. + #[error("client status operation conflicts with a committed request")] + ConflictingRetry, + /// Input could not be represented safely. + #[error("client status allocation input is invalid")] + InvalidInput, + /// PostgreSQL failed before commit was attempted; the transaction rolls back. + #[error("client status allocation failed before commit")] + Database(#[source] sqlx::Error), + /// PostgreSQL commit acknowledgement was ambiguous. The receipt decides. + #[error("client status commit acknowledgement is ambiguous")] + CommitUnknown(#[source] sqlx::Error), +} + +impl From for ClientStatusAllocationError { + fn from(error: sqlx::Error) -> Self { + Self::Database(error) + } +} + +impl Db { + /// Read an exact committed status allocation after ambiguous commit acknowledgement. + pub async fn committed_status_revision( + &self, + community_id: CommunityId, + operation_id: Uuid, + request_fingerprint: [u8; 32], + ) -> Result, ClientStatusAllocationError> { + let row = sqlx::query( + "SELECT request_fingerprint, result_payload FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .fetch_optional(&self.pool) + .await?; + let Some(row) = row else { return Ok(None) }; + let fingerprint: Vec = row.try_get("request_fingerprint")?; + let payload: Vec = row.try_get("result_payload")?; + if fingerprint.as_slice() != request_fingerprint { + return Err(ClientStatusAllocationError::ConflictingRetry); + } + Ok(Some(decode_receipt_payload(&payload)?)) + } + + /// Allocate or replay one strictly monotonic current-status revision. + pub async fn allocate_current_status_revision( + &self, + request: CurrentStatusAllocation<'_>, + ) -> Result { + validate_common( + request.community_id, + request.event_author_pubkey, + request.operation_id, + request.binding_version, + )?; + if request.policy_version.is_empty() + || request.evaluation_generation > i64::MAX as u64 + || request.fresh_until > i64::MAX as u64 + { + return Err(ClientStatusAllocationError::InvalidInput); + } + let mut tx = self.begin_transaction().await.map_err(db_error)?; + lock_scope(&mut tx, request.community_id, request.event_author_pubkey).await?; + let (issuer, subject) = validate_current_authority(&mut tx, &request).await?; + validate_invalidation( + &mut tx, + request.community_id, + request.evaluation_generation, + request.binding_id, + request.binding_version, + request.event_author_pubkey, + request.policy_version, + &issuer, + &subject, + ) + .await?; + if let Some(revision) = replay_revision( + &mut tx, + request.community_id, + request.operation_id, + CURRENT_KIND, + request.request_fingerprint, + ) + .await? + { + tx.commit() + .await + .map_err(ClientStatusAllocationError::CommitUnknown)?; + return Ok(revision); + } + let allocated = next_revision(&mut tx, request.community_id).await?; + sqlx::query( + "INSERT INTO client_status_revisions \ + (community_id, event_author_pubkey, revision, disposition, binding_id, binding_version) \ + VALUES ($1, $2, $3, 'current', $4, $5) \ + ON CONFLICT (community_id, event_author_pubkey) DO UPDATE SET \ + revision=EXCLUDED.revision, disposition='current', binding_id=EXCLUDED.binding_id, \ + binding_version=EXCLUDED.binding_version, supersedes_revision=NULL, \ + updated_at=clock_timestamp()", + ) + .bind(request.community_id.as_uuid()) + .bind(request.event_author_pubkey.as_slice()) + .bind(allocated as i64) + .bind(request.binding_id) + .bind(request.binding_version as i64) + .execute(&mut *tx) + .await?; + insert_receipt( + &mut tx, + request.community_id, + request.operation_id, + CURRENT_KIND, + request.request_fingerprint, + allocated, + ) + .await?; + tx.commit() + .await + .map_err(ClientStatusAllocationError::CommitUnknown)?; + Ok(AllocatedStatusRevision { + revision: allocated, + floor: allocated, + }) + } + + /// Allocate or replay a withdrawal strictly after its exact current receipt. + pub async fn allocate_withdrawn_status_revision( + &self, + request: WithdrawalStatusAllocation<'_>, + ) -> Result { + validate_common( + request.community_id, + request.event_author_pubkey, + request.operation_id, + request.supersedes_revision, + )?; + let mut tx = self.begin_transaction().await.map_err(db_error)?; + lock_scope(&mut tx, request.community_id, request.event_author_pubkey).await?; + if let Some(revision) = replay_revision( + &mut tx, + request.community_id, + request.operation_id, + WITHDRAW_KIND, + request.request_fingerprint, + ) + .await? + { + tx.commit() + .await + .map_err(ClientStatusAllocationError::CommitUnknown)?; + return Ok(revision); + } + let issuance_exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_kind=$2 AND request_fingerprint=$3 \ + AND octet_length(result_payload) IN (8,16) \ + AND substring(result_payload FROM 1 FOR 8)=$4)", + ) + .bind(request.community_id.as_uuid()) + .bind(CURRENT_KIND) + .bind(request.issuance_fingerprint.as_slice()) + .bind(request.supersedes_revision.to_be_bytes().as_slice()) + .fetch_one(&mut *tx) + .await?; + if !issuance_exists { + return Err(ClientStatusAllocationError::NotCurrent); + } + let row: Option<(i64, String, Option)> = sqlx::query_as( + "SELECT revision, disposition, supersedes_revision FROM client_status_revisions \ + WHERE community_id=$1 AND event_author_pubkey=$2 FOR UPDATE", + ) + .bind(request.community_id.as_uuid()) + .bind(request.event_author_pubkey.as_slice()) + .fetch_optional(&mut *tx) + .await?; + let Some((revision, disposition, prior_supersedes)) = row else { + return Err(ClientStatusAllocationError::NotCurrent); + }; + let receipt_revision = request.supersedes_revision as i64; + let superseded_revision = if disposition == "current" { + if receipt_revision > revision { + return Err(ClientStatusAllocationError::NotCurrent); + } + revision + } else if disposition == "withdrawn" + && prior_supersedes.is_some_and(|superseded| receipt_revision <= superseded) + && revision > receipt_revision + { + prior_supersedes.expect("withdrawn status has a superseded revision") + } else { + return Err(ClientStatusAllocationError::NotCurrent); + }; + let floor: i64 = sqlx::query_scalar( + "SELECT status_revision FROM authorization_authority_epochs \ + WHERE community_id=$1 FOR UPDATE", + ) + .bind(request.community_id.as_uuid()) + .fetch_one(&mut *tx) + .await?; + let allocated = if disposition == "current" || revision < floor { + next_revision(&mut tx, request.community_id).await? + } else { + revision as u64 + }; + if allocated <= superseded_revision as u64 { + return Err(ClientStatusAllocationError::NotCurrent); + } + sqlx::query( + "UPDATE client_status_revisions SET revision=$3, disposition='withdrawn', \ + binding_id=NULL, binding_version=NULL, supersedes_revision=$4, \ + updated_at=clock_timestamp() \ + WHERE community_id=$1 AND event_author_pubkey=$2", + ) + .bind(request.community_id.as_uuid()) + .bind(request.event_author_pubkey.as_slice()) + .bind(allocated as i64) + .bind(superseded_revision) + .execute(&mut *tx) + .await?; + insert_receipt( + &mut tx, + request.community_id, + request.operation_id, + WITHDRAW_KIND, + request.request_fingerprint, + allocated, + ) + .await?; + tx.commit() + .await + .map_err(ClientStatusAllocationError::CommitUnknown)?; + Ok(AllocatedStatusRevision { + revision: allocated, + floor: allocated, + }) + } +} + +fn validate_common( + community_id: CommunityId, + pubkey: &[u8; 32], + operation_id: Uuid, + positive: u64, +) -> Result<(), ClientStatusAllocationError> { + if community_id.as_uuid().is_nil() + || operation_id.is_nil() + || positive == 0 + || positive > i64::MAX as u64 + || pubkey.iter().all(|byte| *byte == 0) + { + return Err(ClientStatusAllocationError::InvalidInput); + } + Ok(()) +} + +fn db_error(error: crate::DbError) -> ClientStatusAllocationError { + match error { + crate::DbError::Sqlx(error) => ClientStatusAllocationError::Database(error), + _ => ClientStatusAllocationError::InvalidInput, + } +} + +async fn lock_scope( + tx: &mut Transaction<'static, Postgres>, + community_id: CommunityId, + pubkey: &[u8; 32], +) -> Result<(), ClientStatusAllocationError> { + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(format!( + "client-status:{}:{}", + community_id, + hex::encode(pubkey) + )) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn validate_current_authority( + tx: &mut Transaction<'static, Postgres>, + request: &CurrentStatusAllocation<'_>, +) -> Result<(String, String), ClientStatusAllocationError> { + let row: Option<(String, String)> = sqlx::query_as( + "SELECT binding.issuer, binding.uid FROM identity_bindings binding \ + JOIN identity_principals principal ON principal.community_id=binding.community_id \ + AND principal.issuer=binding.issuer AND principal.uid=binding.uid \ + JOIN relay_members member ON member.community_id=binding.community_id \ + AND member.pubkey=encode(binding.pubkey, 'hex') \ + WHERE binding.community_id=$1 AND binding.binding_id=$2 AND binding.pubkey=$3 \ + AND binding.binding_version=$4 AND binding.binding_state='active' \ + AND principal.disabled_at IS NULL \ + AND NOT EXISTS (SELECT 1 FROM identity_revoked_keys revoked \ + WHERE revoked.community_id=binding.community_id AND revoked.pubkey=binding.pubkey) \ + FOR SHARE OF binding, principal, member", + ) + .bind(request.community_id.as_uuid()) + .bind(request.binding_id) + .bind(request.event_author_pubkey.as_slice()) + .bind(request.binding_version as i64) + .fetch_optional(&mut **tx) + .await?; + let Some(principal) = row else { + return Err(ClientStatusAllocationError::NotCurrent); + }; + let fresh: bool = + sqlx::query_scalar("SELECT clock_timestamp() < to_timestamp($1::double precision)") + .bind(request.fresh_until as f64) + .fetch_one(&mut **tx) + .await?; + if !fresh { + return Err(ClientStatusAllocationError::NotCurrent); + } + Ok(principal) +} + +#[allow(clippy::too_many_arguments)] +async fn validate_invalidation( + tx: &mut Transaction<'static, Postgres>, + community_id: CommunityId, + evaluation_generation: u64, + binding_id: Uuid, + binding_version: u64, + pubkey: &[u8; 32], + policy_version: &str, + issuer: &str, + subject: &str, +) -> Result<(), ClientStatusAllocationError> { + let generation: Option = sqlx::query_scalar( + "SELECT generation FROM authorization_invalidation_domains \ + WHERE community_id=$1 FOR SHARE", + ) + .bind(community_id.as_uuid()) + .fetch_optional(&mut **tx) + .await?; + if generation != Some(evaluation_generation as i64) { + return Err(ClientStatusAllocationError::NotCurrent); + } + let selectors = [ + AuthorizationSelector::domain(), + AuthorizationSelector::principal(issuer, subject) + .map_err(|_| ClientStatusAllocationError::InvalidInput)?, + AuthorizationSelector::nostr_key(*pubkey), + AuthorizationSelector::binding(binding_id, binding_version) + .map_err(|_| ClientStatusAllocationError::InvalidInput)?, + AuthorizationSelector::policy_version(policy_version) + .map_err(|_| ClientStatusAllocationError::InvalidInput)?, + ]; + for selector in selectors { + let row = sqlx::query( + "SELECT generation, sticky_deny, binding_version_floor \ + FROM authorization_invalidation_floors WHERE community_id=$1 \ + AND selector_kind=$2 AND selector_fingerprint=$3 FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(selector.kind().as_str()) + .bind(selector.fingerprint().as_slice()) + .fetch_optional(&mut **tx) + .await?; + let Some(row) = row else { continue }; + let floor_generation: i64 = row.try_get("generation")?; + let sticky: bool = row.try_get("sticky_deny")?; + let version_floor: Option = row.try_get("binding_version_floor")?; + if sticky + || floor_generation > evaluation_generation as i64 + || version_floor.is_some_and(|floor| binding_version <= floor as u64) + { + return Err(ClientStatusAllocationError::NotCurrent); + } + } + Ok(()) +} + +async fn replay_revision( + tx: &mut Transaction<'static, Postgres>, + community_id: CommunityId, + operation_id: Uuid, + operation_kind: &str, + request_fingerprint: [u8; 32], +) -> Result, ClientStatusAllocationError> { + let row = sqlx::query( + "SELECT operation_kind, request_fingerprint, result_payload \ + FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2 FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .fetch_optional(&mut **tx) + .await?; + let Some(row) = row else { return Ok(None) }; + let kind: String = row.try_get("operation_kind")?; + let fingerprint: Vec = row.try_get("request_fingerprint")?; + let payload: Vec = row.try_get("result_payload")?; + if kind != operation_kind || fingerprint.as_slice() != request_fingerprint { + return Err(ClientStatusAllocationError::ConflictingRetry); + } + Ok(Some(decode_receipt_payload(&payload)?)) +} + +fn decode_receipt_payload( + payload: &[u8], +) -> Result { + let (revision_bytes, floor_bytes) = match payload.len() { + // Compatibility with receipts written before allocation-time floors + // were retained. The allocation revision was also its floor. + 8 => (&payload[..8], &payload[..8]), + 16 => (&payload[..8], &payload[8..16]), + _ => return Err(ClientStatusAllocationError::ConflictingRetry), + }; + let revision = u64::from_be_bytes( + revision_bytes + .try_into() + .map_err(|_| ClientStatusAllocationError::ConflictingRetry)?, + ); + let floor = u64::from_be_bytes( + floor_bytes + .try_into() + .map_err(|_| ClientStatusAllocationError::ConflictingRetry)?, + ); + if revision == 0 || floor == 0 || revision < floor { + return Err(ClientStatusAllocationError::ConflictingRetry); + } + Ok(AllocatedStatusRevision { revision, floor }) +} + +async fn next_revision( + tx: &mut Transaction<'static, Postgres>, + community_id: CommunityId, +) -> Result { + let revision: i64 = sqlx::query_scalar( + "UPDATE authorization_authority_epochs SET \ + authority_epoch=authority_epoch+1, status_revision=status_revision+1, \ + updated_at=clock_timestamp() WHERE community_id=$1 RETURNING status_revision", + ) + .bind(community_id.as_uuid()) + .fetch_one(&mut **tx) + .await?; + u64::try_from(revision).map_err(|_| ClientStatusAllocationError::InvalidInput) +} + +async fn insert_receipt( + tx: &mut Transaction<'static, Postgres>, + community_id: CommunityId, + operation_id: Uuid, + operation_kind: &str, + request_fingerprint: [u8; 32], + revision: u64, +) -> Result<(), ClientStatusAllocationError> { + let mut result_payload = Vec::with_capacity(16); + result_payload.extend_from_slice(&revision.to_be_bytes()); + result_payload.extend_from_slice(&revision.to_be_bytes()); + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, operation_kind, request_fingerprint, \ + result_version, result_payload, lease_expires_at) \ + VALUES ($1,$2,$3,$4,1,$5,clock_timestamp()+interval '100 years')", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .bind(operation_kind) + .bind(request_fingerprint.as_slice()) + .bind(result_payload) + .execute(&mut **tx) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + #[ignore = "requires migrated Postgres"] + async fn current_replay_withdrawal_and_revocation_are_transaction_owned() { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); + let pool = sqlx::PgPool::connect(&database_url) + .await + .expect("test database"); + crate::migration::run_migrations(&pool) + .await + .expect("migrations"); + let db = Db::from_pool(pool); + let community = CommunityId::from_uuid(Uuid::new_v4()); + let binding_id = Uuid::new_v4(); + let author = [0x41; 32]; + sqlx::query("INSERT INTO communities (id, host) VALUES ($1,$2)") + .bind(community.as_uuid()) + .bind(format!("status-{}.example", community.as_uuid())) + .execute(&db.pool) + .await + .expect("community"); + sqlx::query("INSERT INTO identity_principals (community_id,issuer,uid) VALUES ($1,$2,$3)") + .bind(community.as_uuid()) + .bind("https://idp.example") + .bind("subject") + .execute(&db.pool) + .await + .expect("principal"); + sqlx::query( + "INSERT INTO identity_bindings \ + (community_id,issuer,uid,pubkey,source,binding_id,binding_version, \ + binding_state,binding_provenance) \ + VALUES ($1,$2,$3,$4,'jwt_npub',$5,1,'active','attested_key')", + ) + .bind(community.as_uuid()) + .bind("https://idp.example") + .bind("subject") + .bind(author.as_slice()) + .bind(binding_id) + .execute(&db.pool) + .await + .expect("binding"); + sqlx::query("INSERT INTO relay_members (community_id,pubkey,role) VALUES ($1,$2,'member')") + .bind(community.as_uuid()) + .bind(hex::encode(author)) + .execute(&db.pool) + .await + .expect("member"); + sqlx::query( + "INSERT INTO authorization_invalidation_domains (community_id) VALUES ($1) \ + ON CONFLICT (community_id) DO NOTHING", + ) + .bind(community.as_uuid()) + .execute(&db.pool) + .await + .expect("invalidation domain"); + let generation: i64 = sqlx::query_scalar( + "SELECT generation FROM authorization_invalidation_domains WHERE community_id=$1", + ) + .bind(community.as_uuid()) + .fetch_one(&db.pool) + .await + .expect("generation"); + let fresh_until = chrono::Utc::now().timestamp() as u64 + 300; + let operation_id = Uuid::new_v4(); + let allocate = |operation_id, fingerprint| CurrentStatusAllocation { + community_id: community, + event_author_pubkey: &author, + binding_id, + binding_version: 1, + policy_version: "policy-v1", + evaluation_generation: generation as u64, + fresh_until, + operation_id, + request_fingerprint: fingerprint, + }; + let first = db + .allocate_current_status_revision(allocate(operation_id, [1; 32])) + .await + .expect("first current"); + let replay = db + .allocate_current_status_revision(allocate(operation_id, [1; 32])) + .await + .expect("exact replay"); + assert_eq!(first, replay); + let second = db + .allocate_current_status_revision(allocate(Uuid::new_v4(), [2; 32])) + .await + .expect("new issuance"); + assert!(second.revision > first.revision); + let withdrawal_operation = Uuid::new_v4(); + let withdrawn = db + .allocate_withdrawn_status_revision(WithdrawalStatusAllocation { + community_id: community, + event_author_pubkey: &author, + supersedes_revision: second.revision, + issuance_fingerprint: [2; 32], + operation_id: withdrawal_operation, + request_fingerprint: [3; 32], + }) + .await + .expect("withdrawal"); + assert!(withdrawn.revision > second.revision); + let fanout = db + .allocate_withdrawn_status_revision(WithdrawalStatusAllocation { + community_id: community, + event_author_pubkey: &author, + supersedes_revision: first.revision, + issuance_fingerprint: [1; 32], + operation_id: Uuid::new_v4(), + request_fingerprint: [4; 32], + }) + .await + .expect("older displayed current receives the same withdrawal"); + assert_eq!(fanout, withdrawn); + + sqlx::query( + "UPDATE authorization_authority_epochs \ + SET authority_epoch=authority_epoch+1, status_revision=status_revision+1 \ + WHERE community_id=$1", + ) + .bind(community.as_uuid()) + .execute(&db.pool) + .await + .expect("advance unrelated durable status floor"); + let delayed_replay = db + .allocate_withdrawn_status_revision(WithdrawalStatusAllocation { + community_id: community, + event_author_pubkey: &author, + supersedes_revision: second.revision, + issuance_fingerprint: [2; 32], + operation_id: withdrawal_operation, + request_fingerprint: [3; 32], + }) + .await + .expect("exact delayed fan-out replay retains allocation-time floor"); + assert_eq!(delayed_replay, withdrawn); + + let reissued = db + .allocate_current_status_revision(allocate(Uuid::new_v4(), [6; 32])) + .await + .expect("a fresh current status can replace a withdrawn projection"); + assert!(reissued.revision > withdrawn.revision); + let row: (String, Option) = sqlx::query_as( + "SELECT disposition, supersedes_revision FROM client_status_revisions \ + WHERE community_id=$1 AND event_author_pubkey=$2", + ) + .bind(community.as_uuid()) + .bind(author.as_slice()) + .fetch_one(&db.pool) + .await + .expect("reissued projection"); + assert_eq!(row, ("current".to_owned(), None)); + + assert!(matches!( + db.allocate_withdrawn_status_revision(WithdrawalStatusAllocation { + community_id: community, + event_author_pubkey: &author, + supersedes_revision: second.revision, + issuance_fingerprint: [9; 32], + operation_id: Uuid::new_v4(), + request_fingerprint: [5; 32], + }) + .await, + Err(ClientStatusAllocationError::NotCurrent) + )); + } +} diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 590590a345..e69d57ab0a 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -15,8 +15,16 @@ pub mod admin_moderation; pub mod api_token; /// Relay-scoped archived identity persistence (NIP-IA). pub mod archived_identities; +/// Transaction-owned admission records for protected audio sessions. +pub mod audio_admission; +/// Durable provider-neutral authorization invalidation authority. +pub mod authorization_invalidation; +/// Restore-independent high-water snapshots for protected authority. +pub mod authorization_version; /// Channel and membership persistence. pub mod channel; +/// Transaction-owned current-only client verification-status revisions. +pub mod client_status; /// Direct message channel persistence. pub mod dm; /// Database error types. @@ -39,6 +47,12 @@ pub mod moderation; pub mod partition; /// Buzz product-feedback sidecar persistence. pub mod product_feedback; +/// PostgreSQL-authoritative visibility for protected object-store content. +pub mod protected_publication; +/// Monotonic migration and cutover authority for protected object visibility. +pub mod protected_visibility; +/// Durable reconciliation for optional relay-authored identity projections. +pub mod public_projection; /// Community-scoped push lease and durable wake-outbox persistence. pub mod push; /// Reaction persistence. @@ -69,7 +83,7 @@ use uuid::Uuid; use buzz_core::{CommunityId, StoredEvent}; -fn event_replacement_lock_key( +pub(crate) fn event_replacement_lock_key( community_id: CommunityId, kind: i32, pubkey: &[u8], @@ -172,6 +186,43 @@ pub async fn insert_mentions( Ok(()) } +/// Transaction-aware mention-index projection for a protected event commit. +pub async fn insert_mentions_tx( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, +) -> Result<()> { + let created_at_secs = event.created_at.as_secs() as i64; + let created_at = DateTime::from_timestamp(created_at_secs, 0) + .ok_or(DbError::InvalidTimestamp(created_at_secs))?; + for pubkey in event.tags.iter().filter_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 2 + && parts[0] == "p" + && parts[1].len() == 64 + && parts[1] + .chars() + .all(|character| character.is_ascii_hexdigit())) + .then(|| parts[1].to_ascii_lowercase()) + }) { + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind) \ + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .bind(event.id.as_bytes().as_slice()) + .bind(created_at) + .bind(channel_id) + .bind(event.kind.as_u16() as i32) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + /// Database handle. Clone is cheap (Arc-backed pool). #[derive(Clone, Debug)] pub struct Db { @@ -1933,6 +1984,17 @@ impl Db { push::claim_due_match_batch(&self.pool, limit, lease_until).await } + /// Claim a matcher batch outside exact protected Enforce domains. + pub async fn claim_due_push_match_batch_excluding( + &self, + limit: i64, + lease_until: DateTime, + excluded_communities: &[Uuid], + ) -> Result> { + push::claim_due_match_batch_excluding(&self.pool, limit, lease_until, excluded_communities) + .await + } + /// Load active endpoint-enabled leases eligible for push matching. pub async fn active_push_match_leases( &self, @@ -1967,6 +2029,14 @@ impl Db { push::reap_exhausted_matches(&self.pool).await } + /// Reap matcher jobs outside exact protected Enforce domains. + pub async fn reap_exhausted_push_matches_excluding( + &self, + excluded_communities: &[Uuid], + ) -> Result { + push::reap_exhausted_matches_excluding(&self.pool, excluded_communities).await + } + /// Idempotently enqueue a wake for a matched lease and event. pub async fn enqueue_push_wake( &self, @@ -2320,6 +2390,27 @@ impl Db { channel::get_accessible_channel_ids(&self.pool, community_id, pubkey).await } + /// Revalidate uncached read access to one channel at an outbound release + /// boundary. + pub async fn channel_read_authorized( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) -> Result { + channel::channel_read_authorized(&self.pool, community_id, channel_id, pubkey).await + } + + /// Revalidate uncached read access to a complete channel set in one query. + pub async fn channel_set_read_authorized( + &self, + community_id: CommunityId, + channel_ids: &[Uuid], + pubkey: &[u8], + ) -> Result { + channel::channel_set_read_authorized(&self.pool, community_id, channel_ids, pubkey).await + } + /// Lists channels, optionally filtered by visibility. pub async fn list_channels( &self, @@ -2454,6 +2545,14 @@ impl Db { channel::reap_expired_ephemeral_channels(&self.pool).await } + /// Archive expired ephemeral channels outside protected Enforce domains. + pub async fn reap_expired_ephemeral_channels_excluding( + &self, + excluded_communities: &[Uuid], + ) -> Result> { + channel::reap_expired_ephemeral_channels_excluding(&self.pool, excluded_communities).await + } + /// Query due reminders ready for delivery. pub async fn query_due_reminders( &self, @@ -2463,6 +2562,22 @@ impl Db { event::query_due_reminders(&self.pool, now_secs, batch_limit).await } + /// Query reminders outside protected Enforce domains. + pub async fn query_due_reminders_excluding( + &self, + now_secs: i64, + batch_limit: i64, + excluded_communities: &[Uuid], + ) -> Result> { + event::query_due_reminders_excluding( + &self.pool, + now_secs, + batch_limit, + excluded_communities, + ) + .await + } + /// Atomically claim a due reminder for delivery (cross-pod dedup). pub async fn claim_due_reminder( &self, @@ -4333,6 +4448,16 @@ impl Db { relay_invite::reap_expired_relay_invites(&self.pool, cutoff).await } + /// Delete expired invites outside protected Enforce domains. + pub async fn reap_expired_relay_invites_excluding( + &self, + cutoff: chrono::DateTime, + excluded_communities: &[Uuid], + ) -> Result { + relay_invite::reap_expired_relay_invites_excluding(&self.pool, cutoff, excluded_communities) + .await + } + /// Atomically claims a v2 relay invite. The full redemption (membership /// insert, policy evidence, use_count increment) runs in one PostgreSQL /// transaction with `FOR UPDATE` on the invite row. @@ -4570,6 +4695,16 @@ impl Db { git_repo::count_repos_for_owner(&self.pool, community, owner_pubkey).await } + /// Return an existing Git reservation's immutable publication origin. + pub async fn repo_publication_origin( + &self, + community_id: CommunityId, + repo_id: &str, + owner_pubkey: &str, + ) -> Result> { + git_repo::repo_publication_origin(&self.pool, community_id, repo_id, owner_pubkey).await + } + /// Release a git repo name reservation held by `owner_pubkey` (rollback). /// /// Returns the number of rows removed (0 or 1). See [`git_repo::release_repo_name`]. @@ -6281,6 +6416,61 @@ mod tests { assert_eq!(retry, restored); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn protected_community_lifecycle_is_fail_closed_while_off_remains_legacy() { + let db = setup_db().await; + let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let protected_host = format!("protected-lifecycle-{}.example", Uuid::new_v4().simple()); + let created = db + .create_community_with_owner(&protected_host, &owner) + .await + .expect("create protected fixture"); + let CreateCommunityWithOwnerResult::Created(protected) = created else { + panic!("expected new protected fixture"); + }; + sqlx::query("INSERT INTO authorization_invalidation_domains (community_id) VALUES ($1)") + .bind(protected.id.as_uuid()) + .execute(&db.pool) + .await + .expect("activate protected marker"); + + assert!(db + .archive_community_owned_by(&protected_host, &owner, "reserved.example") + .await + .is_err()); + assert!(sqlx::query("DELETE FROM communities WHERE id=$1") + .bind(protected.id.as_uuid()) + .execute(&db.pool) + .await + .is_err()); + assert!(db + .lookup_community_by_host(&protected_host) + .await + .expect("protected lookup") + .is_some()); + + let off_host = format!("off-lifecycle-{}.example", Uuid::new_v4().simple()); + let created = db + .create_community_with_owner(&off_host, &owner) + .await + .expect("create Off fixture"); + let CreateCommunityWithOwnerResult::Created(off) = created else { + panic!("expected new Off fixture"); + }; + assert!(db + .archive_community_owned_by(&off_host, &owner, "reserved.example") + .await + .expect("Off archive keeps legacy behavior") + .is_some()); + assert!(db + .unarchive_community_owned_by(&off_host, &owner) + .await + .expect("Off restore keeps legacy behavior") + .is_some()); + assert!(!off.id.as_uuid().is_nil()); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn create_community_with_owner_enforces_per_owner_limit() { diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 76d346deb0..303d98c3b4 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -566,7 +566,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 43); + assert_eq!(migrations.len(), 45); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1046,6 +1046,20 @@ mod tests { ] { assert!(authority_epochs.contains(protected_table)); } + + assert_eq!(migrations[43].version, 44); + let client_status = migrations[43].sql.as_str(); + assert!(client_status.contains("ADD COLUMN supersedes_revision")); + assert!(client_status.contains("client_status_revisions_withdrawal")); + + assert_eq!(migrations[44].version, 45); + let projection_retirement = migrations[44].sql.as_str(); + assert!(projection_retirement.contains("identity_public_projection_heads")); + assert!(projection_retirement.contains("identity_public_projection_retirements")); + assert!(projection_retirement.contains("source_binding_version")); + assert!(!projection_retirement.contains("issuer")); + assert!(!projection_retirement.contains("subject TEXT")); + assert!(!projection_retirement.contains("display_name")); } fn additive_identity_executable_sql(sql: &str) -> String { diff --git a/crates/buzz-db/src/public_projection.rs b/crates/buzz-db/src/public_projection.rs new file mode 100644 index 0000000000..516699e67d --- /dev/null +++ b/crates/buzz-db/src/public_projection.rs @@ -0,0 +1,2592 @@ +//! Durable reconciliation for the optional relay-authored identity projection. +//! +//! O3 lifecycle rows remain authoritative. This module stores only public +//! event coordinates and opaque binding generations; it is neither an +//! operator API nor a durable audit surface. + +use std::{fmt, time::Duration}; + +use buzz_core::{CommunityId, StoredEvent}; +use chrono::{DateTime, Utc}; +use nostr::Event; +use sqlx::{Postgres, Row, Transaction}; +use uuid::Uuid; + +use crate::{ + event::{self, EventQuery}, + identity_binding::{key_lock_coordinate, lock_identity_coordinates_tx}, + Db, DbError, Result, +}; + +const ASSERTION_KIND: i32 = 30382; +const CLAIM_LEASE: Duration = Duration::from_secs(30); + +/// Opaque source generation for one relay-authored public projection. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ProjectionBindingOrigin { + binding_id: Uuid, + binding_version: u64, +} + +impl ProjectionBindingOrigin { + /// Stable binding identifier used only for server-side fencing. + pub const fn binding_id(self) -> Uuid { + self.binding_id + } + + /// Positive binding generation used only for server-side fencing. + pub const fn binding_version(self) -> u64 { + self.binding_version + } +} + +impl fmt::Debug for ProjectionBindingOrigin { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProjectionBindingOrigin") + .field("binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") + .finish() + } +} + +/// Server-only disposition recorded for the current public projection head. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectionDisposition { + /// A current binding owns a label-bearing assertion. + Active, + /// The assertion is the canonical label-free inactive replacement. + Inactive, +} + +impl ProjectionDisposition { + const fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Inactive => "inactive", + } + } +} + +/// Current server-only ownership metadata for an assertion coordinate. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ProjectionHead { + event_id: [u8; 32], + disposition: ProjectionDisposition, + origin: Option, +} + +impl ProjectionHead { + /// Exact signed event installed with this ownership record. + pub const fn event_id(self) -> [u8; 32] { + self.event_id + } + + /// Whether the current projection is active or inactive. + pub const fn disposition(self) -> ProjectionDisposition { + self.disposition + } + + /// Exact binding generation that created the current projection, if known. + pub const fn origin(self) -> Option { + self.origin + } +} + +impl fmt::Debug for ProjectionHead { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProjectionHead") + .field("event_id", &"[redacted]") + .field("disposition", &self.disposition) + .field("origin", &"[redacted]") + .finish() + } +} + +fn checked_version(value: i64) -> Result { + u64::try_from(value) + .map_err(|_| DbError::InvalidData("public projection version is invalid".to_owned())) +} + +fn checked_i64(value: u64) -> Result { + i64::try_from(value) + .map_err(|_| DbError::InvalidData("public projection version is invalid".to_owned())) +} + +fn validate_pubkey(value: &[u8]) -> Result<()> { + if value.len() != 32 { + return Err(DbError::InvalidData( + "public projection key must be 32 bytes".to_owned(), + )); + } + Ok(()) +} + +async fn authoritative_binding_for_exact_principal_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + issuer: &str, + subject: &str, + pubkey: &[u8], +) -> Result> { + let row = sqlx::query( + r#" + SELECT binding.binding_id, binding.binding_version + FROM identity_bindings binding + WHERE binding.community_id=$1 + AND binding.issuer=$2 + AND binding.uid=$3 + AND binding.pubkey=$4 + AND binding.binding_state='active' + AND binding.revoked_at IS NULL + AND binding.rotation_completed_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM identity_migration_denials denial + WHERE denial.community_id=binding.community_id + AND denial.issuer=binding.issuer AND denial.subject=binding.uid) + AND NOT EXISTS ( + SELECT 1 FROM identity_migration_denied_keys denial + WHERE denial.community_id=binding.community_id + AND denial.pubkey=binding.pubkey) + AND NOT EXISTS ( + SELECT 1 FROM identity_principals principal + WHERE principal.community_id=binding.community_id + AND principal.issuer=binding.issuer AND principal.uid=binding.uid + AND principal.disabled_at IS NOT NULL) + AND NOT EXISTS ( + SELECT 1 FROM identity_revoked_keys revoked + WHERE revoked.community_id=binding.community_id + AND revoked.pubkey=binding.pubkey) + AND NOT EXISTS ( + SELECT 1 FROM identity_pending_replacements pending + WHERE pending.community_id=binding.community_id + AND pending.issuer=binding.issuer AND pending.subject=binding.uid + AND pending.cleared_at IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM identity_retired_pairs retired + WHERE retired.community_id=binding.community_id + AND retired.issuer=binding.issuer AND retired.subject=binding.uid + AND retired.pubkey=binding.pubkey) + FOR SHARE OF binding + "#, + ) + .bind(community_id.as_uuid()) + .bind(issuer) + .bind(subject) + .bind(pubkey) + .fetch_optional(&mut **tx) + .await?; + row.map(|row| { + Ok(ProjectionBindingOrigin { + binding_id: row.try_get("binding_id")?, + binding_version: checked_version(row.try_get("binding_version")?)?, + }) + }) + .transpose() +} + +async fn authoritative_binding_for_key_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &[u8], +) -> Result> { + let row = sqlx::query( + r#" + SELECT binding.binding_id, binding.binding_version + FROM identity_bindings binding + WHERE binding.community_id=$1 + AND binding.pubkey=$2 + AND binding.binding_state='active' + AND binding.revoked_at IS NULL + AND binding.rotation_completed_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM identity_migration_denials denial + WHERE denial.community_id=binding.community_id + AND denial.issuer=binding.issuer AND denial.subject=binding.uid) + AND NOT EXISTS ( + SELECT 1 FROM identity_migration_denied_keys denial + WHERE denial.community_id=binding.community_id + AND denial.pubkey=binding.pubkey) + AND NOT EXISTS ( + SELECT 1 FROM identity_principals principal + WHERE principal.community_id=binding.community_id + AND principal.issuer=binding.issuer AND principal.uid=binding.uid + AND principal.disabled_at IS NOT NULL) + AND NOT EXISTS ( + SELECT 1 FROM identity_revoked_keys revoked + WHERE revoked.community_id=binding.community_id + AND revoked.pubkey=binding.pubkey) + AND NOT EXISTS ( + SELECT 1 FROM identity_pending_replacements pending + WHERE pending.community_id=binding.community_id + AND pending.issuer=binding.issuer AND pending.subject=binding.uid + AND pending.cleared_at IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM identity_retired_pairs retired + WHERE retired.community_id=binding.community_id + AND retired.issuer=binding.issuer AND retired.subject=binding.uid + AND retired.pubkey=binding.pubkey) + FOR SHARE OF binding + "#, + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_optional(&mut **tx) + .await?; + row.map(|row| { + Ok(ProjectionBindingOrigin { + binding_id: row.try_get("binding_id")?, + binding_version: checked_version(row.try_get("binding_version")?)?, + }) + }) + .transpose() +} + +async fn current_projection_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + relay_pubkey: &[u8], + subject_pubkey: &[u8], +) -> Result> { + let subject = hex::encode(subject_pubkey); + Ok(event::query_events_tx( + tx, + &EventQuery { + kinds: Some(vec![ASSERTION_KIND]), + pubkey: Some(relay_pubkey.to_vec()), + d_tag: Some(subject), + global_only: true, + limit: Some(1), + ..EventQuery::for_community(community_id) + }, + ) + .await? + .into_iter() + .next()) +} + +async fn projection_by_id_including_deleted_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + event_id: &[u8], +) -> Result> { + let row = sqlx::query( + "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ + FROM events WHERE community_id=$1 AND id=$2 \ + ORDER BY created_at DESC LIMIT 1 FOR SHARE", + ) + .bind(community_id.as_uuid()) + .bind(event_id) + .fetch_optional(&mut **tx) + .await?; + row.map(event::row_to_stored_event) + .transpose() + .map(Option::flatten) +} + +async fn projection_head_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + relay_pubkey: &[u8], + subject_pubkey: &[u8], +) -> Result> { + let row = sqlx::query( + "SELECT event_id, disposition, source_binding_id, source_binding_version \ + FROM identity_public_projection_heads \ + WHERE community_id=$1 AND relay_pubkey=$2 AND subject_pubkey=$3 FOR UPDATE", + ) + .bind(community_id.as_uuid()) + .bind(relay_pubkey) + .bind(subject_pubkey) + .fetch_optional(&mut **tx) + .await?; + row.map(|row| { + let event_id: Vec = row.try_get("event_id")?; + let event_id = event_id.try_into().map_err(|_| { + DbError::InvalidData("public projection head event id is invalid".to_owned()) + })?; + let disposition: String = row.try_get("disposition")?; + let disposition = match disposition.as_str() { + "active" => ProjectionDisposition::Active, + "inactive" => ProjectionDisposition::Inactive, + _ => { + return Err(DbError::InvalidData( + "public projection head disposition is invalid".to_owned(), + )) + } + }; + let binding_id: Option = row.try_get("source_binding_id")?; + let binding_version: Option = row.try_get("source_binding_version")?; + let origin = match (binding_id, binding_version) { + (Some(binding_id), Some(binding_version)) => Some(ProjectionBindingOrigin { + binding_id, + binding_version: checked_version(binding_version)?, + }), + (None, None) => None, + _ => { + return Err(DbError::InvalidData( + "public projection head origin is incomplete".to_owned(), + )) + } + }; + Ok(ProjectionHead { + event_id, + disposition, + origin, + }) + }) + .transpose() +} + +async fn upsert_projection_head_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + relay_pubkey: &[u8], + subject_pubkey: &[u8], + event: &Event, + disposition: ProjectionDisposition, + origin: Option, +) -> Result<()> { + let created_at = DateTime::::from_timestamp(event.created_at.as_secs() as i64, 0) + .ok_or(DbError::InvalidTimestamp(event.created_at.as_secs() as i64))?; + sqlx::query( + r#" + INSERT INTO identity_public_projection_heads + (community_id, relay_pubkey, subject_pubkey, event_id, + event_created_at, disposition, source_binding_id, + source_binding_version, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW()) + ON CONFLICT (community_id, relay_pubkey, subject_pubkey) DO UPDATE + SET event_id=EXCLUDED.event_id, + event_created_at=EXCLUDED.event_created_at, + disposition=EXCLUDED.disposition, + source_binding_id=EXCLUDED.source_binding_id, + source_binding_version=EXCLUDED.source_binding_version, + updated_at=NOW() + "#, + ) + .bind(community_id.as_uuid()) + .bind(relay_pubkey) + .bind(subject_pubkey) + .bind(event.id.as_bytes().as_slice()) + .bind(created_at) + .bind(disposition.as_str()) + .bind(origin.map(ProjectionBindingOrigin::binding_id)) + .bind( + origin + .map(ProjectionBindingOrigin::binding_version) + .map(checked_i64) + .transpose()?, + ) + .execute(&mut **tx) + .await?; + Ok(()) +} + +fn validate_event_coordinate( + event: &Event, + relay_pubkey: &[u8], + subject_pubkey: &[u8], +) -> Result { + let subject = hex::encode(subject_pubkey); + let exact_tag_count = |name: &str, value: &str| { + event + .tags + .iter() + .filter(|tag| { + let parts = tag.as_slice(); + parts.len() == 2 && parts[0] == name && parts[1] == value + }) + .count() + }; + if event.kind.as_u16() as i32 != ASSERTION_KIND + || event.pubkey.as_bytes() != relay_pubkey + || exact_tag_count("d", &subject) != 1 + || exact_tag_count("p", &subject) != 1 + || exact_tag_count("verified", "relay") != 1 + || !event.verify_id() + || !event.verify_signature() + { + return Err(DbError::InvalidData( + "public projection event coordinate is invalid".to_owned(), + )); + } + Ok(subject) +} + +fn validate_event_disposition(event: &Event, disposition: ProjectionDisposition) -> Result<()> { + let exact_tag_count = |name: &str, value: &str| { + event + .tags + .iter() + .filter(|tag| { + let parts = tag.as_slice(); + parts.len() == 2 && parts[0] == name && parts[1] == value + }) + .count() + }; + let expiration = event + .tags + .iter() + .filter(|tag| { + let parts = tag.as_slice(); + parts.len() == 2 && parts[0] == "expiration" + }) + .map(|tag| tag.as_slice()[1].parse::()) + .collect::, _>>() + .map_err(|_| DbError::InvalidData("public projection expiration is invalid".to_owned()))?; + let display_names = event + .tags + .iter() + .filter(|tag| { + let parts = tag.as_slice(); + parts.len() == 2 && parts[0] == "display_name" + }) + .map(|tag| tag.as_slice()[1].as_str()) + .collect::>(); + let valid = event.content.is_empty() + && match disposition { + ProjectionDisposition::Active => { + event.tags.len() == 6 + && exact_tag_count("active", "true") == 1 + && exact_tag_count("active", "false") == 0 + && expiration.len() == 1 + && expiration[0] > 0 + && display_names.len() == 1 + && !display_names[0].is_empty() + } + ProjectionDisposition::Inactive => { + event.tags.len() == 5 + && exact_tag_count("active", "false") == 1 + && exact_tag_count("active", "true") == 0 + && expiration.as_slice() == [0] + && display_names.is_empty() + } + }; + if !valid { + return Err(DbError::InvalidData( + "public projection disposition is invalid".to_owned(), + )); + } + Ok(()) +} + +fn canonical_later_projection_is_proven( + current: &StoredEvent, + head: ProjectionHead, + expected: &StoredEvent, + source: ProjectionBindingOrigin, + relay_pubkey: &[u8], + subject_pubkey: &[u8], +) -> Result { + validate_event_coordinate(¤t.event, relay_pubkey, subject_pubkey)?; + validate_event_disposition(¤t.event, head.disposition)?; + validate_event_coordinate(&expected.event, relay_pubkey, subject_pubkey)?; + validate_event_disposition(&expected.event, ProjectionDisposition::Inactive)?; + + let head_matches_current = head.event_id.as_slice() == current.event.id.as_bytes().as_slice(); + let current_is_later = current.event.created_at > expected.event.created_at + || (current.event.created_at == expected.event.created_at + && current.event.id.as_bytes().as_slice() < expected.event.id.as_bytes().as_slice()); + let later_owner_is_proven = head.origin.is_some_and(|origin| { + origin.binding_id != source.binding_id || origin.binding_version > source.binding_version + }); + + Ok(head_matches_current && current_is_later && later_owner_is_proven) +} + +/// Transaction-owned active publication permit. +pub struct ActivePublicProjectionPermit { + tx: Transaction<'static, Postgres>, + community_id: CommunityId, + relay_pubkey: Vec, + subject_pubkey: Vec, + origin: ProjectionBindingOrigin, + current: Option, +} + +impl fmt::Debug for ActivePublicProjectionPermit { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ActivePublicProjectionPermit") + .field("community_id", &"[redacted]") + .field("relay_pubkey", &"[redacted]") + .field("subject_pubkey", &"[redacted]") + .field("origin", &"[redacted]") + .finish_non_exhaustive() + } +} + +impl ActivePublicProjectionPermit { + /// Current stored projection at the locked coordinate. + pub fn current_projection(&self) -> Option<&StoredEvent> { + self.current.as_ref() + } + + /// Exact active binding generation retained through commit. + pub const fn origin(&self) -> ProjectionBindingOrigin { + self.origin + } + + /// Atomically replace or accept the candidate and record private ownership. + pub async fn commit( + mut self, + event: &Event, + disposition: ProjectionDisposition, + ) -> Result { + let subject = validate_event_coordinate(event, &self.relay_pubkey, &self.subject_pubkey)?; + validate_event_disposition(event, disposition)?; + let (candidate, inserted) = event::replace_parameterized_event_tx( + &mut self.tx, + self.community_id, + event, + &subject, + None, + ) + .await?; + let stored = if inserted { + candidate + } else { + let current = current_projection_tx( + &mut self.tx, + self.community_id, + &self.relay_pubkey, + &self.subject_pubkey, + ) + .await? + .ok_or_else(|| { + DbError::InvalidData("public projection replacement disappeared".to_owned()) + })?; + if current.event.id != event.id { + return Err(DbError::InvalidData( + "public projection candidate lost ordering".to_owned(), + )); + } + current + }; + upsert_projection_head_tx( + &mut self.tx, + self.community_id, + &self.relay_pubkey, + &self.subject_pubkey, + &stored.event, + disposition, + Some(self.origin), + ) + .await?; + self.tx.commit().await?; + Ok(stored) + } +} + +/// Begin active publication while retaining exact binding authority to commit. +pub async fn begin_active_public_projection( + db: &Db, + community_id: CommunityId, + relay_pubkey: &[u8], + issuer: &str, + subject: &str, + subject_pubkey: &[u8], +) -> Result> { + validate_pubkey(relay_pubkey)?; + validate_pubkey(subject_pubkey)?; + if issuer.is_empty() || subject.is_empty() { + return Err(DbError::InvalidData( + "public projection principal is invalid".to_owned(), + )); + } + let mut tx = db.pool.begin().await?; + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut *tx) + .await?; + lock_identity_coordinates_tx( + &mut tx, + vec![key_lock_coordinate(community_id, subject_pubkey)], + ) + .await?; + let Some(origin) = authoritative_binding_for_exact_principal_tx( + &mut tx, + community_id, + issuer, + subject, + subject_pubkey, + ) + .await? + else { + tx.rollback().await?; + return Ok(None); + }; + let current = + current_projection_tx(&mut tx, community_id, relay_pubkey, subject_pubkey).await?; + Ok(Some(ActivePublicProjectionPermit { + tx, + community_id, + relay_pubkey: relay_pubkey.to_vec(), + subject_pubkey: subject_pubkey.to_vec(), + origin, + current, + })) +} + +/// Materialize committed O3 revoke/rotate operations as retryable O4 work. +pub async fn materialize_public_projection_retirements( + db: &Db, + domains: &[CommunityId], + relay_pubkey: &[u8], +) -> Result { + validate_pubkey(relay_pubkey)?; + if domains.is_empty() { + return Ok(0); + } + let domain_ids = domains + .iter() + .map(|domain| *domain.as_uuid()) + .collect::>(); + let result = sqlx::query( + r#" + INSERT INTO identity_public_projection_retirements + (community_id, operation_id, relay_pubkey, old_pubkey, + source_binding_id, source_binding_version, operation_kind) + SELECT operation.community_id, operation.operation_id, $2, + operation.pubkey, retired.binding_id, + retired.binding_version - 1, operation.operation_kind + FROM identity_lifecycle_operations operation + LEFT JOIN LATERAL ( + SELECT history.binding_id, history.binding_version + FROM identity_binding_history history + WHERE history.community_id=operation.community_id + AND history.operation_id=operation.operation_id + AND history.binding_id IS NOT DISTINCT FROM operation.binding_id + AND history.pubkey=operation.pubkey + AND history.binding_state IN ('revoked', 'rotated') + AND history.binding_version > 1 + ORDER BY history.recorded_at DESC, history.history_id + LIMIT 1 + ) retired ON TRUE + WHERE operation.community_id = ANY($1) + AND operation.operation_kind IN ('revoke_key', 'rotate') + AND operation.pubkey IS NOT NULL + ON CONFLICT (community_id, operation_id, relay_pubkey) DO NOTHING + "#, + ) + .bind(domain_ids) + .bind(relay_pubkey) + .execute(&db.pool) + .await?; + Ok(result.rows_affected()) +} + +/// Retryable retirement work claimed by one relay replica. +#[derive(Clone, PartialEq, Eq)] +pub struct ProjectionRetirementClaim { + community_id: CommunityId, + operation_id: Uuid, + relay_pubkey: Vec, + old_pubkey: Vec, + source_origin: Option, + claim_token: Uuid, +} + +impl ProjectionRetirementClaim { + /// Server-resolved authorization domain. + pub const fn community_id(&self) -> CommunityId { + self.community_id + } + + /// Public subject key whose old assertion may need retirement. + pub fn old_pubkey(&self) -> &[u8] { + &self.old_pubkey + } + + /// Exact retired source generation, when the lifecycle transition had one. + pub const fn source_origin(&self) -> Option { + self.source_origin + } +} + +impl fmt::Debug for ProjectionRetirementClaim { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProjectionRetirementClaim") + .field("community_id", &"[redacted]") + .field("operation_id", &"[redacted]") + .field("relay_pubkey", &"[redacted]") + .field("old_pubkey", &"[redacted]") + .field("source_origin", &"[redacted]") + .finish() + } +} + +async fn claim_next( + db: &Db, + domains: &[CommunityId], + relay_pubkey: &[u8], + phase: &str, +) -> Result> { + validate_pubkey(relay_pubkey)?; + if domains.is_empty() { + return Ok(None); + } + let domain_ids = domains + .iter() + .map(|domain| *domain.as_uuid()) + .collect::>(); + let lease_seconds = i64::try_from(CLAIM_LEASE.as_secs()) + .map_err(|_| DbError::InvalidData("projection claim lease is invalid".to_owned()))?; + let row = sqlx::query( + r#" + WITH candidate AS ( + SELECT community_id, operation_id, relay_pubkey + FROM identity_public_projection_retirements + WHERE community_id=ANY($1) AND relay_pubkey=$2 AND phase=$3 + AND next_attempt_at <= NOW() + AND (claim_token IS NULL OR lease_until <= NOW()) + ORDER BY next_attempt_at, community_id, operation_id + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE identity_public_projection_retirements work + SET claim_token=gen_random_uuid(), + lease_until=NOW() + ($4::DOUBLE PRECISION * INTERVAL '1 second'), + attempts=attempts+1, + updated_at=NOW() + FROM candidate + WHERE work.community_id=candidate.community_id + AND work.operation_id=candidate.operation_id + AND work.relay_pubkey=candidate.relay_pubkey + RETURNING work.community_id, work.operation_id, work.relay_pubkey, + work.old_pubkey, work.source_binding_id, + work.source_binding_version, work.claim_token + "#, + ) + .bind(domain_ids) + .bind(relay_pubkey) + .bind(phase) + .bind(lease_seconds) + .fetch_optional(&db.pool) + .await?; + row.map(|row| { + let source_binding_id: Option = row.try_get("source_binding_id")?; + let source_binding_version: Option = row.try_get("source_binding_version")?; + let source_origin = match (source_binding_id, source_binding_version) { + (Some(binding_id), Some(binding_version)) => Some(ProjectionBindingOrigin { + binding_id, + binding_version: checked_version(binding_version)?, + }), + (None, None) => None, + _ => { + return Err(DbError::InvalidData( + "projection retirement source is incomplete".to_owned(), + )) + } + }; + Ok(ProjectionRetirementClaim { + community_id: CommunityId::from_uuid(row.try_get("community_id")?), + operation_id: row.try_get("operation_id")?, + relay_pubkey: row.try_get("relay_pubkey")?, + old_pubkey: row.try_get("old_pubkey")?, + source_origin, + claim_token: row.try_get("claim_token")?, + }) + }) + .transpose() +} + +/// Claim one ready public-projection retirement operation. +pub async fn claim_public_projection_retirement( + db: &Db, + domains: &[CommunityId], + relay_pubkey: &[u8], +) -> Result> { + claim_next(db, domains, relay_pubkey, "projection").await +} + +/// Transaction-owned view of one claimed retirement. +pub struct ProjectionRetirementPermit { + tx: Transaction<'static, Postgres>, + claim: ProjectionRetirementClaim, + current: Option, + head: Option, + active_origin: Option, +} + +impl fmt::Debug for ProjectionRetirementPermit { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProjectionRetirementPermit") + .field("claim", &self.claim) + .field("current", &self.current.as_ref().map(|_| "[event]")) + .field("head", &self.head) + .field("active_origin", &"[redacted]") + .finish_non_exhaustive() + } +} + +impl ProjectionRetirementPermit { + /// Public subject key selected by the committed lifecycle operation. + pub fn old_pubkey(&self) -> &[u8] { + &self.claim.old_pubkey + } + + /// Current event at the locked assertion coordinate. + pub fn current_projection(&self) -> Option<&StoredEvent> { + self.current.as_ref() + } + + /// Current private projection ownership metadata. + pub const fn head(&self) -> Option { + self.head + } + + /// Current authoritative binding for the key, if it has been reused. + pub const fn active_origin(&self) -> Option { + self.active_origin + } + + /// Retired binding generation carried by the durable lifecycle work. + pub const fn source_origin(&self) -> Option { + self.claim.source_origin + } + + fn head_for_current(&self, disposition: ProjectionDisposition) -> Result { + let current = self.current.as_ref().ok_or_else(|| { + DbError::InvalidData("public projection retirement has no current event".to_owned()) + })?; + validate_event_coordinate( + ¤t.event, + &self.claim.relay_pubkey, + &self.claim.old_pubkey, + )?; + validate_event_disposition(¤t.event, disposition)?; + let head = self.head.ok_or_else(|| { + DbError::InvalidData("public projection ownership is unavailable".to_owned()) + })?; + if head.event_id != *current.event.id.as_bytes() || head.disposition != disposition { + return Err(DbError::InvalidData( + "public projection ownership does not match the current event".to_owned(), + )); + } + Ok(head) + } + + fn current_can_be_retired_by_source(&self) -> Result { + let (current, head) = match (self.current.as_ref(), self.head) { + (None, None) => return Ok(true), + (None, Some(_)) => { + return Err(DbError::InvalidData( + "public projection ownership exists without an event".to_owned(), + )) + } + (Some(_), None) => return Ok(true), + (Some(current), Some(head)) => (current, head), + }; + validate_event_coordinate( + ¤t.event, + &self.claim.relay_pubkey, + &self.claim.old_pubkey, + )?; + validate_event_disposition(¤t.event, head.disposition)?; + if head.event_id != *current.event.id.as_bytes() { + return Err(DbError::InvalidData( + "public projection ownership does not match the current event".to_owned(), + )); + } + Ok(match (self.claim.source_origin, head.origin) { + (_, None) => true, + (None, Some(_)) => false, + (Some(source), Some(origin)) if source.binding_id == origin.binding_id => { + origin.binding_version <= source.binding_version + } + (Some(_), Some(_)) => false, + }) + } + + async fn finish_terminal(mut self, phase: &str, outcome: &str) -> Result<()> { + let changed = sqlx::query( + "UPDATE identity_public_projection_retirements \ + SET phase=$5, outcome=$6, claim_token=NULL, lease_until=NULL, \ + completed_at=NOW(), updated_at=NOW() \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND claim_token=$4 AND phase='projection' AND lease_until > NOW()", + ) + .bind(self.claim.community_id.as_uuid()) + .bind(self.claim.operation_id) + .bind(&self.claim.relay_pubkey) + .bind(self.claim.claim_token) + .bind(phase) + .bind(outcome) + .execute(&mut *self.tx) + .await?; + if changed.rows_affected() != 1 { + return Err(DbError::InvalidData( + "public projection retirement claim expired".to_owned(), + )); + } + self.tx.commit().await?; + Ok(()) + } + + /// Complete a job whose assertion coordinate is empty. + pub async fn finish_no_projection(self) -> Result<()> { + if self.current.is_some() || self.head.is_some() { + return Err(DbError::InvalidData( + "public projection coordinate is not empty".to_owned(), + )); + } + self.finish_terminal("completed", "no_projection").await + } + + /// Preserve a newer legitimate binding and finish the stale job. + pub async fn finish_superseded(self, current: &Event) -> Result<()> { + let origin = self.active_origin.ok_or_else(|| { + DbError::InvalidData("projection supersession lacks active binding".to_owned()) + })?; + validate_event_coordinate(current, &self.claim.relay_pubkey, &self.claim.old_pubkey)?; + validate_event_disposition(current, ProjectionDisposition::Active)?; + let current_id = self + .current + .as_ref() + .map(|stored| stored.event.id) + .ok_or_else(|| { + DbError::InvalidData("public projection retirement has no current event".to_owned()) + })?; + let head = self.head_for_current(ProjectionDisposition::Active)?; + if current.id != current_id + || head.origin != Some(origin) + || self.claim.source_origin == Some(origin) + { + return Err(DbError::InvalidData( + "active binding does not own the current public projection".to_owned(), + )); + } + self.finish_terminal("superseded", "newer_binding").await + } + + /// Preserve a projection owned by a later binding generation. + pub async fn finish_newer_projection(self) -> Result<()> { + let head = self.head_for_current(ProjectionDisposition::Active)?; + let origin = head.origin.ok_or_else(|| { + DbError::InvalidData("newer public projection lacks an owner".to_owned()) + })?; + let is_newer = match self.claim.source_origin { + None => true, + Some(source) if source.binding_id == origin.binding_id => { + origin.binding_version > source.binding_version + } + Some(source) => source != origin, + }; + if !is_newer { + return Err(DbError::InvalidData( + "public projection is not owned by a later generation".to_owned(), + )); + } + self.finish_terminal("superseded", "newer_projection").await + } + + /// Finish a stale/replayed retirement when the exact coordinate is already + /// the canonical inactive projection. + /// + /// This is a metadata-only convergence path: it preserves the event and + /// ownership head byte-for-byte and never relabels an old assertion to the + /// replayed job's source generation. + pub async fn finish_existing_inactive(self) -> Result<()> { + let current = self.current.as_ref().ok_or_else(|| { + DbError::InvalidData("public projection retirement has no current event".to_owned()) + })?; + validate_event_coordinate( + ¤t.event, + &self.claim.relay_pubkey, + &self.claim.old_pubkey, + )?; + validate_event_disposition(¤t.event, ProjectionDisposition::Inactive)?; + if let Some(head) = self.head { + if head.event_id != *current.event.id.as_bytes() + || head.disposition != ProjectionDisposition::Inactive + { + return Err(DbError::InvalidData( + "public projection ownership does not match the current event".to_owned(), + )); + } + } + self.finish_terminal("completed", "already_inactive").await + } + + /// Atomically install/accept the canonical inactive event and queue delivery. + pub async fn finish_inactive(mut self, inactive: &Event) -> Result { + if !self.current_can_be_retired_by_source()? { + return Err(DbError::InvalidData( + "public projection belongs to a later binding generation".to_owned(), + )); + } + let subject = + validate_event_coordinate(inactive, &self.claim.relay_pubkey, &self.claim.old_pubkey)?; + validate_event_disposition(inactive, ProjectionDisposition::Inactive)?; + let (candidate, inserted) = event::replace_parameterized_event_tx( + &mut self.tx, + self.claim.community_id, + inactive, + &subject, + None, + ) + .await?; + let stored = if inserted { + candidate + } else { + let current = current_projection_tx( + &mut self.tx, + self.claim.community_id, + &self.claim.relay_pubkey, + &self.claim.old_pubkey, + ) + .await? + .ok_or_else(|| { + DbError::InvalidData("inactive projection replacement disappeared".to_owned()) + })?; + if current.event.id != inactive.id { + return Err(DbError::InvalidData( + "inactive projection candidate lost ordering".to_owned(), + )); + } + current + }; + upsert_projection_head_tx( + &mut self.tx, + self.claim.community_id, + &self.claim.relay_pubkey, + &self.claim.old_pubkey, + &stored.event, + ProjectionDisposition::Inactive, + self.claim.source_origin, + ) + .await?; + let changed = sqlx::query( + "UPDATE identity_public_projection_retirements \ + SET phase='delivery', outcome=$5, event_id=$6, claim_token=NULL, \ + lease_until=NULL, next_attempt_at=NOW(), updated_at=NOW() \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND claim_token=$4 AND phase='projection' AND lease_until > NOW()", + ) + .bind(self.claim.community_id.as_uuid()) + .bind(self.claim.operation_id) + .bind(&self.claim.relay_pubkey) + .bind(self.claim.claim_token) + .bind(if inserted { + "replaced_inactive" + } else { + "already_inactive" + }) + .bind(stored.event.id.as_bytes().as_slice()) + .execute(&mut *self.tx) + .await?; + if changed.rows_affected() != 1 { + return Err(DbError::InvalidData( + "public projection retirement claim expired".to_owned(), + )); + } + self.tx.commit().await?; + Ok(stored) + } + + /// Release retryable work with bounded backoff and no authority change. + pub async fn defer(mut self) -> Result<()> { + sqlx::query( + "UPDATE identity_public_projection_retirements \ + SET claim_token=NULL, lease_until=NULL, \ + next_attempt_at=NOW() + (LEAST(60, GREATEST(1, attempts))::DOUBLE PRECISION * INTERVAL '1 second'), \ + updated_at=NOW() \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND claim_token=$4 AND phase='projection'", + ) + .bind(self.claim.community_id.as_uuid()) + .bind(self.claim.operation_id) + .bind(&self.claim.relay_pubkey) + .bind(self.claim.claim_token) + .execute(&mut *self.tx) + .await?; + self.tx.commit().await?; + Ok(()) + } +} + +/// Revalidate and lock one claimed retirement through its event commit boundary. +pub async fn begin_public_projection_retirement( + db: &Db, + claim: ProjectionRetirementClaim, +) -> Result { + let mut tx = db.pool.begin().await?; + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut *tx) + .await?; + lock_identity_coordinates_tx( + &mut tx, + vec![key_lock_coordinate(claim.community_id, &claim.old_pubkey)], + ) + .await?; + let claimed = sqlx::query( + "SELECT 1 FROM identity_public_projection_retirements \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND claim_token=$4 AND phase='projection' AND lease_until > NOW() FOR UPDATE", + ) + .bind(claim.community_id.as_uuid()) + .bind(claim.operation_id) + .bind(&claim.relay_pubkey) + .bind(claim.claim_token) + .fetch_optional(&mut *tx) + .await? + .is_some(); + if !claimed { + return Err(DbError::InvalidData( + "public projection retirement claim expired".to_owned(), + )); + } + let active_origin = + authoritative_binding_for_key_tx(&mut tx, claim.community_id, &claim.old_pubkey).await?; + let head = projection_head_tx( + &mut tx, + claim.community_id, + &claim.relay_pubkey, + &claim.old_pubkey, + ) + .await?; + let current = current_projection_tx( + &mut tx, + claim.community_id, + &claim.relay_pubkey, + &claim.old_pubkey, + ) + .await?; + Ok(ProjectionRetirementPermit { + tx, + claim, + current, + head, + active_origin, + }) +} + +/// Delivery work retained until Redis and local fan-out have both been attempted. +pub struct ProjectionDeliveryPermit { + tx: Transaction<'static, Postgres>, + claim: ProjectionRetirementClaim, + stored: StoredEvent, +} + +impl fmt::Debug for ProjectionDeliveryPermit { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProjectionDeliveryPermit") + .field("claim", &self.claim) + .field("stored", &"[event]") + .finish_non_exhaustive() + } +} + +impl ProjectionDeliveryPermit { + /// Exact current inactive event retained under the identity-key lock. + pub const fn stored(&self) -> &StoredEvent { + &self.stored + } + + /// Server-resolved authorization domain. + pub const fn community_id(&self) -> CommunityId { + self.claim.community_id + } + + /// Mark delivery complete while the exact projection head remains locked. + pub async fn complete(mut self) -> Result<()> { + let changed = sqlx::query( + "UPDATE identity_public_projection_retirements \ + SET phase='completed', claim_token=NULL, lease_until=NULL, \ + completed_at=NOW(), updated_at=NOW() \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND claim_token=$4 AND phase='delivery' AND lease_until > NOW()", + ) + .bind(self.claim.community_id.as_uuid()) + .bind(self.claim.operation_id) + .bind(&self.claim.relay_pubkey) + .bind(self.claim.claim_token) + .execute(&mut *self.tx) + .await?; + if changed.rows_affected() != 1 { + return Err(DbError::InvalidData( + "public projection delivery claim expired".to_owned(), + )); + } + self.tx.commit().await?; + Ok(()) + } + + /// Release delivery for bounded retry without changing the event head. + pub async fn defer(mut self) -> Result<()> { + sqlx::query( + "UPDATE identity_public_projection_retirements \ + SET claim_token=NULL, lease_until=NULL, \ + next_attempt_at=NOW() + (LEAST(60, GREATEST(1, attempts))::DOUBLE PRECISION * INTERVAL '1 second'), \ + updated_at=NOW() \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND claim_token=$4 AND phase='delivery'", + ) + .bind(self.claim.community_id.as_uuid()) + .bind(self.claim.operation_id) + .bind(&self.claim.relay_pubkey) + .bind(self.claim.claim_token) + .execute(&mut *self.tx) + .await?; + self.tx.commit().await?; + Ok(()) + } +} + +/// Claim and lock one pending inactive-event delivery. +pub async fn begin_public_projection_delivery( + db: &Db, + domains: &[CommunityId], + relay_pubkey: &[u8], +) -> Result> { + let Some(claim) = claim_next(db, domains, relay_pubkey, "delivery").await? else { + return Ok(None); + }; + let mut tx = db.pool.begin().await?; + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut *tx) + .await?; + lock_identity_coordinates_tx( + &mut tx, + vec![key_lock_coordinate(claim.community_id, &claim.old_pubkey)], + ) + .await?; + let row = sqlx::query( + "SELECT event_id FROM identity_public_projection_retirements \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND claim_token=$4 AND phase='delivery' AND lease_until > NOW() FOR UPDATE", + ) + .bind(claim.community_id.as_uuid()) + .bind(claim.operation_id) + .bind(&claim.relay_pubkey) + .bind(claim.claim_token) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| DbError::InvalidData("public projection delivery claim expired".to_owned()))?; + let expected_id: Vec = row.try_get("event_id")?; + let current = current_projection_tx( + &mut tx, + claim.community_id, + &claim.relay_pubkey, + &claim.old_pubkey, + ) + .await?; + let head = projection_head_tx( + &mut tx, + claim.community_id, + &claim.relay_pubkey, + &claim.old_pubkey, + ) + .await?; + let head_matches_inactive = head.is_some_and(|head| { + head.event_id.as_slice() == expected_id.as_slice() + && head.disposition == ProjectionDisposition::Inactive + }); + let Some(stored) = current + .as_ref() + .filter(|stored| { + stored.event.id.as_bytes().as_slice() == expected_id.as_slice() && head_matches_inactive + }) + .cloned() + else { + let expected = + projection_by_id_including_deleted_tx(&mut tx, claim.community_id, &expected_id) + .await?; + let later_projection_is_proven = match ( + current.as_ref(), + head, + expected.as_ref(), + claim.source_origin, + ) { + (Some(current), Some(head), Some(expected), Some(source)) => { + canonical_later_projection_is_proven( + current, + head, + expected, + source, + &claim.relay_pubkey, + &claim.old_pubkey, + )? + } + _ => false, + }; + if !later_projection_is_proven { + return Err(DbError::InvalidData( + "public projection delivery lost its canonical ownership proof".to_owned(), + )); + } + let changed = sqlx::query( + "UPDATE identity_public_projection_retirements \ + SET phase='superseded', outcome='newer_projection', claim_token=NULL, \ + lease_until=NULL, completed_at=NOW(), updated_at=NOW() \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND claim_token=$4 AND phase='delivery' AND lease_until > NOW()", + ) + .bind(claim.community_id.as_uuid()) + .bind(claim.operation_id) + .bind(&claim.relay_pubkey) + .bind(claim.claim_token) + .execute(&mut *tx) + .await?; + if changed.rows_affected() != 1 { + return Err(DbError::InvalidData( + "public projection delivery claim expired".to_owned(), + )); + } + tx.commit().await?; + return Ok(None); + }; + validate_event_coordinate(&stored.event, &claim.relay_pubkey, &claim.old_pubkey)?; + validate_event_disposition(&stored.event, ProjectionDisposition::Inactive)?; + Ok(Some(ProjectionDeliveryPermit { tx, claim, stored })) +} + +/// Count unfinished work for a relay author and exact domain set. +pub async fn unfinished_public_projection_retirements( + db: &Db, + domains: &[CommunityId], + relay_pubkey: &[u8], +) -> Result { + validate_pubkey(relay_pubkey)?; + if domains.is_empty() { + return Ok(0); + } + let domain_ids = domains + .iter() + .map(|domain| *domain.as_uuid()) + .collect::>(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_public_projection_retirements \ + WHERE community_id=ANY($1) AND relay_pubkey=$2 \ + AND phase IN ('projection', 'delivery')", + ) + .bind(domain_ids) + .bind(relay_pubkey) + .fetch_one(&db.pool) + .await?; + u64::try_from(count) + .map_err(|_| DbError::InvalidData("projection retirement count is invalid".to_owned())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity_binding::{ + resolve_identity_binding, BindingProvenance, EnrollmentMode, ResolveBindingInput, + ResolveBindingResult, + }; + use crate::identity_lifecycle::{ + revoke_identity_key, rotate_identity_binding, IdentityPrincipal, LifecycleContext, + VerifiedReplacementKey, + }; + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + use sqlx::PgPool; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const ISSUER: &str = "https://idp.example"; + + async fn setup() -> (Db, CommunityId) { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + let pool = PgPool::connect(&database_url).await.expect("test database"); + crate::migration::run_migrations(&pool) + .await + .expect("run migrations"); + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1,$2)") + .bind(id) + .bind(format!("public-projection-{}.example", id.simple())) + .execute(&pool) + .await + .expect("insert community"); + (Db::from_pool(pool), CommunityId::from_uuid(id)) + } + + fn assertion(keys: &Keys, subject: nostr::PublicKey, active: bool, at: u64) -> Event { + let subject = subject.to_hex(); + let active_value = if active { "true" } else { "false" }; + let expiration = if active { "500" } else { "0" }; + let mut tags = vec![ + Tag::parse(["d", subject.as_str()]).expect("d tag"), + Tag::parse(["p", subject.as_str()]).expect("p tag"), + Tag::parse(["verified", "relay"]).expect("verified tag"), + Tag::parse(["active", active_value]).expect("active tag"), + Tag::parse(["expiration", expiration]).expect("expiration tag"), + ]; + if active { + tags.push(Tag::parse(["display_name", "Approved Label"]).expect("display tag")); + } + EventBuilder::new(Kind::Custom(ASSERTION_KIND as u16), "") + .tags(tags) + .custom_created_at(Timestamp::from(at)) + .sign_with_keys(keys) + .expect("sign assertion") + } + + fn stored(event: Event) -> StoredEvent { + StoredEvent::with_received_at(event, Utc::now(), None, true) + } + + #[test] + fn delivery_supersession_requires_a_canonical_later_projection_and_owner() { + let relay = Keys::generate(); + let subject = Keys::generate().public_key(); + let source = ProjectionBindingOrigin { + binding_id: Uuid::new_v4(), + binding_version: 7, + }; + let later = ProjectionBindingOrigin { + binding_id: source.binding_id, + binding_version: 8, + }; + let expected = stored(assertion(&relay, subject, false, 100)); + let current = stored(assertion(&relay, subject, true, 101)); + let head = ProjectionHead { + event_id: *current.event.id.as_bytes(), + disposition: ProjectionDisposition::Active, + origin: Some(later), + }; + + assert!(canonical_later_projection_is_proven( + ¤t, + head, + &expected, + source, + relay.public_key().as_bytes(), + subject.as_bytes(), + ) + .expect("canonical proof")); + + let stale_head = ProjectionHead { + event_id: *expected.event.id.as_bytes(), + ..head + }; + assert!(!canonical_later_projection_is_proven( + ¤t, + stale_head, + &expected, + source, + relay.public_key().as_bytes(), + subject.as_bytes(), + ) + .expect("stale head is a denied proof")); + + let unreplaced_head = ProjectionHead { + origin: Some(source), + ..head + }; + assert!(!canonical_later_projection_is_proven( + ¤t, + unreplaced_head, + &expected, + source, + relay.public_key().as_bytes(), + subject.as_bytes(), + ) + .expect("same owner generation is a denied proof")); + + let noncanonical_expected = stored(assertion(&relay, subject, true, 100)); + assert!(canonical_later_projection_is_proven( + ¤t, + head, + &noncanonical_expected, + source, + relay.public_key().as_bytes(), + subject.as_bytes(), + ) + .is_err()); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn delivery_converges_only_after_a_later_projection_owns_the_coordinate() { + let (db, community) = setup().await; + let relay = Keys::generate(); + let subject = Keys::generate().public_key(); + let source = match resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: ISSUER, + subject: "delivery-race-subject", + pubkey: subject.as_bytes(), + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + }, + ) + .await + .expect("enroll binding") + { + ResolveBindingResult::Enrolled(evidence) => ProjectionBindingOrigin { + binding_id: evidence.binding_id, + binding_version: evidence.binding_version, + }, + other => panic!("unexpected binding result: {other:?}"), + }; + let active = assertion(&relay, subject, true, 100); + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + ISSUER, + "delivery-race-subject", + subject.as_bytes(), + ) + .await + .expect("begin active projection") + .expect("active binding") + .commit(&active, ProjectionDisposition::Active) + .await + .expect("commit active projection"); + + let operation_id = Uuid::new_v4(); + revoke_identity_key( + &db.pool, + community, + LifecycleContext { + operation_id, + actor: None, + reason: "delivery race", + }, + subject.as_bytes(), + ) + .await + .expect("commit revocation"); + materialize_public_projection_retirements(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("materialize retirement"); + let claim = + claim_public_projection_retirement(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("claim retirement") + .expect("retirement exists"); + let expected = assertion(&relay, subject, false, 101); + begin_public_projection_retirement(&db, claim) + .await + .expect("begin retirement") + .finish_inactive(&expected) + .await + .expect("commit inactive projection"); + + let later = assertion(&relay, subject, true, 102); + let later_origin = ProjectionBindingOrigin { + binding_id: source.binding_id, + binding_version: source.binding_version + 1, + }; + let mut tx = db.pool.begin().await.expect("begin replacement"); + let subject_hex = subject.to_hex(); + let (_, inserted) = + event::replace_parameterized_event_tx(&mut tx, community, &later, &subject_hex, None) + .await + .expect("replace inactive projection"); + assert!(inserted, "later projection must win canonical ordering"); + upsert_projection_head_tx( + &mut tx, + community, + relay.public_key().as_bytes(), + subject.as_bytes(), + &later, + ProjectionDisposition::Active, + Some(later_origin), + ) + .await + .expect("install later projection ownership"); + tx.commit().await.expect("commit later projection"); + + assert!( + begin_public_projection_delivery(&db, &[community], relay.public_key().as_bytes(),) + .await + .expect("converge stale delivery") + .is_none(), + "a fully proven later projection must terminalize stale delivery" + ); + assert_eq!( + unfinished_public_projection_retirements( + &db, + &[community], + relay.public_key().as_bytes(), + ) + .await + .expect("count unfinished work"), + 0 + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn committed_revoke_materializes_retries_and_completes_exactly_once() { + let (db, community) = setup().await; + let relay = Keys::generate(); + let subject_keys = Keys::generate(); + let subject = subject_keys.public_key(); + let subject_bytes = subject.to_bytes(); + let resolved = resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: ISSUER, + subject: "subject-one", + pubkey: subject_bytes.as_slice(), + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + }, + ) + .await + .expect("enroll binding"); + let expected_origin = match resolved { + ResolveBindingResult::Enrolled(evidence) => ProjectionBindingOrigin { + binding_id: evidence.binding_id, + binding_version: evidence.binding_version, + }, + other => panic!("unexpected binding result: {other:?}"), + }; + assert_eq!(expected_origin.binding_version(), 1); + + let active = assertion(&relay, subject, true, 100); + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + ISSUER, + "subject-one", + subject.as_bytes(), + ) + .await + .expect("begin active projection") + .expect("active binding") + .commit(&active, ProjectionDisposition::Active) + .await + .expect("commit active projection"); + + let operation_id = Uuid::new_v4(); + revoke_identity_key( + &db.pool, + community, + LifecycleContext { + operation_id, + actor: None, + reason: "test revocation", + }, + subject.as_bytes(), + ) + .await + .expect("commit revocation"); + + assert_eq!( + materialize_public_projection_retirements( + &db, + &[community], + relay.public_key().as_bytes(), + ) + .await + .expect("materialize work"), + 1 + ); + assert_eq!( + materialize_public_projection_retirements( + &db, + &[community], + relay.public_key().as_bytes(), + ) + .await + .expect("duplicate discovery is idempotent"), + 0 + ); + let domains = [community]; + let relay_pubkey = relay.public_key().to_bytes(); + let (first_replica, second_replica) = tokio::join!( + claim_public_projection_retirement(&db, &domains, relay_pubkey.as_slice()), + claim_public_projection_retirement(&db, &domains, relay_pubkey.as_slice()), + ); + let mut claims = [ + first_replica.expect("first replica claim"), + second_replica.expect("second replica claim"), + ] + .into_iter() + .flatten() + .collect::>(); + assert_eq!(claims.len(), 1, "only one replica may own the claim"); + let stale_claim = claims.pop().expect("one claim"); + sqlx::query( + "UPDATE identity_public_projection_retirements \ + SET lease_until=NOW() - INTERVAL '1 second' \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND claim_token=$4", + ) + .bind(community.as_uuid()) + .bind(operation_id) + .bind(relay.public_key().as_bytes()) + .bind(stale_claim.claim_token) + .execute(&db.pool) + .await + .expect("simulate crashed claim owner"); + assert!( + begin_public_projection_retirement(&db, stale_claim) + .await + .is_err(), + "an expired owner must not mutate the projection" + ); + let claim = + claim_public_projection_retirement(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("reclaim crashed work") + .expect("reclaimable work exists"); + assert_eq!(claim.source_origin(), Some(expected_origin)); + let permit = begin_public_projection_retirement(&db, claim) + .await + .expect("begin retirement"); + assert_eq!(permit.active_origin(), None); + assert_eq!( + permit.head().and_then(ProjectionHead::origin), + Some(expected_origin) + ); + let inactive = assertion(&relay, subject, false, 101); + permit + .finish_inactive(&inactive) + .await + .expect("commit inactive projection"); + + let delivery = + begin_public_projection_delivery(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("claim delivery") + .expect("delivery exists"); + assert_eq!(delivery.stored().event.id, inactive.id); + delivery.defer().await.expect("defer failed delivery"); + sqlx::query( + "UPDATE identity_public_projection_retirements \ + SET next_attempt_at=NOW() \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3 \ + AND phase='delivery'", + ) + .bind(community.as_uuid()) + .bind(operation_id) + .bind(relay.public_key().as_bytes()) + .execute(&db.pool) + .await + .expect("make deferred delivery ready"); + let delivery = + begin_public_projection_delivery(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("retry delivery") + .expect("deferred delivery exists"); + assert_eq!(delivery.stored().event.id, inactive.id); + delivery.complete().await.expect("complete delivery"); + assert_eq!( + unfinished_public_projection_retirements( + &db, + &[community], + relay.public_key().as_bytes(), + ) + .await + .expect("count unfinished"), + 0 + ); + assert!(claim_public_projection_retirement( + &db, + &[community], + relay.public_key().as_bytes(), + ) + .await + .expect("replay claim") + .is_none()); + assert!( + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + ISSUER, + "subject-one", + subject.as_bytes(), + ) + .await + .expect("revalidate revoked principal") + .is_none(), + "committed revocation must prevent assertion reactivation" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn committed_rotation_retires_only_the_old_projection_generation() { + let (db, community) = setup().await; + let relay = Keys::generate(); + let old_keys = Keys::generate(); + let new_keys = Keys::generate(); + let old_key = old_keys.public_key(); + let new_key = new_keys.public_key(); + let old_origin = match resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: ISSUER, + subject: "rotated-subject", + pubkey: old_key.as_bytes(), + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + }, + ) + .await + .expect("enroll rotation source") + { + ResolveBindingResult::Enrolled(evidence) => ProjectionBindingOrigin { + binding_id: evidence.binding_id, + binding_version: evidence.binding_version, + }, + other => panic!("unexpected rotation enrollment: {other:?}"), + }; + let old_active = assertion(&relay, old_key, true, 200); + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + ISSUER, + "rotated-subject", + old_key.as_bytes(), + ) + .await + .expect("begin old projection") + .expect("old binding is active") + .commit(&old_active, ProjectionDisposition::Active) + .await + .expect("commit old projection"); + + let operation_id = Uuid::new_v4(); + rotate_identity_binding( + &db.pool, + community, + LifecycleContext { + operation_id, + actor: None, + reason: "test rotation", + }, + IdentityPrincipal { + issuer: ISSUER, + subject: "rotated-subject", + }, + old_key.as_bytes(), + VerifiedReplacementKey::after_verified_proof( + new_key.as_bytes(), + None, + BindingProvenance::AttestedKey, + Some("test-policy-v1"), + ) + .expect("verified replacement"), + ) + .await + .expect("commit rotation"); + + let new_active = assertion(&relay, new_key, true, 201); + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + ISSUER, + "rotated-subject", + new_key.as_bytes(), + ) + .await + .expect("begin replacement projection") + .expect("replacement binding is active") + .commit(&new_active, ProjectionDisposition::Active) + .await + .expect("commit replacement projection"); + + assert_eq!( + materialize_public_projection_retirements( + &db, + &[community], + relay.public_key().as_bytes(), + ) + .await + .expect("materialize rotation"), + 1 + ); + let claim = + claim_public_projection_retirement(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("claim rotation") + .expect("rotation work exists"); + assert_eq!(claim.source_origin(), Some(old_origin)); + let permit = begin_public_projection_retirement(&db, claim) + .await + .expect("begin old projection retirement"); + assert_eq!(permit.active_origin(), None); + let old_inactive = assertion(&relay, old_key, false, 202); + permit + .finish_inactive(&old_inactive) + .await + .expect("retire old projection"); + + let mut tx = db.pool.begin().await.expect("inspect projections"); + let current_old = current_projection_tx( + &mut tx, + community, + relay.public_key().as_bytes(), + old_key.as_bytes(), + ) + .await + .expect("read old projection") + .expect("old projection exists"); + let current_new = current_projection_tx( + &mut tx, + community, + relay.public_key().as_bytes(), + new_key.as_bytes(), + ) + .await + .expect("read replacement projection") + .expect("replacement projection exists"); + tx.rollback().await.expect("rollback inspection"); + assert_eq!(current_old.event.id, old_inactive.id); + assert_eq!(current_new.event.id, new_active.id); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn active_key_reuse_without_republication_cannot_claim_the_old_assertion() { + let (db, community) = setup().await; + let relay = Keys::generate(); + let reused_keys = Keys::generate(); + let replacement_keys = Keys::generate(); + let reused_key = reused_keys.public_key(); + let replacement_key = replacement_keys.public_key(); + let original_origin = match resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: ISSUER, + subject: "original-principal", + pubkey: reused_key.as_bytes(), + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + }, + ) + .await + .expect("enroll original binding") + { + ResolveBindingResult::Enrolled(evidence) => ProjectionBindingOrigin { + binding_id: evidence.binding_id, + binding_version: evidence.binding_version, + }, + other => panic!("unexpected original enrollment: {other:?}"), + }; + let original = assertion(&relay, reused_key, true, 250); + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + ISSUER, + "original-principal", + reused_key.as_bytes(), + ) + .await + .expect("begin original projection") + .expect("original binding is active") + .commit(&original, ProjectionDisposition::Active) + .await + .expect("publish original projection"); + + let operation_id = Uuid::new_v4(); + rotate_identity_binding( + &db.pool, + community, + LifecycleContext { + operation_id, + actor: None, + reason: "free key for unpublished reuse", + }, + IdentityPrincipal { + issuer: ISSUER, + subject: "original-principal", + }, + reused_key.as_bytes(), + VerifiedReplacementKey::after_verified_proof( + replacement_key.as_bytes(), + None, + BindingProvenance::AttestedKey, + Some("test-policy-v1"), + ) + .expect("verified replacement"), + ) + .await + .expect("rotate original binding"); + let reused_origin = match resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: "https://replacement-idp.example", + subject: "replacement-principal", + pubkey: reused_key.as_bytes(), + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + }, + ) + .await + .expect("reuse key before publishing replacement") + { + ResolveBindingResult::Enrolled(evidence) => ProjectionBindingOrigin { + binding_id: evidence.binding_id, + binding_version: evidence.binding_version, + }, + other => panic!("unexpected key reuse: {other:?}"), + }; + assert_ne!(reused_origin, original_origin); + + materialize_public_projection_retirements(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("materialize original retirement"); + let permit = begin_public_projection_retirement( + &db, + claim_public_projection_retirement(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("claim original retirement") + .expect("original retirement exists"), + ) + .await + .expect("begin original retirement"); + assert_eq!(permit.source_origin(), Some(original_origin)); + assert_eq!(permit.active_origin(), Some(reused_origin)); + assert_eq!( + permit.head().and_then(ProjectionHead::origin), + Some(original_origin), + "B has not published and must not own A's assertion" + ); + let current = permit + .current_projection() + .expect("original assertion remains current") + .event + .clone(); + assert!( + permit.finish_superseded(¤t).await.is_err(), + "active binding existence alone cannot transfer projection ownership" + ); + + let mut tx = db.pool.begin().await.expect("inspect unchanged projection"); + let stored = current_projection_tx( + &mut tx, + community, + relay.public_key().as_bytes(), + reused_key.as_bytes(), + ) + .await + .expect("read current projection") + .expect("current projection remains"); + let head = projection_head_tx( + &mut tx, + community, + relay.public_key().as_bytes(), + reused_key.as_bytes(), + ) + .await + .expect("read projection head") + .expect("projection head remains"); + tx.rollback().await.expect("rollback inspection"); + assert_eq!(stored.event.id, original.id); + assert_eq!(head.event_id(), *original.id.as_bytes()); + assert_eq!(head.origin(), Some(original_origin)); + assert_eq!( + unfinished_public_projection_retirements( + &db, + &[community], + relay.public_key().as_bytes(), + ) + .await + .expect("count retryable work"), + 1 + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn binding_version_advance_without_republication_is_not_a_newer_projection() { + let (db, community) = setup().await; + let relay = Keys::generate(); + let subject_keys = Keys::generate(); + let subject = subject_keys.public_key(); + let first_origin = match resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: ISSUER, + subject: "strengthened-principal", + pubkey: subject.as_bytes(), + display_name: None, + enrollment_mode: EnrollmentMode::Tofu, + key_attested: false, + }, + ) + .await + .expect("enroll tofu binding") + { + ResolveBindingResult::Enrolled(evidence) => ProjectionBindingOrigin { + binding_id: evidence.binding_id, + binding_version: evidence.binding_version, + }, + other => panic!("unexpected tofu enrollment: {other:?}"), + }; + let original = assertion(&relay, subject, true, 275); + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + ISSUER, + "strengthened-principal", + subject.as_bytes(), + ) + .await + .expect("begin original projection") + .expect("tofu binding is active") + .commit(&original, ProjectionDisposition::Active) + .await + .expect("publish version-one projection"); + let strengthened_origin = match resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: ISSUER, + subject: "strengthened-principal", + pubkey: subject.as_bytes(), + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + }, + ) + .await + .expect("strengthen binding without republishing") + { + ResolveBindingResult::Existing(evidence) => ProjectionBindingOrigin { + binding_id: evidence.binding_id, + binding_version: evidence.binding_version, + }, + other => panic!("unexpected strengthening result: {other:?}"), + }; + assert_eq!(strengthened_origin.binding_id(), first_origin.binding_id()); + assert!(strengthened_origin.binding_version() > first_origin.binding_version()); + + let operation_id = Uuid::new_v4(); + revoke_identity_key( + &db.pool, + community, + LifecycleContext { + operation_id, + actor: None, + reason: "revoke strengthened binding", + }, + subject.as_bytes(), + ) + .await + .expect("commit strengthened revocation"); + materialize_public_projection_retirements(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("materialize strengthened retirement"); + let permit = begin_public_projection_retirement( + &db, + claim_public_projection_retirement(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("claim strengthened retirement") + .expect("strengthened retirement exists"), + ) + .await + .expect("begin strengthened retirement"); + assert_eq!(permit.source_origin(), Some(strengthened_origin)); + assert_eq!(permit.active_origin(), None); + assert_eq!( + permit.head().and_then(ProjectionHead::origin), + Some(first_origin), + "the projection still belongs to version one" + ); + assert!( + permit.finish_newer_projection().await.is_err(), + "an older head cannot terminalize a later-generation retirement" + ); + assert_eq!( + unfinished_public_projection_retirements( + &db, + &[community], + relay.public_key().as_bytes(), + ) + .await + .expect("count retryable strengthened work"), + 1 + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn stale_rotation_work_cannot_retire_a_reused_key_projection() { + let (db, community) = setup().await; + let relay = Keys::generate(); + let reused_keys = Keys::generate(); + let replacement_keys = Keys::generate(); + let reused_key = reused_keys.public_key(); + let replacement_key = replacement_keys.public_key(); + resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: ISSUER, + subject: "first-principal", + pubkey: reused_key.as_bytes(), + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + }, + ) + .await + .expect("enroll first principal"); + let first = assertion(&relay, reused_key, true, 300); + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + ISSUER, + "first-principal", + reused_key.as_bytes(), + ) + .await + .expect("begin first projection") + .expect("first binding is active") + .commit(&first, ProjectionDisposition::Active) + .await + .expect("commit first projection"); + + let operation_id = Uuid::new_v4(); + rotate_identity_binding( + &db.pool, + community, + LifecycleContext { + operation_id, + actor: None, + reason: "free key for reuse test", + }, + IdentityPrincipal { + issuer: ISSUER, + subject: "first-principal", + }, + reused_key.as_bytes(), + VerifiedReplacementKey::after_verified_proof( + replacement_key.as_bytes(), + None, + BindingProvenance::AttestedKey, + Some("test-policy-v1"), + ) + .expect("verified replacement"), + ) + .await + .expect("rotate first principal"); + resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: "https://second-idp.example", + subject: "second-principal", + pubkey: reused_key.as_bytes(), + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + }, + ) + .await + .expect("reuse key for independent principal"); + let second = assertion(&relay, reused_key, true, 301); + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + "https://second-idp.example", + "second-principal", + reused_key.as_bytes(), + ) + .await + .expect("begin reused projection") + .expect("reused binding is active") + .commit(&second, ProjectionDisposition::Active) + .await + .expect("commit reused projection"); + + materialize_public_projection_retirements(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("materialize stale rotation"); + let claim = + claim_public_projection_retirement(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("claim stale rotation") + .expect("stale rotation exists"); + let permit = begin_public_projection_retirement(&db, claim) + .await + .expect("begin stale rotation"); + assert_ne!(permit.active_origin(), permit.source_origin()); + let current = permit + .current_projection() + .expect("reused projection exists") + .event + .clone(); + permit + .finish_superseded(¤t) + .await + .expect("preserve reused projection"); + + let mut tx = db.pool.begin().await.expect("inspect reused projection"); + let stored = current_projection_tx( + &mut tx, + community, + relay.public_key().as_bytes(), + reused_key.as_bytes(), + ) + .await + .expect("read reused projection") + .expect("reused projection remains"); + tx.rollback().await.expect("rollback inspection"); + assert_eq!(stored.event.id, second.id); + assert_eq!( + unfinished_public_projection_retirements( + &db, + &[community], + relay.public_key().as_bytes(), + ) + .await + .expect("count stale work"), + 0 + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn populated_upgrade_retires_an_assertion_without_private_head_metadata() { + let (db, community) = setup().await; + let relay = Keys::generate(); + let subject_keys = Keys::generate(); + let subject = subject_keys.public_key(); + resolve_identity_binding( + &db.pool, + community, + &ResolveBindingInput { + issuer: ISSUER, + subject: "legacy-projection-subject", + pubkey: subject.as_bytes(), + display_name: None, + enrollment_mode: EnrollmentMode::AttestedKey, + key_attested: true, + }, + ) + .await + .expect("enroll legacy projection subject"); + let active = assertion(&relay, subject, true, 400); + begin_active_public_projection( + &db, + community, + relay.public_key().as_bytes(), + ISSUER, + "legacy-projection-subject", + subject.as_bytes(), + ) + .await + .expect("begin legacy projection") + .expect("legacy binding is active") + .commit(&active, ProjectionDisposition::Active) + .await + .expect("commit legacy projection"); + sqlx::query( + "DELETE FROM identity_public_projection_heads \ + WHERE community_id=$1 AND relay_pubkey=$2 AND subject_pubkey=$3", + ) + .bind(community.as_uuid()) + .bind(relay.public_key().as_bytes()) + .bind(subject.as_bytes()) + .execute(&db.pool) + .await + .expect("simulate pre-migration projection"); + + let operation_id = Uuid::new_v4(); + revoke_identity_key( + &db.pool, + community, + LifecycleContext { + operation_id, + actor: None, + reason: "retire populated upgrade projection", + }, + subject.as_bytes(), + ) + .await + .expect("commit populated-upgrade revocation"); + materialize_public_projection_retirements(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("materialize populated-upgrade work"); + let permit = begin_public_projection_retirement( + &db, + claim_public_projection_retirement(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("claim populated-upgrade work") + .expect("populated-upgrade work exists"), + ) + .await + .expect("begin populated-upgrade retirement"); + assert_eq!(permit.head(), None); + let inactive = assertion(&relay, subject, false, 401); + permit + .finish_inactive(&inactive) + .await + .expect("retire populated-upgrade projection"); + let delivery = + begin_public_projection_delivery(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("claim populated-upgrade delivery") + .expect("populated-upgrade delivery exists"); + assert_eq!(delivery.stored().event.id, inactive.id); + delivery.complete().await.expect("complete delivery"); + + // A crash-restored/pre-head replica can rediscover already-inactive + // work without private ownership metadata. Convergence must not + // relabel that public event to the replayed job's source generation. + sqlx::query( + "DELETE FROM identity_public_projection_heads \ + WHERE community_id=$1 AND relay_pubkey=$2 AND subject_pubkey=$3", + ) + .bind(community.as_uuid()) + .bind(relay.public_key().as_bytes()) + .bind(subject.as_bytes()) + .execute(&db.pool) + .await + .expect("remove restored private head metadata"); + sqlx::query( + "UPDATE identity_public_projection_retirements \ + SET source_binding_id=NULL, source_binding_version=NULL, \ + phase='projection', outcome=NULL, event_id=NULL, \ + completed_at=NULL, next_attempt_at=NOW(), updated_at=NOW() \ + WHERE community_id=$1 AND operation_id=$2 AND relay_pubkey=$3", + ) + .bind(community.as_uuid()) + .bind(operation_id) + .bind(relay.public_key().as_bytes()) + .execute(&db.pool) + .await + .expect("simulate source-less restored retry"); + let replay = begin_public_projection_retirement( + &db, + claim_public_projection_retirement(&db, &[community], relay.public_key().as_bytes()) + .await + .expect("claim restored retry") + .expect("restored retry exists"), + ) + .await + .expect("begin restored retry"); + assert_eq!(replay.head(), None); + assert_eq!( + replay + .current_projection() + .expect("inactive projection remains") + .event + .id, + inactive.id + ); + replay + .finish_existing_inactive() + .await + .expect("source-less inactive retry converges"); + let head_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_public_projection_heads \ + WHERE community_id=$1 AND relay_pubkey=$2 AND subject_pubkey=$3", + ) + .bind(community.as_uuid()) + .bind(relay.public_key().as_bytes()) + .bind(subject.as_bytes()) + .fetch_one(&db.pool) + .await + .expect("count private heads after convergence"); + assert_eq!(head_count, 0, "replay must not manufacture ownership"); + } + + #[test] + fn public_projection_disposition_is_canonical_and_label_free_when_inactive() { + let relay = Keys::generate(); + let subject = Keys::generate().public_key(); + let active = assertion(&relay, subject, true, 500); + let inactive = assertion(&relay, subject, false, 501); + assert!(validate_event_disposition(&active, ProjectionDisposition::Active).is_ok()); + assert!(validate_event_disposition(&inactive, ProjectionDisposition::Inactive).is_ok()); + assert!(validate_event_disposition(&active, ProjectionDisposition::Inactive).is_err()); + assert!(validate_event_disposition(&inactive, ProjectionDisposition::Active).is_err()); + + let subject_hex = subject.to_hex(); + let stale_label = EventBuilder::new(Kind::Custom(ASSERTION_KIND as u16), "") + .tags([ + Tag::parse(["d", subject_hex.as_str()]).expect("d tag"), + Tag::parse(["p", subject_hex.as_str()]).expect("p tag"), + Tag::parse(["verified", "relay"]).expect("verified tag"), + Tag::parse(["active", "false"]).expect("active tag"), + Tag::parse(["expiration", "0"]).expect("expiration tag"), + Tag::parse(["display_name", "Stale label"]).expect("display tag"), + ]) + .custom_created_at(Timestamp::from(502)) + .sign_with_keys(&relay) + .expect("sign malformed projection"); + assert!(validate_event_disposition(&stale_label, ProjectionDisposition::Inactive).is_err()); + + for (active, at) in [(true, 503), (false, 504)] { + let active_value = if active { "true" } else { "false" }; + let expiration = if active { "500" } else { "0" }; + let mut tags = vec![ + Tag::parse(["d", subject_hex.as_str()]).expect("d tag"), + Tag::parse(["p", subject_hex.as_str()]).expect("p tag"), + Tag::parse(["verified", "relay"]).expect("verified tag"), + Tag::parse(["active", active_value]).expect("active tag"), + Tag::parse(["expiration", expiration]).expect("expiration tag"), + ]; + if active { + tags.push( + Tag::parse(["display_name", "Private stale content"]).expect("display tag"), + ); + } + let nonempty = EventBuilder::new( + Kind::Custom(ASSERTION_KIND as u16), + "private content must never be projected", + ) + .tags(tags) + .custom_created_at(Timestamp::from(at)) + .sign_with_keys(&relay) + .expect("sign non-canonical projection"); + let disposition = if active { + ProjectionDisposition::Active + } else { + ProjectionDisposition::Inactive + }; + assert!( + validate_event_disposition(&nonempty, disposition).is_err(), + "signed projection content must be empty" + ); + } + } + + #[test] + fn debug_receipts_redact_binding_and_public_key_coordinates() { + let claim = ProjectionRetirementClaim { + community_id: CommunityId::from_uuid(Uuid::new_v4()), + operation_id: Uuid::new_v4(), + relay_pubkey: vec![3; 32], + old_pubkey: vec![4; 32], + source_origin: Some(ProjectionBindingOrigin { + binding_id: Uuid::new_v4(), + binding_version: 7, + }), + claim_token: Uuid::new_v4(), + }; + let debug = format!("{claim:?}"); + assert!(!debug.contains(&hex::encode(&claim.relay_pubkey))); + assert!(!debug.contains(&hex::encode(&claim.old_pubkey))); + assert!(!debug.contains(&claim.operation_id.to_string())); + assert!(!debug.contains("7")); + } +} diff --git a/crates/buzz-db/src/push.rs b/crates/buzz-db/src/push.rs index 04b6a7ae36..559dbaddc2 100644 --- a/crates/buzz-db/src/push.rs +++ b/crates/buzz-db/src/push.rs @@ -821,10 +821,21 @@ pub async fn claim_due_match_batch( limit: i64, lease_until: DateTime, ) -> Result> { - claim_due_match_batch_with_loader( + claim_due_match_batch_excluding(pool, limit, lease_until, &[]).await +} + +/// Claim a matcher batch without touching exact protected Enforce domains. +pub async fn claim_due_match_batch_excluding( + pool: &PgPool, + limit: i64, + lease_until: DateTime, + excluded_communities: &[Uuid], +) -> Result> { + claim_due_match_batch_with_loader_excluding( pool, limit, lease_until, + excluded_communities, |pool, community, ids| async move { let refs: Vec<&[u8]> = ids.iter().map(Vec::as_slice).collect(); crate::event::get_events_by_ids(&pool, community, &refs).await @@ -833,10 +844,11 @@ pub async fn claim_due_match_batch( .await } -async fn claim_due_match_batch_with_loader( +async fn claim_due_match_batch_with_loader_excluding( pool: &PgPool, limit: i64, lease_until: DateTime, + excluded_communities: &[Uuid], load: F, ) -> Result> where @@ -850,6 +862,7 @@ where SELECT community_id FROM push_match_queue WHERE attempts < $3 + AND NOT (community_id = ANY($5::uuid[])) AND next_attempt_at <= now() AND (state = 'pending' OR (state = 'matching' AND lease_until < now())) ORDER BY next_attempt_at, created_at @@ -860,6 +873,7 @@ where FROM push_match_queue q JOIN target t ON q.community_id = t.community_id WHERE q.attempts < $3 + AND NOT (q.community_id = ANY($5::uuid[])) AND q.next_attempt_at <= now() AND (q.state = 'pending' OR (q.state = 'matching' AND q.lease_until < now())) ORDER BY q.next_attempt_at, q.created_at @@ -877,6 +891,7 @@ where .bind(lease_until) .bind(MAX_MATCH_ATTEMPTS) .bind(limit) + .bind(excluded_communities) .fetch_all(pool) .await?; if rows.is_empty() { @@ -931,11 +946,21 @@ where /// served by the due partial index, so putting it in every claim made claims /// slower exactly when a backlog needed them fastest. pub async fn reap_exhausted_matches(pool: &PgPool) -> Result { + reap_exhausted_matches_excluding(pool, &[]).await +} + +/// Reap exhausted matcher jobs outside exact protected Enforce domains. +pub async fn reap_exhausted_matches_excluding( + pool: &PgPool, + excluded_communities: &[Uuid], +) -> Result { Ok(sqlx::query( "DELETE FROM push_match_queue WHERE attempts >= $1 \ + AND NOT (community_id = ANY($2::uuid[])) \ AND (state='pending' OR (state='matching' AND lease_until < now()))", ) .bind(MAX_MATCH_ATTEMPTS) + .bind(excluded_communities) .execute(pool) .await? .rows_affected()) @@ -1891,6 +1916,18 @@ mod tests { .await .expect("read matcher queue"); assert_eq!(queued, vec![9]); + assert!( + claim_due_match_batch_excluding( + &pool, + 16, + Utc::now() + chrono::Duration::minutes(1), + &[*community.as_uuid()], + ) + .await + .expect("excluded protected matcher claim") + .is_none(), + "an excluded domain must remain unclaimed" + ); sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND id=$2") .bind(community.as_uuid()) @@ -1927,10 +1964,11 @@ mod tests { .await .expect("insert event"); - let error = claim_due_match_batch_with_loader( + let error = claim_due_match_batch_with_loader_excluding( &pool, 16, Utc::now() - chrono::Duration::seconds(1), + &[], |_pool, _community, _event_ids| async { Err(crate::DbError::InvalidData("injected load failure".into())) }, diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 21f30065f0..44c1c7939c 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -223,12 +223,13 @@ async fn feedback_attachment( return Err(ApiError::not_found()); } - let response = crate::api::media::serve_blob_for_tenant(&state, &tenant, &sha256, &headers) - .await - .map_err(|error| match error { - buzz_media::MediaError::NotFound => ApiError::not_found(), - _ => ApiError::internal(), - })?; + let response = + crate::api::media::serve_blob_for_tenant(&state, &tenant, &sha256, &headers, None) + .await + .map_err(|error| match error { + buzz_media::MediaError::NotFound => ApiError::not_found(), + _ => ApiError::internal(), + })?; tracing::info!( feedback_id = %feedback.id, community_id = %feedback.community_id, diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index d9f829433b..0019ef51b5 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -6,6 +6,7 @@ pub mod events; pub mod git; pub mod invites; pub mod media; +pub mod media_migration; pub mod mesh_demo; pub mod nip05; pub mod operator; @@ -92,16 +93,12 @@ pub mod relay_members { .await .map_err(|e| format!("relay membership check (owner) failed: {e}"))?; if owner_is_member { - debug!( - agent = %pubkey_hex, - owner = %owner_hex, - "NIP-OA membership granted via owner" - ); + debug!("NIP-OA membership granted via owner"); return Ok(MembershipDecision::ViaOwner(owner_pubkey)); } } Err(e) => { - info!(agent = %pubkey_hex, "NIP-OA auth tag invalid: {e}"); + info!("NIP-OA auth tag invalid: {e}"); } } } @@ -186,7 +183,7 @@ pub mod relay_members { Ok(true) => { metrics::counter!( "buzz_users_created_total", - "community" => tenant.host().to_owned() + "community" => crate::metrics::community_label(tenant.community()) ) .increment(1); } diff --git a/crates/buzz-relay/src/authorization_runtime/mod.rs b/crates/buzz-relay/src/authorization_runtime/mod.rs new file mode 100644 index 0000000000..7292811390 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/mod.rs @@ -0,0 +1,22 @@ +//! Provider-neutral runtime authorization seams. +//! +//! This commit registers the complete O4 module shape while implementing only +//! exact-domain provider selection, provider-evidence finalization, and bounded +//! leases. Transport adoption, invalidation, and client status remain separate +//! extension lanes. + +pub(crate) mod ephemeral; +/// Transaction-owned protected mutation execution and idempotency. +pub mod executor; +/// Exact-domain provider selection and authorization finalization. +pub mod finalization; +/// Durable provider-neutral invalidation, reconciliation, and use fences. +pub mod invalidation; +/// Disabled-by-default production runtime construction. +pub mod production; +/// Independent high-water protection against stale PostgreSQL restoration. +pub mod restore; +/// Reserved provider-neutral client-status extension seam. +pub mod status; +/// Reserved provider-neutral transport-adoption extension seam. +pub mod transport; diff --git a/crates/buzz-relay/src/authorization_runtime/status.rs b/crates/buzz-relay/src/authorization_runtime/status.rs new file mode 100644 index 0000000000..018a941607 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/status.rs @@ -0,0 +1,1713 @@ +//! Provider-neutral relay client binding status. +//! +//! This module is a one-way presentation adapter. It consumes a display-only +//! [`VerificationOnlyDisposition`](buzz_auth::VerificationOnlyDisposition) or +//! an opaque withdrawal request and returns one relay-signed +//! ephemeral event. The event has no route, ordinary ingest, event storage, +//! pub/sub, membership, capability, access-context, or lease integration. A +//! dedicated delivery trait exists behind a typed presentation +//! permit that only complete external RFC/client gate evidence can construct; +//! the stock binary supplies none and therefore remains disabled by default. + +use std::fmt; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use buzz_auth::{ + AuthorizationProfileId, BindingVersion, PolicyVersion, VerificationOnlyDisposition, +}; +use buzz_core::{ + client_binding_status::{ + ClientBindingStatusBuildError, ClientBindingStatusError, ClientBindingStatusInputV1, + MAX_CLIENT_BINDING_STATUS_LABEL_BYTES, + }, + CommunityId, +}; +use hmac::{Hmac, KeyInit, Mac}; +use nostr::{Event, Keys, PublicKey}; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +const POLICY_REVISION_DOMAIN_SEPARATOR: &[u8] = b"buzz-client-status-policy-v1"; + +/// Dedicated secret for unlinkable provider-neutral client-status revisions. +/// +/// Deployments must inject a purpose-specific random value. Reusing provider +/// assertion keys, relay signing keys, or any public identifier would make the +/// status revision linkable across trust domains. +#[derive(Clone, PartialEq, Eq)] +pub struct ClientStatusPrivacyKey([u8; 32]); + +impl ClientStatusPrivacyKey { + /// Construct a client-status-only privacy key from 32 secret bytes. + pub const fn from_secret(secret: [u8; 32]) -> Self { + Self(secret) + } +} + +impl fmt::Debug for ClientStatusPrivacyKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ClientStatusPrivacyKey") + .field(&"[redacted]") + .finish() + } +} + +/// Exact scope used to obtain a durable client-status revision. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct ClientStatusRevisionScope { + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, +} + +impl ClientStatusRevisionScope { + /// Server-resolved authorization domain. + pub const fn authorization_domain(self) -> CommunityId { + self.authorization_domain + } + + /// Exact event-author key. + pub const fn event_author_pubkey(self) -> PublicKey { + self.event_author_pubkey + } +} + +impl fmt::Debug for ClientStatusRevisionScope { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientStatusRevisionScope") + .field("authorization_domain", &"[redacted]") + .field("event_author_pubkey", &"[redacted]") + .finish() + } +} + +/// Opaque proof of the exact current status delivered to one connection. +/// +/// Callers cannot manufacture a different scope or revision. A withdrawal +/// must present this receipt so it always supersedes the status users saw. +pub struct ClientStatusIssuanceReceipt { + scope: ClientStatusRevisionScope, + connection_id: Uuid, + revision: u64, + issuance_fingerprint: [u8; 32], +} + +impl ClientStatusIssuanceReceipt { + /// Exact authenticated connection that received the current status. + pub const fn connection_id(&self) -> Uuid { + self.connection_id + } + + /// Revision that a withdrawal must supersede. + pub const fn revision(&self) -> u64 { + self.revision + } +} + +impl fmt::Debug for ClientStatusIssuanceReceipt { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientStatusIssuanceReceipt") + .field("scope", &self.scope) + .field("connection_id", &"[redacted]") + .field("revision", &"[redacted]") + .finish() + } +} + +/// Revision and durable floor read atomically from an injected persistence seam. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct DurableClientStatusRevision { + revision: u64, + floor: u64, +} + +impl DurableClientStatusRevision { + /// Validate a revision/floor pair returned by durable state. + pub const fn from_durable_state( + revision: u64, + floor: u64, + ) -> Result { + if revision == 0 { + return Err(ClientStatusRevisionError::ZeroRevision); + } + if revision < floor { + return Err(ClientStatusRevisionError::BelowDurableFloor); + } + Ok(Self { revision, floor }) + } + + /// Current monotonic status revision. + pub const fn revision(self) -> u64 { + self.revision + } + + /// Lowest revision allowed by durable reconciliation state. + pub const fn floor(self) -> u64 { + self.floor + } +} + +impl fmt::Debug for DurableClientStatusRevision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DurableClientStatusRevision") + .field("revision", &"[redacted]") + .field("floor", &"[redacted]") + .finish() + } +} + +/// Read-only durable revision source supplied by the invalidation/reconciliation lane. +/// +/// Implementations must never synthesize a process-local fallback. `None` +/// withholds status, and restore/restart must not return a revision below its +/// persisted floor. +#[async_trait] +pub trait DurableClientStatusRevisionSource: Send + Sync { + /// Atomically revalidate an exact active/fresh binding and return its + /// current revision. `None` withholds output. + async fn current_revision_for( + &self, + requirement: &ClientStatusCurrentRequirement<'_>, + issuance_fingerprint: [u8; 32], + ) -> Option; + + /// Allocate a durable revision for the exact delivered current issuance. + async fn withdrawal_revision_for( + &self, + receipt: &ClientStatusIssuanceReceipt, + withdrawal_fingerprint: [u8; 32], + ) -> Option; +} + +mod postgres; +pub use postgres::PostgresClientStatusRevisionSource; + +/// Exact private state that must still be active at the signing boundary. +pub struct ClientStatusCurrentRequirement<'a> { + scope: ClientStatusRevisionScope, + binding_id: Uuid, + binding_version: BindingVersion, + profile_id: &'a AuthorizationProfileId, + policy_version: &'a PolicyVersion, + evaluation_generation: u64, + fresh_until: u64, +} + +impl ClientStatusCurrentRequirement<'_> { + /// Exact public status scope. + pub const fn scope(&self) -> ClientStatusRevisionScope { + self.scope + } + + /// Stable binding identifier required to remain active. + pub const fn binding_id(&self) -> Uuid { + self.binding_id + } + + /// Exact binding version required to remain active. + pub const fn binding_version(&self) -> BindingVersion { + self.binding_version + } + + /// Exact provider profile required to remain current. + pub const fn profile_id(&self) -> &AuthorizationProfileId { + self.profile_id + } + + /// Exact provider policy version required to remain current. + pub const fn policy_version(&self) -> &PolicyVersion { + self.policy_version + } + + /// Invalidation generation captured before provider evaluation. + pub const fn evaluation_generation(&self) -> u64 { + self.evaluation_generation + } + + /// Absolute status freshness boundary. + pub const fn fresh_until(&self) -> u64 { + self.fresh_until + } +} + +impl fmt::Debug for ClientStatusCurrentRequirement<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientStatusCurrentRequirement") + .field("scope", &self.scope) + .field("binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("evaluation_generation", &"[redacted]") + .field("fresh_until", &"[redacted]") + .finish() + } +} + +/// Pseudonymous policy revision safe for the client-status wire contract. +/// +/// The provider profile and opaque policy string are authenticated under a +/// dedicated privacy key with length framing. The raw profile, issuer, +/// audience, claim names, and policy value never enter the event, and equal +/// provider values are unlinkable across deployments with distinct keys. +#[derive(Clone, PartialEq, Eq)] +pub struct ProviderNeutralPolicyRevision(String); + +impl ProviderNeutralPolicyRevision { + /// Derive a provider-neutral revision from current server/provider evidence. + pub fn derive( + privacy_key: &ClientStatusPrivacyKey, + profile: &AuthorizationProfileId, + policy: &PolicyVersion, + ) -> Result { + let mut mac = as KeyInit>::new_from_slice(&privacy_key.0) + .map_err(|_| ClientStatusPrivacyError::InvalidKeyMaterial)?; + mac.update(POLICY_REVISION_DOMAIN_SEPARATOR); + update_length_framed(&mut mac, profile.as_str().as_bytes()); + update_length_framed(&mut mac, policy.as_str().as_bytes()); + Ok(Self(hex::encode(mac.finalize().into_bytes()))) + } + + /// Lowercase hex digest carried in the signed status. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for ProviderNeutralPolicyRevision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ProviderNeutralPolicyRevision") + .field(&"[redacted]") + .finish() + } +} + +fn update_length_framed(mac: &mut Hmac, value: &[u8]) { + mac.update(&(value.len() as u64).to_be_bytes()); + mac.update(value); +} + +/// Optional display label loaded only from privacy-approved server configuration. +/// +/// There is intentionally no constructor from assertion claims, provider +/// decisions, binding `display_name`, or mutable Nostr profiles. +#[derive(Clone, PartialEq, Eq)] +pub struct PrivacyApprovedClientStatusLabel(String); + +impl PrivacyApprovedClientStatusLabel { + /// Load a non-empty, bounded label from approved server configuration. + pub fn from_server_configuration( + value: impl Into, + ) -> Result { + let value = value.into(); + if value.is_empty() + || value.len() > MAX_CLIENT_BINDING_STATUS_LABEL_BYTES + || value.trim() != value + || value.chars().any(char::is_control) + { + return Err(PrivacyApprovedClientStatusLabelError::InvalidLabel); + } + Ok(Self(value)) + } + + fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for PrivacyApprovedClientStatusLabel { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("PrivacyApprovedClientStatusLabel") + .field(&"[redacted]") + .finish() + } +} + +/// Authoritative, presentation-only evidence used to sign one status. +/// +/// Construction from verification-only finalization is preferred for current +/// display. Invalidation/reconciliation code may reuse only its exact scope +/// and freshness window to issue an opaque withdrawal. Private binding and +/// policy evidence remains server-side and is never serialized into the event. +pub struct AuthoritativeClientStatusEvidence { + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + binding_id: Uuid, + binding_version: BindingVersion, + profile_id: AuthorizationProfileId, + policy_version: PolicyVersion, + policy_revision: ProviderNeutralPolicyRevision, + issuance_id: Uuid, + evaluation_generation: u64, + issued_at: u64, + fresh_until: u64, +} + +impl AuthoritativeClientStatusEvidence { + /// Derive current display evidence from full verification-only finalization. + pub fn from_verification_only( + disposition: &VerificationOnlyDisposition, + privacy_key: &ClientStatusPrivacyKey, + evaluation_generation: u64, + ) -> Result { + Ok(Self { + authorization_domain: disposition.authorization_domain(), + event_author_pubkey: disposition.actor_pubkey(), + binding_id: disposition.binding_id(), + binding_version: disposition.binding_version(), + profile_id: disposition.profile_id().clone(), + policy_version: disposition.policy_version().clone(), + policy_revision: ProviderNeutralPolicyRevision::derive( + privacy_key, + disposition.profile_id(), + disposition.policy_version(), + )?, + issuance_id: disposition.correlation_id(), + evaluation_generation, + issued_at: disposition.issued_at(), + fresh_until: disposition.expires_at(), + }) + } + + /// Consume separately authoritative runtime/lifecycle evidence. + /// + /// Callers must use server-resolved domain/key state and centrally injected + /// time. This constructor validates representation through the core + /// contract during issuance; it performs no persistence or lifecycle read. + #[allow(clippy::too_many_arguments)] + pub const fn from_authoritative_runtime( + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + binding_id: Uuid, + binding_version: BindingVersion, + profile_id: AuthorizationProfileId, + policy_version: PolicyVersion, + policy_revision: ProviderNeutralPolicyRevision, + issuance_id: Uuid, + evaluation_generation: u64, + issued_at: u64, + fresh_until: u64, + ) -> Self { + Self { + authorization_domain, + event_author_pubkey, + binding_id, + binding_version, + profile_id, + policy_version, + policy_revision, + issuance_id, + evaluation_generation, + issued_at, + fresh_until, + } + } + + fn revision_scope(&self) -> ClientStatusRevisionScope { + ClientStatusRevisionScope { + authorization_domain: self.authorization_domain, + event_author_pubkey: self.event_author_pubkey, + } + } + + fn current_requirement(&self) -> ClientStatusCurrentRequirement<'_> { + ClientStatusCurrentRequirement { + scope: self.revision_scope(), + binding_id: self.binding_id, + binding_version: self.binding_version, + profile_id: &self.profile_id, + policy_version: &self.policy_version, + evaluation_generation: self.evaluation_generation, + fresh_until: self.fresh_until, + } + } +} + +impl fmt::Debug for AuthoritativeClientStatusEvidence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthoritativeClientStatusEvidence") + .field("authorization_domain", &"[redacted]") + .field("event_author_pubkey", &"[redacted]") + .field("binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("policy_revision", &self.policy_revision) + .field("issuance_id", &"[redacted]") + .field("evaluation_generation", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("fresh_until", &"[redacted]") + .finish() + } +} + +/// Relay signer for display-only client binding status. +pub struct RelayClientBindingStatusIssuer<'a> { + relay_keys: &'a Keys, + revisions: &'a dyn DurableClientStatusRevisionSource, + privacy_key: &'a ClientStatusPrivacyKey, +} + +impl<'a> RelayClientBindingStatusIssuer<'a> { + /// Bind a relay signing key and externally durable revision source. + pub const fn new( + relay_keys: &'a Keys, + revisions: &'a dyn DurableClientStatusRevisionSource, + privacy_key: &'a ClientStatusPrivacyKey, + ) -> Self { + Self { + relay_keys, + revisions, + privacy_key, + } + } + + /// Sign a generic withdrawal without exposing its lifecycle cause. + pub async fn issue_withdrawn( + &self, + evidence: &AuthoritativeClientStatusEvidence, + receipt: &ClientStatusIssuanceReceipt, + ) -> Result { + if receipt.scope != evidence.revision_scope() { + return Err(RelayClientStatusError::IssuanceReceiptMismatch); + } + let withdrawal_fingerprint = withdrawal_fingerprint(receipt); + let revision = self + .revisions + .withdrawal_revision_for(receipt, withdrawal_fingerprint) + .await + .ok_or(RelayClientStatusError::RevisionUnavailable)?; + if revision.revision() <= receipt.revision { + return Err(RelayClientStatusError::RevisionDidNotAdvance); + } + ClientBindingStatusInputV1::withdrawn( + evidence.authorization_domain, + evidence.event_author_pubkey, + revision.revision(), + evidence.issued_at, + evidence.fresh_until, + )? + .sign_with_relay_keys(self.relay_keys) + .map_err(Into::into) + } + + async fn issue_current( + &self, + evidence: &AuthoritativeClientStatusEvidence, + label: Option<&PrivacyApprovedClientStatusLabel>, + ) -> Result<(Event, u64), RelayClientStatusError> { + let issuance_fingerprint = current_issuance_fingerprint(evidence, label); + let revision = self + .revisions + .current_revision_for(&evidence.current_requirement(), issuance_fingerprint) + .await + .ok_or(RelayClientStatusError::RevisionUnavailable)?; + let input = ClientBindingStatusInputV1::current( + evidence.authorization_domain, + evidence.event_author_pubkey, + evidence.binding_version.get(), + evidence.policy_revision.as_str(), + revision.revision(), + evidence.issued_at, + evidence.fresh_until, + label.map(|value| value.as_str().to_string()), + )?; + let event = input + .sign_with_relay_keys(self.relay_keys) + .map_err(RelayClientStatusError::from)?; + Ok((event, revision.revision())) + } +} + +fn current_issuance_fingerprint( + evidence: &AuthoritativeClientStatusEvidence, + label: Option<&PrivacyApprovedClientStatusLabel>, +) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(b"buzz-client-status-current-issuance-v2"); + digest.update(evidence.authorization_domain.as_uuid().as_bytes()); + digest.update(evidence.event_author_pubkey.to_bytes()); + digest.update(evidence.binding_id.as_bytes()); + digest.update(evidence.binding_version.get().to_be_bytes()); + let profile = evidence.profile_id.as_str().as_bytes(); + digest.update((profile.len() as u64).to_be_bytes()); + digest.update(profile); + let policy = evidence.policy_version.as_str().as_bytes(); + digest.update((policy.len() as u64).to_be_bytes()); + digest.update(policy); + digest.update(evidence.issuance_id.as_bytes()); + digest.update(evidence.evaluation_generation.to_be_bytes()); + digest.update(evidence.issued_at.to_be_bytes()); + digest.update(evidence.fresh_until.to_be_bytes()); + if let Some(label) = label { + digest.update([1]); + let label = label.as_str().as_bytes(); + digest.update((label.len() as u64).to_be_bytes()); + digest.update(label); + } else { + digest.update([0]); + } + digest.finalize().into() +} + +fn withdrawal_fingerprint(receipt: &ClientStatusIssuanceReceipt) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(b"buzz-client-status-withdrawal-v1"); + digest.update(receipt.scope.authorization_domain().as_uuid().as_bytes()); + digest.update(receipt.scope.event_author_pubkey().to_bytes()); + // Every authenticated connection showing this author shares one durable + // withdrawal allocation. The connection remains a delivery target only. + digest.update(receipt.revision.to_be_bytes()); + digest.update(receipt.issuance_fingerprint); + digest.finalize().into() +} + +impl fmt::Debug for RelayClientBindingStatusIssuer<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RelayClientBindingStatusIssuer") + .field("relay_keys", &"[redacted]") + .field("revisions", &"[injected]") + .field("privacy_key", &"[redacted]") + .finish() + } +} + +/// Opaque permission to expose a status on the dedicated authenticated path. +/// +/// This type has no unchecked constructor. Complete external gate evidence is +/// required; ordinary event ingest and pub/sub are never valid substitutes. +pub struct ClientStatusPresentationPermit { + _private: (), +} + +/// Deployment-owned evidence that the RFC presentation gate and exact client +/// contract have both been approved for one reviewed revision. +pub trait CompleteClientStatusPresentationApproval: Send + Sync { + /// Exact lowercase Git revision reviewed by every presentation gate. + fn reviewed_implementation_revision(&self) -> &str; + + /// Whether the applicable RFC presentation/privacy gate passed. + fn presentation_gate_passed(&self) -> bool; + + /// Whether the dedicated client transport contract passed end to end. + fn dedicated_client_contract_passed(&self) -> bool; +} + +impl ClientStatusPresentationPermit { + /// Construct the otherwise unavailable permit from complete external gate + /// evidence. There is intentionally no environment/boolean constructor. + pub fn from_complete_stack( + approval: &dyn CompleteClientStatusPresentationApproval, + ) -> Result { + let revision = approval.reviewed_implementation_revision(); + if revision.len() != 40 + || !revision + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + || !approval.presentation_gate_passed() + || !approval.dedicated_client_contract_passed() + { + return Err(ClientStatusPresentationGateError::Incomplete); + } + Ok(Self { _private: () }) + } +} + +impl fmt::Debug for ClientStatusPresentationPermit { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientStatusPresentationPermit") + .finish_non_exhaustive() + } +} + +/// One relay-authenticated status targeted to an exact connection scope. +/// +/// The transport implementation must deliver only to the authenticated +/// connection for `authorization_domain` and `event_author_pubkey`. It must +/// not route through event ingestion, storage, subscriptions, or pub/sub. +pub struct DedicatedClientStatusDelivery<'a> { + event: &'a Event, + relay_pubkey: PublicKey, + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + connection_id: Uuid, +} + +impl DedicatedClientStatusDelivery<'_> { + /// Relay-signed ephemeral status event. + pub const fn event(&self) -> &Event { + self.event + } + + /// Relay key against which the transport must authenticate the event. + pub const fn relay_pubkey(&self) -> PublicKey { + self.relay_pubkey + } + + /// Server-resolved authorization domain of the target connection. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Exact authenticated event-author key of the target connection. + pub const fn event_author_pubkey(&self) -> PublicKey { + self.event_author_pubkey + } + + /// Exact server-owned authenticated connection target. + pub const fn connection_id(&self) -> Uuid { + self.connection_id + } +} + +impl fmt::Debug for DedicatedClientStatusDelivery<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DedicatedClientStatusDelivery") + .field("event", &"[redacted]") + .field("relay_pubkey", &"[redacted]") + .field("authorization_domain", &"[redacted]") + .field("event_author_pubkey", &"[redacted]") + .field("connection_id", &"[redacted]") + .finish() + } +} + +/// Dedicated relay-authenticated status channel. +/// +/// Implementations become reachable only behind the separately approved RFC +/// presentation gate. This trait must never be implemented by ordinary event +/// ingest, persistence, subscription, or pub/sub paths. +pub trait DedicatedClientStatusTransport: Send + Sync { + /// Deliver one status only to its exact authenticated connection scope. + fn deliver( + &self, + delivery: DedicatedClientStatusDelivery<'_>, + ) -> Result<(), DedicatedClientStatusTransportError>; +} + +/// Result of one current-status delivery attempt. +/// +/// The durable receipt is retained even when the transport reports failure, +/// because the effect may have become visible before its acknowledgement was +/// lost. Callers must register the receipt for invalidation reconciliation. +pub struct ClientStatusDeliveryAttempt { + event: Event, + receipt: ClientStatusIssuanceReceipt, + delivery_error: Option, +} + +impl ClientStatusDeliveryAttempt { + /// Relay-signed current status attempted on the dedicated connection. + pub const fn event(&self) -> &Event { + &self.event + } + + /// Durable receipt that must remain withdrawable after any delivery result. + pub const fn receipt(&self) -> &ClientStatusIssuanceReceipt { + &self.receipt + } + + /// Dedicated transport result; failure can be ambiguous after visibility. + pub const fn delivery_error(&self) -> Option { + self.delivery_error + } + + /// Consume the attempt while retaining its exact withdrawal receipt. + pub fn into_receipt(self) -> ClientStatusIssuanceReceipt { + self.receipt + } +} + +/// Dedicated exact-connection transport backed by the live connection +/// manager. It bypasses event ingest, storage, subscriptions, and pub/sub. +pub struct ConnectionManagerClientStatusTransport { + connections: Arc, +} + +impl ConnectionManagerClientStatusTransport { + /// Bind the server-owned connection registry. + pub fn new(connections: Arc) -> Self { + Self { connections } + } +} + +impl DedicatedClientStatusTransport for ConnectionManagerClientStatusTransport { + fn deliver( + &self, + delivery: DedicatedClientStatusDelivery<'_>, + ) -> Result<(), DedicatedClientStatusTransportError> { + if self + .connections + .community_for_conn(delivery.connection_id()) + != Some(delivery.authorization_domain()) + || self + .connections + .pubkey_for(delivery.connection_id()) + .as_deref() + != Some(delivery.event_author_pubkey().as_bytes()) + { + return Err(DedicatedClientStatusTransportError::Unavailable); + } + let frame = crate::protocol::RelayMessage::event( + "__buzz_client_binding_status_v1__", + delivery.event(), + ); + self.connections + .send_to(delivery.connection_id(), frame) + .then_some(()) + .ok_or(DedicatedClientStatusTransportError::Unavailable) + } +} + +/// Installed, opt-in production presentation runtime. +/// +/// Construction requires the complete external gate. The stock OSS binary +/// never creates this value, so presentation remains disabled by default. +pub struct ProductionClientStatusRuntime { + permit: Arc, + privacy_key: ClientStatusPrivacyKey, + transport: Arc, +} + +impl ProductionClientStatusRuntime { + /// Bind approved presentation evidence to a dedicated transport. + pub fn new( + permit: ClientStatusPresentationPermit, + privacy_key: ClientStatusPrivacyKey, + transport: Arc, + ) -> Self { + Self { + permit: Arc::new(permit), + privacy_key, + transport, + } + } + + /// Evaluate, issue, and register withdrawal for one authenticated direct + /// connection. Failure withholds presentation and never changes access. + pub async fn present_after_auth( + self: &Arc, + state: Arc, + proof: Arc, + assertion: Arc, + connection_id: Uuid, + connection_cancellation: CancellationToken, + ) -> Result<(), ClientStatusRuntimeError> { + let protected = state + .protected_transport() + .ok_or(ClientStatusRuntimeError::ProtectedRuntimeUnavailable)?; + // Presentation invalidation must withdraw the indicator without + // becoming connection authority in VerifyOnly. Enforce retains its + // independent protected-session cancellation fence. + let presentation_cancellation = CancellationToken::new(); + let request = super::transport::ProtectedOperationRequest::new_with_cancellation( + proof, + Some(assertion), + buzz_auth::AuthorizationCapability::CommunityRead, + Uuid::new_v4(), + "client.status.current", + Some(connection_id), + Some(presentation_cancellation.clone()), + )?; + let Some(resolution) = protected.present_status(&request).await? else { + return Ok(()); + }; + let (disposition, observer, evaluation_generation) = resolution.into_parts(); + observer + .observe_current() + .map_err(|_| ClientStatusRuntimeError::StatusStale)?; + let evidence = AuthoritativeClientStatusEvidence::from_verification_only( + &disposition, + &self.privacy_key, + evaluation_generation, + )?; + let restore = state + .restore_protection() + .cloned() + .ok_or(ClientStatusRuntimeError::ProtectedRuntimeUnavailable)?; + let revisions = PostgresClientStatusRevisionSource::new(state.db.clone(), restore); + let issuer = RelayClientBindingStatusIssuer::new( + &state.relay_keypair, + &revisions, + &self.privacy_key, + ); + let attempt = issuer + .deliver_verification_only( + &self.permit, + &disposition, + evaluation_generation, + None, + connection_id, + self.transport.as_ref(), + ) + .await?; + let delivery_failed = attempt.delivery_error().is_some(); + let receipt = attempt.into_receipt(); + let runtime = Arc::clone(self); + tokio::spawn(async move { + let now = nostr::Timestamp::now().as_secs(); + let expiry_delay = Duration::from_secs(disposition.expires_at().saturating_sub(now)); + let invalidated = tokio::select! { + _ = connection_cancellation.cancelled() => false, + _ = presentation_cancellation.cancelled() => true, + _ = tokio::time::sleep(expiry_delay) => { + // Status freshness is exclusive and clients clear locally at + // this bound. Dropping the observer prevents any extension. + false + } + }; + if invalidated { + if let Some(restore) = state.restore_protection().cloned() { + let revisions = + PostgresClientStatusRevisionSource::new(state.db.clone(), restore); + let issuer = RelayClientBindingStatusIssuer::new( + &state.relay_keypair, + &revisions, + &runtime.privacy_key, + ); + let _ = issuer + .deliver_withdrawn_after_invalidation( + &runtime.permit, + &evidence, + &receipt, + runtime.transport.as_ref(), + ) + .await; + } + } + drop(observer); + }); + if delivery_failed { + return Err(ClientStatusRuntimeError::DeliveryUnavailable); + } + Ok(()) + } +} + +impl fmt::Debug for ClientStatusDeliveryAttempt { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientStatusDeliveryAttempt") + .field("event", &"[redacted]") + .field("receipt", &self.receipt) + .field("delivery_error", &self.delivery_error) + .finish() + } +} + +impl RelayClientBindingStatusIssuer<'_> { + /// Issue and deliver verification-only status through the typed gate. + /// + /// Returning the delivery future preserves the event-storage-agnostic + /// public contract while durable revision allocation remains an injected + /// asynchronous implementation detail. + pub fn issue_verification_only<'a>( + &'a self, + permit: &'a ClientStatusPresentationPermit, + disposition: &'a VerificationOnlyDisposition, + evaluation_generation: u64, + label: Option<&'a PrivacyApprovedClientStatusLabel>, + connection_id: Uuid, + transport: &'a dyn DedicatedClientStatusTransport, + ) -> impl std::future::Future> + + 'a { + self.deliver_verification_only( + permit, + disposition, + evaluation_generation, + label, + connection_id, + transport, + ) + } + + async fn deliver_current( + &self, + evidence: AuthoritativeClientStatusEvidence, + label: Option<&PrivacyApprovedClientStatusLabel>, + connection_id: Uuid, + transport: &dyn DedicatedClientStatusTransport, + ) -> Result { + let issuance_fingerprint = current_issuance_fingerprint(&evidence, label); + let (event, revision) = self.issue_current(&evidence, label).await?; + let delivery_error = transport + .deliver(DedicatedClientStatusDelivery { + event: &event, + relay_pubkey: self.relay_keys.public_key(), + authorization_domain: evidence.authorization_domain, + event_author_pubkey: evidence.event_author_pubkey, + connection_id, + }) + .err(); + Ok(ClientStatusDeliveryAttempt { + event, + receipt: ClientStatusIssuanceReceipt { + scope: evidence.revision_scope(), + connection_id, + revision, + issuance_fingerprint, + }, + delivery_error, + }) + } + + /// Issue and deliver a verification-only status on the dedicated path. + /// + /// The production runtime can reach this only after injected complete + /// presentation approval constructs `permit`. It never creates a client + /// route or weakens verification-only authorization semantics. + pub async fn deliver_verification_only( + &self, + _permit: &ClientStatusPresentationPermit, + disposition: &VerificationOnlyDisposition, + evaluation_generation: u64, + label: Option<&PrivacyApprovedClientStatusLabel>, + connection_id: Uuid, + transport: &dyn DedicatedClientStatusTransport, + ) -> Result { + let evidence = AuthoritativeClientStatusEvidence::from_verification_only( + disposition, + self.privacy_key, + evaluation_generation, + )?; + self.deliver_current(evidence, label, connection_id, transport) + .await + } + + /// Issue an opaque, strictly newer withdrawal after invalidation and + /// deliver it only to the exact authenticated connection. Without an + /// externally approved and installed presentation runtime, this path + /// remains unreachable. + pub async fn deliver_withdrawn_after_invalidation( + &self, + _permit: &ClientStatusPresentationPermit, + evidence: &AuthoritativeClientStatusEvidence, + receipt: &ClientStatusIssuanceReceipt, + transport: &dyn DedicatedClientStatusTransport, + ) -> Result { + if receipt.connection_id.is_nil() { + return Err(RelayClientStatusError::IssuanceReceiptMismatch); + } + let event = self.issue_withdrawn(evidence, receipt).await?; + transport.deliver(DedicatedClientStatusDelivery { + event: &event, + relay_pubkey: self.relay_keys.public_key(), + authorization_domain: evidence.authorization_domain, + event_author_pubkey: evidence.event_author_pubkey, + connection_id: receipt.connection_id, + })?; + Ok(event) + } +} + +/// Opaque dedicated-transport failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum DedicatedClientStatusTransportError { + /// Exact authenticated connection delivery was unavailable. + #[error("dedicated client-status transport is unavailable")] + Unavailable, +} + +/// Incomplete client-presentation approval evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum ClientStatusPresentationGateError { + /// Revision, RFC gate, or dedicated client contract was incomplete. + #[error("client-status presentation approval is incomplete")] + Incomplete, +} + +/// Fail-closed production client-status result. These failures withhold only +/// presentation and never alter access policy. +#[derive(Debug, Error)] +pub enum ClientStatusRuntimeError { + /// The protected/restore runtime was not completely installed. + #[error("client-status protected runtime is unavailable")] + ProtectedRuntimeUnavailable, + /// Dedicated delivery failed or became ambiguous. + #[error("client-status delivery is unavailable")] + DeliveryUnavailable, + /// Current authority changed after evaluation and before delivery. + #[error("client-status authority is stale")] + StatusStale, + /// Protected status evaluation failed. + #[error(transparent)] + Protected(#[from] super::transport::ProtectedTransportError), + /// Status construction, revision allocation, or signing failed. + #[error(transparent)] + Status(#[from] RelayClientStatusError), + /// Privacy transform initialization failed. + #[error(transparent)] + Privacy(#[from] ClientStatusPrivacyError), +} + +/// Invalid durable revision/floor state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum ClientStatusRevisionError { + /// Revision zero cannot be issued. + #[error("durable client-status revision must be positive")] + ZeroRevision, + /// Reconciliation returned a revision below its durable floor. + #[error("durable client-status revision is below its floor")] + BelowDurableFloor, +} + +/// Invalid privacy-approved label configuration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum PrivacyApprovedClientStatusLabelError { + /// The configured label was empty, unsafe, or exceeded its public bound. + #[error("privacy-approved client-status label is invalid")] + InvalidLabel, +} + +/// Failure to initialize the keyed client-status privacy transform. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum ClientStatusPrivacyError { + /// The injected privacy key could not initialize the HMAC primitive. + #[error("client-status privacy key material is invalid")] + InvalidKeyMaterial, +} + +/// Fail-closed status issuance error. +#[derive(Debug, Error)] +pub enum RelayClientStatusError { + /// Durable status revision/floor state was unavailable. + #[error("durable client-status revision is unavailable")] + RevisionUnavailable, + /// A withdrawal revision did not supersede the last current status. + #[error("durable client-status withdrawal revision did not advance")] + RevisionDidNotAdvance, + /// Withdrawal did not name the exact delivered current status and connection. + #[error("client-status issuance receipt does not match the withdrawal scope")] + IssuanceReceiptMismatch, + /// The provider-neutral revision could not be derived safely. + #[error(transparent)] + Privacy(#[from] ClientStatusPrivacyError), + /// Dedicated relay-authenticated delivery failed. + #[error(transparent)] + DedicatedTransport(#[from] DedicatedClientStatusTransportError), + /// Core status representation was invalid. + #[error(transparent)] + InvalidStatus(#[from] ClientBindingStatusError), + /// Event construction or signing failed. + #[error(transparent)] + Build(#[from] ClientBindingStatusBuildError), +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::atomic::AtomicU8; + use std::sync::Mutex; + + use buzz_auth::{AuthorizationProfileId, PolicyVersion}; + use buzz_core::client_binding_status::{ + validate_client_binding_status_event, ClientBindingStatusDisposition, + }; + use uuid::Uuid; + + use super::*; + + const ISSUED_AT: u64 = 1_800_000_000; + type ObservedCurrentRequirement = (Uuid, u64, String, String, u64); + type ObservedDedicatedDelivery = (nostr::EventId, CommunityId, PublicKey, PublicKey, Uuid); + + struct SyntheticRevisions { + value: Option, + seen: Mutex>, + requirements: Mutex>, + } + + struct SyntheticDedicatedTransport { + deliveries: Mutex>, + } + + struct FailingDedicatedTransport; + + struct SyntheticPresentationApproval { + revision: &'static str, + presentation: bool, + client: bool, + } + + impl CompleteClientStatusPresentationApproval for SyntheticPresentationApproval { + fn reviewed_implementation_revision(&self) -> &str { + self.revision + } + + fn presentation_gate_passed(&self) -> bool { + self.presentation + } + + fn dedicated_client_contract_passed(&self) -> bool { + self.client + } + } + + impl DedicatedClientStatusTransport for FailingDedicatedTransport { + fn deliver( + &self, + _delivery: DedicatedClientStatusDelivery<'_>, + ) -> Result<(), DedicatedClientStatusTransportError> { + Err(DedicatedClientStatusTransportError::Unavailable) + } + } + + impl DedicatedClientStatusTransport for SyntheticDedicatedTransport { + fn deliver( + &self, + delivery: DedicatedClientStatusDelivery<'_>, + ) -> Result<(), DedicatedClientStatusTransportError> { + self.deliveries.lock().expect("synthetic lock").push(( + delivery.event().id, + delivery.authorization_domain(), + delivery.event_author_pubkey(), + delivery.relay_pubkey(), + delivery.connection_id(), + )); + Ok(()) + } + } + + #[async_trait] + impl DurableClientStatusRevisionSource for SyntheticRevisions { + async fn current_revision_for( + &self, + requirement: &ClientStatusCurrentRequirement<'_>, + _issuance_fingerprint: [u8; 32], + ) -> Option { + let scope = requirement.scope(); + self.seen.lock().expect("synthetic lock").push(scope); + self.requirements.lock().expect("synthetic lock").push(( + requirement.binding_id(), + requirement.binding_version().get(), + requirement.profile_id().as_str().to_owned(), + requirement.policy_version().as_str().to_owned(), + requirement.fresh_until(), + )); + self.value + } + + async fn withdrawal_revision_for( + &self, + receipt: &ClientStatusIssuanceReceipt, + _withdrawal_fingerprint: [u8; 32], + ) -> Option { + self.seen + .lock() + .expect("synthetic lock") + .push(receipt.scope); + self.value + } + } + + fn domain() -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(7)) + } + + fn privacy_key() -> ClientStatusPrivacyKey { + ClientStatusPrivacyKey::from_secret([0x51; 32]) + } + + fn policy_revision() -> ProviderNeutralPolicyRevision { + let profile = AuthorizationProfileId::from_server_configuration("synthetic-profile") + .expect("synthetic profile is valid"); + let policy = + PolicyVersion::new("private-provider-policy-value").expect("synthetic policy is valid"); + ProviderNeutralPolicyRevision::derive(&privacy_key(), &profile, &policy) + .expect("synthetic privacy key derives a revision") + } + + fn evidence(author: PublicKey) -> AuthoritativeClientStatusEvidence { + AuthoritativeClientStatusEvidence::from_authoritative_runtime( + domain(), + author, + Uuid::from_u128(0x900), + BindingVersion::new(9).expect("synthetic binding version is valid"), + AuthorizationProfileId::from_server_configuration("synthetic-profile") + .expect("synthetic profile is valid"), + PolicyVersion::new("private-provider-policy-value").expect("synthetic policy is valid"), + policy_revision(), + Uuid::from_u128(0x901), + 1, + ISSUED_AT, + ISSUED_AT + 120, + ) + } + + fn receipt(author: PublicKey, revision: u64) -> ClientStatusIssuanceReceipt { + ClientStatusIssuanceReceipt { + scope: ClientStatusRevisionScope { + authorization_domain: domain(), + event_author_pubkey: author, + }, + connection_id: Uuid::from_u128(0x902), + revision, + issuance_fingerprint: [0x45; 32], + } + } + + fn revisions(value: Option) -> SyntheticRevisions { + SyntheticRevisions { + value, + seen: Mutex::new(Vec::new()), + requirements: Mutex::new(Vec::new()), + } + } + + #[test] + fn durable_revision_validates_floor() { + assert_eq!( + DurableClientStatusRevision::from_durable_state(0, 0), + Err(ClientStatusRevisionError::ZeroRevision) + ); + assert_eq!( + DurableClientStatusRevision::from_durable_state(8, 9), + Err(ClientStatusRevisionError::BelowDurableFloor) + ); + let revision = DurableClientStatusRevision::from_durable_state(9, 9) + .expect("revision at its floor is valid"); + assert_eq!(revision.revision(), 9); + assert_eq!(revision.floor(), 9); + } + + #[test] + fn presentation_permit_requires_one_exact_complete_revision() { + let complete = SyntheticPresentationApproval { + revision: "0123456789abcdef0123456789abcdef01234567", + presentation: true, + client: true, + }; + assert!(ClientStatusPresentationPermit::from_complete_stack(&complete).is_ok()); + + for incomplete in [ + SyntheticPresentationApproval { + revision: "not-a-revision", + presentation: true, + client: true, + }, + SyntheticPresentationApproval { + revision: "0123456789abcdef0123456789abcdef01234567", + presentation: false, + client: true, + }, + SyntheticPresentationApproval { + revision: "0123456789abcdef0123456789abcdef01234567", + presentation: true, + client: false, + }, + ] { + assert!(matches!( + ClientStatusPresentationPermit::from_complete_stack(&incomplete), + Err(ClientStatusPresentationGateError::Incomplete) + )); + } + } + + #[tokio::test] + async fn withdrawal_uses_exact_scope_and_omits_current_state() { + let relay = Keys::generate(); + let author = Keys::generate(); + let source = revisions(Some( + DurableClientStatusRevision::from_durable_state(12, 11) + .expect("synthetic revision is valid"), + )); + let privacy_key = privacy_key(); + let issuer = RelayClientBindingStatusIssuer::new(&relay, &source, &privacy_key); + let issuance = receipt(author.public_key(), 11); + let event = issuer + .issue_withdrawn(&evidence(author.public_key()), &issuance) + .await + .expect("withdrawal event signs"); + let status = validate_client_binding_status_event( + &event, + &relay.public_key(), + domain(), + &author.public_key(), + ISSUED_AT, + ) + .expect("signed status validates"); + + assert_eq!(status.status_revision(), 12); + assert_eq!(status.binding_version(), None); + assert_eq!(status.policy_version(), None); + assert!(!event.content.contains("private-provider-policy-value")); + assert!(!event.content.contains("synthetic-profile")); + assert_eq!( + status.disposition(), + ClientBindingStatusDisposition::Withdrawn + ); + + let seen = source.seen.lock().expect("synthetic lock"); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].authorization_domain(), domain()); + assert_eq!(seen[0].event_author_pubkey(), author.public_key()); + } + + #[tokio::test] + async fn missing_durable_revision_withholds_all_output() { + let relay = Keys::generate(); + let author = Keys::generate(); + let source = revisions(None); + let privacy_key = privacy_key(); + let issuer = RelayClientBindingStatusIssuer::new(&relay, &source, &privacy_key); + let issuance = receipt(author.public_key(), 1); + assert!(matches!( + issuer + .issue_withdrawn(&evidence(author.public_key()), &issuance) + .await, + Err(RelayClientStatusError::RevisionUnavailable) + )); + } + + #[tokio::test] + async fn current_issuance_revalidates_exact_private_binding_state() { + let relay = Keys::generate(); + let author = Keys::generate(); + let source = revisions(Some( + DurableClientStatusRevision::from_durable_state(12, 12) + .expect("synthetic revision is valid"), + )); + let privacy_key = privacy_key(); + let issuer = RelayClientBindingStatusIssuer::new(&relay, &source, &privacy_key); + issuer + .issue_current(&evidence(author.public_key()), None) + .await + .expect("exact current binding signs"); + + let requirements = source.requirements.lock().expect("synthetic lock"); + assert_eq!(requirements.len(), 1); + let requirement = &requirements[0]; + assert_eq!(requirement.0, Uuid::from_u128(0x900)); + assert_eq!(requirement.1, 9); + assert_eq!(requirement.2, "synthetic-profile"); + assert_eq!(requirement.3, "private-provider-policy-value"); + assert_eq!(requirement.4, ISSUED_AT + 120); + } + + #[tokio::test] + async fn withdrawal_must_strictly_advance() { + let relay = Keys::generate(); + let author = Keys::generate(); + let source = revisions(Some( + DurableClientStatusRevision::from_durable_state(12, 12) + .expect("synthetic revision is valid"), + )); + let privacy_key = privacy_key(); + let issuer = RelayClientBindingStatusIssuer::new(&relay, &source, &privacy_key); + let issuance = receipt(author.public_key(), 12); + assert!(matches!( + issuer + .issue_withdrawn(&evidence(author.public_key()), &issuance) + .await, + Err(RelayClientStatusError::RevisionDidNotAdvance) + )); + } + + #[test] + fn privacy_label_is_configuration_only_and_current_only() { + assert!(PrivacyApprovedClientStatusLabel::from_server_configuration("").is_err()); + assert!(PrivacyApprovedClientStatusLabel::from_server_configuration(" private").is_err()); + let label = PrivacyApprovedClientStatusLabel::from_server_configuration( + "Privacy Approved Enterprise", + ) + .expect("synthetic configured label is valid"); + assert_eq!(label.as_str(), "Privacy Approved Enterprise"); + assert_eq!( + format!("{label:?}"), + "PrivacyApprovedClientStatusLabel(\"[redacted]\")" + ); + } + + #[test] + fn policy_revision_is_keyed_and_unlinkable_across_privacy_keys() { + let profile = AuthorizationProfileId::from_server_configuration("synthetic-profile") + .expect("synthetic profile is valid"); + let policy = + PolicyVersion::new("private-provider-policy-value").expect("synthetic policy is valid"); + let first = ProviderNeutralPolicyRevision::derive( + &ClientStatusPrivacyKey::from_secret([0x11; 32]), + &profile, + &policy, + ) + .expect("first synthetic key derives a revision"); + let second = ProviderNeutralPolicyRevision::derive( + &ClientStatusPrivacyKey::from_secret([0x22; 32]), + &profile, + &policy, + ) + .expect("second synthetic key derives a revision"); + + assert_eq!(first.as_str().len(), 64); + assert_eq!(second.as_str().len(), 64); + assert_ne!(first, second); + for revision in [first.as_str(), second.as_str()] { + assert!(!revision.contains(profile.as_str())); + assert!(!revision.contains(policy.as_str())); + } + assert_eq!( + format!("{:?}", ClientStatusPrivacyKey::from_secret([0x33; 32])), + "ClientStatusPrivacyKey(\"[redacted]\")" + ); + } + + #[tokio::test] + async fn dedicated_transport_contract_is_exact_scope_and_test_only_permitted() { + let relay = Keys::generate(); + let author = Keys::generate(); + let source = revisions(Some( + DurableClientStatusRevision::from_durable_state(14, 14) + .expect("synthetic revision is valid"), + )); + let privacy_key = privacy_key(); + let issuer = RelayClientBindingStatusIssuer::new(&relay, &source, &privacy_key); + let issuance = receipt(author.public_key(), 13); + let event = issuer + .issue_withdrawn(&evidence(author.public_key()), &issuance) + .await + .expect("synthetic gated status signs"); + let permit = ClientStatusPresentationPermit { _private: () }; + let connection_id = Uuid::new_v4(); + let transport = SyntheticDedicatedTransport { + deliveries: Mutex::new(Vec::new()), + }; + + transport + .deliver(DedicatedClientStatusDelivery { + event: &event, + relay_pubkey: relay.public_key(), + authorization_domain: domain(), + event_author_pubkey: author.public_key(), + connection_id, + }) + .expect("synthetic dedicated delivery succeeds"); + + let deliveries = transport.deliveries.lock().expect("synthetic lock"); + assert_eq!( + deliveries.as_slice(), + &[( + event.id, + domain(), + author.public_key(), + relay.public_key(), + connection_id, + )] + ); + assert!(format!("{permit:?}").starts_with("ClientStatusPresentationPermit")); + } + + #[tokio::test] + async fn production_transport_targets_only_the_exact_authenticated_connection() { + let relay = Keys::generate(); + let author = Keys::generate(); + let source = revisions(Some( + DurableClientStatusRevision::from_durable_state(14, 14) + .expect("synthetic revision is valid"), + )); + let privacy_key = privacy_key(); + let issuer = RelayClientBindingStatusIssuer::new(&relay, &source, &privacy_key); + let event = issuer + .issue_withdrawn( + &evidence(author.public_key()), + &receipt(author.public_key(), 13), + ) + .await + .expect("synthetic withdrawal signs"); + + let connections = Arc::new(crate::state::ConnectionManager::new()); + let connection_id = Uuid::new_v4(); + let (tx, mut rx) = tokio::sync::mpsc::channel(2); + let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(2); + connections.register( + connection_id, + tx, + ctrl_tx, + CancellationToken::new(), + domain(), + Arc::new(AtomicU8::new(0)), + Arc::new(tokio::sync::Mutex::new(HashMap::new())), + 3, + ); + connections + .set_authenticated_pubkey(connection_id, author.public_key().to_bytes().to_vec()); + let transport = ConnectionManagerClientStatusTransport::new(Arc::clone(&connections)); + transport + .deliver(DedicatedClientStatusDelivery { + event: &event, + relay_pubkey: relay.public_key(), + authorization_domain: domain(), + event_author_pubkey: author.public_key(), + connection_id, + }) + .expect("exact authenticated connection accepts status"); + let outbound = rx.recv().await.expect("dedicated frame is queued"); + let axum::extract::ws::Message::Text(frame) = outbound.message else { + panic!("dedicated status must be a text frame"); + }; + assert!(frame.as_str().contains("__buzz_client_binding_status_v1__")); + assert!(frame.as_str().contains(&event.id.to_string())); + + for (wrong_domain, wrong_author) in [ + ( + CommunityId::from_uuid(Uuid::from_u128(8)), + author.public_key(), + ), + (domain(), Keys::generate().public_key()), + ] { + assert_eq!( + transport.deliver(DedicatedClientStatusDelivery { + event: &event, + relay_pubkey: relay.public_key(), + authorization_domain: wrong_domain, + event_author_pubkey: wrong_author, + connection_id, + }), + Err(DedicatedClientStatusTransportError::Unavailable) + ); + } + assert!(rx.try_recv().is_err(), "wrong scopes emit no frame"); + } + + #[tokio::test] + async fn ambiguous_current_delivery_retains_withdrawable_receipt() { + let relay = Keys::generate(); + let author = Keys::generate(); + let source = revisions(Some( + DurableClientStatusRevision::from_durable_state(21, 21) + .expect("synthetic revision is valid"), + )); + let privacy_key = privacy_key(); + let issuer = RelayClientBindingStatusIssuer::new(&relay, &source, &privacy_key); + let connection_id = Uuid::new_v4(); + let attempt = issuer + .deliver_current( + evidence(author.public_key()), + None, + connection_id, + &FailingDedicatedTransport, + ) + .await + .expect("durable allocation and signing succeed"); + + assert_eq!(attempt.receipt().connection_id(), connection_id); + assert_eq!(attempt.receipt().revision(), 21); + assert_eq!( + attempt.delivery_error(), + Some(DedicatedClientStatusTransportError::Unavailable) + ); + assert_eq!(attempt.event().pubkey, relay.public_key()); + } + + #[tokio::test] + async fn invalidation_withdraws_every_exact_connection_receipt() { + let relay = Keys::generate(); + let author = Keys::generate(); + let source = revisions(Some( + DurableClientStatusRevision::from_durable_state(31, 31) + .expect("synthetic withdrawal revision is valid"), + )); + let privacy_key = privacy_key(); + let issuer = RelayClientBindingStatusIssuer::new(&relay, &source, &privacy_key); + let permit = ClientStatusPresentationPermit { _private: () }; + let first_connection = Uuid::new_v4(); + let second_connection = Uuid::new_v4(); + let mut first = receipt(author.public_key(), 30); + first.connection_id = first_connection; + let mut second = receipt(author.public_key(), 30); + second.connection_id = second_connection; + let transport = SyntheticDedicatedTransport { + deliveries: Mutex::new(Vec::new()), + }; + + issuer + .deliver_withdrawn_after_invalidation( + &permit, + &evidence(author.public_key()), + &first, + &transport, + ) + .await + .expect("first exact connection withdrawal"); + issuer + .deliver_withdrawn_after_invalidation( + &permit, + &evidence(author.public_key()), + &second, + &transport, + ) + .await + .expect("second exact connection withdrawal"); + + let targets = transport + .deliveries + .lock() + .expect("synthetic lock") + .iter() + .map(|delivery| delivery.4) + .collect::>(); + assert_eq!(targets, vec![first_connection, second_connection]); + } + + #[test] + fn current_issuance_fingerprint_frames_variable_fields() { + let author = Keys::generate().public_key(); + let mut first = evidence(author); + first.profile_id = + AuthorizationProfileId::from_server_configuration("a").expect("first profile"); + first.policy_version = PolicyVersion::new("bc").expect("first policy"); + let mut second = evidence(author); + second.profile_id = + AuthorizationProfileId::from_server_configuration("ab").expect("second profile"); + second.policy_version = PolicyVersion::new("c").expect("second policy"); + + assert_ne!( + current_issuance_fingerprint(&first, None), + current_issuance_fingerprint(&second, None) + ); + } + + #[test] + fn no_public_api_can_sign_current_status_without_a_receipt() { + let source = include_str!("status.rs"); + let production = source + .split("#[cfg(test)]") + .next() + .expect("production section exists"); + assert!(!production.contains("pub async fn issue_verification_only")); + assert!(!production.contains("pub async fn issue_current")); + } + + #[test] + fn production_module_has_no_authority_or_delivery_dependency() { + let source = include_str!("status.rs"); + let production = source + .split("#[cfg(test)]") + .next() + .expect("production section exists"); + for forbidden in [ + "AuthContext", + "AuthorizationLease", + "CapabilitySet", + "buzz_pubsub", + "handlers::", + "KIND_USER_TRUSTED_ASSERTION", + "corporate_identity", + ] { + assert!( + !production.contains(forbidden), + "status adapter gained forbidden dependency {forbidden}" + ); + } + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/status/postgres.rs b/crates/buzz-relay/src/authorization_runtime/status/postgres.rs new file mode 100644 index 0000000000..891d844b3f --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/status/postgres.rs @@ -0,0 +1,155 @@ +//! Durable PostgreSQL implementation behind the storage-agnostic status seam. + +use async_trait::async_trait; +use uuid::Uuid; + +use super::{ + ClientStatusCurrentRequirement, ClientStatusIssuanceReceipt, ClientStatusRevisionScope, + DurableClientStatusRevision, DurableClientStatusRevisionSource, +}; + +/// PostgreSQL-backed revision source coupled to the independent restore witness. +/// +/// Construction does not enable presentation; the unconstructible presentation +/// permit remains the separate runtime gate. +pub struct PostgresClientStatusRevisionSource { + db: buzz_db::Db, + restore: std::sync::Arc, +} + +impl PostgresClientStatusRevisionSource { + /// Bind the writer database and the exact initialized restore runtime. + pub fn new( + db: buzz_db::Db, + restore: std::sync::Arc, + ) -> Self { + Self { db, restore } + } + + async fn reconcile_allocation( + &self, + scope: ClientStatusRevisionScope, + operation_id: Uuid, + request_fingerprint: [u8; 32], + result: Result< + buzz_db::client_status::AllocatedStatusRevision, + buzz_db::client_status::ClientStatusAllocationError, + >, + witness: super::super::restore::RestoreMutationGuard, + ) -> Option { + match result { + Ok(revision) => { + witness.commit().await.ok()?; + DurableClientStatusRevision::from_durable_state(revision.revision, revision.floor) + .ok() + } + Err(buzz_db::client_status::ClientStatusAllocationError::CommitUnknown(_)) => { + witness.commit().await.ok()?; + let revision = self + .db + .committed_status_revision( + scope.authorization_domain(), + operation_id, + request_fingerprint, + ) + .await + .ok()??; + DurableClientStatusRevision::from_durable_state(revision.revision, revision.floor) + .ok() + } + Err(_) => { + let _ = witness.abort().await; + None + } + } + } +} + +#[async_trait] +impl DurableClientStatusRevisionSource for PostgresClientStatusRevisionSource { + async fn current_revision_for( + &self, + requirement: &ClientStatusCurrentRequirement<'_>, + issuance_fingerprint: [u8; 32], + ) -> Option { + let scope = requirement.scope(); + let operation_id = super::super::executor::ProtectedOperationId::derive( + scope.authorization_domain(), + "client.status.current.v1", + &issuance_fingerprint, + ) + .ok()? + .as_uuid(); + let witness = self + .restore + .begin( + scope.authorization_domain(), + operation_id, + issuance_fingerprint, + ) + .await + .ok()?; + let event_author_pubkey = scope.event_author_pubkey().to_bytes(); + let result = self + .db + .allocate_current_status_revision(buzz_db::client_status::CurrentStatusAllocation { + community_id: scope.authorization_domain(), + event_author_pubkey: &event_author_pubkey, + binding_id: requirement.binding_id(), + binding_version: requirement.binding_version().get(), + policy_version: requirement.policy_version().as_str(), + evaluation_generation: requirement.evaluation_generation(), + fresh_until: requirement.fresh_until(), + operation_id, + request_fingerprint: issuance_fingerprint, + }) + .await; + self.reconcile_allocation(scope, operation_id, issuance_fingerprint, result, witness) + .await + } + + async fn withdrawal_revision_for( + &self, + receipt: &ClientStatusIssuanceReceipt, + withdrawal_fingerprint: [u8; 32], + ) -> Option { + let operation_id = super::super::executor::ProtectedOperationId::derive( + receipt.scope.authorization_domain(), + "client.status.withdraw.v1", + &withdrawal_fingerprint, + ) + .ok()? + .as_uuid(); + let witness = self + .restore + .begin( + receipt.scope.authorization_domain(), + operation_id, + withdrawal_fingerprint, + ) + .await + .ok()?; + let event_author_pubkey = receipt.scope.event_author_pubkey().to_bytes(); + let result = self + .db + .allocate_withdrawn_status_revision( + buzz_db::client_status::WithdrawalStatusAllocation { + community_id: receipt.scope.authorization_domain(), + event_author_pubkey: &event_author_pubkey, + supersedes_revision: receipt.revision, + issuance_fingerprint: receipt.issuance_fingerprint, + operation_id, + request_fingerprint: withdrawal_fingerprint, + }, + ) + .await; + self.reconcile_allocation( + receipt.scope, + operation_id, + withdrawal_fingerprint, + result, + witness, + ) + .await + } +} diff --git a/crates/buzz-relay/src/corporate_identity.rs b/crates/buzz-relay/src/corporate_identity.rs index 0c0cfabf3f..cc5964d04e 100644 --- a/crates/buzz-relay/src/corporate_identity.rs +++ b/crates/buzz-relay/src/corporate_identity.rs @@ -3131,9 +3131,15 @@ mod tests { ) .await .expect("commit active projection"); - db.revoke_identity_key(community, subject.as_bytes(), None, "synthetic revocation") - .await - .expect("revoke synthetic key"); + db.revoke_identity_key( + community, + buzz_db::identity_lifecycle::LifecycleOperationId::issue(), + subject.as_bytes(), + subject.as_bytes(), + "synthetic revocation", + ) + .await + .expect("revoke synthetic key"); let observational = make_community(&pool).await; let observational_keys = Keys::generate(); @@ -3175,8 +3181,9 @@ mod tests { .expect("commit observational projection"); db.revoke_identity_key( observational, + buzz_db::identity_lifecycle::LifecycleOperationId::issue(), + observational_subject.as_bytes(), observational_subject.as_bytes(), - None, "observational revocation", ) .await diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 10cb7ad6d6..53ae9437ea 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -12,6 +12,9 @@ use std::sync::Arc; use axum::extract::ws::Message as WsMessage; +use buzz_auth::{ + AuthTransport, VerifiedDelegationOutput, VerifiedEvidenceAdapter, VerifiedNostrProof, +}; use tracing::{debug, info, warn}; use crate::connection::{AuthState, ConnectionState}; @@ -42,6 +45,7 @@ pub fn extract_auth_tag_json(event: &nostr::Event) -> Option { #[tracing::instrument(skip_all, fields(event_id, conn_id))] pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Arc) { let event_id_hex = event.id.to_hex(); + let verified_event = event.clone(); let (challenge, conn_id) = { let auth = conn.auth_state.read().await; match &*auth { @@ -146,7 +150,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Ok(state) if state.banned => BanOutcome::Banned, Ok(_) => BanOutcome::Clear, Err(e) => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, + warn!(conn_id = %conn_id, error = %e, "ban-state DB lookup failed, denying (fail-closed)"); BanOutcome::DbError } @@ -168,7 +172,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Ok(state) if state.banned => BanOutcome::Banned, Ok(_) => BanOutcome::Clear, Err(e) => { - warn!(conn_id = %conn_id, owner = %owner.to_hex(), error = %e, + warn!(conn_id = %conn_id, error = %e, "owner ban-state DB lookup failed, denying (fail-closed)"); BanOutcome::DbError } @@ -188,7 +192,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: }; if let Some((metric_reason, deny_reason)) = denial { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), reason = deny_reason, "principal denied at ban seam"); + warn!(conn_id = %conn_id, reason = deny_reason, "principal denied at ban seam"); metrics::counter!("buzz_auth_failures_total", "reason" => metric_reason) .increment(1); *conn.auth_state.write().await = AuthState::Failed; @@ -205,26 +209,61 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } } + let identity_lane = crate::authorization_runtime::transport::legacy_identity_lane( + &state, + conn.tenant.community(), + ); let identity_proof = match crate::corporate_identity::verify_corporate_identity( &state, conn.tenant.community(), pubkey, - conn.corporate_identity_jwt.as_deref(), + conn.corporate_identity_assertion.as_ref(), auth_tag_json.as_deref(), ) .await { - Ok(proof) => proof, + Ok(proof) => Some(proof), Err(e) => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, "corporate identity denied"); - *conn.auth_state.write().await = AuthState::Failed; - conn.send(RelayMessage::ok( - &event_id_hex, - false, - &format!("restricted: {}", e.public_message()), - )); - return; + warn!(conn_id = %conn_id, error = ?e, "corporate identity denied"); + if identity_lane + == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly + { + None + } else { + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + &format!("restricted: {}", e.public_message()), + )); + return; + } + } + }; + + let verified_assertion = match identity_proof.as_ref() { + Some(proof) => { + match crate::corporate_identity::current_verified_assertion_for_proof( + &state, + proof, + conn.tenant.community(), + AuthTransport::RelayWebSocket, + ) { + Ok(assertion) => assertion.map(Arc::new), + Err(error) => { + warn!(conn_id = %conn_id, error = %error, "federated evidence sealing failed"); + if identity_lane + == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly + { + None + } else { + *conn.auth_state.write().await = AuthState::Failed; + return; + } + } + } } + None => None, }; // Pubkey allowlist gate — only for pubkey-only auth. @@ -238,13 +277,13 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: { Ok(v) => v, Err(e) => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, + warn!(conn_id = %conn_id, error = %e, "allowlist DB lookup failed, denying (fail-closed)"); false } }; if !allowed { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "pubkey not in allowlist"); + warn!(conn_id = %conn_id, "pubkey not in allowlist"); metrics::counter!("buzz_auth_failures_total", "reason" => "allowlist_denied") .increment(1); *conn.auth_state.write().await = AuthState::Failed; @@ -268,7 +307,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: { Ok(owner) => owner, Err(e) => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = ?e, "not a relay member"); + warn!(conn_id = %conn_id, error = ?e, "not a relay member"); metrics::counter!("buzz_auth_failures_total", "reason" => "not_relay_member") .increment(1); *conn.auth_state.write().await = AuthState::Failed; @@ -281,30 +320,40 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } }; - let identity_decision = match crate::corporate_identity::finalize_corporate_identity( - &state, - conn.tenant.community(), - pubkey, - identity_proof, - ) - .await + let identity_decision = if identity_lane + == crate::authorization_runtime::transport::LegacyIdentityLane::Legacy { - Ok(decision) => decision, - Err(e) => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, "corporate identity finalization denied"); - *conn.auth_state.write().await = AuthState::Failed; - conn.send(RelayMessage::ok( - &event_id_hex, - false, - &format!("restricted: {}", e.public_message()), - )); - return; + if let Some(identity_proof) = identity_proof.clone() { + match crate::corporate_identity::finalize_corporate_identity( + &state, + conn.tenant.community(), + pubkey, + identity_proof, + ) + .await + { + Ok(decision) => Some(decision), + Err(e) => { + warn!(conn_id = %conn_id, error = ?e, "corporate identity finalization denied"); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + &format!("restricted: {}", e.public_message()), + )); + return; + } + } + } else { + None } + } else { + None }; - if let crate::corporate_identity::CorporateIdentityDecision::Delegated { + if let Some(crate::corporate_identity::CorporateIdentityDecision::Delegated { owner_pubkey, .. - } = &identity_decision + }) = &identity_decision { auth_ctx.agent_owner_pubkey = Some(*owner_pubkey); } @@ -327,37 +376,95 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // Stash NIP-OA owner on the auth context only after the shared // backfill confirms the first-write-wins relationship. if let Some(owner) = nip_oa_owner { - if crate::api::relay_members::materialize_nip_oa_owner( - &state, - &conn.tenant, - &pubkey, - &owner, - ) - .await - { + let owner_is_current = identity_lane + != crate::authorization_runtime::transport::LegacyIdentityLane::Legacy + || crate::api::relay_members::materialize_nip_oa_owner( + &state, + &conn.tenant, + &pubkey, + &owner, + ) + .await; + if owner_is_current { auth_ctx.agent_owner_pubkey = Some(owner); } else { warn!( conn_id = %conn_id, - agent = %pubkey.to_hex(), - nip_oa_owner = %owner.to_hex(), "NIP-OA owner could not be materialized" ); } } - info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful"); + info!(conn_id = %conn_id, "NIP-42 auth successful"); + let transport_delegation = + crate::corporate_identity::verify_unconditional_nip_oa_owner( + pubkey, + auth_tag_json.as_deref(), + ) + .map(|owner| { + VerifiedDelegationOutput::from_workspace_verifier(owner, pubkey, None, true) + }); + let verified_proof: Arc = match VerifiedEvidenceAdapter::new() + .verify_nip42( + conn.tenant.community(), + AuthTransport::RelayWebSocket, + &verified_event, + &challenge, + &relay_url, + transport_delegation, + ) { + Ok(proof) => Arc::new(proof), + Err(error) => { + warn!(conn_id = %conn_id, error = %error, "sealed NIP-42 evidence creation failed"); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: verification failed", + )); + return; + } + }; *conn.auth_state.write().await = AuthState::Authenticated(auth_ctx); - state - .conn_manager - .set_authenticated_pubkey(conn_id, pubkey.to_bytes().to_vec()); - crate::corporate_identity::spawn_session_revalidation( - Arc::clone(&state), - conn.tenant.community(), - pubkey, - identity_decision, - conn.cancel.clone(), + state.conn_manager.set_authenticated_authority( + conn_id, + Arc::clone(&verified_proof), + verified_assertion.clone(), ); + if let (Some(runtime), Some(assertion)) = + (state.client_status_runtime().cloned(), verified_assertion) + { + if let Err(error) = runtime + .present_after_auth( + Arc::clone(&state), + verified_proof, + assertion, + conn_id, + conn.cancel.clone(), + ) + .await + { + // Presentation failure never widens or narrows access. The + // client receives no current indicator and clears any old + // status on its existing freshness/disconnect boundary. + metrics::counter!("buzz_client_status_degradation_total").increment(1); + warn!( + conn_id = %conn_id, + reason = "client_status_unavailable", + "client binding status withheld" + ); + tracing::debug!(error = %error, "client binding status detail"); + } + } + if let Some(identity_decision) = identity_decision { + crate::corporate_identity::spawn_session_revalidation( + Arc::clone(&state), + conn.tenant.community(), + pubkey, + identity_decision, + conn.cancel.clone(), + ); + } conn.send(RelayMessage::ok(&event_id_hex, true, "")); } Err(e) => { @@ -378,6 +485,48 @@ mod tests { use super::extract_auth_tag_json; use nostr::{EventBuilder, Keys, Kind, Tag}; + #[test] + fn observational_auth_cannot_enter_mutating_identity_lane() { + use crate::authorization_runtime::{ + finalization::AuthorizationMode, + transport::{legacy_identity_lane_for_mode, LegacyIdentityLane}, + }; + + let mut binding_writes = 0; + let mut membership_writes = 0; + let mut public_projection_writes = 0; + for mode in [ + AuthorizationMode::Shadow, + AuthorizationMode::VerifyOnly, + AuthorizationMode::Enforce, + ] { + if legacy_identity_lane_for_mode(Some(mode)) == LegacyIdentityLane::Legacy { + binding_writes += 1; + membership_writes += 1; + public_projection_writes += 1; + } + } + assert_eq!(binding_writes, 0); + assert_eq!(membership_writes, 0); + assert_eq!(public_projection_writes, 0); + assert_eq!( + legacy_identity_lane_for_mode(Some(AuthorizationMode::Off)), + LegacyIdentityLane::Legacy + ); + assert_eq!( + legacy_identity_lane_for_mode(Some(AuthorizationMode::Shadow)), + LegacyIdentityLane::ObserveOnly + ); + assert_eq!( + legacy_identity_lane_for_mode(Some(AuthorizationMode::VerifyOnly)), + LegacyIdentityLane::ObserveOnly + ); + assert_eq!( + legacy_identity_lane_for_mode(Some(AuthorizationMode::Enforce)), + LegacyIdentityLane::ProtectedEnforce + ); + } + /// Build a signed NIP-98 (kind 27235) event carrying the given tags. The /// `auth` tag lives inside the signed event exactly as the git and /// WebSocket auth paths receive it. diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 904af74803..3bdd3d8b4e 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -4,6 +4,9 @@ mod admission; +/// Provider-neutral runtime authorization and bounded finalization. +pub mod authorization_runtime; + /// REST API route handlers. pub mod api; /// WebSocket audio relay for huddle voice channels. @@ -31,6 +34,8 @@ pub mod mesh_boot; pub mod metrics; /// NIP-11 relay information document. pub mod nip11; +/// Provider-neutral inventory of every protected relay surface. +pub mod protected_surface; /// NIP-01 client/relay message parsing. pub mod protocol; /// Durable NIP-PL matcher and delivery worker. diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 799cf9cf60..fd7ba659e8 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -319,7 +319,7 @@ async fn main() -> anyhow::Result<()> { (deployment_community, config.relay_owner_pubkey.as_ref()) { match db.bootstrap_owner(community, owner_pubkey).await { - Ok(()) => info!(pubkey = %owner_pubkey, "Relay owner bootstrapped"), + Ok(()) => info!("Relay owner bootstrapped"), Err(e) => { if config.require_relay_membership { // Membership enforcement is on — a missing owner means no one @@ -427,7 +427,6 @@ async fn main() -> anyhow::Result<()> { "0000000000000000000000000000000000000000000000000000000000000001"; let keys = nostr::Keys::parse(DEV_RELAY_PRIVKEY).expect("hardcoded dev key is valid"); tracing::warn!( - pubkey = %keys.public_key().to_hex(), "Using hardcoded dev relay keypair (BUZZ_REQUIRE_AUTH_TOKEN=false). \ Set BUZZ_RELAY_PRIVATE_KEY for production." ); @@ -461,6 +460,15 @@ async fn main() -> anyhow::Result<()> { ); let state = Arc::new(app_state); + // Protected authorization is absent unless exact domains are named in + // server configuration. When present, durable invalidation snapshots are + // initialized before the runtime becomes reachable by any transport. + if buzz_relay::authorization_runtime::production::install_from_environment(&state).await? + == buzz_relay::authorization_runtime::production::ProtectedRuntimeInstallation::Installed + { + info!("Protected authorization runtime installed"); + } + // Inter-relay mesh (BUZZ_MESH seam). `boot_mesh` returns None when the // kill switch is off — nothing is bound, published, or spawned, so the // relay behaves byte-identically to a build without the mesh. When @@ -480,6 +488,8 @@ async fn main() -> anyhow::Result<()> { // BUZZ_MESH_DEMO_ECHO) before peers can route traffic here. handle.wire_consumers( Arc::clone(&state.audio_rooms), + state.db.clone(), + state.relay_keypair.secret_key().as_secret_bytes(), state.config.mesh_demo_echo, Arc::clone(&state.shutting_down), ); @@ -527,6 +537,41 @@ async fn main() -> anyhow::Result<()> { ); } + // Enforce startup is verification-only for protected-object cutover. + // The resumable one-way preparation must complete before the independent + // restore anchor is provisioned; mutating PostgreSQL after anchor + // verification would create an unwitnessed authority advance. + if let Some(runtime) = state.protected_transport() { + let enforcing = runtime.enforcing_domains(); + if !enforcing.is_empty() { + let hosts = state + .db + .usage_community_hosts() + .await? + .into_iter() + .map(|record| (buzz_core::CommunityId::from_uuid(record.id), record.host)) + .collect::>(); + for community_id in enforcing { + let host = hosts.get(&community_id).ok_or_else(|| { + anyhow::anyhow!( + "protected object verification domain has no active community mapping" + ) + })?; + let tenant = buzz_core::TenantContext::resolved(community_id, host); + let verification = async { + buzz_relay::api::git::migration::require_reconciled_authority(&state, &tenant) + .await?; + buzz_relay::api::media_migration::require_reconciled_authority(&state, &tenant) + .await?; + anyhow::Ok(()) + }; + tokio::time::timeout(std::time::Duration::from_secs(600), verification) + .await + .map_err(|_| anyhow::anyhow!("protected object verification timed out"))??; + } + } + } + // NIP-43: reconcile the event-backed roster for every provisioned // community before opening the listener. `relay_members` is canonical; // this repairs pre-snapshot communities and any publication that failed @@ -616,8 +661,12 @@ async fn main() -> anyhow::Result<()> { }); } - // Wire the action sink — must happen after AppState (which creates - // sub_registry, conn_manager) and before the cron loop starts. + // Wire the provider-neutral mutation gate and action sink after AppState + // construction and before any scheduled workflow can start. + let mutation_gate = Arc::new(buzz_relay::workflow_sink::RelayWorkflowMutationGate::new( + &state, + )); + workflow_engine.set_mutation_gate(mutation_gate); let action_sink = Arc::new(buzz_relay::workflow_sink::RelayActionSink::new(&state)); workflow_engine.set_action_sink(action_sink); @@ -645,7 +694,12 @@ async fn main() -> anyhow::Result<()> { loop { tokio::time::sleep(std::time::Duration::from_secs(reaper_interval_secs)).await; - let expired = match reaper_state.db.reap_expired_ephemeral_channels().await { + let excluded = reaper_state.enforcing_protected_domain_ids(); + let expired = match reaper_state + .db + .reap_expired_ephemeral_channels_excluding(&excluded) + .await + { Ok(ids) => ids, Err(e) => { error!("Ephemeral reaper tick failed: {e}"); @@ -748,9 +802,10 @@ async fn main() -> anyhow::Result<()> { tokio::time::sleep(std::time::Duration::from_secs(scheduler_interval_secs)).await; let now_secs = chrono::Utc::now().timestamp(); + let excluded = scheduler_state.enforcing_protected_domain_ids(); let due = match scheduler_state .db - .query_due_reminders(now_secs, scheduler_batch_limit) + .query_due_reminders_excluding(now_secs, scheduler_batch_limit, &excluded) .await { Ok(reminders) => reminders, @@ -1506,9 +1561,10 @@ async fn run_usage_metrics_tick( return Err(error); } let invite_retention_cutoff = chrono::Utc::now() - chrono::Duration::days(30); + let excluded = state.enforcing_protected_domain_ids(); match state .db - .reap_expired_relay_invites(invite_retention_cutoff) + .reap_expired_relay_invites_excluding(invite_retention_cutoff, &excluded) .await { Ok(deleted) if deleted > 0 => { diff --git a/crates/buzz-relay/src/mesh_boot.rs b/crates/buzz-relay/src/mesh_boot.rs index 2ad3ce5fa7..d6c2242b70 100644 --- a/crates/buzz-relay/src/mesh_boot.rs +++ b/crates/buzz-relay/src/mesh_boot.rs @@ -157,6 +157,9 @@ pub struct MeshHandle { /// /// [`MeshAudioRouter`]: crate::audio::mesh::MeshAudioRouter pub audio_fence: Arc, + /// Live reliable-control attachments accepted by the realtime media lane. + /// Datagrams cannot create entries in this registry. + pub audio_attachments: Arc, /// The running mesh (status snapshots, shutdown). runtime: MeshRuntime, /// Per-room huddle owner-lease coordination. Shared with the WS-join owner @@ -180,6 +183,8 @@ impl MeshHandle { pub fn wire_consumers( &self, rooms: Arc, + db: buzz_db::Db, + relay_secret: &[u8], demo_echo: bool, shutting_down: Arc, ) { @@ -189,8 +194,15 @@ impl MeshHandle { Arc::clone(&self.transport), self.local_runtime_id, Arc::clone(&self.audio_fence), + Arc::clone(&self.audio_attachments), rooms, Arc::clone(&self.owners), + Some( + crate::authorization_runtime::ephemeral::AuthorityTokenVerifier::new( + db, + relay_secret, + ), + ), demo_echo, shutting_down, ) @@ -221,14 +233,16 @@ impl MeshHandle { /// [`HuddleControlAcceptor::accept_inbound`]: crate::audio::join::HuddleControlAcceptor::accept_inbound /// [`ReliableJoin::Owned`]: crate::tunnel::reliable::ReliableJoin::Owned #[allow(clippy::too_many_arguments)] // boot-only parts bundle, one caller + tests -pub fn wire_mesh_consumers( +pub(crate) fn wire_mesh_consumers( dispatcher: &MeshInboundDispatcher, directory: SessionDirectory, transport: Arc, local_runtime_id: RuntimeId, audio_fence: Arc, + audio_attachments: Arc, rooms: Arc, owners: Arc, + authority_verifier: Option, demo_echo: bool, shutting_down: Arc, ) { @@ -239,21 +253,29 @@ pub fn wire_mesh_consumers( Arc::clone(&rooms), local_runtime_id, audio_fence, + Arc::clone(&audio_attachments), ); - dispatcher.register_datagrams(Box::new(move |_from, dgram| { - audio_router.on_media_datagram(&dgram); + dispatcher.register_datagrams(Box::new(move |from, dgram| { + audio_router.on_media_datagram(from, &dgram); })); // HuddleControl streams: owner-side peer registration for cross-pod // huddles. The acceptor validates structurally, then Redis-fences every // stateful frame in its control loop. - let acceptor = Arc::new(crate::audio::join::HuddleControlAcceptor::new( + let acceptor = crate::audio::join::HuddleControlAcceptor::new( rooms, Arc::clone(&transport), Arc::new(directory.clone()), local_runtime_id, Arc::clone(&owners), - )); + audio_attachments, + ); + let acceptor = if let Some(verifier) = authority_verifier { + acceptor.with_authority_verifier(verifier) + } else { + acceptor + }; + let acceptor = Arc::new(acceptor); dispatcher.register_huddle_control(Box::new(move |from, hello, stream| { let acceptor = Arc::clone(&acceptor); tokio::spawn(async move { @@ -486,6 +508,7 @@ pub async fn boot_mesh( let runtime = MeshRuntime::start(endpoint, membership, Some(registry)); let owners = Arc::new(crate::audio::join::HuddleOwnerRegistry::new()); + let audio_attachments = Arc::new(crate::audio::mesh::MediaAttachmentRegistry::default()); // Dial seed peers now rather than waiting for the first reconcile tick. runtime.reconcile_now().await; @@ -528,6 +551,7 @@ pub async fn boot_mesh( local_runtime_id: runtime_id, dispatcher, audio_fence: Arc::new(crate::audio::mesh::GenerationFloor::new()), + audio_attachments, runtime, owners, })) @@ -719,6 +743,7 @@ mod tests { let dispatcher = MeshInboundDispatcher::default(); let fence = Arc::new(crate::audio::mesh::GenerationFloor::new()); + let attachments = Arc::new(crate::audio::mesh::MediaAttachmentRegistry::default()); let pool = deadpool_redis::Config::from_url("redis://127.0.0.1:1") // never dialed .create_pool(Some(deadpool_redis::Runtime::Tokio1)) .unwrap(); @@ -728,25 +753,30 @@ mod tests { Arc::new(NoopTransport), rid(9), Arc::clone(&fence), + Arc::clone(&attachments), Arc::new(crate::audio::AudioRoomManager::new()), Arc::new(crate::audio::join::HuddleOwnerRegistry::new()), + None, false, Arc::new(AtomicBool::new(false)), ); let session = uuid::Uuid::new_v4(); + let fenced = FencedHeader { + session_id: session, + generation: 7, + owner_runtime_id: rid(1), + }; + let _attachment = attachments.register_owner_fanout(fenced, uuid::Uuid::new_v4(), u64::MAX); dispatcher.on_datagram( rid(1), MeshDatagram { - fenced: FencedHeader { - session_id: session, - generation: 7, - owner_runtime_id: rid(9), - }, + fenced, seq: 0, payload: vec![0, 1, 2], }, ); + tokio::task::yield_now().await; // The shared fence observed the datagram's generation: a stale check // through the HANDLE's Arc is rejected, proving one floor, not two. diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index c1e62b33b6..d99b46adcd 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -1,6 +1,9 @@ //! NIP-11 relay information document. +use std::num::NonZeroU64; + use serde::{Deserialize, Serialize}; +use thiserror::Error; #[cfg(test)] use crate::config::DEFAULT_MAX_FRAME_BYTES; @@ -20,6 +23,143 @@ pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33 /// to be verifiable by clients. pub(crate) const NIP_RELAY_MEMBERSHIP: u32 = 43; +/// Provider-neutral NIP-FI assertion transport profile. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum NipFiTransportProfile { + /// Assertions are injected only by an origin-isolated trusted proxy that + /// strips untrusted inbound copies of the configured assertion header. + TrustedProxy, + /// Assertions are attached by the client to the same protected HTTP + /// request as its NIP-98 proof. + ClientAttached, +} + +/// Provider-neutral NIP-FI enrollment mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum NipFiEnrollmentMode { + /// First enrollment requires an assertion key attestation. + AttestedKey, + /// Binding creation requires a separate privileged transition. + Provisioned, + /// First valid use may create the binding under explicit TOFU policy. + Tofu, +} + +/// Provider-neutral NIP-FI discovery object. +/// +/// Construction makes the delegation bound invariant unrepresentable: +/// delegation is `true` exactly when a positive finite maximum is present. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NipFiDiscovery { + transports: Vec, + enrollment: NipFiEnrollmentMode, + delegation: bool, + #[serde(skip_serializing_if = "Option::is_none")] + delegated_lease_max_seconds: Option, +} + +impl NipFiDiscovery { + /// Validate provider-neutral discovery configuration. + pub fn new( + mut transports: Vec, + enrollment: NipFiEnrollmentMode, + delegated_lease_max_seconds: Option, + ) -> Result { + if transports.is_empty() { + return Err(NipFiDiscoveryError::NoTransport); + } + transports.sort_unstable(); + let original_len = transports.len(); + transports.dedup(); + if transports.len() != original_len { + return Err(NipFiDiscoveryError::DuplicateTransport); + } + + Ok(Self { + transports, + enrollment, + delegation: delegated_lease_max_seconds.is_some(), + delegated_lease_max_seconds, + }) + } + + fn includes_transport(&self, transport: NipFiTransportProfile) -> bool { + self.transports.contains(&transport) + } +} + +/// Complete-stack conformance input supplied by the release/conformance lane. +/// +/// A source may return `true` only when every applicable NIP-FI row passed +/// against the same reviewed implementation revision. Trusted-proxy support +/// additionally requires deployment evidence for origin isolation and inbound +/// header stripping; synthetic code tests alone are insufficient. +pub trait CompleteNipFiRuntimeConformance: Send + Sync { + /// Exact reviewed implementation revision used for every applicable row. + fn reviewed_implementation_revision(&self) -> &str; + + /// Whether every applicable row passed at the reviewed revision. + fn all_applicable_rows_passed_at_same_revision(&self) -> bool; + + /// Whether trusted-proxy deployment controls and negative tests passed. + fn trusted_proxy_deployment_evidence_passed(&self) -> bool; +} + +/// Discovery proven ready by an injected complete-stack conformance source. +/// +/// The reviewed revision and evidence are deliberately not serialized into +/// NIP-11. This wrapper has no public field or unchecked constructor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConformanceReadyNipFiDiscovery(NipFiDiscovery); + +impl ConformanceReadyNipFiDiscovery { + /// Gate discovery on complete same-revision runtime and deployment proof. + pub fn from_complete_stack( + discovery: NipFiDiscovery, + conformance: &dyn CompleteNipFiRuntimeConformance, + ) -> Result { + let revision = conformance.reviewed_implementation_revision(); + if revision.len() != 40 + || !revision + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(NipFiDiscoveryError::InvalidReviewedRevision); + } + if !conformance.all_applicable_rows_passed_at_same_revision() { + return Err(NipFiDiscoveryError::IncompleteConformance); + } + if discovery.includes_transport(NipFiTransportProfile::TrustedProxy) + && !conformance.trusted_proxy_deployment_evidence_passed() + { + return Err(NipFiDiscoveryError::MissingTrustedProxyEvidence); + } + Ok(Self(discovery)) + } +} + +/// Fail-closed NIP-FI discovery construction error. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum NipFiDiscoveryError { + /// At least one supported transport must be advertised. + #[error("NIP-FI discovery requires at least one transport")] + NoTransport, + /// Each supported transport may appear only once. + #[error("NIP-FI discovery contains a duplicate transport")] + DuplicateTransport, + /// The complete-stack report did not identify one exact Git revision. + #[error("NIP-FI conformance report has an invalid reviewed revision")] + InvalidReviewedRevision, + /// Not every applicable row passed at the same revision. + #[error("NIP-FI complete-stack conformance is incomplete")] + IncompleteConformance, + /// Trusted-proxy origin isolation and header stripping were not proven. + #[error("NIP-FI trusted-proxy deployment evidence is incomplete")] + MissingTrustedProxyEvidence, +} + /// Relay information document served at `GET /` with `Accept: application/nostr+json`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayInfo { @@ -55,6 +195,10 @@ pub struct RelayInfo { /// Relay's own signing pubkey (NIP-11 `self` field, NIP-43). #[serde(rename = "self", skip_serializing_if = "Option::is_none")] pub relay_self: Option, + /// Provider-neutral NIP-FI capabilities. Omitted until a complete-stack + /// same-revision conformance input explicitly enables discovery. + #[serde(skip_serializing_if = "Option::is_none")] + federated_identity: Option, } /// Protocol and resource limits advertised in the NIP-11 document. @@ -85,6 +229,9 @@ pub struct RelayLimitation { /// NIP-ER: maximum allowed `not_before` horizon in seconds from now. #[serde(skip_serializing_if = "Option::is_none")] pub max_not_before_delta: Option, + /// NIP-FI support. Omitted until complete-stack conformance is proven. + #[serde(skip_serializing_if = "Option::is_none")] + federated_identity: Option, } /// Canonical `RelayLimitation` advertised by this relay. @@ -116,6 +263,7 @@ fn relay_limitation(max_message_length: usize) -> RelayLimitation { restricted_writes: true, due_delivery_mode: Some("push".to_string()), max_not_before_delta: Some(max_not_before_delta), + federated_identity: None, } } @@ -169,8 +317,25 @@ impl RelayInfo { limitation: Some(relay_limitation(max_message_length)), pairing_relay_url: pairing_relay_url.map(str::to_string), relay_self: relay_self.map(|s| s.to_string()), + federated_identity: None, } } + + /// Add provider-neutral NIP-FI discovery after complete-stack proof. + /// + /// There is intentionally no raw boolean/configuration overload. The + /// normal runtime build path has no readiness input and therefore remains + /// silent until the release/conformance lane supplies this gated value. + pub fn with_conformant_federated_identity( + mut self, + ready: ConformanceReadyNipFiDiscovery, + ) -> Self { + if let Some(limitation) = &mut self.limitation { + limitation.federated_identity = Some(true); + } + self.federated_identity = Some(ready.0); + self + } } /// Axum handler that returns the NIP-11 relay information document as JSON. @@ -267,6 +432,9 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st .push("nip-pl".to_string()); info.push = Some(push); } + if let Some(ready) = state.nip_fi_discovery().cloned() { + info = info.with_conformant_federated_identity(ready); + } info } @@ -350,6 +518,34 @@ const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn( mod tests { use super::*; + struct SyntheticConformance { + revision: &'static str, + complete: bool, + trusted_proxy_evidence: bool, + } + + impl CompleteNipFiRuntimeConformance for SyntheticConformance { + fn reviewed_implementation_revision(&self) -> &str { + self.revision + } + + fn all_applicable_rows_passed_at_same_revision(&self) -> bool { + self.complete + } + + fn trusted_proxy_deployment_evidence_passed(&self) -> bool { + self.trusted_proxy_evidence + } + } + + fn complete_conformance() -> SyntheticConformance { + SyntheticConformance { + revision: "0123456789abcdef0123456789abcdef01234567", + complete: true, + trusted_proxy_evidence: true, + } + } + #[test] fn push_descriptor_is_gated_by_gateway_configuration_and_tenant_binding() { let keys = nostr::Keys::generate(); @@ -402,6 +598,142 @@ mod tests { assert_eq!(info.software, "https://github.com/block/buzz"); } + #[test] + fn default_discovery_is_silent_until_complete_stack_input_exists() { + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + let json = serde_json::to_value(info).expect("serialize default NIP-11"); + + assert!(json.get("federated_identity").is_none()); + assert!(json["limitation"].get("federated_identity").is_none()); + } + + #[test] + fn discovery_rejects_empty_duplicate_and_incomplete_inputs() { + assert_eq!( + NipFiDiscovery::new(Vec::new(), NipFiEnrollmentMode::AttestedKey, None), + Err(NipFiDiscoveryError::NoTransport) + ); + assert_eq!( + NipFiDiscovery::new( + vec![ + NipFiTransportProfile::ClientAttached, + NipFiTransportProfile::ClientAttached, + ], + NipFiEnrollmentMode::Provisioned, + None, + ), + Err(NipFiDiscoveryError::DuplicateTransport) + ); + + let discovery = NipFiDiscovery::new( + vec![NipFiTransportProfile::ClientAttached], + NipFiEnrollmentMode::Provisioned, + None, + ) + .expect("synthetic discovery is valid"); + for conformance in [ + SyntheticConformance { + revision: "not-a-revision", + complete: true, + trusted_proxy_evidence: true, + }, + SyntheticConformance { + revision: "0123456789abcdef0123456789abcdef01234567", + complete: false, + trusted_proxy_evidence: true, + }, + ] { + assert!(ConformanceReadyNipFiDiscovery::from_complete_stack( + discovery.clone(), + &conformance, + ) + .is_err()); + } + } + + #[test] + fn trusted_proxy_advertisement_requires_deployment_evidence() { + let discovery = NipFiDiscovery::new( + vec![NipFiTransportProfile::TrustedProxy], + NipFiEnrollmentMode::AttestedKey, + None, + ) + .expect("synthetic discovery is valid"); + let conformance = SyntheticConformance { + revision: "0123456789abcdef0123456789abcdef01234567", + complete: true, + trusted_proxy_evidence: false, + }; + + assert_eq!( + ConformanceReadyNipFiDiscovery::from_complete_stack(discovery, &conformance), + Err(NipFiDiscoveryError::MissingTrustedProxyEvidence) + ); + } + + #[test] + fn conformant_discovery_is_provider_neutral_and_delegation_bounded() { + let max = NonZeroU64::new(300).expect("synthetic bound is positive"); + let discovery = NipFiDiscovery::new( + vec![ + NipFiTransportProfile::TrustedProxy, + NipFiTransportProfile::ClientAttached, + ], + NipFiEnrollmentMode::AttestedKey, + Some(max), + ) + .expect("synthetic discovery is valid"); + let ready = + ConformanceReadyNipFiDiscovery::from_complete_stack(discovery, &complete_conformance()) + .expect("complete synthetic report enables discovery"); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None) + .with_conformant_federated_identity(ready); + let json = serde_json::to_value(info).expect("serialize conformant discovery"); + + assert_eq!(json["limitation"]["federated_identity"], true); + assert_eq!( + json["federated_identity"], + serde_json::json!({ + "transports": ["trusted-proxy", "client-attached"], + "enrollment": "attested-key", + "delegation": true, + "delegated_lease_max_seconds": 300, + }) + ); + let encoded = json.to_string(); + for private in [ + "synthetic-issuer", + "synthetic-subject", + "tenant.example", + "private-audience", + "assertion-header-name", + ] { + assert!(!encoded.contains(private)); + } + } + + #[test] + fn discovery_without_delegation_omits_lease_bound() { + let discovery = NipFiDiscovery::new( + vec![NipFiTransportProfile::ClientAttached], + NipFiEnrollmentMode::Tofu, + None, + ) + .expect("synthetic discovery is valid"); + let ready = + ConformanceReadyNipFiDiscovery::from_complete_stack(discovery, &complete_conformance()) + .expect("complete synthetic report enables discovery"); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None) + .with_conformant_federated_identity(ready); + let json = serde_json::to_value(info).expect("serialize conformant discovery"); + + assert_eq!(json["federated_identity"]["delegation"], false); + assert!(json["federated_identity"] + .get("delegated_lease_max_seconds") + .is_none()); + assert_eq!(json["supported_nips"], serde_json::json!(SUPPORTED_NIPS)); + } + #[test] fn configured_pairing_relay_is_advertised_and_unset_value_is_omitted() { let info = RelayInfo::build( diff --git a/crates/buzz-relay/src/push_runtime.rs b/crates/buzz-relay/src/push_runtime.rs index 49845067ea..05835938f9 100644 --- a/crates/buzz-relay/src/push_runtime.rs +++ b/crates/buzz-relay/src/push_runtime.rs @@ -58,8 +58,13 @@ pub async fn run_matcher(state: Arc) { let mut idle_delay = IDLE_POLL_FLOOR; let mut last_reap = tokio::time::Instant::now(); loop { + let excluded = state.enforcing_protected_domain_ids(); if last_reap.elapsed() >= REAP_INTERVAL { - match state.db.reap_exhausted_push_matches().await { + match state + .db + .reap_exhausted_push_matches_excluding(&excluded) + .await + { Ok(reaped) if reaped > 0 => warn!(reaped, "reaped exhausted push match jobs"), Ok(_) => {} Err(e) => error!("push match reap failed: {e}"), @@ -69,7 +74,7 @@ pub async fn run_matcher(state: Arc) { let until = Utc::now() + TimeDelta::seconds(CLAIM_SECS); match state .db - .claim_due_push_match_batch(MATCH_BATCH_LIMIT, until) + .claim_due_push_match_batch_excluding(MATCH_BATCH_LIMIT, until, &excluded) .await { Ok(Some(batch)) => { @@ -321,6 +326,9 @@ pub async fn run_delivery_worker(state: Arc) { Ok(communities) => { for community in communities { let community = buzz_core::CommunityId::from_uuid(community.id); + if state.is_protected_enforcing(community) { + continue; + } let until = Utc::now() + TimeDelta::seconds(CLAIM_SECS); match state.db.claim_due_push_wakes(community, 16, until).await { Ok(wakes) => { @@ -351,6 +359,9 @@ async fn deliver_one( http: &reqwest::Client, claimed: buzz_db::push::ClaimedWake, ) { + if state.is_protected_enforcing(claimed.community) { + return; + } let outcome = match state .db .revalidate_push_wake(claimed.community, claimed.id, claimed.claim_id) diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index c870c8f280..37ae3e41c5 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -89,33 +89,56 @@ fn verified_identities( let parts = tag.as_slice(); (parts.len() == 2).then(|| parts[1].as_str()) }; + let canonical_tag_set = |active: bool| { + let allowed: &[&str] = if active { + &["d", "p", "verified", "active", "expiration", "display_name"] + } else { + &["d", "p", "verified", "active", "expiration"] + }; + event.tags.len() == allowed.len() + && event.tags.iter().all(|tag| { + let parts = tag.as_slice(); + parts.len() == 2 + && allowed.contains(&parts[0].as_str()) + && event + .tags + .iter() + .filter(|candidate| candidate.as_slice().first() == parts.first()) + .count() + == 1 + }) + }; // Select the signed replaceable-event head before validating its // payload. Otherwise a newer malformed assertion could be skipped and // silently resurrect the older active label returned alongside it. - let identity = match (tag_value("d"), tag_value("verified"), tag_value("p")) { - (Some(assertion_d), Some("relay"), Some(asserted_subject)) - if assertion_d == subject && asserted_subject == subject => - { - match tag_value("active") { - Some("false") => None, - Some("true") => match ( - tag_value("expiration") - .and_then(|value| value.parse::().ok()) - .filter(|expiration| *expiration > now), - tag_value("display_name") - .map(str::trim) - .filter(|value| !value.is_empty()), - ) { - (Some(expires_at), Some(display_name)) => Some(VerifiedIdentity { - display_name: display_name.to_string(), - expires_at, - }), + let identity = if event.content.is_empty() { + match (tag_value("d"), tag_value("verified"), tag_value("p")) { + (Some(assertion_d), Some("relay"), Some(asserted_subject)) + if assertion_d == subject && asserted_subject == subject => + { + match tag_value("active") { + Some("false") if canonical_tag_set(false) => None, + Some("true") if canonical_tag_set(true) => match ( + tag_value("expiration") + .and_then(|value| value.parse::().ok()) + .filter(|expiration| *expiration > now), + tag_value("display_name") + .map(str::trim) + .filter(|value| !value.is_empty()), + ) { + (Some(expires_at), Some(display_name)) => Some(VerifiedIdentity { + display_name: display_name.to_string(), + expires_at, + }), + _ => None, + }, _ => None, - }, - _ => None, + } } + _ => None, } - _ => None, + } else { + None }; let created_at = event.created_at.as_secs(); let event_id = event.id.to_hex(); @@ -650,6 +673,85 @@ mod tests { ); } + #[test] + fn newer_nonempty_projection_removes_verified_identity() { + let relay = nostr::Keys::generate(); + let subject = nostr::Keys::generate().public_key().to_hex(); + let created_at = nostr::Timestamp::now().as_secs(); + let expires_at = created_at + 60; + let canonical_tags = || { + [ + nostr::Tag::parse(["d", subject.as_str()]).unwrap(), + nostr::Tag::parse(["p", subject.as_str()]).unwrap(), + nostr::Tag::parse(["verified", "relay"]).unwrap(), + nostr::Tag::parse(["active", "true"]).unwrap(), + nostr::Tag::parse(["expiration", &expires_at.to_string()]).unwrap(), + nostr::Tag::parse(["display_name", "Example User"]).unwrap(), + ] + }; + let active = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") + .tags(canonical_tags()) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(&relay) + .unwrap(); + let nonempty = nostr::EventBuilder::new( + nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), + "private content must never be projected", + ) + .tags(canonical_tags()) + .custom_created_at(nostr::Timestamp::from(created_at + 1)) + .sign_with_keys(&relay) + .unwrap(); + + assert!( + verified_identities(&[active, nonempty], Some(&relay.public_key().to_hex())).is_empty(), + "a malformed newer head must withdraw rather than reveal or resurrect a label" + ); + } + + #[test] + fn newer_projection_with_unknown_or_duplicate_tags_removes_verified_identity() { + let relay = nostr::Keys::generate(); + let subject = nostr::Keys::generate().public_key().to_hex(); + let created_at = nostr::Timestamp::now().as_secs(); + let expires_at = created_at + 60; + let canonical = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") + .tags([ + nostr::Tag::parse(["d", subject.as_str()]).unwrap(), + nostr::Tag::parse(["p", subject.as_str()]).unwrap(), + nostr::Tag::parse(["verified", "relay"]).unwrap(), + nostr::Tag::parse(["active", "true"]).unwrap(), + nostr::Tag::parse(["expiration", &expires_at.to_string()]).unwrap(), + nostr::Tag::parse(["display_name", "Example User"]).unwrap(), + ]) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(&relay) + .unwrap(); + + for extra in [ + nostr::Tag::parse(["issuer", "private.invalid"]).unwrap(), + nostr::Tag::parse(["display_name", "Replacement"]).unwrap(), + ] { + let mut tags = canonical.tags.clone().to_vec(); + tags.push(extra); + let malformed = nostr::EventBuilder::new( + nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), + "", + ) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(created_at + 1)) + .sign_with_keys(&relay) + .unwrap(); + assert!(verified_identities( + &[canonical.clone(), malformed], + Some(&relay.public_key().to_hex()) + ) + .is_empty()); + } + } + #[test] fn newer_malformed_assertion_does_not_resurrect_older_identity() { let relay = nostr::Keys::generate(); diff --git a/migrations/0044_client_status_fanout_withdrawals.sql b/migrations/0044_client_status_fanout_withdrawals.sql new file mode 100644 index 0000000000..eb0371cca7 --- /dev/null +++ b/migrations/0044_client_status_fanout_withdrawals.sql @@ -0,0 +1,21 @@ +-- Retain the exact current revision superseded by a withdrawal so every +-- authenticated connection that displayed that author can receive a +-- strictly newer opaque withdrawal. This remains server-side reconciliation +-- state and is never serialized into the client projection. + +ALTER TABLE client_status_revisions + ADD COLUMN supersedes_revision BIGINT; + +UPDATE client_status_revisions +SET supersedes_revision = revision - 1 +WHERE disposition = 'withdrawn'; + +ALTER TABLE client_status_revisions + ADD CONSTRAINT client_status_revisions_withdrawal CHECK ( + (disposition = 'current' AND supersedes_revision IS NULL) + OR + (disposition = 'withdrawn' + AND supersedes_revision IS NOT NULL + AND supersedes_revision > 0 + AND revision > supersedes_revision) + ); diff --git a/migrations/0045_identity_public_projection_retirement.sql b/migrations/0045_identity_public_projection_retirement.sql new file mode 100644 index 0000000000..e6182bd202 --- /dev/null +++ b/migrations/0045_identity_public_projection_retirement.sql @@ -0,0 +1,80 @@ +-- Durable, provider-neutral reconciliation for the optional public identity +-- projection. O3 lifecycle rows remain the authority; these tables contain +-- only public event coordinates and opaque binding generations. + +CREATE TABLE identity_public_projection_heads ( + community_id UUID NOT NULL REFERENCES communities(id), + relay_pubkey BYTEA NOT NULL, + subject_pubkey BYTEA NOT NULL, + event_id BYTEA NOT NULL, + event_created_at TIMESTAMPTZ NOT NULL, + disposition TEXT NOT NULL, + source_binding_id UUID, + source_binding_version BIGINT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, relay_pubkey, subject_pubkey), + FOREIGN KEY (community_id, source_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + CHECK (length(relay_pubkey) = 32), + CHECK (length(subject_pubkey) = 32), + CHECK (length(event_id) = 32), + CHECK (disposition IN ('active', 'inactive')), + CHECK (source_binding_version IS NULL OR source_binding_version > 0), + CHECK ( + (source_binding_id IS NULL AND source_binding_version IS NULL) + OR + (source_binding_id IS NOT NULL AND source_binding_version IS NOT NULL) + ) +); + +CREATE TABLE identity_public_projection_retirements ( + community_id UUID NOT NULL REFERENCES communities(id), + operation_id UUID NOT NULL, + relay_pubkey BYTEA NOT NULL, + old_pubkey BYTEA NOT NULL, + source_binding_id UUID, + source_binding_version BIGINT, + operation_kind TEXT NOT NULL, + phase TEXT NOT NULL DEFAULT 'projection', + outcome TEXT, + event_id BYTEA, + attempts BIGINT NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + claim_token UUID, + lease_until TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, operation_id, relay_pubkey), + FOREIGN KEY (community_id, operation_id) + REFERENCES identity_lifecycle_operations (community_id, operation_id), + FOREIGN KEY (community_id, source_binding_id) + REFERENCES identity_bindings (community_id, binding_id), + CHECK (length(relay_pubkey) = 32), + CHECK (length(old_pubkey) = 32), + CHECK (source_binding_version IS NULL OR source_binding_version > 0), + CHECK ( + (source_binding_id IS NULL AND source_binding_version IS NULL) + OR + (source_binding_id IS NOT NULL AND source_binding_version IS NOT NULL) + ), + CHECK (operation_kind IN ('revoke_key', 'rotate')), + CHECK (phase IN ('projection', 'delivery', 'completed', 'superseded')), + CHECK (outcome IS NULL OR outcome IN ( + 'no_projection', 'already_inactive', 'replaced_inactive', + 'newer_binding', 'newer_projection' + )), + CHECK (event_id IS NULL OR length(event_id) = 32), + CHECK (attempts >= 0), + CHECK ((claim_token IS NULL) = (lease_until IS NULL)), + CHECK ( + (phase IN ('completed', 'superseded') AND completed_at IS NOT NULL) + OR + (phase IN ('projection', 'delivery') AND completed_at IS NULL) + ) +); + +CREATE INDEX idx_identity_public_projection_retirements_ready + ON identity_public_projection_retirements + (phase, next_attempt_at, community_id, operation_id) + WHERE phase IN ('projection', 'delivery'); From a592ff7ec68c698585277a9fdc381d2ffaf158fc Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:18:06 -0500 Subject: [PATCH 2/2] feat(auth): enforce session runtime conformance Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- .github/workflows/ci.yml | 29 ++ crates/buzz-auth/src/context/evidence.rs | 84 ++++ crates/buzz-auth/src/context/mod.rs | 11 +- crates/buzz-auth/src/context/reason.rs | 10 + crates/buzz-auth/src/context/tests.rs | 17 +- crates/buzz-auth/src/evidence_adapter.rs | 12 + crates/buzz-auth/src/finalization.rs | 91 ++-- crates/buzz-auth/src/lib.rs | 14 +- crates/buzz-auth/src/provider/tests.rs | 18 +- .../buzz-db/src/authorization_invalidation.rs | 125 +++++- crates/buzz-db/src/migration.rs | 8 +- crates/buzz-relay/src/api/bridge.rs | 22 +- crates/buzz-relay/src/api/git/transport.rs | 24 +- crates/buzz-relay/src/api/invites.rs | 50 ++- crates/buzz-relay/src/api/media.rs | 53 ++- crates/buzz-relay/src/audio/handler.rs | 84 ++-- .../src/authorization_runtime/finalization.rs | 7 + .../src/authorization_runtime/invalidation.rs | 144 +++--- .../src/authorization_runtime/production.rs | 160 +++++-- .../src/authorization_runtime/status.rs | 6 +- .../src/authorization_runtime/transport.rs | 219 ++++++++-- crates/buzz-relay/src/corporate_identity.rs | 158 ++++++- crates/buzz-relay/src/handlers/auth.rs | 48 +- crates/buzz-relay/src/state.rs | 21 +- .../tests/fixtures/nip_fi_trusted_proxy.json | 90 ++++ .../tests/nip_fi_runtime_conformance.rs | 409 ++++++++++++++++++ desktop/src-tauri/Cargo.lock | 19 + docs/NIP_FI_RUNTIME_OPERATIONS.md | 152 +++++++ docs/nips/NIP-FI-RUNTIME-CONFORMANCE.md | 166 +++++++ ...zation_delegated_relationship_selector.sql | 19 + schema/schema.sql | 3 +- 31 files changed, 1976 insertions(+), 297 deletions(-) create mode 100644 crates/buzz-relay/tests/fixtures/nip_fi_trusted_proxy.json create mode 100644 crates/buzz-relay/tests/nip_fi_runtime_conformance.rs create mode 100644 docs/NIP_FI_RUNTIME_OPERATIONS.md create mode 100644 docs/nips/NIP-FI-RUNTIME-CONFORMANCE.md create mode 100644 migrations/0046_authorization_delegated_relationship_selector.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd8734fc27..4e71ee37ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -356,6 +356,7 @@ jobs: -p buzz-relay \ -p buzz-test-client \ --lib \ + --test nip_fi_runtime_conformance \ --test e2e_event_reminder \ --archive-file target/ci/backend-integration-tests.tar.zst - name: Save relay artifacts cache @@ -713,6 +714,20 @@ jobs: --run-ignored all env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz_identity_tests + - name: NIP-FI runtime and protected transport conformance + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'binary(nip_fi_runtime_conformance)' + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and (test(protected_media_reads_require_corporate_identity_for_get_and_head) or test(moderation_reads_require_corporate_identity_after_nip98_proof))' \ + --test-threads 1 \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + BUZZ_TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + REDIS_URL: redis://localhost:6379 - name: Workspace profile (kind:9033) gate tests # Call-site integration for the 9033 authorization gate: open relay # rosterless/steward transitions and the closed-relay admin/owner rule, @@ -725,6 +740,20 @@ jobs: --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Protected Git and media authority migration tests + run: | + docker exec -e PGPASSWORD="${BUZZ_TEST_POSTGRES_PASSWORD}" buzz-postgres \ + psql -U buzz -d postgres -v ON_ERROR_STOP=1 \ + -c "CREATE DATABASE buzz_visibility_tests" + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E '(package(buzz-db) and test(/protected_visibility::tests::cutover_waits/)) or (package(buzz-relay) and test(/api::media_migration::tests::populated_git_and_media_cutover/))' \ + --test-threads 1 \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz_visibility_tests + BUZZ_TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz_visibility_tests + REDIS_URL: redis://localhost:6379 - name: NIP-ER reminder e2e # Feature e2e for NIP-ER (Event Reminders, kind:30300): write-path # validation, author-only read filtering, and scheduler delivery against diff --git a/crates/buzz-auth/src/context/evidence.rs b/crates/buzz-auth/src/context/evidence.rs index a7b3580de9..ac0d8964e3 100644 --- a/crates/buzz-auth/src/context/evidence.rs +++ b/crates/buzz-auth/src/context/evidence.rs @@ -505,6 +505,11 @@ impl VerifiedProviderEvidence { self.fresh_until } + /// Sealed normalized assertion carried by this verified evidence object. + pub const fn verified_assertion(&self) -> &VerifiedFederatedAssertion { + &self.assertion + } + /// Revalidate this evidence for one exact protected request. pub fn validate_for( &self, @@ -659,6 +664,67 @@ impl DelegationCapability { } } +/// Exact authority-defined identity of one verified delegated relationship. +/// +/// This identifier must come from the successful delegation verifier. It is +/// deliberately distinct from owner, delegate, and identity-binding IDs so a +/// broad principal selector cannot stand in for one signed relationship. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct DelegatedRelationshipId(Uuid); + +impl DelegatedRelationshipId { + pub(crate) fn new(value: Uuid) -> Result { + if value.is_nil() { + return Err(AuthContextError::InvalidDelegatedRelationshipId); + } + Ok(Self(value)) + } + + /// Opaque stable relationship identifier. + pub const fn as_uuid(self) -> Uuid { + self.0 + } +} + +impl fmt::Debug for DelegatedRelationshipId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("DelegatedRelationshipId") + .field(&"[redacted]") + .finish() + } +} + +/// Monotonic authority revision for one delegated relationship. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DelegatedRelationshipRevision(u64); + +impl DelegatedRelationshipRevision { + /// Initial revision of an immutable signed relationship issuance. + pub const INITIAL: Self = Self(1); + + pub(crate) fn new(value: u64) -> Result { + if value == 0 { + return Err(AuthContextError::InvalidDelegatedRelationshipRevision); + } + Ok(Self(value)) + } + + /// Positive monotonic relationship revision. + pub const fn get(self) -> u64 { + self.0 + } +} + +impl fmt::Debug for DelegatedRelationshipRevision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("DelegatedRelationshipRevision") + .field(&"[redacted]") + .finish() + } +} + /// Transport-wide delegation from a bound owner to the authenticated key. /// /// A verifier may construct this only after proving the capability authorizes @@ -671,6 +737,8 @@ impl DelegationCapability { pub struct VerifiedTransportDelegation { owner_pubkey: PublicKey, delegate_pubkey: PublicKey, + relationship_id: DelegatedRelationshipId, + relationship_revision: DelegatedRelationshipRevision, capability: DelegationCapability, expires_at: Option, } @@ -681,6 +749,8 @@ impl fmt::Debug for VerifiedTransportDelegation { .debug_struct("VerifiedTransportDelegation") .field("owner_pubkey", &"[redacted]") .field("delegate_pubkey", &"[redacted]") + .field("relationship_id", &"[redacted]") + .field("relationship_revision", &"[redacted]") .field("capability", &"[redacted]") .field("expires_at", &"[redacted]") .finish() @@ -693,6 +763,8 @@ impl VerifiedTransportDelegation { pub(crate) fn new_unrestricted( owner_pubkey: PublicKey, delegate_pubkey: PublicKey, + relationship_id: Uuid, + relationship_revision: u64, expires_at: Option, ) -> Result { if owner_pubkey == delegate_pubkey { @@ -701,6 +773,8 @@ impl VerifiedTransportDelegation { Ok(Self { owner_pubkey, delegate_pubkey, + relationship_id: DelegatedRelationshipId::new(relationship_id)?, + relationship_revision: DelegatedRelationshipRevision::new(relationship_revision)?, capability: DelegationCapability::TransportWide, expires_at, }) @@ -716,6 +790,16 @@ impl VerifiedTransportDelegation { self.delegate_pubkey } + /// Exact verifier-defined delegated-relationship identity. + pub const fn relationship_id(&self) -> DelegatedRelationshipId { + self.relationship_id + } + + /// Exact monotonic revision of the delegated relationship. + pub const fn relationship_revision(&self) -> DelegatedRelationshipRevision { + self.relationship_revision + } + /// Verified capability scope. pub const fn capability(&self) -> DelegationCapability { self.capability diff --git a/crates/buzz-auth/src/context/mod.rs b/crates/buzz-auth/src/context/mod.rs index ce24e54390..a04d52eddf 100644 --- a/crates/buzz-auth/src/context/mod.rs +++ b/crates/buzz-auth/src/context/mod.rs @@ -37,11 +37,12 @@ pub use binding::{ }; pub use evidence::{ AdmissionExpiry, AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthMethod, - AuthTransport, AuthorizedCommunityAccess, DelegationCapability, DelegationExpiry, - FederatedPrincipal, NostrAuthority, ProviderEvidenceValidationError, - VerifiedFederatedAssertion, VerifiedKeyAttestation, VerifiedNostrProof, - VerifiedOperationBinding, VerifiedOperationBindingKind, VerifiedOwnerAdmission, - VerifiedProviderEvidence, VerifiedTransportDelegation, + AuthTransport, AuthorizedCommunityAccess, DelegatedRelationshipId, + DelegatedRelationshipRevision, DelegationCapability, DelegationExpiry, FederatedPrincipal, + NostrAuthority, ProviderEvidenceValidationError, VerifiedFederatedAssertion, + VerifiedKeyAttestation, VerifiedNostrProof, VerifiedOperationBinding, + VerifiedOperationBindingKind, VerifiedOwnerAdmission, VerifiedProviderEvidence, + VerifiedTransportDelegation, }; pub use reason::{AuthContextError, AuthorizationReason}; diff --git a/crates/buzz-auth/src/context/reason.rs b/crates/buzz-auth/src/context/reason.rs index eba2f4356c..973b490e31 100644 --- a/crates/buzz-auth/src/context/reason.rs +++ b/crates/buzz-auth/src/context/reason.rs @@ -79,6 +79,12 @@ pub enum AuthContextError { /// Delegation expiry was not a valid Unix timestamp. #[error("delegation expiry must be greater than zero")] InvalidDelegationExpiry, + /// Delegated-relationship identity was the nil UUID. + #[error("delegated relationship identifier must not be nil")] + InvalidDelegatedRelationshipId, + /// Delegated-relationship revision was zero. + #[error("delegated relationship revision must be greater than zero")] + InvalidDelegatedRelationshipRevision, /// Admission expiry was not a valid Unix timestamp. #[error("admission expiry must be greater than zero")] InvalidAdmissionExpiry, @@ -198,6 +204,10 @@ impl AuthContextError { Self::InvalidFederatedPolicyInterval => "federated_policy_invalid_interval", Self::InvalidAssertionExpiry => "federated_assertion_invalid_expiry", Self::InvalidDelegationExpiry => "delegation_invalid_expiry", + Self::InvalidDelegatedRelationshipId => "delegation_invalid_relationship_id", + Self::InvalidDelegatedRelationshipRevision => { + "delegation_invalid_relationship_revision" + } Self::InvalidAdmissionExpiry => "owner_admission_invalid_expiry", Self::AssertionExpired => "federated_assertion_expired", Self::BindingExpired => "federated_binding_expired", diff --git a/crates/buzz-auth/src/context/tests.rs b/crates/buzz-auth/src/context/tests.rs index d444239274..b052ab6d1f 100644 --- a/crates/buzz-auth/src/context/tests.rs +++ b/crates/buzz-auth/src/context/tests.rs @@ -288,6 +288,8 @@ fn input_with_delegation_expiry( VerifiedTransportDelegation::new_unrestricted( owner_pubkey, actor_pubkey, + Uuid::from_u128(0x601), + 1, Some( DelegationExpiry::new(delegation_expiry) .expect("synthetic delegation expiry is valid"), @@ -644,6 +646,8 @@ fn verified_nostr_proof_requires_the_authenticated_delegate() { let delegation = VerifiedTransportDelegation::new_unrestricted( owner.public_key(), other_delegate.public_key(), + Uuid::from_u128(0x602), + 1, None, ) .expect("synthetic owner and delegate are distinct"); @@ -1856,9 +1860,14 @@ fn nostr_only_authorization_may_preserve_a_verified_owner() { #[test] fn transport_delegation_rejects_self_reference() { let actor = Keys::generate(); - let error = - VerifiedTransportDelegation::new_unrestricted(actor.public_key(), actor.public_key(), None) - .expect_err("an actor cannot be its own verified owner"); + let error = VerifiedTransportDelegation::new_unrestricted( + actor.public_key(), + actor.public_key(), + Uuid::from_u128(0x603), + 1, + None, + ) + .expect_err("an actor cannot be its own verified owner"); assert_eq!(error, AuthContextError::SelfDelegation); } @@ -1870,6 +1879,8 @@ fn transport_delegation_is_explicitly_transport_wide() { let delegation = VerifiedTransportDelegation::new_unrestricted( owner.public_key(), delegate.public_key(), + Uuid::from_u128(0x604), + 1, None, ) .expect("synthetic owner and delegate are distinct"); diff --git a/crates/buzz-auth/src/evidence_adapter.rs b/crates/buzz-auth/src/evidence_adapter.rs index 4f39063379..8b215a4f11 100644 --- a/crates/buzz-auth/src/evidence_adapter.rs +++ b/crates/buzz-auth/src/evidence_adapter.rs @@ -43,6 +43,8 @@ pub enum ActiveBindingResolution { pub struct VerifiedDelegationOutput { owner_pubkey: PublicKey, delegate_pubkey: PublicKey, + relationship_id: Uuid, + relationship_revision: u64, expires_at: Option, transport_wide: bool, } @@ -52,12 +54,16 @@ impl VerifiedDelegationOutput { pub const fn from_workspace_verifier( owner_pubkey: PublicKey, delegate_pubkey: PublicKey, + relationship_id: Uuid, + relationship_revision: u64, expires_at: Option, transport_wide: bool, ) -> Self { Self { owner_pubkey, delegate_pubkey, + relationship_id, + relationship_revision, expires_at, transport_wide, } @@ -288,6 +294,8 @@ impl VerifiedEvidenceAdapter { VerifiedTransportDelegation::new_unrestricted( output.owner_pubkey, output.delegate_pubkey, + output.relationship_id, + output.relationship_revision, expires_at, ) .map_err(Into::into) @@ -650,6 +658,8 @@ mod tests { Some(VerifiedDelegationOutput::from_workspace_verifier( owner.public_key(), actor.public_key(), + Uuid::from_u128(0x701), + 1, None, true, )), @@ -694,6 +704,8 @@ mod tests { Some(VerifiedDelegationOutput::from_workspace_verifier( owner.public_key(), Keys::generate().public_key(), + Uuid::from_u128(0x702), + 1, None, true, )), diff --git a/crates/buzz-auth/src/finalization.rs b/crates/buzz-auth/src/finalization.rs index 0e7ef25598..d3842894ec 100644 --- a/crates/buzz-auth/src/finalization.rs +++ b/crates/buzz-auth/src/finalization.rs @@ -417,9 +417,10 @@ mod tests { use crate::{ context::{ AssertionExpiry, AssertionTransport, AuthContextVersion, AuthMethod, AuthTransport, + AuthoritativeBindingEvidence, AuthoritativeBindingResolution, AuthorizedCommunityAccess, BindingSource, DelegationExpiry, EnrollmentMode, - VerifiedFederatedAssertion, VerifiedKeyAttestation, VerifiedNostrProof, - VerifiedTransportDelegation, + FederatedPolicyStamp, VerifiedFederatedAssertion, VerifiedKeyAttestation, + VerifiedNostrProof, VerifiedTransportDelegation, }, lease::{ ApplicationLeaseLimit, AuthorizationClock, AuthorizationClockSkew, @@ -454,12 +455,22 @@ mod tests { } } + impl crate::provider::AuthorizationClock for FixedClock { + fn now_unix_seconds(&self) -> Option { + Some(self.0.load(Ordering::SeqCst)) + } + } + struct AllowProvider { issued_at: u64, fresh_until: u64, } impl AuthorizationProvider for AllowProvider { + fn profile_id(&self) -> AuthorizationProfileId { + profile() + } + fn authorize<'a>( &'a self, request: &'a AuthorizationRequest, @@ -467,7 +478,7 @@ mod tests { let allow = ProviderAllow::new( request.authorization_domain(), request.principal().clone(), - request.profile_id().clone(), + self.profile_id(), request.requested_capabilities().clone(), PolicyVersion::new("policy.synthetic.example") .expect("synthetic policy version is valid"), @@ -488,6 +499,24 @@ mod tests { .expect("synthetic profile is valid") } + fn required_policy( + correlation_id: Uuid, + enrollment_mode: EnrollmentMode, + ) -> ResolvedFederatedPolicy { + ResolvedFederatedPolicy::from_authoritative_resolution( + FederatedPolicyStamp::from_authoritative_state( + domain(), + Uuid::from_u128(0x40), + 1, + correlation_id, + FederatedIdentityRequirement::Required(enrollment_mode), + 1, + u64::MAX, + ) + .expect("synthetic federated policy lineage is valid"), + ) + } + struct DirectFixture { input: AuthContextInput, policy: ResolvedFederatedPolicy, @@ -528,6 +557,7 @@ mod tests { principal, actor, binding_version, + None, BindingSource::AttestedKey, ) .expect("synthetic binding is valid"); @@ -542,10 +572,7 @@ mod tests { proof, AuthorizedCommunityAccess::new(domain(), vec![Scope::MessagesRead], None), ), - policy: ResolvedFederatedPolicy::server_resolved_required( - domain(), - EnrollmentMode::AttestedKey, - ), + policy: required_policy(Uuid::from_u128(0x300), EnrollmentMode::AttestedKey), authorization: FederatedAuthorization::Direct { binding, assertion }, binding_bound, binding_id, @@ -556,7 +583,7 @@ mod tests { async fn direct_snapshot( fixture: &DirectFixture, - profile: AuthorizationProfileId, + clock: &FixedClock, provider_fresh_until: u64, ) -> Box { let FederatedAuthorization::Direct { assertion, .. } = &fixture.authorization else { @@ -565,7 +592,7 @@ mod tests { let request = AuthorizationRequest::direct( fixture.input.nostr_proof(), assertion, - profile, + required_policy(fixture.input.correlation_id(), EnrollmentMode::AttestedKey), CapabilitySet::single(AuthorizationCapability::CommunityRead), fixture.input.correlation_id(), 1_000, @@ -577,8 +604,9 @@ mod tests { fresh_until: provider_fresh_until, }, &request, - 1_000, + clock, ProviderTimeout::new(Duration::from_secs(1)).expect("synthetic timeout is valid"), + Uuid::from_u128(0x600), ) .await; match outcome { @@ -601,7 +629,7 @@ mod tests { let finalizer = AuthorizationFinalizer::new(clock.clone()); let expected_profile = profile(); let fixture = direct_fixture(1_500, 1_400); - let snapshot = direct_snapshot(&fixture, expected_profile.clone(), 1_300).await; + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; let context = finalizer .finalize_access( fixture.input, @@ -656,7 +684,7 @@ mod tests { let actor = fixture.actor_pubkey; let binding_id = fixture.binding_id; let binding_version = fixture.binding_version; - let snapshot = direct_snapshot(&fixture, expected_profile.clone(), 1_300).await; + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; let context = finalizer .finalize_access( fixture.input, @@ -704,7 +732,7 @@ mod tests { let fixture = direct_fixture(1_500, 1_400); let actor = fixture.actor_pubkey; let binding_version = fixture.binding_version; - let snapshot = direct_snapshot(&fixture, expected_profile.clone(), 1_300).await; + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; let context = finalizer .finalize_access( fixture.input, @@ -746,7 +774,7 @@ mod tests { let actor = fixture.actor_pubkey; let binding_id = fixture.binding_id; let binding_version = fixture.binding_version; - let snapshot = direct_snapshot(&fixture, expected_profile.clone(), 1_300).await; + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; let context = finalizer .finalize_access( fixture.input, @@ -791,7 +819,7 @@ mod tests { let actor = fixture.actor_pubkey; let binding_id = fixture.binding_id; let binding_version = fixture.binding_version; - let snapshot = direct_snapshot(&fixture, expected_profile.clone(), 1_300).await; + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; let context = finalizer .finalize_access( fixture.input, @@ -843,7 +871,7 @@ mod tests { let finalizer = AuthorizationFinalizer::new(clock.clone()); let expected_profile = profile(); let fixture = direct_fixture(1_500, 1_400); - let snapshot = direct_snapshot(&fixture, expected_profile.clone(), 1_300).await; + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; let status = finalizer .finalize_verification_only( fixture.input, @@ -871,7 +899,7 @@ mod tests { let finalizer = AuthorizationFinalizer::new(clock.clone()); let expected_profile = profile(); let fixture = direct_fixture(1_500, 1_400); - let snapshot = direct_snapshot(&fixture, expected_profile.clone(), 1_300).await; + let snapshot = direct_snapshot(&fixture, clock.as_ref(), 1_300).await; let context = finalizer .finalize_access( fixture.input, @@ -916,7 +944,7 @@ mod tests { #[tokio::test] async fn delegated_lease_is_bounded_by_delegation_and_owner_binding() { let clock = Arc::new(FixedClock::new(1_000)); - let finalizer = AuthorizationFinalizer::new(clock); + let finalizer = AuthorizationFinalizer::new(clock.clone()); let expected_profile = profile(); let owner = Keys::generate().public_key(); let delegate = Keys::generate().public_key(); @@ -934,6 +962,8 @@ mod tests { VerifiedTransportDelegation::new_unrestricted( owner, delegate, + Uuid::from_u128(0x401), + 1, Some( DelegationExpiry::new(1_050).expect("synthetic delegation expiry is valid"), ), @@ -950,13 +980,26 @@ mod tests { principal.clone(), owner, binding_version, + None, BindingSource::Provisioned, ) .expect("synthetic owner binding is valid"); + let owner_resolution = AuthoritativeBindingResolution::existing_active( + AuthoritativeBindingEvidence::new( + domain(), + binding_id, + principal, + owner, + binding_version, + None, + BindingSource::Provisioned, + ) + .expect("synthetic owner binding resolution is valid"), + ); let request = AuthorizationRequest::delegated( &proof, - &owner_binding, - expected_profile.clone(), + &owner_resolution, + required_policy(Uuid::from_u128(0x500), EnrollmentMode::Provisioned), CapabilitySet::single(AuthorizationCapability::CommunityWrite), Uuid::from_u128(0x500), 1_000, @@ -968,8 +1011,9 @@ mod tests { fresh_until: 1_300, }, &request, - 1_000, + clock.as_ref(), ProviderTimeout::new(Duration::from_secs(1)).expect("synthetic timeout is valid"), + Uuid::from_u128(0x600), ) .await { @@ -995,10 +1039,7 @@ mod tests { let context = finalizer .finalize_access( input, - ResolvedFederatedPolicy::server_resolved_required( - domain(), - EnrollmentMode::Provisioned, - ), + required_policy(Uuid::from_u128(0x500), EnrollmentMode::Provisioned), authorization, snapshot, &expected_profile, diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index 06b7c4005d..d7e9682c61 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -49,13 +49,13 @@ pub use context::{ AuthContextVersion, AuthMethod, AuthTransport, AuthorityAdapterError, AuthorityAdapterFuture, AuthorizationReason, AuthorizedCommunityAccess, BindingResolutionRequest, BindingSource, BindingVersion, CapabilityFinalizationSeal, CurrentPolicyRequest, CurrentPolicyResolutionSink, - DelegationCapability, DelegationExpiry, DirectBindingResolutionSink, EnrollmentMode, - ExistingBindingResolutionSink, FederatedAuthorityAdapter, FederatedAuthorization, - FederatedIdentityRequirement, FederatedPrincipal, NostrAuthority, - ProviderEvidenceValidationError, ResolvedFederatedPolicy, VerifiedFederatedAssertion, - VerifiedKeyAttestation, VerifiedNostrProof, VerifiedOperationBinding, - VerifiedOperationBindingKind, VerifiedOwnerAdmission, VerifiedProviderEvidence, - VerifiedTransportDelegation, VersionedBindingRef, + DelegatedRelationshipId, DelegatedRelationshipRevision, DelegationCapability, DelegationExpiry, + DirectBindingResolutionSink, EnrollmentMode, ExistingBindingResolutionSink, + FederatedAuthorityAdapter, FederatedAuthorization, FederatedIdentityRequirement, + FederatedPrincipal, NostrAuthority, ProviderEvidenceValidationError, ResolvedFederatedPolicy, + VerifiedFederatedAssertion, VerifiedKeyAttestation, VerifiedNostrProof, + VerifiedOperationBinding, VerifiedOperationBindingKind, VerifiedOwnerAdmission, + VerifiedProviderEvidence, VerifiedTransportDelegation, VersionedBindingRef, }; pub use error::AuthError; pub use evidence_adapter::{ diff --git a/crates/buzz-auth/src/provider/tests.rs b/crates/buzz-auth/src/provider/tests.rs index f346f31741..f64273d52b 100644 --- a/crates/buzz-auth/src/provider/tests.rs +++ b/crates/buzz-auth/src/provider/tests.rs @@ -467,6 +467,8 @@ fn delegated_proof(actor: &Keys, owner: &Keys, expiry: u64) -> VerifiedNostrProo let delegation = VerifiedTransportDelegation::new_unrestricted( owner.public_key(), actor.public_key(), + Uuid::from_u128(0x501), + 1, Some(DelegationExpiry::new(expiry).expect("synthetic delegation expiry is valid")), ) .expect("synthetic delegation is valid"); @@ -1836,8 +1838,18 @@ async fn delegated_owner_admission_does_not_require_owner_assertion() { assert_eq!(snapshot.binding_id(), Some(Uuid::from_u128(10))); assert_eq!(snapshot.binding_version(), Some(BindingVersion::INITIAL)); assert_eq!(snapshot.transport(), AuthTransport::RelayWebSocket); + let owner_binding = VersionedBindingRef::new_existing_active_for_test( + domain(1), + Uuid::from_u128(10), + principal(), + owner.public_key(), + BindingVersion::INITIAL, + None, + BindingSource::Provisioned, + ) + .expect("synthetic owner binding is valid"); let admission = snapshot - .verified_owner_admission(&existing_binding(&owner)) + .verified_owner_admission(&owner_binding) .expect("delegated snapshot matches the exact owner binding"); assert_eq!(admission.authorization_domain(), domain(1)); assert_eq!(admission.principal(), request.principal()); @@ -2206,6 +2218,8 @@ fn request_construction_rejects_mismatched_verified_evidence() { let delegation = VerifiedTransportDelegation::new_unrestricted( owner.public_key(), actor.public_key(), + Uuid::from_u128(0x502), + 1, Some(DelegationExpiry::new(NOW + 20).expect("synthetic expiry is valid")), ) .expect("synthetic delegation is valid"); @@ -2263,6 +2277,8 @@ fn request_construction_rejects_mismatched_verified_evidence() { let expired_delegation = VerifiedTransportDelegation::new_unrestricted( owner.public_key(), actor.public_key(), + Uuid::from_u128(0x503), + 1, Some(DelegationExpiry::new(NOW).expect("synthetic expiry is valid")), ) .expect("synthetic delegation is valid"); diff --git a/crates/buzz-db/src/authorization_invalidation.rs b/crates/buzz-db/src/authorization_invalidation.rs index 7e71e3716e..c22766a448 100644 --- a/crates/buzz-db/src/authorization_invalidation.rs +++ b/crates/buzz-db/src/authorization_invalidation.rs @@ -34,6 +34,8 @@ pub enum AuthorizationSelectorKind { PolicyVersion, /// Exact delegated owner Nostr key. DelegatedOwner, + /// Exact verified delegated relationship and monotonic revision. + DelegatedRelationship, } impl AuthorizationSelectorKind { @@ -47,6 +49,7 @@ impl AuthorizationSelectorKind { Self::Domain => "domain", Self::PolicyVersion => "policy_version", Self::DelegatedOwner => "delegated_owner", + Self::DelegatedRelationship => "delegated_relationship", } } @@ -59,6 +62,7 @@ impl AuthorizationSelectorKind { "domain" => Ok(Self::Domain), "policy_version" => Ok(Self::PolicyVersion), "delegated_owner" => Ok(Self::DelegatedOwner), + "delegated_relationship" => Ok(Self::DelegatedRelationship), _ => Err(DbError::InvalidData( "authorization invalidation selector kind is invalid".into(), )), @@ -66,6 +70,52 @@ impl AuthorizationSelectorKind { } } +/// Exact server-issued runtime session target. +/// +/// A connection UUID is insufficient because UUID reuse would silently target +/// another issuance. The independent non-reuse fence is generated once when +/// the server registers the session and retained for its complete lifetime. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct AuthorizationSessionTarget { + session_id: Uuid, + issuance_fence: Uuid, +} + +impl AuthorizationSessionTarget { + /// Construct one exact session issuance from server-owned identifiers. + pub fn new(session_id: Uuid, issuance_fence: Uuid) -> Result { + if session_id.is_nil() || issuance_fence.is_nil() { + return Err(DbError::InvalidData( + "authorization session target requires non-nil session and issuance IDs".into(), + )); + } + Ok(Self { + session_id, + issuance_fence, + }) + } + + /// Runtime connection identifier. + pub const fn session_id(self) -> Uuid { + self.session_id + } + + /// Server-issued fence that prevents session identity reuse. + pub const fn issuance_fence(self) -> Uuid { + self.issuance_fence + } +} + +impl fmt::Debug for AuthorizationSessionTarget { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationSessionTarget") + .field("session_id", &"[redacted]") + .field("issuance_fence", &"[redacted]") + .finish() + } +} + /// A typed selector for one authorization dependency. #[derive(Clone, PartialEq, Eq)] pub enum AuthorizationSelector { @@ -80,14 +130,21 @@ pub enum AuthorizationSelector { /// All binding versions through this value are invalid. invalid_through: u64, }, - /// Exact runtime session. - Session(Uuid), + /// Exact runtime session issuance. + Session(AuthorizationSessionTarget), /// Entire authorization domain. Domain, /// Exact opaque provider policy version. PolicyVersion(String), /// Exact delegated owner key. DelegatedOwner([u8; 32]), + /// Exact delegated relationship identity and invalid-through revision. + DelegatedRelationship { + /// Verifier-defined relationship identifier. + relationship_id: Uuid, + /// All relationship revisions through this value are invalid. + relationship_revision: u64, + }, } impl fmt::Debug for AuthorizationSelector { @@ -137,14 +194,9 @@ impl AuthorizationSelector { }) } - /// Select an exact non-nil runtime session. - pub fn session(session_id: Uuid) -> Result { - if session_id.is_nil() { - return Err(DbError::InvalidData( - "authorization session selector must not be nil".into(), - )); - } - Ok(Self::Session(session_id)) + /// Select one exact server-issued runtime session. + pub const fn session(target: AuthorizationSessionTarget) -> Self { + Self::Session(target) } /// Select the entire authorization domain. @@ -168,6 +220,23 @@ impl AuthorizationSelector { Self::DelegatedOwner(key) } + /// Select one exact verified delegated relationship revision. + pub fn delegated_relationship( + relationship_id: Uuid, + relationship_revision: u64, + ) -> Result { + if relationship_id.is_nil() || relationship_revision == 0 { + return Err(DbError::InvalidData( + "delegated relationship selector requires a non-nil ID and positive revision" + .into(), + )); + } + Ok(Self::DelegatedRelationship { + relationship_id, + relationship_revision, + }) + } + /// Selector class. pub const fn kind(&self) -> AuthorizationSelectorKind { match self { @@ -178,6 +247,7 @@ impl AuthorizationSelector { Self::Domain => AuthorizationSelectorKind::Domain, Self::PolicyVersion(_) => AuthorizationSelectorKind::PolicyVersion, Self::DelegatedOwner(_) => AuthorizationSelectorKind::DelegatedOwner, + Self::DelegatedRelationship { .. } => AuthorizationSelectorKind::DelegatedRelationship, } } @@ -189,12 +259,28 @@ impl AuthorizationSelector { Self::Binding { binding_id, .. } => { tagged_fingerprint(b"binding", &[binding_id.as_bytes()]) } - Self::Session(session_id) => tagged_fingerprint(b"session", &[session_id.as_bytes()]), + Self::Session(target) => tagged_fingerprint( + b"session-issuance-v2", + &[ + target.session_id().as_bytes(), + target.issuance_fence().as_bytes(), + ], + ), Self::Domain => tagged_fingerprint(b"domain", &[]), Self::PolicyVersion(version) => { tagged_fingerprint(b"policy-version", &[version.as_bytes()]) } Self::DelegatedOwner(key) => tagged_fingerprint(b"delegated-owner", &[key]), + Self::DelegatedRelationship { + relationship_id, + relationship_revision, + } => tagged_fingerprint( + b"delegated-relationship-v1", + &[ + relationship_id.as_bytes(), + &relationship_revision.to_be_bytes(), + ], + ), } } @@ -258,7 +344,7 @@ impl AuthorizationInvalidationEntry { } /// Fence authority captured before reversible admission loss for one exact - /// principal, Nostr key, or delegated owner. + /// principal, Nostr key, delegated owner, or exact delegated relationship. /// /// This is intentionally unavailable for bindings, sessions, domains, and /// policy versions. Binding invalidation uses a monotonic version floor, @@ -269,9 +355,11 @@ impl AuthorizationInvalidationEntry { AuthorizationSelectorKind::PrincipalFingerprint | AuthorizationSelectorKind::NostrKey | AuthorizationSelectorKind::DelegatedOwner + | AuthorizationSelectorKind::DelegatedRelationship ) { return Err(DbError::InvalidData( - "authorization admission loss requires a principal, key, or delegated owner".into(), + "authorization admission loss requires a principal, key, owner, or relationship" + .into(), )); } Ok(Self { @@ -337,6 +425,7 @@ impl AuthorizationInvalidationRequest { AuthorizationSelectorKind::PrincipalFingerprint | AuthorizationSelectorKind::NostrKey | AuthorizationSelectorKind::DelegatedOwner + | AuthorizationSelectorKind::DelegatedRelationship | AuthorizationSelectorKind::Domain, AuthorizationInvalidationEffect::Fence, ) @@ -849,7 +938,10 @@ mod tests { for selector in [ AuthorizationSelector::binding(Uuid::new_v4(), 1).expect("valid binding"), - AuthorizationSelector::session(Uuid::new_v4()).expect("valid session"), + AuthorizationSelector::session( + AuthorizationSessionTarget::new(Uuid::new_v4(), Uuid::new_v4()) + .expect("valid session"), + ), AuthorizationSelector::domain(), AuthorizationSelector::policy_version("policy").expect("valid policy"), ] { @@ -948,7 +1040,10 @@ mod tests { let second = request( Uuid::new_v4(), - AuthorizationSelector::session(Uuid::new_v4()).expect("valid session"), + AuthorizationSelector::session( + AuthorizationSessionTarget::new(Uuid::new_v4(), Uuid::new_v4()) + .expect("valid session"), + ), ); let third = request( Uuid::new_v4(), diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 303d98c3b4..3563ce7b4e 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -566,7 +566,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 45); + assert_eq!(migrations.len(), 46); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1060,6 +1060,12 @@ mod tests { assert!(!projection_retirement.contains("issuer")); assert!(!projection_retirement.contains("subject TEXT")); assert!(!projection_retirement.contains("display_name")); + + assert_eq!(migrations[45].version, 46); + let delegated_relationship = migrations[45].sql.as_str(); + assert!(delegated_relationship.contains("delegated_relationship")); + assert!(delegated_relationship + .contains("authorization_invalidation_floors_selector_kind_check")); } fn additive_identity_executable_sql(sql: &str) -> String { diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 15ad91119d..43b45e9d80 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -201,13 +201,19 @@ pub(crate) fn retain_bridge_proof( return Ok(None); }; let actor = proof.actor_pubkey(); - let proof = match crate::corporate_identity::verify_unconditional_nip_oa_owner(actor, auth_tag) - { - Some(owner) => buzz_auth::VerifiedEvidenceAdapter::new() + let proof = match crate::corporate_identity::verify_unconditional_nip_oa_relationship( + actor, auth_tag, + ) { + Some(relationship) => buzz_auth::VerifiedEvidenceAdapter::new() .attach_transport_delegation( proof, buzz_auth::VerifiedDelegationOutput::from_workspace_verifier( - owner, actor, None, true, + relationship.owner_pubkey(), + actor, + relationship.relationship_id(), + relationship.relationship_revision(), + None, + true, ), ) .map_err(|_| { @@ -289,6 +295,14 @@ async fn verify_bridge_corporate_identity( headers, ) .map_err(crate::corporate_identity::CorporateIdentityError::into_api_error)?; + if identity_assertion.is_none() + && crate::authorization_runtime::transport::provider_evidence_resolver_is_installed( + state, + tenant.community(), + ) + { + return Ok(None); + } match crate::corporate_identity::verify_corporate_identity( state, tenant.community(), diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 4303bbea87..687197cc76 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -240,7 +240,15 @@ impl axum::extract::FromRequestParts> for GitAuth { &parts.headers, ) .map_err(|error| (error.status_code(), error.public_message()).into_response())?; - let identity_proof = match crate::corporate_identity::verify_corporate_identity( + let neutral_evidence = identity_assertion.is_none() + && crate::authorization_runtime::transport::provider_evidence_resolver_is_installed( + state, + tenant.community(), + ); + let identity_proof = if neutral_evidence { + None + } else { + match crate::corporate_identity::verify_corporate_identity( state, tenant.community(), pubkey, @@ -264,6 +272,7 @@ impl axum::extract::FromRequestParts> for GitAuth { warn!(error = ?e, "git: corporate identity denied"); return Err((e.status_code(), e.public_message()).into_response()); } + } }; if crate::api::relay_members::enforce_relay_membership( state, @@ -278,12 +287,19 @@ impl axum::extract::FromRequestParts> for GitAuth { return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response()); } let verified_proof = - match crate::corporate_identity::verify_unconditional_nip_oa_owner(pubkey, auth_tag) { - Some(owner) => buzz_auth::VerifiedEvidenceAdapter::new() + match crate::corporate_identity::verify_unconditional_nip_oa_relationship( + pubkey, auth_tag, + ) { + Some(relationship) => buzz_auth::VerifiedEvidenceAdapter::new() .attach_transport_delegation( verified_proof, buzz_auth::VerifiedDelegationOutput::from_workspace_verifier( - owner, pubkey, None, true, + relationship.owner_pubkey(), + pubkey, + relationship.relationship_id(), + relationship.relationship_revision(), + None, + true, ), ) .map_err(|_| { diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 24f0129679..53faafb16a 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -308,30 +308,41 @@ async fn authenticate( .and_then(|value| value.to_str().ok()); let identity_lane = crate::authorization_runtime::transport::legacy_identity_lane(state, tenant.community()); - let identity_proof = match crate::corporate_identity::verify_corporate_identity( - state, - tenant.community(), - pubkey, - identity_assertion.as_ref(), - auth_tag, - ) - .await - { - Ok(proof) => Some(proof), - Err(error) - if identity_lane - == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly => + let neutral_evidence = identity_assertion.is_none() + && crate::authorization_runtime::transport::provider_evidence_resolver_is_installed( + state, + tenant.community(), + ); + let identity_proof = if neutral_evidence { + None + } else { + match crate::corporate_identity::verify_corporate_identity( + state, + tenant.community(), + pubkey, + identity_assertion.as_ref(), + auth_tag, + ) + .await { - tracing::warn!(error = ?error, "observational invite identity verification unavailable"); - None + Ok(proof) => Some(proof), + Err(error) + if identity_lane + == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly => + { + tracing::warn!(error = ?error, "observational invite identity verification unavailable"); + None + } + Err(error) => return Err(error.into_api_error()), } - Err(error) => return Err(error.into_api_error()), }; let verified_proof = bridge::retain_bridge_proof(verified_proof, auth_tag)? .ok_or_else(|| api_error(StatusCode::UNAUTHORIZED, "NIP-98 evidence required"))?; - let enrollment_assertion = if state + let enrollment_assertion = if neutral_evidence { + None + } else if state .protected_transport() .and_then(|runtime| runtime.mode_for_domain(tenant.community())) == Some(AuthorizationMode::Enforce) @@ -625,13 +636,10 @@ pub async fn claim_invite( let token_hash = hash_v2_code(&request.code); if enforcing { - let assertion = enrollment_assertion.ok_or_else(|| { - api_error(StatusCode::FORBIDDEN, "relay identity verification failed") - })?; let enrollment = authorize_enrollment_if_configured( &state, Arc::clone(&verified_proof), - assertion, + enrollment_assertion, stable_correlation_from_proof(&verified_proof), ) .await diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 70c8166277..4b0c071414 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -130,6 +130,14 @@ async fn verify_media_corporate_identity( headers, ) .map_err(protected_media_denied)?; + if identity_assertion.is_none() + && crate::authorization_runtime::transport::provider_evidence_resolver_is_installed( + state, + tenant.community(), + ) + { + return Ok(None); + } match crate::corporate_identity::verify_corporate_identity( state, tenant.community(), @@ -394,23 +402,26 @@ impl FromRequestParts> for AuthenticatedUpload { ) .await .map_err(|_| MediaError::RelayMembershipRequired)?; - let verified_blossom = match crate::corporate_identity::verify_unconditional_nip_oa_owner( - auth_event.pubkey, - auth_tag, - ) { - Some(owner) => VerifiedEvidenceAdapter::new() - .attach_transport_delegation( - verified_blossom, - buzz_auth::VerifiedDelegationOutput::from_workspace_verifier( - owner, - auth_event.pubkey, - None, - true, - ), - ) - .map_err(protected_media_denied)?, - None => verified_blossom, - }; + let verified_blossom = + match crate::corporate_identity::verify_unconditional_nip_oa_relationship( + auth_event.pubkey, + auth_tag, + ) { + Some(relationship) => VerifiedEvidenceAdapter::new() + .attach_transport_delegation( + verified_blossom, + buzz_auth::VerifiedDelegationOutput::from_workspace_verifier( + relationship.owner_pubkey(), + auth_event.pubkey, + relationship.relationship_id(), + relationship.relationship_revision(), + None, + true, + ), + ) + .map_err(protected_media_denied)?, + None => verified_blossom, + }; let correlation_id = stable_media_correlation(&verified_blossom); let verified_assertion = seal_media_assertion( state, @@ -921,16 +932,18 @@ async fn authenticate_media_read( ) .await .map_err(|_| MediaError::RelayMembershipRequired)?; - let verified_blossom = match crate::corporate_identity::verify_unconditional_nip_oa_owner( + let verified_blossom = match crate::corporate_identity::verify_unconditional_nip_oa_relationship( auth_event.pubkey, auth_tag, ) { - Some(owner) => VerifiedEvidenceAdapter::new() + Some(relationship) => VerifiedEvidenceAdapter::new() .attach_transport_delegation( verified_blossom, buzz_auth::VerifiedDelegationOutput::from_workspace_verifier( - owner, + relationship.owner_pubkey(), auth_event.pubkey, + relationship.relationship_id(), + relationship.relationship_revision(), None, true, ), diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 04a428e755..f1f8e47fba 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -37,7 +37,7 @@ use buzz_auth::{ VerifiedEvidenceAdapter, }; use buzz_core::{tenant::TenantContext, CommunityId}; -use buzz_db::channel::MemberRole; +use buzz_db::{authorization_invalidation::AuthorizationSessionTarget, channel::MemberRole}; use buzz_core::StoredEvent; use buzz_pubsub::EventTopic; @@ -47,7 +47,7 @@ use crate::audio::room::{ ProtectedPeerEpoch, Room, RoomOwnerEpoch, }; use crate::authorization_runtime::transport::{ - authorize_session_if_configured, ProtectedAuthorization, + authorize_exact_session_if_configured, ProtectedAuthorization, }; use crate::state::{run_registered_community_connection, AppState}; @@ -269,6 +269,10 @@ async fn handle_active_audio_connection( cancel: CancellationToken, corporate_identity_assertion: Option, ) { + let Ok(session_target) = AuthorizationSessionTarget::new(session_id, Uuid::new_v4()) else { + cancel.cancel(); + return; + }; let (mut ws_send, mut ws_recv) = socket.split(); let challenge = generate_challenge(); @@ -342,31 +346,40 @@ async fn handle_active_audio_connection( let identity_lane = crate::authorization_runtime::transport::legacy_identity_lane(&state, tenant.community()); - let identity_proof = match crate::corporate_identity::verify_corporate_identity( - &state, - tenant.community(), - pubkey, - corporate_identity_assertion.as_ref(), - auth_tag_json.as_deref(), - ) - .await - { - Ok(proof) => Some(proof), - Err(e) => { - warn!(error = ?e, "audio: corporate identity denied"); - if identity_lane - == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly - { - None - } else { - let _ = ws_send - .send(WsMessage::Text( - serde_json::json!({"type": "error", "message": e.public_message()}) - .to_string() - .into(), - )) - .await; - return; + let neutral_evidence = corporate_identity_assertion.is_none() + && crate::authorization_runtime::transport::provider_evidence_resolver_is_installed( + &state, + tenant.community(), + ); + let identity_proof = if neutral_evidence { + None + } else { + match crate::corporate_identity::verify_corporate_identity( + &state, + tenant.community(), + pubkey, + corporate_identity_assertion.as_ref(), + auth_tag_json.as_deref(), + ) + .await + { + Ok(proof) => Some(proof), + Err(e) => { + warn!(error = ?e, "audio: corporate identity denied"); + if identity_lane + == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly + { + None + } else { + let _ = ws_send + .send(WsMessage::Text( + serde_json::json!({"type": "error", "message": e.public_message()}) + .to_string() + .into(), + )) + .await; + return; + } } } }; @@ -391,11 +404,20 @@ async fn handle_active_audio_connection( return; } - let transport_delegation = crate::corporate_identity::verify_unconditional_nip_oa_owner( + let transport_delegation = crate::corporate_identity::verify_unconditional_nip_oa_relationship( pubkey, auth_tag_json.as_deref(), ) - .map(|owner| VerifiedDelegationOutput::from_workspace_verifier(owner, pubkey, None, true)); + .map(|relationship| { + VerifiedDelegationOutput::from_workspace_verifier( + relationship.owner_pubkey(), + pubkey, + relationship.relationship_id(), + relationship.relationship_revision(), + None, + true, + ) + }); let verified_proof = match VerifiedEvidenceAdapter::new().verify_nip42( tenant.community(), AuthTransport::Audio, @@ -436,14 +458,14 @@ async fn handle_active_audio_connection( }, None => None, }; - let protected_authority = match authorize_session_if_configured( + let protected_authority = match authorize_exact_session_if_configured( &state, Arc::clone(&verified_proof), verified_assertion, AuthorizationCapability::AudioJoin, Uuid::from_bytes(correlation), "audio.join", - session_id, + session_target, cancel.clone(), ) .await diff --git a/crates/buzz-relay/src/authorization_runtime/finalization.rs b/crates/buzz-relay/src/authorization_runtime/finalization.rs index 1b82752c65..0f3522091c 100644 --- a/crates/buzz-relay/src/authorization_runtime/finalization.rs +++ b/crates/buzz-relay/src/authorization_runtime/finalization.rs @@ -563,6 +563,13 @@ mod tests { struct DenyProvider; impl AuthorizationProvider for DenyProvider { + fn profile_id(&self) -> buzz_auth::AuthorizationProfileId { + buzz_auth::AuthorizationProfileId::from_server_configuration( + "profile.synthetic-deny.example", + ) + .expect("synthetic profile is valid") + } + fn authorize<'a>( &'a self, _request: &'a AuthorizationRequest, diff --git a/crates/buzz-relay/src/authorization_runtime/invalidation.rs b/crates/buzz-relay/src/authorization_runtime/invalidation.rs index 05c5b159e3..88d090e892 100644 --- a/crates/buzz-relay/src/authorization_runtime/invalidation.rs +++ b/crates/buzz-relay/src/authorization_runtime/invalidation.rs @@ -18,7 +18,7 @@ use buzz_db::authorization_invalidation::{ AuthorizationInvalidationEntry, AuthorizationInvalidationFloor, AuthorizationInvalidationReceipt, AuthorizationInvalidationRequest, AuthorizationInvalidationResult, AuthorizationInvalidationSnapshot, AuthorizationSelector, - AuthorizationSelectorKind, + AuthorizationSelectorKind, AuthorizationSessionTarget, }; use buzz_db::{Db, DbError}; use buzz_pubsub::authorization_invalidation::{ @@ -206,51 +206,49 @@ impl AuthorizationDependencies { /// direct binding. This registration can withdraw presentation but cannot /// authorize access or create a lease. pub fn from_verification_only( - session_id: Uuid, + session_target: Option, disposition: &VerificationOnlyDisposition, ) -> Result { - Self::from_selectors( - disposition.authorization_domain(), - vec![ - AuthorizationSelector::nostr_key(disposition.actor_pubkey().to_bytes()), - AuthorizationSelector::binding( - disposition.binding_id(), - disposition.binding_version().get(), - ) + let mut selectors = vec![ + AuthorizationSelector::nostr_key(disposition.actor_pubkey().to_bytes()), + AuthorizationSelector::binding( + disposition.binding_id(), + disposition.binding_version().get(), + ) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + AuthorizationSelector::domain(), + AuthorizationSelector::policy_version(disposition.policy_version().as_str()) .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, - AuthorizationSelector::session(session_id) - .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, - AuthorizationSelector::domain(), - AuthorizationSelector::policy_version(disposition.policy_version().as_str()) - .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, - ], - ) + ]; + if let Some(target) = session_target { + selectors.push(AuthorizationSelector::session(target)); + } + Self::from_selectors(disposition.authorization_domain(), selectors) } /// Derive the pre-binding dependencies for one staged direct enrollment. /// The transaction revalidates these selectors before it may create the /// first binding or membership row. pub fn from_enrollment( - session_id: Uuid, + session_target: Option, disposition: &super::finalization::EnrollmentDisposition, ) -> Result { let actor = disposition.actor_pubkey().to_bytes(); - Self::from_selectors( - disposition.authorization_domain(), - vec![ - AuthorizationSelector::principal( - disposition.principal().issuer(), - disposition.principal().subject(), - ) + let mut selectors = vec![ + AuthorizationSelector::principal( + disposition.principal().issuer(), + disposition.principal().subject(), + ) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + AuthorizationSelector::nostr_key(actor), + AuthorizationSelector::domain(), + AuthorizationSelector::policy_version(disposition.policy_version().as_str()) .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, - AuthorizationSelector::nostr_key(actor), - AuthorizationSelector::session(session_id) - .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, - AuthorizationSelector::domain(), - AuthorizationSelector::policy_version(disposition.policy_version().as_str()) - .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, - ], - ) + ]; + if let Some(target) = session_target { + selectors.push(AuthorizationSelector::session(target)); + } + Self::from_selectors(disposition.authorization_domain(), selectors) } /// Derive every invalidation dependency from one finalized enforcing @@ -259,35 +257,48 @@ impl AuthorizationDependencies { /// The principal is read from the same active binding that the finalizer /// bound to the lease. It is never accepted as an independent argument. pub fn from_context( - session_id: Uuid, + session_target: Option, context: &AuthContext, ) -> Result { let lease = context .authorization_lease() .ok_or(AuthorizationInvalidationRuntimeError::InvalidDependencies)?; - let (binding, delegated_owner) = match context.federated_authorization() { - FederatedAuthorization::Direct { binding, .. } => { - if lease.owner_pubkey().is_some() - || binding.bound_pubkey() != context.pubkey() - || context.agent_owner_pubkey().is_some() - { - return Err(AuthorizationInvalidationRuntimeError::InvalidDependencies); + let (binding, delegated_owner, delegated_relationship) = + match context.federated_authorization() { + FederatedAuthorization::Direct { binding, .. } => { + if lease.owner_pubkey().is_some() + || binding.bound_pubkey() != context.pubkey() + || context.agent_owner_pubkey().is_some() + { + return Err(AuthorizationInvalidationRuntimeError::InvalidDependencies); + } + (binding, None, None) } - (binding, None) - } - FederatedAuthorization::Delegated { owner, .. } => { - let owner_key = owner.bound_pubkey(); - if lease.owner_pubkey() != Some(owner_key) - || context.agent_owner_pubkey() != Some(owner_key) - { + FederatedAuthorization::Delegated { owner, .. } => { + let owner_key = owner.bound_pubkey(); + let delegation = context + .nostr() + .verified_delegation() + .ok_or(AuthorizationInvalidationRuntimeError::InvalidDependencies)?; + if lease.owner_pubkey() != Some(owner_key) + || context.agent_owner_pubkey() != Some(owner_key) + || delegation.owner_pubkey() != owner_key + { + return Err(AuthorizationInvalidationRuntimeError::InvalidDependencies); + } + ( + owner, + Some(owner_key), + Some(( + delegation.relationship_id().as_uuid(), + delegation.relationship_revision().get(), + )), + ) + } + FederatedAuthorization::NotRequired => { return Err(AuthorizationInvalidationRuntimeError::InvalidDependencies); } - (owner, Some(owner_key)) - } - FederatedAuthorization::NotRequired => { - return Err(AuthorizationInvalidationRuntimeError::InvalidDependencies); - } - }; + }; if !matches!( context.federated_policy().requirement(), FederatedIdentityRequirement::Required(_) @@ -312,15 +323,25 @@ impl AuthorizationDependencies { AuthorizationSelector::nostr_key(actor), AuthorizationSelector::binding(lease.binding_id(), lease.binding_version().get()) .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, - AuthorizationSelector::session(session_id) - .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, AuthorizationSelector::domain(), AuthorizationSelector::policy_version(lease.policy_version().as_str()) .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, ]; + if let Some(target) = session_target { + selectors.push(AuthorizationSelector::session(target)); + } if let Some(owner) = delegated_owner { selectors.extend(delegated_owner_selectors(owner.to_bytes())); } + if let Some((relationship_id, relationship_revision)) = delegated_relationship { + selectors.push( + AuthorizationSelector::delegated_relationship( + relationship_id, + relationship_revision, + ) + .map_err(|_| AuthorizationInvalidationRuntimeError::InvalidDependencies)?, + ); + } Self::from_selectors(lease.authorization_domain(), selectors) } @@ -1310,7 +1331,9 @@ mod tests { async fn two_nodes_converge_after_lost_reordered_and_replayed_hints() { let store = Arc::new(FakeStore::default()); let community_id = domain(1); - let selector = AuthorizationSelector::session(Uuid::new_v4()).expect("valid session"); + let selector = AuthorizationSelector::session( + AuthorizationSessionTarget::new(Uuid::new_v4(), Uuid::new_v4()).expect("valid session"), + ); store.set(AuthorizationInvalidationSnapshot { community_id, generation: 0, @@ -1893,7 +1916,10 @@ mod tests { for selector in [ AuthorizationSelector::binding(Uuid::new_v4(), 1).expect("valid binding"), - AuthorizationSelector::session(Uuid::new_v4()).expect("valid session"), + AuthorizationSelector::session( + AuthorizationSessionTarget::new(Uuid::new_v4(), Uuid::new_v4()) + .expect("valid session"), + ), AuthorizationSelector::domain(), AuthorizationSelector::policy_version("private-policy").expect("valid policy"), ] { @@ -1981,7 +2007,7 @@ mod tests { fn public_constructor_has_no_caller_supplied_principal_slot() { fn assert_context_only_signature( _constructor: fn( - Uuid, + Option, &AuthContext, ) -> Result< AuthorizationDependencies, diff --git a/crates/buzz-relay/src/authorization_runtime/production.rs b/crates/buzz-relay/src/authorization_runtime/production.rs index fe69cd9d21..208ea804af 100644 --- a/crates/buzz-relay/src/authorization_runtime/production.rs +++ b/crates/buzz-relay/src/authorization_runtime/production.rs @@ -9,10 +9,10 @@ use async_trait::async_trait; use buzz_auth::{ resolve_current_federated_policy, AccessLeasePolicy, ActiveBindingResolution, ApplicationLeaseLimit, AuthContextInput, AuthorizationClockSkew, AuthorizationOutcome, - AuthorizationProvider, BindingLeaseBound, BindingSource, CapabilitySet, EnrollmentMode, - FederatedAuthorization, LeaseVersion, ProviderTimeout, ResolvedFederatedPolicy, Scope, - SharedAuthorizationClock, SystemAuthorizationClock, VerificationStatusPolicy, - VerifiedEvidenceAdapter, + AuthorizationProfileId, AuthorizationProvider, BindingLeaseBound, BindingSource, CapabilitySet, + EnrollmentMode, FederatedAuthorization, LeaseVersion, ProviderTimeout, ResolvedFederatedPolicy, + Scope, SharedAuthorizationClock, SystemAuthorizationClock, VerificationStatusPolicy, + VerifiedEvidenceAdapter, VerifiedProviderEvidence, }; use buzz_core::{CommunityId, TenantContext}; use sha2::{Digest, Sha256}; @@ -31,6 +31,7 @@ use super::{ DomainTransportPolicy, LeaseCurrentState, LeaseCurrentStateError, LeaseCurrentStateObserver, ProtectedAuthorizationResolver, ProtectedOperationRequest, ProtectedResolution, ProtectedResolutionError, ProtectedTransportRuntime, + VerifiedProviderEvidenceResolver, }, }; @@ -127,6 +128,7 @@ struct ProductionResolver { finalizer: RelayAuthorizationFinalizer, invalidation: AuthorizationInvalidationRuntime, clock: SharedAuthorizationClock, + profiles: HashMap, } impl ProductionResolver { @@ -237,6 +239,52 @@ impl ProductionResolver { ) .map_err(|_| ProtectedResolutionError::new("assertion_stale")) } + + fn normalized_provider_evidence( + &self, + request: &ProtectedOperationRequest, + capabilities: &CapabilitySet, + ) -> Result, ProtectedResolutionError> { + let profile = self + .profiles + .get(&request.authorization_domain()) + .ok_or(ProtectedResolutionError::new("provider_profile_missing"))?; + let now = self + .clock + .now() + .map_err(|_| ProtectedResolutionError::new("authorization_clock"))? + .unix_seconds(); + + if let Some(evidence) = request.provider_evidence() { + evidence + .validate_for( + request.authorization_domain(), + request.transport(), + profile, + capabilities, + now, + ) + .map_err(|_| ProtectedResolutionError::new("provider_evidence_invalid"))?; + return Ok(Arc::clone(evidence)); + } + + let assertion = request + .verified_assertion() + .ok_or(ProtectedResolutionError::new("provider_evidence_missing"))?; + let assertion = self.reseal_assertion(assertion)?; + let fresh_until = assertion.expires_at().unix_seconds(); + VerifiedEvidenceAdapter::new() + .provider_evidence_from_verified_assertion( + assertion, + profile.clone(), + capabilities.clone(), + now, + fresh_until, + now, + ) + .map(Arc::new) + .map_err(|_| ProtectedResolutionError::new("provider_evidence_invalid")) + } } #[async_trait] @@ -253,12 +301,14 @@ impl ProtectedAuthorizationResolver for ProductionResolver { .map_err(|_| ProtectedResolutionError::new("invalidation_unavailable"))?; self.require_membership(request).await?; let capabilities = CapabilitySet::single(request.capability()); + let provider_evidence = self.normalized_provider_evidence(request, &capabilities)?; + let assertion = provider_evidence.verified_assertion(); let federated_policy = self .current_policy(request.authorization_domain(), request.correlation_id()) .await?; let outcome = if let Some(owner) = request.owner_pubkey() { let binding = self - .active_binding(request.authorization_domain(), owner, None) + .active_binding(request.authorization_domain(), owner, Some(assertion)) .await?; self.finalizer .evaluate_delegated( @@ -266,14 +316,11 @@ impl ProtectedAuthorizationResolver for ProductionResolver { request.verified_proof(), &binding, federated_policy, - capabilities, + capabilities.clone(), request.correlation_id(), ) .await } else { - let assertion = request - .verified_assertion() - .ok_or(ProtectedResolutionError::new("direct_assertion_required"))?; let _binding = self .active_binding( request.authorization_domain(), @@ -287,7 +334,7 @@ impl ProtectedAuthorizationResolver for ProductionResolver { request.verified_proof(), assertion, federated_policy, - capabilities, + capabilities.clone(), request.correlation_id(), ) .await @@ -317,9 +364,9 @@ impl ProtectedAuthorizationResolver for ProductionResolver { .await .map_err(|_| ProtectedResolutionError::new("invalidation_unavailable"))?; self.require_membership(request).await?; - let assertion = request - .verified_assertion() - .ok_or(ProtectedResolutionError::new("direct_assertion_required"))?; + let capabilities = CapabilitySet::single(request.capability()); + let provider_evidence = self.normalized_provider_evidence(request, &capabilities)?; + let assertion = provider_evidence.verified_assertion(); let binding = self .active_binding( request.authorization_domain(), @@ -337,7 +384,7 @@ impl ProtectedAuthorizationResolver for ProductionResolver { request.verified_proof(), assertion, evaluation_policy, - CapabilitySet::single(request.capability()), + capabilities, request.correlation_id(), ) .await @@ -380,9 +427,7 @@ impl ProtectedAuthorizationResolver for ProductionResolver { ) .map_err(|_| ProtectedResolutionError::new("status_finalization"))?; let dependencies = AuthorizationDependencies::from_verification_only( - request - .session_id() - .unwrap_or_else(|| request.correlation_id()), + request.session_target(), &disposition, ) .map_err(|_| ProtectedResolutionError::new("invalidation_dependencies"))?; @@ -416,8 +461,10 @@ impl ProtectedAuthorizationResolver for ProductionResolver { .await .map_err(|_| ProtectedResolutionError::new("invalidation_unavailable"))?; let capabilities = CapabilitySet::single(request.capability()); + let provider_evidence = self.normalized_provider_evidence(request, &capabilities)?; + let assertion = provider_evidence.verified_assertion(); - if let Some(assertion) = request.enrollment_assertion() { + if request.enrollment_requested() { let evaluation_policy = self .current_policy(request.authorization_domain(), request.correlation_id()) .await?; @@ -428,7 +475,7 @@ impl ProtectedAuthorizationResolver for ProductionResolver { request.verified_proof(), assertion, evaluation_policy, - capabilities, + capabilities.clone(), request.correlation_id(), ) .await @@ -450,13 +497,9 @@ impl ProtectedAuthorizationResolver for ProductionResolver { request.correlation_id(), ) .map_err(|_| ProtectedResolutionError::new("enrollment_finalization"))?; - let dependencies = AuthorizationDependencies::from_enrollment( - request - .session_id() - .unwrap_or_else(|| request.correlation_id()), - &disposition, - ) - .map_err(|_| ProtectedResolutionError::new("invalidation_dependencies"))?; + let dependencies = + AuthorizationDependencies::from_enrollment(request.session_target(), &disposition) + .map_err(|_| ProtectedResolutionError::new("invalidation_dependencies"))?; let observer = self .invalidation .observe_authority(fence, dependencies, request.cancellation()) @@ -482,7 +525,7 @@ impl ProtectedAuthorizationResolver for ProductionResolver { .await?; let (authorization, snapshot) = if let Some(owner) = request.owner_pubkey() { let binding = self - .active_binding(request.authorization_domain(), owner, None) + .active_binding(request.authorization_domain(), owner, Some(assertion)) .await?; let outcome = self .finalizer @@ -491,7 +534,7 @@ impl ProtectedAuthorizationResolver for ProductionResolver { request.verified_proof(), &binding, evaluation_policy, - capabilities, + capabilities.clone(), request.correlation_id(), ) .await @@ -510,9 +553,6 @@ impl ProtectedAuthorizationResolver for ProductionResolver { snapshot, ) } else { - let assertion = request - .verified_assertion() - .ok_or(ProtectedResolutionError::new("direct_assertion_required"))?; let binding = self .active_binding( request.authorization_domain(), @@ -527,7 +567,7 @@ impl ProtectedAuthorizationResolver for ProductionResolver { request.verified_proof(), assertion, evaluation_policy, - capabilities, + capabilities.clone(), request.correlation_id(), ) .await @@ -586,13 +626,9 @@ impl ProtectedAuthorizationResolver for ProductionResolver { "non_authoritative_disposition", )); }; - let dependencies = AuthorizationDependencies::from_context( - request - .session_id() - .unwrap_or_else(|| request.correlation_id()), - &context, - ) - .map_err(|_| ProtectedResolutionError::new("invalidation_dependencies"))?; + let dependencies = + AuthorizationDependencies::from_context(request.session_target(), &context) + .map_err(|_| ProtectedResolutionError::new("invalidation_dependencies"))?; let observer = self .invalidation .observe_authority(fence, dependencies, request.cancellation()) @@ -643,11 +679,27 @@ pub async fn install_from_environment( pub async fn install_from_environment_with_providers( state: &Arc, providers: ProductionProviderRegistry, +) -> Result { + install_from_environment_with_providers_and_evidence(state, providers, None).await +} + +/// Build and install one runtime with an optional provider-neutral evidence seam. +/// +/// When installed, the resolver must return zero, exactly one, or ambiguous +/// sealed evidence for every protected request. JWT verification remains an +/// optional provider profile and conflicts with supplied neutral evidence. +pub async fn install_from_environment_with_providers_and_evidence( + state: &Arc, + providers: ProductionProviderRegistry, + evidence_resolver: Option>, ) -> Result { if state.protected_transport().is_some() || state.restore_protection().is_some() { return Err(ProductionRuntimeError::AlreadyInstalled); } - let Some(installed) = build_from_environment_with_providers(state, providers).await? else { + let Some(installed) = + build_from_environment_with_providers_and_evidence(state, providers, evidence_resolver) + .await? + else { return Ok(ProtectedRuntimeInstallation::Disabled); }; let InstalledProtectedRuntime { @@ -723,6 +775,15 @@ pub async fn install_from_environment_with_providers( pub async fn build_from_environment_with_providers( state: &crate::state::AppState, providers: ProductionProviderRegistry, +) -> Result, ProductionRuntimeError> { + build_from_environment_with_providers_and_evidence(state, providers, None).await +} + +/// Build the disabled-by-default runtime with optional verified evidence input. +pub async fn build_from_environment_with_providers_and_evidence( + state: &crate::state::AppState, + providers: ProductionProviderRegistry, + evidence_resolver: Option>, ) -> Result, ProductionRuntimeError> { let raw = env::var(DOMAINS_ENV).unwrap_or_default(); let configured = parse_domains(&raw)?; @@ -732,7 +793,8 @@ pub async fn build_from_environment_with_providers( return Ok(None); } validate_provider_coverage(&configured, &providers)?; - if configured.values().any(|mode| mode.evaluates_provider()) + if evidence_resolver.is_none() + && configured.values().any(|mode| mode.evaluates_provider()) && state.identity_assertion_provenance().is_none() { return Err(ProductionRuntimeError::AssertionProvenanceMissing); @@ -740,7 +802,10 @@ pub async fn build_from_environment_with_providers( let protected_domains = protected_domains(&configured); let enforcing_domains = enforcing_domains(&configured); let projection_domains = projection_reconciliation_domains(&configured); - if !enforcing_domains.is_empty() && state.corporate_identity.is_none() { + if evidence_resolver.is_none() + && !enforcing_domains.is_empty() + && state.corporate_identity.is_none() + { return Err(ProductionRuntimeError::VerifierMissing); } let clock: SharedAuthorizationClock = Arc::new(SystemAuthorizationClock); @@ -749,12 +814,15 @@ pub async fn build_from_environment_with_providers( &protected_domains, )?; let profile = env::var(PROFILE_ENV).unwrap_or_else(|_| "current-membership-v1".to_owned()); + let installed_profile = AuthorizationProfileId::from_server_configuration(profile.clone()) + .map_err(|_| ProductionRuntimeError::InvalidConfiguration)?; let lease_seconds = parse_positive_seconds(LEASE_SECONDS_ENV, 300)?; let lease_limit = ApplicationLeaseLimit::from_seconds(lease_seconds)?; let status_limit = ApplicationLeaseLimit::from_seconds(lease_seconds.min(60))?; let skew = AuthorizationClockSkew::from_seconds(0)?; let mut policies = Vec::with_capacity(configured.len()); let mut transports = Vec::with_capacity(configured.len()); + let mut profiles = HashMap::with_capacity(configured.len()); for (domain, mode) in &configured { transports.push(DomainTransportPolicy::from_server_configuration( *domain, *mode, @@ -763,6 +831,7 @@ pub async fn build_from_environment_with_providers( continue; } let provider = providers.provider_for(*domain)?; + profiles.insert(*domain, installed_profile.clone()); policies.push(DomainAuthorizationPolicy::from_server_configuration( *domain, profile.clone(), @@ -822,8 +891,13 @@ pub async fn build_from_environment_with_providers( finalizer, invalidation: invalidation.clone(), clock: Arc::clone(&clock), + profiles, }); - let transport = Arc::new(ProtectedTransportRuntime::new(transports, resolver, clock)?); + let mut transport = ProtectedTransportRuntime::new(transports, resolver, clock)?; + if let Some(evidence_resolver) = evidence_resolver { + transport = transport.with_provider_evidence_resolver(evidence_resolver); + } + let transport = Arc::new(transport); Ok(Some(InstalledProtectedRuntime { transport, invalidation, diff --git a/crates/buzz-relay/src/authorization_runtime/status.rs b/crates/buzz-relay/src/authorization_runtime/status.rs index 018a941607..a3e702aeb3 100644 --- a/crates/buzz-relay/src/authorization_runtime/status.rs +++ b/crates/buzz-relay/src/authorization_runtime/status.rs @@ -812,13 +812,17 @@ impl ProductionClientStatusRuntime { // becoming connection authority in VerifyOnly. Enforce retains its // independent protected-session cancellation fence. let presentation_cancellation = CancellationToken::new(); + let session_target = state + .conn_manager + .authorization_session_target(connection_id) + .ok_or(super::transport::ProtectedTransportError::InvalidSessionId)?; let request = super::transport::ProtectedOperationRequest::new_with_cancellation( proof, Some(assertion), buzz_auth::AuthorizationCapability::CommunityRead, Uuid::new_v4(), "client.status.current", - Some(connection_id), + Some(session_target), Some(presentation_cancellation.clone()), )?; let Some(resolution) = protected.present_status(&request).await? else { diff --git a/crates/buzz-relay/src/authorization_runtime/transport.rs b/crates/buzz-relay/src/authorization_runtime/transport.rs index d996615675..2253cf58a2 100644 --- a/crates/buzz-relay/src/authorization_runtime/transport.rs +++ b/crates/buzz-relay/src/authorization_runtime/transport.rs @@ -13,9 +13,10 @@ use buzz_auth::{ AuthContext, AuthTransport, AuthorizationCapability, AuthorizationLease, AuthorizationLeaseValidator, AuthorizationProfileId, BindingVersion, LeaseUseRequirement, LeaseValidationError, PolicyVersion, SharedAuthorizationClock, VerificationOnlyDisposition, - VerifiedFederatedAssertion, VerifiedNostrProof, + VerifiedFederatedAssertion, VerifiedNostrProof, VerifiedProviderEvidence, }; use buzz_core::CommunityId; +use buzz_db::authorization_invalidation::AuthorizationSessionTarget; use thiserror::Error; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -70,6 +71,45 @@ pub struct DomainTransportPolicy { mode: AuthorizationMode, } +/// Exact result produced by one installed provider-neutral evidence resolver. +pub enum VerifiedProviderEvidenceResolution { + /// No verified evidence was available for this request. + Absent, + /// Exactly one sealed evidence object was available. + One(Arc), + /// More than one source or value was present; callers must deny. + Ambiguous, +} + +impl fmt::Debug for VerifiedProviderEvidenceResolution { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Absent => "VerifiedProviderEvidenceResolution::Absent", + Self::One(_) => "VerifiedProviderEvidenceResolution::One([redacted])", + Self::Ambiguous => "VerifiedProviderEvidenceResolution::Ambiguous", + }) + } +} + +/// Redacted failure from the deployment-installed evidence resolver. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("verified provider evidence resolution failed")] +pub struct VerifiedProviderEvidenceResolutionError; + +/// Deployment adapter that supplies already-verified provider-neutral evidence. +/// +/// The adapter receives only typed protected-request metadata. Raw headers, +/// tokens, and transport classifications cannot construct the returned sealed +/// value. Implementations must return [`VerifiedProviderEvidenceResolution::Ambiguous`] +/// when multiple or conflicting sources are observed. +pub trait VerifiedProviderEvidenceResolver: Send + Sync { + /// Resolve zero, exactly one, or ambiguous evidence for one exact request. + fn resolve( + &self, + request: &ProtectedOperationRequest, + ) -> Result; +} + impl DomainTransportPolicy { /// Construct policy from immutable server configuration. pub const fn from_server_configuration( @@ -99,11 +139,13 @@ pub struct ProtectedOperationRequest { verified_proof: Arc, capability: AuthorizationCapability, correlation_id: Uuid, - session_id: Option, + session_target: Option, surface: &'static str, cancellation: Option, verified_assertion: Option>, enrollment_assertion: Option>, + enrollment_requested: bool, + provider_evidence: Option>, } impl ProtectedOperationRequest { @@ -132,15 +174,12 @@ impl ProtectedOperationRequest { capability: AuthorizationCapability, correlation_id: Uuid, surface: &'static str, - session_id: Option, + session_target: Option, cancellation: Option, ) -> Result { if correlation_id.is_nil() { return Err(ProtectedTransportError::InvalidCorrelationId); } - if session_id == Some(Uuid::nil()) { - return Err(ProtectedTransportError::InvalidSessionId); - } if surface.is_empty() { return Err(ProtectedTransportError::InvalidSurface); } @@ -155,28 +194,31 @@ impl ProtectedOperationRequest { verified_proof, capability, correlation_id, - session_id, + session_target, surface, cancellation, verified_assertion, enrollment_assertion: None, + enrollment_requested: false, + provider_evidence: None, }) } fn new_enrollment( verified_proof: Arc, - assertion: Arc, + assertion: Option>, correlation_id: Uuid, surface: &'static str, ) -> Result { let mut request = Self::new( verified_proof, - Some(Arc::clone(&assertion)), + assertion.clone(), AuthorizationCapability::InviteClaim, correlation_id, surface, )?; - request.enrollment_assertion = Some(assertion); + request.enrollment_assertion = assertion; + request.enrollment_requested = true; Ok(request) } @@ -217,6 +259,16 @@ impl ProtectedOperationRequest { self.verified_assertion.as_ref() } + /// Whether this request is the dedicated first-enrollment operation. + pub const fn enrollment_requested(&self) -> bool { + self.enrollment_requested + } + + /// Exactly one provider-neutral evidence object resolved for this request. + pub fn provider_evidence(&self) -> Option<&Arc> { + self.provider_evidence.as_ref() + } + /// Exact dynamically selected capability. pub const fn capability(&self) -> AuthorizationCapability { self.capability @@ -227,9 +279,9 @@ impl ProtectedOperationRequest { self.correlation_id } - /// Stable server-owned session identity for long-lived transports. - pub const fn session_id(&self) -> Option { - self.session_id + /// Exact server-issued target for a long-lived transport session. + pub const fn session_target(&self) -> Option { + self.session_target } /// Stable low-cardinality surface name for resolver telemetry. @@ -499,6 +551,7 @@ pub enum LeaseCurrentStateError { pub struct ProtectedTransportRuntime { domains: HashMap, resolver: Arc, + provider_evidence_resolver: Option>, validator: AuthorizationLeaseValidator, } @@ -521,10 +574,51 @@ impl ProtectedTransportRuntime { Ok(Self { domains, resolver, + provider_evidence_resolver: None, validator: AuthorizationLeaseValidator::new(clock), }) } + /// Install exactly one immutable provider-neutral evidence resolver. + pub fn with_provider_evidence_resolver( + mut self, + resolver: Arc, + ) -> Self { + self.provider_evidence_resolver = Some(resolver); + self + } + + /// Whether the production composition installed the neutral evidence seam. + pub const fn has_provider_evidence_resolver(&self) -> bool { + self.provider_evidence_resolver.is_some() + } + + fn resolve_provider_evidence( + &self, + request: &ProtectedOperationRequest, + ) -> Result { + let Some(resolver) = self.provider_evidence_resolver.as_ref() else { + return Ok(request.clone()); + }; + let resolution = resolver + .resolve(request) + .map_err(|_| ProtectedTransportError::ProviderEvidenceUnavailable)?; + match resolution { + VerifiedProviderEvidenceResolution::Absent => Ok(request.clone()), + VerifiedProviderEvidenceResolution::Ambiguous => { + Err(ProtectedTransportError::AmbiguousProviderEvidence) + } + VerifiedProviderEvidenceResolution::One(evidence) => { + if request.verified_assertion.is_some() || request.provider_evidence.is_some() { + return Err(ProtectedTransportError::ConflictingProviderEvidence); + } + let mut resolved = request.clone(); + resolved.provider_evidence = Some(evidence); + Ok(resolved) + } + } + } + /// Return the exact configured mode, if this runtime owns the domain. pub fn mode_for_domain(&self, domain: CommunityId) -> Option { self.domains.get(&domain).copied() @@ -554,14 +648,17 @@ impl ProtectedTransportRuntime { AuthorizationMode::Shadow | AuthorizationMode::VerifyOnly => { // Observation failures are telemetry only. These modes must // never alter the inherited access result. - let _ = self.resolver.observe(request).await; + if let Ok(request) = self.resolve_provider_evidence(request) { + let _ = self.resolver.observe(&request).await; + } Ok(ProtectedAuthorization::Legacy) } AuthorizationMode::DenyProtected => deny_protected_request(request), AuthorizationMode::Enforce => { + let request = self.resolve_provider_evidence(request)?; let resolution = self .resolver - .resolve(request) + .resolve(&request) .await .map_err(ProtectedTransportError::Resolution)?; let (context, observer) = match resolution.kind { @@ -580,7 +677,7 @@ impl ProtectedTransportRuntime { validator: self.validator.clone(), observer, }; - authority.validate_exact_request(request)?; + authority.validate_exact_request(&request)?; authority.revalidate()?; Ok(ProtectedAuthorization::Access(authority)) } @@ -594,12 +691,14 @@ impl ProtectedTransportRuntime { request: &ProtectedOperationRequest, ) -> Result, ProtectedTransportError> { match self.mode_for_domain(request.authorization_domain()) { - Some(AuthorizationMode::VerifyOnly | AuthorizationMode::Enforce) => self - .resolver - .present(request) - .await - .map(Some) - .map_err(ProtectedTransportError::Resolution), + Some(AuthorizationMode::VerifyOnly | AuthorizationMode::Enforce) => { + let request = self.resolve_provider_evidence(request)?; + self.resolver + .present(&request) + .await + .map(Some) + .map_err(ProtectedTransportError::Resolution) + } Some(AuthorizationMode::DenyProtected) => Err(ProtectedTransportError::DenyProtected), None | Some(AuthorizationMode::Off | AuthorizationMode::Shadow) => Ok(None), } @@ -619,15 +718,16 @@ impl ProtectedTransportRuntime { } AuthorizationMode::DenyProtected => Err(ProtectedTransportError::DenyProtected), AuthorizationMode::Enforce => { + let request = self.resolve_provider_evidence(request)?; if request.capability() != AuthorizationCapability::InviteClaim - || request.enrollment_assertion().is_none() + || !request.enrollment_requested() || request.owner_pubkey().is_some() { return Err(ProtectedTransportError::EnrollmentEvidenceRequired); } let resolution = self .resolver - .resolve(request) + .resolve(&request) .await .map_err(ProtectedTransportError::Resolution)?; let (disposition, observer) = match resolution.kind { @@ -642,7 +742,7 @@ impl ProtectedTransportRuntime { observer, validator: self.validator.clone(), }; - authority.validate_exact_request(request)?; + authority.validate_exact_request(&request)?; authority.revalidate()?; Ok(ProtectedEnrollmentAuthorization::Enrollment(authority)) } @@ -687,6 +787,44 @@ pub async fn authorize_session_if_configured( surface: &'static str, session_id: Uuid, cancellation: CancellationToken, +) -> Result { + if state.protected_transport().is_none() { + return Ok(ProtectedAuthorization::Legacy); + } + let session_target = state + .conn_manager + .authorization_session_target(session_id) + .filter(|target| target.session_id() == session_id) + .ok_or(ProtectedTransportError::InvalidSessionId)?; + let authority = authorize_exact_session_if_configured( + state, + verified_proof, + verified_assertion, + capability, + correlation_id, + surface, + session_target, + cancellation, + ) + .await?; + state + .conn_manager + .retain_protected_session_authority(session_id, &authority); + Ok(authority) +} + +/// Consult the runtime for a server-issued session target that is not managed +/// by the ordinary relay connection registry (for example, protected audio). +#[allow(clippy::too_many_arguments)] +pub(crate) async fn authorize_exact_session_if_configured( + state: &crate::state::AppState, + verified_proof: Arc, + verified_assertion: Option>, + capability: AuthorizationCapability, + correlation_id: Uuid, + surface: &'static str, + session_target: AuthorizationSessionTarget, + cancellation: CancellationToken, ) -> Result { let Some(runtime) = state.protected_transport() else { return Ok(ProtectedAuthorization::Legacy); @@ -697,21 +835,17 @@ pub async fn authorize_session_if_configured( capability, correlation_id, surface, - Some(session_id), + Some(session_target), Some(cancellation), )?; - let authority = runtime.authorize(&request).await?; - state - .conn_manager - .retain_protected_session_authority(session_id, &authority); - Ok(authority) + runtime.authorize(&request).await } /// Resolve staged direct authority for atomic invite enrollment. pub async fn authorize_enrollment_if_configured( state: &crate::state::AppState, verified_proof: Arc, - assertion: Arc, + assertion: Option>, correlation_id: Uuid, ) -> Result { let Some(runtime) = state.protected_transport() else { @@ -726,6 +860,17 @@ pub async fn authorize_enrollment_if_configured( runtime.authorize_enrollment(&request).await } +/// Whether an enforcing domain can resolve provider-neutral verified evidence. +pub fn provider_evidence_resolver_is_installed( + state: &crate::state::AppState, + authorization_domain: CommunityId, +) -> bool { + state.protected_transport().is_some_and(|runtime| { + runtime.mode_for_domain(authorization_domain) == Some(AuthorizationMode::Enforce) + && runtime.has_provider_evidence_resolver() + }) +} + /// Preserve legacy behavior only when an unwired surface cannot enter an /// enforcing exact-domain runtime. /// @@ -785,6 +930,7 @@ impl fmt::Debug for ProtectedTransportRuntime { .debug_struct("ProtectedTransportRuntime") .field("domains", &"[redacted]") .field("resolver", &"[configured]") + .field("provider_evidence_resolver", &"[configured]") .field("validator", &self.validator) .finish() } @@ -1108,6 +1254,15 @@ pub enum ProtectedTransportError { /// An enforcing surface did not retain sealed verifier evidence. #[error("protected authorization requires verified transport evidence")] MissingVerifiedProof, + /// The installed evidence adapter could not resolve the request. + #[error("verified provider evidence is unavailable")] + ProviderEvidenceUnavailable, + /// Multiple provider-neutral evidence values or sources were present. + #[error("verified provider evidence is ambiguous")] + AmbiguousProviderEvidence, + /// JWT-derived and provider-neutral evidence were both present. + #[error("verified provider evidence sources conflict")] + ConflictingProviderEvidence, /// The exact domain is in the explicit fail-safe protected-denial mode. #[error("protected authorization is unavailable in deny-protected mode")] DenyProtected, diff --git a/crates/buzz-relay/src/corporate_identity.rs b/crates/buzz-relay/src/corporate_identity.rs index cc5964d04e..971d2b3577 100644 --- a/crates/buzz-relay/src/corporate_identity.rs +++ b/crates/buzz-relay/src/corporate_identity.rs @@ -20,6 +20,7 @@ use jsonwebtoken::{ use nostr::{Event, EventBuilder, FromBech32, Kind, PublicKey, Tag, Timestamp}; use serde::Deserialize; use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; use thiserror::Error; use tokio::sync::{Mutex, RwLock}; use tracing::{debug, warn}; @@ -1633,7 +1634,9 @@ async fn verify_delegated_corporate_identity( auth_tag_json: Option<&str>, ) -> Result { if config.allow_delegation { - if let Some(owner_pubkey) = verify_unconditional_nip_oa_owner(signer, auth_tag_json) { + if let Some(relationship) = verify_unconditional_nip_oa_relationship(signer, auth_tag_json) + { + let owner_pubkey = relationship.owner_pubkey(); let owner_binding = db .get_active_identity_binding_by_pubkey(community_id, owner_pubkey.as_bytes()) .await?; @@ -1654,20 +1657,86 @@ async fn verify_delegated_corporate_identity( } } -/// Verify an unconditional NIP-OA owner attestation for transport-wide use. +/// Exact verified identity and revision of one immutable NIP-OA relationship. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct VerifiedNipOaRelationship { + owner_pubkey: PublicKey, + relationship_id: uuid::Uuid, + relationship_revision: u64, +} + +impl VerifiedNipOaRelationship { + /// Verified owner that signed the relationship. + pub const fn owner_pubkey(self) -> PublicKey { + self.owner_pubkey + } + + /// Domain-separated identity of the exact verified signed relationship. + pub const fn relationship_id(self) -> uuid::Uuid { + self.relationship_id + } + + /// Monotonic revision of this immutable relationship issuance. + pub const fn relationship_revision(self) -> u64 { + self.relationship_revision + } +} + +impl fmt::Debug for VerifiedNipOaRelationship { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedNipOaRelationship") + .field("owner_pubkey", &"[redacted]") + .field("relationship_id", &"[redacted]") + .field("relationship_revision", &"[redacted]") + .finish() + } +} + +/// Verify and identify an unconditional NIP-OA relationship. /// -/// Conditional attestations are deliberately rejected because their narrower -/// event constraints cannot be promoted into connection or request authority. -pub fn verify_unconditional_nip_oa_owner( +/// The exact relationship ID is derived only after signature verification +/// from the canonical signed tag and authenticated delegate key. Each signed +/// immutable issuance starts at revision one; a different issuance receives a +/// different relationship ID rather than reusing an owner-wide selector. +pub fn verify_unconditional_nip_oa_relationship( signer: PublicKey, auth_tag_json: Option<&str>, -) -> Option { +) -> Option { let tag_json = auth_tag_json?; let tag: Vec = serde_json::from_str(tag_json).ok()?; if tag.len() != 4 || tag.get(2).and_then(Value::as_str) != Some("") { return None; } - buzz_sdk::nip_oa::verify_auth_tag(tag_json, &signer).ok() + let owner_pubkey = buzz_sdk::nip_oa::verify_auth_tag(tag_json, &signer).ok()?; + let canonical_tag = serde_json::to_vec(&tag).ok()?; + let mut hasher = Sha256::new(); + hasher.update(b"buzz:nip-oa:delegated-relationship:v1"); + hasher.update(signer.to_bytes()); + hasher.update((canonical_tag.len() as u64).to_be_bytes()); + hasher.update(canonical_tag); + let digest = hasher.finalize(); + let mut identity = [0_u8; 16]; + identity.copy_from_slice(&digest[..16]); + identity[6] = (identity[6] & 0x0f) | 0x80; + identity[8] = (identity[8] & 0x3f) | 0x80; + Some(VerifiedNipOaRelationship { + owner_pubkey, + relationship_id: uuid::Uuid::from_bytes(identity), + relationship_revision: 1, + }) +} + +/// Verify an unconditional NIP-OA owner attestation for transport-wide use. +/// +/// Conditional attestations are deliberately rejected because their narrower +/// event constraints cannot be promoted into connection or request authority. +pub fn verify_unconditional_nip_oa_owner( + signer: PublicKey, + auth_tag_json: Option<&str>, +) -> Option { + verify_unconditional_nip_oa_relationship(signer, auth_tag_json) + .map(VerifiedNipOaRelationship::owner_pubkey) } fn is_allowed_jwt_algorithm(algorithm: Algorithm) -> bool { @@ -1953,6 +2022,81 @@ mod tests { } } + #[test] + fn o4_shared_contract_repairs_are_redacted_and_revision_exact() { + let sentinel_key = "private_raw_claim_key"; + let sentinel_value = "private_raw_claim_value"; + let raw_claims = RawJwtClaims { + claims: Map::from_iter([( + sentinel_key.to_string(), + Value::String(sentinel_value.to_string()), + )]), + }; + let debug = format!("{raw_claims:?}"); + let delegation_evidence = include_str!("../../buzz-auth/src/context/evidence.rs"); + let invalidation_contract = include_str!("../../buzz-db/src/authorization_invalidation.rs"); + + let mut missing = Vec::new(); + if debug.contains(sentinel_key) || debug.contains(sentinel_value) { + missing.push("raw-jwt-debug-redaction"); + } + if !(delegation_evidence.contains("DelegatedRelationshipId") + && delegation_evidence.contains("DelegatedRelationshipRevision") + && delegation_evidence.contains("relationship_id") + && delegation_evidence.contains("relationship_revision")) + { + missing.push("delegated-relationship-identity-and-revision"); + } + if !(invalidation_contract.contains("AuthorizationSessionTarget") + && invalidation_contract.contains("issuance_fence") + && invalidation_contract.contains("session-issuance-v2")) + { + missing.push("session-issuance-nonreuse-fence"); + } + + assert!( + missing.is_empty(), + "missing O4 shared-contract repairs: {missing:?}" + ); + + let session_id = Uuid::from_u128(0x801); + let first_session = buzz_db::authorization_invalidation::AuthorizationSessionTarget::new( + session_id, + Uuid::from_u128(0x802), + ) + .expect("first session issuance is valid"); + let second_session = buzz_db::authorization_invalidation::AuthorizationSessionTarget::new( + session_id, + Uuid::from_u128(0x803), + ) + .expect("second session issuance is valid"); + let first_session_fingerprint = + buzz_db::authorization_invalidation::AuthorizationSelector::session(first_session) + .fingerprint(); + let second_session_fingerprint = + buzz_db::authorization_invalidation::AuthorizationSelector::session(second_session) + .fingerprint(); + assert_ne!(first_session_fingerprint, second_session_fingerprint); + + let relationship_id = Uuid::from_u128(0x804); + let first_relationship = + buzz_db::authorization_invalidation::AuthorizationSelector::delegated_relationship( + relationship_id, + 1, + ) + .expect("first relationship revision is valid"); + let second_relationship = + buzz_db::authorization_invalidation::AuthorizationSelector::delegated_relationship( + relationship_id, + 2, + ) + .expect("second relationship revision is valid"); + assert_ne!( + first_relationship.fingerprint(), + second_relationship.fingerprint() + ); + } + #[test] fn observational_modes_never_mutate_the_public_projection() { use crate::authorization_runtime::finalization::AuthorizationMode; diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 53ae9437ea..9bc15e9e09 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -213,19 +213,27 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: &state, conn.tenant.community(), ); - let identity_proof = match crate::corporate_identity::verify_corporate_identity( - &state, - conn.tenant.community(), - pubkey, - conn.corporate_identity_assertion.as_ref(), - auth_tag_json.as_deref(), - ) - .await - { - Ok(proof) => Some(proof), - Err(e) => { - warn!(conn_id = %conn_id, error = ?e, "corporate identity denied"); - if identity_lane + let neutral_evidence = conn.corporate_identity_assertion.is_none() + && crate::authorization_runtime::transport::provider_evidence_resolver_is_installed( + &state, + conn.tenant.community(), + ); + let identity_proof = if neutral_evidence { + None + } else { + match crate::corporate_identity::verify_corporate_identity( + &state, + conn.tenant.community(), + pubkey, + conn.corporate_identity_assertion.as_ref(), + auth_tag_json.as_deref(), + ) + .await + { + Ok(proof) => Some(proof), + Err(e) => { + warn!(conn_id = %conn_id, error = ?e, "corporate identity denied"); + if identity_lane == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly { None @@ -238,6 +246,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: )); return; } + } } }; @@ -397,12 +406,19 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: info!(conn_id = %conn_id, "NIP-42 auth successful"); let transport_delegation = - crate::corporate_identity::verify_unconditional_nip_oa_owner( + crate::corporate_identity::verify_unconditional_nip_oa_relationship( pubkey, auth_tag_json.as_deref(), ) - .map(|owner| { - VerifiedDelegationOutput::from_workspace_verifier(owner, pubkey, None, true) + .map(|relationship| { + VerifiedDelegationOutput::from_workspace_verifier( + relationship.owner_pubkey(), + pubkey, + relationship.relationship_id(), + relationship.relationship_revision(), + None, + true, + ) }); let verified_proof: Arc = match VerifiedEvidenceAdapter::new() .verify_nip42( diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 8d4baa3a99..55658369be 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -19,7 +19,7 @@ use buzz_audit::AuditService; use buzz_auth::{AuthService, Nip98ReplayGuard, VerifiedFederatedAssertion, VerifiedNostrProof}; use buzz_core::tenant::TenantContext; use buzz_core::CommunityId; -use buzz_db::Db; +use buzz_db::{authorization_invalidation::AuthorizationSessionTarget, Db}; use buzz_media::MediaStorage; use buzz_pubsub::cache_invalidation::CacheInvalidation; use buzz_pubsub::conn_control::ConnControl; @@ -42,6 +42,8 @@ type ScopedRateLimiter = DashMap; /// Per-connection entry in the connection manager. struct ConnEntry { + /// Exact server-issued identity of this connection issuance. + authorization_session_target: AuthorizationSessionTarget, tx: mpsc::Sender, /// Control-frame sender, drained ahead of data and before cancel wins in /// the send loop. Used to deliver a ban-disconnect frame that must reach @@ -263,11 +265,18 @@ impl ConnectionManager { subscriptions: ConnectionSubscriptions, grace_limit: u8, ) { + let Ok(authorization_session_target) = + AuthorizationSessionTarget::new(conn_id, Uuid::new_v4()) + else { + cancel.cancel(); + return; + }; let drain_ctrl_tx = ctrl_tx.clone(); let drain_cancel = cancel.clone(); self.connections.insert( conn_id, ConnEntry { + authorization_session_target, tx, ctrl_tx, cancel, @@ -484,6 +493,16 @@ impl ConnectionManager { .and_then(|entry| entry.verified_nostr_proof.read().ok()?.clone()) } + /// Return the exact server-issued target for one live connection issuance. + pub fn authorization_session_target( + &self, + conn_id: Uuid, + ) -> Option { + self.connections + .get(&conn_id) + .map(|entry| entry.authorization_session_target) + } + /// Return current direct federated evidence recorded for a connection. pub fn federated_assertion_for_conn( &self, diff --git a/crates/buzz-relay/tests/fixtures/nip_fi_trusted_proxy.json b/crates/buzz-relay/tests/fixtures/nip_fi_trusted_proxy.json new file mode 100644 index 0000000000..affe64ec70 --- /dev/null +++ b/crates/buzz-relay/tests/fixtures/nip_fi_trusted_proxy.json @@ -0,0 +1,90 @@ +{ + "schema_version": 1, + "fixture_classification": "synthetic-only", + "full_stack_conformance_claim": false, + "presentation_gate": "disabled", + "trusted_proxy_cases": [ + { + "row": "TR-1.direct-bypass", + "origin_isolation_enforced": false, + "inbound_assertion_header_stripped": true, + "expected": "deny-before-verification" + }, + { + "row": "TR-1.inbound-header-copy", + "origin_isolation_enforced": true, + "inbound_assertion_header_stripped": false, + "expected": "deny-before-verification" + }, + { + "row": "TR-1.complete-deployment-evidence", + "origin_isolation_enforced": true, + "inbound_assertion_header_stripped": true, + "expected": "eligible-for-provider-verification" + } + ], + "nip_fi_rows": [ + { + "row": "AS-3.future-iat", + "owner": "o4-client-status", + "status": "covered" + }, + { + "row": "BD-1.cross-domain", + "owner": "authorization-runtime", + "status": "required-before-full-stack-claim" + }, + { + "row": "SE-4.invalidation", + "owner": "invalidation-runtime", + "status": "required-before-full-stack-claim" + }, + { + "row": "DG-3.no-finite-bound", + "owner": "delegation-runtime", + "status": "required-before-full-stack-claim" + }, + { + "row": "OP-2.discovery", + "owner": "o4-client-status", + "status": "covered" + }, + { + "row": "OP-3.absent", + "owner": "o4-client-status", + "status": "covered" + }, + { + "row": "OP-3.implemented", + "owner": "o4-client-status", + "status": "disabled-pending-approved-rfc-presentation-gate" + }, + { + "row": "OP-4.privacy", + "owner": "o4-client-status", + "status": "covered" + } + ], + "j3c_rows": [ + "J3C-STATUS-RELAY-SIGNER", + "J3C-STATUS-EXACT-SCOPE", + "J3C-STATUS-FRESHNESS", + "J3C-STATUS-REVISION-FOLD", + "J3C-STATUS-WITHDRAWAL", + "J3C-STATUS-PRIVACY", + "J3C-STATUS-VERIFY-ONLY", + "J3C-STATUS-DEDICATED-TRANSPORT", + "J3C-STATUS-REAL-USER-HIDDEN" + ], + "forbidden_public_fields": [ + "iss", + "sub", + "display_name", + "bearer_assertion", + "private_audience", + "provider_tenant_url", + "corporate_history", + "historical_label", + "employment_history" + ] +} diff --git a/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs b/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs new file mode 100644 index 0000000000..95b708e7a8 --- /dev/null +++ b/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs @@ -0,0 +1,409 @@ +//! Structural O4 conformance checks for the disabled client-status surface. + +use std::fs; +use std::path::{Path, PathBuf}; + +const FIXTURE: &str = include_str!("fixtures/nip_fi_trusted_proxy.json"); +const STATUS_MODULE: &str = include_str!("../src/authorization_runtime/status.rs"); +const ASSERTION_VERIFIER: &str = include_str!("../src/corporate_identity.rs"); +const INVALIDATION_RUNTIME: &str = include_str!("../src/authorization_runtime/invalidation.rs"); +const TRANSPORT_RUNTIME: &str = include_str!("../src/authorization_runtime/transport.rs"); +const FINALIZATION_RUNTIME: &str = include_str!("../src/authorization_runtime/finalization.rs"); +const PRODUCTION_RUNTIME: &str = include_str!("../src/authorization_runtime/production.rs"); +const AUTH_HANDLER: &str = include_str!("../src/handlers/auth.rs"); +const KIND_REGISTRY: &str = include_str!("../../buzz-core/src/kind.rs"); +const INGEST_HANDLER: &str = include_str!("../src/handlers/ingest.rs"); + +#[test] +fn mandatory_o4_security_contracts_are_present() { + let cases = [ + ( + "protected-header-denial", + ASSERTION_VERIFIER.contains("headers.get_all(config.jwt_header.as_str())") + && ASSERTION_VERIFIER.contains("values.next().is_some()") + && ASSERTION_VERIFIER.contains("raw.contains(',')"), + ), + ( + "bounded-known-key-jwks-degradation", + ASSERTION_VERIFIER.contains("JWKS_CACHE_MAX_AGE") + && ASSERTION_VERIFIER.contains("buzz_jwks_stale_key_uses_total") + && ASSERTION_VERIFIER.contains("buzz_jwks_unknown_kid_total") + && ASSERTION_VERIFIER.contains("buzz_jwt_verification_errors_total"), + ), + ( + "lease-expiry-and-invalidation", + TRANSPORT_RUNTIME.contains("expiry_delay") + && INVALIDATION_RUNTIME.contains("cancel_invalid(community_id)"), + ), + ( + "revocation-enforcement-timing", + INVALIDATION_RUNTIME.contains("buzz_authorization_revocation_to_enforcement_seconds"), + ), + ( + "client-status-fail-closed-degradation", + AUTH_HANDLER.contains("client_status_unavailable") + && AUTH_HANDLER.contains("buzz_client_status_degradation_total"), + ), + ( + "deny-protected-request-renewal-and-session-eviction", + FINALIZATION_RUNTIME.contains("DenyProtected") + && PRODUCTION_RUNTIME + .contains("\"deny_protected\" => AuthorizationMode::DenyProtected") + && TRANSPORT_RUNTIME.contains("AuthorizationMode::DenyProtected =>") + && TRANSPORT_RUNTIME.contains("deny_protected_request(request)") + && TRANSPORT_RUNTIME.contains("request.cancellation()") + && TRANSPORT_RUNTIME.contains("cancellation.cancel()") + && TRANSPORT_RUNTIME.contains("ProtectedTransportError::DenyProtected"), + ), + ]; + + for (name, present) in cases { + assert!(present, "missing mandatory O4 security contract: {name}"); + } + + for source in [ASSERTION_VERIFIER, INVALIDATION_RUNTIME, AUTH_HANDLER] { + let observability = source + .lines() + .filter(|line| line.contains("metrics::")) + .collect::>() + .join("\n"); + for forbidden in [ + "identity_token =", + "access_token =", + "refresh_token =", + "uid =", + "subject =", + "kid =", + ] { + assert!( + !observability.contains(forbidden), + "security observability gained a private label: {forbidden}" + ); + } + } +} + +#[test] +fn synthetic_fixture_names_required_nip_fi_and_j3c_rows() { + let fixture: serde_json::Value = serde_json::from_str(FIXTURE).expect("fixture JSON parses"); + assert_eq!(fixture["fixture_classification"], "synthetic-only"); + assert_eq!(fixture["full_stack_conformance_claim"], false); + assert_eq!(fixture["presentation_gate"], "disabled"); + + for row in [ + "TR-1.direct-bypass", + "TR-1.inbound-header-copy", + "TR-1.complete-deployment-evidence", + "AS-3.future-iat", + "BD-1.cross-domain", + "SE-4.invalidation", + "DG-3.no-finite-bound", + "OP-2.discovery", + "OP-3.absent", + "OP-3.implemented", + "OP-4.privacy", + "J3C-STATUS-RELAY-SIGNER", + "J3C-STATUS-EXACT-SCOPE", + "J3C-STATUS-FRESHNESS", + "J3C-STATUS-REVISION-FOLD", + "J3C-STATUS-WITHDRAWAL", + "J3C-STATUS-PRIVACY", + "J3C-STATUS-VERIFY-ONLY", + "J3C-STATUS-DEDICATED-TRANSPORT", + "J3C-STATUS-REAL-USER-HIDDEN", + ] { + assert!(FIXTURE.contains(row), "fixture omitted required row {row}"); + } + + let future_iat = fixture["nip_fi_rows"] + .as_array() + .expect("NIP-FI rows are an array") + .iter() + .find(|row| row["row"] == "AS-3.future-iat") + .expect("future-iat allocation exists"); + assert_eq!(future_iat["owner"], "o4-client-status"); + assert_eq!(future_iat["status"], "covered"); + + let projection = fixture["nip_fi_rows"] + .as_array() + .expect("NIP-FI rows are an array") + .iter() + .find(|row| row["row"] == "OP-3.implemented") + .expect("implemented projection allocation exists"); + assert_eq!( + projection["status"], + "disabled-pending-approved-rfc-presentation-gate" + ); +} + +#[test] +fn optional_iat_uses_the_shared_injected_authorization_clock() { + assert!(ASSERTION_VERIFIER.contains("self.authorization_clock.now()?")); + assert!(ASSERTION_VERIFIER.contains("validate_optional_iat(")); + assert!( + !ASSERTION_VERIFIER + .contains("validate_optional_iat(&decoded.claims.claims, Timestamp::now().as_secs())"), + "optional iat must not bypass the injected authorization clock" + ); +} + +#[test] +fn client_authored_status_is_rejected_by_the_central_relay_only_fence() { + let relay_only_predicate = KIND_REGISTRY + .split("pub const fn is_relay_only_kind") + .nth(1) + .and_then(|suffix| suffix.split("/// Extract the kind").next()) + .expect("central relay-only predicate exists"); + assert!(relay_only_predicate.contains("KIND_CLIENT_BINDING_STATUS")); + assert!(INGEST_HANDLER.contains("buzz_core::kind::is_relay_only_kind(kind_u32)")); + assert!(INGEST_HANDLER.contains("restricted: relay-only kind")); +} + +#[test] +fn public_projection_retirement_is_durable_internal_and_not_operator_wired() { + let production = ASSERTION_VERIFIER + .split("#[cfg(test)]") + .next() + .expect("production assertion verifier exists"); + let migration = + include_str!("../../../migrations/0045_identity_public_projection_retirement.sql"); + assert!(migration.contains("identity_public_projection_retirements")); + assert!(migration.contains("source_binding_id")); + assert!(migration.contains("source_binding_version")); + for private in [ + "issuer TEXT", + "uid", + "display_name", + "actor", + "reason", + "identity_token", + "access_token", + "refresh_token", + ] { + assert!( + !migration.contains(private), + "projection retirement state gained private field {private}" + ); + } + assert!( + production.contains("begin_active_public_projection"), + "active projection publication must revalidate the exact binding at its database boundary" + ); + let production_runtime = include_str!("../src/authorization_runtime/production.rs"); + assert!( + production_runtime.contains("reconcile_public_projection_retirements_startup"), + "committed lifecycle retirement must be reconciled before protected runtime installation" + ); + assert!( + production_runtime.contains("run_public_projection_retirement_reconciliation"), + "committed lifecycle retirement needs durable periodic/restart reconciliation" + ); + for operator_surface in [ + include_str!("../src/api/operator.rs"), + include_str!("../src/api/bridge.rs"), + ] { + assert!(!operator_surface.contains("PublicProjectionRetirement")); + } +} + +#[test] +fn trusted_proxy_fixture_fails_closed_without_both_deployment_controls() { + let fixture: serde_json::Value = serde_json::from_str(FIXTURE).expect("fixture JSON parses"); + let cases = fixture["trusted_proxy_cases"] + .as_array() + .expect("trusted proxy cases are an array"); + assert_eq!(cases.len(), 3); + + for case in cases { + let isolation = case["origin_isolation_enforced"] + .as_bool() + .expect("fixture isolation flag is boolean"); + let stripping = case["inbound_assertion_header_stripped"] + .as_bool() + .expect("fixture stripping flag is boolean"); + let expected = case["expected"] + .as_str() + .expect("fixture expectation is a string"); + if isolation && stripping { + assert_eq!(expected, "eligible-for-provider-verification"); + } else { + assert_eq!(expected, "deny-before-verification"); + } + } +} + +#[test] +fn verification_only_adapter_has_no_authority_storage_or_pubsub_dependency() { + let production = STATUS_MODULE + .split("#[cfg(test)]") + .next() + .expect("production status module exists"); + for forbidden in [ + "AuthContext", + "AuthorizationLease", + "CapabilitySet", + "AuthState", + "buzz_db", + "buzz_pubsub", + "publish_event", + "store_event", + "KIND_USER_TRUSTED_ASSERTION", + "corporate_identity", + ] { + assert!( + !production.contains(forbidden), + "verification-only status gained forbidden authority path {forbidden}" + ); + } + + assert_eq!( + production + .matches("pub struct ClientStatusPresentationPermit {") + .count(), + 1, + "the disabled presentation permit must have one opaque definition" + ); + assert!(production.contains("impl ClientStatusPresentationPermit")); + assert!(production.contains("pub fn from_complete_stack(")); + assert!(production.contains("reviewed_implementation_revision")); + assert!(production.contains("presentation_gate_passed")); + assert!(production.contains("dedicated_client_contract_passed")); + assert!( + !production.contains("std::env"), + "presentation must not be enabled by an environment boolean" + ); + + let current_issuance = production + .split("pub fn issue_verification_only") + .nth(1) + .and_then(|suffix| suffix.split("/// Sign a generic withdrawal").next()) + .expect("current-display issuance method exists"); + assert!( + current_issuance.contains("&ClientStatusPresentationPermit"), + "current-display signing must require the complete-stack permit" + ); + assert_eq!( + production + .matches("issue_current(&evidence, label)") + .count(), + 1, + "no second production current-display signing path may bypass the permit" + ); +} + +#[test] +fn status_uses_only_the_dedicated_authenticated_production_path() { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let repo = manifest + .parent() + .and_then(Path::parent) + .expect("relay crate is nested under repository crates directory"); + let roots = [ + manifest.join("src/handlers"), + manifest.join("src/api"), + manifest.join("src/main.rs"), + manifest.join("src/router.rs"), + manifest.join("src/subscription.rs"), + manifest.join("src/connection.rs"), + manifest.join("src/protocol.rs"), + repo.join("desktop/src"), + repo.join("desktop/src-tauri/src"), + repo.join("mobile/lib"), + repo.join("web/src"), + ]; + + for root in roots { + for file in source_files(&root) { + if file.ends_with("src/handlers/auth.rs") { + continue; + } + let source = fs::read_to_string(&file).expect("source file is readable"); + for forbidden in [ + "KIND_CLIENT_BINDING_STATUS", + "ClientBindingStatus", + "client_binding_status", + "24244", + "deliver_verification_only", + ] { + assert!( + !source.contains(forbidden), + "{} exposes status through an ordinary route {forbidden}", + file.display() + ); + } + } + } + + let handler = fs::read_to_string(manifest.join("src/handlers/auth.rs")) + .expect("AUTH handler source is readable"); + let state = fs::read_to_string(manifest.join("src/state.rs")) + .expect("application state source is readable"); + let status = fs::read_to_string(manifest.join("src/authorization_runtime/status.rs")) + .expect("status runtime source is readable"); + let nip11 = + fs::read_to_string(manifest.join("src/nip11.rs")).expect("NIP-11 source is readable"); + let router = + fs::read_to_string(manifest.join("src/router.rs")).expect("router source is readable"); + assert!(handler.contains("present_after_auth")); + assert!(state.contains("install_client_status_runtime")); + assert!(state.contains("install_nip_fi_discovery")); + assert!(status.contains("from_complete_stack")); + assert!(status.contains("__buzz_client_binding_status_v1__")); + assert!(nip11.contains("state.nip_fi_discovery()")); + assert!(nip11.contains("with_conformant_federated_identity")); + assert!(router.contains("nip11_document(&state, raw_host).await")); + assert!(!status.contains("std::env")); +} + +#[test] +fn neutral_verified_evidence_is_exactly_one_and_reachable_in_production() { + assert!(TRANSPORT_RUNTIME.contains("trait VerifiedProviderEvidenceResolver")); + assert!(TRANSPORT_RUNTIME.contains("VerifiedProviderEvidenceResolution::Absent")); + assert!(TRANSPORT_RUNTIME.contains("VerifiedProviderEvidenceResolution::One(evidence)")); + assert!(TRANSPORT_RUNTIME.contains("VerifiedProviderEvidenceResolution::Ambiguous")); + assert!(TRANSPORT_RUNTIME.contains("ConflictingProviderEvidence")); + assert!(TRANSPORT_RUNTIME.contains("provider_evidence_resolver_is_installed")); + assert!(PRODUCTION_RUNTIME.contains("install_from_environment_with_providers_and_evidence")); + assert!(PRODUCTION_RUNTIME.contains("normalized_provider_evidence")); + assert!(PRODUCTION_RUNTIME.contains("provider_evidence_missing")); + assert!(PRODUCTION_RUNTIME.contains("provider_evidence_invalid")); + + for route in [ + include_str!("../src/api/bridge.rs"), + include_str!("../src/api/invites.rs"), + include_str!("../src/api/media.rs"), + include_str!("../src/api/git/transport.rs"), + include_str!("../src/audio/handler.rs"), + AUTH_HANDLER, + ] { + assert!( + route.contains("provider_evidence_resolver_is_installed"), + "protected route omitted the neutral evidence seam" + ); + } +} + +fn source_files(root: &Path) -> Vec { + if root.is_file() { + return vec![root.to_path_buf()]; + } + let mut pending = vec![root.to_path_buf()]; + let mut files = Vec::new(); + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(&directory).expect("source directory is readable") { + let path = entry.expect("source directory entry is readable").path(); + if path.is_dir() { + pending.push(path); + } else if path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + matches!(extension, "rs" | "ts" | "tsx" | "js" | "jsx" | "dart") + }) + { + files.push(path); + } + } + } + files +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index da80c5b07a..95c7258767 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1037,6 +1037,24 @@ dependencies = [ "webbrowser", ] +[[package]] +name = "buzz-auth" +version = "0.1.0" +dependencies = [ + "buzz-core", + "hex", + "nostr", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid", +] + [[package]] name = "buzz-core" version = "0.1.0" @@ -1156,6 +1174,7 @@ version = "0.1.0" dependencies = [ "axum", "blurhash", + "buzz-auth", "buzz-core", "bytes", "chrono", diff --git a/docs/NIP_FI_RUNTIME_OPERATIONS.md b/docs/NIP_FI_RUNTIME_OPERATIONS.md new file mode 100644 index 0000000000..f9f1a1fd57 --- /dev/null +++ b/docs/NIP_FI_RUNTIME_OPERATIONS.md @@ -0,0 +1,152 @@ +# NIP-FI runtime operations + +This runbook covers provider-neutral NIP-FI session/discovery behavior and the +separate disabled relay-authenticated client-status contract. It does not +authorize enabling a provider, a client presentation surface, or a conformance +claim. + +## Session and reconnect behavior + +For WebSocket authorization, the assertion belongs on the upgrade request and +fresh NIP-42 proof follows on that connection. A direct lease ends at the +earliest assertion, binding, policy, or implementation bound. Base V1 has no +in-connection assertion renewal: expiry requires a new connection, a fresh +upgrade assertion, and fresh NIP-42 proof. + +Delegated sessions require a separately validated delegation, an active owner +binding, and a positive finite configured implementation maximum. A cached +owner lease is not substitute authority. Reconnect requires fresh delegate +proof and revalidation of every dependency. + +When an observed binding, identity, key, policy, or delegation dependency +becomes invalid, reject protected operations or close the affected connection +within the documented detection bound. A polling deployment must publish its +maximum detection latency and must not claim immediate revocation. + +The optional assertion `iat` check uses the shared injected authorization +clock. The current JWT library still evaluates `exp` and `nbf` with its own +system-clock source, so operators must maintain host clock synchronization and +must not claim fully centralized assertion time until that library boundary is +made injectable. + +`iat` is optional in Base V1. When present, a malformed or more-than-60-second +future value is rejected. A `kid` absent from a still-fresh JWKS set is denied +without an immediate refetch; the default set lifetime is 300 seconds. Issuers +must overlap old and new signing keys for at least the cache lifetime plus the +documented clock allowance. Refresh after expiry is single-flight, and refresh +or issuer failure never falls back to an unverified key. + +The assertion header is singular. Multiple field lines, a comma-combined +value, invalid UTF-8, or an empty value is denied before verification. A +trusted-proxy adapter must prove origin isolation and that it stripped every +inbound copy before injecting exactly one assertion; merely observing the +configured header is not transport provenance. + +Protected media downloads include both `GET` and `HEAD`. An enforcing domain +cannot expose either method without current authority; a deployment that wants +public media needs a separately reviewed public-media policy rather than an +implicit read bypass. + +Client status is presentation-only. It expires independently of an +authorization lease and is cleared on expiry, disconnect, relay-key change, +domain change, or author change. A status cannot renew a session, authorize an +operation, create a binding, mint a lease, or mutate membership. + +## Upgrade sequence + +1. Upgrade and reconcile durable authorization, binding, lifecycle, lease, and + status-revision-floor state before enabling any behavior. +2. Deploy servers with NIP-FI discovery absent and the client-status + presentation gate disabled. +3. Run every applicable NIP-FI row against the exact candidate revision. For + `trusted-proxy`, attach enforced origin-isolation evidence plus negative + direct-bypass and inbound-header-copy tests. +4. Confirm mixed-version servers all omit discovery. Never advertise based on + a per-process flag or a partial fleet. +5. Supply a complete-stack conformance input only after the whole serving fleet + runs the reviewed revision and all applicable rows pass. +6. Supply the typed client-presentation approval only after its deployment, + privacy, and client-compatibility gates pass at one exact revision. The + stock binary has no environment or boolean shortcut; without that injected + proof it cannot construct the presentation permit or install the dedicated + exact-connection transport. + +Old clients ignore unknown status events, and old servers emit none. New +clients must default to no indicator when status is absent, invalid, expired, +withheld, or unsupported. NIP-FI authorization behavior must remain identical +whether client presentation code is present or absent. + +## Rollback + +Remove the complete-stack readiness input before or with the first server +rollback so NIP-11 immediately omits NIP-FI discovery. Do not leave discovery +enabled for a mixed or unreviewed fleet. + +Client-status rollback requires no authority migration: the events are +ephemeral and display-only. Disconnect affected clients or wait no longer than +the bounded status lifetime; clients clear on either condition. Never translate +a cached status into an authorization decision during rollback. + +Preserve durable authorization and lifecycle state. Preserve and reconcile the +status revision floor so a restored older process cannot emit a lower revision +that a client might mistake for current state. If that state is unavailable, +emit no status. + +## Public projection retirement + +The privacy-approved NIP-85 label projection is optional and never authority. +After an authoritative revoke or rotate commits, the lifecycle integration must +derive internal retirement work from the committed lifecycle record. The work +contains only the server-resolved domain, old public Nostr key, relay author, +operation identifier, and opaque binding generation. It must not contain +issuer, subject, display label, provider claims, actor, or free-text reason. +The reconciler idempotently replaces an active projection with the existing +inactive, label-free parameterized event. + +A read, clock, build, or write failure must not roll back the already committed +lifecycle mutation. Retry the same domain/key request. If a write committed but +its acknowledgement was lost, the retry observes the inactive replacement and +terminates without another write. + +The runtime materializes committed revoke/rotate operations into an internal +durable queue, fences active publication and retirement with the exact binding +generation, and drains unfinished projection and delivery work before protected +runtime installation and after restart. Periodic discovery is the crash-window +backstop. The active projection TTL remains defense in depth. Authenticated +lifecycle routes and durable operator audit remain separately owned. + +## Backup and restore + +Back up authoritative binding/lifecycle state, policy state, cryptographic +secrets required by deployment policy, and durable status revision/floor state +using the owning subsystem's procedure. Protect the dedicated client-status +privacy key as a secret and never reuse it across unrelated deployments. + +Do not back up or restore: + +- authorization or provider caches; +- direct or delegated leases; +- WebSocket connection state; +- client presentation caches; +- emitted client-status events; or +- ordinary event/pubsub copies of client status, because none may exist. + +After restore, start with discovery absent and presentation disabled. Rebuild +authorization decisions from authoritative state, reconcile revision floors, +reconcile committed projection retirements, and reconnect clients with fresh +assertions/proofs. If the relay signing key or client-status privacy key +changed, treat every old presentation as invalid. A restored service must +complete same-revision conformance again before discovery can return. + +## Privacy and observability + +Logs, metrics, traces, fixtures, and NIP-11 output must not contain raw bearer +assertions or unredacted issuer, subject, audience, tenant URL, claim name, +display name, email, or provider-private metadata. Use bounded categorical +failure classes and pseudonymous correlation where necessary. + +Alert on aggregate validation failures, revision-source unavailability, +dedicated-transport unavailability after a future gate is approved, and +dependency-invalidation lag. Do not include the rejected private value in an +alert. The presence or absence of a client status is not evidence of access and +must never drive an authorization SLO. diff --git a/docs/nips/NIP-FI-RUNTIME-CONFORMANCE.md b/docs/nips/NIP-FI-RUNTIME-CONFORMANCE.md new file mode 100644 index 0000000000..0995f9eb57 --- /dev/null +++ b/docs/nips/NIP-FI-RUNTIME-CONFORMANCE.md @@ -0,0 +1,166 @@ +# NIP-FI runtime conformance and client-status boundary + +This document maps Buzz runtime evidence to the normative +[NIP-FI specification](NIP-FI.md), [formal model](NIP-FI-MODEL.md), and +[conformance matrix](NIP-FI-CONFORMANCE.md). It does not make a conformance +claim. Discovery remains absent until an injected report proves that every +applicable row passed against one reviewed implementation revision. + +## Discovery gate + +`RelayInfo::build` omits both `limitation.federated_identity` and the top-level +`federated_identity` object. `ConformanceReadyNipFiDiscovery` is the only API +that can add them. It requires all of the following: + +- a provider-neutral discovery value with at least one unique supported + transport and exactly one enrollment mode; +- a positive finite delegated-lease maximum whenever delegation is advertised; +- an exact 40-character reviewed Git revision; +- an injected complete-stack result asserting that every applicable row passed + at that same revision; and +- for `trusted-proxy`, deployment evidence for both origin isolation and + stripping untrusted inbound assertion-header copies. + +The reviewed revision and deployment evidence are gate inputs, not public +metadata. NIP-11 exposes no issuer URL, tenant URL, claim name, subject, +audience, assertion header name, or provisional NIP number. Unsupported +behavior is omitted rather than advertised as partially implemented. + +The assertion header is singular at every ingress. Repeated field lines, +comma-combined values, invalid UTF-8, and empty values fail closed. An installed +adapter must supply verified transport provenance; header presence alone is +never classified as `trusted-proxy`. Protected media `GET` and `HEAD` remain +inside the enforcing transport inventory unless a separate reviewed public +media policy is selected. + +The optional assertion `iat` check uses the shared injected authorization +clock and accepts a missing claim. A present value must be an unsigned integer +no later than injected verifier time plus the bounded 60-second skew. The +current `jsonwebtoken` dependency still evaluates `exp` and `nbf` against its +own system-clock source. Therefore `AS-3.future-iat` is covered, but this +candidate does not claim that all assertion-time checks use one injected clock; +that inherited limitation remains part of the full-stack review. + +## Relay-authenticated client status + +Kind `24244` is a Buzz-local, short-lived presentation contract, not NIP-FI +authorization evidence or a NIP-FI conformance surface. A status is signed by +the trusted relay and scoped to an exact server-resolved authorization domain +and event-author key. A current status contains a binding version, a +privacy-keyed policy revision, a monotonic durable status revision, and a +bounded validity window. A withdrawal contains only its exact scope, revision, +and bounded validity window. The two wire states are: + +- `display_current`; or +- `withdrawn`, with no lifecycle cause or historical binding fields. + +Clients fold only within one trusted relay/domain/author scope. A lower +revision is rejected. An equal revision is idempotent only for the identical +signed event; a conflicting equal revision is rejected. Expiry, disconnect, +relay-key change, authorization-domain change, or event-author change clears +presentation. Revision high-water state may survive a transient disconnect, +but it is never authority. + +The relay adapter is one-way from `VerificationOnlyDisposition` to a signed +event. It has no dependency on authorization leases, membership mutation, +event ingest, persistence, subscriptions, pub/sub, ordinary delivery, or +NIP-85. The production seam targets an exact authenticated connection and can +construct its permit only from typed evidence that the RFC presentation, +privacy, and dedicated-client gates passed at one exact reviewed revision. The +stock binary supplies no such evidence, key, or transport, so status remains +disabled by default. + +The optional label constructor accepts only privacy-approved server +configuration. There is no constructor from issuer data, subject data, +`display_name`, mutable profile content, or provider decisions. The policy +revision is a length-framed, domain-separated HMAC under an injected dedicated +client-status privacy key; identical provider values are unlinkable under +distinct keys. + +## Stable row allocation + +The synthetic fixture is +`crates/buzz-relay/tests/fixtures/nip_fi_trusted_proxy.json`. It contains no +production issuer, subject, domain, key, assertion, or tenant data. + +| Row | Evidence in this lane | Full-stack state | +|---|---|---| +| `TR-1.direct-bypass` | Negative origin-isolation fixture | Deployment proof still required | +| `TR-1.inbound-header-copy` | Negative header-copy fixture | Deployment proof still required | +| `TR-1.complete-deployment-evidence` | Positive two-control fixture shape | Real enforced-control evidence still required | +| `AS-3.future-iat` | Optional assertion `iat` accepts absence and bounded skew; malformed or farther-future values fail closed. Status also rejects future issue time | Covered by O4 | +| `BD-1.cross-domain` | Status validation and folding reject cross-domain scope | Authorization-runtime row must pass at the reviewed revision | +| `SE-4.invalidation` | Withdrawal and client clearing are covered | Lease invalidation runtime must pass at the reviewed revision | +| `DG-3.no-finite-bound` | Discovery cannot represent delegation without a positive bound | Delegation authorization must pass at the reviewed revision | +| `OP-2.discovery` | Default omission, provider-neutral fields, and complete-stack gate | Covered here; final claim still requires all rows | +| `OP-3.absent` | No real-user route or ordinary delivery path | Covered | +| `OP-3.implemented` | Dedicated exact-connection production seam exists behind typed complete-stack approval | Disabled unless the approval, privacy key, transport, and runtime are explicitly installed | +| `OP-4.privacy` | Keyed revision, bounded configured label, field/source scans | Covered | + +The local client-status rows are: + +- `J3C-STATUS-RELAY-SIGNER` +- `J3C-STATUS-EXACT-SCOPE` +- `J3C-STATUS-FRESHNESS` +- `J3C-STATUS-REVISION-FOLD` +- `J3C-STATUS-WITHDRAWAL` +- `J3C-STATUS-PRIVACY` +- `J3C-STATUS-VERIFY-ONLY` +- `J3C-STATUS-DEDICATED-TRANSPORT` +- `J3C-STATUS-REAL-USER-HIDDEN` + +These J3C rows test presentation safety only. They cannot substitute for any +NIP-FI authorization, lifecycle, session, delegation, or deployment row. + +## Public projection retirement join + +The existing opt-in NIP-85 label projection is separate from both NIP-FI +authorization and kind `24244` client status. Its active assertion is TTL +bounded, but a committed revoke or rotate also needs an inactive parameterized +replacement for the old public key. + +The public-projection retirement reconciler is a provider-neutral post-commit +seam. It derives private retry work from committed lifecycle rows and persists +only public event coordinates plus opaque binding generations. The relay reads +the exact relay-authored projection and, when active, writes the existing +`active=false`, `expiration=0`, label-free replacement. A missing or already +inactive projection is an idempotent terminal result. Store or clock failure +leaves both lifecycle authority and the active projection unchanged while the +work remains retryable. + +Active publication and retirement share the identity-key and parameterized +event commit boundaries. Server-only head metadata prevents a delayed rotation +job from retiring a later legitimate use of the same key. Startup and periodic +reconciliation provide restart recovery and Redis/local delivery retry. This +lane does not add authenticated lifecycle endpoints or durable operator audit. + +## Compatibility cases + +| Case | Required result | +|---|---| +| Old relay, new client | No discovery or status; client shows no indicator | +| New relay, old client | Unknown ephemeral status is ignored; ordinary event behavior is unchanged | +| Mixed relay fleet before complete conformance | Discovery stays absent; presentation stays disabled | +| Stale client cache | Expired status is cleared; lower or conflicting revisions cannot restore it | +| Spoofed user event | Wrong signer, kind, tags, content, or signature is rejected | +| Cross-domain replay | Exact expected domain and author mismatch is rejected; scope change clears state | +| Relay signing-key rotation | Old presentation is cleared and the new relay key must be trusted independently | +| Privacy-key rotation | Policy revision changes; it grants no authority and clients accept it only at a higher durable status revision | +| Provider or lifecycle outage | Relay issues an opaque `withdrawn` status only with authoritative revision evidence, otherwise emits nothing | +| Gate disabled | No presentation runtime is installed; no real-user status is delivered | + +## Mechanical checks + +Run from the repository root in the Hermit environment: + +```sh +cargo test -p buzz-core client_binding_status +cargo test -p buzz-relay authorization_runtime::status +cargo test -p buzz-relay nip11 +cargo test -p buzz-relay --test nip_fi_runtime_conformance +``` + +The integration test scans ordinary relay ingest, API, router, state, +subscription, connection, and protocol sources, plus desktop, mobile, and web +client sources. Any reference to the status kind, contract, or disabled +delivery method fails the test. diff --git a/migrations/0046_authorization_delegated_relationship_selector.sql b/migrations/0046_authorization_delegated_relationship_selector.sql new file mode 100644 index 0000000000..69e4ad85b5 --- /dev/null +++ b/migrations/0046_authorization_delegated_relationship_selector.sql @@ -0,0 +1,19 @@ +-- Add the exact delegated-relationship selector introduced by the O4 +-- trusted-evidence contract repair. Existing selector fingerprints and floors +-- remain byte-for-byte unchanged. + +ALTER TABLE authorization_invalidation_floors + DROP CONSTRAINT authorization_invalidation_floors_selector_kind_check; + +ALTER TABLE authorization_invalidation_floors + ADD CONSTRAINT authorization_invalidation_floors_selector_kind_check + CHECK (selector_kind IN ( + 'principal_fingerprint', + 'nostr_key', + 'binding', + 'session', + 'domain', + 'policy_version', + 'delegated_owner', + 'delegated_relationship' + )); diff --git a/schema/schema.sql b/schema/schema.sql index 95f616d918..9802d8d9ad 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -587,7 +587,8 @@ CREATE TABLE authorization_invalidation_floors ( 'session', 'domain', 'policy_version', - 'delegated_owner' + 'delegated_owner', + 'delegated_relationship' )), selector_fingerprint BYTEA NOT NULL CHECK (length(selector_fingerprint) = 32), generation BIGINT NOT NULL CHECK (generation > 0),