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
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,9 @@ jobs:
PGSCHEMA_PLAN_USER: buzz
PGSCHEMA_PLAN_PASSWORD: buzz_dev
run: |
docker exec -e PGPASSWORD=buzz_dev buzz-postgres \
psql -U buzz -d buzz -v ON_ERROR_STOP=1 \
-c 'CREATE EXTENSION IF NOT EXISTS pgcrypto;'
./bin/pgschema apply --file schema/schema.sql --auto-approve
docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \
psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/attach-schema-partitions.sql
Expand Down Expand Up @@ -666,6 +669,9 @@ jobs:
PGSCHEMA_PLAN_USER: buzz
PGSCHEMA_PLAN_PASSWORD: buzz_dev
run: |
docker exec -e PGPASSWORD=buzz_dev buzz-postgres \
psql -U buzz -d buzz -v ON_ERROR_STOP=1 \
-c 'CREATE EXTENSION IF NOT EXISTS pgcrypto;'
./bin/pgschema apply --file schema/schema.sql --auto-approve
docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \
psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/attach-schema-partitions.sql
Expand Down Expand Up @@ -715,6 +721,12 @@ jobs:
docker exec -e PGPASSWORD="${BUZZ_TEST_POSTGRES_PASSWORD}" buzz-postgres \
psql -U buzz -d postgres -v ON_ERROR_STOP=1 \
-c "CREATE DATABASE buzz_identity_tests"
cargo nextest run \
--archive-file target/ci/backend-integration-tests.tar.zst \
-E 'package(buzz-db) and test(/migration::tests::(migration_nip_fi_lifecycle_tests|migration_nip_fi_operator_audit_tests|nip_fi_invitation_object_tests)::/)' \
--no-tests=fail \
--test-threads 1 \
--run-ignored all
cargo nextest run \
--archive-file target/ci/backend-integration-tests.tar.zst \
-E 'package(buzz-db) and test(/migration::tests::nip_fi_(authorization_tests|direct_final_tests)::/)' \
Expand Down
152 changes: 152 additions & 0 deletions crates/buzz-auth/src/blossom.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
//! Origin-sealed Blossom transport proofs for canonical authorization.

use chrono::{DateTime, Utc};
use nostr::Event;

use crate::{AuthError, ProofTransport, VerifiedFederatedAssertion, VerifiedNostrProof};

/// Closed Blossom operation bound by a kind:24242 authorization event.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlossomAuthorizationVerb {
/// Upload one exact content digest.
Upload,
/// Read one exact content digest, optionally under a server-wide grant.
Get,
}

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

/// Verify Blossom transport and bind it to one canonical federated assertion.
///
/// The event must name exactly one verb and expiration. Uploads require the
/// exact content digest; reads require either that digest or a matching server
/// grant. The returned proof is bound to the assertion's exact fingerprints.
#[allow(clippy::too_many_arguments)]
pub fn verify_blossom_authorization_proof(
event: &Event,
verb: BlossomAuthorizationVerb,
expected_server: &str,
content_sha256: &str,
maximum_age_seconds: u64,
assertion: &VerifiedFederatedAssertion,
request_fingerprint: [u8; 32],
target_fingerprint: [u8; 32],
transport_context_fingerprint: [u8; 32],
) -> Result<VerifiedNostrProof, AuthError> {
if maximum_age_seconds == 0
|| content_sha256.len() != 64
|| !content_sha256
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(AuthError::BlossomInvalid);
}
event.verify().map_err(|_| AuthError::BlossomInvalid)?;
if event.kind.as_u16() != 24242 || event.content.trim().is_empty() {
return Err(AuthError::BlossomInvalid);
}

let mut verb_tag = None;
let mut expiration_tag = None;
let mut digest_matches = false;
let mut server_seen = false;
let mut server_matches = false;
let expected_server = normalized_server(expected_server);
for tag in event.tags.iter() {
match tag.kind().to_string().as_str() {
"t" if verb_tag.replace(tag.content()).is_some() => {
return Err(AuthError::BlossomInvalid);
}
"t" => {}
"expiration" if expiration_tag.replace(tag.content()).is_some() => {
return Err(AuthError::BlossomInvalid);
}
"expiration" => {}
"x" => {
digest_matches |= tag.content() == Some(content_sha256);
}
"server" => {
server_seen = true;
server_matches |= tag
.content()
.map(normalized_server)
.is_some_and(|server| server == expected_server);
}
_ => {}
}
}
if verb_tag.flatten() != Some(verb.tag())
|| (server_seen && !server_matches)
|| match verb {
BlossomAuthorizationVerb::Upload => !digest_matches,
BlossomAuthorizationVerb::Get => !digest_matches && !server_matches,
}
{
return Err(AuthError::BlossomInvalid);
}

let expiration_seconds = expiration_tag
.flatten()
.and_then(|value| value.parse::<u64>().ok())
.and_then(|value| i64::try_from(value).ok())
.ok_or(AuthError::BlossomInvalid)?;
let event_expires_at =
DateTime::<Utc>::from_timestamp(expiration_seconds, 0).ok_or(AuthError::BlossomInvalid)?;
let now_seconds = Utc::now().timestamp();
let created_seconds =
i64::try_from(event.created_at.as_secs()).map_err(|_| AuthError::BlossomInvalid)?;
let maximum_age_seconds =
i64::try_from(maximum_age_seconds).map_err(|_| AuthError::BlossomInvalid)?;
if created_seconds > now_seconds.saturating_add(5)
|| now_seconds.saturating_sub(created_seconds) > maximum_age_seconds
{
return Err(AuthError::BlossomInvalid);
}

let (_, assertion_expires_at) = assertion.time_bounds();
let expires_at = event_expires_at.min(assertion_expires_at);
let (assertion_transport, assertion_request, assertion_target, assertion_context) =
assertion.request_binding();
if assertion_transport != ProofTransport::Blossom
|| assertion_request != &request_fingerprint
|| assertion_target != &target_fingerprint
|| assertion_context != &transport_context_fingerprint
|| expires_at <= Utc::now()
{
return Err(AuthError::BlossomInvalid);
}

VerifiedNostrProof::from_verifier(
assertion.authorization_domain(),
event.pubkey,
ProofTransport::Blossom,
request_fingerprint,
target_fingerprint,
transport_context_fingerprint,
Some(*assertion.assertion_fingerprint()),
None,
expires_at,
)
.ok_or(AuthError::BlossomInvalid)
}

fn normalized_server(value: &str) -> String {
let authority = match value.split_once("://") {
Some((_scheme, rest)) => match rest.split('/').next() {
Some(authority) => authority,
None => rest,
},
None => match value.split('/').next() {
Some(authority) => authority,
None => value,
},
};
buzz_core::tenant::normalize_host(authority)
}
4 changes: 4 additions & 0 deletions crates/buzz-auth/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ pub enum AuthError {
#[error("NIP-98 HTTP Auth verification failed: {0}")]
Nip98Invalid(String),

/// Blossom transport proof failed closed before canonical authorization.
#[error("Blossom authorization proof is invalid")]
BlossomInvalid,

/// A NIP-98 event with the same id has already been observed within the
/// replay-prevention window. The event itself was structurally valid; the
/// rejection is on freshness, not validity.
Expand Down
Loading
Loading