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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions crates/buzz-relay/src/handlers/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
99 changes: 99 additions & 0 deletions crates/buzz-relay/src/handlers/freshness.rs
Original file line number Diff line number Diff line change
@@ -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());
}
}
55 changes: 42 additions & 13 deletions crates/buzz-relay/src/handlers/identity_archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -437,7 +437,7 @@ mod tests {

async fn test_pool() -> Option<sqlx::PgPool> {
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()
}

Expand Down Expand Up @@ -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());
}
}
24 changes: 14 additions & 10 deletions crates/buzz-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
));
Expand Down Expand Up @@ -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()
)));
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/buzz-relay/src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
21 changes: 9 additions & 12 deletions crates/buzz-relay/src/handlers/moderation_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
///
Expand Down Expand Up @@ -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()
)));
}

Expand Down
16 changes: 8 additions & 8 deletions crates/buzz-relay/src/handlers/relay_admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
));
}
}
Expand Down
Loading