From 6f961638f2eec16e4e3ab90fd7ef3fbf571686f8 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:39:00 +0200 Subject: [PATCH] Timestamp sanity windows: future-dated QR codes and implausible Nostr DM rumors (#1647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Claude Fable 5 --- bitchat/Services/TransportConfig.swift | 4 +++ bitchat/Services/VerificationService.swift | 5 +-- bitchat/ViewModels/NostrInboundPipeline.swift | 28 ++++++++++++++++ .../NostrInboundPipelineTimestampTests.swift | 32 +++++++++++++++++++ .../Services/VerificationServiceTests.swift | 13 ++++++++ 5 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 bitchatTests/NostrInboundPipelineTimestampTests.swift diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 98029094..b1e19701 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -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 diff --git a/bitchat/Services/VerificationService.swift b/bitchat/Services/VerificationService.swift index 8aa46ed1..26247379 100644 --- a/bitchat/Services/VerificationService.swift +++ b/bitchat/Services/VerificationService.swift @@ -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 } diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index 85024436..a6592802 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -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? { diff --git a/bitchatTests/NostrInboundPipelineTimestampTests.swift b/bitchatTests/NostrInboundPipelineTimestampTests.swift new file mode 100644 index 00000000..187133be --- /dev/null +++ b/bitchatTests/NostrInboundPipelineTimestampTests.swift @@ -0,0 +1,32 @@ +// +// NostrInboundPipelineTimestampTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +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)) + } +} diff --git a/bitchatTests/Services/VerificationServiceTests.swift b/bitchatTests/Services/VerificationServiceTests.swift index b8f688d3..ce854a58 100644 --- a/bitchatTests/Services/VerificationServiceTests.swift +++ b/bitchatTests/Services/VerificationServiceTests.swift @@ -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)