Merge 4d1ed3592fd441e220da192dcfda95148a58be65 into c127eb83ab94c069c32d37530d2faecd381cd2a8

This commit is contained in:
Taksh Kothari 2026-09-14 09:43:25 +05:30 committed by GitHub
commit d2e8bd8b05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 131 additions and 6 deletions

View File

@ -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 {

View File

@ -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

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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))
}
}