From 1905ea7cc0cbe07fa960226c6af8e8edca2170a8 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:47:14 +0200 Subject: [PATCH] Bound future timestamps on sync replies and in the gossip store (#1717) Sync replies (RSR) skipped the packet timestamp check, and nothing downstream bounded future timestamps: the gossip freshness checks, archive restore and the archived-echo key only handled the past. - BLEIngressPacketGuard: RSR packets may still be old, but not dated beyond now + skew (TransportConfig.bleMaxTimestampSkewMs, 120 s). - ChatPublicConversationCoordinator.archivedEchoKey is total for any Date instead of using a trapping UInt64 conversion. - GossipSyncManager.isPacketFresh rejects packets dated beyond the same skew, which gates every store, sync serving and archive restore; restore rewrites the archive when it drops entries. - BLEAnnounceHandler tracks only verified announces for sync. - LocationNotesManager clamps note created_at to now. - LocationNotesManager clamps future note timestamps to the board's clock-skew allowance (not to now), so bridged copies of board posts still dedupe in UnifiedNotices.merge. Co-Authored-By: Claude Opus 5.5 (1M context) --- bitchat/Services/BLE/BLEAnnounceHandler.swift | 10 +- .../Services/BLE/BLEIngressPacketGuard.swift | 14 +- bitchat/Services/BLE/BLEService.swift | 2 +- bitchat/Services/LocationNotesManager.swift | 9 +- bitchat/Services/TransportConfig.swift | 5 + bitchat/Sync/GossipSyncManager.swift | 22 ++- .../ChatPublicConversationCoordinator.swift | 10 +- ...cConversationCoordinatorContextTests.swift | 62 +++++++ bitchatTests/GossipSyncManagerTests.swift | 169 ++++++++++++++++++ bitchatTests/LocationNotesManagerTests.swift | 59 ++++++ .../Services/BLEAnnounceHandlerTests.swift | 11 +- .../Services/BLEIngressPacketGuardTests.swift | 57 +++++- 12 files changed, 415 insertions(+), 15 deletions(-) diff --git a/bitchat/Services/BLE/BLEAnnounceHandler.swift b/bitchat/Services/BLE/BLEAnnounceHandler.swift index 08b603a4..d09bbb4b 100644 --- a/bitchat/Services/BLE/BLEAnnounceHandler.swift +++ b/bitchat/Services/BLE/BLEAnnounceHandler.swift @@ -273,8 +273,14 @@ final class BLEAnnounceHandler { responsePlan.shouldNotifyPeerConnected && responsePlan.shouldScheduleInitialSync ) - // Track for sync (include our own and others' announces) - env.trackPacketSeen(packet) + // Track for sync — verified announces only (our own are tracked at + // send time). An unverified announce costs nothing to mint (any 32 + // random bytes satisfy the sender binding), so tracking it would let + // a nearby device fill the announce store and the sync filter with + // junk identities that every peer then re-serves. + if verifiedAnnounce { + env.trackPacketSeen(packet) + } if responsePlan.shouldSendAnnounceBack { // Reciprocate announce for bidirectional discovery diff --git a/bitchat/Services/BLE/BLEIngressPacketGuard.swift b/bitchat/Services/BLE/BLEIngressPacketGuard.swift index 4e4345f2..3081a5b3 100644 --- a/bitchat/Services/BLE/BLEIngressPacketGuard.swift +++ b/bitchat/Services/BLE/BLEIngressPacketGuard.swift @@ -16,7 +16,7 @@ enum BLEIngressPacketGuard { localPeerID: PeerID, directAnnounceTTL: UInt8, nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000), - maxTimestampSkewMs: UInt64 = 120_000, + maxTimestampSkewMs: UInt64 = TransportConfig.bleMaxTimestampSkewMs, isValidSyncResponse: (PeerID) -> Bool ) -> Result { let contextResult = BLEIngressLinkRegistry.packetContext( @@ -55,17 +55,25 @@ enum BLEIngressPacketGuard { _ packet: BitchatPacket, from peerID: PeerID, nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000), - maxTimestampSkewMs: UInt64 = 120_000, + maxTimestampSkewMs: UInt64 = TransportConfig.bleMaxTimestampSkewMs, isValidSyncResponse: (PeerID) -> Bool ) -> Result { + let packetTime = packet.timestamp if packet.isRSR { guard isValidSyncResponse(peerID) else { return .failure(.invalidRSR(peerID: peerID)) } + // Sync replies legitimately replay old packets, so only the past + // bound is waived. A far-future packet is never legitimate: it + // would outlive every gossip freshness window, drag requesters' + // since-cursors past all real history, and pin itself below live + // chat. + guard packetTime <= nowMs || packetTime - nowMs <= maxTimestampSkewMs else { + return .failure(.timestampSkew(peerID: peerID, skewMs: packetTime - nowMs, maxSkewMs: maxTimestampSkewMs)) + } return .success(()) } - let packetTime = packet.timestamp let skew = packetTime > nowMs ? packetTime - nowMs : nowMs - packetTime guard skew <= maxTimestampSkewMs else { return .failure(.timestampSkew(peerID: peerID, skewMs: skew, maxSkewMs: maxTimestampSkewMs)) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 5f9b97af..edd630e9 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -1943,7 +1943,7 @@ final class BLEService: NSObject { private func logValidRSR(from peerID: PeerID) { guard logRateLimiter.shouldLog(key: "valid-rsr:\(peerID.id)") else { return } - SecureLogger.debug("Valid RSR packet from \(peerID.id.prefix(8))… - skipping timestamp check", category: .security) + SecureLogger.debug("Valid RSR packet from \(peerID.id.prefix(8))… - skipping past-timestamp check", category: .security) } private func logSelfLoopback(packetType: UInt8, linkDescription: String) { diff --git a/bitchat/Services/LocationNotesManager.swift b/bitchat/Services/LocationNotesManager.swift index 4657a7b3..3e5b0cb5 100644 --- a/bitchat/Services/LocationNotesManager.swift +++ b/bitchat/Services/LocationNotesManager.swift @@ -267,7 +267,14 @@ final class LocationNotesManager: ObservableObject { if let expiresAt, expiresAt <= self.dependencies.now() { return } self.noteIDs.insert(event.id) let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first - let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + // created_at is author-chosen; clamp so a future-dated note can't + // hold the top of the newest-first list past the memory cap. The + // bound is the board's clock-skew allowance: a bridged copy of an + // accepted board post must keep its timestamp, or + // UnifiedNotices.merge no longer recognizes it as the same notice. + let latestAccepted = self.dependencies.now() + .addingTimeInterval(TimeInterval(BoardStore.Limits.clockSkewMs) / 1000) + let ts = min(Date(timeIntervalSince1970: TimeInterval(event.created_at)), latestAccepted) let urgent = event.tags.contains { $0.count >= 2 && $0[0].lowercased() == "t" && $0[1].lowercased() == "urgent" } let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick, geohash: matchedGeohash, expiresAt: expiresAt, isUrgent: urgent) self.notes.append(note) diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index b1e19701..764910da 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -9,6 +9,11 @@ enum TransportConfig { static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends + // Clock skew tolerated on received packet timestamps. Live packets must + // sit within ± this of local time; sync replies may be arbitrarily old + // but never further ahead than this, and the gossip store applies the + // same future bound so nothing future-dated is stored, served or restored. + static let bleMaxTimestampSkewMs: UInt64 = 120_000 // Bounded wait for the session-authenticated capability proof used by // private-media migration. Expiry never auto-sends clear bytes; it only // resolves to the existing one-shot consent or downgrade-blocked path. diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index eeb1ff97..01deee8a 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -89,6 +89,9 @@ final class GossipSyncManager { var prekeyBundleCapacity: Int = 200 var prekeyBundleSyncIntervalSeconds: TimeInterval = 60.0 var prekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60 + // Future bound for every store: matches the ingress skew so nothing + // the radio accepts is refused here. + var maxFutureSkewMs: UInt64 = TransportConfig.bleMaxTimestampSkewMs } private let myPeerID: PeerID @@ -224,6 +227,13 @@ final class GossipSyncManager { maxAgeSeconds = config.maxMessageAgeSeconds } let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + // Age windows only bound the past. A future-dated packet would never + // expire, sort ahead of everything in each GCS filter, and push the + // requester's since-cursor past all real history, so anything dated + // beyond the ingress skew is never stored, served or restored. + if packet.timestamp > nowMs, packet.timestamp - nowMs > config.maxFutureSkewMs { + return false + } let ageThresholdMs = UInt64(maxAgeSeconds * 1000) // If current time is less than threshold, accept all (handle clock issues gracefully) @@ -619,20 +629,28 @@ final class GossipSyncManager { // MARK: - Archive (public message persistence) /// Rebuild the public message store from disk on launch, dropping - /// anything that aged out while the app was dead. + /// anything that aged out while the app was dead — or that is dated in + /// the future, which an older build could have archived from a sync + /// reply. Any drop rewrites the file so it is purged from disk too. private func restoreArchivedMessages() { guard let archive else { return } var restored = 0 + var droppedAny = false for data in archive.load() { guard let packet = BitchatPacket.from(data), packet.type == MessageType.message.rawValue, - isPacketFresh(packet) else { continue } + isPacketFresh(packet) else { + droppedAny = true + continue + } let idHex = PacketIdUtil.computeId(packet).hexEncodedString() messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity)) restored += 1 } if restored > 0 { SecureLogger.debug("Restored \(restored) archived public message(s) for gossip sync", category: .sync) + } + if restored > 0 || droppedAny { archiveDirty = true } } diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index d12e2c1d..d69e9880 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -435,8 +435,14 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { } static func archivedEchoKey(senderPeerID: PeerID?, timestamp: Date, content: String) -> String { - let ms = UInt64((timestamp.timeIntervalSince1970 * 1000).rounded()) - return "\(senderPeerID?.id ?? "")|\(ms)|\(content)" + // The timestamp is peer-chosen: a wire value near UInt64.max comes + // back from Date as 2^64, so a trapping UInt64(_:) here would be a + // remote crash. Anything that isn't an exact UInt64 keys on its + // Double form instead — still deterministic, which is all the dedup + // needs. + let ms = (timestamp.timeIntervalSince1970 * 1000).rounded() + let msKey = UInt64(exactly: ms).map { String($0) } ?? "\(ms)" + return "\(senderPeerID?.id ?? "")|\(msKey)|\(content)" } func handlePublicMessage(_ message: BitchatMessage, powBits: Int = 0) { diff --git a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift index 8a9aec64..13424e0f 100644 --- a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift @@ -350,6 +350,68 @@ struct ChatPublicConversationCoordinatorContextTests { #expect(context.enqueuedMessages.first?.conversationID == .geohash(geohash)) } + /// A peer-chosen wire timestamp near UInt64.max comes back from Date as + /// 2^64 ms; keying an archived echo on it used to trap the main actor — + /// a remote crash on receipt, and on every launch once archived. + @Test @MainActor + func handlePublicMessage_uint64MaxTimestamp_neitherTrapsNorBreaksEchoDedup() async { + let context = MockChatPublicConversationContext() + let coordinator = ChatPublicConversationCoordinator(context: context) + let senderPeerID = PeerID(str: "aabbccddeeff0011") + // Same conversion as BLEPublicMessageHandler / the archive decoder. + let wireMax = Date(timeIntervalSince1970: Double(UInt64.max) / 1000) + + // Launch-time echo seeding path. + coordinator.registerArchivedEcho(senderPeerID: senderPeerID, timestamp: wireMax, content: "echoed") + // Live path: a copy of the seeded echo is still recognized... + coordinator.handlePublicMessage(BitchatMessage( + id: "live-copy", + sender: "alice", + content: "echoed", + timestamp: wireMax, + isRelay: false, + senderPeerID: senderPeerID + )) + #expect(context.enqueuedMessageIDs.isEmpty) + + // ...and a fresh message with the same timestamp is delivered. + coordinator.handlePublicMessage(BitchatMessage( + id: "live-new", + sender: "alice", + content: "new", + timestamp: wireMax, + isRelay: false, + senderPeerID: senderPeerID + )) + #expect(context.enqueuedMessageIDs == ["live-new"]) + } + + @Test @MainActor + func archivedEchoKey_isTotalForAnyDate() async { + let sender = PeerID(str: "aabbccddeeff0011") + func key(_ date: Date) -> String { + ChatPublicConversationCoordinator.archivedEchoKey(senderPeerID: sender, timestamp: date, content: "x") + } + + // Ordinary timestamps keep the integer-millisecond key. + #expect(key(Date(timeIntervalSince1970: 1_700_000_000.123)) == "aabbccddeeff0011|1700000000123|x") + + // Out-of-range dates key deterministically instead of trapping. + let extremes = [ + Date(timeIntervalSince1970: Double(UInt64.max) / 1000), + Date(timeIntervalSince1970: -1), + Date(timeIntervalSince1970: .infinity), + Date(timeIntervalSince1970: -.infinity), + Date(timeIntervalSince1970: .nan), + .distantFuture, + .distantPast + ] + for date in extremes { + #expect(key(date).hasPrefix("aabbccddeeff0011|")) + } + #expect(key(extremes[0]) != key(Date(timeIntervalSince1970: 1_700_000_000))) + } + /// `/clear` on the mesh timeline used to only record a watermark, leaving /// the archive on disk for up to its freshness window — so someone who /// cleared before a police stop had deleted nothing. It must now erase the diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index e0f3580c..7714c806 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -738,6 +738,121 @@ struct GossipSyncManagerTests { #expect(manager._hasPrekeyBundle(for: ownerPeer)) } + // MARK: - Future-dated packets + + /// Age windows used to bound only the past, so a sync reply dated 2099 + /// never expired and was re-served to every requester. + @Test func futureDatedPacketsAreNeitherStoredNorServed() async throws { + var config = GossipSyncManager.Config() + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + config.prekeyBundleSyncIntervalSeconds = 0 + + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager()) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let futureSender = try #require(Data(hexString: "f0f1f2f3f4f5f6f7")) + let honestSender = try #require(Data(hexString: "a0a1a2a3a4a5a6a7")) + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + let beyondSkewMs = nowMs + config.maxFutureSkewMs + 60_000 + let futureTypes: [MessageType] = [.announce, .message, .fragment, .fileTransfer, .groupMessage] + for (index, type) in futureTypes.enumerated() { + for timestamp in [beyondSkewMs, UInt64.max] { + manager.onPublicPacketSeen(BitchatPacket( + type: type.rawValue, + senderID: futureSender, + recipientID: nil, + timestamp: timestamp, + payload: Data([UInt8(index), 0xEE]), + signature: nil, + ttl: 1 + )) + } + } + let honest = BitchatPacket( + type: MessageType.message.rawValue, + senderID: honestSender, + recipientID: nil, + timestamp: nowMs, + payload: Data([0x01]), + signature: nil, + ttl: 1 + ) + manager.onPublicPacketSeen(honest) + + #expect(manager._hasAnnouncement(for: PeerID(hexData: futureSender)) == false) + #expect(manager._messageCount(for: PeerID(hexData: futureSender)) == 0) + + // An empty filter asks for everything the stores hold. + let request = RequestSyncPacket( + p: 7, + m: 1, + data: Data(), + types: SyncTypeFlags.publicMessages.union([.fragment, .fileTransfer, .groupMessage]) + ) + manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) + try await TestHelpers.waitFor({ !delegate.packets.isEmpty }, timeout: TestConstants.settleTimeout) + // Barrier: flush the sync queue so any late future-dated reply is visible. + manager._performMaintenanceSynchronously(now: Date()) + + #expect(delegate.packets.map(\.senderID) == [honestSender]) + } + + /// The sync cursor is the oldest timestamp the filter covers. Enough + /// future-dated junk used to fill the filter's newest slots and push the + /// cursor past every real message, so responders skipped all backfill. + @Test func futureDatedJunkCannotDragTheSinceCursor() throws { + var config = GossipSyncManager.Config() + config.seenCapacity = 100 + config.gcsMaxBytes = 32 // caps the filter at 28 IDs (256 bits / 9 bits per element) + config.messageSyncIntervalSeconds = 1 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + config.prekeyBundleSyncIntervalSeconds = 0 + config.maintenanceIntervalSeconds = 0 + + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager()) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let junkSender = try #require(Data(hexString: "f0f1f2f3f4f5f6f7")) + let honestSender = try #require(Data(hexString: "a0a1a2a3a4a5a6a7")) + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + // Unsigned group ciphertext needs no key to mint. + for i in 0..<40 { + manager.onPublicPacketSeen(BitchatPacket( + type: MessageType.groupMessage.rawValue, + senderID: junkSender, + recipientID: nil, + timestamp: nowMs + 3_600_000 + UInt64(i), + payload: Data([UInt8(i)]), + signature: nil, + ttl: 1 + )) + } + for i in 0..<5 { + manager.onPublicPacketSeen(BitchatPacket( + type: MessageType.message.rawValue, + senderID: honestSender, + recipientID: nil, + timestamp: nowMs - 60_000 + UInt64(i), + payload: Data([UInt8(i)]), + signature: nil, + ttl: 1 + )) + } + + manager._performMaintenanceSynchronously(now: Date()) + + let requests = delegate.packets.compactMap { RequestSyncPacket.decode(from: $0.payload) } + let messageRequest = try #require(requests.first { $0.types?.contains(.message) == true }) + // Only the five honest messages are candidates, so the filter covers + // them all and no cursor is needed. + #expect(messageRequest.sinceTimestamp == nil) + } + // MARK: - Archive persistence @Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws { @@ -810,6 +925,60 @@ struct GossipSyncManagerTests { #expect(manager._messageCount(for: PeerID(hexData: senderID)) == 0) } + /// An older build could archive a future-dated sync reply; restoring it + /// seeded a launch-time echo that crashed the app on every launch. The + /// restore must drop it and rewrite the file without it. + @Test func archiveRestoreDropsAndPurgesFutureDatedMessages() async throws { + let mixedURL = FileManager.default.temporaryDirectory + .appendingPathComponent("gossip-archive-\(UUID().uuidString).json") + let poisonOnlyURL = FileManager.default.temporaryDirectory + .appendingPathComponent("gossip-archive-\(UUID().uuidString).json") + defer { + try? FileManager.default.removeItem(at: mixedURL) + try? FileManager.default.removeItem(at: poisonOnlyURL) + } + + let poisonSender = try #require(Data(hexString: "f0f1f2f3f4f5f6f7")) + let honestSender = try #require(Data(hexString: "a0a1a2a3a4a5a6a7")) + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + func archived(from sender: Data, at timestamp: UInt64) throws -> Data { + try #require(BitchatPacket( + type: MessageType.message.rawValue, + senderID: sender, + recipientID: nil, + timestamp: timestamp, + payload: Data([0x01]), + signature: nil, + ttl: 1 + ).toBinaryData(padding: false)) + } + let poison = [try archived(from: poisonSender, at: .max), try archived(from: poisonSender, at: nowMs + 3_600_000)] + + // Mixed archive: only the honest entry is restored, offered as an + // echo, and written back. + let mixedArchive = GossipMessageArchive(fileURL: mixedURL) + mixedArchive.save(poison + [try archived(from: honestSender, at: nowMs)]) + let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: RequestSyncManager(), archive: mixedArchive) + // The restore is queued at init; this synchronous pass runs after it + // and persists the dirty store. + manager._performMaintenanceSynchronously(now: Date()) + #expect(manager._messageCount(for: PeerID(hexData: honestSender)) == 1) + #expect(manager._messageCount(for: PeerID(hexData: poisonSender)) == 0) + let echoes = await withCheckedContinuation { continuation in + manager.collectPublicMessagePackets { continuation.resume(returning: $0) } + } + #expect(echoes.map(\.senderID) == [honestSender]) + #expect(mixedArchive.load().compactMap { BitchatPacket.from($0)?.senderID } == [honestSender]) + + // Poison-only archive: nothing restores, and the file is purged. + let poisonOnlyArchive = GossipMessageArchive(fileURL: poisonOnlyURL) + poisonOnlyArchive.save(poison) + let purging = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: RequestSyncManager(), archive: poisonOnlyArchive) + purging._performMaintenanceSynchronously(now: Date()) + #expect(purging._messageCount(for: PeerID(hexData: poisonSender)) == 0) + #expect(!FileManager.default.fileExists(atPath: poisonOnlyURL.path)) + } + /// Clearing the mesh timeline must leave nothing behind on disk: a /// relaunch that restored the archive would undo the clear. @Test func removeAllPublicMessagesErasesTheArchiveOnDisk() async throws { diff --git a/bitchatTests/LocationNotesManagerTests.swift b/bitchatTests/LocationNotesManagerTests.swift index abd16e3d..6b9a9d83 100644 --- a/bitchatTests/LocationNotesManagerTests.swift +++ b/bitchatTests/LocationNotesManagerTests.swift @@ -236,6 +236,65 @@ struct LocationNotesManagerTests { #expect(manager.notes.first?.expiresAt == Date(timeIntervalSince1970: TimeInterval(Int(now.timeIntervalSince1970) + 3600))) } + /// created_at is author-chosen: a note dated 2100 used to sort above + /// every real note forever and survive the newest-first memory cap. + @Test + func ingestClampsFutureDatedNotesToTheBoardSkew() throws { + var storedHandler: ((NostrEvent) -> Void)? + let start = Date(timeIntervalSince1970: 1_700_000_000) + var currentNow = start + let deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, _, _, handler, _ in + storedHandler = handler + }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in throw TestError.shouldNotDerive }, + now: { currentNow } + ) + + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) + let identity = try NostrIdentity.generate() + let skew = TimeInterval(BoardStore.Limits.clockSkewMs) / 1000 + + // Within the board's skew allowance the author's timestamp is kept, + // so a bridged copy of a board post still dedupes against the post. + let slightlyAhead = start.addingTimeInterval(30 * 60) + let bridged = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: slightlyAhead, + kind: .textNote, + tags: [["g", "u4pruydq"]], + content: "bridged" + ) + storedHandler?(try bridged.sign(with: identity.schnorrSigningKey())) + #expect(manager.notes.first(where: { $0.content == "bridged" })?.createdAt == slightlyAhead) + + // Far-future timestamps are clamped to the edge of that allowance. + let futureDated = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(timeIntervalSince1970: 4_102_444_800), // 2100-01-01 + kind: .textNote, + tags: [["g", "u4pruydq"]], + content: "pinned" + ) + storedHandler?(try futureDated.sign(with: identity.schnorrSigningKey())) + #expect(manager.notes.first(where: { $0.content == "pinned" })?.createdAt == start.addingTimeInterval(skew)) + + // Once local time passes that edge, newer notes sort above it. + currentNow = start.addingTimeInterval(skew + 120) + let later = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: start.addingTimeInterval(skew + 60), + kind: .textNote, + tags: [["g", "u4pruydq"]], + content: "later" + ) + storedHandler?(try later.sign(with: identity.schnorrSigningKey())) + #expect(manager.notes.map(\.content) == ["later", "pinned", "bridged"]) + } + @Test func postDrop_sendsExpiringNoteToGeoRelays() throws { var sentEvents: [NostrEvent] = [] diff --git a/bitchatTests/Services/BLEAnnounceHandlerTests.swift b/bitchatTests/Services/BLEAnnounceHandlerTests.swift index d1589303..8e3f533d 100644 --- a/bitchatTests/Services/BLEAnnounceHandlerTests.swift +++ b/bitchatTests/Services/BLEAnnounceHandlerTests.swift @@ -186,8 +186,8 @@ struct BLEAnnounceHandlerTests { #expect(recorder.upsertCalls.isEmpty) #expect(recorder.topologyUpdates.isEmpty) #expect(recorder.afterglowDelays.isEmpty) - // Original behavior: list refresh, identity persistence, sync tracking - // and announce-back still occur for unverified announces. + // Original behavior: list refresh and announce-back still occur for + // unverified announces. #expect(recorder.uiEventDeliveries.count == 1) #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) #expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == false) @@ -195,7 +195,9 @@ struct BLEAnnounceHandlerTests { // persisting would let an attacker who replays a victim's noisePublicKey // overwrite the victim's stored signing key/nickname (identity poisoning). #expect(recorder.persistedIdentities.isEmpty) - #expect(recorder.trackedPackets.count == 1) + // Nor gossip tracking: an unsigned announce is free to mint, so + // tracking it let junk identities fill the sync store and filter. + #expect(recorder.trackedPackets.isEmpty) #expect(recorder.announceBacks == 1) } @@ -222,6 +224,7 @@ struct BLEAnnounceHandlerTests { #expect(recorder.upsertCalls.isEmpty) #expect(recorder.uiEventDeliveries.count == 1) #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) + #expect(recorder.trackedPackets.isEmpty) } @Test @@ -542,6 +545,8 @@ struct BLEAnnounceHandlerTests { #expect(recorder.topologyUpdates.isEmpty) #expect(recorder.uiEventDeliveries.count == 1) #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) + // The impersonation announce is not carried for sync either. + #expect(recorder.trackedPackets.isEmpty) } @Test diff --git a/bitchatTests/Services/BLEIngressPacketGuardTests.swift b/bitchatTests/Services/BLEIngressPacketGuardTests.swift index 0d07ec1b..37112a3d 100644 --- a/bitchatTests/Services/BLEIngressPacketGuardTests.swift +++ b/bitchatTests/Services/BLEIngressPacketGuardTests.swift @@ -119,7 +119,7 @@ struct BLEIngressPacketGuardTests { ))) } - @Test("valid RSR packets use bound peer validation and bypass timestamp skew") + @Test("valid RSR packets use bound peer validation and bypass the past timestamp bound") func validRSRUsesBoundPeerAndBypassesTimestamp() throws { let local = PeerID(str: "0011223344556677") let bound = PeerID(str: "1122334455667788") @@ -141,6 +141,61 @@ struct BLEIngressPacketGuardTests { #expect(context.validationPeerID == bound) } + @Test("valid RSR packets dated beyond the skew window are rejected") + func futureDatedRSRIsRejected() throws { + // Sync replies may replay old packets but never future-dated ones: + // UInt64.max used to reach the UI (and trap) or pin the gossip store. + let local = PeerID(str: "0011223344556677") + let bound = PeerID(str: "1122334455667788") + let sender = PeerID(str: "8899aabbccddeeff") + let nowMs: UInt64 = 1_000_000 + let skewMs: UInt64 = 120_000 + + func evaluateRSR(timestamp: UInt64) -> Result { + var packet = makePacket(sender: sender, timestamp: timestamp) + packet.isRSR = true + return BLEIngressPacketGuard.evaluate( + packet: packet, + claimedSenderID: sender, + boundPeerID: bound, + localPeerID: local, + directAnnounceTTL: 7, + nowMs: nowMs, + maxTimestampSkewMs: skewMs, + isValidSyncResponse: { $0 == bound } + ) + } + + #expect(evaluateRSR(timestamp: .max) == .failure(.timestampSkew( + peerID: bound, + skewMs: .max - nowMs, + maxSkewMs: skewMs + ))) + #expect(evaluateRSR(timestamp: nowMs + skewMs + 1) == .failure(.timestampSkew( + peerID: bound, + skewMs: skewMs + 1, + maxSkewMs: skewMs + ))) + // The edge of the window is still tolerated clock skew. + let edgeContext = try #require(success(evaluateRSR(timestamp: nowMs + skewMs))) + #expect(edgeContext.validationPeerID == bound) + + // Reassembled inner packets are checked through validatePayload alone. + var innerPacket = makePacket(sender: sender, timestamp: .max) + innerPacket.isRSR = true + let innerResult = BLEIngressPacketGuard.validatePayload( + innerPacket, + from: sender, + nowMs: nowMs, + maxTimestampSkewMs: skewMs, + isValidSyncResponse: { _ in true } + ) + guard case .failure(.timestampSkew) = innerResult else { + Issue.record("Expected a future-dated reassembled RSR packet to be rejected, got \(innerResult)") + return + } + } + @Test("invalid RSR packets are rejected") func invalidRSRIsRejected() { let local = PeerID(str: "0011223344556677")