Timestamp sanity windows: future-dated QR codes and implausible Nostr DM rumors (#1647)

Two re-assessment findings:

- verifyScannedQR checked only staleness (now - ts > maxAge), so a
  future-dated timestamp bought a QR a longer validity window than a
  fresh one. The freshness check is now symmetric.

- Inbound Nostr DMs had no client-side created_at validation; the age
  bound relied entirely on relays honoring the subscription's `since`
  filter. Both gift-wrap decrypt paths now drop rumors outside
  [now - lookback - skew, now + skew]. The inner rumor timestamp is the
  sender's true send time (only the outer gift wrap is randomized per
  NIP-17), so the window mirrors exactly what an honest relay already
  guarantees — a dishonest relay can no longer inject stale or
  future-dated DMs. Existing mitigations (persistent gift-wrap dedup,
  relay-side since) are unchanged; this closes the malicious-relay gap.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack 2026-08-09 11:39:00 +02:00 committed by GitHub
parent 2a9fb4d53f
commit 6f961638f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 80 additions and 2 deletions

View File

@ -216,6 +216,10 @@ enum TransportConfig {
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
static let nostrGeohashSampleLimit: Int = 100
static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400
// Tolerated clock skew for the client-side rumor-timestamp window on
// inbound Nostr DMs (senders stamp the inner rumor with real time; only
// the outer gift wrap is randomized per NIP-17).
static let nostrDMMaxClockSkewSeconds: TimeInterval = 900
// A sampled chat message this recent means "a conversation is happening
// there" for the empty-timeline nearby-activity hint.
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900

View File

@ -106,9 +106,10 @@ final class VerificationService {
/// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks)
func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = TransportConfig.verificationQRMaxAgeSeconds) -> VerificationQR? {
guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil }
// Freshness
// Freshness, in both directions: a future-dated timestamp must not
// buy a QR a longer validity window than a fresh one gets.
let now = Date().timeIntervalSince1970
if now - Double(qr.ts) > maxAge { return nil }
if abs(now - Double(qr.ts)) > maxAge { return nil }
// Verify signature using embedded ed25519 signKey
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
guard let transport = transport else { return nil }

View File

@ -357,6 +357,13 @@ final class NostrInboundPipeline {
return
}
guard Self.isPlausibleRumorTimestamp(rumorTs) else {
if verbose {
SecureLogger.warning("GeoDM: dropping gift-wrap with implausible rumor timestamp id=\(giftWrap.id.prefix(8))", category: .session)
}
return
}
if verbose {
SecureLogger.debug(
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
@ -445,6 +452,11 @@ final class NostrInboundPipeline {
recipientIdentity: currentIdentity
)
guard Self.isPlausibleRumorTimestamp(rumorTimestamp) else {
SecureLogger.warning("Dropping Nostr DM with implausible rumor timestamp id=\(giftWrap.id.prefix(8))", category: .session)
return
}
if content.hasPrefix("verify:") {
return
}
@ -542,6 +554,22 @@ final class NostrInboundPipeline {
}
}
extension NostrInboundPipeline {
/// Client-side mirror of the relay-side `since` filter on DM
/// subscriptions: a relay that ignores `since` 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 randomized per NIP-17), so the plausible window is the
/// subscription lookback plus tolerated clock skew on both ends.
/// Internal (not private) so tests can pin the window directly.
static func isPlausibleRumorTimestamp(_ ts: Int, now: Date = Date()) -> Bool {
let age = now.timeIntervalSince1970 - TimeInterval(ts)
return age >= -TransportConfig.nostrDMMaxClockSkewSeconds
&& age <= TransportConfig.nostrDMSubscribeLookbackSeconds
+ TransportConfig.nostrDMMaxClockSkewSeconds
}
}
private extension NostrInboundPipeline {
@MainActor
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {

View File

@ -0,0 +1,32 @@
//
// NostrInboundPipelineTimestampTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Testing
@testable import bitchat
struct NostrInboundPipelineTimestampTests {
private let now = Date(timeIntervalSince1970: 1_700_000_000)
private let nowSeconds = 1_700_000_000
private let skew = Int(TransportConfig.nostrDMMaxClockSkewSeconds)
private let lookback = Int(TransportConfig.nostrDMSubscribeLookbackSeconds)
@Test("Rumor timestamps inside the lookback-plus-skew window are accepted")
func acceptsPlausibleTimestamps() {
#expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds, now: now))
#expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds - lookback + 60, now: now))
// A sender clock slightly ahead of the receiver is tolerated.
#expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds + skew - 60, now: now))
}
@Test("Future-dated and stale rumor timestamps are rejected")
func rejectsImplausibleTimestamps() {
#expect(!NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds + skew + 60, now: now))
#expect(!NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds - lookback - skew - 60, now: now))
}
}

View File

@ -39,6 +39,19 @@ final class VerificationServiceTests: XCTestCase {
XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60))
}
func test_verifyScannedQR_rejectsFutureDatedPayload() throws {
let (service, noise) = makeService()
let futureTimestamp = Int64(Date().addingTimeInterval(3600).timeIntervalSince1970)
let qrString = try makeSignedQR(
noise: noise,
nickname: "future-\(UUID().uuidString)",
npub: nil,
ts: futureTimestamp
)
XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60))
}
func test_verifyScannedQR_rejectsTamperedSignature() throws {
let (service, noise) = makeService()
let badSignature = Data(repeating: 0xAA, count: 64)