diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0e86af76d..2725b4890c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -704,14 +704,14 @@ jobs: --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz_identity_tests - - name: NIP-FI 0031 projection and exact catalog parity + - name: NIP-FI 0031-0032 projection, protected authority, and exact catalog parity 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_schema_parity" cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-db) and (test(identity_0031_fresh_populated_and_desired_schema_have_full_postgresql_catalog_parity) or test(populated_0030_projects_exact_lifecycle_and_capacity_bootstrap) or test(identity_0031_rejects_duplicate_ambiguous_and_open_lineage_atomically) or test(authorization_capacity_hard_boundaries_are_exact))' \ + -E 'package(buzz-db) and (test(protected_authority_0032_fresh_populated_and_desired_schema_have_full_postgresql_catalog_parity) or test(protected_authority_0032_enforces_exact_fence_and_immutability) or test(populated_0030_projects_exact_lifecycle_and_capacity_bootstrap) or test(identity_0031_rejects_duplicate_ambiguous_and_open_lineage_atomically) or test(authorization_capacity_hard_boundaries_are_exact))' \ --test-threads 1 \ --run-ignored ignored-only env: diff --git a/Cargo.lock b/Cargo.lock index a18d5dee46..c043f600b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -905,8 +905,10 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "buzz-core", + "buzz-sdk", "chrono", "hex", + "hmac 0.13.0", "http", "jsonwebtoken", "nostr", diff --git a/Cargo.toml b/Cargo.toml index 09e78a885c..8ef14e20b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ tokio-util = { version = "0.7", features = ["rt", "codec"] } # HTTP + WebSocket axum = { version = "0.8", features = ["ws", "macros"] } +http = "1" tower = { version = "0.5", features = ["timeout", "util", "limit"] } tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip", "limit", "timeout", "fs"] } diff --git a/crates/buzz-auth/Cargo.toml b/crates/buzz-auth/Cargo.toml index f304f09c7f..03c8cda461 100644 --- a/crates/buzz-auth/Cargo.toml +++ b/crates/buzz-auth/Cargo.toml @@ -13,6 +13,7 @@ dev = [] [dependencies] buzz-core = { workspace = true } +buzz-sdk = { workspace = true } chrono = { workspace = true } nostr = { workspace = true } serde = { workspace = true } @@ -21,7 +22,8 @@ tokio = { workspace = true } tracing = { workspace = true } thiserror = { workspace = true } sha2 = { workspace = true } -http = "1" +hmac = { workspace = true } +http = { workspace = true } hex = { workspace = true } jsonwebtoken = { workspace = true } base64 = { workspace = true } diff --git a/crates/buzz-auth/src/evidence.rs b/crates/buzz-auth/src/evidence.rs index 95631a41dd..87c136c4e9 100644 --- a/crates/buzz-auth/src/evidence.rs +++ b/crates/buzz-auth/src/evidence.rs @@ -1,21 +1,26 @@ -//! Provider-free verification and sealing of corporate identity binding evidence. +//! Provider-free verification and sealing of protected-route evidence. //! -//! This module verifies one exact JWT against an immutable JWKS snapshot and -//! binds it to server-resolved authorization coordinates plus an already -//! authenticated Nostr actor. It never decides local admission. +//! This module is the only public route from raw JWT, NIP-42, NIP-98, or +//! delegated material to the origin-sealed values consumed by the local +//! binding resolver. It performs cryptographic and exact request binding; it +//! never decides local admission and contains no provider registry vocabulary. -use std::{fmt, sync::Arc, time::Duration}; +use std::{fmt, future::Future, sync::Arc, time::Duration}; use base64::Engine as _; use buzz_core::CommunityId; use chrono::{DateTime, TimeDelta, Utc}; -use http::{HeaderMap, HeaderName}; +use hmac::{Hmac, KeyInit, Mac}; +use http::{ + uri::{Authority, PathAndQuery, Scheme}, + HeaderMap, HeaderName, Method, +}; use jsonwebtoken::{ decode, decode_header, jwk::{Jwk, JwkSet, KeyAlgorithm, KeyOperations, PublicKeyUse}, Algorithm, DecodingKey, Validation, }; -use nostr::{FromBech32, PublicKey}; +use nostr::{Alphabet, Event, FromBech32, Kind, PublicKey, SingleLetterTag, Tag, TagKind}; use serde::{ de::{Error as _, MapAccess, Visitor}, Deserialize, Deserializer, @@ -23,19 +28,29 @@ use serde::{ use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use thiserror::Error; - -use crate::foundation::{ - FederatedPrincipal, ProofTransport, VerifiedFederatedAssertion, VerifiedNostrProof, +use uuid::Uuid; + +use crate::{ + foundation::{ + FederatedPrincipal, ProofTransport, RouteCapability, VerifiedDelegation, + VerifiedFederatedAssertion, VerifiedNostrProof, + }, + nip98_replay::{Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS}, }; +const PROOF_LIFETIME_SECONDS: i64 = 60; +const MIN_PROVENANCE_KEY_BYTES: usize = 32; const MAX_IDENTITY_PART_BYTES: usize = 2048; const MAX_CLAIM_NAME_BYTES: usize = 128; +type ProvenanceMac = Hmac; + /// One unambiguous HTTP header occurrence extracted from the complete header map. /// /// The only constructor examines every occurrence of `name`. It rejects /// duplicate header lines, comma-joined alternatives, non-UTF-8 values, and -/// leading or trailing whitespace. +/// leading or trailing whitespace. Callers therefore cannot mint this value +/// from one conveniently selected occurrence while hiding another. pub struct ExactSingleHttpHeader(Box); impl ExactSingleHttpHeader { @@ -60,7 +75,7 @@ impl ExactSingleHttpHeader { Ok(Self(value.into())) } - /// Borrow the exact validated header bytes as UTF-8. + /// Borrow the exact value after complete-map cardinality validation. pub fn as_str(&self) -> &str { &self.0 } @@ -72,6 +87,330 @@ impl fmt::Debug for ExactSingleHttpHeader { } } +/// Exact absolute request target assembled only from server-resolved HTTP parts. +/// +/// NIP-98 verification compares the signed `u` tag byte-for-byte with this +/// representation. In particular, `/protected` and `/protected/` remain +/// distinct targets. +#[derive(Clone, PartialEq, Eq)] +pub struct CanonicalHttpRequestTarget(Box); + +impl CanonicalHttpRequestTarget { + /// Assemble the one request-target representation used by routing and proof verification. + pub fn from_server_parts( + scheme: &Scheme, + authority: &Authority, + path_and_query: &PathAndQuery, + ) -> Result { + if !matches!(scheme.as_str(), "http" | "https") + || authority.as_str().is_empty() + || !path_and_query.as_str().starts_with('/') + { + return Err(EvidenceError::InvalidBinding); + } + let uri = http::Uri::builder() + .scheme(scheme.clone()) + .authority(authority.clone()) + .path_and_query(path_and_query.clone()) + .build() + .map_err(|_| EvidenceError::InvalidBinding)?; + Ok(Self(uri.to_string().into())) + } + + fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for CanonicalHttpRequestTarget { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("CanonicalHttpRequestTarget([REDACTED])") + } +} + +/// Closed Git smart-HTTP operation admitted by a reusable repository session proof. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GitSmartHttpOperation { + /// Advertise refs and capabilities for clone/fetch. + AdvertiseUploadPack, + /// Advertise refs and capabilities for push. + AdvertiseReceivePack, + /// Stream clone/fetch negotiation and pack data. + UploadPack, + /// Stream push commands and pack data. + ReceivePack, +} + +impl GitSmartHttpOperation { + fn as_str(self) -> &'static str { + match self { + Self::AdvertiseUploadPack => "advertise-upload-pack", + Self::AdvertiseReceivePack => "advertise-receive-pack", + Self::UploadPack => "upload-pack", + Self::ReceivePack => "receive-pack", + } + } +} + +/// Exact server-resolved Git smart-HTTP route admitted by one repository session proof. +#[derive(Clone, PartialEq, Eq)] +pub struct CanonicalGitSmartHttpRequest { + signed_repo_root: CanonicalHttpRequestTarget, + operation: GitSmartHttpOperation, + owner: Box, + repository: Box, + request_bytes: Box<[u8]>, + target_bytes: Box<[u8]>, + transport_context_bytes: Box<[u8]>, +} + +impl CanonicalGitSmartHttpRequest { + /// Validate one exact Git route and derive its stable repository target. + #[allow(clippy::too_many_arguments)] + pub fn from_server_parts( + scheme: &Scheme, + authority: &Authority, + owner: &str, + repo: &str, + method: &Method, + path_and_query: &PathAndQuery, + ) -> Result { + if owner.len() != 64 + || !owner + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err(EvidenceError::InvalidBinding); + } + let repo_name = repo.strip_suffix(".git").unwrap_or(repo); + if repo_name.is_empty() + || repo_name.len() > 64 + || repo_name.starts_with('.') + || repo_name.contains("..") + || !repo_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err(EvidenceError::InvalidBinding); + } + + let repo_root_path = format!("/git/{owner}/{repo}"); + let operation = match (method, path_and_query.as_str()) { + (&Method::GET, value) + if value == format!("{repo_root_path}/info/refs?service=git-upload-pack") => + { + GitSmartHttpOperation::AdvertiseUploadPack + } + (&Method::GET, value) + if value == format!("{repo_root_path}/info/refs?service=git-receive-pack") => + { + GitSmartHttpOperation::AdvertiseReceivePack + } + (&Method::POST, value) if value == format!("{repo_root_path}/git-upload-pack") => { + GitSmartHttpOperation::UploadPack + } + (&Method::POST, value) if value == format!("{repo_root_path}/git-receive-pack") => { + GitSmartHttpOperation::ReceivePack + } + _ => return Err(EvidenceError::InvalidBinding), + }; + let signed_repo_root = CanonicalHttpRequestTarget::from_server_parts( + scheme, + authority, + &repo_root_path + .parse() + .map_err(|_| EvidenceError::InvalidBinding)?, + )?; + Ok(Self { + signed_repo_root, + operation, + owner: owner.into(), + repository: repo_name.into(), + request_bytes: framed_coordinates(&[ + method.as_str().as_bytes(), + operation.as_str().as_bytes(), + path_and_query.as_str().as_bytes(), + ]) + .into_boxed_slice(), + target_bytes: framed_coordinates(&[ + b"repository", + owner.as_bytes(), + repo_name.as_bytes(), + ]) + .into_boxed_slice(), + transport_context_bytes: framed_coordinates(&[ + b"git-smart-http-session-v1", + scheme.as_str().as_bytes(), + authority.as_str().as_bytes(), + ]) + .into_boxed_slice(), + }) + } + + /// Construct the only evidence binding valid for this exact Git route. + pub fn evidence_binding( + &self, + authorization_domain: CommunityId, + ) -> Result { + RequestEvidenceBinding::new( + authorization_domain, + ProofTransport::GitSmartHttpSession, + &self.request_bytes, + &self.target_bytes, + &self.transport_context_bytes, + ) + } + + /// Closed operation mapped from the actual HTTP method and path. + pub const fn operation(&self) -> GitSmartHttpOperation { + self.operation + } + + /// Exact lowercase repository owner selected by routing. + pub fn owner(&self) -> &str { + &self.owner + } + + /// Canonical repository name without the optional `.git` suffix. + pub fn repository(&self) -> &str { + &self.repository + } +} + +impl fmt::Debug for CanonicalGitSmartHttpRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("CanonicalGitSmartHttpRequest([REDACTED])") + } +} + +/// Closed Blossom request action bound by a protected media proof. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlossomAction { + /// Upload one immutable object named by its SHA-256 digest. + Upload, + /// Read one immutable object. + Get, + /// Read metadata for one immutable object. + Head, +} + +impl BlossomAction { + fn request_label(self) -> &'static str { + match self { + Self::Upload => "upload", + Self::Get => "get", + Self::Head => "head", + } + } + + fn signed_verb(self) -> &'static str { + match self { + Self::Upload => "upload", + Self::Get | Self::Head => "get", + } + } +} + +/// Exact server-resolved Blossom request and stable immutable-object target. +#[derive(Clone, PartialEq, Eq)] +pub struct CanonicalBlossomRequest { + action: BlossomAction, + object_sha256: Box, + requested_path: Box, + server: Box, + request_bytes: Box<[u8]>, + target_bytes: Box<[u8]>, + transport_context_bytes: Box<[u8]>, +} + +impl CanonicalBlossomRequest { + /// Validate one exact Blossom upload/read route and object digest. + pub fn from_server_parts( + scheme: &Scheme, + authority: &Authority, + method: &Method, + path_and_query: &PathAndQuery, + object_sha256: &str, + ) -> Result { + if object_sha256.len() != 64 + || !object_sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(EvidenceError::InvalidBinding); + } + let action = match (method, path_and_query.as_str()) { + (&Method::PUT, "/upload" | "/media/upload") => BlossomAction::Upload, + (&Method::GET, path) if media_path_names_hash(path, object_sha256) => { + BlossomAction::Get + } + (&Method::HEAD, path) if media_path_names_hash(path, object_sha256) => { + BlossomAction::Head + } + _ => return Err(EvidenceError::InvalidBinding), + }; + let server = buzz_core::tenant::normalize_host(authority.as_str()); + if server.is_empty() { + return Err(EvidenceError::InvalidBinding); + } + Ok(Self { + action, + object_sha256: object_sha256.into(), + requested_path: path_and_query.as_str().into(), + server: server.clone().into_boxed_str(), + request_bytes: framed_coordinates(&[ + method.as_str().as_bytes(), + action.request_label().as_bytes(), + path_and_query.as_str().as_bytes(), + ]) + .into_boxed_slice(), + target_bytes: framed_coordinates(&[b"media", object_sha256.as_bytes()]) + .into_boxed_slice(), + transport_context_bytes: framed_coordinates(&[ + b"blossom-v1", + scheme.as_str().as_bytes(), + server.as_bytes(), + ]) + .into_boxed_slice(), + }) + } + + /// Construct the only evidence binding valid for this exact Blossom route. + pub fn evidence_binding( + &self, + authorization_domain: CommunityId, + ) -> Result { + RequestEvidenceBinding::new( + authorization_domain, + ProofTransport::Blossom, + &self.request_bytes, + &self.target_bytes, + &self.transport_context_bytes, + ) + } + + /// Exact protected media action. + pub const fn action(&self) -> BlossomAction { + self.action + } + + /// Canonical immutable-object digest named by this route. + pub fn object_sha256(&self) -> &str { + &self.object_sha256 + } + + /// Exact path-and-query admitted by the server route parser. + pub fn requested_path(&self) -> &str { + &self.requested_path + } +} + +impl fmt::Debug for CanonicalBlossomRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("CanonicalBlossomRequest([REDACTED])") + } +} + /// Trusted time source shared by assertion and proof verification. pub trait EvidenceClock: Send + Sync { /// Return the current verifier time. @@ -303,6 +642,11 @@ impl VerifiedAssertionEvidence { expires_at: self.expires_at, }) } + + /// Consume the wrapper after its paired Nostr proof has been constructed. + pub fn into_assertion(self) -> VerifiedFederatedAssertion { + self.assertion + } } impl VerifiedIdentityBindingEvidence { @@ -409,6 +753,21 @@ impl StaticJwksAssertionVerifier { self.verify_assertion(token.as_str(), binding, b"client-attached") } + /// Bind this JWT verifier to an authenticated trusted-proxy boundary. + pub fn trusted_proxy( + self, + assertion_header_name: impl Into, + provenance_header_name: impl Into, + provenance_key: impl AsRef<[u8]>, + ) -> Result { + TrustedProxyAssertionVerifier::new( + self, + assertion_header_name, + provenance_header_name, + provenance_key, + ) + } + fn verify_assertion( &self, token: &str, @@ -511,131 +870,923 @@ impl fmt::Debug for StaticJwksAssertionVerifier { } } -fn validate_exact_token(token: &str) -> Result<(), EvidenceError> { - if token.is_empty() || token != token.trim() || token.contains(',') { - return Err(EvidenceError::AmbiguousAssertion); +/// Trusted-proxy assertion verifier requiring an exact authenticated ingress MAC. +pub struct TrustedProxyAssertionVerifier { + verifier: StaticJwksAssertionVerifier, + assertion_header_name: HeaderName, + provenance_header_name: HeaderName, + provenance_key: Vec, +} + +impl TrustedProxyAssertionVerifier { + fn new( + verifier: StaticJwksAssertionVerifier, + assertion_header_name: impl Into, + provenance_header_name: impl Into, + provenance_key: impl AsRef<[u8]>, + ) -> Result { + let assertion_header_name = assertion_header_name.into(); + let provenance_header_name = provenance_header_name.into(); + let provenance_key = provenance_key.as_ref(); + let assertion_header_name = HeaderName::try_from(assertion_header_name) + .map_err(|_| EvidenceError::InvalidConfiguration)?; + let provenance_header_name = HeaderName::try_from(provenance_header_name) + .map_err(|_| EvidenceError::InvalidConfiguration)?; + if assertion_header_name == provenance_header_name + || provenance_key.len() < MIN_PROVENANCE_KEY_BYTES + { + return Err(EvidenceError::InvalidConfiguration); + } + Ok(Self { + verifier, + assertion_header_name, + provenance_header_name, + provenance_key: provenance_key.to_vec(), + }) + } + + /// Extract and verify the exact proxy assertion and lowercase hexadecimal MAC. + pub fn verify( + &self, + headers: &HeaderMap, + binding: &RequestEvidenceBinding, + ) -> Result { + let token = ExactSingleHttpHeader::from_headers(headers, &self.assertion_header_name)?; + let provenance_proof = + ExactSingleHttpHeader::from_headers(headers, &self.provenance_header_name)?; + let token = token.as_str(); + let provenance_proof = provenance_proof.as_str(); + validate_exact_token(token)?; + if provenance_proof.len() != 64 + || !provenance_proof + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(EvidenceError::UntrustedOrigin); + } + let proof = hex::decode(provenance_proof).map_err(|_| EvidenceError::UntrustedOrigin)?; + let mac = provenance_mac( + &self.provenance_key, + self.assertion_header_name.as_str().as_bytes(), + token.as_bytes(), + binding, + )?; + mac.verify_slice(&proof) + .map_err(|_| EvidenceError::UntrustedOrigin)?; + self.verifier + .verify_assertion(token, binding, b"trusted-proxy") } - Ok(()) } -fn validate_unique_jwt_header(token: &str) -> Result<(), EvidenceError> { - let encoded = token - .split('.') - .next() - .ok_or(EvidenceError::InvalidAssertion)?; - let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(encoded) - .map_err(|_| EvidenceError::InvalidAssertion)?; - serde_json::from_slice::(&decoded) - .map(|_| ()) - .map_err(|_| EvidenceError::AmbiguousAssertion) +impl fmt::Debug for TrustedProxyAssertionVerifier { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("TrustedProxyAssertionVerifier([REDACTED])") + } } -fn exact_jwk_for_kid<'a>(set: &'a JwkSet, kid: &str) -> Result<&'a Jwk, EvidenceError> { - let mut matches = set - .keys - .iter() - .filter(|jwk| jwk.common.key_id.as_deref() == Some(kid)); - let first = matches.next().ok_or(EvidenceError::InvalidAssertion)?; - if matches.next().is_some() { - return Err(EvidenceError::AmbiguousAssertion); +/// Direct assertion and Nostr proof sealed as one non-substitutable pair. +pub struct VerifiedDirectEvidence { + assertion: VerifiedFederatedAssertion, + proof: VerifiedNostrProof, +} + +impl VerifiedDirectEvidence { + /// Consume the pair for direct binding resolution. + pub fn into_parts(self) -> (VerifiedFederatedAssertion, VerifiedNostrProof) { + (self.assertion, self.proof) } - Ok(first) } -fn is_allowed_algorithm(algorithm: Algorithm) -> bool { - matches!( - algorithm, - Algorithm::RS256 - | Algorithm::RS384 - | Algorithm::RS512 - | Algorithm::PS256 - | Algorithm::PS384 - | Algorithm::PS512 - | Algorithm::ES256 - | Algorithm::ES384 - | Algorithm::EdDSA - ) +impl fmt::Debug for VerifiedDirectEvidence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("VerifiedDirectEvidence([REDACTED])") + } } -fn validate_jwk_signature_metadata(jwk: &Jwk, algorithm: Algorithm) -> Result<(), EvidenceError> { - if jwk - .common - .public_key_use - .as_ref() - .is_some_and(|usage| usage != &PublicKeyUse::Signature) - || jwk - .common - .key_operations - .as_ref() - .is_some_and(|operations| { - !operations.contains(&KeyOperations::Verify) - || operations.contains(&KeyOperations::Sign) - }) - || jwk - .common - .key_algorithm - .as_ref() - .is_some_and(|configured| !jwk_algorithm_matches(configured, algorithm)) +/// Verify a NIP-42 proof and pair it with one exact assertion. +pub fn verify_nip42_direct( + event: &Event, + expected_challenge: &str, + relay_url: &str, + binding: &RequestEvidenceBinding, + assertion: VerifiedAssertionEvidence, + clock: &dyn EvidenceClock, +) -> Result { + let proof = verify_nip42( + event, + expected_challenge, + relay_url, + binding, + Some(assertion.assertion_fingerprint), + None, + clock.now(), + )?; + Ok(VerifiedDirectEvidence { + assertion: assertion.assertion, + proof, + }) +} + +/// Verify a protected NIP-98 proof, claim its replay marker, and pair it with an assertion. +#[allow(clippy::too_many_arguments)] +pub async fn verify_nip98_direct( + event_json: &str, + expected_target: &CanonicalHttpRequestTarget, + expected_method: &str, + body: Option<&[u8]>, + require_payload: bool, + binding: &RequestEvidenceBinding, + assertion: VerifiedAssertionEvidence, + replay: &dyn Nip98ReplayGuard, + clock: &dyn EvidenceClock, +) -> Result { + let event = verify_nip98( + event_json, + expected_target, + expected_method, + body, + require_payload, + clock.now(), + )?; + let scope = binding.authorization_domain.to_string(); + match replay + .try_mark_in_scope(&scope, &event.id, DEFAULT_REPLAY_TTL_SECS) + .await { - return Err(EvidenceError::InvalidAssertion); + Ok(true) => {} + Ok(false) => return Err(EvidenceError::Replay), + Err(_) => return Err(EvidenceError::DependencyUnavailable), } - Ok(()) + let proof = seal_nostr_proof( + &event, + ProofTransport::Nip98, + binding, + Some(assertion.assertion_fingerprint), + None, + )?; + Ok(VerifiedDirectEvidence { + assertion: assertion.assertion, + proof, + }) } -fn jwk_algorithm_matches(configured: &KeyAlgorithm, actual: Algorithm) -> bool { - matches!( - (configured, actual), - (KeyAlgorithm::RS256, Algorithm::RS256) - | (KeyAlgorithm::RS384, Algorithm::RS384) - | (KeyAlgorithm::RS512, Algorithm::RS512) - | (KeyAlgorithm::PS256, Algorithm::PS256) - | (KeyAlgorithm::PS384, Algorithm::PS384) - | (KeyAlgorithm::PS512, Algorithm::PS512) - | (KeyAlgorithm::ES256, Algorithm::ES256) - | (KeyAlgorithm::ES384, Algorithm::ES384) - | (KeyAlgorithm::EdDSA, Algorithm::EdDSA) - ) +/// Verify one bounded reusable Git smart-HTTP session credential and pair it +/// with the exact direct assertion for the current closed Git operation. +pub fn verify_git_smart_http_direct( + authorization: &VerifiedGitSmartHttpAuthorization, + binding: &RequestEvidenceBinding, + assertion: VerifiedAssertionEvidence, +) -> Result { + if authorization + .request + .evidence_binding(binding.authorization_domain())? + != *binding + { + return Err(EvidenceError::InvalidBinding); + } + let proof = seal_nostr_proof_until( + &authorization.event, + ProofTransport::GitSmartHttpSession, + binding, + Some(assertion.assertion_fingerprint), + None, + authorization.expires_at, + )?; + Ok(VerifiedDirectEvidence { + assertion: assertion.assertion, + proof, + }) } -fn exact_string_claim(claims: &Map, name: &str) -> Result { - claims - .get(name) - .and_then(Value::as_str) - .filter(|value| is_exact_identity_part(value)) - .map(ToOwned::to_owned) - .ok_or(EvidenceError::InvalidAssertion) +/// Origin-verify one bounded reusable Git smart-HTTP session credential. +/// +/// The signed method is deliberately and exactly `GET`: Git's credential +/// helper signs the repository-root credential once and reuses it across the +/// closed GET/POST operation set admitted by [`CanonicalGitSmartHttpRequest`]. +pub fn verify_git_smart_http_authorization( + event_json: &str, + request: &CanonicalGitSmartHttpRequest, + clock: &dyn EvidenceClock, +) -> Result { + let event: Event = serde_json::from_str(event_json).map_err(|_| EvidenceError::InvalidProof)?; + if event.kind != Kind::HttpAuth { + return Err(EvidenceError::InvalidProof); + } + buzz_core::verify_event(&event).map_err(|_| EvidenceError::InvalidProof)?; + let now = clock.now(); + validate_proof_time(&event, now)?; + if proof_expires_at(&event)? <= now { + return Err(EvidenceError::Expired); + } + let url = exact_tag_content( + &event, + |tag| tag.kind() == TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::U)), + true, + )? + .ok_or(EvidenceError::InvalidProof)?; + if url.as_bytes() != request.signed_repo_root.as_str().as_bytes() { + return Err(EvidenceError::InvalidProof); + } + let method = exact_tag_content(&event, |tag| tag.kind() == TagKind::Method, true)? + .ok_or(EvidenceError::InvalidProof)?; + if method != Method::GET.as_str() + || exact_tag_content(&event, |tag| tag.kind() == TagKind::Payload, false)?.is_some() + { + return Err(EvidenceError::InvalidProof); + } + let expires_at = proof_expires_at(&event)?; + Ok(VerifiedGitSmartHttpAuthorization { + event, + request: request.clone(), + expires_at, + }) } -fn verifier_policy_digest( - policy: &AssertionVerificationPolicy, - jwks: &JwkSet, -) -> Result<[u8; 32], EvidenceError> { - let mut encoded_keys = jwks - .keys - .iter() - .map(|key| serde_json::to_vec(key).map_err(|_| EvidenceError::InvalidConfiguration)) - .collect::, _>>()?; - encoded_keys.sort(); - if encoded_keys.windows(2).any(|pair| pair[0] == pair[1]) { - return Err(EvidenceError::InvalidConfiguration); +/// Origin-verified Git session credential bound to one canonical operation. +pub struct VerifiedGitSmartHttpAuthorization { + event: Event, + request: CanonicalGitSmartHttpRequest, + expires_at: DateTime, +} + +impl VerifiedGitSmartHttpAuthorization { + /// Exact event author admitted by the signature verifier. + pub fn actor_pubkey(&self) -> PublicKey { + self.event.pubkey } - let encoding = match policy.nostr_key_claim.as_ref().map(|claim| claim.encoding) { - None => 0_u8, - Some(NostrKeyClaimEncoding::LowerHex) => 1, - Some(NostrKeyClaimEncoding::CanonicalPublicKey) => 2, - }; - let claim_name = policy - .nostr_key_claim - .as_ref() - .map_or(&[][..], |claim| claim.claim_name.as_bytes()); - let maximum_age = policy.maximum_token_age.as_secs().to_be_bytes(); - let clock_skew = policy.clock_skew.as_secs().to_be_bytes(); - let key_count = (encoded_keys.len() as u64).to_be_bytes(); - let encoding_bytes = [encoding]; - let mut digest = Sha256::new(); - for field in [ - b"buzz:nip-fi:assertion-verifier-policy:v1".as_slice(), + + /// Closed actual Git smart-HTTP operation. + pub const fn operation(&self) -> GitSmartHttpOperation { + self.request.operation() + } + + /// Borrow the immutable server-derived route joined to this proof. + pub fn request(&self) -> &CanonicalGitSmartHttpRequest { + &self.request + } + + /// Construct the only evidence binding valid for this origin-sealed route. + pub fn evidence_binding( + &self, + authorization_domain: CommunityId, + ) -> Result { + self.request.evidence_binding(authorization_domain) + } +} + +impl fmt::Debug for VerifiedGitSmartHttpAuthorization { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("VerifiedGitSmartHttpAuthorization([REDACTED])") + } +} + +/// Git smart-HTTP delegated evidence is unavailable until a reviewed positive +/// relationship authority source is installed. +pub fn verify_git_smart_http_delegated() -> Result { + Err(EvidenceError::DelegatedAuthorityUnavailable) +} + +/// Verify one exact Blossom upload/read credential and pair it with the exact +/// direct assertion for the same media route. +pub fn verify_blossom_direct( + authorization: &VerifiedBlossomAuthorization, + binding: &RequestEvidenceBinding, + assertion: VerifiedAssertionEvidence, +) -> Result { + if authorization + .request + .evidence_binding(binding.authorization_domain())? + != *binding + { + return Err(EvidenceError::InvalidBinding); + } + let proof = seal_nostr_proof_until( + &authorization.event, + ProofTransport::Blossom, + binding, + Some(assertion.assertion_fingerprint), + None, + authorization.expires_at, + )?; + Ok(VerifiedDirectEvidence { + assertion: assertion.assertion, + proof, + }) +} + +/// Origin-verified Blossom event bound to one canonical request target. +pub struct VerifiedBlossomAuthorization { + event: Event, + request: CanonicalBlossomRequest, + expires_at: DateTime, +} + +impl VerifiedBlossomAuthorization { + /// Exact signer proven by the event signature. + pub const fn actor_pubkey(&self) -> PublicKey { + self.event.pubkey + } + + /// Exact protected media action. + pub const fn action(&self) -> BlossomAction { + self.request.action + } + + /// Borrow the immutable server-derived route joined to this proof. + pub fn request(&self) -> &CanonicalBlossomRequest { + &self.request + } + + /// Canonical immutable-object digest named by this authorization. + pub fn object_sha256(&self) -> &str { + self.request.object_sha256() + } + + /// Construct the only evidence binding this authorization may seal. + pub fn evidence_binding( + &self, + authorization_domain: CommunityId, + ) -> Result { + self.request.evidence_binding(authorization_domain) + } +} + +impl fmt::Debug for VerifiedBlossomAuthorization { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("VerifiedBlossomAuthorization([REDACTED])") + } +} + +/// Canonically verify one Blossom event before local authorization finalization. +pub fn verify_blossom_authorization( + event: &Event, + request: &CanonicalBlossomRequest, + clock: &dyn EvidenceClock, +) -> Result { + if event.kind.as_u16() != 24_242 || event.content.trim().is_empty() { + return Err(EvidenceError::InvalidProof); + } + buzz_core::verify_event(event).map_err(|_| EvidenceError::InvalidProof)?; + let now = clock.now(); + validate_proof_time(event, now)?; + + let verb = exact_custom_tag_content(event, "t", true)?.ok_or(EvidenceError::InvalidProof)?; + if verb != request.action.signed_verb() { + return Err(EvidenceError::InvalidProof); + } + let expiration = exact_custom_tag_content(event, "expiration", true)? + .ok_or(EvidenceError::InvalidProof)? + .parse::() + .ok() + .and_then(|seconds| DateTime::from_timestamp(seconds, 0)) + .ok_or(EvidenceError::InvalidProof)?; + if expiration <= now { + return Err(EvidenceError::Expired); + } + + let object_scope = exact_custom_tag_content(event, "x", false)?; + let server_scope = exact_custom_tag_content(event, "server", false)?; + match request.action { + BlossomAction::Upload => { + if object_scope != Some(request.object_sha256()) + || server_scope.is_some_and(|server| { + normalize_blossom_server(server) != request.server.as_ref() + }) + { + return Err(EvidenceError::InvalidProof); + } + } + BlossomAction::Get | BlossomAction::Head => match (object_scope, server_scope) { + (Some(object), None) if object == request.object_sha256() => {} + (None, Some(server)) if normalize_blossom_server(server) == request.server.as_ref() => { + } + _ => return Err(EvidenceError::InvalidProof), + }, + } + + let proof_expiry = proof_expires_at(event)?.min(expiration); + if proof_expiry <= now { + return Err(EvidenceError::Expired); + } + Ok(VerifiedBlossomAuthorization { + event: event.clone(), + request: request.clone(), + expires_at: proof_expiry, + }) +} + +/// Blossom delegated evidence is unavailable until a reviewed positive +/// relationship authority source is installed. +pub fn verify_blossom_delegated() -> Result { + Err(EvidenceError::DelegatedAuthorityUnavailable) +} + +/// Authoritative local relationship state joined to a verified NIP-OA signature. +#[derive(Clone)] +pub struct AuthoritativeDelegationGrant { + relationship_id: Uuid, + relationship_revision: u64, + capabilities: Vec, + expires_at: DateTime, +} + +impl AuthoritativeDelegationGrant { + /// Construct a grant returned by the configured canonical local relationship store. + pub fn from_local_store( + relationship_id: Uuid, + relationship_revision: u64, + mut capabilities: Vec, + expires_at: DateTime, + ) -> Result { + capabilities.sort_unstable(); + capabilities.dedup(); + if relationship_id.is_nil() || relationship_revision == 0 || capabilities.is_empty() { + return Err(EvidenceError::InvalidDelegation); + } + Ok(Self { + relationship_id, + relationship_revision, + capabilities, + expires_at, + }) + } +} + +impl fmt::Debug for AuthoritativeDelegationGrant { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("AuthoritativeDelegationGrant([REDACTED])") + } +} + +/// Narrow canonical relationship-store boundary used after signature verification. +pub trait DelegationGrantSource: Send + Sync { + /// Fail-closed storage error. + type Error: Send; + + /// Resolve current revision, capabilities, and exclusive expiry. + fn current_grant<'a>( + &'a self, + authorization_domain: CommunityId, + owner_pubkey: PublicKey, + delegate_pubkey: PublicKey, + conditions_fingerprint: [u8; 32], + ) -> impl Future, Self::Error>> + Send + 'a; +} + +/// Delegation and Nostr proof sealed as one assertion-free pair. +pub struct VerifiedDelegatedEvidence { + delegation: VerifiedDelegation, + proof: VerifiedNostrProof, +} + +impl VerifiedDelegatedEvidence { + /// Consume the pair for delegated owner-bound resolution. + pub fn into_parts(self) -> (VerifiedDelegation, VerifiedNostrProof) { + (self.delegation, self.proof) + } +} + +impl fmt::Debug for VerifiedDelegatedEvidence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("VerifiedDelegatedEvidence([REDACTED])") + } +} + +/// Verify an assertion-free NIP-42 delegation against current local relationship state. +pub async fn verify_nip42_delegated( + event: &Event, + expected_challenge: &str, + relay_url: &str, + auth_tag_json: &str, + binding: &RequestEvidenceBinding, + grants: &S, + clock: &dyn EvidenceClock, +) -> Result { + let now = clock.now(); + let (owner, conditions, conditions_fingerprint, grant) = + resolve_delegation(event.pubkey, auth_tag_json, binding, grants, now).await?; + let proof = verify_nip42( + event, + expected_challenge, + relay_url, + binding, + None, + Some(conditions_fingerprint), + now, + )?; + let delegation = seal_delegation( + binding, + owner, + event.pubkey, + &conditions, + conditions_fingerprint, + grant, + )?; + Ok(VerifiedDelegatedEvidence { delegation, proof }) +} + +async fn resolve_delegation( + delegate: PublicKey, + auth_tag_json: &str, + binding: &RequestEvidenceBinding, + grants: &S, + now: DateTime, +) -> Result<(PublicKey, String, [u8; 32], AuthoritativeDelegationGrant), EvidenceError> { + let owner = buzz_sdk::nip_oa::verify_auth_tag(auth_tag_json, &delegate) + .map_err(|_| EvidenceError::InvalidDelegation)?; + let tag: Vec = + serde_json::from_str(auth_tag_json).map_err(|_| EvidenceError::InvalidDelegation)?; + let conditions = tag + .get(2) + .and_then(Value::as_str) + .ok_or(EvidenceError::InvalidDelegation)? + .to_owned(); + let conditions_fingerprint = framed_fingerprint( + b"buzz:nip-fi:delegation-conditions:v1", + &[conditions.as_bytes()], + ); + let grant = grants + .current_grant( + binding.authorization_domain, + owner, + delegate, + conditions_fingerprint, + ) + .await + .map_err(|_| EvidenceError::DependencyUnavailable)? + .ok_or(EvidenceError::InvalidDelegation)?; + if grant.expires_at <= now { + return Err(EvidenceError::Expired); + } + Ok((owner, conditions, conditions_fingerprint, grant)) +} + +fn seal_delegation( + binding: &RequestEvidenceBinding, + owner: PublicKey, + delegate: PublicKey, + _conditions: &str, + conditions_fingerprint: [u8; 32], + grant: AuthoritativeDelegationGrant, +) -> Result { + VerifiedDelegation::from_verifier( + binding.authorization_domain, + owner, + delegate, + binding.transport, + grant.relationship_id, + grant.relationship_revision, + grant.capabilities, + conditions_fingerprint, + binding.target_fingerprint, + binding.request_fingerprint, + binding.transport_context_fingerprint, + grant.expires_at, + ) + .ok_or(EvidenceError::InvalidDelegation) +} + +fn verify_nip42( + event: &Event, + expected_challenge: &str, + relay_url: &str, + binding: &RequestEvidenceBinding, + bound_assertion_fingerprint: Option<[u8; 32]>, + delegation_conditions_fingerprint: Option<[u8; 32]>, + now: DateTime, +) -> Result { + validate_proof_time(event, now)?; + crate::verify_nip42_authorization_proof( + event, + expected_challenge, + relay_url, + binding.authorization_domain, + binding.request_fingerprint, + binding.target_fingerprint, + binding.transport_context_fingerprint, + bound_assertion_fingerprint, + delegation_conditions_fingerprint, + proof_expires_at(event)?, + ) + .map_err(|_| EvidenceError::InvalidProof) +} + +fn verify_nip98( + event_json: &str, + expected_target: &CanonicalHttpRequestTarget, + expected_method: &str, + body: Option<&[u8]>, + require_payload: bool, + now: DateTime, +) -> Result { + let event: Event = serde_json::from_str(event_json).map_err(|_| EvidenceError::InvalidProof)?; + if event.kind != Kind::HttpAuth { + return Err(EvidenceError::InvalidProof); + } + buzz_core::verify_event(&event).map_err(|_| EvidenceError::InvalidProof)?; + validate_proof_time(&event, now)?; + let url = exact_tag_content( + &event, + |tag| tag.kind() == TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::U)), + true, + )? + .ok_or(EvidenceError::InvalidProof)?; + if url.as_bytes() != expected_target.as_str().as_bytes() { + return Err(EvidenceError::InvalidProof); + } + let method = exact_tag_content(&event, |tag| tag.kind() == TagKind::Method, true)? + .ok_or(EvidenceError::InvalidProof)?; + if method != expected_method { + return Err(EvidenceError::InvalidProof); + } + let payload = exact_tag_content( + &event, + |tag| tag.kind() == TagKind::Payload, + require_payload, + )?; + if require_payload && (payload.is_none() || body.is_none()) { + return Err(EvidenceError::InvalidProof); + } + if let Some(payload) = payload { + let body = body.ok_or(EvidenceError::InvalidProof)?; + let digest: [u8; 32] = Sha256::digest(body).into(); + if payload != hex::encode(digest) { + return Err(EvidenceError::InvalidProof); + } + } + Ok(event) +} + +fn exact_tag_content( + event: &Event, + matches: impl Fn(&Tag) -> bool, + required: bool, +) -> Result, EvidenceError> { + let mut matching = event.tags.iter().filter(|tag| matches(tag)); + let first = matching.next(); + if matching.next().is_some() { + return Err(EvidenceError::InvalidProof); + } + match first { + Some(tag) => tag.content().map(Some).ok_or(EvidenceError::InvalidProof), + None if required => Err(EvidenceError::InvalidProof), + None => Ok(None), + } +} + +fn exact_custom_tag_content<'a>( + event: &'a Event, + name: &str, + required: bool, +) -> Result, EvidenceError> { + exact_tag_content(event, |tag| tag.kind().to_string() == name, required) +} + +fn validate_proof_time(event: &Event, now: DateTime) -> Result<(), EvidenceError> { + let event_time = DateTime::from_timestamp(event.created_at.as_secs() as i64, 0) + .ok_or(EvidenceError::InvalidProof)?; + let delta = now + .signed_duration_since(event_time) + .num_seconds() + .unsigned_abs(); + if delta > PROOF_LIFETIME_SECONDS as u64 { + return Err(EvidenceError::Expired); + } + Ok(()) +} + +fn proof_expires_at(event: &Event) -> Result, EvidenceError> { + DateTime::from_timestamp(event.created_at.as_secs() as i64, 0) + .and_then(|event_time| { + event_time.checked_add_signed(TimeDelta::seconds(PROOF_LIFETIME_SECONDS)) + }) + .ok_or(EvidenceError::InvalidProof) +} + +fn seal_nostr_proof( + event: &Event, + transport: ProofTransport, + binding: &RequestEvidenceBinding, + bound_assertion_fingerprint: Option<[u8; 32]>, + delegation_conditions_fingerprint: Option<[u8; 32]>, +) -> Result { + seal_nostr_proof_until( + event, + transport, + binding, + bound_assertion_fingerprint, + delegation_conditions_fingerprint, + proof_expires_at(event)?, + ) +} + +fn seal_nostr_proof_until( + event: &Event, + transport: ProofTransport, + binding: &RequestEvidenceBinding, + bound_assertion_fingerprint: Option<[u8; 32]>, + delegation_conditions_fingerprint: Option<[u8; 32]>, + expires_at: DateTime, +) -> Result { + if bound_assertion_fingerprint.is_some() == delegation_conditions_fingerprint.is_some() { + return Err(EvidenceError::InvalidProof); + } + let event_time = DateTime::from_timestamp(event.created_at.as_secs() as i64, 0) + .ok_or(EvidenceError::InvalidProof)?; + let hard_expiry = event_time + .checked_add_signed(TimeDelta::seconds(PROOF_LIFETIME_SECONDS)) + .ok_or(EvidenceError::InvalidProof)?; + if expires_at <= event_time || expires_at > hard_expiry { + return Err(EvidenceError::InvalidProof); + } + let proof = VerifiedNostrProof::from_verifier( + binding.authorization_domain, + event.pubkey, + transport, + binding.request_fingerprint, + binding.target_fingerprint, + binding.transport_context_fingerprint, + bound_assertion_fingerprint, + delegation_conditions_fingerprint, + expires_at, + ) + .ok_or(EvidenceError::InvalidProof)?; + if !binding.accepts_proof(&proof) { + return Err(EvidenceError::InvalidProof); + } + Ok(proof) +} + +fn framed_coordinates(parts: &[&[u8]]) -> Vec { + let mut encoded = Vec::new(); + for part in parts { + encoded.extend_from_slice(&(part.len() as u64).to_be_bytes()); + encoded.extend_from_slice(part); + } + encoded +} + +fn media_path_names_hash(path_and_query: &str, object_sha256: &str) -> bool { + let Some(path) = path_and_query.strip_prefix("/media/") else { + return false; + }; + if path.contains('/') || path.contains('?') || path.contains('#') { + return false; + } + let mut segments = path.split('.'); + if segments.next() != Some(object_sha256) { + return false; + } + let suffixes: Vec<_> = segments.collect(); + match suffixes.as_slice() { + [] => true, + [extension] => is_safe_media_extension(extension), + ["thumb", "jpg"] => true, + _ => false, + } +} + +fn is_safe_media_extension(extension: &str) -> bool { + !extension.is_empty() + && extension.len() <= 8 + && extension + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) +} + +fn normalize_blossom_server(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) +} + +fn validate_exact_token(token: &str) -> Result<(), EvidenceError> { + if token.is_empty() || token != token.trim() || token.contains(',') { + return Err(EvidenceError::AmbiguousAssertion); + } + Ok(()) +} + +fn validate_unique_jwt_header(token: &str) -> Result<(), EvidenceError> { + let encoded = token + .split('.') + .next() + .ok_or(EvidenceError::InvalidAssertion)?; + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| EvidenceError::InvalidAssertion)?; + serde_json::from_slice::(&decoded) + .map(|_| ()) + .map_err(|_| EvidenceError::AmbiguousAssertion) +} + +fn exact_jwk_for_kid<'a>(set: &'a JwkSet, kid: &str) -> Result<&'a Jwk, EvidenceError> { + let mut matches = set + .keys + .iter() + .filter(|jwk| jwk.common.key_id.as_deref() == Some(kid)); + let first = matches.next().ok_or(EvidenceError::InvalidAssertion)?; + if matches.next().is_some() { + return Err(EvidenceError::AmbiguousAssertion); + } + Ok(first) +} + +fn is_allowed_algorithm(algorithm: Algorithm) -> bool { + matches!( + algorithm, + Algorithm::RS256 + | Algorithm::RS384 + | Algorithm::RS512 + | Algorithm::PS256 + | Algorithm::PS384 + | Algorithm::PS512 + | Algorithm::ES256 + | Algorithm::ES384 + | Algorithm::EdDSA + ) +} + +fn validate_jwk_signature_metadata(jwk: &Jwk, algorithm: Algorithm) -> Result<(), EvidenceError> { + if jwk + .common + .public_key_use + .as_ref() + .is_some_and(|usage| usage != &PublicKeyUse::Signature) + || jwk + .common + .key_operations + .as_ref() + .is_some_and(|operations| { + !operations.contains(&KeyOperations::Verify) + || operations.contains(&KeyOperations::Sign) + }) + || jwk + .common + .key_algorithm + .as_ref() + .is_some_and(|configured| !jwk_algorithm_matches(configured, algorithm)) + { + return Err(EvidenceError::InvalidAssertion); + } + Ok(()) +} + +fn jwk_algorithm_matches(configured: &KeyAlgorithm, actual: Algorithm) -> bool { + matches!( + (configured, actual), + (KeyAlgorithm::RS256, Algorithm::RS256) + | (KeyAlgorithm::RS384, Algorithm::RS384) + | (KeyAlgorithm::RS512, Algorithm::RS512) + | (KeyAlgorithm::PS256, Algorithm::PS256) + | (KeyAlgorithm::PS384, Algorithm::PS384) + | (KeyAlgorithm::PS512, Algorithm::PS512) + | (KeyAlgorithm::ES256, Algorithm::ES256) + | (KeyAlgorithm::ES384, Algorithm::ES384) + | (KeyAlgorithm::EdDSA, Algorithm::EdDSA) + ) +} + +fn exact_string_claim(claims: &Map, name: &str) -> Result { + claims + .get(name) + .and_then(Value::as_str) + .filter(|value| is_exact_identity_part(value)) + .map(ToOwned::to_owned) + .ok_or(EvidenceError::InvalidAssertion) +} + +fn verifier_policy_digest( + policy: &AssertionVerificationPolicy, + jwks: &JwkSet, +) -> Result<[u8; 32], EvidenceError> { + let mut encoded_keys = jwks + .keys + .iter() + .map(|key| serde_json::to_vec(key).map_err(|_| EvidenceError::InvalidConfiguration)) + .collect::, _>>()?; + encoded_keys.sort(); + if encoded_keys.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(EvidenceError::InvalidConfiguration); + } + let encoding = match policy.nostr_key_claim.as_ref().map(|claim| claim.encoding) { + None => 0_u8, + Some(NostrKeyClaimEncoding::LowerHex) => 1, + Some(NostrKeyClaimEncoding::CanonicalPublicKey) => 2, + }; + let claim_name = policy + .nostr_key_claim + .as_ref() + .map_or(&[][..], |claim| claim.claim_name.as_bytes()); + let maximum_age = policy.maximum_token_age.as_secs().to_be_bytes(); + let clock_skew = policy.clock_skew.as_secs().to_be_bytes(); + let key_count = (encoded_keys.len() as u64).to_be_bytes(); + let encoding_bytes = [encoding]; + let mut digest = Sha256::new(); + for field in [ + b"buzz:nip-fi:assertion-verifier-policy:v1".as_slice(), policy.issuer.as_bytes(), policy.audience.as_bytes(), policy.subject_claim.as_bytes(), @@ -743,6 +1894,26 @@ fn framed_fingerprint(label: &[u8], parts: &[&[u8]]) -> [u8; 32] { digest.finalize().into() } +fn provenance_mac( + key: &[u8], + header: &[u8], + token: &[u8], + binding: &RequestEvidenceBinding, +) -> Result { + let mut mac = ::new_from_slice(key) + .map_err(|_| EvidenceError::InvalidConfiguration)?; + mac.update(b"buzz:nip-fi:trusted-proxy:v1"); + mac.update(&(header.len() as u64).to_be_bytes()); + mac.update(header); + mac.update(&(token.len() as u64).to_be_bytes()); + mac.update(token); + mac.update(binding.authorization_domain.as_uuid().as_bytes()); + mac.update(&binding.request_fingerprint); + mac.update(&binding.target_fingerprint); + mac.update(&binding.transport_context_fingerprint); + Ok(mac) +} + struct UniqueClaims(Map); impl<'de> Deserialize<'de> for UniqueClaims { @@ -818,19 +1989,12 @@ pub enum EvidenceError { #[cfg(test)] mod tests { use super::*; - + use base64::engine::general_purpose::STANDARD; use jsonwebtoken::{encode, EncodingKey, Header}; - use nostr::{Keys, ToBech32}; - use serde_json::json; + use nostr::{EventBuilder, Keys, RelayUrl, Tag, Timestamp, ToBech32}; - const TEST_SIGNING_KEY: &[u8] = &[ - 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, - 0x20, 0xca, 0x81, 0x76, 0x9d, 0x87, 0xd0, 0x03, 0x9d, 0x8c, 0x55, 0xfa, 0x7d, 0xba, 0xbb, - 0xf4, 0x7b, 0x7c, 0x54, 0xca, 0x69, 0x05, 0x40, 0x50, 0x77, 0xea, 0x88, 0x24, 0x35, 0xcb, - 0xfa, 0xd3, 0x1f, - ]; + const RSA_PRIVATE_KEY_DER_BASE64: &str = "MIIEpAIBAAKCAQEAyRE6rHuNR0QbHO3H3Kt2pOKGVhQqGZXInOduQNxXzuKlvQTLUTv4l4sggh5/CYYi/cvI+SXVT9kPWSKXxJXBXd/4LkvcPuUakBoAkfh+eiFVMh2VrUyWyj3MFl0HTVF9KwRXLAcwkREiS3npThHRyIxuy0ZMeZfxVL5arMhw1SRELB8HoGfG/AtH89BIE9jDBHZ9dLelK9a184zAf8LwoPLxvJb3Il5nncqPcSfKDDodMFBIMc4lQzDKL5gvmiXLXB1AGLm8KBjfE8s3L5xqi+yUod+j8MtvIj812dkS4QMiRVN/by2h3ZY8LYVGrqZXZTcgn2ujn8uKjXLZVD5TdQIDAQABAoIBAHREk0I0O9DvECKdWUpAmF3mY7oY9PNQiu44Yaf+AoSuyRpRUGTMIgc3u3eivOE8ALX0BmYUO5JtuRNZDpvt4SAwqCnVUinIf6C+eH/wSurCpapSM0BAHp4aOA7igptyOMgMPYBHNA1e9A7jE0dCxKWMl3DSWNyjQTk4zeRGEAEfbNjHrq6YCtjHSZSLmWiG80hnfnYos9hOr5JnLnyS7ZmFE/5P3XVrxLc/tQ5zum0R4cbrgzHiQP5RgfxGJaEi7XcgherCCOgurJSSbYH29Gz8u5fFbS+Yg8s+OiCss3cs1rSgJ9/eHZuzGEdUZVARH6hVMjSuwvqVTFaE8AgtleECgYEA+uLMn4kNqHlJS2A5uAnCkj90ZxEtNm3E8hAxUrhssktY5XSOAPBlxyf5RuRGIImGtUVIr4HuJSa5TX48n3Vdt9MYCprO/iYl6moNRSPt5qowIIOJmIjY2mqPDfDt/zw+fcDD3lmCJrFlzcnh0uea1CohxEbQnL3cypeLt+WbU6kCgYEAzSp19m1ajieFkqgoB0YTpt/OroDx38vvI5unInJlEeOjQ+oIAQdN2wpxBvTrRorMU6P07mFUbt1j+Co6CbNiw+X8HcCaqYLR5clbJOOWNR36PuzOpQLkfK8woupBxzW9B8gZmY8rB1mbJ+/WTPrEJy6YGmIEBkWylQ2VpW8O4O0CgYEApdbvvfFBlwD9YxbrcGz7MeNCFbMz+MucqQntIKoKJ91ImPxvtc0y6e/Rhnv0oyNlaUOwJVu0yNgNG117w0g4t/+Q38mvVC5xV7/cn7x9UMFk6MkqVir3dYGEqIl/OP1grY2Tq9HtB5iyG9L8NIamQOLMyUqqMUILxdthHyFmiGkCgYEAn9+PjpjGMPHxL0gj8Q8VbzsFtou6b1deIRRA2CHmSltltR1gYVTMwXxQeUhPMmgkMqUXzs4/WijgpthY44hK1TaZEKIuoxrS70nJ4WQLf5a9k1065fDsFZD6yGjdGxvwEmlGMZgTwqV7t1I4X0Ilqhav5hcs5apYL7gnPYPeRz0CgYALHCj/Ji8XSsDoF/MhVhnGdIs2P99NNdmo3R2Pv0CuZbDKMU559LJHUvrKS8WkuWRDuKrz1W/EQKApFjDGpdqToZqriUFQzwy7mR3ayIiogzNtHcvbDHx8oFnGY0OFksX/ye0/XGpy2SFxYRwGU98HPYeBvAQQrVjdkzfy7BmXQQ=="; - #[derive(Debug)] struct FixedClock(DateTime); impl EvidenceClock for FixedClock { @@ -839,140 +2003,281 @@ mod tests { } } - fn fixture_jwk(kid: &str) -> Jwk { - serde_json::from_value(json!({ - "kty": "OKP", - "crv": "Ed25519", - "x": "oBAtP8MYjHmJjala5i0zw8HhuyXcYODdc9WAwCwC8hA", - "alg": "EdDSA", - "kid": kid, - "use": "sig", - "key_ops": ["verify"], - })) - .expect("fixture JWK") + fn fixture_now() -> DateTime { + DateTime::from_timestamp(1_800_000_000, 0).expect("valid fixture time") } - fn fixture_policy(maximum_age: Duration, with_key: bool) -> AssertionVerificationPolicy { - let policy = AssertionVerificationPolicy::new( - "https://issuer.example", - "buzz-relay", - "employee_id", - maximum_age, - Duration::from_secs(30), + fn fixture_binding() -> RequestEvidenceBinding { + fixture_binding_for(ProofTransport::Nip42) + } + + fn fixture_binding_for(transport: ProofTransport) -> RequestEvidenceBinding { + RequestEvidenceBinding::new( + CommunityId::from_uuid(Uuid::from_u128(1)), + transport, + b"POST /protected", + b"protected-object", + b"connection-epoch", ) - .expect("fixture policy"); - if with_key { - policy - .with_nostr_key_claim( - NostrKeyClaimPolicy::canonical_public_key("nostr_pubkey").expect("key claim"), + .expect("valid binding") + } + + #[test] + fn request_binding_accepts_only_exact_root_proof_coordinates() { + let actor = Keys::generate().public_key(); + for (transport, substitutes) in [ + ( + ProofTransport::GitSmartHttpSession, + [ProofTransport::Nip98, ProofTransport::Blossom], + ), + ( + ProofTransport::Blossom, + [ProofTransport::Nip98, ProofTransport::GitSmartHttpSession], + ), + ] { + let binding = fixture_binding_for(transport); + let proof = |domain, + transport, + request_fingerprint, + target_fingerprint, + transport_context_fingerprint| { + VerifiedNostrProof::from_verifier( + domain, + actor, + transport, + request_fingerprint, + target_fingerprint, + transport_context_fingerprint, + Some([2; 32]), + None, + fixture_now() + TimeDelta::seconds(30), ) - .expect("install key claim") - } else { - policy + .expect("valid fixture proof") + }; + let exact = proof( + binding.authorization_domain, + transport, + binding.request_fingerprint, + binding.target_fingerprint, + binding.transport_context_fingerprint, + ); + assert!(binding.accepts_proof(&exact)); + + for substitute in substitutes { + assert!(!binding.accepts_proof(&proof( + binding.authorization_domain, + substitute, + binding.request_fingerprint, + binding.target_fingerprint, + binding.transport_context_fingerprint, + ))); + } + for changed in [ + proof( + CommunityId::from_uuid(Uuid::from_u128(2)), + transport, + binding.request_fingerprint, + binding.target_fingerprint, + binding.transport_context_fingerprint, + ), + proof( + binding.authorization_domain, + transport, + [12; 32], + binding.target_fingerprint, + binding.transport_context_fingerprint, + ), + proof( + binding.authorization_domain, + transport, + binding.request_fingerprint, + [12; 32], + binding.transport_context_fingerprint, + ), + proof( + binding.authorization_domain, + transport, + binding.request_fingerprint, + binding.target_fingerprint, + [12; 32], + ), + ] { + assert!(!binding.accepts_proof(&changed)); + } } } - fn verified( - actor: PublicKey, - maximum_age: Duration, - with_key: bool, - ) -> ( - StaticJwksAssertionVerifier, - ExactSingleHttpHeader, - RequestEvidenceBinding, - ) { - let now = DateTime::from_timestamp(1_750_000_000, 0).expect("fixture time"); - let verifier = StaticJwksAssertionVerifier::with_clock( - fixture_policy(maximum_age, with_key), + fn rsa_private_key() -> EncodingKey { + let private_key = STANDARD + .decode(RSA_PRIVATE_KEY_DER_BASE64) + .expect("decode fixture key"); + EncodingKey::from_rsa_der(&private_key) + } + + fn rsa_jwk(private_key: &EncodingKey, kid: &str) -> Jwk { + let mut jwk = + Jwk::from_encoding_key(private_key, Algorithm::RS256).expect("derive public JWK"); + jwk.common.key_id = Some(kid.to_owned()); + jwk.common.public_key_use = Some(PublicKeyUse::Signature); + jwk.common.key_operations = Some(vec![KeyOperations::Verify]); + jwk + } + + fn verifier_at(now: DateTime) -> StaticJwksAssertionVerifier { + let key = rsa_private_key(); + StaticJwksAssertionVerifier::with_clock( + AssertionVerificationPolicy::new( + "https://issuer.example", + "buzz-relay", + "employee_id", + Duration::from_secs(300), + Duration::from_secs(30), + ) + .expect("valid policy"), JwkSet { - keys: vec![fixture_jwk("fixture-key")], + keys: vec![rsa_jwk(&key, "key-1")], }, Arc::new(FixedClock(now)), ) - .expect("fixture verifier"); - let claims = if with_key { - json!({ - "iss": "https://issuer.example", - "aud": "buzz-relay", - "employee_id": "opaque-subject", - "nostr_pubkey": actor.to_hex(), - "iat": now.timestamp(), - "nbf": now.timestamp(), - "exp": now.timestamp() + 600, - }) - } else { - json!({ - "iss": "https://issuer.example", - "aud": "buzz-relay", - "employee_id": "opaque-subject", - "iat": now.timestamp(), - "nbf": now.timestamp(), - "exp": now.timestamp() + 600, - }) - }; - let mut header = Header::new(Algorithm::EdDSA); - header.kid = Some("fixture-key".to_owned()); - let token = encode( - &header, - &claims, - &EncodingKey::from_ed_der(TEST_SIGNING_KEY), + .expect("valid verifier") + } + + fn verifier_with_nostr_key_at(now: DateTime) -> StaticJwksAssertionVerifier { + let key = rsa_private_key(); + let policy = AssertionVerificationPolicy::new( + "https://issuer.example", + "buzz-relay", + "employee_id", + Duration::from_secs(300), + Duration::from_secs(30), ) - .expect("sign fixture assertion"); - let name = HeaderName::from_static("x-buzz-identity"); + .expect("valid policy") + .with_nostr_key_claim( + NostrKeyClaimPolicy::canonical_public_key("nostr_pubkey").expect("key claim"), + ) + .expect("install key claim"); + StaticJwksAssertionVerifier::with_clock( + policy, + JwkSet { + keys: vec![rsa_jwk(&key, "key-1")], + }, + Arc::new(FixedClock(now)), + ) + .expect("valid verifier") + } + + fn jwt_at(now: DateTime, mutate: impl FnOnce(&mut Value)) -> String { + let key = rsa_private_key(); + let mut claims = serde_json::json!({ + "iss": "https://issuer.example", + "aud": "buzz-relay", + "employee_id": "opaque-123", + "iat": now.timestamp(), + "nbf": now.timestamp(), + "exp": now.timestamp() + 600, + }); + mutate(&mut claims); + let mut header = Header::new(Algorithm::RS256); + header.kid = Some("key-1".to_owned()); + encode(&header, &claims, &key).expect("sign fixture JWT") + } + + fn proxy_proof( + key: &[u8], + header: &str, + token: &str, + binding: &RequestEvidenceBinding, + ) -> String { + let mac = + provenance_mac(key, header.as_bytes(), token.as_bytes(), binding).expect("fixture MAC"); + hex::encode(mac.finalize().into_bytes()) + } + + fn exact_assertion_header(token: &str) -> ExactSingleHttpHeader { let mut headers = HeaderMap::new(); - headers.insert(name.clone(), token.parse().expect("header")); - let exact = ExactSingleHttpHeader::from_headers(&headers, &name).expect("exact header"); - let binding = RequestEvidenceBinding::new( - CommunityId::from_uuid(uuid::Uuid::from_u128(7)), - ProofTransport::Nip42, - b"fixture-request", - b"fixture-target", - b"fixture-transport", + headers.insert( + HeaderName::from_static("x-buzz-identity"), + token.parse().expect("fixture header value"), + ); + ExactSingleHttpHeader::from_headers(&headers, &HeaderName::from_static("x-buzz-identity")) + .expect("one assertion header") + } + + fn protected_target(path: &str) -> CanonicalHttpRequestTarget { + CanonicalHttpRequestTarget::from_server_parts( + &Scheme::HTTPS, + &"relay.example".parse().expect("fixture authority"), + &path.parse().expect("fixture path and query"), ) - .expect("binding"); - (verifier, exact, binding) + .expect("canonical target") } #[test] - fn exact_header_rejects_missing_duplicate_comma_and_whitespace() { - let name = HeaderName::from_static("x-buzz-identity"); - assert!(matches!( - ExactSingleHttpHeader::from_headers(&HeaderMap::new(), &name), - Err(EvidenceError::AmbiguousAssertion) - )); - for value in ["a,b", " value", "value "] { - let mut headers = HeaderMap::new(); - headers.insert(name.clone(), value.parse().expect("header")); - assert!(ExactSingleHttpHeader::from_headers(&headers, &name).is_err()); + fn opaque_subject_policy_rejects_profile_claims() { + for claim in [ + "email", + "email_address", + "username", + "preferred_username", + "name", + "display-name", + "given.name", + ] { + assert!( + matches!( + AssertionVerificationPolicy::new( + "issuer", + "audience", + claim, + Duration::from_secs(60), + Duration::ZERO, + ), + Err(EvidenceError::InvalidConfiguration) + ), + "{claim} must not be an opaque subject adapter" + ); } - let mut duplicated = HeaderMap::new(); - duplicated.append(name.clone(), "value".parse().expect("header")); - duplicated.append(name.clone(), "value".parse().expect("header")); - assert!(ExactSingleHttpHeader::from_headers(&duplicated, &name).is_err()); + assert!(AssertionVerificationPolicy::new( + "issuer", + "audience", + "employee_id", + Duration::from_secs(60), + Duration::ZERO, + ) + .is_ok()); } #[test] fn attested_actor_is_sealed_and_substitution_is_rejected() { + let now = fixture_now(); let actor = Keys::generate().public_key(); - let (verifier, token, binding) = verified(actor, Duration::from_secs(300), true); - let assertion = verifier - .verify_client_attached(&token, &binding) - .expect("verified assertion"); - let evidence = assertion + let binding = fixture_binding(); + let token = jwt_at(now, |claims| { + claims["nostr_pubkey"] = Value::from(actor.to_hex()); + }); + let verifier = verifier_with_nostr_key_at(now); + let evidence = verifier + .verify_client_attached(&exact_assertion_header(&token), &binding) + .expect("verified assertion") .bind_authenticated_actor(actor) .expect("exact actor"); assert!(evidence.enrollment_permitted()); - assert!(evidence.accepts_authorization_coordinates(binding.authorization_domain(), actor,)); + assert!(evidence.accepts_authorization_coordinates(binding.authorization_domain(), actor)); assert_ne!(evidence.evidence_fingerprint(), [0; 32]); - assert_ne!(evidence.enrollment_policy_digest(), [0; 32]); + assert_eq!( + evidence.enrollment_policy_digest(), + verifier.enrollment_policy_digest() + ); assert_eq!( format!("{evidence:?}"), "VerifiedIdentityBindingEvidence([REDACTED])" ); - let (verifier, token, binding) = verified(actor, Duration::from_secs(300), true); - assert!(verifier - .verify_client_attached(&token, &binding) + let token = jwt_at(now, |claims| { + claims["nostr_pubkey"] = Value::from(actor.to_hex()); + }); + assert!(verifier_with_nostr_key_at(now) + .verify_client_attached(&exact_assertion_header(&token), &binding) .expect("verified assertion") .bind_authenticated_actor(Keys::generate().public_key()) .is_err()); @@ -980,10 +2285,12 @@ mod tests { #[test] fn claimless_assertion_is_existing_binding_only() { + let now = fixture_now(); let actor = Keys::generate().public_key(); - let (verifier, token, binding) = verified(actor, Duration::from_secs(300), false); - let evidence = verifier - .verify_client_attached(&token, &binding) + let binding = fixture_binding(); + let token = jwt_at(now, |_| {}); + let evidence = verifier_at(now) + .verify_client_attached(&exact_assertion_header(&token), &binding) .expect("verified assertion") .bind_authenticated_actor(actor) .expect("authenticated actor"); @@ -1011,9 +2318,21 @@ mod tests { #[test] fn verifier_policy_digest_binds_jwks_and_ignores_key_order() { - let policy = fixture_policy(Duration::from_secs(300), true); - let first = fixture_jwk("first"); - let second = fixture_jwk("second"); + let policy = AssertionVerificationPolicy::new( + "https://issuer.example", + "buzz-relay", + "employee_id", + Duration::from_secs(300), + Duration::from_secs(30), + ) + .expect("valid policy") + .with_nostr_key_claim( + NostrKeyClaimPolicy::canonical_public_key("nostr_pubkey").expect("key claim"), + ) + .expect("install key claim"); + let key = rsa_private_key(); + let first = rsa_jwk(&key, "first"); + let second = rsa_jwk(&key, "second"); let forward = verifier_policy_digest( &policy, &JwkSet { @@ -1034,4 +2353,853 @@ mod tests { verifier_policy_digest(&policy, &JwkSet { keys: vec![first] }).expect("different keys") ); } + + #[test] + fn client_assertion_requires_iat_and_uses_exclusive_age_bound() { + let now = fixture_now(); + let verifier = verifier_at(now); + let binding = fixture_binding(); + let valid_token = jwt_at(now, |_| {}); + assert!(verifier + .verify_client_attached(&exact_assertion_header(&valid_token), &binding) + .is_ok()); + + let missing_iat = jwt_at(now, |claims| { + claims.as_object_mut().expect("claims object").remove("iat"); + }); + assert!(matches!( + verifier.verify_client_attached(&exact_assertion_header(&missing_iat), &binding), + Err(EvidenceError::InvalidAssertion) + )); + + let age_boundary = jwt_at(now, |claims| { + claims["iat"] = Value::from(now.timestamp() - 300); + }); + assert!(matches!( + verifier.verify_client_attached(&exact_assertion_header(&age_boundary), &binding), + Err(EvidenceError::Expired) + )); + + let audience_alternative = jwt_at(now, |claims| { + claims["aud"] = serde_json::json!(["buzz-relay"]); + }); + assert!(matches!( + verifier + .verify_client_attached(&exact_assertion_header(&audience_alternative), &binding), + Err(EvidenceError::InvalidAssertion) + )); + + let control_subject = jwt_at(now, |claims| { + claims["employee_id"] = Value::from("opaque\nsubject"); + }); + assert!(matches!( + verifier.verify_client_attached(&exact_assertion_header(&control_subject), &binding), + Err(EvidenceError::InvalidAssertion) + )); + } + + #[test] + fn trusted_proxy_requires_exact_mac_and_origin_changes_fingerprint() { + let now = fixture_now(); + let token = jwt_at(now, |_| {}); + let binding = fixture_binding(); + let key = [9_u8; 32]; + let proxy = verifier_at(now) + .trusted_proxy("x-buzz-identity", "x-buzz-identity-proof", key) + .expect("valid proxy verifier"); + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("x-buzz-identity"), + token.parse().expect("token header"), + ); + headers.insert( + HeaderName::from_static("x-buzz-identity-proof"), + "0".repeat(64).parse().expect("proof header"), + ); + assert!(matches!( + proxy.verify(&headers, &binding), + Err(EvidenceError::UntrustedOrigin) + )); + let proof = proxy_proof(&key, "x-buzz-identity", &token, &binding); + headers.insert( + HeaderName::from_static("x-buzz-identity-proof"), + proof.parse().expect("proof header"), + ); + let proxy_assertion = proxy.verify(&headers, &binding).expect("valid proxy proof"); + let other_binding = RequestEvidenceBinding::new( + CommunityId::from_uuid(Uuid::from_u128(1)), + ProofTransport::Nip42, + b"POST /other", + b"protected-object", + b"connection-epoch", + ) + .expect("valid alternate binding"); + assert!(matches!( + proxy.verify(&headers, &other_binding), + Err(EvidenceError::UntrustedOrigin) + )); + let client_assertion = verifier_at(now) + .verify_client_attached(&exact_assertion_header(&token), &binding) + .expect("valid client assertion"); + assert_ne!( + proxy_assertion.assertion_fingerprint, + client_assertion.assertion_fingerprint + ); + } + + #[test] + fn duplicate_header_and_claim_names_fail_closed() { + let duplicate_header = "eyJhbGciOiJSUzI1NiIsImFsZyI6IlJTMjU2Iiwia2lkIjoia2V5LTEifQ.e30.AA"; + assert_eq!( + validate_unique_jwt_header(duplicate_header), + Err(EvidenceError::AmbiguousAssertion) + ); + let duplicate_claims = br#"{"sub":"a","sub":"b"}"#; + assert!(serde_json::from_slice::(duplicate_claims).is_err()); + } + + #[test] + fn exact_http_header_rejects_missing_repeated_comma_and_rewritten_values() { + let name = HeaderName::from_static("x-buzz-identity"); + assert!(matches!( + ExactSingleHttpHeader::from_headers(&HeaderMap::new(), &name), + Err(EvidenceError::AmbiguousAssertion) + )); + + let mut repeated = HeaderMap::new(); + repeated.append(name.clone(), "token-a".parse().expect("header")); + repeated.append(name.clone(), "token-a".parse().expect("header")); + assert!(matches!( + ExactSingleHttpHeader::from_headers(&repeated, &name), + Err(EvidenceError::AmbiguousAssertion) + )); + + for ambiguous in ["token-a,token-b", " token-a", "token-a "] { + let mut headers = HeaderMap::new(); + headers.insert( + name.clone(), + http::HeaderValue::from_bytes(ambiguous.as_bytes()).expect("header value"), + ); + assert!(matches!( + ExactSingleHttpHeader::from_headers(&headers, &name), + Err(EvidenceError::AmbiguousAssertion) + )); + } + } + + #[test] + fn trusted_proxy_rejects_duplicate_assertion_or_provenance_occurrences() { + let now = fixture_now(); + let token = jwt_at(now, |_| {}); + let binding = fixture_binding(); + let key = [7_u8; 32]; + let proof = proxy_proof(&key, "x-buzz-identity", &token, &binding); + let proxy = verifier_at(now) + .trusted_proxy("x-buzz-identity", "x-buzz-identity-proof", key) + .expect("proxy verifier"); + + for duplicated_name in ["x-buzz-identity", "x-buzz-identity-proof"] { + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("x-buzz-identity"), + token.parse().expect("token header"), + ); + headers.insert( + HeaderName::from_static("x-buzz-identity-proof"), + proof.parse().expect("proof header"), + ); + headers.append( + HeaderName::from_bytes(duplicated_name.as_bytes()).expect("header name"), + if duplicated_name == "x-buzz-identity" { + token.parse().expect("duplicate token") + } else { + proof.parse().expect("duplicate proof") + }, + ); + assert!(matches!( + proxy.verify(&headers, &binding), + Err(EvidenceError::AmbiguousAssertion) + )); + } + } + + #[test] + fn canonical_request_target_preserves_route_significant_bytes() { + let without_slash = protected_target("/protected"); + let with_slash = protected_target("/protected/"); + let with_query = protected_target("/protected?part=1&part=2"); + assert_eq!(without_slash.as_str(), "https://relay.example/protected"); + assert_eq!(with_slash.as_str(), "https://relay.example/protected/"); + assert_eq!( + with_query.as_str(), + "https://relay.example/protected?part=1&part=2" + ); + assert_ne!(without_slash, with_slash); + } + + #[test] + fn nip42_pair_binds_exact_assertion_and_actor() { + let now = Utc::now(); + let clock = FixedClock(now); + let binding = fixture_binding(); + let assertion = verifier_at(now) + .verify_client_attached(&exact_assertion_header(&jwt_at(now, |_| {})), &binding) + .expect("valid assertion"); + let keys = Keys::generate(); + let relay = RelayUrl::parse("wss://relay.example").expect("relay URL"); + let event = EventBuilder::auth("challenge", relay) + .custom_created_at(Timestamp::from(now.timestamp() as u64)) + .sign_with_keys(&keys) + .expect("sign AUTH event"); + let pair = verify_nip42_direct( + &event, + "challenge", + "wss://relay.example", + &binding, + assertion, + &clock, + ) + .expect("valid direct pair"); + let (_, proof) = pair.into_parts(); + assert_eq!(proof.actor_pubkey(), keys.public_key()); + } + + #[tokio::test] + async fn protected_nip98_requires_payload_and_claims_replay_marker() { + let now = fixture_now(); + let clock = FixedClock(now); + let binding = fixture_binding_for(ProofTransport::Nip98); + let token = jwt_at(now, |_| {}); + let keys = Keys::generate(); + let body = b"payload"; + let payload = hex::encode(Sha256::digest(body)); + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags([ + Tag::parse(["u", "https://relay.example/protected"]).expect("url tag"), + Tag::parse(["method", "POST"]).expect("method tag"), + Tag::parse(["payload", &payload]).expect("payload tag"), + ]) + .custom_created_at(Timestamp::from(now.timestamp() as u64)) + .sign_with_keys(&keys) + .expect("sign NIP-98 event"); + let event_json = serde_json::to_string(&event).expect("serialize event"); + let assertion = verifier_at(now) + .verify_client_attached(&exact_assertion_header(&token), &binding) + .expect("valid assertion"); + let pair = verify_nip98_direct( + &event_json, + &protected_target("/protected"), + "POST", + Some(body), + true, + &binding, + assertion, + &crate::nip98_replay::AlwaysFreshReplayGuard, + &clock, + ) + .await + .expect("valid NIP-98 pair"); + let (_, proof) = pair.into_parts(); + assert_eq!(proof.actor_pubkey(), keys.public_key()); + + let assertion = verifier_at(now) + .verify_client_attached(&exact_assertion_header(&token), &binding) + .expect("valid assertion"); + assert!(matches!( + verify_nip98_direct( + &serde_json::to_string( + &EventBuilder::new(Kind::HttpAuth, "") + .tags([ + Tag::parse(["u", "https://relay.example/protected"]).expect("url tag"), + Tag::parse(["method", "POST"]).expect("method tag"), + ]) + .custom_created_at(Timestamp::from(now.timestamp() as u64)) + .sign_with_keys(&keys) + .expect("sign NIP-98 event") + ) + .expect("serialize event"), + &protected_target("/protected"), + "POST", + Some(body), + true, + &binding, + assertion, + &crate::nip98_replay::AlwaysFreshReplayGuard, + &clock, + ) + .await, + Err(EvidenceError::InvalidProof) + )); + } + + fn signed_nip98(now: DateTime, keys: &Keys, tags: Vec) -> String { + serde_json::to_string( + &EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .custom_created_at(Timestamp::from(now.timestamp() as u64)) + .sign_with_keys(keys) + .expect("sign NIP-98 event"), + ) + .expect("serialize NIP-98 event") + } + + fn direct_assertion( + now: DateTime, + binding: &RequestEvidenceBinding, + ) -> VerifiedAssertionEvidence { + verifier_at(now) + .verify_client_attached(&exact_assertion_header(&jwt_at(now, |_| {})), binding) + .expect("valid direct assertion") + } + + fn git_route( + owner: &str, + repo: &str, + method: Method, + path_and_query: &str, + ) -> CanonicalGitSmartHttpRequest { + CanonicalGitSmartHttpRequest::from_server_parts( + &Scheme::HTTPS, + &"relay.example".parse().expect("authority"), + owner, + repo, + &method, + &path_and_query.parse().expect("path and query"), + ) + .expect("canonical Git request") + } + + fn signed_git_session( + now: DateTime, + keys: &Keys, + repo_root: &str, + method: &str, + extra_tags: impl IntoIterator, + ) -> String { + let mut tags = vec![ + Tag::parse(["u", repo_root]).expect("repo-root tag"), + Tag::parse(["method", method]).expect("method tag"), + ]; + tags.extend(extra_tags); + signed_nip98(now, keys, tags) + } + + #[test] + fn git_session_maps_only_closed_operations_and_binds_exact_target() { + let now = fixture_now(); + let clock = FixedClock(now); + let owner_a = "a".repeat(64); + let owner_b = "b".repeat(64); + let keys = Keys::generate(); + let signed = signed_git_session( + now, + &keys, + &format!("https://relay.example/git/{owner_a}/repo"), + "GET", + [], + ); + let cases = [ + ( + Method::GET, + format!("/git/{owner_a}/repo/info/refs?service=git-upload-pack"), + GitSmartHttpOperation::AdvertiseUploadPack, + ), + ( + Method::GET, + format!("/git/{owner_a}/repo/info/refs?service=git-receive-pack"), + GitSmartHttpOperation::AdvertiseReceivePack, + ), + ( + Method::POST, + format!("/git/{owner_a}/repo/git-upload-pack"), + GitSmartHttpOperation::UploadPack, + ), + ( + Method::POST, + format!("/git/{owner_a}/repo/git-receive-pack"), + GitSmartHttpOperation::ReceivePack, + ), + ]; + for (method, path, expected_operation) in cases { + let request = git_route(&owner_a, "repo", method, &path); + assert_eq!(request.operation(), expected_operation); + let binding = request + .evidence_binding(CommunityId::from_uuid(Uuid::from_u128(1))) + .expect("binding"); + let authorization = verify_git_smart_http_authorization(&signed, &request, &clock) + .expect("origin-verified session authorization"); + let pair = verify_git_smart_http_direct( + &authorization, + &binding, + direct_assertion(now, &binding), + ) + .expect("one reusable session credential admits the closed operation"); + let (_, proof) = pair.into_parts(); + assert_eq!(proof.actor_pubkey(), keys.public_key()); + assert_eq!(proof.transport(), ProofTransport::GitSmartHttpSession); + } + + let route_a = git_route( + &owner_a, + "repo", + Method::POST, + &format!("/git/{owner_a}/repo/git-upload-pack"), + ); + let route_b = git_route( + &owner_b, + "repo", + Method::POST, + &format!("/git/{owner_b}/repo/git-upload-pack"), + ); + let binding_b = route_b + .evidence_binding(CommunityId::from_uuid(Uuid::from_u128(1))) + .expect("binding B"); + let authorization_a = verify_git_smart_http_authorization(&signed, &route_a, &clock) + .expect("route A authorization"); + assert!(matches!( + verify_git_smart_http_direct( + &authorization_a, + &binding_b, + direct_assertion(now, &binding_b), + ), + Err(EvidenceError::InvalidBinding) + )); + } + + #[test] + fn git_session_rejects_method_payload_repo_alias_and_expiry_boundaries() { + let now = fixture_now(); + let owner = "a".repeat(64); + let request = git_route( + &owner, + "repo", + Method::POST, + &format!("/git/{owner}/repo/git-upload-pack"), + ); + let keys = Keys::generate(); + for signed in [ + signed_git_session( + now, + &keys, + &format!("https://relay.example/git/{owner}/repo"), + "POST", + [], + ), + signed_git_session( + now, + &keys, + &format!("https://relay.example/git/{owner}/other"), + "GET", + [], + ), + signed_git_session( + now, + &keys, + &format!("https://relay.example/git/{owner}/repo"), + "GET", + [Tag::parse(["payload", "00"]).expect("payload tag")], + ), + signed_git_session( + now, + &keys, + &format!("https://relay.example/git/{owner}/repo"), + "GET", + [Tag::parse(["method", "GET"]).expect("duplicate method")], + ), + ] { + assert!(matches!( + verify_git_smart_http_authorization(&signed, &request, &FixedClock(now),), + Err(EvidenceError::InvalidProof) + )); + } + + let expired_at_equality = signed_git_session( + now - TimeDelta::seconds(PROOF_LIFETIME_SECONDS), + &keys, + &format!("https://relay.example/git/{owner}/repo"), + "GET", + [], + ); + assert!(matches!( + verify_git_smart_http_authorization(&expired_at_equality, &request, &FixedClock(now),), + Err(EvidenceError::Expired) + )); + + for (method, path) in [ + (Method::GET, format!("/git/{owner}/repo/git-upload-pack")), + ( + Method::POST, + format!("/git/{owner}/repo/info/refs?service=git-upload-pack"), + ), + ( + Method::GET, + format!("/git/{owner}/repo/info/refs?service=other"), + ), + ] { + assert!(matches!( + CanonicalGitSmartHttpRequest::from_server_parts( + &Scheme::HTTPS, + &"relay.example".parse().expect("authority"), + &owner, + "repo", + &method, + &path.parse().expect("path"), + ), + Err(EvidenceError::InvalidBinding) + )); + } + } + + fn blossom_route(method: Method, path: &str, object_sha256: &str) -> CanonicalBlossomRequest { + CanonicalBlossomRequest::from_server_parts( + &Scheme::HTTPS, + &"relay.example".parse().expect("authority"), + &method, + &path.parse().expect("path"), + object_sha256, + ) + .expect("canonical Blossom request") + } + + fn signed_blossom( + now: DateTime, + keys: &Keys, + verb: &str, + expiration: i64, + scopes: impl IntoIterator, + ) -> Event { + let mut tags = vec![ + Tag::parse(["t", verb]).expect("verb tag"), + Tag::parse(["expiration", &expiration.to_string()]).expect("expiration tag"), + ]; + tags.extend(scopes); + EventBuilder::new(Kind::from(24_242), "Authorize Blossom request") + .tags(tags) + .custom_created_at(Timestamp::from(now.timestamp() as u64)) + .sign_with_keys(keys) + .expect("sign Blossom event") + } + + #[test] + fn blossom_direct_binds_exact_action_object_or_server_scope() { + let now = fixture_now(); + let clock = FixedClock(now); + let keys = Keys::generate(); + let hash_a = "a".repeat(64); + let hash_b = "b".repeat(64); + for (request, event) in [ + ( + blossom_route(Method::PUT, "/upload", &hash_a), + signed_blossom( + now, + &keys, + "upload", + now.timestamp() + 30, + [Tag::parse(["x", &hash_a]).expect("object scope")], + ), + ), + ( + blossom_route(Method::GET, &format!("/media/{hash_a}.jpg"), &hash_a), + signed_blossom( + now, + &keys, + "get", + now.timestamp() + 30, + [Tag::parse(["x", &hash_a]).expect("object scope")], + ), + ), + ( + blossom_route(Method::HEAD, &format!("/media/{hash_a}"), &hash_a), + signed_blossom( + now, + &keys, + "get", + now.timestamp() + 30, + [Tag::parse(["server", "relay.example"]).expect("server scope")], + ), + ), + ] { + let binding = request + .evidence_binding(CommunityId::from_uuid(Uuid::from_u128(1))) + .expect("binding"); + let authorization = verify_blossom_authorization(&event, &request, &clock) + .expect("valid canonical Blossom authorization"); + let pair = + verify_blossom_direct(&authorization, &binding, direct_assertion(now, &binding)) + .expect("valid Blossom pair"); + let (_, proof) = pair.into_parts(); + assert_eq!(proof.actor_pubkey(), keys.public_key()); + assert_eq!(proof.transport(), ProofTransport::Blossom); + } + + let route_a = blossom_route(Method::GET, &format!("/media/{hash_a}"), &hash_a); + let route_b = blossom_route(Method::GET, &format!("/media/{hash_b}"), &hash_b); + let binding_b = route_b + .evidence_binding(CommunityId::from_uuid(Uuid::from_u128(1))) + .expect("binding B"); + let event_a = signed_blossom( + now, + &keys, + "get", + now.timestamp() + 30, + [Tag::parse(["x", &hash_a]).expect("object scope")], + ); + let authorization_a = verify_blossom_authorization(&event_a, &route_a, &clock) + .expect("valid route A authorization"); + assert!(matches!( + verify_blossom_direct( + &authorization_a, + &binding_b, + direct_assertion(now, &binding_b), + ), + Err(EvidenceError::InvalidBinding) + )); + } + + #[test] + fn blossom_rejects_ambiguous_scope_action_server_and_expiry() { + let now = fixture_now(); + let keys = Keys::generate(); + let hash = "a".repeat(64); + let request = blossom_route(Method::GET, &format!("/media/{hash}"), &hash); + let rejects = [ + signed_blossom( + now, + &keys, + "upload", + now.timestamp() + 30, + [Tag::parse(["x", &hash]).expect("scope")], + ), + signed_blossom( + now, + &keys, + "get", + now.timestamp() + 30, + [ + Tag::parse(["x", &hash]).expect("object scope"), + Tag::parse(["server", "relay.example"]).expect("server scope"), + ], + ), + signed_blossom( + now, + &keys, + "get", + now.timestamp() + 30, + [Tag::parse(["server", "other.example"]).expect("server scope")], + ), + signed_blossom( + now, + &keys, + "get", + now.timestamp(), + [Tag::parse(["x", &hash]).expect("scope")], + ), + signed_blossom( + now - TimeDelta::seconds(PROOF_LIFETIME_SECONDS), + &keys, + "get", + now.timestamp() + 30, + [Tag::parse(["x", &hash]).expect("scope")], + ), + signed_blossom( + now, + &keys, + "get", + now.timestamp() + 30, + [ + Tag::parse(["x", &hash]).expect("scope"), + Tag::parse(["x", &hash]).expect("duplicate scope"), + ], + ), + ]; + for event in rejects { + assert!(matches!( + verify_blossom_authorization(&event, &request, &FixedClock(now)), + Err(EvidenceError::InvalidProof | EvidenceError::Expired) + )); + } + assert!(matches!( + verify_git_smart_http_delegated(), + Err(EvidenceError::DelegatedAuthorityUnavailable) + )); + assert!(matches!( + verify_blossom_delegated(), + Err(EvidenceError::DelegatedAuthorityUnavailable) + )); + } + + #[test] + fn nip98_rejects_route_aliases_and_every_duplicate_required_tag_order() { + let now = fixture_now(); + let keys = Keys::generate(); + let body = b"payload"; + let payload = hex::encode(Sha256::digest(body)); + let url = || Tag::parse(["u", "https://relay.example/protected"]).expect("url tag"); + let method = || Tag::parse(["method", "POST"]).expect("method tag"); + let payload_tag = || Tag::parse(["payload", payload.as_str()]).expect("payload tag"); + let rejects = vec![ + vec![ + Tag::parse(["u", "https://relay.example/protected/"]).expect("slash URL"), + method(), + payload_tag(), + ], + vec![url(), url(), method(), payload_tag()], + vec![ + url(), + Tag::parse(["u", "https://relay.example/other"]).expect("other URL"), + method(), + payload_tag(), + ], + vec![ + Tag::parse(["u", "https://relay.example/other"]).expect("other URL"), + url(), + method(), + payload_tag(), + ], + vec![url(), method(), method(), payload_tag()], + vec![ + url(), + Tag::parse(["method", "GET"]).expect("other method"), + method(), + payload_tag(), + ], + vec![ + url(), + method(), + Tag::parse(["method", "GET"]).expect("other method"), + payload_tag(), + ], + vec![url(), method(), payload_tag(), payload_tag()], + vec![ + url(), + method(), + Tag::parse(["payload", "00"]).expect("other payload"), + payload_tag(), + ], + vec![ + url(), + method(), + payload_tag(), + Tag::parse(["payload", "00"]).expect("other payload"), + ], + vec![method(), payload_tag()], + vec![url(), payload_tag()], + vec![url(), method()], + ]; + + for tags in rejects { + let event = signed_nip98(now, &keys, tags); + assert!(matches!( + verify_nip98( + &event, + &protected_target("/protected"), + "POST", + Some(body), + true, + now, + ), + Err(EvidenceError::InvalidProof) + )); + } + + let valid = signed_nip98(now, &keys, vec![url(), method(), payload_tag()]); + assert!(verify_nip98( + &valid, + &protected_target("/protected"), + "POST", + Some(body), + true, + now, + ) + .is_ok()); + + let malformed_optional_payload = signed_nip98( + now, + &keys, + vec![ + url(), + method(), + Tag::parse(["payload"]).expect("malformed payload tag"), + ], + ); + assert!(matches!( + verify_nip98( + &malformed_optional_payload, + &protected_target("/protected"), + "POST", + None, + false, + now, + ), + Err(EvidenceError::InvalidProof) + )); + } + + struct FixedGrant(AuthoritativeDelegationGrant); + + impl DelegationGrantSource for FixedGrant { + type Error = (); + + async fn current_grant( + &self, + _authorization_domain: CommunityId, + _owner_pubkey: PublicKey, + _delegate_pubkey: PublicKey, + _conditions_fingerprint: [u8; 32], + ) -> Result, Self::Error> { + Ok(Some(self.0.clone())) + } + } + + #[tokio::test] + async fn delegated_pair_is_assertion_free_and_revision_exact() { + let now = Utc::now(); + let clock = FixedClock(now); + let binding = fixture_binding(); + let owner = Keys::generate(); + let delegate = Keys::generate(); + let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &delegate.public_key(), "") + .expect("valid delegation signature"); + let event = EventBuilder::auth( + "challenge", + RelayUrl::parse("wss://relay.example").expect("relay URL"), + ) + .custom_created_at(Timestamp::from(now.timestamp() as u64)) + .sign_with_keys(&delegate) + .expect("sign AUTH event"); + let grants = FixedGrant( + AuthoritativeDelegationGrant::from_local_store( + Uuid::from_u128(7), + 9, + vec![RouteCapability::AudioJoin], + now + TimeDelta::seconds(30), + ) + .expect("valid grant"), + ); + let pair = verify_nip42_delegated( + &event, + "challenge", + "wss://relay.example", + &auth_tag, + &binding, + &grants, + &clock, + ) + .await + .expect("valid delegated pair"); + let (delegation, proof) = pair.into_parts(); + assert_eq!(delegation.owner_pubkey(), owner.public_key()); + assert_eq!(delegation.delegate_pubkey(), delegate.public_key()); + assert_eq!(proof.actor_pubkey(), delegate.public_key()); + } + + #[test] + fn debug_output_redacts_all_evidence() { + let binding = fixture_binding(); + assert_eq!(format!("{binding:?}"), "RequestEvidenceBinding([REDACTED])"); + assert_eq!( + format!("{:?}", verifier_at(fixture_now())), + "StaticJwksAssertionVerifier([REDACTED])" + ); + } } diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index 37c80f1cf6..fe987870e3 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -40,9 +40,16 @@ pub mod scope; pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker}; pub use error::AuthError; pub use evidence::{ - AssertionVerificationPolicy, EvidenceClock, EvidenceError, ExactSingleHttpHeader, - NostrKeyClaimPolicy, RequestEvidenceBinding, StaticJwksAssertionVerifier, SystemEvidenceClock, - VerifiedAssertionEvidence, VerifiedIdentityBindingEvidence, + verify_blossom_authorization, verify_blossom_delegated, verify_blossom_direct, + verify_git_smart_http_authorization, verify_git_smart_http_delegated, + verify_git_smart_http_direct, verify_nip42_delegated, verify_nip42_direct, verify_nip98_direct, + AssertionVerificationPolicy, AuthoritativeDelegationGrant, BlossomAction, + CanonicalBlossomRequest, CanonicalGitSmartHttpRequest, CanonicalHttpRequestTarget, + DelegationGrantSource, EvidenceClock, EvidenceError, ExactSingleHttpHeader, + GitSmartHttpOperation, NostrKeyClaimPolicy, RequestEvidenceBinding, + StaticJwksAssertionVerifier, SystemEvidenceClock, TrustedProxyAssertionVerifier, + VerifiedAssertionEvidence, VerifiedBlossomAuthorization, VerifiedDelegatedEvidence, + VerifiedDirectEvidence, VerifiedGitSmartHttpAuthorization, VerifiedIdentityBindingEvidence, }; pub use foundation::{ ActiveLocalBinding, AuthContext, AuthorizationAuditConfig, AuthorizationAuditConfigError, @@ -55,7 +62,10 @@ pub use foundation::{ VerifiedFederatedAssertion, VerifiedNostrProof, HARD_MAX_AUTHORIZATION_EVENTS_PER_DOMAIN, HARD_MAX_AUTHORIZATION_EVENT_BYTES_PER_DOMAIN, HARD_MAX_AUTHORIZATION_EVENT_ENVELOPE_BYTES, }; -pub use nip42::{generate_challenge, verify_nip42_event}; +pub use nip42::{ + generate_challenge, verify_nip42_authorization_proof, verify_nip42_event, + Nip42AuthorizationProofError, +}; pub use nip98::verify_nip98_event; pub use nip98_replay::{ nip98_replay_key, nip98_replay_key_for_scope, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS, diff --git a/crates/buzz-auth/src/nip42.rs b/crates/buzz-auth/src/nip42.rs index 8ee7c90890..221176a335 100644 --- a/crates/buzz-auth/src/nip42.rs +++ b/crates/buzz-auth/src/nip42.rs @@ -6,10 +6,14 @@ //! //! AUTH events are **never** stored or logged (may contain bearer tokens). +use buzz_core::CommunityId; +use chrono::{DateTime, Utc}; use nostr::{Event, Kind, TagKind, Timestamp}; +use thiserror::Error; use url::Url; use crate::error::AuthError; +use crate::foundation::{ProofTransport, VerifiedNostrProof}; /// Normalize a relay URL for comparison. /// @@ -34,6 +38,15 @@ fn normalize_relay_url(raw: &str) -> String { const TIMESTAMP_TOLERANCE_SECS: u64 = 60; +fn exact_tag_content<'a>(event: &'a Event, kind: TagKind<'_>) -> Option<&'a str> { + let mut matching = event.tags.iter().filter(|tag| tag.kind() == kind); + let tag = matching.next()?; + if matching.next().is_some() { + return None; + } + tag.content() +} + /// Generate a random NIP-42 challenge (32 CSPRNG bytes, hex-encoded). pub fn generate_challenge() -> String { let bytes: [u8; 32] = rand::random(); @@ -55,21 +68,14 @@ pub fn verify_nip42_event( buzz_core::verify_event(event).map_err(|_| AuthError::InvalidSignature)?; - let challenge = event - .tags - .find(TagKind::Challenge) - .and_then(|t| t.content()) - .ok_or(AuthError::ChallengeMismatch)?; + let challenge = + exact_tag_content(event, TagKind::Challenge).ok_or(AuthError::ChallengeMismatch)?; if challenge != expected_challenge { return Err(AuthError::ChallengeMismatch); } - let relay = event - .tags - .find(TagKind::Relay) - .and_then(|t| t.content()) - .ok_or(AuthError::RelayUrlMismatch)?; + let relay = exact_tag_content(event, TagKind::Relay).ok_or(AuthError::RelayUrlMismatch)?; if normalize_relay_url(relay) != normalize_relay_url(relay_url) { return Err(AuthError::RelayUrlMismatch); @@ -85,10 +91,59 @@ pub fn verify_nip42_event( Ok(()) } +/// Fail-closed result of minting an origin-sealed NIP-42 authorization proof. +#[derive(Debug, Error)] +pub enum Nip42AuthorizationProofError { + /// The signed NIP-42 event failed cryptographic or protocol verification. + #[error(transparent)] + Authentication(#[from] AuthError), + /// Server-derived binding coordinates were nil, zero, or already expired. + #[error("invalid NIP-42 authorization proof binding")] + InvalidBinding, +} + +/// Verify a NIP-42 event and mint its origin-sealed authorization proof. +/// +/// The caller supplies only server-resolved routing coordinates and hashes of +/// already canonicalized request context. This function performs NIP-42 +/// signature/challenge/relay/freshness verification before invoking the +/// crate-private proof constructor. The expiry is exclusive and must still be +/// in the future according to trusted relay time. +#[allow(clippy::too_many_arguments)] +pub fn verify_nip42_authorization_proof( + event: &Event, + expected_challenge: &str, + relay_url: &str, + authorization_domain: CommunityId, + request_fingerprint: [u8; 32], + target_fingerprint: [u8; 32], + transport_context_fingerprint: [u8; 32], + bound_assertion_fingerprint: Option<[u8; 32]>, + delegation_conditions_fingerprint: Option<[u8; 32]>, + expires_at: DateTime, +) -> Result { + verify_nip42_event(event, expected_challenge, relay_url)?; + if expires_at <= Utc::now() { + return Err(Nip42AuthorizationProofError::InvalidBinding); + } + VerifiedNostrProof::from_verifier( + authorization_domain, + event.pubkey, + ProofTransport::Nip42, + request_fingerprint, + target_fingerprint, + transport_context_fingerprint, + bound_assertion_fingerprint, + delegation_conditions_fingerprint, + expires_at, + ) + .ok_or(Nip42AuthorizationProofError::InvalidBinding) +} + #[cfg(test)] mod tests { use super::*; - use nostr::{EventBuilder, Keys, Kind, RelayUrl, Timestamp}; + use nostr::{EventBuilder, Keys, Kind, RelayUrl, Tag, Timestamp}; const TEST_RELAY: &str = "wss://relay.example.com"; @@ -99,6 +154,17 @@ mod tests { .expect("signing failed") } + fn make_auth_event_with_tags(keys: &Keys, tags: Vec) -> Event { + EventBuilder::new(Kind::Authentication, "") + .tags(tags) + .sign_with_keys(keys) + .expect("signing failed") + } + + fn auth_tag(kind: &str, value: &str) -> Tag { + Tag::parse([kind, value]).expect("valid auth tag") + } + #[test] fn challenge_is_64_hex_chars_and_unique() { let c1 = generate_challenge(); @@ -116,6 +182,118 @@ mod tests { assert!(verify_nip42_event(&event, &challenge, TEST_RELAY).is_ok()); } + #[test] + fn required_tags_are_order_independent() { + let keys = Keys::generate(); + let challenge = generate_challenge(); + let event = make_auth_event_with_tags( + &keys, + vec![ + auth_tag("challenge", &challenge), + auth_tag("relay", TEST_RELAY), + ], + ); + let reversed = make_auth_event_with_tags( + &keys, + vec![ + auth_tag("relay", TEST_RELAY), + auth_tag("challenge", &challenge), + ], + ); + + assert!(verify_nip42_event(&event, &challenge, TEST_RELAY).is_ok()); + assert!(verify_nip42_event(&reversed, &challenge, TEST_RELAY).is_ok()); + } + + #[test] + fn challenge_tag_must_appear_exactly_once() { + let keys = Keys::generate(); + let challenge = generate_challenge(); + let cases = [ + ("missing", vec![auth_tag("relay", TEST_RELAY)]), + ( + "equal duplicate", + vec![ + auth_tag("challenge", &challenge), + auth_tag("challenge", &challenge), + auth_tag("relay", TEST_RELAY), + ], + ), + ( + "matching then conflicting", + vec![ + auth_tag("challenge", &challenge), + auth_tag("challenge", "wrong"), + auth_tag("relay", TEST_RELAY), + ], + ), + ( + "conflicting then matching", + vec![ + auth_tag("challenge", "wrong"), + auth_tag("relay", TEST_RELAY), + auth_tag("challenge", &challenge), + ], + ), + ]; + + for (case, tags) in cases { + let event = make_auth_event_with_tags(&keys, tags); + assert!( + matches!( + verify_nip42_event(&event, &challenge, TEST_RELAY), + Err(AuthError::ChallengeMismatch) + ), + "challenge case {case} must fail closed" + ); + } + } + + #[test] + fn relay_tag_must_appear_exactly_once() { + let keys = Keys::generate(); + let challenge = generate_challenge(); + let other_relay = "wss://other.example.com"; + let cases = [ + ("missing", vec![auth_tag("challenge", &challenge)]), + ( + "equal duplicate", + vec![ + auth_tag("relay", TEST_RELAY), + auth_tag("challenge", &challenge), + auth_tag("relay", TEST_RELAY), + ], + ), + ( + "matching then conflicting", + vec![ + auth_tag("relay", TEST_RELAY), + auth_tag("relay", other_relay), + auth_tag("challenge", &challenge), + ], + ), + ( + "conflicting then matching", + vec![ + auth_tag("relay", other_relay), + auth_tag("challenge", &challenge), + auth_tag("relay", TEST_RELAY), + ], + ), + ]; + + for (case, tags) in cases { + let event = make_auth_event_with_tags(&keys, tags); + assert!( + matches!( + verify_nip42_event(&event, &challenge, TEST_RELAY), + Err(AuthError::RelayUrlMismatch) + ), + "relay case {case} must fail closed" + ); + } + } + #[test] fn wrong_challenge_rejected() { let keys = Keys::generate(); diff --git a/crates/buzz-auth/src/nip98_replay.rs b/crates/buzz-auth/src/nip98_replay.rs index 5923c0414d..8e85e7ad34 100644 --- a/crates/buzz-auth/src/nip98_replay.rs +++ b/crates/buzz-auth/src/nip98_replay.rs @@ -60,6 +60,29 @@ pub const MAX_REPLAY_TTL_SECS: u64 = 3600; /// /// The production implementation lives in `buzz-pubsub` (Redis `SET NX EX`). /// A test impl is provided behind `cfg(any(test, feature = "test-utils"))`. +/// +/// A legacy guard that implements only the single-id operation is rejected at +/// compile time rather than failing a live multi-proof authorization request: +/// +/// ```compile_fail,E0046 +/// use std::{future::Future, pin::Pin}; +/// +/// use buzz_auth::{AuthError, Nip98ReplayGuard}; +/// use nostr::EventId; +/// +/// struct LegacySingleIdGuard; +/// +/// impl Nip98ReplayGuard for LegacySingleIdGuard { +/// fn try_mark_in_scope<'a>( +/// &'a self, +/// _scope: &'a str, +/// _event_id: &'a EventId, +/// _ttl_secs: u64, +/// ) -> Pin> + Send + 'a>> { +/// Box::pin(async { Ok(true) }) +/// } +/// } +/// ``` pub trait Nip98ReplayGuard: Send + Sync { /// Atomically claim `event_id` in an explicit deployment or community scope. fn try_mark_in_scope<'a>( @@ -69,6 +92,19 @@ pub trait Nip98ReplayGuard: Send + Sync { ttl_secs: u64, ) -> Pin> + Send + 'a>>; + /// Atomically claim one complete proof set in an explicit scope. + /// + /// Every implementation MUST provide one atomic all-or-none operation. + /// It must never emulate atomicity by claiming ids sequentially. Requiring + /// the method prevents legacy single-id guards from reaching live + /// multi-proof authorization and failing only at runtime. + fn try_mark_all_in_scope<'a>( + &'a self, + scope: &'a str, + event_ids: &'a [EventId], + ttl_secs: u64, + ) -> Pin> + Send + 'a>>; + /// Atomically claim `event_id` for `ctx`'s community. /// /// Returns `Ok(true)` when the id is newly inserted (proceed) and @@ -136,6 +172,28 @@ impl Nip98ReplayGuard for AlwaysFreshReplayGuard { ) -> Pin> + Send + 'a>> { Box::pin(async { Ok(true) }) } + + fn try_mark_all_in_scope<'a>( + &'a self, + _scope: &'a str, + event_ids: &'a [EventId], + _ttl_secs: u64, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let valid_cardinality = (1..=3).contains(&event_ids.len()); + let distinct = event_ids + .iter() + .enumerate() + .all(|(index, event_id)| !event_ids[..index].contains(event_id)); + if valid_cardinality && distinct { + Ok(true) + } else { + Err(AuthError::Internal( + "invalid NIP-98 proof-set replay claim".to_owned(), + )) + } + }) + } } #[cfg(test)] @@ -246,4 +304,32 @@ mod tests { .await .unwrap()); } + + #[tokio::test] + async fn always_fresh_batch_contract_accepts_one_two_three_and_rejects_malformed_sets() { + let guard = AlwaysFreshReplayGuard; + let event_ids = [fixture_event_id(), fixture_event_id(), fixture_event_id()]; + for count in 1..=3 { + assert!(guard + .try_mark_all_in_scope( + "mandatory-batch", + &event_ids[..count], + DEFAULT_REPLAY_TTL_SECS, + ) + .await + .expect("valid atomic proof set")); + } + assert!(guard + .try_mark_all_in_scope("empty", &[], DEFAULT_REPLAY_TTL_SECS) + .await + .is_err()); + assert!(guard + .try_mark_all_in_scope( + "duplicate", + &[event_ids[0], event_ids[0]], + DEFAULT_REPLAY_TTL_SECS, + ) + .await + .is_err()); + } } diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 7d22a06da2..ec0eba397c 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -565,7 +565,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 31); + assert_eq!(migrations.len(), 32); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -997,6 +997,20 @@ mod tests { .sql .as_str() .contains("CREATE TABLE authorization_events")); + assert_eq!(migrations[31].version, 32); + assert_eq!(&*migrations[31].description, "nip fi protected authority"); + assert!(migrations[31] + .sql + .as_str() + .contains("CREATE TABLE authorization_invalidation_domains")); + assert!(migrations[31] + .sql + .as_str() + .contains("CREATE TABLE authorization_authority_epochs")); + assert!(migrations[31] + .sql + .as_str() + .contains("CREATE TABLE protected_object_authority")); } #[test] @@ -1014,12 +1028,12 @@ mod tests { } assert_eq!( migrations.last().map(|migration| migration.version), - Some(31) + Some(32) ); } #[test] - fn synthesized_0029_and_0030_migration_identity_is_frozen() { + fn synthesized_0029_through_0031_migration_identity_is_frozen() { let expected = [ ( 29, @@ -1031,6 +1045,11 @@ mod tests { "identity binding lifecycle", "5cfea5f92cab63b9d9dc31ac1d0e7e09f291c9ab631662c2160662ba79d783515d3ed40417e641ded428f63a50a4881b", ), + ( + 31, + "nip fi identity lifecycle upgrade", + "31e919e55af4c50d4de4247eaddbdb454178df218a07fcb635466fecf632bdbb7b2f25cb1d486d1225dc9f2dfef92065", + ), ]; for (version, description, checksum) in expected { let migration = MIGRATOR @@ -1371,7 +1390,7 @@ mod tests { run_migrations(&pool) .await .expect("retry succeeds after operator repair"); - assert_eq!(applied_versions(&pool).await.last().copied(), Some(31)); + assert_eq!(applied_versions(&pool).await.last().copied(), Some(32)); } #[tokio::test] @@ -1903,4 +1922,260 @@ mod tests { .await .expect("0031 projection rejection test exceeded 120 seconds"); } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn protected_authority_0032_enforces_exact_fence_and_immutability() { + tokio::time::timeout(std::time::Duration::from_secs(120), async { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(30, &pool) + .await + .expect("apply synthesized #1476 boundary"); + sqlx::raw_sql( + r#" + INSERT INTO communities (id,host) + VALUES ('62000000-0000-0000-0000-000000000001','protected-authority.example'); + INSERT INTO identity_bindings + (community_id,issuer,uid,pubkey,source,created_at,updated_at,last_seen_at) + VALUES + ('62000000-0000-0000-0000-000000000001','protected-issuer', + 'protected-subject',decode(repeat('62',32),'hex'),'jwt_npub', + '2026-04-01 00:00:00+00','2026-04-01 00:00:00+00', + '2026-04-01 00:00:00+00'); + "#, + ) + .execute(&pool) + .await + .expect("seed populated #1476 identity"); + MIGRATOR + .run_to(32, &pool) + .await + .expect("project identity and apply protected-authority prerequisite"); + for later_table in [ + "authorization_invalidation_floors", + "authorization_operation_version_delta_manifests", + "authorization_operation_version_deltas", + "client_status_revisions", + "authorization_operator_authentication_denial_attempts", + "git_repo_publications", + "media_publications", + "audio_session_admissions", + ] { + let present: Option = sqlx::query_scalar("SELECT to_regclass($1)::text") + .bind(format!("public.{later_table}")) + .fetch_one(&pool) + .await + .expect("inspect excluded later-scope table"); + assert_eq!(present, None, "0032 must not create {later_table}"); + } + sqlx::query( + "SELECT authorization_event_capacity_install_v1( \ + '62000000-0000-0000-0000-000000000001',10000,16777216,16384)", + ) + .execute(&pool) + .await + .expect("adopt fixture capacity policy"); + + sqlx::raw_sql( + r#" + INSERT INTO authorization_operation_receipts + (community_id,operation_id,request_fingerprint,operation_kind, + actor_fingerprint,outcome_code,result_digest) + VALUES + ('62000000-0000-0000-0000-000000000001', + '62000000-0000-0000-0000-000000000011',decode(repeat('11',32),'hex'), + 11,decode(repeat('12',32),'hex'),1,decode(repeat('13',32),'hex')); + INSERT INTO authorization_events + (community_id,event_id,event_kind,outcome_code,reason_code,actor_kind, + actor_fingerprint,operation_id,request_fingerprint,correlation_id, + attempt_id,occurred_at,canonical_envelope,envelope_digest) + VALUES + ('62000000-0000-0000-0000-000000000001', + '62000000-0000-0000-0000-000000000012',10,1,1,1, + decode(repeat('12',32),'hex'), + '62000000-0000-0000-0000-000000000011',decode(repeat('11',32),'hex'), + '62000000-0000-0000-0000-000000000013', + '62000000-0000-0000-0000-000000000014',clock_timestamp(), + decode('01','hex'),digest(decode('01','hex'),'sha256')); + INSERT INTO authorization_invalidation_domains + (community_id,current_generation) + VALUES ('62000000-0000-0000-0000-000000000001',0); + "#, + ) + .execute(&pool) + .await + .expect("seed protected operation receipt, event, and domain marker"); + + for statement in [ + "INSERT INTO authorization_authority_epochs \ + (community_id,object_kind,object_key,authority_epoch,fence,operation_id,request_fingerprint) \ + VALUES ('62000000-0000-0000-0000-000000000001',3,decode(repeat('31',32),'hex'),1, \ + decode(repeat('00',32),'hex'),'62000000-0000-0000-0000-000000000011', \ + decode(repeat('11',32),'hex'))", + "INSERT INTO authorization_authority_epochs \ + (community_id,object_kind,object_key,authority_epoch,fence,operation_id,request_fingerprint) \ + VALUES ('62000000-0000-0000-0000-000000000001',8,decode(repeat('31',32),'hex'),1, \ + decode(repeat('21',32),'hex'),'62000000-0000-0000-0000-000000000011', \ + decode(repeat('11',32),'hex'))", + ] { + assert!( + sqlx::query(statement).execute(&pool).await.is_err(), + "invalid epoch/fence row must be rejected: {statement}" + ); + } + + sqlx::raw_sql( + r#" + INSERT INTO authorization_authority_epochs + (community_id,object_kind,object_key,authority_epoch,fence, + operation_id,request_fingerprint) + VALUES + ('62000000-0000-0000-0000-000000000001',3, + decode(repeat('31',32),'hex'),1,decode(repeat('21',32),'hex'), + '62000000-0000-0000-0000-000000000011',decode(repeat('11',32),'hex')); + INSERT INTO protected_object_authority + (community_id,object_kind,object_key,capability,actor_pubkey, + binding_id,binding_version,policy_revision,invalidation_generation, + authority_epoch,fence,issued_at,expires_at,operation_id,request_fingerprint) + SELECT + '62000000-0000-0000-0000-000000000001',3, + decode(repeat('31',32),'hex'),16,decode(repeat('62',32),'hex'), + binding_id,binding_version,1,0,1,decode(repeat('21',32),'hex'), + '2026-04-02 00:00:00+00','2026-04-02 01:00:00+00', + '62000000-0000-0000-0000-000000000011',decode(repeat('11',32),'hex') + FROM identity_bindings + WHERE community_id='62000000-0000-0000-0000-000000000001'; + "#, + ) + .execute(&pool) + .await + .expect("insert exact epoch and protected authority"); + + for statement in [ + "UPDATE authorization_invalidation_domains \ + SET current_generation=-1,updated_at=updated_at+interval '1 microsecond'", + "DELETE FROM authorization_invalidation_domains", + "TRUNCATE authorization_invalidation_domains", + "UPDATE authorization_authority_epochs \ + SET authority_epoch=2,updated_at=updated_at+interval '1 microsecond'", + "UPDATE authorization_authority_epochs \ + SET fence=decode(repeat('22',32),'hex'),updated_at=updated_at+interval '1 microsecond'", + "DELETE FROM authorization_authority_epochs", + "TRUNCATE authorization_authority_epochs", + "UPDATE protected_object_authority \ + SET expires_at=expires_at+interval '1 second'", + "UPDATE protected_object_authority \ + SET authority_epoch=2,issued_at=issued_at+interval '1 second'", + "DELETE FROM protected_object_authority", + "TRUNCATE protected_object_authority", + ] { + assert!( + sqlx::query(statement).execute(&pool).await.is_err(), + "forbidden mutation must fail closed: {statement}" + ); + } + + for statement in [ + "INSERT INTO protected_object_authority \ + (community_id,object_kind,object_key,capability,actor_pubkey,binding_id, \ + binding_version,owner_pubkey,delegated_relationship_id, \ + delegated_relationship_revision,delegation_conditions_fingerprint, \ + policy_revision,invalidation_generation,authority_epoch,fence,issued_at, \ + expires_at,operation_id,request_fingerprint) \ + SELECT community_id,4,decode(repeat('41',32),'hex'),18, \ + event_author_pubkey,binding_id,binding_version,decode(repeat('62',32),'hex'), \ + '00000000-0000-0000-0000-000000000000',1,decode(repeat('42',32),'hex'), \ + 1,0,1,decode(repeat('21',32),'hex'),'2026-04-02 00:00:00+00', \ + '2026-04-02 01:00:00+00','62000000-0000-0000-0000-000000000011', \ + decode(repeat('11',32),'hex') FROM identity_bindings \ + WHERE community_id='62000000-0000-0000-0000-000000000001'", + "INSERT INTO protected_object_authority \ + (community_id,object_kind,object_key,capability,actor_pubkey,binding_id, \ + binding_version,owner_pubkey,policy_revision,invalidation_generation, \ + authority_epoch,fence,issued_at,expires_at,operation_id,request_fingerprint) \ + SELECT community_id,4,decode(repeat('42',32),'hex'),18, \ + event_author_pubkey,binding_id,binding_version,decode(repeat('62',32),'hex'), \ + 1,0,1,decode(repeat('21',32),'hex'),'2026-04-02 00:00:00+00', \ + '2026-04-02 01:00:00+00','62000000-0000-0000-0000-000000000011', \ + decode(repeat('11',32),'hex') FROM identity_bindings \ + WHERE community_id='62000000-0000-0000-0000-000000000001'", + "INSERT INTO protected_object_authority \ + (community_id,object_kind,object_key,capability,actor_pubkey,binding_id, \ + binding_version,policy_revision,invalidation_generation,authority_epoch,fence, \ + issued_at,expires_at,operation_id,request_fingerprint) \ + SELECT community_id,4,decode(repeat('43',32),'hex'),18,event_author_pubkey, \ + binding_id,binding_version,1,0,1,decode(repeat('21',32),'hex'), \ + '2026-04-02 00:00:00+00','2026-04-02 01:00:00+00', \ + '62000000-0000-0000-0000-000000000011',decode(repeat('11',32),'hex') \ + FROM identity_bindings \ + WHERE community_id='62000000-0000-0000-0000-000000000001'", + ] { + assert!( + sqlx::query(statement).execute(&pool).await.is_err(), + "invalid delegation or mismatched epoch witness must fail: {statement}" + ); + } + + sqlx::raw_sql( + r#" + INSERT INTO authorization_operation_receipts + (community_id,operation_id,request_fingerprint,operation_kind, + actor_fingerprint,outcome_code,result_digest) + VALUES + ('62000000-0000-0000-0000-000000000001', + '62000000-0000-0000-0000-000000000021',decode(repeat('51',32),'hex'), + 11,decode(repeat('52',32),'hex'),1,decode(repeat('53',32),'hex')); + INSERT INTO authorization_events + (community_id,event_id,event_kind,outcome_code,reason_code,actor_kind, + actor_fingerprint,operation_id,request_fingerprint,correlation_id, + attempt_id,occurred_at,canonical_envelope,envelope_digest) + VALUES + ('62000000-0000-0000-0000-000000000001', + '62000000-0000-0000-0000-000000000022',10,1,1,1, + decode(repeat('52',32),'hex'), + '62000000-0000-0000-0000-000000000021',decode(repeat('51',32),'hex'), + '62000000-0000-0000-0000-000000000023', + '62000000-0000-0000-0000-000000000024',clock_timestamp(), + decode('02','hex'),digest(decode('02','hex'),'sha256')); + BEGIN; + UPDATE authorization_authority_epochs + SET authority_epoch=2,fence=decode(repeat('22',32),'hex'), + operation_id='62000000-0000-0000-0000-000000000021', + request_fingerprint=decode(repeat('51',32),'hex'), + updated_at=updated_at+interval '1 second' + WHERE community_id='62000000-0000-0000-0000-000000000001' + AND object_kind=3 AND object_key=decode(repeat('31',32),'hex'); + UPDATE protected_object_authority + SET authority_epoch=2,fence=decode(repeat('22',32),'hex'), + operation_id='62000000-0000-0000-0000-000000000021', + request_fingerprint=decode(repeat('51',32),'hex'), + issued_at=issued_at+interval '1 second', + expires_at=expires_at+interval '1 second' + WHERE community_id='62000000-0000-0000-0000-000000000001' + AND object_kind=3 AND object_key=decode(repeat('31',32),'hex'); + COMMIT; + "#, + ) + .execute(&pool) + .await + .expect("strict new-operation epoch/fence replacement commits atomically"); + let replaced: (i64, Vec, uuid::Uuid) = sqlx::query_as( + "SELECT authority_epoch,fence,operation_id FROM protected_object_authority", + ) + .fetch_one(&pool) + .await + .expect("read replaced authority"); + assert_eq!(replaced.0, 2); + assert_eq!(replaced.1, vec![0x22; 32]); + assert_eq!( + replaced.2, + uuid::Uuid::parse_str("62000000-0000-0000-0000-000000000021").unwrap() + ); + pool.close().await; + }) + .await + .expect("0032 protected-authority test exceeded 120 seconds"); + } } diff --git a/crates/buzz-db/src/migration_catalog_tests.rs b/crates/buzz-db/src/migration_catalog_tests.rs index 89925adfa0..a11fc05dde 100644 --- a/crates/buzz-db/src/migration_catalog_tests.rs +++ b/crates/buzz-db/src/migration_catalog_tests.rs @@ -254,11 +254,25 @@ async fn parity_scenario() { assert_eq!(preserved.2, vec![0x41; 32]); assert_eq!(preserved.3, 1); assert!(preserved.4 > 0); + MIGRATOR + .run_to(32, &pool) + .await + .expect("apply protected-authority prerequisite 0032"); + let protected_rows: (i64, i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT count(*) FROM authorization_invalidation_domains), \ + (SELECT count(*) FROM authorization_authority_epochs), \ + (SELECT count(*) FROM protected_object_authority)", + ) + .fetch_one(&pool) + .await + .expect("read empty protected-authority prerequisite tables"); + assert_eq!(protected_rows, (0, 0, 0)); let populated_catalog = catalog_snapshot(&pool).await; let populated_seeds = seed_snapshot(&pool).await; assert_eq!( populated_catalog, fresh_catalog, - "populated synthesized-#1476 upgrade must produce the fresh 0031 catalog" + "populated synthesized-#1476 upgrade must produce the fresh 0032 catalog" ); assert_eq!(populated_seeds, fresh_seeds); @@ -295,7 +309,8 @@ async fn parity_scenario() { #[tokio::test] #[ignore = "requires a dedicated disposable Postgres database"] -async fn identity_0031_fresh_populated_and_desired_schema_have_full_postgresql_catalog_parity() { +async fn protected_authority_0032_fresh_populated_and_desired_schema_have_full_postgresql_catalog_parity( +) { tokio::time::timeout(TEST_DEADLINE, parity_scenario()) .await .expect("full catalog parity exceeded its 120-second wall-clock budget"); diff --git a/crates/buzz-media/src/lib.rs b/crates/buzz-media/src/lib.rs index 67896d4ef2..6babe8d32e 100644 --- a/crates/buzz-media/src/lib.rs +++ b/crates/buzz-media/src/lib.rs @@ -6,6 +6,7 @@ pub mod auth; pub mod bucket_index; pub mod config; pub mod error; +pub mod publication; pub mod storage; pub mod thumbnail; pub mod types; @@ -19,9 +20,16 @@ pub use bucket_index::{ }; pub use config::{MediaConfig, S3AddressingStyle}; pub use error::MediaError; +pub use publication::{ + media_publication_manifest_key, MediaPublicationManifest, StagedMediaPublication, + VerifiedMediaObject, +}; pub use storage::{BlobHeadMeta, BlobMeta, ByteStream, MediaStorage}; pub use types::BlobDescriptor; -pub use upload::{process_file_upload, process_upload, process_video_upload}; +pub use upload::{ + process_file_upload, process_upload, process_video_upload, stage_file_upload, stage_upload, + stage_video_upload, StagedMediaUpload, +}; pub use upload_record::{ parse_port, parse_public_ip, upload_record_key, UploadAttribution, UploadNetworkInfo, UploadRecord, UPLOAD_RECORD_VERSION, diff --git a/crates/buzz-media/src/publication.rs b/crates/buzz-media/src/publication.rs new file mode 100644 index 0000000000..5bc86ba73f --- /dev/null +++ b/crates/buzz-media/src/publication.rs @@ -0,0 +1,828 @@ +//! Immutable media publication manifests and cache-only sidecar projection. +//! +//! Raw blobs, thumbnails, and this manifest are staged before PostgreSQL +//! authorization commits. Only a canonical operation receipt whose result +//! digest equals [`StagedMediaPublication::result_digest`] makes the media +//! visible. The legacy community sidecar is refreshed afterward as a cache; +//! it is never an authority witness. + +use buzz_core::{CommunityId, TenantContext}; +use bytes::Bytes; +use futures_core::Stream; +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fmt; +use std::pin::Pin; +use std::task::{Context, Poll}; +use tempfile::NamedTempFile; +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use tokio::sync::OwnedSemaphorePermit; +use tokio_util::io::ReaderStream; +use uuid::Uuid; + +use crate::{BlobDescriptor, BlobMeta, MediaError, MediaStorage}; + +const MEDIA_PUBLICATION_VERSION: u8 = 1; +const PUBLICATION_PREFIX: &str = "_publications/media"; +const MAX_PUBLICATION_BYTES: u64 = 16 * 1024; +const MAX_THUMBNAIL_BYTES: u64 = 16 * 1024 * 1024; +const MAX_PRIMARY_BYTES: u64 = 500 * 1024 * 1024; +const MAX_DURATION_SECONDS: f64 = 600.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum MediaObjectKind { + Primary, + Thumbnail, +} + +/// A DB-witnessed media object whose complete bytes match the manifest digest. +/// +/// Construction downloads the full immutable object into a private temporary +/// file and verifies its size and SHA-256 before this value is returned. HTTP +/// handlers can therefore form headers, full streams, or range responses only +/// from already-verified bytes; no object-store bytes escape while verification +/// is still in progress. +#[derive(Debug)] +pub struct VerifiedMediaObject { + file: NamedTempFile, + content_type: String, + size: u64, + _verification_slot: OwnedSemaphorePermit, +} + +impl VerifiedMediaObject { + /// Exact content type authenticated by the DB-witnessed manifest. + pub fn content_type(&self) -> &str { + &self.content_type + } + + /// Exact verified byte length. + pub const fn size(&self) -> u64 { + self.size + } + + /// Read an inclusive byte range from the already-verified local copy. + pub async fn read_range(&self, start: u64, end: u64) -> Result, MediaError> { + if start > end || end >= self.size { + return Err(invalid_publication()); + } + let length = end + .checked_sub(start) + .and_then(|value| value.checked_add(1)) + .ok_or_else(invalid_publication)?; + let length = usize::try_from(length).map_err(|_| invalid_publication())?; + let file = self + .file + .reopen() + .map_err(|error| MediaError::Io(error.to_string()))?; + let mut file = tokio::fs::File::from_std(file); + file.seek(std::io::SeekFrom::Start(start)) + .await + .map_err(|error| MediaError::Io(error.to_string()))?; + let mut bytes = vec![0; length]; + file.read_exact(&mut bytes) + .await + .map_err(|error| MediaError::Io(error.to_string()))?; + Ok(bytes) + } + + /// Consume this object into a stream over the already-verified local copy. + pub fn into_stream(self) -> Result { + let file = self + .file + .reopen() + .map_err(|error| MediaError::Io(error.to_string()))?; + Ok(Box::pin(VerifiedMediaStream { + inner: ReaderStream::new(tokio::fs::File::from_std(file)), + _file: self.file, + _verification_slot: self._verification_slot, + })) + } +} + +struct VerifiedMediaStream { + inner: ReaderStream, + _file: NamedTempFile, + _verification_slot: OwnedSemaphorePermit, +} + +impl Stream for VerifiedMediaStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_next(context).map(|item| { + item.map(|result| result.map_err(|error| MediaError::Io(error.to_string()))) + }) + } +} + +/// Canonical immutable description of one media publication. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MediaPublicationManifest { + version: u8, + community_id: Uuid, + blob_sha256: String, + extension: String, + mime_type: String, + size: u64, + uploaded_at: i64, + dimensions: Option, + blurhash: Option, + thumbnail_sha256: Option, + duration_seconds_bits: Option, +} + +impl MediaPublicationManifest { + /// Build a canonical manifest from validated, durably staged blob metadata. + pub(crate) fn from_staged_blob( + tenant: &TenantContext, + blob_sha256: impl Into, + metadata: &BlobMeta, + ) -> Result { + let manifest = Self { + version: MEDIA_PUBLICATION_VERSION, + community_id: *tenant.community().as_uuid(), + blob_sha256: blob_sha256.into(), + extension: metadata.ext.clone(), + mime_type: metadata.mime_type.clone(), + size: metadata.size, + uploaded_at: metadata.uploaded_at, + dimensions: nonempty(metadata.dim.clone()), + blurhash: nonempty(metadata.blurhash.clone()), + thumbnail_sha256: metadata.thumbnail_sha256.clone(), + duration_seconds_bits: metadata.duration_secs.map(f64::to_bits), + }; + manifest.validate()?; + if manifest.thumbnail_sha256.is_some() == metadata.thumb_url.is_empty() { + return Err(invalid_publication()); + } + Ok(manifest) + } + + /// Server-resolved community bound into the publication digest. + pub const fn community_id(&self) -> CommunityId { + CommunityId::from_uuid(self.community_id) + } + + /// Exact content hash of the primary blob. + pub fn blob_sha256(&self) -> &str { + &self.blob_sha256 + } + + /// Exact content-addressed storage key of the primary blob. + pub fn blob_key(&self) -> String { + format!("{}.{}", self.blob_sha256, self.extension) + } + + /// Canonical extension authenticated by this manifest. + pub fn extension(&self) -> &str { + &self.extension + } + + /// Canonical MIME type authenticated by this manifest. + pub fn mime_type(&self) -> &str { + &self.mime_type + } + + /// Exact byte size authenticated by this manifest. + pub const fn size(&self) -> u64 { + self.size + } + + /// Optional exact thumbnail digest authenticated by this manifest. + pub fn thumbnail_sha256(&self) -> Option<&str> { + self.thumbnail_sha256.as_deref() + } + + fn thumbnail_blob_key(&self) -> Option { + self.thumbnail_sha256() + .map(|digest| format!("{digest}.thumb.jpg")) + } + + fn resolve_request(&self, requested_path: &str) -> Result { + let bare = self.blob_sha256(); + if requested_path == bare || requested_path == self.blob_key() { + return Ok(MediaObjectKind::Primary); + } + if self.thumbnail_sha256.is_some() && requested_path == format!("{bare}.thumb.jpg") { + return Ok(MediaObjectKind::Thumbnail); + } + Err(MediaError::NotFound) + } + + /// Content-addressed manifest bytes used to derive the receipt digest. + pub fn canonical_bytes(&self) -> Result, MediaError> { + self.validate()?; + serde_json::to_vec(self).map_err(MediaError::from) + } + + /// Reconstruct the exact upload response for an applied or replayed witness. + pub fn descriptor(&self, public_base_url: &str) -> Result { + let metadata = self.to_blob_meta(public_base_url)?; + Ok(BlobDescriptor { + url: format!("{public_base_url}/{}", self.blob_key()), + sha256: self.blob_sha256.clone(), + size: self.size, + mime_type: self.mime_type.clone(), + uploaded: self.uploaded_at, + dim: self.dimensions.clone(), + blurhash: self.blurhash.clone(), + thumb: (!metadata.thumb_url.is_empty()).then_some(metadata.thumb_url), + duration: self.duration_seconds_bits.map(f64::from_bits), + }) + } + + fn validate(&self) -> Result<(), MediaError> { + let duration = self.duration_seconds_bits.map(f64::from_bits); + if self.version != MEDIA_PUBLICATION_VERSION + || self.community_id.is_nil() + || !is_lower_sha256(&self.blob_sha256) + || !is_safe_extension(&self.extension) + || !is_exact_mime(&self.mime_type) + || self.size == 0 + || self.size > MAX_PRIMARY_BYTES + || self.uploaded_at <= 0 + || self + .dimensions + .as_deref() + .is_some_and(|value| !is_bounded_text(value, 64)) + || self + .blurhash + .as_deref() + .is_some_and(|value| !is_bounded_text(value, 256)) + || self + .thumbnail_sha256 + .as_deref() + .is_some_and(|value| !is_lower_sha256(value)) + || duration.is_some_and(|value| { + !value.is_finite() || !(0.0..=MAX_DURATION_SECONDS).contains(&value) + }) + { + return Err(invalid_publication()); + } + Ok(()) + } + + fn to_blob_meta(&self, public_base_url: &str) -> Result { + self.validate()?; + if public_base_url.is_empty() || public_base_url != public_base_url.trim_end_matches('/') { + return Err(invalid_publication()); + } + Ok(BlobMeta { + dim: self.dimensions.clone().unwrap_or_default(), + blurhash: self.blurhash.clone().unwrap_or_default(), + thumb_url: self + .thumbnail_sha256 + .as_ref() + .map_or_else(String::new, |_| { + format!("{public_base_url}/{}.thumb.jpg", self.blob_sha256) + }), + thumbnail_sha256: self.thumbnail_sha256.clone(), + ext: self.extension.clone(), + mime_type: self.mime_type.clone(), + size: self.size, + uploaded_at: self.uploaded_at, + duration_secs: self.duration_seconds_bits.map(f64::from_bits), + }) + } +} + +/// Opaque proof that immutable publication bytes were durably staged. +#[derive(Clone)] +pub struct StagedMediaPublication { + manifest: MediaPublicationManifest, + manifest_key: String, + result_digest: [u8; 32], +} + +impl fmt::Debug for StagedMediaPublication { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("StagedMediaPublication([REDACTED])") + } +} + +impl StagedMediaPublication { + /// Canonical manifest staged under the exact result digest. + pub const fn manifest(&self) -> &MediaPublicationManifest { + &self.manifest + } + + /// Immutable manifest storage key. + pub fn manifest_key(&self) -> &str { + &self.manifest_key + } + + /// Exact digest to commit as the protected-operation result. + pub const fn result_digest(&self) -> [u8; 32] { + self.result_digest + } + + /// Refresh the legacy sidecar only after the DB witness is committed. + /// + /// Readers must still resolve and validate the PostgreSQL witness first; + /// this sidecar is only a compatibility/cache projection. + pub async fn publish_sidecar_cache( + &self, + storage: &MediaStorage, + tenant: &TenantContext, + public_base_url: &str, + ) -> Result<(), MediaError> { + if self.manifest.community_id() != tenant.community() { + return Err(invalid_publication()); + } + if let Some(thumbnail_digest) = self.manifest.thumbnail_sha256() { + let immutable_key = self + .manifest + .thumbnail_blob_key() + .ok_or_else(invalid_publication)?; + let thumbnail = read_bounded_stream( + storage.get_stream(&immutable_key).await?, + MAX_THUMBNAIL_BYTES, + ) + .await?; + if hex::encode(Sha256::digest(&thumbnail)) != thumbnail_digest { + return Err(invalid_publication()); + } + let cache_key = format!("{}.thumb.jpg", self.manifest.blob_sha256()); + storage.put(&cache_key, &thumbnail, "image/jpeg").await?; + } + let metadata = self.manifest.to_blob_meta(public_base_url)?; + storage + .put_sidecar(tenant, self.manifest.blob_sha256(), &metadata) + .await + } +} + +impl MediaStorage { + /// Persist one immutable publication manifest without publishing visibility. + pub(crate) async fn stage_media_publication( + &self, + manifest: MediaPublicationManifest, + ) -> Result { + let bytes = manifest.canonical_bytes()?; + let result_digest: [u8; 32] = Sha256::digest(&bytes).into(); + if result_digest == [0; 32] { + return Err(invalid_publication()); + } + let manifest_key = media_publication_manifest_key(result_digest); + self.put(&manifest_key, &bytes, "application/json").await?; + Ok(StagedMediaPublication { + manifest, + manifest_key, + result_digest, + }) + } + + /// Fetch and digest-verify the immutable manifest named by a DB witness. + pub async fn get_media_publication( + &self, + result_digest: [u8; 32], + ) -> Result { + if result_digest == [0; 32] { + return Err(invalid_publication()); + } + let key = media_publication_manifest_key(result_digest); + let size = self + .head_with_metadata(&key) + .await? + .ok_or(MediaError::NotFound)? + .size; + if size == 0 || size > MAX_PUBLICATION_BYTES { + return Err(invalid_publication()); + } + let bytes = + read_bounded_stream(self.get_stream(&key).await?, MAX_PUBLICATION_BYTES).await?; + let actual: [u8; 32] = Sha256::digest(&bytes).into(); + if actual != result_digest { + return Err(invalid_publication()); + } + decode_media_publication(&bytes, result_digest) + } + + /// Resolve a DB witness and fully verify the exact requested media bytes. + /// + /// `result_digest` must come from the canonical PostgreSQL publication + /// witness. Sidecars are deliberately ignored. Tenant and request-path + /// mismatches collapse to `NotFound`; storage absence or corruption never + /// falls back to a raw object or sidecar. + pub async fn verify_media_object( + &self, + tenant: &TenantContext, + result_digest: [u8; 32], + requested_path: &str, + ) -> Result { + let manifest = self.get_media_publication(result_digest).await?; + if manifest.community_id() != tenant.community() { + return Err(MediaError::NotFound); + } + let kind = manifest.resolve_request(requested_path)?; + let (key, expected_digest, expected_size, content_type) = match kind { + MediaObjectKind::Primary => ( + manifest.blob_key(), + manifest.blob_sha256().to_owned(), + Some(manifest.size()), + manifest.mime_type().to_owned(), + ), + MediaObjectKind::Thumbnail => { + let thumbnail_digest = manifest + .thumbnail_sha256() + .ok_or_else(invalid_publication)?; + ( + manifest + .thumbnail_blob_key() + .ok_or_else(invalid_publication)?, + thumbnail_digest.to_owned(), + None, + "image/jpeg".to_owned(), + ) + } + }; + let verification_slot = self + .verification_slots + .clone() + .try_acquire_owned() + .map_err(|_| { + MediaError::StorageError("media verification capacity unavailable".to_owned()) + })?; + let stream = self.get_stream(&key).await?; + verify_media_stream( + stream, + &expected_digest, + expected_size, + content_type, + verification_slot, + ) + .await + } +} + +fn decode_media_publication( + bytes: &[u8], + result_digest: [u8; 32], +) -> Result { + let manifest: MediaPublicationManifest = serde_json::from_slice(bytes)?; + let canonical = manifest.canonical_bytes()?; + let canonical_digest: [u8; 32] = Sha256::digest(&canonical).into(); + if canonical != bytes || canonical_digest != result_digest { + return Err(invalid_publication()); + } + Ok(manifest) +} + +async fn verify_media_stream( + mut stream: crate::ByteStream, + expected_digest: &str, + expected_size: Option, + content_type: String, + verification_slot: OwnedSemaphorePermit, +) -> Result { + let maximum_size = expected_size.unwrap_or(MAX_THUMBNAIL_BYTES); + if maximum_size == 0 { + return Err(invalid_publication()); + } + let file = NamedTempFile::new().map_err(|error| MediaError::Io(error.to_string()))?; + let writer = file + .reopen() + .map_err(|error| MediaError::Io(error.to_string()))?; + let mut writer = tokio::fs::File::from_std(writer); + let mut digest = Sha256::new(); + let mut size = 0_u64; + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + size = size + .checked_add(u64::try_from(chunk.len()).map_err(|_| invalid_publication())?) + .ok_or_else(invalid_publication)?; + if size > maximum_size { + return Err(invalid_publication()); + } + digest.update(&chunk); + writer + .write_all(&chunk) + .await + .map_err(|error| MediaError::Io(error.to_string()))?; + } + writer + .flush() + .await + .map_err(|error| MediaError::Io(error.to_string()))?; + if size == 0 + || expected_size.is_some_and(|expected| expected != size) + || hex::encode(digest.finalize()) != expected_digest + { + return Err(invalid_publication()); + } + Ok(VerifiedMediaObject { + file, + content_type, + size, + _verification_slot: verification_slot, + }) +} + +async fn read_bounded_stream( + mut stream: crate::ByteStream, + maximum_size: u64, +) -> Result, MediaError> { + let mut bytes = Vec::new(); + let mut size = 0_u64; + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + size = size + .checked_add(u64::try_from(chunk.len()).map_err(|_| invalid_publication())?) + .ok_or_else(invalid_publication)?; + if size > maximum_size { + return Err(invalid_publication()); + } + bytes.extend_from_slice(&chunk); + } + if bytes.is_empty() { + return Err(invalid_publication()); + } + Ok(bytes) +} + +/// Exact immutable manifest key for a protected-operation result digest. +pub fn media_publication_manifest_key(result_digest: [u8; 32]) -> String { + format!("{PUBLICATION_PREFIX}/{}.json", hex::encode(result_digest)) +} + +fn nonempty(value: String) -> Option { + (!value.is_empty()).then_some(value) +} + +fn is_lower_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn is_safe_extension(value: &str) -> bool { + !value.is_empty() + && value.len() <= 8 + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) +} + +fn is_exact_mime(value: &str) -> bool { + is_bounded_text(value, 255) + && value.contains('/') + && !value.contains(',') + && value.bytes().all(|byte| byte.is_ascii_graphic()) +} + +fn is_bounded_text(value: &str, maximum: usize) -> bool { + !value.is_empty() + && value.len() <= maximum + && value == value.trim() + && !value.chars().any(char::is_control) +} + +fn invalid_publication() -> MediaError { + MediaError::StorageError("invalid immutable media publication".to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tenant(id: u128) -> TenantContext { + TenantContext::resolved( + CommunityId::from_uuid(Uuid::from_u128(id)), + format!("{id}.example"), + ) + } + + fn metadata() -> BlobMeta { + BlobMeta { + dim: "800x600".into(), + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj".into(), + thumb_url: "https://media.example/aaaaaaaa.thumb.jpg".into(), + thumbnail_sha256: Some("b".repeat(64)), + ext: "jpg".into(), + mime_type: "image/jpeg".into(), + size: 42, + uploaded_at: 1_800_000_000, + duration_secs: None, + } + } + + #[test] + fn canonical_manifest_is_deterministic_and_domain_bound() { + let sha = "a".repeat(64); + let one = MediaPublicationManifest::from_staged_blob(&tenant(1), &sha, &metadata()) + .expect("manifest"); + let again = MediaPublicationManifest::from_staged_blob(&tenant(1), &sha, &metadata()) + .expect("manifest"); + let other = MediaPublicationManifest::from_staged_blob(&tenant(2), &sha, &metadata()) + .expect("manifest"); + let one_bytes = one.canonical_bytes().expect("canonical bytes"); + assert_eq!(one_bytes, again.canonical_bytes().expect("canonical bytes")); + assert_ne!(one_bytes, other.canonical_bytes().expect("canonical bytes")); + assert_eq!(one.blob_key(), format!("{sha}.jpg")); + let expected_thumbnail_key = format!("{}.thumb.jpg", "b".repeat(64)); + assert_eq!( + one.thumbnail_blob_key().as_deref(), + Some(expected_thumbnail_key.as_str()) + ); + } + + #[test] + fn exact_metadata_changes_the_publication_digest() { + let sha = "a".repeat(64); + let original = MediaPublicationManifest::from_staged_blob(&tenant(1), &sha, &metadata()) + .expect("manifest"); + let mut changed = metadata(); + changed.mime_type = "image/png".into(); + let changed = MediaPublicationManifest::from_staged_blob(&tenant(1), &sha, &changed) + .expect("manifest"); + let digest = |manifest: &MediaPublicationManifest| -> [u8; 32] { + Sha256::digest(manifest.canonical_bytes().expect("canonical bytes")).into() + }; + assert_ne!(digest(&original), digest(&changed)); + } + + #[test] + fn manifest_rejects_ambiguous_or_unverifiable_metadata() { + let sha = "a".repeat(64); + let mut invalid = metadata(); + invalid.ext = "../jpg".into(); + assert!(MediaPublicationManifest::from_staged_blob(&tenant(1), &sha, &invalid).is_err()); + + let mut missing_thumbnail_digest = metadata(); + missing_thumbnail_digest.thumbnail_sha256 = None; + assert!(MediaPublicationManifest::from_staged_blob( + &tenant(1), + &sha, + &missing_thumbnail_digest + ) + .is_err()); + + let mut non_finite_duration = metadata(); + non_finite_duration.thumb_url.clear(); + non_finite_duration.thumbnail_sha256 = None; + non_finite_duration.duration_secs = Some(f64::NAN); + assert!( + MediaPublicationManifest::from_staged_blob(&tenant(1), &sha, &non_finite_duration) + .is_err() + ); + + let mut oversized = metadata(); + oversized.size = MAX_PRIMARY_BYTES + 1; + assert!(MediaPublicationManifest::from_staged_blob(&tenant(1), &sha, &oversized).is_err()); + } + + #[test] + fn manifest_round_trip_reconstructs_cache_metadata() { + let manifest = + MediaPublicationManifest::from_staged_blob(&tenant(1), "a".repeat(64), &metadata()) + .expect("manifest"); + let bytes = manifest.canonical_bytes().expect("bytes"); + let decoded: MediaPublicationManifest = serde_json::from_slice(&bytes).expect("decode"); + decoded.validate().expect("valid decoded manifest"); + let cached = decoded + .to_blob_meta("https://media.example") + .expect("cache metadata"); + assert_eq!(cached.thumbnail_sha256, Some("b".repeat(64))); + assert_eq!( + cached.thumb_url, + format!("https://media.example/{}.thumb.jpg", "a".repeat(64)) + ); + let descriptor = decoded + .descriptor("https://media.example") + .expect("replay descriptor"); + assert_eq!( + descriptor.url, + format!("https://media.example/{}.jpg", "a".repeat(64)) + ); + assert_eq!(descriptor.thumb, Some(cached.thumb_url)); + } + + #[test] + fn publication_decode_requires_exact_canonical_witness_bytes() { + let manifest = + MediaPublicationManifest::from_staged_blob(&tenant(1), "a".repeat(64), &metadata()) + .expect("manifest"); + let canonical = manifest.canonical_bytes().expect("canonical bytes"); + let digest: [u8; 32] = Sha256::digest(&canonical).into(); + assert_eq!( + decode_media_publication(&canonical, digest).expect("canonical witness"), + manifest + ); + + let noncanonical = serde_json::to_vec_pretty(&manifest).expect("pretty bytes"); + let noncanonical_digest: [u8; 32] = Sha256::digest(&noncanonical).into(); + assert!(decode_media_publication(&noncanonical, noncanonical_digest).is_err()); + assert!(decode_media_publication(&canonical, [0x55; 32]).is_err()); + } + + #[test] + fn manifest_key_is_exact_digest_address() { + assert_eq!( + media_publication_manifest_key([0xab; 32]), + format!("{PUBLICATION_PREFIX}/{}.json", "ab".repeat(32)) + ); + } + + #[test] + fn request_resolution_is_exact_and_sidecar_independent() { + let sha = "a".repeat(64); + let manifest = MediaPublicationManifest::from_staged_blob(&tenant(1), &sha, &metadata()) + .expect("manifest"); + assert_eq!( + manifest.resolve_request(&sha).expect("bare primary"), + MediaObjectKind::Primary + ); + assert_eq!( + manifest + .resolve_request(&format!("{sha}.jpg")) + .expect("exact primary"), + MediaObjectKind::Primary + ); + assert_eq!( + manifest + .resolve_request(&format!("{sha}.thumb.jpg")) + .expect("exact thumbnail"), + MediaObjectKind::Thumbnail + ); + for rejected in [ + format!("{sha}.png"), + format!("{sha}.JPG"), + format!("{sha}.thumb.jpeg"), + format!("{sha}.jpg/extra"), + ] { + assert!(matches!( + manifest.resolve_request(&rejected), + Err(MediaError::NotFound) + )); + } + } + + #[tokio::test] + async fn complete_bytes_are_verified_before_a_stream_is_returned() { + let bytes = Bytes::from_static(b"canonical media bytes"); + let digest = hex::encode(Sha256::digest(&bytes)); + let stream = futures_util::stream::iter([Ok(bytes.clone())]); + let verified = verify_media_stream( + Box::pin(stream), + &digest, + Some(bytes.len() as u64), + "application/octet-stream".into(), + std::sync::Arc::new(tokio::sync::Semaphore::new(1)) + .try_acquire_owned() + .expect("verification slot"), + ) + .await + .expect("verified object"); + assert_eq!(verified.size(), bytes.len() as u64); + assert_eq!( + verified.read_range(1, 4).await.expect("verified range"), + b"anon" + ); + + for (expected_digest, expected_size) in [ + ("00".repeat(32), Some(bytes.len() as u64)), + (digest.clone(), Some(bytes.len() as u64 + 1)), + ] { + let stream = futures_util::stream::iter([Ok(bytes.clone())]); + assert!(verify_media_stream( + Box::pin(stream), + &expected_digest, + expected_size, + "application/octet-stream".into(), + std::sync::Arc::new(tokio::sync::Semaphore::new(1)) + .try_acquire_owned() + .expect("verification slot"), + ) + .await + .is_err()); + } + } + + #[tokio::test] + async fn manifest_stream_read_is_bounded_independently_of_head() { + let chunks = [ + Ok(Bytes::from_static(b"1234")), + Ok(Bytes::from_static(b"5678")), + ]; + assert_eq!( + read_bounded_stream(Box::pin(futures_util::stream::iter(chunks)), 8) + .await + .expect("exact bound"), + b"12345678" + ); + + let oversized = [ + Ok(Bytes::from_static(b"1234")), + Ok(Bytes::from_static(b"56789")), + ]; + assert!( + read_bounded_stream(Box::pin(futures_util::stream::iter(oversized)), 8) + .await + .is_err() + ); + } +} diff --git a/crates/buzz-media/src/storage.rs b/crates/buzz-media/src/storage.rs index cbf980201f..f34d9c6153 100644 --- a/crates/buzz-media/src/storage.rs +++ b/crates/buzz-media/src/storage.rs @@ -2,6 +2,7 @@ use std::path::Path; use std::pin::Pin; +use std::sync::Arc; use buzz_core::tenant::{CommunityId, TenantContext}; @@ -12,12 +13,16 @@ use s3::creds::Credentials; use s3::{Bucket, Region}; use serde::{Deserialize, Serialize}; +/// Bound full-object verification disk and object-store amplification per process. +const DEFAULT_MEDIA_VERIFICATION_CONCURRENCY: usize = 2; + /// A stream of byte chunks from S3, usable with `axum::body::Body::from_stream()`. pub type ByteStream = Pin> + Send>>; /// S3-compatible object storage client. pub struct MediaStorage { bucket: Box, + pub(crate) verification_slots: Arc, } impl MediaStorage { @@ -66,7 +71,12 @@ impl MediaStorage { S3AddressingStyle::Path => bucket.with_path_style(), S3AddressingStyle::Virtual => bucket, }; - Ok(Self { bucket }) + Ok(Self { + bucket, + verification_slots: Arc::new(tokio::sync::Semaphore::new( + DEFAULT_MEDIA_VERIFICATION_CONCURRENCY, + )), + }) } /// Store an object from a byte slice. @@ -410,6 +420,9 @@ pub struct BlobMeta { pub blurhash: String, /// Full URL to thumbnail. pub thumb_url: String, + /// SHA-256 of the exact thumbnail bytes, when a thumbnail exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thumbnail_sha256: Option, /// File extension (e.g. "jpg"). pub ext: String, /// MIME type (e.g. "image/jpeg"). diff --git a/crates/buzz-media/src/upload.rs b/crates/buzz-media/src/upload.rs index 524b033280..0833562181 100644 --- a/crates/buzz-media/src/upload.rs +++ b/crates/buzz-media/src/upload.rs @@ -1,13 +1,15 @@ -//! Upload pipeline — validate, store, thumbnail, sidecar. +//! Upload pipeline — validate and stage immutable blobs and manifests. use buzz_core::tenant::TenantContext; use bytes::Bytes; use sha2::{Digest, Sha256}; +use std::fmt; use tokio::io::AsyncWriteExt; use crate::auth::verify_blossom_upload_auth; use crate::config::MediaConfig; use crate::error::MediaError; +use crate::publication::{MediaPublicationManifest, StagedMediaPublication}; use crate::storage::{BlobMeta, MediaStorage}; use crate::thumbnail::generate_image_metadata_sync; use crate::types::BlobDescriptor; @@ -24,24 +26,20 @@ use crate::validation::{ /// the `(mime, ext)` pair for the body. Images derive `ext` from the MIME; /// generic files get both from the deny-list validator. /// - `prepare_metadata`: builds metadata and stores any derived artifacts such -/// as a thumbnail, but deliberately does not write the sidecar. The sidecar -/// is the media serve gate and is published only after the moderation record -/// succeeds. It receives the already-computed +/// as a thumbnail, but deliberately does not write operational projections. +/// It receives the already-computed /// `(sha256, ext, mime, uploaded_at)` so no work is repeated. /// /// Everything else — hash, Blossom auth (10-minute window), content-addressed -/// key, the both-exist idempotency short-circuit, blob store, orphan-blob -/// handling, and descriptor build — is common. The streaming video path stays -/// separate (see [`process_video_upload`]) because it never buffers in RAM. +/// key, blob store, orphan-blob handling, and descriptor build — is common. +/// The streaming video path stays separate (see [`stage_video_upload`]) +/// because it never buffers in RAM. /// /// `attribution` is `Some` when per-event upload records are enabled -/// (`BUZZ_MEDIA_UPLOAD_RECORDS`): a record is then written for **every** -/// accepted upload — including the idempotent short-circuit, which does no -/// blob PUT and would otherwise be invisible to the moderation pipeline. -/// For fresh uploads, the record is written after the blob and derived -/// artifacts but before the sidecar. This preserves both contracts: record -/// existence implies referenced objects are readable, while a record failure -/// cannot publish media without triggering moderation. +/// (`BUZZ_MEDIA_UPLOAD_RECORDS`). Staging retains the exact record facts but +/// does not call the record sink: an upload is not accepted until its canonical +/// protected-operation witness commits. The caller publishes the retained +/// record and cache-only sidecar afterward. struct BufferedUploadInput<'a> { storage: &'a MediaStorage, config: &'a MediaConfig, @@ -51,11 +49,83 @@ struct BufferedUploadInput<'a> { attribution: Option, } -async fn process_buffered_upload( +struct StagedUploadRecord { + uploader: nostr::PublicKey, + attribution: UploadAttribution, + sha256: String, + ext: String, + mime: String, + size: u64, + uploaded_at: i64, +} + +/// Immutable artifacts and response metadata staged before DB publication. +pub struct StagedMediaUpload { + publication: StagedMediaPublication, + descriptor: BlobDescriptor, + upload_record: Option, +} + +impl fmt::Debug for StagedMediaUpload { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("StagedMediaUpload([REDACTED])") + } +} + +impl StagedMediaUpload { + /// Opaque immutable publication consumed by the protected-operation commit. + pub const fn publication(&self) -> &StagedMediaPublication { + &self.publication + } + + /// Response descriptor returned only after the DB witness commits. + pub const fn descriptor(&self) -> &BlobDescriptor { + &self.descriptor + } + + /// Consume the staged upload after publication and cache projection. + pub fn into_descriptor(self) -> BlobDescriptor { + self.descriptor + } + + /// Publish non-authoritative operational projections after DB publication. + /// + /// The moderation record describes an accepted publication, so staging + /// must not write it before the canonical protected-operation commit. The + /// sidecar is refreshed afterward and remains a disposable cache. + pub async fn publish_post_commit_projections( + &self, + storage: &MediaStorage, + ctx: &TenantContext, + public_base_url: &str, + ) -> Result<(), MediaError> { + if let Some(record) = &self.upload_record { + record_upload_event( + storage, + ctx, + &record.uploader, + &record.attribution, + UploadEventFacts { + sha256: &record.sha256, + ext: &record.ext, + mime: &record.mime, + size: record.size, + uploaded_at: record.uploaded_at, + }, + ) + .await?; + } + self.publication + .publish_sidecar_cache(storage, ctx, public_base_url) + .await + } +} + +async fn stage_buffered_upload( input: BufferedUploadInput<'_>, validate: V, prepare_metadata: M, -) -> Result +) -> Result where V: FnOnce(&Bytes, &MediaConfig) -> Result<(String, String), MediaError> + Send + 'static, M: FnOnce(MetadataInput) -> Fut, @@ -89,45 +159,6 @@ where .map_err(|_| MediaError::Internal)??; let key = format!("{sha256}.{ext}"); - let meta_key = MediaStorage::ctx_sidecar_key(ctx, &sha256); - - // Idempotent: short-circuit only if BOTH sidecar and blob exist. If the - // sidecar exists but the blob is missing, fall through to re-upload. - let sidecar_exists = storage.head(&meta_key).await?; - let blob_exists = storage.head(&key).await?; - if sidecar_exists && blob_exists { - let meta = storage.get_sidecar(ctx, &sha256).await?; - // A re-upload of known bytes is still a distinct upload *event*: no - // blob PUT happens, so without this record the uploader would be - // invisible to the moderation pipeline (and takedown re-uploads - // would go unscanned). - if let Some(attribution) = &attribution { - record_upload_event( - storage, - ctx, - &auth_event.pubkey, - attribution, - UploadEventFacts { - sha256: &sha256, - ext: &ext, - mime: &mime, - size: body.len() as u64, - uploaded_at: chrono::Utc::now().timestamp(), - }, - ) - .await?; - } - return Ok(build_descriptor( - config, - &sha256, - &ext, - &mime, - body.len() as u64, - Some(&meta), - meta.uploaded_at, - )); - } - // Compute uploaded_at once — single source of truth for sidecar and response. let uploaded_at = chrono::Utc::now().timestamp(); @@ -156,36 +187,23 @@ where } }; - // The moderation record precedes the sidecar publish gate. If this write - // fails, the blob and any thumbnail remain orphaned but the media cannot be - // served. Conversely, record existence still implies those objects exist. - if let Some(attribution) = &attribution { - record_upload_event( - storage, - ctx, - &auth_event.pubkey, - attribution, - UploadEventFacts { - sha256: &sha256, - ext: &ext, - mime: &mime, - size: body.len() as u64, - uploaded_at, - }, - ) - .await?; - } - storage.put_sidecar(ctx, &sha256, &meta).await?; - - Ok(build_descriptor( - config, - &sha256, - &ext, - &mime, - body.len() as u64, - Some(&meta), + let manifest = MediaPublicationManifest::from_staged_blob(ctx, &sha256, &meta)?; + let publication = storage.stage_media_publication(manifest).await?; + let descriptor = publication.manifest().descriptor(&config.public_base_url)?; + let upload_record = attribution.map(|attribution| StagedUploadRecord { + uploader: auth_event.pubkey, + attribution, + sha256, + ext, + mime, + size: body.len() as u64, uploaded_at, - )) + }); + Ok(StagedMediaUpload { + publication, + descriptor, + upload_record, + }) } /// Inputs handed to a buffered-upload metadata builder, after the shared @@ -200,19 +218,19 @@ struct MetadataInput { uploaded_at: i64, } -/// Process an upload end-to-end: validate, store, thumbnail, return descriptor. +/// Validate an image and stage all immutable artifacts without publishing it. /// /// This is the image path — body is already fully buffered in RAM. Do NOT use -/// this for video uploads; use [`process_video_upload`] instead. -pub async fn process_upload( +/// this for video uploads; use [`stage_video_upload`] instead. +pub async fn stage_upload( storage: &MediaStorage, config: &MediaConfig, ctx: &TenantContext, auth_event: &nostr::Event, body: Bytes, attribution: Option, -) -> Result { - process_buffered_upload( +) -> Result { + stage_buffered_upload( BufferedUploadInput { storage, config, @@ -231,6 +249,26 @@ pub async fn process_upload( .await } +/// Legacy end-to-end image helper retained until relay DB composition lands. +/// +/// Protected transports must call [`stage_upload`], commit the returned digest +/// in PostgreSQL, and only then invoke +/// [`StagedMediaUpload::publish_post_commit_projections`]. +pub async fn process_upload( + storage: &MediaStorage, + config: &MediaConfig, + ctx: &TenantContext, + auth_event: &nostr::Event, + body: Bytes, + attribution: Option, +) -> Result { + let staged = stage_upload(storage, config, ctx, auth_event, body, attribution).await?; + staged + .publish_post_commit_projections(storage, ctx, &config.public_base_url) + .await?; + Ok(staged.into_descriptor()) +} + /// Process a generic non-media file upload end-to-end. /// /// This is the catch-all attachment path for documents, archives, text, and @@ -242,15 +280,15 @@ pub async fn process_upload( /// /// The resulting blob is served with `Content-Disposition: attachment`, so the /// client always downloads it rather than rendering it inline. -pub async fn process_file_upload( +pub async fn stage_file_upload( storage: &MediaStorage, config: &MediaConfig, ctx: &TenantContext, auth_event: &nostr::Event, body: Bytes, attribution: Option, -) -> Result { - process_buffered_upload( +) -> Result { + stage_buffered_upload( BufferedUploadInput { storage, config, @@ -266,6 +304,7 @@ pub async fn process_file_upload( dim: String::new(), blurhash: String::new(), thumb_url: String::new(), + thumbnail_sha256: None, size: input.body.len() as u64, ext: input.ext, mime_type: input.mime, @@ -278,7 +317,23 @@ pub async fn process_file_upload( .await } -/// Process a video upload end-to-end using a streaming pipeline. +/// Legacy end-to-end file helper retained until relay DB composition lands. +pub async fn process_file_upload( + storage: &MediaStorage, + config: &MediaConfig, + ctx: &TenantContext, + auth_event: &nostr::Event, + body: Bytes, + attribution: Option, +) -> Result { + let staged = stage_file_upload(storage, config, ctx, auth_event, body, attribution).await?; + staged + .publish_post_commit_projections(storage, ctx, &config.public_base_url) + .await?; + Ok(staged.into_descriptor()) +} + +/// Validate a video and stage all immutable artifacts using a streaming pipeline. /// /// Unlike [`process_upload`], this function: /// 1. Streams the request body to a [`tempfile::NamedTempFile`] while computing @@ -289,7 +344,7 @@ pub async fn process_file_upload( /// 5. Writes a sidecar with `duration_secs` (no thumbnail — desktop handles that). /// /// Returns a [`BlobDescriptor`] with the `duration` field populated. -pub async fn process_video_upload( +pub async fn stage_video_upload( storage: &MediaStorage, config: &MediaConfig, ctx: &TenantContext, @@ -297,7 +352,7 @@ pub async fn process_video_upload( body_stream: impl futures_core::Stream> + Send + 'static, content_length: Option, attribution: Option, -) -> Result { +) -> Result { // --- 1. Stream body to temp file, compute SHA-256 incrementally --- let tmp = tempfile::NamedTempFile::new().map_err(|e| MediaError::Io(e.to_string()))?; let tmp_path = tmp.path().to_path_buf(); @@ -424,42 +479,6 @@ pub async fn process_video_upload( let ext = "mp4"; let key = format!("{sha256_hex}.{ext}"); - let meta_key = MediaStorage::ctx_sidecar_key(ctx, &sha256_hex); - - // --- 5. Idempotency check --- - let sidecar_exists = storage.head(&meta_key).await?; - let blob_exists = storage.head(&key).await?; - if sidecar_exists && blob_exists { - let meta = storage.get_sidecar(ctx, &sha256_hex).await?; - // Re-upload of known bytes: still a distinct upload event — see the - // buffered path's short-circuit for the rationale. - if let Some(attribution) = &attribution { - record_upload_event( - storage, - ctx, - &auth_event.pubkey, - attribution, - UploadEventFacts { - sha256: &sha256_hex, - ext, - mime: &mime, - size: file_size, - uploaded_at: chrono::Utc::now().timestamp(), - }, - ) - .await?; - } - return Ok(build_descriptor( - config, - &sha256_hex, - ext, - &mime, - file_size, - Some(&meta), - meta.uploaded_at, - )); - } - let uploaded_at = chrono::Utc::now().timestamp(); // --- 6. Stream blob from temp file to S3 --- @@ -471,6 +490,7 @@ pub async fn process_video_upload( dim: format!("{}x{}", video_meta.width, video_meta.height), blurhash: String::new(), thumb_url: String::new(), + thumbnail_sha256: None, ext: ext.to_string(), mime_type: mime.clone(), size: file_size, @@ -478,37 +498,52 @@ pub async fn process_video_upload( duration_secs: Some(video_meta.duration_secs), }; - // Record before publishing the sidecar serve gate. See the buffered path. - if let Some(attribution) = &attribution { - record_upload_event( - storage, - ctx, - &auth_event.pubkey, - attribution, - UploadEventFacts { - sha256: &sha256_hex, - ext, - mime: &mime, - size: file_size, - uploaded_at, - }, - ) - .await?; - } - storage.put_sidecar(ctx, &sha256_hex, &meta).await?; + let manifest = MediaPublicationManifest::from_staged_blob(ctx, &sha256_hex, &meta)?; + let publication = storage.stage_media_publication(manifest).await?; + let descriptor = publication.manifest().descriptor(&config.public_base_url)?; + let upload_record = attribution.map(|attribution| StagedUploadRecord { + uploader: auth_event.pubkey, + attribution, + sha256: sha256_hex, + ext: ext.to_owned(), + mime, + size: file_size, + uploaded_at, + }); + Ok(StagedMediaUpload { + publication, + descriptor, + upload_record, + }) +} - Ok(build_descriptor( +/// Legacy end-to-end video helper retained until relay DB composition lands. +pub async fn process_video_upload( + storage: &MediaStorage, + config: &MediaConfig, + ctx: &TenantContext, + auth_event: &nostr::Event, + body_stream: impl futures_core::Stream> + Send + 'static, + content_length: Option, + attribution: Option, +) -> Result { + let staged = stage_video_upload( + storage, config, - &sha256_hex, - ext, - &mime, - file_size, - Some(&meta), - uploaded_at, - )) + ctx, + auth_event, + body_stream, + content_length, + attribution, + ) + .await?; + staged + .publish_post_commit_projections(storage, ctx, &config.public_base_url) + .await?; + Ok(staged.into_descriptor()) } -/// Generate thumbnail and metadata without publishing the sidecar serve gate. +/// Generate immutable thumbnail and metadata without publishing cache projections. /// Returns the completed [`BlobMeta`] on success. async fn prepare_image_metadata( storage: &MediaStorage, @@ -529,68 +564,27 @@ async fn prepare_image_metadata( meta.uploaded_at = input.uploaded_at; if let Some(ref tb) = thumb_bytes { - let thumb_key = format!("{}.thumb.jpg", input.sha256); + let thumbnail_sha256 = hex::encode(Sha256::digest(tb)); + let thumb_key = format!("{thumbnail_sha256}.thumb.jpg"); storage.put(&thumb_key, tb, "image/jpeg").await?; + meta.thumbnail_sha256 = Some(thumbnail_sha256); } Ok(meta) } -fn build_descriptor( - config: &MediaConfig, - sha256: &str, - ext: &str, - mime: &str, - size: u64, - meta: Option<&BlobMeta>, - uploaded_at: i64, -) -> BlobDescriptor { - let duration = meta.and_then(|m| m.duration_secs); - BlobDescriptor { - url: format!("{}/{sha256}.{ext}", config.public_base_url), - sha256: sha256.to_string(), - size, - mime_type: mime.to_string(), - uploaded: uploaded_at, - dim: meta.and_then(|m| (!m.dim.is_empty()).then(|| m.dim.clone())), - blurhash: meta.and_then(|m| (!m.blurhash.is_empty()).then(|| m.blurhash.clone())), - thumb: meta.and_then(|m| (!m.thumb_url.is_empty()).then(|| m.thumb_url.clone())), - duration, - } -} - #[cfg(test)] mod tests { use super::*; - fn test_config() -> MediaConfig { - MediaConfig { - s3_endpoint: String::new(), - s3_access_key: String::new(), - s3_secret_key: String::new(), - s3_bucket: String::new(), - s3_region: "us-east-1".to_string(), - s3_addressing_style: crate::config::S3AddressingStyle::Path, - max_image_bytes: 50 * 1024 * 1024, - max_gif_bytes: 10 * 1024 * 1024, - max_video_bytes: 524_288_000, - max_file_bytes: 104_857_600, - public_base_url: "https://media.example.com".to_string(), - upload_records_enabled: false, - upload_ip_header: None, - upload_port_header: None, - } - } - #[test] - fn test_build_descriptor_video_omits_empty_thumb_and_blurhash() { - // Video uploads produce a BlobMeta with empty thumb_url and blurhash. - // build_descriptor must convert these to None so they're omitted from JSON. - let config = test_config(); + fn test_publication_descriptor_video_omits_empty_thumb_and_blurhash() { + // Video publications omit thumbnail and blurhash response fields. let meta = BlobMeta { dim: "320x240".to_string(), blurhash: String::new(), // empty — video has no blurhash thumb_url: String::new(), // empty — video has no thumbnail + thumbnail_sha256: None, ext: "mp4".to_string(), mime_type: "video/mp4".to_string(), size: 5_000_000, @@ -598,15 +592,17 @@ mod tests { duration_secs: Some(29.5), }; - let desc = build_descriptor( - &config, - "abc123", - "mp4", - "video/mp4", - 5_000_000, - Some(&meta), - 1700000000, - ); + let desc = MediaPublicationManifest::from_staged_blob( + &TenantContext::resolved( + buzz_core::CommunityId::from_uuid(uuid::Uuid::from_u128(1)), + "media.example.com", + ), + "a".repeat(64), + &meta, + ) + .expect("manifest") + .descriptor("https://media.example.com") + .expect("descriptor"); // Empty strings must become None, not Some("") assert!( @@ -641,14 +637,14 @@ mod tests { } #[test] - fn test_build_descriptor_image_includes_thumb_and_blurhash() { + fn test_publication_descriptor_image_includes_thumb_and_blurhash() { // Image uploads produce a BlobMeta with populated thumb_url and blurhash. - let config = test_config(); let hash = "a".repeat(64); let meta = BlobMeta { dim: "800x600".to_string(), blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj".to_string(), thumb_url: format!("https://media.example.com/{hash}.thumb.jpg"), + thumbnail_sha256: Some("b".repeat(64)), ext: "jpg".to_string(), mime_type: "image/jpeg".to_string(), size: 100_000, @@ -656,15 +652,17 @@ mod tests { duration_secs: None, }; - let desc = build_descriptor( - &config, + let desc = MediaPublicationManifest::from_staged_blob( + &TenantContext::resolved( + buzz_core::CommunityId::from_uuid(uuid::Uuid::from_u128(1)), + "media.example.com", + ), &hash, - "jpg", - "image/jpeg", - 100_000, - Some(&meta), - 1700000000, - ); + &meta, + ) + .expect("manifest") + .descriptor("https://media.example.com") + .expect("descriptor"); assert_eq!( desc.blurhash, @@ -712,18 +710,29 @@ mod tests { } #[test] - fn test_build_descriptor_no_meta() { - // When meta is None, all optional fields should be None. - let config = test_config(); - let desc = build_descriptor( - &config, - "abc123", - "jpg", - "image/jpeg", - 100, - None, - 1700000000, - ); + fn test_publication_descriptor_without_optional_metadata() { + let meta = BlobMeta { + dim: String::new(), + blurhash: String::new(), + thumb_url: String::new(), + thumbnail_sha256: None, + ext: "bin".to_owned(), + mime_type: "application/octet-stream".to_owned(), + size: 100, + uploaded_at: 1_700_000_000, + duration_secs: None, + }; + let desc = MediaPublicationManifest::from_staged_blob( + &TenantContext::resolved( + buzz_core::CommunityId::from_uuid(uuid::Uuid::from_u128(1)), + "media.example.com", + ), + "a".repeat(64), + &meta, + ) + .expect("manifest") + .descriptor("https://media.example.com") + .expect("descriptor"); assert!(desc.dim.is_none()); assert!(desc.blurhash.is_none()); diff --git a/crates/buzz-media/src/upload_record.rs b/crates/buzz-media/src/upload_record.rs index 42f57fbd26..ee9306a14c 100644 --- a/crates/buzz-media/src/upload_record.rs +++ b/crates/buzz-media/src/upload_record.rs @@ -31,10 +31,11 @@ //! The moderation pipeline triggers on `ObjectCreated` events under the //! `_uploads/` prefix and parses this record instead of HEADing blobs: //! -//! - For fresh uploads, the record is written after the blob and derived -//! artifacts but before the sidecar serve gate. Record existence therefore -//! implies the scan inputs are readable, while record failure cannot leave -//! unscanned media publicly servable. +//! - For fresh uploads, the record is written after immutable blob staging and +//! the canonical PostgreSQL publication commit, but before cache projections. +//! Record existence therefore implies the scan inputs are readable. A record +//! failure leaves the DB-witnessed object authoritative but unprojected for +//! moderation retry; a sidecar never grants canonical visibility. //! - `ext`, `mime_type`, and `size` are always present so the consumer can //! derive the blob key (`{sha256}.{ext}`) and scan eligibility without //! extra round-trips. @@ -131,11 +132,10 @@ pub struct UploadEventFacts<'a> { /// Build and store the per-event record for one accepted upload. /// -/// Called after blob and derived-artifact durability but before the sidecar -/// publish gate on fresh uploads; called on the existing published state for -/// idempotent re-uploads. A write failure propagates and fails the upload. The -/// record's `ObjectCreated` event is the moderation pipeline's only scan -/// trigger, so no newly published media may exist without a record. +/// Protected publication callers invoke this only after the canonical DB +/// witness commits; immutable staging alone must not claim acceptance. The +/// cache-only sidecar is refreshed afterward. A write failure propagates and +/// leaves the DB-witnessed immutable object authoritative but unprojected. pub async fn record_upload_event( storage: &crate::storage::MediaStorage, ctx: &TenantContext, diff --git a/crates/buzz-pubsub/src/nip98_replay.rs b/crates/buzz-pubsub/src/nip98_replay.rs index 858f0f5ace..4c6e4f7989 100644 --- a/crates/buzz-pubsub/src/nip98_replay.rs +++ b/crates/buzz-pubsub/src/nip98_replay.rs @@ -1,8 +1,9 @@ //! Redis-backed NIP-98 replay seen-set. //! //! Implements the [`Nip98ReplayGuard`] trait from `buzz-auth`. Uses Redis -//! `SET NX EX` for an atomic set-if-absent with TTL — the §5 pre-build gate -//! for multi-tenant HA replay protection. +//! `SET NX EX` for single proofs and one Lua all-or-none transaction for +//! multi-proof sets — the §5 pre-build gate for multi-tenant HA replay +//! protection. use buzz_auth::{ error::AuthError, @@ -12,6 +13,18 @@ use buzz_auth::{ }; use nostr::EventId; +const CLAIM_ALL_SCRIPT: &str = r#" +for _, key in ipairs(KEYS) do + if redis.call('EXISTS', key) ~= 0 then + return 0 + end +end +for _, key in ipairs(KEYS) do + redis.call('SET', key, '1', 'EX', ARGV[1]) +end +return 1 +"#; + /// Redis-backed NIP-98 replay seen-set. /// /// Each `try_mark(ctx, event_id, ttl)` issues a single @@ -96,6 +109,75 @@ impl Nip98ReplayGuard for RedisNip98ReplayGuard { } }) } + + fn try_mark_all_in_scope<'a>( + &'a self, + scope: &'a str, + event_ids: &'a [EventId], + ttl_secs: u64, + ) -> std::pin::Pin> + Send + 'a>> + { + Box::pin(async move { + let valid_cardinality = (1..=3).contains(&event_ids.len()); + let distinct = event_ids + .iter() + .enumerate() + .all(|(index, event_id)| !event_ids[..index].contains(event_id)); + if !valid_cardinality || !distinct { + return Err(AuthError::Internal( + "invalid NIP-98 proof-set replay claim".to_owned(), + )); + } + + let ttl = ttl_secs.clamp(DEFAULT_REPLAY_TTL_SECS, MAX_REPLAY_TTL_SECS); + let keys: Vec = event_ids + .iter() + .map(|event_id| nip98_replay_key_for_scope(scope, event_id)) + .collect(); + let mut conn = self.pool.get().await.map_err(|error| { + tracing::warn!( + scope = %scope, + error = %error, + "nip98 replay: redis pool acquire failed for proof set — caller MUST fail closed" + ); + AuthError::Internal(format!("Redis pool: {error}")) + })?; + + let script = redis::Script::new(CLAIM_ALL_SCRIPT); + let mut invocation = script.prepare_invoke(); + for key in &keys { + invocation.key(key); + } + let result: i64 = + invocation + .arg(ttl) + .invoke_async(&mut *conn) + .await + .map_err(|error| { + tracing::warn!( + scope = %scope, + proof_count = event_ids.len(), + error = %error, + "nip98 replay: atomic proof-set claim failed — caller MUST fail closed" + ); + AuthError::Internal(format!("Redis atomic proof-set claim: {error}")) + })?; + match result { + 1 => Ok(true), + 0 => Ok(false), + other => { + tracing::error!( + scope = %scope, + reply = other, + "nip98 replay: atomic proof-set claim returned an unexpected reply" + ); + Err(AuthError::Internal(format!( + "unexpected atomic proof-set reply: {other}" + ))) + } + } + }) + } } #[cfg(test)] @@ -106,6 +188,13 @@ mod tests { use nostr::{EventBuilder, Keys, Kind}; use uuid::Uuid; + #[test] + fn redis_guard_implements_the_mandatory_atomic_batch_contract() { + fn requires_mandatory_batch() {} + + requires_mandatory_batch::(); + } + fn redis_pool() -> deadpool_redis::Pool { let url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into()); Config::from_url(url) @@ -199,4 +288,91 @@ mod tests { .await .expect("replay with extreme ttl must succeed via clamp")); } + + #[tokio::test] + async fn malformed_proof_sets_fail_before_redis_io() { + let guard = RedisNip98ReplayGuard::new(redis_pool()); + let event_id = fresh_event_id(); + assert!(guard + .try_mark_all_in_scope("invalid-empty", &[], DEFAULT_REPLAY_TTL_SECS) + .await + .is_err()); + assert!(guard + .try_mark_all_in_scope( + "invalid-duplicate", + &[event_id, event_id], + DEFAULT_REPLAY_TTL_SECS, + ) + .await + .is_err()); + assert!(guard + .try_mark_all_in_scope( + "invalid-large", + &[ + fresh_event_id(), + fresh_event_id(), + fresh_event_id(), + fresh_event_id(), + ], + DEFAULT_REPLAY_TTL_SECS, + ) + .await + .is_err()); + } + + #[tokio::test] + #[ignore = "requires Redis"] + async fn atomic_proof_sets_support_one_two_and_three_ids() { + let guard = RedisNip98ReplayGuard::new(redis_pool()); + for count in 1..=3 { + let scope = format!("proof-set-cardinality-{count}-{}", Uuid::new_v4()); + let event_ids: Vec<_> = (0..count).map(|_| fresh_event_id()).collect(); + assert!(guard + .try_mark_all_in_scope(&scope, &event_ids, DEFAULT_REPLAY_TTL_SECS) + .await + .expect("first proof-set claim")); + assert!(!guard + .try_mark_all_in_scope(&scope, &event_ids, DEFAULT_REPLAY_TTL_SECS) + .await + .expect("replayed proof-set claim")); + } + } + + #[tokio::test] + #[ignore = "requires Redis"] + async fn conflicting_proof_set_writes_no_partial_markers() { + let guard = RedisNip98ReplayGuard::new(redis_pool()); + let scope = format!("proof-set-conflict-{}", Uuid::new_v4()); + let existing = fresh_event_id(); + let fresh = fresh_event_id(); + assert!(guard + .try_mark_in_scope(&scope, &existing, DEFAULT_REPLAY_TTL_SECS) + .await + .expect("seed existing marker")); + assert!(!guard + .try_mark_all_in_scope(&scope, &[existing, fresh], DEFAULT_REPLAY_TTL_SECS,) + .await + .expect("conflicting batch")); + assert!(guard + .try_mark_in_scope(&scope, &fresh, DEFAULT_REPLAY_TTL_SECS) + .await + .expect("fresh marker was not partially written")); + } + + #[tokio::test] + #[ignore = "requires Redis"] + async fn concurrent_complete_proof_set_has_one_winner() { + let pool = redis_pool(); + let first = RedisNip98ReplayGuard::new(pool.clone()); + let second = RedisNip98ReplayGuard::new(pool); + let scope = format!("proof-set-race-{}", Uuid::new_v4()); + let event_ids = [fresh_event_id(), fresh_event_id(), fresh_event_id()]; + let (first_result, second_result) = tokio::join!( + first.try_mark_all_in_scope(&scope, &event_ids, DEFAULT_REPLAY_TTL_SECS), + second.try_mark_all_in_scope(&scope, &event_ids, DEFAULT_REPLAY_TTL_SECS), + ); + let first_result = first_result.expect("first contender result"); + let second_result = second_result.expect("second contender result"); + assert_ne!(first_result, second_result); + } } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index c08536948b..d6bba6e4fd 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -2516,6 +2516,19 @@ mod tests { )) }) } + + fn try_mark_all_in_scope<'a>( + &'a self, + _scope: &'a str, + _event_ids: &'a [EventId], + _ttl_secs: u64, + ) -> Pin> + Send + 'a>> { + Box::pin(async { + Err(AuthError::Internal( + "simulated Redis pool acquire failure".into(), + )) + }) + } } let guard = AlwaysErrGuard; @@ -3467,6 +3480,29 @@ mod tests { > { Box::pin(async { Ok(true) }) } + + fn try_mark_all_in_scope<'a>( + &'a self, + _scope: &'a str, + event_ids: &'a [nostr::EventId], + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(async move { + if !(1..=3).contains(&event_ids.len()) + || event_ids + .iter() + .enumerate() + .any(|(index, event_id)| event_ids[..index].contains(event_id)) + { + return Err(buzz_auth::AuthError::Internal( + "invalid atomic NIP-98 proof set".to_owned(), + )); + } + Ok(true) + }) + } } const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 diff --git a/crates/buzz-relay/src/api/git/cas_publish.rs b/crates/buzz-relay/src/api/git/cas_publish.rs index 50bb36d818..41e0a07544 100644 --- a/crates/buzz-relay/src/api/git/cas_publish.rs +++ b/crates/buzz-relay/src/api/git/cas_publish.rs @@ -62,6 +62,7 @@ //! exact contention `Inv_NoFork` proves safe. use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; @@ -76,7 +77,7 @@ use crate::api::git::manifest::{ PACK_COMPACTION_THRESHOLD, }; use crate::api::git::store::{CasOutcome, ETag, GitStore, Precond, StoreError}; -use buzz_core::TenantContext; +use buzz_core::{CommunityId, TenantContext}; const PACK_CAPTURE_TIMEOUT: Duration = Duration::from_secs(300); const PACK_COMPACTION_OPERATION_TIMEOUT: Duration = Duration::from_secs(600); @@ -179,6 +180,74 @@ struct CompactionObservation { compacted_bytes: u64, } +/// Immutable Git publication staged in content-addressed storage. +/// +/// This value does not make the repository visible. The manifest digest must +/// first be committed as the canonical PostgreSQL protected-operation result; +/// only then may a raw object-store pointer be refreshed as a disposable cache. +pub(crate) struct StagedGitPublication { + community_id: CommunityId, + owner: String, + repo: String, + expected_parent_result_digest: Option<[u8; 32]>, + manifest: Manifest, + manifest_key: String, + result_digest: [u8; 32], +} + +impl fmt::Debug for StagedGitPublication { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("StagedGitPublication([REDACTED])") + } +} + +#[allow(dead_code)] // Accessed by the pending S5 publication transaction adapter. +impl StagedGitPublication { + /// Server-resolved authorization domain bound to this publication. + pub(crate) const fn community_id(&self) -> CommunityId { + self.community_id + } + + /// Canonical repository owner bound to this publication. + pub(crate) fn owner(&self) -> &str { + &self.owner + } + + /// Canonical repository name bound to this publication. + pub(crate) fn repo(&self) -> &str { + &self.repo + } + + /// Exact DB-witnessed parent, or `None` only for first publication. + pub(crate) const fn expected_parent_result_digest(&self) -> Option<[u8; 32]> { + self.expected_parent_result_digest + } + + /// Canonical immutable manifest to use for derived ref-state events. + pub(crate) const fn manifest(&self) -> &Manifest { + &self.manifest + } + + /// Full content-addressed manifest key (`manifests/`). + pub(crate) fn manifest_key(&self) -> &str { + &self.manifest_key + } + + /// Exact immutable publication digest committed in the operation receipt. + pub(crate) const fn result_digest(&self) -> [u8; 32] { + self.result_digest + } +} + +/// Best-effort result of refreshing the non-authoritative raw pointer cache. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PointerCacheUpdate { + /// The cache now names the staged manifest. + Updated, + /// The cache CAS observed another value and was deliberately left alone. + Stale, +} + struct PreparedCompaction { pack_keys: Vec, packs_after: usize, @@ -1018,6 +1087,35 @@ pub async fn cas_publish( .await } +/// Stage packs and one verified immutable manifest without publishing visibility. +/// +/// The returned digest is suitable for the canonical protected-operation +/// receipt. This function never reads or writes the raw repository pointer. +#[allow(dead_code)] // Activated only after S5 freezes the joined publication seam. +pub(crate) async fn stage_git_publication( + store: &GitStore, + ctx: &TenantContext, + owner: &str, + repo: &str, + repo_path: &Path, + parent_state: &ParentState, + limits: PublishLimits, +) -> Result { + stage_git_publication_inner( + store, + ctx, + owner, + repo, + repo_path, + parent_state, + PublishOptions { + limits, + compaction_threshold: PACK_COMPACTION_THRESHOLD, + }, + ) + .await +} + async fn cas_publish_inner( store: &GitStore, ctx: &TenantContext, @@ -1027,8 +1125,44 @@ async fn cas_publish_inner( parent_state: &ParentState, options: PublishOptions, ) -> Result { + let staged = + stage_git_publication_inner(store, ctx, owner, repo, repo_path, parent_state, options) + .await?; + publish_pointer_cache_strict(store, ctx, owner, repo, parent_state, staged).await +} + +async fn stage_git_publication_inner( + store: &GitStore, + ctx: &TenantContext, + owner: &str, + repo: &str, + repo_path: &Path, + parent_state: &ParentState, + options: PublishOptions, +) -> Result { + let canonical_repo = repo.strip_suffix(".git").unwrap_or(repo); + if owner.len() != 64 + || !owner + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + || canonical_repo.is_empty() + || canonical_repo.len() > 64 + || canonical_repo.starts_with('.') + || canonical_repo.contains("..") + || !canonical_repo + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err(CasError::ManifestInvalid(ManifestError::UnsafeRefName( + "invalid repository publication target".to_owned(), + ))); + } + let expected_parent_result_digest = parent_state + .parent_digest + .as_deref() + .map(parse_publication_digest) + .transpose()?; let limits = options.limits; - let pkey = pointer_key(ctx.community(), owner, repo); // Hydrated repositories are direct children of the configured Git scratch // root. Reuse that parent for publication tempfiles so they remain on the @@ -1207,56 +1341,84 @@ async fn cas_publish_inner( } }; let manifest_digest = digest_from_manifest_key(&manifest_key)?; + let result_digest = parse_publication_digest(&manifest_digest)?; + if let Some(observation) = &compaction_observation { + record_compaction( + "staged", + observation.started_at, + observation.packs_before, + Some(observation.packs_after), + Some(observation.compacted_bytes), + ); + } + Ok(StagedGitPublication { + community_id: ctx.community(), + owner: owner.to_owned(), + repo: canonical_repo.to_owned(), + expected_parent_result_digest, + manifest: m_after, + manifest_key, + result_digest, + }) +} - // Step 7: CAS the pointer. - let precond = match &parent_state.if_match { - Some(e) => Precond::IfMatch(e.clone()), +/// Refresh the legacy raw pointer as a disposable cache after DB publication. +/// +/// A stale or missing cache never confers authority. Callers must already have +/// committed `staged.result_digest()` as the canonical PostgreSQL witness and +/// must tolerate [`PointerCacheUpdate::Stale`]. +#[allow(dead_code)] // Activated only after canonical DB publication succeeds. +pub(crate) async fn publish_pointer_cache( + store: &GitStore, + staged: &StagedGitPublication, +) -> Result { + let pkey = pointer_key(staged.community_id(), staged.owner(), staged.repo()); + let manifest_digest = staged + .manifest_key + .strip_prefix("manifests/") + .ok_or_else(|| CasError::ManifestReadFailed("staged manifest key is invalid".into()))?; + let precond = match store.get_pointer(&pkey).await? { + Some((_etag, current)) if current.as_ref() == manifest_digest.as_bytes() => { + return Ok(PointerCacheUpdate::Updated); + } + Some((etag, _)) => Precond::IfMatch(etag), None => Precond::IfNoneMatchStar, }; - let cas_outcome = match store + match store .put_pointer(&pkey, manifest_digest.as_bytes(), precond) - .await + .await? { - Ok(outcome) => outcome, - Err(error) => { - if let Some(observation) = &compaction_observation { - record_compaction( - "publish_error", - observation.started_at, - observation.packs_before, - Some(observation.packs_after), - Some(observation.compacted_bytes), - ); - } - return Err(error.into()); - } + CasOutcome::Won(_) => Ok(PointerCacheUpdate::Updated), + CasOutcome::LostRace => Ok(PointerCacheUpdate::Stale), + } +} + +async fn publish_pointer_cache_strict( + store: &GitStore, + ctx: &TenantContext, + owner: &str, + repo: &str, + parent_state: &ParentState, + staged: StagedGitPublication, +) -> Result { + let pkey = pointer_key(ctx.community(), owner, repo); + let precond = match &parent_state.if_match { + Some(etag) => Precond::IfMatch(etag.clone()), + None => Precond::IfNoneMatchStar, }; - match cas_outcome { - CasOutcome::Won(_new_etag) => { - if let Some(observation) = &compaction_observation { - record_compaction( - "success", - observation.started_at, - observation.packs_before, - Some(observation.packs_after), - Some(observation.compacted_bytes), - ); - } - Ok(CasSuccess { - manifest: m_after, - manifest_key, - }) - } + let manifest_digest = staged + .manifest_key + .strip_prefix("manifests/") + .ok_or_else(|| CasError::ManifestReadFailed("staged manifest key is invalid".into()))?; + match store + .put_pointer(&pkey, manifest_digest.as_bytes(), precond) + .await? + { + CasOutcome::Won(_) => Ok(CasSuccess { + manifest: staged.manifest, + manifest_key: staged.manifest_key, + }), CasOutcome::LostRace => { - if let Some(observation) = &compaction_observation { - record_compaction( - "cas_conflict", - observation.started_at, - observation.packs_before, - Some(observation.packs_after), - Some(observation.compacted_bytes), - ); - } // Surface a typed Conflict carrying the winner so the caller // can reconcile the on-disk workspace without re-reading the // pointer. We re-GET the pointer here on the slow path; a @@ -1271,7 +1433,7 @@ async fn cas_publish_inner( warn!( pointer = %pkey, expected_etag = %expected, - attempted_manifest = %manifest_key, + attempted_manifest = %staged.manifest_key, "CAS lost race; resolving winner for reconcile" ); let (winner_manifest, winner_manifest_key) = @@ -1284,6 +1446,25 @@ async fn cas_publish_inner( } } +fn parse_publication_digest(encoded: &str) -> Result<[u8; 32], CasError> { + let bytes = hex::decode(encoded) + .map_err(|_| CasError::ManifestReadFailed("manifest digest is not hexadecimal".into()))?; + let digest: [u8; 32] = bytes + .try_into() + .map_err(|_| CasError::ManifestReadFailed("manifest digest has wrong length".into()))?; + if hex::encode(digest) != encoded { + return Err(CasError::ManifestReadFailed( + "manifest digest is not canonical lowercase hexadecimal".into(), + )); + } + if digest == [0; 32] { + return Err(CasError::ManifestReadFailed( + "manifest digest is unallocated".into(), + )); + } + Ok(digest) +} + /// Re-read the pointer after a `LostRace` and fetch the winner's manifest. /// /// Fail-closed at every step: if the pointer is now absent (a deletion @@ -1331,6 +1512,7 @@ async fn read_winner_after_conflict( #[cfg(test)] mod tests { use super::*; + use sha2::Digest as _; // `pointer_key` is owned by `manifest.rs` and unit-tested there // (one source of truth — Max/Sami's centralization point). @@ -1391,6 +1573,53 @@ mod tests { assert!(digest_from_manifest_key("not/manifests/abc").is_err()); } + #[test] + fn staged_publication_seals_exact_manifest_digest_without_pointer_state() { + let manifest = Manifest { + version: MANIFEST_VERSION, + head: "refs/heads/main".into(), + refs: BTreeMap::from([("refs/heads/main".into(), "1".repeat(40))]), + packs: vec![pack_key('a')], + parent: None, + }; + let canonical = manifest.canonical_bytes().expect("canonical manifest"); + let digest: [u8; 32] = sha2::Sha256::digest(canonical).into(); + let tenant = tenant(); + let staged = StagedGitPublication { + community_id: tenant.community(), + owner: "a".repeat(64), + repo: "project".to_owned(), + expected_parent_result_digest: None, + manifest: manifest.clone(), + manifest_key: format!("manifests/{}", hex::encode(digest)), + result_digest: digest, + }; + + assert_eq!(staged.community_id(), tenant.community()); + assert_eq!(staged.owner(), "a".repeat(64)); + assert_eq!(staged.repo(), "project"); + assert_eq!(staged.expected_parent_result_digest(), None); + assert_eq!(staged.manifest(), &manifest); + assert_eq!(staged.result_digest(), digest); + assert_eq!(format!("{staged:?}"), "StagedGitPublication([REDACTED])"); + assert_eq!( + staged.manifest_key(), + format!("manifests/{}", hex::encode(digest)) + ); + } + + #[test] + fn publication_digest_rejects_noncanonical_and_unallocated_values() { + assert!(parse_publication_digest("not-hex").is_err()); + assert!(parse_publication_digest(&"01".repeat(31)).is_err()); + assert!(parse_publication_digest(&"00".repeat(32)).is_err()); + assert!(parse_publication_digest(&"AB".repeat(32)).is_err()); + assert_eq!( + parse_publication_digest(&"ab".repeat(32)).expect("digest"), + [0xab; 32] + ); + } + #[test] fn compose_after_first_push() { let parent = ParentState::fresh().parent; diff --git a/crates/buzz-relay/src/api/git/hydrate.rs b/crates/buzz-relay/src/api/git/hydrate.rs index 3ce809d18f..19ea9b508e 100644 --- a/crates/buzz-relay/src/api/git/hydrate.rs +++ b/crates/buzz-relay/src/api/git/hydrate.rs @@ -30,6 +30,7 @@ use std::path::{Path, PathBuf}; +use sha2::{Digest, Sha256}; use tempfile::TempDir; use tokio::process::Command; @@ -178,6 +179,48 @@ pub async fn load_manifest_for_read( .map(|(_etag, _digest, manifest)| manifest)) } +/// Load the exact immutable manifest named by a PostgreSQL publication witness. +/// +/// This path never reads the raw repository pointer. The object-store key, +/// stored bytes, canonical re-encoding, and DB result digest must all agree. +pub(crate) async fn load_authoritative_manifest( + store: &GitStore, + result_digest: [u8; 32], +) -> Result { + if result_digest == [0; 32] { + return Err(HydrateError::InvalidPointer); + } + let digest = hex::encode(result_digest); + let key = format!("manifests/{digest}"); + let bytes = get_verified_limited(store, &key, &digest, MAX_MANIFEST_BYTES).await?; + decode_authoritative_manifest(&bytes, result_digest) +} + +fn decode_authoritative_manifest( + bytes: &[u8], + result_digest: [u8; 32], +) -> Result { + let manifest = Manifest::from_bytes(bytes)?; + manifest.validate()?; + let canonical = manifest.canonical_bytes()?; + let canonical_digest: [u8; 32] = Sha256::digest(canonical).into(); + if canonical_digest != result_digest { + return Err(HydrateError::InvalidPointer); + } + Ok(manifest) +} + +/// Hydrate a read workspace only from the DB-witnessed immutable manifest. +#[allow(dead_code)] // Activated only after S5 freezes the joined read witness. +pub(crate) async fn hydrate_authoritative_read( + store: &GitStore, + result_digest: [u8; 32], + options: HydrationOptions<'_>, +) -> Result { + let manifest = load_authoritative_manifest(store, result_digest).await?; + materialize_manifest(store, &manifest, options).await +} + async fn init_bare_repo(path: &Path) -> Result<(), HydrateError> { run_git(path, &["init", "--bare", "--quiet"]).await?; run_git(path, &["symbolic-ref", "HEAD", "refs/heads/main"]).await @@ -239,6 +282,45 @@ pub async fn hydrate_for_write( } } +/// Hydrate a write workspace from the DB-witnessed parent publication. +/// +/// `None` is the authoritative first-publication state. A present digest is +/// fetched and verified directly; the raw pointer is neither read nor trusted. +#[allow(dead_code)] // Activated only after S5 freezes expected-parent publication. +pub(crate) async fn hydrate_authoritative_write( + store: &GitStore, + parent_result_digest: Option<[u8; 32]>, + options: HydrationOptions<'_>, +) -> Result<(HydratedRepo, ParentState), HydrateError> { + let Some(result_digest) = parent_result_digest else { + let tempdir = TempDir::new_in(options.scratch_dir).map_err(|error| { + HydrateError::Hydrate(format!("tempdir in {:?}: {error}", options.scratch_dir)) + })?; + let path = tempdir.path().to_path_buf(); + init_bare_repo(&path).await?; + return Ok(( + HydratedRepo { + _tempdir: tempdir, + path, + hydrated_bytes: 0, + hydrated_packs: 0, + }, + ParentState::fresh(), + )); + }; + + let manifest = load_authoritative_manifest(store, result_digest).await?; + let repo = materialize_manifest(store, &manifest, options).await?; + Ok(( + repo, + ParentState { + if_match: None, + parent_digest: Some(hex::encode(result_digest)), + parent: manifest, + }, + )) +} + /// Resolve the pointer to its `(ETag, digest, verified Manifest)` triple. /// /// `Ok(None)` if the pointer is absent (caller decides 404 vs first-push @@ -505,6 +587,34 @@ mod tests { assert!(!is_hex_oid("")); } + #[test] + fn authoritative_manifest_requires_exact_canonical_witness_bytes() { + let manifest = Manifest { + version: 1, + head: "refs/heads/main".into(), + refs: BTreeMap::from([("refs/heads/main".into(), "1".repeat(40))]), + packs: Vec::new(), + parent: None, + }; + let canonical = manifest.canonical_bytes().expect("canonical manifest"); + let canonical_digest: [u8; 32] = Sha256::digest(&canonical).into(); + assert_eq!( + decode_authoritative_manifest(&canonical, canonical_digest).expect("witness"), + manifest + ); + + let noncanonical = serde_json::to_vec_pretty(&manifest).expect("pretty manifest"); + let noncanonical_digest: [u8; 32] = Sha256::digest(&noncanonical).into(); + assert!(matches!( + decode_authoritative_manifest(&noncanonical, noncanonical_digest), + Err(HydrateError::InvalidPointer) + )); + assert!(matches!( + decode_authoritative_manifest(&canonical, [0x55; 32]), + Err(HydrateError::InvalidPointer) + )); + } + #[tokio::test] async fn fresh_bare_repo_defaults_head_to_main() { let scratch = TempDir::new().expect("scratch"); diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index a89df58f71..38abc345ca 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -25,6 +25,7 @@ use base64::Engine; use hex; use serde::Deserialize; use tokio::process::Command; +use tokio_util::sync::CancellationToken; use tower_http::limit::RequestBodyLimitLayer; use tracing::{error, info, warn}; @@ -32,8 +33,8 @@ use super::binding::{resolve_repo_binding, RepoBinding}; use super::cas_publish::{cas_publish, CasError, ParentState, PublishLimits}; use super::hook::install_hook; use super::hydrate::{ - hydrate_for_read, hydrate_for_write, load_manifest_for_read, HydrateError, HydratedRepo, - HydrationOptions, + hydrate_authoritative_read, hydrate_for_read, hydrate_for_write, load_authoritative_manifest, + load_manifest_for_read, HydrateError, HydratedRepo, HydrationOptions, }; use super::manifest_event::{build_ref_state_event, RefStateInputs}; use crate::state::AppState; @@ -79,6 +80,81 @@ pub struct GitAuth { identity_proof: crate::corporate_identity::CorporateIdentityProof, } +/// Temporary adapter for a sealed joined publication witness. +struct ProvisionalGitPublicationWitness { + tenant: TenantContext, + owner: String, + repo: String, + result_digest: [u8; 32], + cancellation: CancellationToken, +} + +#[allow(dead_code)] +impl ProvisionalGitPublicationWitness { + fn from_joined_witness_parts( + tenant: TenantContext, + owner: impl Into, + repo: impl Into, + result_digest: [u8; 32], + cancellation: CancellationToken, + ) -> Option { + let owner = owner.into(); + let repo = repo.into(); + let canonical_repo = validate_repo_id(&owner, &repo).ok()?.to_owned(); + if result_digest == [0; 32] { + return None; + } + Some(Self { + tenant, + owner, + repo: canonical_repo, + result_digest, + cancellation, + }) + } +} + +impl std::fmt::Debug for ProvisionalGitPublicationWitness { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("ProvisionalGitPublicationWitness([REDACTED])") + } +} + +fn git_witness_invalid_response() -> Response { + (StatusCode::SERVICE_UNAVAILABLE, "repository unavailable").into_response() +} + +async fn final_git_witness_recheck( + cancellation: &CancellationToken, + final_recheck: F, +) -> Result<(), Response> +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + if cancellation.is_cancelled() { + return Err(git_witness_invalid_response()); + } + final_recheck() + .await + .map_err(|_| git_witness_invalid_response())?; + if cancellation.is_cancelled() { + return Err(git_witness_invalid_response()); + } + Ok(()) +} + +fn exact_git_nostr_token(headers: &axum::http::HeaderMap) -> Result { + let header = buzz_auth::ExactSingleHttpHeader::from_headers(headers, &header::AUTHORIZATION) + .map_err(|_| "missing or ambiguous Authorization header")?; + header + .as_str() + .strip_prefix("Nostr ") + .filter(|token| !token.is_empty()) + .map(str::to_owned) + .ok_or("expected Authorization: Nostr ") +} + impl axum::extract::FromRequestParts> for GitAuth { type Rejection = Response; @@ -88,35 +164,20 @@ impl axum::extract::FromRequestParts> for GitAuth { ) -> Result { let method = parts.method.as_str(); - let auth_header = parts - .headers - .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| { - Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header( - "WWW-Authenticate", - format!("Nostr realm=\"buzz\", method=\"{method}\""), - ) - .body(Body::from("missing Authorization header")) - .unwrap() - })?; - - let token = auth_header.strip_prefix("Nostr ").ok_or_else(|| { + let token = exact_git_nostr_token(&parts.headers).map_err(|message| { Response::builder() .status(StatusCode::UNAUTHORIZED) .header( "WWW-Authenticate", format!("Nostr realm=\"buzz\", method=\"{method}\""), ) - .body(Body::from("expected Authorization: Nostr ")) + .body(Body::from(message)) .unwrap() })?; let event_bytes = base64::engine::general_purpose::STANDARD - .decode(token) - .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(token)) + .decode(&token) + .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(&token)) .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid base64").into_response())?; let event_json = String::from_utf8(event_bytes) .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid utf-8").into_response())?; @@ -768,6 +829,45 @@ fn build_upload_pack_advertisement(manifest: &super::manifest::Manifest) -> Vec< out } +/// Build a fast ref advertisement only from the exact DB-witnessed manifest. +/// +/// The immutable manifest is fully digest-verified before `final_recheck` and +/// no response is constructed until that recheck succeeds. An ineligible +/// tagged manifest returns `Ok(None)` for the witnessed subprocess fallback. +#[allow(dead_code)] // Activated by the pending high-level S5 read witness. +async fn witnessed_fast_upload_pack_advertisement( + state: &AppState, + witness: &ProvisionalGitPublicationWitness, + final_recheck: F, +) -> Result, Response> +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + if witness.tenant.community().as_uuid().is_nil() { + return Err(git_witness_invalid_response()); + } + let manifest = load_authoritative_manifest(&state.git_store, witness.result_digest) + .await + .map_err(|error| hydrate_error_to_response(&witness.owner, &witness.repo, error))?; + if !fast_path_eligible(&manifest) { + return Ok(None); + } + let body = build_upload_pack_advertisement(&manifest); + final_git_witness_recheck(&witness.cancellation, final_recheck).await?; + Ok(Some( + Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/x-git-upload-pack-advertisement", + ) + .header(header::CACHE_CONTROL, "no-cache") + .body(Body::from(body)) + .expect("static witnessed git advertisement response"), + )) +} + /// `GET /git/{owner}/{repo}/info/refs?service={service}` /// /// Advertises refs for clone (git-upload-pack) or push (git-receive-pack). @@ -1586,6 +1686,57 @@ struct GitPermitStream { _permit: tokio::sync::OwnedSemaphorePermit, } +struct CancellationCheckedGitStream { + inner: std::pin::Pin>, + cancellation: CancellationToken, + finished: bool, +} + +impl CancellationCheckedGitStream { + fn new(inner: S, cancellation: CancellationToken) -> Self { + Self { + inner: Box::pin(inner), + cancellation, + finished: false, + } + } +} + +impl futures_util::Stream for CancellationCheckedGitStream +where + S: futures_util::Stream>, +{ + type Item = Result; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + context: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + if self.finished { + return std::task::Poll::Ready(None); + } + if self.cancellation.is_cancelled() { + self.finished = true; + return std::task::Poll::Ready(Some(Err(std::io::Error::other( + "protected git witness is no longer current", + )))); + } + match self.inner.as_mut().poll_next(context) { + std::task::Poll::Ready(Some(Ok(_bytes))) if self.cancellation.is_cancelled() => { + self.finished = true; + std::task::Poll::Ready(Some(Err(std::io::Error::other( + "protected git witness is no longer current", + )))) + } + std::task::Poll::Ready(None) => { + self.finished = true; + std::task::Poll::Ready(None) + } + other => other, + } + } +} + impl futures_util::Stream for GitPermitStream where S: futures_util::Stream, @@ -1711,6 +1862,86 @@ fn stream_git_read( prefix: Vec, content_type: String, ) -> Result { + spawn_git_read_stream(GitReadStreamInput { + repo, + permit, + service, + extra_args, + body, + prefix, + content_type, + cancellation: None, + }) +} + +/// Stream clone/fetch bytes only from the exact DB-witnessed manifest. +/// +/// Hydration verifies every immutable manifest/pack digest. The authoritative +/// callback runs afterward, immediately before subprocess/response creation; +/// its sticky cancellation signal is then checked before every emitted chunk. +#[allow(dead_code)] // Activated by the pending high-level S5 read witness. +async fn stream_witnessed_upload_pack( + state: &Arc, + witness: ProvisionalGitPublicationWitness, + permit: tokio::sync::OwnedSemaphorePermit, + body: Body, + final_recheck: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + if witness.tenant.community().as_uuid().is_nil() { + return Err(git_witness_invalid_response()); + } + let repo = hydrate_authoritative_read( + &state.git_store, + witness.result_digest, + HydrationOptions { + pack_cache: &state.git_pack_cache, + scratch_dir: &state.config.git_repo_path, + max_pack_bytes: state.config.git_max_pack_bytes, + max_repo_bytes: state.config.git_max_repo_bytes, + }, + ) + .await + .map_err(|error| hydrate_error_to_response(&witness.owner, &witness.repo, error))?; + final_git_witness_recheck(&witness.cancellation, final_recheck).await?; + spawn_git_read_stream(GitReadStreamInput { + repo, + permit, + service: "upload-pack", + extra_args: &[], + body, + prefix: Vec::new(), + content_type: "application/x-git-upload-pack-result".to_owned(), + cancellation: Some(witness.cancellation), + }) +} + +struct GitReadStreamInput<'a> { + repo: HydratedRepo, + permit: tokio::sync::OwnedSemaphorePermit, + service: &'static str, + extra_args: &'a [&'a str], + body: Body, + prefix: Vec, + content_type: String, + cancellation: Option, +} + +#[allow(clippy::result_large_err)] +fn spawn_git_read_stream(input: GitReadStreamInput<'_>) -> Result { + let GitReadStreamInput { + repo, + permit, + service, + extra_args, + body, + prefix, + content_type, + cancellation, + } = input; let mut cmd = Command::new("git"); cmd.arg(service).arg("--stateless-rpc"); for a in extra_args { @@ -1776,11 +2007,17 @@ fn stream_git_read( _permit: permit, }; + let body = match cancellation { + Some(cancellation) => { + Body::from_stream(CancellationCheckedGitStream::new(body_stream, cancellation)) + } + None => Body::from_stream(body_stream), + }; Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, content_type) .header(header::CACHE_CONTROL, "no-cache") - .body(Body::from_stream(body_stream)) + .body(body) .unwrap()) } @@ -2580,6 +2817,109 @@ mod track_c_tests { assert_eq!(pubkey, keys.public_key()); } + #[test] + fn git_authorization_requires_one_unambiguous_header_occurrence() { + use axum::http::HeaderMap; + + let mut headers = HeaderMap::new(); + headers.append( + header::AUTHORIZATION, + "Nostr first".parse().expect("header"), + ); + headers.append( + header::AUTHORIZATION, + "Nostr second".parse().expect("header"), + ); + assert!(exact_git_nostr_token(&headers).is_err()); + + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + "Nostr first, Nostr second".parse().expect("header"), + ); + assert!(exact_git_nostr_token(&headers).is_err()); + + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + "Nostr exact".parse().expect("header"), + ); + assert_eq!( + exact_git_nostr_token(&headers).expect("single exact header"), + "exact" + ); + } + + #[test] + fn provisional_git_witness_keeps_domain_target_and_digest_joined() { + let tenant = TenantContext::resolved( + CommunityId::from_uuid(uuid::Uuid::from_u128(9)), + "git.example", + ); + let owner = "a".repeat(64); + let witness = ProvisionalGitPublicationWitness::from_joined_witness_parts( + tenant.clone(), + &owner, + "project.git", + [0x55; 32], + CancellationToken::new(), + ) + .expect("joined witness"); + assert_eq!(witness.tenant, tenant); + assert_eq!(witness.owner, owner); + assert_eq!(witness.repo, "project"); + assert_eq!(witness.result_digest, [0x55; 32]); + assert_eq!( + format!("{witness:?}"), + "ProvisionalGitPublicationWitness([REDACTED])" + ); + + assert!(ProvisionalGitPublicationWitness::from_joined_witness_parts( + witness.tenant.clone(), + "a".repeat(64), + "project", + [0; 32], + CancellationToken::new(), + ) + .is_none()); + } + + #[tokio::test] + async fn witnessed_git_stream_stops_before_next_chunk_after_cancellation() { + use futures_util::StreamExt; + + let source = futures_util::stream::iter([ + Ok::<_, std::io::Error>(bytes::Bytes::from_static(b"first")), + Ok(bytes::Bytes::from_static(b"second")), + ]); + let cancellation = CancellationToken::new(); + let mut stream = CancellationCheckedGitStream::new(source, cancellation.clone()); + assert_eq!( + stream.next().await.expect("first chunk").expect("bytes"), + bytes::Bytes::from_static(b"first") + ); + cancellation.cancel(); + assert!(stream.next().await.expect("closed chunk").is_err()); + assert!(stream.next().await.is_none()); + } + + #[tokio::test] + async fn final_git_recheck_fails_closed_before_invoking_stale_callback() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let cancellation = CancellationToken::new(); + cancellation.cancel(); + let calls = Arc::new(AtomicUsize::new(0)); + let observed = calls.clone(); + let result = final_git_witness_recheck(&cancellation, move || async move { + observed.fetch_add(1, Ordering::SeqCst); + Ok::<_, ()>(()) + }) + .await; + assert!(result.is_err()); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } + /// Split a pkt-line stream into `(len_prefix, payload)` frames, validating /// that each 4-hex length counts itself and that `0000` is a flush. fn parse_pkt_lines(bytes: &[u8]) -> Vec> { diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index efd5a550d9..ec2b2f82fa 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -651,6 +651,29 @@ mod tests { > { Box::pin(async { Ok(true) }) } + + fn try_mark_all_in_scope<'a>( + &'a self, + _scope: &'a str, + event_ids: &'a [nostr::EventId], + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(async move { + if !(1..=3).contains(&event_ids.len()) + || event_ids + .iter() + .enumerate() + .any(|(index, event_id)| event_ids[..index].contains(event_id)) + { + return Err(buzz_auth::AuthError::Internal( + "invalid atomic NIP-98 proof set".to_owned(), + )); + } + Ok(true) + }) + } } const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 @@ -1650,6 +1673,38 @@ mod tests { let inserted = self.seen.lock().expect("replay set").insert(bytes); Box::pin(async move { Ok(inserted) }) } + + fn try_mark_all_in_scope<'a>( + &'a self, + _scope: &'a str, + event_ids: &'a [EventId], + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + if !(1..=3).contains(&event_ids.len()) + || event_ids + .iter() + .enumerate() + .any(|(index, event_id)| event_ids[..index].contains(event_id)) + { + return Box::pin(async { + Err(buzz_auth::AuthError::Internal( + "invalid atomic NIP-98 proof set".to_owned(), + )) + }); + } + let ids = event_ids + .iter() + .map(|event_id| *event_id.as_bytes()) + .collect::>(); + let mut seen = self.seen.lock().expect("replay set"); + if ids.iter().any(|event_id| seen.contains(event_id)) { + return Box::pin(async { Ok(false) }); + } + seen.extend(ids); + Box::pin(async { Ok(true) }) + } } /// Endpoint-level proof that a replayed NIP-98 auth event on a claim POST diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index dc9ee9bcfe..8cc8768751 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -6,7 +6,10 @@ //! GET /media/{sha256_ext} — BUD-01 serve blob //! HEAD /media/{sha256_ext} — BUD-01 existence check +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; +use std::task::{Context, Poll}; use std::time::{Duration, Instant}; use axum::http::header; @@ -20,6 +23,7 @@ use base64::Engine; use buzz_audit::{AuditAction, NewAuditEntry}; use buzz_core::tenant::TenantContext; use buzz_media::{BlobDescriptor, MediaError, UploadAttribution, UploadNetworkInfo}; +use tokio_util::sync::CancellationToken; use crate::state::AppState; @@ -804,6 +808,232 @@ pub(crate) async fn serve_blob_for_tenant( } } +/// Serve only bytes named by a sealed PostgreSQL publication witness. +/// +/// The immutable manifest and complete object are fetched and hash-verified +/// first. `final_recheck` is then consumed immediately before response headers +/// or bytes are constructed. The callback is the provisional boundary for +/// S5's typed joined-witness recheck; an error fails closed without falling +/// back to a sidecar or raw object-store pointer. +struct ProvisionalMediaPublicationWitness { + tenant: TenantContext, + result_digest: [u8; 32], + requested_path: String, + cancellation: CancellationToken, +} + +#[allow(dead_code)] // Constructed only by the pending S5 joined-witness adapter. +impl ProvisionalMediaPublicationWitness { + fn from_joined_witness_parts( + tenant: TenantContext, + result_digest: [u8; 32], + requested_path: impl Into, + cancellation: CancellationToken, + ) -> Result { + let requested_path = requested_path.into(); + if result_digest == [0; 32] { + return Err(media_witness_invalid()); + } + validate_media_path(&requested_path)?; + Ok(Self { + tenant, + result_digest, + requested_path, + cancellation, + }) + } +} + +#[allow(dead_code)] // Activated by the pending high-level S5 publication witness. +async fn serve_witnessed_blob_for_tenant( + state: &AppState, + witness: ProvisionalMediaPublicationWitness, + req_headers: &HeaderMap, + head_only: bool, + final_recheck: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + let object = state + .media_storage + .verify_media_object( + &witness.tenant, + witness.result_digest, + &witness.requested_path, + ) + .await?; + + let content_type = object.content_type().to_owned(); + let total = object.size(); + let cache_control = blob_cache_control(); + let disposition = if buzz_media::serve_inline(&content_type) { + "inline" + } else { + "attachment" + }; + + if head_only { + final_media_witness_recheck(&witness.cancellation, final_recheck).await?; + return axum::response::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::CONTENT_LENGTH, total.to_string()) + .header(header::CONTENT_DISPOSITION, disposition) + .header(header::CACHE_CONTROL, cache_control) + .header(header::CONTENT_SECURITY_POLICY, "default-src 'none'") + .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") + .header(header::ACCEPT_RANGES, "bytes") + .body(axum::body::Body::empty()) + .map_err(|_| MediaError::Internal); + } + + let single_range = req_headers + .get(header::RANGE) + .and_then(|value| value.to_str().ok()) + .filter(|value| !value.contains(',')); + let Some(range) = single_range else { + final_media_witness_recheck(&witness.cancellation, final_recheck).await?; + let stream = object.into_stream()?; + let stream = CancellationCheckedMediaStream::new(stream, witness.cancellation); + return axum::response::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::CONTENT_LENGTH, total.to_string()) + .header(header::CONTENT_DISPOSITION, disposition) + .header(header::CACHE_CONTROL, cache_control) + .header(header::CONTENT_SECURITY_POLICY, "default-src 'none'") + .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") + .header(header::ACCEPT_RANGES, "bytes") + .body(axum::body::Body::from_stream(stream)) + .map_err(|_| MediaError::Internal); + }; + + let Some((start, end)) = parse_byte_range(range, total) else { + return witnessed_range_not_satisfiable_response( + range, + total, + &witness.cancellation, + final_recheck, + ) + .await; + }; + if start >= total { + return witnessed_range_not_satisfiable_response( + range, + total, + &witness.cancellation, + final_recheck, + ) + .await; + } + let end = end + .min(start.saturating_add(MAX_RANGE_CHUNK - 1)) + .min(total.saturating_sub(1)); + let bytes = object.read_range(start, end).await?; + final_media_witness_recheck(&witness.cancellation, final_recheck).await?; + axum::response::Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(header::CONTENT_TYPE, content_type) + .header( + header::CONTENT_RANGE, + format!("bytes {start}-{end}/{total}"), + ) + .header(header::CONTENT_LENGTH, bytes.len().to_string()) + .header(header::CONTENT_DISPOSITION, disposition) + .header(header::CACHE_CONTROL, cache_control) + .header(header::CONTENT_SECURITY_POLICY, "default-src 'none'") + .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") + .header(header::ACCEPT_RANGES, "bytes") + .body(axum::body::Body::from(bytes)) + .map_err(|_| MediaError::Internal) +} + +async fn witnessed_range_not_satisfiable_response( + range: &str, + total: u64, + cancellation: &CancellationToken, + final_recheck: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + if parse_byte_range(range, total).is_some_and(|(start, _)| start < total) { + return Err(MediaError::Internal); + } + final_media_witness_recheck(cancellation, final_recheck).await?; + axum::response::Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header(header::CONTENT_RANGE, format!("bytes */{total}")) + .body(axum::body::Body::empty()) + .map_err(|_| MediaError::Internal) +} + +async fn final_media_witness_recheck( + cancellation: &CancellationToken, + final_recheck: F, +) -> Result<(), MediaError> +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + if cancellation.is_cancelled() { + return Err(media_witness_invalid()); + } + final_recheck().await.map_err(|_| media_witness_invalid())?; + if cancellation.is_cancelled() { + return Err(media_witness_invalid()); + } + Ok(()) +} + +fn media_witness_invalid() -> MediaError { + MediaError::StorageError("protected media witness is no longer current".to_owned()) +} + +struct CancellationCheckedMediaStream { + inner: buzz_media::ByteStream, + cancellation: CancellationToken, + finished: bool, +} + +impl CancellationCheckedMediaStream { + fn new(inner: buzz_media::ByteStream, cancellation: CancellationToken) -> Self { + Self { + inner, + cancellation, + finished: false, + } + } +} + +impl futures_util::Stream for CancellationCheckedMediaStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + if self.finished { + return Poll::Ready(None); + } + if self.cancellation.is_cancelled() { + self.finished = true; + return Poll::Ready(Some(Err(media_witness_invalid()))); + } + match self.inner.as_mut().poll_next(context) { + Poll::Ready(Some(Ok(_bytes))) if self.cancellation.is_cancelled() => { + self.finished = true; + Poll::Ready(Some(Err(media_witness_invalid()))) + } + Poll::Ready(None) => { + self.finished = true; + Poll::Ready(None) + } + other => other, + } + } +} + /// Parse a `Range: bytes=START-END` header value. /// /// Returns `Some((start, end))` for a valid absolute or suffix range. @@ -939,12 +1169,11 @@ async fn resolve_s3_key( fn extract_blossom_auth(headers: &HeaderMap) -> Result { use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; - let header = headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .ok_or(MediaError::MissingAuth)?; + let header = buzz_auth::ExactSingleHttpHeader::from_headers(headers, &header::AUTHORIZATION) + .map_err(|_| MediaError::MissingAuth)?; let token = header + .as_str() .strip_prefix("Nostr ") .ok_or(MediaError::InvalidAuthScheme)?; @@ -1096,6 +1325,34 @@ mod tests { tags } + #[test] + fn blossom_authorization_requires_one_unambiguous_header_occurrence() { + let keys = Keys::generate(); + let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None)); + + let mut duplicated = HeaderMap::new(); + duplicated.append(header::AUTHORIZATION, auth.parse().expect("header")); + duplicated.append(header::AUTHORIZATION, auth.parse().expect("header")); + assert!(matches!( + extract_blossom_auth(&duplicated), + Err(MediaError::MissingAuth) + )); + + let mut comma_joined = HeaderMap::new(); + comma_joined.insert( + header::AUTHORIZATION, + format!("{auth}, {auth}").parse().expect("header"), + ); + assert!(matches!( + extract_blossom_auth(&comma_joined), + Err(MediaError::MissingAuth) + )); + + let mut exact = HeaderMap::new(); + exact.insert(header::AUTHORIZATION, auth.parse().expect("header")); + assert!(extract_blossom_auth(&exact).is_ok()); + } + fn media_request(method: &str, auth: Option) -> Request { let mut builder = Request::builder() .method(method) @@ -1384,6 +1641,132 @@ mod tests { ); } + #[tokio::test] + async fn witnessed_media_stream_stops_before_next_chunk_after_cancellation() { + use futures_util::StreamExt; + + let source: buzz_media::ByteStream = Box::pin(futures_util::stream::iter([ + Ok(bytes::Bytes::from_static(b"first")), + Ok(bytes::Bytes::from_static(b"second")), + ])); + let cancellation = CancellationToken::new(); + let mut stream = CancellationCheckedMediaStream::new(source, cancellation.clone()); + + assert_eq!( + stream.next().await.expect("first chunk").expect("bytes"), + bytes::Bytes::from_static(b"first") + ); + cancellation.cancel(); + assert!(stream.next().await.expect("closed chunk").is_err()); + assert!(stream.next().await.is_none()); + } + + #[test] + fn provisional_media_witness_keeps_domain_target_and_digest_joined() { + let tenant = TenantContext::resolved( + buzz_core::CommunityId::from_uuid(Uuid::from_u128(7)), + "media.example", + ); + let witness = ProvisionalMediaPublicationWitness::from_joined_witness_parts( + tenant.clone(), + [0x44; 32], + VALID_HASH, + CancellationToken::new(), + ) + .expect("joined witness"); + assert_eq!(witness.tenant, tenant); + assert_eq!(witness.result_digest, [0x44; 32]); + assert_eq!(witness.requested_path, VALID_HASH); + + assert!( + ProvisionalMediaPublicationWitness::from_joined_witness_parts( + tenant.clone(), + [0; 32], + VALID_HASH, + CancellationToken::new(), + ) + .is_err() + ); + assert!( + ProvisionalMediaPublicationWitness::from_joined_witness_parts( + tenant, + [0x44; 32], + "../other", + CancellationToken::new(), + ) + .is_err() + ); + } + + #[tokio::test] + async fn final_media_recheck_fails_closed_before_invoking_stale_callback() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let cancellation = CancellationToken::new(); + cancellation.cancel(); + let calls = Arc::new(AtomicUsize::new(0)); + let observed = calls.clone(); + let result = final_media_witness_recheck(&cancellation, move || async move { + observed.fetch_add(1, Ordering::SeqCst); + Ok::<_, ()>(()) + }) + .await; + assert!(result.is_err()); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } + + async fn assert_invalid_witnessed_range_rechecks_before_size_response(range: &str) { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let success_calls = Arc::new(AtomicUsize::new(0)); + let observed_success = success_calls.clone(); + let response = witnessed_range_not_satisfiable_response( + range, + 10, + &CancellationToken::new(), + move || async move { + observed_success.fetch_add(1, Ordering::SeqCst); + Ok::<_, ()>(()) + }, + ) + .await + .expect("current witness may disclose the size response"); + assert_eq!(success_calls.load(Ordering::SeqCst), 1); + assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!( + response + .headers() + .get(header::CONTENT_RANGE) + .and_then(|value| value.to_str().ok()), + Some("bytes */10") + ); + + let stale_calls = Arc::new(AtomicUsize::new(0)); + let observed_stale = stale_calls.clone(); + let result = witnessed_range_not_satisfiable_response( + range, + 10, + &CancellationToken::new(), + move || async move { + observed_stale.fetch_add(1, Ordering::SeqCst); + Err::<(), _>("stale") + }, + ) + .await; + assert!(result.is_err(), "stale authority must suppress the 416"); + assert_eq!(stale_calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn malformed_witnessed_range_rechecks_before_disclosing_size() { + assert_invalid_witnessed_range_rechecks_before_size_response("bytes=malformed").await; + } + + #[tokio::test] + async fn unsatisfiable_witnessed_range_rechecks_before_disclosing_size() { + assert_invalid_witnessed_range_rechecks_before_size_response("bytes=10-").await; + } + #[test] fn test_parse_byte_range_basic() { assert_eq!(parse_byte_range("bytes=0-499", 1000), Some((0, 499))); diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 5b69a43874..b42cab4d4d 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -531,6 +531,29 @@ mod tests { > { Box::pin(async { Ok(true) }) } + + fn try_mark_all_in_scope<'a>( + &'a self, + _scope: &'a str, + event_ids: &'a [nostr::EventId], + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(async move { + if !(1..=3).contains(&event_ids.len()) + || event_ids + .iter() + .enumerate() + .any(|(index, event_id)| event_ids[..index].contains(event_id)) + { + return Err(buzz_auth::AuthError::Internal( + "invalid atomic NIP-98 proof set".to_owned(), + )); + } + Ok(true) + }) + } } const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 diff --git a/crates/buzz-relay/src/audio/authorization.rs b/crates/buzz-relay/src/audio/authorization.rs new file mode 100644 index 0000000000..e32148eaa4 --- /dev/null +++ b/crates/buzz-relay/src/audio/authorization.rs @@ -0,0 +1,336 @@ +//! Common in-memory bounded authorization lease for protected audio. +//! +//! Audio does not persist an admission ledger. A session combines separately +//! finalized `AudioJoin` and `AudioMedia` authority, registers both dependency +//! snapshots with the runtime observer, and retains only a monotonic deadline +//! plus a sticky cancellation token. Restart therefore discards every lease. + +use std::{fmt, future::Future, pin::Pin}; + +use buzz_auth::{AuthContext, AuthorizationLeaseDependencySnapshot, RouteCapability}; +use chrono::{DateTime, Utc}; +use thiserror::Error; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; + +/// Successful atomic registration of both audio lease dependency snapshots. +pub struct AudioLeaseObservation { + authoritative_now: DateTime, + cancellation: CancellationToken, +} + +impl AudioLeaseObservation { + /// Construct the observer result after an atomic current-state recheck and subscription. + pub fn current(authoritative_now: DateTime, cancellation: CancellationToken) -> Self { + Self { + authoritative_now, + cancellation, + } + } +} + +impl fmt::Debug for AudioLeaseObservation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("AudioLeaseObservation([REDACTED])") + } +} + +/// Runtime boundary that atomically rechecks and observes two audio leases. +/// +/// Implementations must subscribe before their final recheck so an +/// invalidation cannot land between validation and observer registration. +/// Initial or continuing dependency loss must fail closed by returning an +/// error or cancelling the returned token. +pub trait AudioLeaseObserver: Send + Sync { + /// Observer-specific fail-closed error. + type Error: Send; + + /// Register the exact join and media dependency tuples together. + fn register_audio_session<'a>( + &'a self, + join: &'a AuthorizationLeaseDependencySnapshot, + media: &'a AuthorizationLeaseDependencySnapshot, + ) -> Pin> + Send + 'a>>; +} + +/// Cloneable in-memory authority retained by one live audio connection. +#[derive(Clone)] +pub struct BoundedAudioSessionLease { + cancellation: CancellationToken, + deadline: Instant, + expires_at: DateTime, +} + +impl BoundedAudioSessionLease { + /// Validate an exact join/media pair and atomically register its dependencies. + pub async fn register( + join: &AuthContext, + media: &AuthContext, + observer: &O, + ) -> Result { + let join_coordinates = AudioLeaseCoordinates::from_context(join); + let media_coordinates = AudioLeaseCoordinates::from_context(media); + // Request fingerprints, fences, dependency revisions, issue times, and + // expiries remain independently authoritative for the two capabilities. + // Only stable session identity coordinates must match here; the observer + // atomically rechecks both complete snapshots below. + validate_pair(&join_coordinates, &media_coordinates)?; + + let join_snapshot = join.lease().dependency_snapshot(); + let media_snapshot = media.lease().dependency_snapshot(); + // Anchor before observer I/O. PostgreSQL time is sampled during that + // I/O, so adding the returned remaining duration to this earlier + // monotonic instant is conservative rather than extending the lease. + let local_anchor = Instant::now(); + let observation = observer + .register_audio_session(&join_snapshot, &media_snapshot) + .await + .map_err(|_| AudioLeaseError::ObserverUnavailable)?; + if observation.cancellation.is_cancelled() { + return Err(AudioLeaseError::Invalidated); + } + let expires_at = join.lease().expires_at().min(media.lease().expires_at()); + let deadline = monotonic_deadline(local_anchor, observation.authoritative_now, expires_at)?; + Ok(Self { + cancellation: observation.cancellation, + deadline, + expires_at, + }) + } + + /// Fail closed immediately before admission, publication, or disclosure. + pub fn revalidate(&self) -> Result<(), AudioLeaseError> { + self.revalidate_at(Instant::now()) + } + + /// Sticky token cancelled by invalidation or observer dependency loss. + pub fn cancellation(&self) -> CancellationToken { + self.cancellation.clone() + } + + /// Wait until the exact session authority is invalidated. + pub async fn cancelled(&self) { + self.cancellation.cancelled().await; + } + + /// Exclusive wall-clock expiry retained for diagnostics and scheduling. + pub const fn expires_at(&self) -> DateTime { + self.expires_at + } + + fn revalidate_at(&self, now: Instant) -> Result<(), AudioLeaseError> { + if self.cancellation.is_cancelled() { + return Err(AudioLeaseError::Invalidated); + } + if now >= self.deadline { + return Err(AudioLeaseError::Expired); + } + Ok(()) + } +} + +impl fmt::Debug for BoundedAudioSessionLease { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("BoundedAudioSessionLease([REDACTED])") + } +} + +#[derive(Clone, PartialEq, Eq)] +struct AudioLeaseCoordinates { + capability: RouteCapability, + authorization_domain: buzz_core::CommunityId, + actor_pubkey: nostr::PublicKey, + owner_pubkey: Option, + binding: (uuid::Uuid, u64), + delegated_relationship: Option<(uuid::Uuid, u64)>, + transport: buzz_auth::ProofTransport, + target_fingerprint: [u8; 32], + transport_context_fingerprint: [u8; 32], +} + +impl AudioLeaseCoordinates { + fn from_context(context: &AuthContext) -> Self { + let lease = context.lease(); + let (_, target, transport_context) = lease.request_binding(); + Self { + capability: context.capability(), + authorization_domain: context.authorization_domain(), + actor_pubkey: context.actor_pubkey(), + owner_pubkey: context.owner_pubkey(), + binding: context.binding(), + delegated_relationship: lease.delegated_relationship(), + transport: context.transport(), + target_fingerprint: *target, + transport_context_fingerprint: *transport_context, + } + } +} + +fn validate_pair( + join: &AudioLeaseCoordinates, + media: &AudioLeaseCoordinates, +) -> Result<(), AudioLeaseError> { + if join.capability != RouteCapability::AudioJoin + || media.capability != RouteCapability::AudioMedia + { + return Err(AudioLeaseError::CapabilityMismatch); + } + if join.authorization_domain != media.authorization_domain + || join.actor_pubkey != media.actor_pubkey + || join.owner_pubkey != media.owner_pubkey + || join.binding != media.binding + || join.delegated_relationship != media.delegated_relationship + || join.transport != media.transport + || join.target_fingerprint != media.target_fingerprint + || join.transport_context_fingerprint != media.transport_context_fingerprint + { + return Err(AudioLeaseError::CoordinateMismatch); + } + Ok(()) +} + +fn monotonic_deadline( + local_anchor: Instant, + authoritative_now: DateTime, + expires_at: DateTime, +) -> Result { + if expires_at <= authoritative_now { + return Err(AudioLeaseError::Expired); + } + let remaining = (expires_at - authoritative_now) + .to_std() + .map_err(|_| AudioLeaseError::Expired)?; + local_anchor + .checked_add(remaining) + .ok_or(AudioLeaseError::Expired) +} + +/// Fail-closed common audio lease errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum AudioLeaseError { + /// The contexts were not exactly `AudioJoin` and `AudioMedia`. + #[error("audio capability mismatch")] + CapabilityMismatch, + /// Join and media authority named different subjects or dependencies. + #[error("audio authorization coordinates mismatch")] + CoordinateMismatch, + /// The observer could not establish current distributed authority. + #[error("audio authorization observer unavailable")] + ObserverUnavailable, + /// The exclusive lease deadline was reached. + #[error("audio authorization expired")] + Expired, + /// A dependency changed or its observer became unavailable. + #[error("audio authorization invalidated")] + Invalidated, +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::Keys; + use std::time::Duration; + + fn coordinates(capability: RouteCapability) -> AudioLeaseCoordinates { + AudioLeaseCoordinates { + capability, + authorization_domain: buzz_core::CommunityId::from_uuid(uuid::Uuid::from_u128(1)), + actor_pubkey: Keys::generate().public_key(), + owner_pubkey: None, + binding: (uuid::Uuid::from_u128(2), 3), + delegated_relationship: None, + transport: buzz_auth::ProofTransport::Nip42, + target_fingerprint: [5; 32], + transport_context_fingerprint: [6; 32], + } + } + + fn pair() -> (AudioLeaseCoordinates, AudioLeaseCoordinates) { + let join = coordinates(RouteCapability::AudioJoin); + let mut media = join.clone(); + media.capability = RouteCapability::AudioMedia; + (join, media) + } + + #[test] + fn exact_shared_session_pair_is_required() { + let (join, media) = pair(); + assert_eq!(validate_pair(&join, &media), Ok(())); + let mut wrong_actor = media.clone(); + wrong_actor.actor_pubkey = Keys::generate().public_key(); + assert_eq!( + validate_pair(&join, &wrong_actor), + Err(AudioLeaseError::CoordinateMismatch) + ); + let mut wrong_capability = media; + wrong_capability.capability = RouteCapability::GitRead; + assert_eq!( + validate_pair(&join, &wrong_capability), + Err(AudioLeaseError::CapabilityMismatch) + ); + } + + #[test] + fn deadline_is_exclusive_without_rounding() { + let local_anchor = Instant::now(); + let authoritative_now = fixture_time(); + let deadline = monotonic_deadline( + local_anchor, + authoritative_now, + authoritative_now + chrono::TimeDelta::milliseconds(1500), + ) + .expect("future deadline"); + let lease = BoundedAudioSessionLease { + cancellation: CancellationToken::new(), + deadline, + expires_at: authoritative_now + chrono::TimeDelta::milliseconds(1500), + }; + assert_eq!(lease.revalidate_at(local_anchor), Ok(())); + assert_eq!( + lease.revalidate_at(local_anchor + Duration::from_millis(1499)), + Ok(()) + ); + assert_eq!( + lease.revalidate_at(local_anchor + Duration::from_millis(1500)), + Err(AudioLeaseError::Expired) + ); + } + + #[test] + fn cancellation_is_sticky_and_shared_by_clones() { + let token = CancellationToken::new(); + let lease = BoundedAudioSessionLease { + cancellation: token.clone(), + deadline: Instant::now() + Duration::from_secs(30), + expires_at: fixture_time() + chrono::TimeDelta::seconds(30), + }; + let clone = lease.clone(); + assert_eq!(lease.revalidate(), Ok(())); + token.cancel(); + assert_eq!(lease.revalidate(), Err(AudioLeaseError::Invalidated)); + assert_eq!(clone.revalidate(), Err(AudioLeaseError::Invalidated)); + } + + #[test] + fn expired_authoritative_observation_fails_closed() { + let now = fixture_time(); + assert_eq!( + monotonic_deadline(Instant::now(), now, now), + Err(AudioLeaseError::Expired) + ); + } + + #[test] + fn debug_output_contains_no_authority() { + let lease = BoundedAudioSessionLease { + cancellation: CancellationToken::new(), + deadline: Instant::now() + Duration::from_secs(30), + expires_at: fixture_time() + chrono::TimeDelta::seconds(30), + }; + assert_eq!(format!("{lease:?}"), "BoundedAudioSessionLease([REDACTED])"); + } + + fn fixture_time() -> DateTime { + DateTime::from_timestamp(1_800_000_000, 0).expect("fixture time") + } +} diff --git a/crates/buzz-relay/src/audio/mod.rs b/crates/buzz-relay/src/audio/mod.rs index c6c54d7f2e..56a7cb6c15 100644 --- a/crates/buzz-relay/src/audio/mod.rs +++ b/crates/buzz-relay/src/audio/mod.rs @@ -9,6 +9,7 @@ //! (1-byte peer_index prefix) //! ``` +pub mod authorization; pub mod handler; pub mod join; pub mod mesh; diff --git a/migrations/0032_nip_fi_protected_authority.sql b/migrations/0032_nip_fi_protected_authority.sql new file mode 100644 index 0000000000..9c7840d175 --- /dev/null +++ b/migrations/0032_nip_fi_protected_authority.sql @@ -0,0 +1,211 @@ +-- Provider-free protected-domain and per-object authority prerequisites. +-- +-- The protected-domain marker is intentionally installed before the later +-- invalidation implementation. Protected mutations consume the sole +-- operation-receipt and authorization-event foundation created by 0031. +-- There are no publication, delivery, audio-ledger, restore, status, or +-- operator/pre-authentication tables in this migration. + +-- Durable one-way protected-domain activation marker and current generation. +-- Later invalidation code advances the generation; removing the marker would +-- silently reclassify an Enforce domain as legacy and is always forbidden. +CREATE TABLE authorization_invalidation_domains ( + community_id UUID NOT NULL PRIMARY KEY REFERENCES communities(id), + current_generation BIGINT NOT NULL CHECK (current_generation >= 0), + activated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp() +); + +-- Protected-object kinds: 1 domain, 2 channel, 3 repository, 4 media, +-- 5 moderation target, 6 audio session, 7 binding status. One current row is +-- the durable epoch/fence prerequisite for the exact protected authority row. +CREATE TABLE authorization_authority_epochs ( + community_id UUID NOT NULL REFERENCES communities(id), + object_kind SMALLINT NOT NULL CHECK (object_kind IN (1, 2, 3, 4, 5, 6, 7)), + object_key BYTEA NOT NULL CHECK (octet_length(object_key) = 32), + authority_epoch BIGINT NOT NULL CHECK (authority_epoch > 0), + fence BYTEA NOT NULL CHECK ( + octet_length(fence) = 32 AND fence <> decode(repeat('00', 32), 'hex') + ), + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, object_kind, object_key), + UNIQUE ( + community_id, + object_kind, + object_key, + authority_epoch, + fence, + operation_id, + request_fingerprint + ), + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED +); + +-- Direct-final current authority for one protected object. The authorization +-- lease itself is sealed in memory and dies on restart; this row is the exact +-- durable state re-fenced before a protected mutation or emission. +CREATE TABLE protected_object_authority ( + community_id UUID NOT NULL REFERENCES communities(id), + object_kind SMALLINT NOT NULL CHECK (object_kind IN (1, 2, 3, 4, 5, 6, 7)), + object_key BYTEA NOT NULL CHECK (octet_length(object_key) = 32), + capability SMALLINT NOT NULL CHECK ( + capability IN ( + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, + 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27 + ) + ), + actor_pubkey BYTEA NOT NULL CHECK (octet_length(actor_pubkey) = 32), + owner_pubkey BYTEA CHECK (owner_pubkey IS NULL OR octet_length(owner_pubkey) = 32), + binding_id UUID NOT NULL, + binding_version BIGINT NOT NULL CHECK (binding_version > 0), + delegated_relationship_id UUID, + delegated_relationship_revision BIGINT CHECK ( + delegated_relationship_revision IS NULL OR delegated_relationship_revision > 0 + ), + delegation_conditions_fingerprint BYTEA CHECK ( + delegation_conditions_fingerprint IS NULL + OR octet_length(delegation_conditions_fingerprint) = 32 + ), + policy_revision BIGINT NOT NULL CHECK (policy_revision > 0), + invalidation_generation BIGINT NOT NULL CHECK (invalidation_generation >= 0), + authority_epoch BIGINT NOT NULL CHECK (authority_epoch > 0), + fence BYTEA NOT NULL CHECK ( + octet_length(fence) = 32 AND fence <> decode(repeat('00', 32), 'hex') + ), + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + PRIMARY KEY (community_id, object_kind, object_key), + CONSTRAINT protected_object_authority_delegated_relationship_non_nil CHECK ( + delegated_relationship_id IS NULL + OR delegated_relationship_id <> '00000000-0000-0000-0000-000000000000'::UUID + ), + FOREIGN KEY (community_id, binding_id, binding_version) + REFERENCES identity_bindings (community_id, binding_id, binding_version) + DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (community_id, operation_id, request_fingerprint) + REFERENCES authorization_operation_receipts + (community_id, operation_id, request_fingerprint) + DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY ( + community_id, + object_kind, + object_key, + authority_epoch, + fence, + operation_id, + request_fingerprint + ) REFERENCES authorization_authority_epochs ( + community_id, + object_kind, + object_key, + authority_epoch, + fence, + operation_id, + request_fingerprint + ) DEFERRABLE INITIALLY DEFERRED, + CHECK (issued_at < expires_at), + CHECK ( + (owner_pubkey IS NULL + AND delegated_relationship_id IS NULL + AND delegated_relationship_revision IS NULL + AND delegation_conditions_fingerprint IS NULL) + OR (owner_pubkey IS NOT NULL + AND delegated_relationship_id IS NOT NULL + AND delegated_relationship_revision IS NOT NULL + AND delegation_conditions_fingerprint IS NOT NULL) + ) +); + +CREATE FUNCTION authorization_invalidation_domain_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.activated_at IS DISTINCT FROM OLD.activated_at + OR NEW.current_generation <= OLD.current_generation + OR NEW.updated_at <= OLD.updated_at + THEN + RAISE EXCEPTION 'authorization invalidation activation/generation cannot move backward' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_invalidation_domains_monotonic + BEFORE UPDATE ON authorization_invalidation_domains + FOR EACH ROW EXECUTE FUNCTION authorization_invalidation_domain_guard_v1(); +CREATE TRIGGER authorization_invalidation_domains_no_delete + BEFORE DELETE ON authorization_invalidation_domains + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_invalidation_domains_no_truncate + BEFORE TRUNCATE ON authorization_invalidation_domains + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE FUNCTION authorization_authority_epoch_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.object_kind IS DISTINCT FROM OLD.object_kind + OR NEW.object_key IS DISTINCT FROM OLD.object_key + OR NEW.authority_epoch <= OLD.authority_epoch + OR NEW.fence IS NOT DISTINCT FROM OLD.fence + OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id + OR NEW.updated_at <= OLD.updated_at + THEN + RAISE EXCEPTION 'authorization authority epoch cannot move backward' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER authorization_authority_epochs_monotonic + BEFORE UPDATE ON authorization_authority_epochs + FOR EACH ROW EXECUTE FUNCTION authorization_authority_epoch_guard_v1(); +CREATE TRIGGER authorization_authority_epochs_no_delete + BEFORE DELETE ON authorization_authority_epochs + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER authorization_authority_epochs_no_truncate + BEFORE TRUNCATE ON authorization_authority_epochs + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); + +CREATE FUNCTION protected_object_authority_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.object_kind IS DISTINCT FROM OLD.object_kind + OR NEW.object_key IS DISTINCT FROM OLD.object_key + OR NEW.authority_epoch <= OLD.authority_epoch + OR NEW.fence IS NOT DISTINCT FROM OLD.fence + OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id + OR NEW.issued_at <= OLD.issued_at + THEN + RAISE EXCEPTION 'protected authority replacement requires a new operation and epoch' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER protected_object_authority_no_delete + BEFORE DELETE ON protected_object_authority + FOR EACH ROW EXECUTE FUNCTION nip_fi_reject_row_mutation_v1(); +CREATE TRIGGER protected_object_authority_no_truncate + BEFORE TRUNCATE ON protected_object_authority + FOR EACH STATEMENT EXECUTE FUNCTION nip_fi_reject_truncate_v1(); +CREATE TRIGGER protected_object_authority_strict_replacement + BEFORE UPDATE ON protected_object_authority + FOR EACH ROW EXECUTE FUNCTION protected_object_authority_guard_v1(); diff --git a/schema/schema.sql b/schema/schema.sql index 64740dd167..818e9e29bd 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -160,6 +160,33 @@ CREATE TYPE public.workflow_status AS ENUM ( ); +-- +-- Name: authorization_authority_epoch_guard_v1(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.authorization_authority_epoch_guard_v1() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.object_kind IS DISTINCT FROM OLD.object_kind + OR NEW.object_key IS DISTINCT FROM OLD.object_key + OR NEW.authority_epoch <= OLD.authority_epoch + OR NEW.fence IS NOT DISTINCT FROM OLD.fence + OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id + OR NEW.updated_at <= OLD.updated_at + THEN + RAISE EXCEPTION 'authorization authority epoch cannot move backward' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$; + + -- -- Name: authorization_event_capacity_before_insert_v1(); Type: FUNCTION; Schema: public; Owner: - -- @@ -421,6 +448,30 @@ END; $$; +-- +-- Name: authorization_invalidation_domain_guard_v1(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.authorization_invalidation_domain_guard_v1() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.activated_at IS DISTINCT FROM OLD.activated_at + OR NEW.current_generation <= OLD.current_generation + OR NEW.updated_at <= OLD.updated_at + THEN + RAISE EXCEPTION 'authorization invalidation activation/generation cannot move backward' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$; + + -- -- Name: authorization_operation_receipt_event_guard_v1(); Type: FUNCTION; Schema: public; Owner: - -- @@ -1261,6 +1312,33 @@ END; $$; +-- +-- Name: protected_object_authority_guard_v1(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.protected_object_authority_guard_v1() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF NEW IS NOT DISTINCT FROM OLD THEN + RETURN NEW; + END IF; + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.object_kind IS DISTINCT FROM OLD.object_kind + OR NEW.object_key IS DISTINCT FROM OLD.object_key + OR NEW.authority_epoch <= OLD.authority_epoch + OR NEW.fence IS NOT DISTINCT FROM OLD.fence + OR NEW.operation_id IS NOT DISTINCT FROM OLD.operation_id + OR NEW.issued_at <= OLD.issued_at + THEN + RAISE EXCEPTION 'protected authority replacement requires a new operation and epoch' + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$; + + -- -- Name: purge_soft_deleted_buzz_mesh_status(); Type: FUNCTION; Schema: public; Owner: - -- @@ -1447,6 +1525,27 @@ CREATE TABLE public.audit_log ( ); +-- +-- Name: authorization_authority_epochs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.authorization_authority_epochs ( + community_id uuid NOT NULL, + object_kind smallint NOT NULL, + object_key bytea NOT NULL, + authority_epoch bigint NOT NULL, + fence bytea NOT NULL, + operation_id uuid NOT NULL, + request_fingerprint bytea NOT NULL, + updated_at timestamp with time zone DEFAULT transaction_timestamp() NOT NULL, + CONSTRAINT authorization_authority_epochs_authority_epoch_check CHECK ((authority_epoch > 0)), + CONSTRAINT authorization_authority_epochs_fence_check CHECK (((octet_length(fence) = 32) AND (fence <> decode(repeat('00'::text, 32), 'hex'::text)))), + CONSTRAINT authorization_authority_epochs_object_key_check CHECK ((octet_length(object_key) = 32)), + CONSTRAINT authorization_authority_epochs_object_kind_check CHECK ((object_kind = ANY (ARRAY[1, 2, 3, 4, 5, 6, 7]))), + CONSTRAINT authorization_authority_epochs_request_fingerprint_check CHECK ((octet_length(request_fingerprint) = 32)) +); + + -- -- Name: authorization_event_capacity; Type: TABLE; Schema: public; Owner: - -- @@ -1520,6 +1619,19 @@ CREATE TABLE public.authorization_events ( ); +-- +-- Name: authorization_invalidation_domains; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.authorization_invalidation_domains ( + community_id uuid NOT NULL, + current_generation bigint NOT NULL, + activated_at timestamp with time zone DEFAULT transaction_timestamp() NOT NULL, + updated_at timestamp with time zone DEFAULT transaction_timestamp() NOT NULL, + CONSTRAINT authorization_invalidation_domains_current_generation_check CHECK ((current_generation >= 0)) +); + + -- -- Name: authorization_operation_receipts; Type: TABLE; Schema: public; Owner: - -- @@ -2352,6 +2464,49 @@ CREATE TABLE public.product_feedback ( ); +-- +-- Name: protected_object_authority; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.protected_object_authority ( + community_id uuid NOT NULL, + object_kind smallint NOT NULL, + object_key bytea NOT NULL, + capability smallint NOT NULL, + actor_pubkey bytea NOT NULL, + owner_pubkey bytea, + binding_id uuid NOT NULL, + binding_version bigint NOT NULL, + delegated_relationship_id uuid, + delegated_relationship_revision bigint, + delegation_conditions_fingerprint bytea, + policy_revision bigint NOT NULL, + invalidation_generation bigint NOT NULL, + authority_epoch bigint NOT NULL, + fence bytea NOT NULL, + issued_at timestamp with time zone NOT NULL, + expires_at timestamp with time zone NOT NULL, + operation_id uuid NOT NULL, + request_fingerprint bytea NOT NULL, + CONSTRAINT protected_object_authority_actor_pubkey_check CHECK ((octet_length(actor_pubkey) = 32)), + CONSTRAINT protected_object_authority_authority_epoch_check CHECK ((authority_epoch > 0)), + CONSTRAINT protected_object_authority_binding_version_check CHECK ((binding_version > 0)), + CONSTRAINT protected_object_authority_capability_check CHECK ((capability = ANY (ARRAY[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27]))), + CONSTRAINT protected_object_authority_check CHECK ((issued_at < expires_at)), + CONSTRAINT protected_object_authority_check1 CHECK ((((owner_pubkey IS NULL) AND (delegated_relationship_id IS NULL) AND (delegated_relationship_revision IS NULL) AND (delegation_conditions_fingerprint IS NULL)) OR ((owner_pubkey IS NOT NULL) AND (delegated_relationship_id IS NOT NULL) AND (delegated_relationship_revision IS NOT NULL) AND (delegation_conditions_fingerprint IS NOT NULL)))), + CONSTRAINT protected_object_authority_delegated_relationship_non_nil CHECK (((delegated_relationship_id IS NULL) OR (delegated_relationship_id <> '00000000-0000-0000-0000-000000000000'::uuid))), + CONSTRAINT protected_object_authority_delegated_relationship_revisio_check CHECK (((delegated_relationship_revision IS NULL) OR (delegated_relationship_revision > 0))), + CONSTRAINT protected_object_authority_delegation_conditions_fingerpr_check CHECK (((delegation_conditions_fingerprint IS NULL) OR (octet_length(delegation_conditions_fingerprint) = 32))), + CONSTRAINT protected_object_authority_fence_check CHECK (((octet_length(fence) = 32) AND (fence <> decode(repeat('00'::text, 32), 'hex'::text)))), + CONSTRAINT protected_object_authority_invalidation_generation_check CHECK ((invalidation_generation >= 0)), + CONSTRAINT protected_object_authority_object_key_check CHECK ((octet_length(object_key) = 32)), + CONSTRAINT protected_object_authority_object_kind_check CHECK ((object_kind = ANY (ARRAY[1, 2, 3, 4, 5, 6, 7]))), + CONSTRAINT protected_object_authority_owner_pubkey_check CHECK (((owner_pubkey IS NULL) OR (octet_length(owner_pubkey) = 32))), + CONSTRAINT protected_object_authority_policy_revision_check CHECK ((policy_revision > 0)), + CONSTRAINT protected_object_authority_request_fingerprint_check CHECK ((octet_length(request_fingerprint) = 32)) +); + + -- -- Name: pubkey_allowlist; Type: TABLE; Schema: public; Owner: - -- @@ -2912,6 +3067,22 @@ ALTER TABLE ONLY public.audit_log ADD CONSTRAINT audit_log_pkey PRIMARY KEY (community_id, seq); +-- +-- Name: authorization_authority_epochs authorization_authority_epoch_community_id_object_kind_obje_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.authorization_authority_epochs + ADD CONSTRAINT authorization_authority_epoch_community_id_object_kind_obje_key UNIQUE (community_id, object_kind, object_key, authority_epoch, fence, operation_id, request_fingerprint); + + +-- +-- Name: authorization_authority_epochs authorization_authority_epochs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.authorization_authority_epochs + ADD CONSTRAINT authorization_authority_epochs_pkey PRIMARY KEY (community_id, object_kind, object_key); + + -- -- Name: authorization_event_capacity authorization_event_capacity_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -2952,6 +3123,14 @@ ALTER TABLE ONLY public.authorization_events ADD CONSTRAINT authorization_events_pkey PRIMARY KEY (community_id, event_id); +-- +-- Name: authorization_invalidation_domains authorization_invalidation_domains_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.authorization_invalidation_domains + ADD CONSTRAINT authorization_invalidation_domains_pkey PRIMARY KEY (community_id); + + -- -- Name: authorization_operation_receipts authorization_operation_recei_community_id_operation_id_re_key1; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -3304,6 +3483,14 @@ ALTER TABLE ONLY public.product_feedback ADD CONSTRAINT product_feedback_pkey PRIMARY KEY (id); +-- +-- Name: protected_object_authority protected_object_authority_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.protected_object_authority + ADD CONSTRAINT protected_object_authority_pkey PRIMARY KEY (community_id, object_kind, object_key); + + -- -- Name: pubkey_allowlist pubkey_allowlist_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -5390,6 +5577,27 @@ ALTER INDEX public.idx_events_search_tsv ATTACH PARTITION public.events_p_past_s ALTER INDEX public.idx_events_tags_gin ATTACH PARTITION public.events_p_past_tags_idx; +-- +-- Name: authorization_authority_epochs authorization_authority_epochs_monotonic; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER authorization_authority_epochs_monotonic BEFORE UPDATE ON public.authorization_authority_epochs FOR EACH ROW EXECUTE FUNCTION public.authorization_authority_epoch_guard_v1(); + + +-- +-- Name: authorization_authority_epochs authorization_authority_epochs_no_delete; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER authorization_authority_epochs_no_delete BEFORE DELETE ON public.authorization_authority_epochs FOR EACH ROW EXECUTE FUNCTION public.nip_fi_reject_row_mutation_v1(); + + +-- +-- Name: authorization_authority_epochs authorization_authority_epochs_no_truncate; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER authorization_authority_epochs_no_truncate BEFORE TRUNCATE ON public.authorization_authority_epochs FOR EACH STATEMENT EXECUTE FUNCTION public.nip_fi_reject_truncate_v1(); + + -- -- Name: authorization_event_capacity authorization_event_capacity_monotonic; Type: TRIGGER; Schema: public; Owner: - -- @@ -5446,6 +5654,27 @@ CREATE CONSTRAINT TRIGGER authorization_events_lifecycle_cardinality AFTER INSER CREATE TRIGGER authorization_events_no_truncate BEFORE TRUNCATE ON public.authorization_events FOR EACH STATEMENT EXECUTE FUNCTION public.nip_fi_reject_truncate_v1(); +-- +-- Name: authorization_invalidation_domains authorization_invalidation_domains_monotonic; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER authorization_invalidation_domains_monotonic BEFORE UPDATE ON public.authorization_invalidation_domains FOR EACH ROW EXECUTE FUNCTION public.authorization_invalidation_domain_guard_v1(); + + +-- +-- Name: authorization_invalidation_domains authorization_invalidation_domains_no_delete; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER authorization_invalidation_domains_no_delete BEFORE DELETE ON public.authorization_invalidation_domains FOR EACH ROW EXECUTE FUNCTION public.nip_fi_reject_row_mutation_v1(); + + +-- +-- Name: authorization_invalidation_domains authorization_invalidation_domains_no_truncate; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER authorization_invalidation_domains_no_truncate BEFORE TRUNCATE ON public.authorization_invalidation_domains FOR EACH STATEMENT EXECUTE FUNCTION public.nip_fi_reject_truncate_v1(); + + -- -- Name: authorization_operation_receipts authorization_operation_receipt_event_cardinality; Type: TRIGGER; Schema: public; Owner: - -- @@ -5656,6 +5885,27 @@ CREATE TRIGGER identity_lifecycle_selectors_no_truncate BEFORE TRUNCATE ON publi CREATE CONSTRAINT TRIGGER identity_lifecycle_transition_integrity AFTER INSERT ON public.identity_lifecycle_history DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION public.identity_lifecycle_transition_integrity_guard_v1(); +-- +-- Name: protected_object_authority protected_object_authority_no_delete; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER protected_object_authority_no_delete BEFORE DELETE ON public.protected_object_authority FOR EACH ROW EXECUTE FUNCTION public.nip_fi_reject_row_mutation_v1(); + + +-- +-- Name: protected_object_authority protected_object_authority_no_truncate; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER protected_object_authority_no_truncate BEFORE TRUNCATE ON public.protected_object_authority FOR EACH STATEMENT EXECUTE FUNCTION public.nip_fi_reject_truncate_v1(); + + +-- +-- Name: protected_object_authority protected_object_authority_strict_replacement; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER protected_object_authority_strict_replacement BEFORE UPDATE ON public.protected_object_authority FOR EACH ROW EXECUTE FUNCTION public.protected_object_authority_guard_v1(); + + -- -- Name: channels trg_channels_community_id_immutable; Type: TRIGGER; Schema: public; Owner: - -- @@ -5730,6 +5980,22 @@ ALTER TABLE ONLY public.audit_log ADD CONSTRAINT audit_log_community_id_fkey FOREIGN KEY (community_id) REFERENCES public.communities(id); +-- +-- Name: authorization_authority_epochs authorization_authority_epoch_community_id_operation_id_re_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.authorization_authority_epochs + ADD CONSTRAINT authorization_authority_epoch_community_id_operation_id_re_fkey FOREIGN KEY (community_id, operation_id, request_fingerprint) REFERENCES public.authorization_operation_receipts(community_id, operation_id, request_fingerprint) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: authorization_authority_epochs authorization_authority_epochs_community_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.authorization_authority_epochs + ADD CONSTRAINT authorization_authority_epochs_community_id_fkey FOREIGN KEY (community_id) REFERENCES public.communities(id); + + -- -- Name: authorization_event_capacity authorization_event_capacity_community_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -5754,6 +6020,14 @@ ALTER TABLE ONLY public.authorization_events ADD CONSTRAINT authorization_events_community_id_operation_id_request_fin_fkey FOREIGN KEY (community_id, operation_id, request_fingerprint) REFERENCES public.authorization_operation_receipts(community_id, operation_id, request_fingerprint) DEFERRABLE INITIALLY DEFERRED; +-- +-- Name: authorization_invalidation_domains authorization_invalidation_domains_community_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.authorization_invalidation_domains + ADD CONSTRAINT authorization_invalidation_domains_community_id_fkey FOREIGN KEY (community_id) REFERENCES public.communities(id); + + -- -- Name: authorization_operation_receipts authorization_operation_receipts_community_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -6026,6 +6300,38 @@ ALTER TABLE ONLY public.product_feedback ADD CONSTRAINT product_feedback_community_id_fkey FOREIGN KEY (community_id) REFERENCES public.communities(id); +-- +-- Name: protected_object_authority protected_object_authority_community_id_binding_id_binding_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.protected_object_authority + ADD CONSTRAINT protected_object_authority_community_id_binding_id_binding_fkey FOREIGN KEY (community_id, binding_id, binding_version) REFERENCES public.identity_bindings(community_id, binding_id, binding_version) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: protected_object_authority protected_object_authority_community_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.protected_object_authority + ADD CONSTRAINT protected_object_authority_community_id_fkey FOREIGN KEY (community_id) REFERENCES public.communities(id); + + +-- +-- Name: protected_object_authority protected_object_authority_community_id_object_kind_object_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.protected_object_authority + ADD CONSTRAINT protected_object_authority_community_id_object_kind_object_fkey FOREIGN KEY (community_id, object_kind, object_key, authority_epoch, fence, operation_id, request_fingerprint) REFERENCES public.authorization_authority_epochs(community_id, object_kind, object_key, authority_epoch, fence, operation_id, request_fingerprint) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: protected_object_authority protected_object_authority_community_id_operation_id_reque_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.protected_object_authority + ADD CONSTRAINT protected_object_authority_community_id_operation_id_reque_fkey FOREIGN KEY (community_id, operation_id, request_fingerprint) REFERENCES public.authorization_operation_receipts(community_id, operation_id, request_fingerprint) DEFERRABLE INITIALLY DEFERRED; + + -- -- Name: pubkey_allowlist pubkey_allowlist_community_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -6238,6 +6544,7 @@ ALTER TABLE ONLY public.workflows -- PostgreSQL database dump complete -- + -- Deterministic seed state installed by migrations. INSERT INTO public._operator_global_tables (table_name, reason) VALUES ('_operator_global_tables', 'the registry table itself'),