diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt index 0257c756..c5385759 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt @@ -241,10 +241,8 @@ class NostrClient private constructor(private val context: Context) { giftWrap: NostrEvent, handler: (content: String, senderNpub: String, timestamp: Int) -> Unit ) { - // Age filtering (24h + 15min buffer for randomized timestamps) - val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt - if (messageAge > 173700) { // 48 hours + 15 minutes - Log.v(TAG, "Ignoring old private message") + if (!NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(giftWrap.createdAt)) { + Log.v(TAG, "Ignoring private message with implausible gift-wrap created_at") return } @@ -254,6 +252,10 @@ class NostrClient private constructor(private val context: Context) { val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity) if (decryptResult != null) { val (content, senderPubkey, timestamp) = decryptResult + if (!NostrTimestampPolicy.isPlausibleRumorTimestamp(timestamp)) { + Log.w(TAG, "Dropping private message with implausible rumor timestamp") + return + } // Convert sender pubkey to npub val senderNpub = try { diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 4125d5ca..5df246c1 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -60,8 +60,10 @@ class NostrDirectMessageHandler( try { if (dedupe(giftWrap.id)) return@launch - val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt - if (messageAge > 173700) return@launch // 48 hours + 15 mins + if (!NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(giftWrap.createdAt)) { + Log.v(TAG, "Ignoring gift wrap with implausible created_at") + return@launch + } val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity) if (decryptResult == null) { @@ -70,6 +72,10 @@ class NostrDirectMessageHandler( } val (content, rawSenderPubkey, rumorTimestamp) = decryptResult + if (!NostrTimestampPolicy.isPlausibleRumorTimestamp(rumorTimestamp)) { + Log.w(TAG, "Dropping Nostr DM with implausible rumor timestamp") + return@launch + } val senderPubkey = rawSenderPubkey.lowercase() // If sender is blocked for geohash contexts, drop any events from this pubkey diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTimestampPolicy.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTimestampPolicy.kt new file mode 100644 index 00000000..1daca641 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTimestampPolicy.kt @@ -0,0 +1,42 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.util.AppConstants + +/** + * Client-side timestamp windows for inbound Nostr DMs. + * + * Mirrors iOS `NostrInboundPipeline.isPlausibleRumorTimestamp`: a relay that + * ignores the subscription `since` filter — or replays archived events — must + * not inject stale or future-dated DMs. The inner rumor timestamp is the + * sender's true send time; only the outer gift wrap is NIP-17-randomized. + */ +object NostrTimestampPolicy { + + /** + * Accept an inner rumor `created_at` inside + * `[now − lookback − skew, now + skew]`. + */ + fun isPlausibleRumorTimestamp( + tsSeconds: Int, + nowSeconds: Long = System.currentTimeMillis() / 1000L + ): Boolean { + val age = nowSeconds - tsSeconds.toLong() + val skew = AppConstants.Nostr.DM_MAX_CLOCK_SKEW_SECONDS + val lookback = AppConstants.Nostr.DM_SUBSCRIBE_LOOKBACK_SECONDS + return age >= -skew && age <= lookback + skew + } + + /** + * Accept an outer gift-wrap `created_at` that is not in the far future and + * not older than the NIP-17 randomization ceiling plus skew. + */ + fun isAcceptableGiftWrapTimestamp( + createdAtSeconds: Int, + nowSeconds: Long = System.currentTimeMillis() / 1000L + ): Boolean { + val age = nowSeconds - createdAtSeconds.toLong() + val skew = AppConstants.Nostr.DM_MAX_CLOCK_SKEW_SECONDS + val maxAge = AppConstants.Nostr.DM_GIFT_WRAP_MAX_AGE_SECONDS + return age >= -skew && age <= maxAge + } +} diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index b1822806..b398373f 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -107,6 +107,14 @@ object AppConstants { // Relay subscription validation const val SUBSCRIPTION_VALIDATION_INTERVAL_MS: Long = 30_000L + + // Client-side timestamp windows for inbound DMs (iOS TransportConfig parity). + // Inner rumor created_at is the sender's true send time; outer gift-wrap + // created_at is NIP-17-randomized into the past and may be older. + const val DM_SUBSCRIBE_LOOKBACK_SECONDS: Long = 86_400L // 24h + const val DM_MAX_CLOCK_SKEW_SECONDS: Long = 900L // 15min + // Outer gift-wrap age ceiling: 48h randomization + 15min skew. + const val DM_GIFT_WRAP_MAX_AGE_SECONDS: Long = 173_700L } object Tor { diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrTimestampPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrTimestampPolicyTest.kt new file mode 100644 index 00000000..c1411956 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrTimestampPolicyTest.kt @@ -0,0 +1,67 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.util.AppConstants +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NostrTimestampPolicyTest { + + private val now = 1_700_000_000L + private val skew = AppConstants.Nostr.DM_MAX_CLOCK_SKEW_SECONDS + private val lookback = AppConstants.Nostr.DM_SUBSCRIBE_LOOKBACK_SECONDS + private val giftWrapMax = AppConstants.Nostr.DM_GIFT_WRAP_MAX_AGE_SECONDS + + @Test + fun `rumor timestamp at now is accepted`() { + assertTrue(NostrTimestampPolicy.isPlausibleRumorTimestamp(now.toInt(), now)) + } + + @Test + fun `rumor timestamp within lookback is accepted`() { + val ts = (now - lookback).toInt() + assertTrue(NostrTimestampPolicy.isPlausibleRumorTimestamp(ts, now)) + } + + @Test + fun `rumor timestamp just inside skew past lookback is accepted`() { + val ts = (now - lookback - skew).toInt() + assertTrue(NostrTimestampPolicy.isPlausibleRumorTimestamp(ts, now)) + } + + @Test + fun `rumor timestamp older than lookback plus skew is rejected`() { + val ts = (now - lookback - skew - 1).toInt() + assertFalse(NostrTimestampPolicy.isPlausibleRumorTimestamp(ts, now)) + } + + @Test + fun `future-dated rumor within skew is accepted`() { + val ts = (now + skew).toInt() + assertTrue(NostrTimestampPolicy.isPlausibleRumorTimestamp(ts, now)) + } + + @Test + fun `future-dated rumor beyond skew is rejected`() { + val ts = (now + skew + 1).toInt() + assertFalse(NostrTimestampPolicy.isPlausibleRumorTimestamp(ts, now)) + } + + @Test + fun `future-dated gift wrap beyond skew is rejected`() { + val createdAt = (now + skew + 1).toInt() + assertFalse(NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(createdAt, now)) + } + + @Test + fun `gift wrap within randomization ceiling is accepted`() { + val createdAt = (now - giftWrapMax).toInt() + assertTrue(NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(createdAt, now)) + } + + @Test + fun `gift wrap older than randomization ceiling is rejected`() { + val createdAt = (now - giftWrapMax - 1).toInt() + assertFalse(NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(createdAt, now)) + } +}