mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-22 07:16:03 +00:00
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:
parent
2a9fb4d53f
commit
6f961638f2
@ -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
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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? {
|
||||
|
||||
32
bitchatTests/NostrInboundPipelineTimestampTests.swift
Normal file
32
bitchatTests/NostrInboundPipelineTimestampTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user