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) <noreply@anthropic.com>
This commit is contained in:
jack 2026-09-23 19:47:14 +02:00 committed by GitHub
parent 3257ced77e
commit 1905ea7cc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 415 additions and 15 deletions

View File

@ -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

View File

@ -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<BLEIngressPacketContext, Rejection> {
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<Void, Rejection> {
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))

View File

@ -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) {

View File

@ -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)

View File

@ -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.

View File

@ -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
}
}

View File

@ -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) {

View File

@ -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

View File

@ -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 {

View File

@ -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] = []

View File

@ -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

View File

@ -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<BLEIngressPacketContext, BLEIngressPacketGuard.Rejection> {
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")