mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-11-01 07:53:26 +00:00
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:
parent
3257ced77e
commit
1905ea7cc0
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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] = []
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user