diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 7c93733262b..4d3dfc8a08e 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1364,9 +1364,8 @@ async fn handle_agent_observer_event( } // Freshness check: reject observer frames with stale/future timestamps - let now = chrono::Utc::now().timestamp(); - let event_ts = event.created_at.as_secs() as i64; - if (event_ts - now).unsigned_abs() > 300 { + let now = super::freshness::unix_now_secs(); + if super::freshness::check_created_at_skew(event.created_at, now, 300).is_err() { write.ok( &conn, event_id_hex, diff --git a/crates/buzz-relay/src/handlers/freshness.rs b/crates/buzz-relay/src/handlers/freshness.rs new file mode 100644 index 00000000000..082a089ddce --- /dev/null +++ b/crates/buzz-relay/src/handlers/freshness.rs @@ -0,0 +1,99 @@ +//! Shared `created_at` freshness (clock-skew) checks. +//! +//! Every skew gate compares in `u64` with [`u64::abs_diff`], so no +//! `created_at` value can overflow the comparison. The previous per-site +//! copies cast to `i64` and used `(event_ts - now).abs()`, which wraps for +//! `created_at = now + 2^63` and let an extreme future timestamp through. + +use nostr::Timestamp; + +/// Maximum `created_at` skew (seconds, either direction) accepted for signed +/// command events that are executed and not stored: moderation commands, +/// relay admin commands, NIP-IA archive requests and NIP-43 leave requests. +pub(crate) const MAX_COMMAND_SKEW_SECS: u64 = 120; + +/// Current Unix time in whole seconds (0 if the system clock is before the +/// epoch, which then fails every freshness check closed). +pub(crate) fn unix_now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// A `created_at` that lies more than the allowed skew away from `now`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SkewExceeded { + /// The event's `created_at`, in seconds. + pub(crate) created_at: u64, + /// The server time it was compared against, in seconds. + pub(crate) now: u64, +} + +impl SkewExceeded { + /// Signed `created_at - now`, widened so it cannot overflow. + pub(crate) fn delta(&self) -> i128 { + i128::from(self.created_at) - i128::from(self.now) + } +} + +/// Accept `created_at` only if it is within `max_skew_secs` of `now`. +pub(crate) fn check_created_at_skew( + created_at: Timestamp, + now: u64, + max_skew_secs: u64, +) -> Result<(), SkewExceeded> { + let created_at = created_at.as_secs(); + if created_at.abs_diff(now) > max_skew_secs { + return Err(SkewExceeded { created_at, now }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn skew_table() { + let now: u64 = 1_700_000_000; + let max = MAX_COMMAND_SKEW_SECS; + let cases: &[(u64, bool)] = &[ + (now, true), + (now + max, true), + (now - max, true), + (now + max + 1, false), + (now - max - 1, false), + (0, false), + (u64::MAX, false), + // `as i64 - now` == i64::MIN: the old `.abs()` wrapped and passed. + ((1u64 << 63) + now, false), + ((1u64 << 63) + now - 1, false), + ((1u64 << 63) + now + 1, false), + (1u64 << 63, false), + ]; + for &(created_at, ok) in cases { + let got = check_created_at_skew(Timestamp::from_secs(created_at), now, max); + assert_eq!(got.is_ok(), ok, "created_at={created_at}"); + } + } + + #[test] + fn delta_is_signed_and_does_not_overflow() { + let e = SkewExceeded { + created_at: u64::MAX, + now: 0, + }; + assert_eq!(e.delta(), i128::from(u64::MAX)); + let e = SkewExceeded { + created_at: 0, + now: 5, + }; + assert_eq!(e.delta(), -5); + } + + #[test] + fn epoch_now_rejects_current_timestamps() { + assert!(check_created_at_skew(Timestamp::from_secs(1_700_000_000), 0, 120).is_err()); + } +} diff --git a/crates/buzz-relay/src/handlers/identity_archive.rs b/crates/buzz-relay/src/handlers/identity_archive.rs index 53f25cacee2..3d30f0585cb 100644 --- a/crates/buzz-relay/src/handlers/identity_archive.rs +++ b/crates/buzz-relay/src/handlers/identity_archive.rs @@ -14,6 +14,7 @@ use buzz_core::tenant::TenantContext; use buzz_core::CommunityId; use buzz_db::EventQuery; +use crate::handlers::freshness::MAX_COMMAND_SKEW_SECS; use crate::handlers::side_effects::{ publish_nipia_archival_list, publish_nipia_archived, publish_nipia_unarchived, }; @@ -139,18 +140,17 @@ pub async fn handle_identity_archive_event( } fn enforce_freshness(event: &Event) -> Result<(), String> { - let event_ts = event.created_at.as_secs() as i64; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - if (event_ts - now).abs() > 120 { - return Err(format!( - "event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±120s)", - event_ts - now - )); - } - Ok(()) + let now = super::freshness::unix_now_secs(); + super::freshness::check_created_at_skew(event.created_at, now, MAX_COMMAND_SKEW_SECS).map_err( + |e| { + format!( + "event timestamp out of range: created_at={}, now={}, delta={}s (max ±{MAX_COMMAND_SKEW_SECS}s)", + e.created_at, + e.now, + e.delta() + ) + }, + ) } fn require_single_protected_tag(event: &Event) -> Result<(), String> { @@ -437,7 +437,7 @@ mod tests { async fn test_pool() -> Option { let url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".into()); + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".into()); // sadscan:disable np.postgres.1 -- local test fixture sqlx::PgPool::connect(&url).await.ok() } @@ -766,3 +766,32 @@ mod tests { ); } } + +#[cfg(test)] +mod freshness_tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Timestamp}; + + fn archive_request_at(created_at: u64) -> Event { + EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16), "") + .custom_created_at(Timestamp::from_secs(created_at)) + .sign_with_keys(&Keys::generate()) + .expect("sign test event") + } + + #[test] + fn freshness_rejects_created_at_that_wraps_i64_delta() { + let now = crate::handlers::freshness::unix_now_secs(); + // `created_at as i64 - now` is exactly `i64::MIN` here; its `abs()` + // overflows (panic in debug, stays negative in release). + let err = enforce_freshness(&archive_request_at((1u64 << 63) + now)) + .expect_err("extreme future timestamp must be rejected"); + assert!(err.contains("out of range"), "{err}"); + } + + #[test] + fn freshness_accepts_current_created_at() { + let now = crate::handlers::freshness::unix_now_secs(); + assert!(enforce_freshness(&archive_request_at(now)).is_ok()); + } +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 46a1c81f282..78341f98632 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2667,10 +2667,16 @@ async fn ingest_event_inner( }); } - const MAX_TIMESTAMP_DRIFT_SECS: i64 = 900; // ±15 minutes + const MAX_TIMESTAMP_DRIFT_SECS: u64 = 900; // ±15 minutes let now = chrono::Utc::now().timestamp(); let event_ts = event.created_at.as_secs() as i64; - if (event_ts - now).abs() > MAX_TIMESTAMP_DRIFT_SECS { + if super::freshness::check_created_at_skew( + event.created_at, + u64::try_from(now).unwrap_or(0), + MAX_TIMESTAMP_DRIFT_SECS, + ) + .is_err() + { return Err(IngestError::Rejected( "invalid: event timestamp too far from server time".into(), )); @@ -3005,15 +3011,13 @@ async fn ingest_event_inner( // Freshness check: reject events outside ±120s of now (same as admin commands). { - let event_ts = event.created_at.as_secs() as i64; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - if (event_ts - now).abs() > 120 { + use super::freshness::{check_created_at_skew, unix_now_secs, MAX_COMMAND_SKEW_SECS}; + if let Err(e) = + check_created_at_skew(event.created_at, unix_now_secs(), MAX_COMMAND_SKEW_SECS) + { return Err(IngestError::Rejected(format!( - "invalid: leave request timestamp out of range (delta={}s, max ±120s)", - event_ts - now + "invalid: leave request timestamp out of range (delta={}s, max ±{MAX_COMMAND_SKEW_SECS}s)", + e.delta() ))); } } diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index 3a0216ecdd3..4c9207d320b 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -26,6 +26,8 @@ mod deletion_postgres_tests; mod edit_metadata_postgres_tests; /// EVENT handler — WS dispatcher → ingest pipeline → fan-out. pub mod event; +/// Shared `created_at` freshness (clock-skew) checks. +pub(crate) mod freshness; #[cfg(test)] pub(crate) mod group_state_postgres_tests; /// NIP-IA identity archive request handler (kinds 9035–9036). diff --git a/crates/buzz-relay/src/handlers/moderation_commands.rs b/crates/buzz-relay/src/handlers/moderation_commands.rs index 65897bdd6f2..243a7a15882 100644 --- a/crates/buzz-relay/src/handlers/moderation_commands.rs +++ b/crates/buzz-relay/src/handlers/moderation_commands.rs @@ -76,10 +76,7 @@ use crate::handlers::report_resolution::{enforcement_audit_action, resolve_repor use crate::state::AppState; use buzz_db::moderation::NewAction; -/// Max clock skew for a freshly-signed command (mirrors `relay_admin.rs` and -/// the NIP-42 auth freshness window). Commands are never stored, so replay of a -/// captured command is the only threat and a tight window is the mitigation. -const MAX_COMMAND_SKEW_SECS: i64 = 120; +use super::freshness::MAX_COMMAND_SKEW_SECS; /// Validate and execute a moderation command (kinds 9040–9044). /// @@ -133,15 +130,15 @@ async fn execute_moderation_command( let kind = event.kind.as_u16() as u32; // Freshness: reject stale/replayed commands (they are never stored). - let event_ts = event.created_at.as_secs() as i64; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - if (event_ts - now).abs() > MAX_COMMAND_SKEW_SECS { + let now = super::freshness::unix_now_secs(); + if let Err(e) = + super::freshness::check_created_at_skew(event.created_at, now, MAX_COMMAND_SKEW_SECS) + { return Err(invalid(format!( - "event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±{MAX_COMMAND_SKEW_SECS}s)", - event_ts - now + "event timestamp out of range: created_at={}, now={}, delta={}s (max ±{MAX_COMMAND_SKEW_SECS}s)", + e.created_at, + e.now, + e.delta() ))); } diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 976a6c8f375..9489c81f511 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -238,15 +238,15 @@ async fn execute_relay_admin_command( // of captured admin commands. The window is intentionally tight — admin // events should be freshly signed. { - let event_ts = event.created_at.as_secs() as i64; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - if (event_ts - now).abs() > 120 { + use super::freshness::{check_created_at_skew, unix_now_secs, MAX_COMMAND_SKEW_SECS}; + if let Err(e) = + check_created_at_skew(event.created_at, unix_now_secs(), MAX_COMMAND_SKEW_SECS) + { return Err(format!( - "event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±120s)", - event_ts - now + "event timestamp out of range: created_at={}, now={}, delta={}s (max ±{MAX_COMMAND_SKEW_SECS}s)", + e.created_at, + e.now, + e.delta() )); } }