Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 33 additions & 1 deletion crates/buzz-auth/src/evidence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,38 @@ impl SealedTransportEvidence {
transport: ProofTransport,
proxy_expires_at: DateTime<Utc>,
authenticated_client_peer: AuthenticatedClientPeer,
) -> Self {
Self::for_test_with_nonce(
authorization_domain,
assertion,
method,
authority,
path_and_query,
body_digest,
transport,
proxy_expires_at,
authenticated_client_peer,
[0x91; 32],
)
}

/// Construct test evidence with an explicit opaque replay identity.
///
/// This permits independent connections in one domain to exercise the
/// production nonce ledger without deleting or bypassing prior claims.
#[cfg(any(test, feature = "dev"))]
#[allow(clippy::too_many_arguments)]
pub fn for_test_with_nonce(
authorization_domain: CommunityId,
assertion: impl Into<Box<str>>,
method: &[u8],
authority: &[u8],
path_and_query: &[u8],
body_digest: [u8; 32],
transport: ProofTransport,
proxy_expires_at: DateTime<Utc>,
authenticated_client_peer: AuthenticatedClientPeer,
nonce_claim: [u8; 32],
) -> Self {
let assertion = assertion.into();
let assertion_digest: [u8; 32] = Sha256::digest(assertion.as_bytes()).into();
Expand All @@ -145,7 +177,7 @@ impl SealedTransportEvidence {
body_digest,
transport,
proxy_expires_at,
TrustedProxyNonceClaim::new([0x91; 32], proxy_expires_at),
TrustedProxyNonceClaim::new(nonce_claim, proxy_expires_at),
AssertionTransportProfile::TrustedProxyHmacV2,
Some(authenticated_client_peer),
)
Expand Down
19 changes: 19 additions & 0 deletions crates/buzz-auth/src/foundation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1060,6 +1060,25 @@ impl VerifiedFederatedAssertion {
(self.not_before, self.expires_at)
}

pub(crate) fn with_request_binding(
&self,
request_fingerprint: [u8; 32],
target_fingerprint: [u8; 32],
transport_context_fingerprint: [u8; 32],
) -> Option<Self> {
if request_fingerprint == [0; 32]
|| target_fingerprint == [0; 32]
|| transport_context_fingerprint == [0; 32]
{
return None;
}
let mut rebound = self.clone();
rebound.request_fingerprint = request_fingerprint;
rebound.target_fingerprint = target_fingerprint;
rebound.transport_context_fingerprint = transport_context_fingerprint;
Some(rebound)
}

#[cfg(test)]
pub(crate) fn with_test_attested_event_author(mut self, author: PublicKey) -> Self {
self.attested_event_author_pubkey = Some(author);
Expand Down
4 changes: 2 additions & 2 deletions crates/buzz-auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ pub use foundation::{
};
pub use nip42::{
generate_challenge, verify_nip42_authorization_proof, verify_nip42_binding_status_proof,
verify_nip42_event, Nip42AuthorizationProofError, Nip42BindingStatusCoordinates,
VerifiedBindingStatusProof,
verify_nip42_event, verify_nip42_session_event_proof, Nip42AuthorizationProofError,
Nip42BindingStatusCoordinates, VerifiedBindingStatusProof,
};
pub use nip98::{
verify_nip42_moderation_command_proof, verify_nip98_authorization_proof, verify_nip98_event,
Expand Down
71 changes: 68 additions & 3 deletions crates/buzz-auth/src/nip42.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ use uuid::Uuid;

use crate::error::AuthError;
use crate::foundation::{
ActiveLocalBinding, AuthorizationError, AuthorizationFinalizer, AuthorizationInput,
LocalAuthorizationPolicy, LocalBindingResolution, PreparedAuthorization, ProofTransport,
RouteCapability, VerifiedNostrProof,
ActiveLocalBinding, AuthContext as FinalizedAuthContext, AuthorizationError,
AuthorizationFinalizer, AuthorizationInput, LocalAuthorizationPolicy, LocalBindingResolution,
PreparedAuthorization, ProofTransport, RouteCapability, VerifiedFederatedAssertion,
VerifiedNostrProof,
};

/// Normalize a relay URL for comparison.
Expand Down Expand Up @@ -147,6 +148,60 @@ pub fn verify_nip42_authorization_proof(
.ok_or(Nip42AuthorizationProofError::InvalidBinding)
}

/// Bind one signed WebSocket EVENT to an already finalized NIP-42 session.
///
/// The session and assertion are origin-sealed values produced by canonical
/// AUTH. This verifier rechecks the submitted event signature and derives an
/// exact request/target binding without treating a reusable session grant as
/// the mutation itself. Gift wraps may use their protocol-defined ephemeral
/// envelope signer; every other event must be signed by the authenticated
/// actor.
pub fn verify_nip42_session_event_proof(
event: &Event,
session: &FinalizedAuthContext,
assertion: &VerifiedFederatedAssertion,
request_fingerprint: [u8; 32],
target_fingerprint: [u8; 32],
transport_context_fingerprint: [u8; 32],
) -> Result<(VerifiedFederatedAssertion, VerifiedNostrProof), Nip42AuthorizationProofError> {
buzz_core::verify_event(event).map_err(|_| Nip42AuthorizationProofError::InvalidBinding)?;
let now = Utc::now();
let (assertion_transport, _, _, _) = assertion.request_binding();
let (_, assertion_expires_at) = assertion.time_bounds();
let expires_at = assertion_expires_at.min(session.lease().expires_at());
if event.kind == Kind::Authentication
|| (event.pubkey != session.actor_pubkey() && event.kind != Kind::GiftWrap)
|| session.authorization_domain() != assertion.authorization_domain()
|| session.capability() != RouteCapability::MessagesRead
|| session.transport() != ProofTransport::Nip42
|| assertion_transport != ProofTransport::Nip42
|| assertion.attested_event_author_pubkey() != Some(session.actor_pubkey())
|| expires_at <= now
{
return Err(Nip42AuthorizationProofError::InvalidBinding);
}
let assertion = assertion
.with_request_binding(
request_fingerprint,
target_fingerprint,
transport_context_fingerprint,
)
.ok_or(Nip42AuthorizationProofError::InvalidBinding)?;
let proof = VerifiedNostrProof::from_verifier(
session.authorization_domain(),
session.actor_pubkey(),
ProofTransport::Nip42,
request_fingerprint,
target_fingerprint,
transport_context_fingerprint,
Some(*assertion.assertion_fingerprint()),
None,
expires_at,
)
.ok_or(Nip42AuthorizationProofError::InvalidBinding)?;
Ok((assertion, proof))
}

/// Exact server-derived coordinates for one opted-in status connection.
pub struct Nip42BindingStatusCoordinates {
authorization_domain: CommunityId,
Expand Down Expand Up @@ -227,6 +282,16 @@ impl Nip42BindingStatusCoordinates {
pub const fn target_fingerprint(&self) -> [u8; 32] {
self.target_fingerprint
}

/// Exact request, target, and transport fingerprints sealed into the
/// purpose-specific status proof.
pub const fn request_binding(&self) -> (&[u8; 32], &[u8; 32], &[u8; 32]) {
(
&self.request_fingerprint,
&self.target_fingerprint,
&self.transport_context_fingerprint,
)
}
}

impl std::fmt::Debug for Nip42BindingStatusCoordinates {
Expand Down
4 changes: 0 additions & 4 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,6 @@ pub const KIND_LONG_FORM: u32 = 30023;
/// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`.
/// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped.
pub const KIND_USER_STATUS: u32 = 30315;
/// NIP-85: relay-signed binding assertion about a user public key.
///
/// The relay authors the event and keys it by the subject public key in `d`.
pub const KIND_USER_TRUSTED_ASSERTION: u32 = 30382;
/// NIP-78 / NIP-RS: Per-client read state blob for cross-device read position sync.
/// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`.
/// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped.
Expand Down
22 changes: 22 additions & 0 deletions crates/buzz-db/src/authorization_admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,27 @@ impl AdmissionObject {
)
}

/// Bind one canonical AUTH mutation to its signed event and domain.
///
/// Binding-status presentation remains connection-local, so independent
/// connections for the same actor must not compete for one latest-wins
/// protected-object epoch.
pub fn binding_status_auth_event(
authorization_domain: CommunityId,
auth_event_id: [u8; 32],
) -> Option<Self> {
if authorization_domain.as_uuid().is_nil() || auth_event_id == [0; 32] {
return None;
}
Self::new(
AdmissionObjectKind::BindingStatus,
admission_framed_digest(
b"buzz:nip-fi:binding-status-auth-event:v1",
&[authorization_domain.as_uuid().as_bytes(), &auth_event_id],
),
)
}

const fn event_kind() -> AdmissionObjectKind {
AdmissionObjectKind::Event
}
Expand Down Expand Up @@ -4465,6 +4486,7 @@ mod tests {

const TEST_VERIFIER_ISSUER: &str = "https://verifier.example";
const TEST_VERIFIER_AUDIENCE: &str = "buzz-relay-test";

fn loopback_test_database_url() -> String {
format!(
"{}://{}:{}@{}:{}/{}",
Expand Down
46 changes: 31 additions & 15 deletions crates/buzz-db/src/authorization_resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,31 @@ async fn read_current_status_evidence(
if authority_epoch != protected_authority_epoch || fence != protected_fence {
return Err(AuthorizationResolverError::PolicyUnavailable);
}
let binding_id = row.try_get("binding_id").map_err(DbError::from)?;
let binding_version = database_u64(
row.try_get("binding_version").map_err(DbError::from)?,
"status binding version",
)?;
let policy_revision = database_u64(
row.try_get("policy_revision").map_err(DbError::from)?,
"status policy revision",
)?;
let invalidation_generation = database_u64(
row.try_get("current_generation").map_err(DbError::from)?,
"status invalidation generation",
)?;
let status_authority_epoch = invalidation_generation
.checked_add(1)
.ok_or(AuthorizationResolverError::PolicyUnavailable)?;
let status_fence = crate::client_status_delivery::derive_status_evidence_fence(
request.authorization_domain(),
request.event_author_pubkey(),
binding_id,
binding_version,
policy_revision,
invalidation_generation,
status_authority_epoch,
)?;
let observed_at: DateTime<Utc> = row.try_get("authoritative_now").map_err(DbError::from)?;
let mut fresh_until = observed_at + chrono::Duration::seconds(300);
for deadline in [
Expand All @@ -427,21 +452,12 @@ async fn read_current_status_evidence(
CanonicalCurrentBindingEvidence::new(
request.authorization_domain(),
request.event_author_pubkey(),
row.try_get("binding_id").map_err(DbError::from)?,
database_u64(
row.try_get("binding_version").map_err(DbError::from)?,
"status binding version",
)?,
database_u64(
row.try_get("policy_revision").map_err(DbError::from)?,
"status policy revision",
)?,
database_u64(
row.try_get("current_generation").map_err(DbError::from)?,
"status invalidation generation",
)?,
authority_epoch,
fence,
binding_id,
binding_version,
policy_revision,
invalidation_generation,
status_authority_epoch,
status_fence,
observed_at,
fresh_until,
)
Expand Down
19 changes: 15 additions & 4 deletions crates/buzz-db/src/client_status_delivery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1513,9 +1513,17 @@ async fn recheck_status_evidence_tx(
transaction: &mut Transaction<'_, Postgres>,
evidence: &CanonicalCurrentBindingEvidence,
authoritative_now: DateTime<Utc>,
) -> Result<bool> {
recheck_connection_status_evidence_tx(transaction, evidence, authoritative_now).await
}

async fn recheck_connection_status_evidence_tx(
transaction: &mut Transaction<'_, Postgres>,
evidence: &CanonicalCurrentBindingEvidence,
authoritative_now: DateTime<Utc>,
) -> Result<bool> {
let row = sqlx::query(
"SELECT binding.binding_id, binding.binding_version, binding.policy_revision, \
"SELECT binding.binding_id,binding.binding_version,binding.policy_revision, \
domain.current_generation \
FROM identity_bindings binding \
JOIN authorization_invalidation_domains domain \
Expand All @@ -1525,14 +1533,17 @@ async fn recheck_status_evidence_tx(
AND policy.policy_revision=binding.policy_revision \
WHERE binding.community_id=$1 AND binding.event_author_pubkey=$2 \
AND binding.binding_state=1 AND binding.lifecycle_revision=1 \
AND (binding.expires_at IS NULL OR binding.expires_at > transaction_timestamp()) \
AND (binding.expires_at IS NULL OR binding.expires_at > $3) \
AND policy.effective_at <= $3 \
AND (policy.expires_at IS NULL OR policy.expires_at > $3) \
AND NOT EXISTS (SELECT 1 FROM identity_enrollment_policies newer \
WHERE newer.community_id=binding.community_id \
AND newer.policy_revision > binding.policy_revision) \
FOR SHARE OF binding, domain, policy",
FOR SHARE OF binding,domain,policy",
)
.bind(evidence.authorization_domain().as_uuid())
.bind(evidence.event_author_pubkey().as_bytes())
.bind(authoritative_now)
.fetch_optional(&mut **transaction)
.await?;
let Some(row) = row else {
Expand Down Expand Up @@ -1565,7 +1576,7 @@ async fn recheck_status_evidence_tx(
}

#[allow(clippy::too_many_arguments)]
fn derive_status_evidence_fence(
pub(crate) fn derive_status_evidence_fence(
community_id: CommunityId,
author: PublicKey,
binding_id: Uuid,
Expand Down
Loading
Loading