mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Reject implausible Nostr DM rumor and gift-wrap timestamps
A malicious relay can ignore the subscription since filter and push arbitrarily old or future-dated events. Validate the inner rumor against the 24h lookback plus 15min skew (iOS parity) and reject future-dated outer gift wraps the same way.
This commit is contained in:
parent
81ba775063
commit
4d1ed3592f
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user