Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

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

212 changes: 212 additions & 0 deletions crates/buzz-auth/src/blossom.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
//! Blossom kind:24242 authentication verification (BUD-11 compliant).

/// Blossom kind:24242 verbs Buzz currently accepts.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlossomVerb {
/// Authorize one blob upload.
Upload,
/// Authorize one blob download or a server-scoped download.
Get,
}

impl BlossomVerb {
fn as_str(self) -> &'static str {
match self {
Self::Upload => "upload",
Self::Get => "get",
}
}
}

/// Rejection from full Blossom operation verification.
#[derive(Debug, thiserror::Error)]
pub enum BlossomAuthError {
/// The Schnorr signature is invalid.
#[error("invalid signature")]
InvalidSignature,
/// The event is not kind 24242.
#[error("invalid auth event kind")]
InvalidAuthKind,
/// The event does not contain a human-readable description.
#[error("invalid auth event")]
InvalidAuthEvent,
/// The `t` tag does not name the required operation.
#[error("invalid auth verb")]
InvalidAuthVerb,
/// A required tag is absent.
#[error("missing required tag: {0}")]
MissingTag(&'static str),
/// The event has expired.
#[error("token expired")]
TokenExpired,
/// The event creation time is outside the accepted replay window.
#[error("timestamp out of window")]
TimestampOutOfWindow,
/// A server tag does not match the request-bound host.
#[error("server mismatch")]
ServerMismatch,
/// No `x` tag matches the exact blob hash.
#[error("hash mismatch")]
HashMismatch,
/// The event does not authorize the requested blob or server.
#[error("insufficient scope")]
InsufficientScope,
}

/// Verify common kind:24242 Blossom event validity for one exact verb.
///
/// This verifies the signature, kind, non-empty content, verb, expiration,
/// creation-time replay window, and any request-bound server tags. It does not
/// check verb-specific blob scope.
pub fn verify_blossom_auth_event_for_verb(
auth_event: &nostr::Event,
verb: BlossomVerb,
server_domain: Option<&str>,
max_age_secs: u64,
) -> Result<(), BlossomAuthError> {
auth_event
.verify()
.map_err(|_| BlossomAuthError::InvalidSignature)?;

if auth_event.kind.as_u16() != 24242 {
return Err(BlossomAuthError::InvalidAuthKind);
}
if auth_event.content.trim().is_empty() {
return Err(BlossomAuthError::InvalidAuthEvent);
}

let mut found_t = false;
let mut found_exp = false;
let mut server_tags: Vec<&str> = Vec::new();
let mut exp_value: u64 = 0;

for tag in auth_event.tags.iter() {
match tag.kind().to_string().as_str() {
"t" => {
if let Some(value) = tag.content() {
if value != verb.as_str() {
return Err(BlossomAuthError::InvalidAuthVerb);
}
found_t = true;
}
}
"expiration" => {
if let Some(value) = tag.content() {
exp_value = value.parse().unwrap_or(0);
found_exp = true;
}
}
"server" => {
if let Some(value) = tag.content() {
server_tags.push(value);
}
}
_ => {}
}
}

if !found_t {
return Err(BlossomAuthError::MissingTag("t"));
}
if !found_exp {
return Err(BlossomAuthError::MissingTag("expiration"));
}

let now = nostr::Timestamp::now().as_secs();
if exp_value <= now {
return Err(BlossomAuthError::TokenExpired);
}

let created = auth_event.created_at.as_secs();
if created > now + 5 || now > created + max_age_secs {
return Err(BlossomAuthError::TimestampOutOfWindow);
}

if !server_tags.is_empty() {
let Some(domain) = server_domain else {
return Err(BlossomAuthError::ServerMismatch);
};
let expected = normalize_server_host(domain);
if !server_tags
.iter()
.any(|tag| normalize_server_host(tag) == expected)
{
return Err(BlossomAuthError::ServerMismatch);
}
}

Ok(())
}

/// Verify common upload auth event validity without checking the blob hash.
pub fn verify_blossom_auth_event(
auth_event: &nostr::Event,
server_domain: Option<&str>,
max_age_secs: u64,
) -> Result<(), BlossomAuthError> {
verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Upload, server_domain, max_age_secs)
}

/// Verify a kind:24242 upload event including the exact `x` tag blob hash.
pub fn verify_blossom_upload_auth(
auth_event: &nostr::Event,
sha256: &str,
server_domain: Option<&str>,
max_age_secs: u64,
) -> Result<(), BlossomAuthError> {
verify_blossom_auth_event_for_verb(
auth_event,
BlossomVerb::Upload,
server_domain,
max_age_secs,
)?;

let has_matching_x = auth_event
.tags
.iter()
.any(|tag| tag.kind().to_string() == "x" && tag.content() == Some(sha256));
if !has_matching_x {
return Err(BlossomAuthError::HashMismatch);
}
Ok(())
}

/// Verify a kind:24242 download event for one exact blob and server.
///
/// BUD-01 permits either an `x` tag matching `sha256` or a matching `server`
/// tag. Callers must still enforce relay membership after this verifier.
pub fn verify_blossom_get_auth(
auth_event: &nostr::Event,
sha256: &str,
server_domain: Option<&str>,
max_age_secs: u64,
) -> Result<(), BlossomAuthError> {
verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Get, server_domain, max_age_secs)?;

let has_matching_x = auth_event
.tags
.iter()
.any(|tag| tag.kind().to_string() == "x" && tag.content() == Some(sha256));
let has_matching_server = server_domain.is_some_and(|domain| {
let expected = normalize_server_host(domain);
auth_event.tags.iter().any(|tag| {
tag.kind().to_string() == "server"
&& tag
.content()
.is_some_and(|value| normalize_server_host(value) == expected)
})
});

if !has_matching_x && !has_matching_server {
return Err(BlossomAuthError::InsufficientScope);
}
Ok(())
}

fn normalize_server_host(value: &str) -> String {
let authority = match value.split_once("://") {
Some((_scheme, rest)) => rest.split('/').next().unwrap_or(rest),
None => value.split('/').next().unwrap_or(value),
};
buzz_core::tenant::normalize_host(authority)
}
39 changes: 37 additions & 2 deletions crates/buzz-auth/src/context/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,6 @@ impl ResolvedFederatedPolicy {
pub(crate) const fn from_authoritative_resolution(stamp: FederatedPolicyStamp) -> Self {
Self { stamp }
}

#[cfg(test)]
pub(crate) fn not_required(authorization_domain: CommunityId) -> Self {
Self::from_authoritative_resolution(
Expand Down Expand Up @@ -601,6 +600,42 @@ impl VersionedBindingRef {
}
}

pub(crate) fn from_evidence_adapter(
authorization_domain: CommunityId,
binding_id: Uuid,
principal: FederatedPrincipal,
bound_pubkey: PublicKey,
binding_version: BindingVersion,
expires_at: Option<BindingExpiry>,
source: BindingSource,
resolution_reason: AuthorizationReason,
) -> Result<Self, AuthContextError> {
if binding_id.is_nil() {
return Err(AuthContextError::InvalidBindingId);
}
let valid_reason = matches!(
(source, resolution_reason),
(_, AuthorizationReason::ExistingBinding)
| (
BindingSource::AttestedKey,
AuthorizationReason::EnrolledAttestedKey
)
| (BindingSource::Tofu, AuthorizationReason::EnrolledTofu)
);
if !valid_reason {
return Err(AuthContextError::InvalidAuthorizationReason);
}
Ok(Self {
authorization_domain,
binding_id,
principal,
bound_pubkey,
binding_version,
expires_at,
source,
resolution_reason,
})
}
/// Build a reference to a binding authoritatively resolved as already active.
#[cfg(test)]
pub(crate) fn new_existing_active_for_test(
Expand Down Expand Up @@ -697,7 +732,7 @@ impl VersionedBindingRef {
}

/// Stable reason proven by the authoritative binding lifecycle result.
pub(super) const fn authorization_reason(&self) -> AuthorizationReason {
pub(crate) const fn authorization_reason(&self) -> AuthorizationReason {
self.resolution_reason
}
}
Loading
Loading