Skip to content

Commit 322587d

Browse files
committed
feat(nostr_manager event_processor): add timestamp validation with future skew protection and giftwrap timestamp handling
1 parent 470b0a2 commit 322587d

File tree

6 files changed

+426
-52
lines changed

6 files changed

+426
-52
lines changed

src/integration_tests/test_cases/subscription_processing/verify_last_synced_timestamp.rs

Lines changed: 73 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
use crate::integration_tests::core::*;
2-
use crate::WhitenoiseError;
1+
use std::time::Duration;
2+
33
use async_trait::async_trait;
44
use nostr_sdk::prelude::*;
5-
use std::time::Duration;
5+
6+
use crate::integration_tests::core::*;
7+
use crate::WhitenoiseError;
68

79
pub struct VerifyLastSyncedTimestampTestCase {
810
mode: Mode,
@@ -31,8 +33,8 @@ impl VerifyLastSyncedTimestampTestCase {
3133
context: &ScenarioContext,
3234
pubkey: PublicKey,
3335
) -> Result<Option<chrono::DateTime<chrono::Utc>>, WhitenoiseError> {
34-
let fresh = context.whitenoise.find_account_by_pubkey(&pubkey).await?;
35-
Ok(fresh.last_synced_at)
36+
let account = context.whitenoise.find_account_by_pubkey(&pubkey).await?;
37+
Ok(account.last_synced_at)
3638
}
3739

3840
async fn assert_advanced(
@@ -43,13 +45,13 @@ impl VerifyLastSyncedTimestampTestCase {
4345
description: &str,
4446
) -> Result<(), WhitenoiseError> {
4547
retry(
46-
50,
47-
Duration::from_millis(50),
48+
100,
49+
Duration::from_millis(75),
4850
|| async {
49-
let refreshed = context.whitenoise.find_account_by_pubkey(&pubkey).await?;
50-
match (before, refreshed.last_synced_at) {
51+
let account = context.whitenoise.find_account_by_pubkey(&pubkey).await?;
52+
match (before, account.last_synced_at) {
5153
(None, Some(_)) => Ok(()),
52-
(Some(b), Some(a)) if a > b => Ok(()),
54+
(Some(before_time), Some(after_time)) if after_time > before_time => Ok(()),
5355
_ => Err(WhitenoiseError::Other(anyhow::anyhow!(
5456
"last_synced_at not advanced yet"
5557
))),
@@ -71,12 +73,8 @@ impl VerifyLastSyncedTimestampTestCase {
7173
50,
7274
Duration::from_millis(50),
7375
|| async {
74-
let after = context
75-
.whitenoise
76-
.find_account_by_pubkey(&pubkey)
77-
.await?
78-
.last_synced_at;
79-
if after == before {
76+
let account = context.whitenoise.find_account_by_pubkey(&pubkey).await?;
77+
if account.last_synced_at == before {
8078
Ok(())
8179
} else {
8280
Err(WhitenoiseError::Other(anyhow::anyhow!(
@@ -89,38 +87,49 @@ impl VerifyLastSyncedTimestampTestCase {
8987
.await
9088
}
9189

92-
async fn publish_account_follow_event(
90+
async fn publish_account_follow_event_with_timestamp(
9391
&self,
9492
context: &ScenarioContext,
9593
pubkey: PublicKey,
94+
event_timestamp: Timestamp,
9695
) -> Result<(), WhitenoiseError> {
97-
let account_owned = context.whitenoise.find_account_by_pubkey(&pubkey).await?;
98-
let nsec = context
99-
.whitenoise
100-
.export_account_nsec(&account_owned)
101-
.await?;
96+
let account = context.whitenoise.find_account_by_pubkey(&pubkey).await?;
97+
let nsec = context.whitenoise.export_account_nsec(&account).await?;
10298
let keys = Keys::parse(&nsec)?;
103-
let client = create_test_client(&context.dev_relays, keys).await?;
99+
let client = create_test_client(&context.dev_relays, keys.clone()).await?;
104100
let contact = Keys::generate().public_key();
105-
publish_follow_list(&client, &[contact]).await?;
101+
102+
let tags = vec![Tag::custom(TagKind::p(), [contact.to_hex()])];
103+
let event = EventBuilder::new(Kind::ContactList, "")
104+
.tags(tags)
105+
.custom_created_at(event_timestamp)
106+
.sign_with_keys(&keys)
107+
.map_err(|e| WhitenoiseError::Other(e.into()))?;
108+
109+
client.send_event(&event).await?;
106110
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
107111
client.disconnect().await;
108112
Ok(())
109113
}
110114

111-
async fn publish_global_metadata_event(
115+
async fn publish_global_metadata_event_with_timestamp(
112116
&self,
113117
context: &ScenarioContext,
118+
event_timestamp: Timestamp,
114119
) -> Result<(), WhitenoiseError> {
115-
let ext = Keys::generate();
116-
let client = create_test_client(&context.dev_relays, ext).await?;
120+
let keys = Keys::generate();
121+
let client = create_test_client(&context.dev_relays, keys.clone()).await?;
117122
let metadata = Metadata {
118-
name: Some("No-op for account sync".to_string()),
123+
name: Some("Test metadata for sync verification".to_string()),
119124
..Default::default()
120125
};
121-
client
122-
.send_event_builder(EventBuilder::metadata(&metadata))
123-
.await?;
126+
127+
let event = EventBuilder::metadata(&metadata)
128+
.custom_created_at(event_timestamp)
129+
.sign_with_keys(&keys)
130+
.map_err(|e| WhitenoiseError::Other(e.into()))?;
131+
132+
client.send_event(&event).await?;
124133
tokio::time::sleep(tokio::time::Duration::from_millis(600)).await;
125134
client.disconnect().await;
126135
Ok(())
@@ -132,9 +141,37 @@ impl TestCase for VerifyLastSyncedTimestampTestCase {
132141
async fn run(&self, context: &mut ScenarioContext) -> Result<(), WhitenoiseError> {
133142
let pubkey = { context.get_account("subscription_test_account")?.pubkey };
134143
let before = self.baseline(context, pubkey).await?;
144+
145+
// Create deterministic base timestamp for this test run
146+
let base_timestamp = Timestamp::now();
147+
135148
match self.mode {
136149
Mode::AccountFollowEvent => {
137-
self.publish_account_follow_event(context, pubkey).await?;
150+
// Wait to ensure that when the event timestamp gets capped to now(),
151+
// it will still be greater than the baseline last_synced_at
152+
if let Some(before_time) = before {
153+
let before_timestamp_secs = before_time.timestamp() as u64;
154+
let current_timestamp_secs = Timestamp::now().as_u64();
155+
156+
if current_timestamp_secs <= before_timestamp_secs {
157+
// Wait enough time to ensure now() > baseline when event gets processed
158+
let wait_time = (before_timestamp_secs - current_timestamp_secs + 2) * 1000; // +2 seconds buffer
159+
tracing::debug!(
160+
target: "verify_last_synced_timestamp",
161+
"Waiting {}ms to ensure capped timestamp > baseline ({} vs {})",
162+
wait_time,
163+
current_timestamp_secs,
164+
before_timestamp_secs
165+
);
166+
tokio::time::sleep(std::time::Duration::from_millis(wait_time)).await;
167+
}
168+
}
169+
170+
// Use base timestamp + 10 seconds for guaranteed advancement
171+
// Even if this gets capped to now(), it should be > baseline due to our wait
172+
let event_timestamp = Timestamp::from_secs(base_timestamp.as_u64() + 10);
173+
self.publish_account_follow_event_with_timestamp(context, pubkey, event_timestamp)
174+
.await?;
138175
self.assert_advanced(
139176
context,
140177
pubkey,
@@ -144,7 +181,10 @@ impl TestCase for VerifyLastSyncedTimestampTestCase {
144181
.await?;
145182
}
146183
Mode::GlobalMetadataEvent => {
147-
self.publish_global_metadata_event(context).await?;
184+
// Use base timestamp + 5 seconds (should not affect account sync)
185+
let event_timestamp = Timestamp::from_secs(base_timestamp.as_u64() + 5);
186+
self.publish_global_metadata_event_with_timestamp(context, event_timestamp)
187+
.await?;
148188
self.assert_unchanged(
149189
context,
150190
pubkey,

src/nostr_manager/query.rs

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
11
//! This module contains functions for querying Nostr events from relays.
22
3-
use std::time::Duration;
4-
53
use nostr_sdk::prelude::*;
64

75
use crate::{
8-
nostr_manager::{NostrManager, Result},
6+
nostr_manager::{utils::is_event_timestamp_valid, NostrManager, Result},
97
RelayType,
108
};
119

12-
/// Maximum allowed skew for event timestamps in the future (1 hour)
13-
const MAX_FUTURE_SKEW: Duration = Duration::from_secs(60 * 60);
14-
1510
impl NostrManager {
1611
pub(crate) async fn fetch_metadata_from(
1712
&self,
@@ -54,11 +49,9 @@ impl NostrManager {
5449
}
5550

5651
fn latest_from_events(events: Events) -> Result<Option<Event>> {
57-
// Filter out events with timestamps too far in the future
58-
let cutoff = Timestamp::now() + MAX_FUTURE_SKEW;
5952
let latest = events
6053
.into_iter()
61-
.filter(|e| e.created_at <= cutoff)
54+
.filter(is_event_timestamp_valid)
6255
.max_by_key(|e| (e.created_at, e.id));
6356
Ok(latest)
6457
}

src/nostr_manager/subscriptions.rs

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ use sha2::{Digest, Sha256};
99

1010
const MAX_USERS_PER_GLOBAL_SUBSCRIPTION: usize = 1000;
1111

12-
use crate::nostr_manager::{NostrManager, NostrManagerError, Result};
12+
use crate::nostr_manager::{
13+
utils::adjust_since_for_giftwrap, NostrManager, NostrManagerError, Result,
14+
};
1315

1416
impl NostrManager {
1517
/// Create a short hash from a pubkey for use in subscription IDs
@@ -372,7 +374,9 @@ impl NostrManager {
372374
) -> Result<()> {
373375
tracing::debug!(
374376
target: "whitenoise::nostr_manager::setup_user_follow_list_subscription",
375-
"Setting up user follow list subscription"
377+
"Setting up subscription for pubkey {} on {} relays",
378+
pubkey.to_hex(),
379+
user_relays.len()
376380
);
377381
let pubkey_hash = self.create_pubkey_hash(&pubkey);
378382
let subscription_id = SubscriptionId::new(format!("{}_user_follow_list", pubkey_hash));
@@ -383,12 +387,14 @@ impl NostrManager {
383387
}
384388

385389
self.client
386-
.subscribe_with_id_to(user_relays, subscription_id, user_follow_list_filter, None)
390+
.subscribe_with_id_to(user_relays, subscription_id.clone(), user_follow_list_filter, None)
387391
.await?;
388392

389393
tracing::debug!(
390394
target: "whitenoise::nostr_manager::setup_user_follow_list_subscription",
391-
"User follow list subscription set up"
395+
"FollowList subscription '{}' set up successfully for {}",
396+
subscription_id,
397+
pubkey.to_hex()
392398
);
393399
Ok(())
394400
}
@@ -408,7 +414,12 @@ impl NostrManager {
408414

409415
let mut giftwrap_filter = Filter::new().kind(Kind::GiftWrap).pubkey(pubkey);
410416
if let Some(since) = since {
411-
giftwrap_filter = giftwrap_filter.since(since);
417+
// Account for NIP-59 backdated timestamps - giftwrap events may be timestamped
418+
// in the past for privacy, so we look back further than last_synced_at
419+
let adjusted_since = adjust_since_for_giftwrap(Some(since));
420+
if let Some(adjusted) = adjusted_since {
421+
giftwrap_filter = giftwrap_filter.since(adjusted);
422+
}
412423
}
413424

414425
self.client
@@ -507,4 +518,30 @@ mod tests {
507518
assert_eq!(hash1, hash2);
508519
assert_eq!(hash1.len(), 12); // Should be 12 characters as specified
509520
}
521+
522+
#[tokio::test]
523+
async fn test_giftwrap_subscription_lookback_buffer() {
524+
use crate::nostr_manager::utils::GIFTWRAP_LOOKBACK_BUFFER;
525+
use std::time::Duration;
526+
527+
// Test that adjust_since_for_giftwrap extends the lookback period correctly
528+
let original_timestamp = Timestamp::now();
529+
let adjusted = adjust_since_for_giftwrap(Some(original_timestamp));
530+
531+
assert!(adjusted.is_some());
532+
let adjusted_ts = adjusted.unwrap();
533+
534+
// Should be exactly GIFTWRAP_LOOKBACK_BUFFER earlier
535+
assert_eq!(adjusted_ts, original_timestamp - GIFTWRAP_LOOKBACK_BUFFER);
536+
537+
// Verify the buffer is significant (7 days)
538+
assert_eq!(
539+
GIFTWRAP_LOOKBACK_BUFFER,
540+
Duration::from_secs(7 * 24 * 60 * 60)
541+
);
542+
543+
// Test with None - should return None
544+
let none_result = adjust_since_for_giftwrap(None);
545+
assert!(none_result.is_none());
546+
}
510547
}

0 commit comments

Comments
 (0)