diff --git a/bitchat/Services/BLE/BLEIngressPacketGuard.swift b/bitchat/Services/BLE/BLEIngressPacketGuard.swift new file mode 100644 index 00000000..4e4345f2 --- /dev/null +++ b/bitchat/Services/BLE/BLEIngressPacketGuard.swift @@ -0,0 +1,76 @@ +import BitFoundation +import Foundation + +enum BLEIngressPacketGuard { + enum Rejection: Error, Equatable { + case selfLoopback(packetType: UInt8) + case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID) + case invalidRSR(peerID: PeerID) + case timestampSkew(peerID: PeerID, skewMs: UInt64, maxSkewMs: UInt64) + } + + static func evaluate( + packet: BitchatPacket, + claimedSenderID: PeerID, + boundPeerID: PeerID?, + localPeerID: PeerID, + directAnnounceTTL: UInt8, + nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000), + maxTimestampSkewMs: UInt64 = 120_000, + isValidSyncResponse: (PeerID) -> Bool + ) -> Result { + let contextResult = BLEIngressLinkRegistry.packetContext( + for: packet, + claimedSenderID: claimedSenderID, + boundPeerID: boundPeerID, + localPeerID: localPeerID, + directAnnounceTTL: directAnnounceTTL + ) + + let context: BLEIngressPacketContext + switch contextResult { + case .success(let acceptedContext): + context = acceptedContext + case .failure(.selfLoopback(let packetType)): + return .failure(.selfLoopback(packetType: packetType)) + case .failure(.directSenderMismatch(let boundPeerID, let claimedSenderID)): + return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID)) + } + + switch validatePayload( + packet, + from: context.validationPeerID, + nowMs: nowMs, + maxTimestampSkewMs: maxTimestampSkewMs, + isValidSyncResponse: isValidSyncResponse + ) { + case .success: + return .success(context) + case .failure(let rejection): + return .failure(rejection) + } + } + + static func validatePayload( + _ packet: BitchatPacket, + from peerID: PeerID, + nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000), + maxTimestampSkewMs: UInt64 = 120_000, + isValidSyncResponse: (PeerID) -> Bool + ) -> Result { + if packet.isRSR { + guard isValidSyncResponse(peerID) else { + return .failure(.invalidRSR(peerID: peerID)) + } + 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)) + } + + return .success(()) + } +} diff --git a/bitchat/Services/BLE/BLELogRateLimiter.swift b/bitchat/Services/BLE/BLELogRateLimiter.swift new file mode 100644 index 00000000..65892970 --- /dev/null +++ b/bitchat/Services/BLE/BLELogRateLimiter.swift @@ -0,0 +1,33 @@ +import Foundation + +final class BLELogRateLimiter { + private let defaultMinimumInterval: TimeInterval + private let queue = DispatchQueue(label: "chat.bitchat.ble.log-rate-limiter") + private var lastLogTimeByKey: [String: Date] = [:] + + init(defaultMinimumInterval: TimeInterval) { + self.defaultMinimumInterval = defaultMinimumInterval + } + + func shouldLog( + key: String, + now: Date = Date(), + minimumInterval: TimeInterval? = nil + ) -> Bool { + queue.sync { + let interval = minimumInterval ?? defaultMinimumInterval + if let lastLogTime = lastLogTimeByKey[key], + now.timeIntervalSince(lastLogTime) < interval { + return false + } + lastLogTimeByKey[key] = now + return true + } + } + + func removeAll() { + queue.sync { + lastLogTimeByKey.removeAll() + } + } +} diff --git a/bitchat/Services/BLE/BLEOutboundFragmentPlanner.swift b/bitchat/Services/BLE/BLEOutboundFragmentPlanner.swift new file mode 100644 index 00000000..9623c204 --- /dev/null +++ b/bitchat/Services/BLE/BLEOutboundFragmentPlanner.swift @@ -0,0 +1,137 @@ +import BitFoundation +import Foundation + +struct BLEOutboundFragmentPlan { + let fragmentPackets: [BitchatPacket] + let fragmentVersion: UInt8 + let chunkSize: Int + let spacingMs: Int + + var totalFragments: Int { + fragmentPackets.count + } + + var shouldPauseScanning: Bool { + totalFragments > 4 + } +} + +enum BLEOutboundFragmentPlanner { + private static let minimumChunkSize = 64 + private static let fragmentIDLength = 8 + + static func makePlan( + for request: BLEOutboundFragmentTransferRequest, + defaultChunkSize: Int, + bleMaxMTU: Int, + fragmentID: Data = randomFragmentID() + ) -> BLEOutboundFragmentPlan? { + guard fragmentID.count == fragmentIDLength, + let fullData = request.packet.toBinaryData(padding: request.pad) else { + return nil + } + + let sizing = sizingPolicy( + for: request.packet, + requestedMaxChunk: request.maxChunk, + defaultChunkSize: defaultChunkSize, + bleMaxMTU: bleMaxMTU + ) + + let chunks = stride(from: 0, to: fullData.count, by: sizing.chunkSize).map { offset in + Data(fullData[offset.. (fragmentVersion: UInt8, chunkSize: Int) { + var fragmentVersion: UInt8 = 1 + var calculatedChunk = defaultChunkSize + + if let route = packet.route, !route.isEmpty { + fragmentVersion = 2 + let routeSize = 1 + (route.count * 8) + let overhead = 16 + 8 + 8 + routeSize + 13 + 16 + calculatedChunk = max(minimumChunkSize, bleMaxMTU - overhead) + } + + return ( + fragmentVersion: fragmentVersion, + chunkSize: max(minimumChunkSize, requestedMaxChunk ?? calculatedChunk) + ) + } + + private static func makeFragmentPacket( + original packet: BitchatPacket, + fragmentID: Data, + index: Int, + total: Int, + fragmentData: Data, + fragmentRecipient: Data?, + fragmentVersion: UInt8 + ) -> BitchatPacket { + var payload = Data() + payload.append(fragmentID) + payload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) }) + payload.append(contentsOf: withUnsafeBytes(of: UInt16(total).bigEndian) { Data($0) }) + payload.append(packet.type) + payload.append(fragmentData) + + return BitchatPacket( + type: MessageType.fragment.rawValue, + senderID: packet.senderID, + recipientID: fragmentRecipient, + timestamp: packet.timestamp, + payload: payload, + signature: nil, + ttl: packet.ttl, + version: fragmentVersion, + route: packet.route, + isRSR: packet.isRSR + ) + } + + private static func spacingMs(for request: BLEOutboundFragmentTransferRequest) -> Int { + if request.directedPeer != nil || request.packet.recipientID != nil { + return TransportConfig.bleFragmentSpacingDirectedMs + } + + return TransportConfig.bleFragmentSpacingMs + } + + private static func randomFragmentID() -> Data { + Data((0.. BLEPeerInfo? { + peers[peerID] + } + + mutating func upsert(_ info: BLEPeerInfo) { + peers[info.peerID] = info + } + + @discardableResult + mutating func remove(_ peerID: PeerID) -> BLEPeerInfo? { + peers.removeValue(forKey: peerID) + } + + func isConnected(_ peerID: PeerID) -> Bool { + peers[peerID.toShort()]?.isConnected ?? false + } + + func isReachable(_ peerID: PeerID, now: Date) -> Bool { + let shortID = peerID.toShort() + let meshAttached = connectedCount > 0 + guard let info = peers[shortID] else { return false } + if info.isConnected { return true } + guard meshAttached else { return false } + + let retention: TimeInterval = info.isVerifiedNickname + ? TransportConfig.bleReachabilityRetentionVerifiedSeconds + : TransportConfig.bleReachabilityRetentionUnverifiedSeconds + return now.timeIntervalSince(info.lastSeen) <= retention + } + + func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? { + guard let peer = peers[peerID] else { return nil } + if connectedOnly && !peer.isConnected { return nil } + return peer.nickname + } + + func fingerprint(for peerID: PeerID) -> String? { + peers[peerID]?.noisePublicKey?.sha256Fingerprint() + } + + func displayNicknames(selfNickname: String) -> [PeerID: String] { + let connected = peers.filter { $0.value.isConnected } + let tuples = connected.map { ($0.key, $0.value.nickname, true) } + return PeerDisplayNameResolver.resolve(tuples, selfNickname: selfNickname) + } + + func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] { + let snapshot = Array(peers.values) + let resolvedNames = PeerDisplayNameResolver.resolve( + snapshot.map { ($0.peerID, $0.nickname, $0.isConnected) }, + selfNickname: selfNickname + ) + return snapshot.map { info in + TransportPeerSnapshot( + peerID: info.peerID, + nickname: resolvedNames[info.peerID] ?? info.nickname, + isConnected: info.isConnected, + noisePublicKey: info.noisePublicKey, + lastSeen: info.lastSeen + ) + } + } + + func collisionResolvedNickname(for peerID: PeerID, selfNickname: String) -> String? { + guard let info = peers[peerID], info.isVerifiedNickname else { return nil } + let hasCollision = peers.values.contains { + $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID + } || selfNickname == info.nickname + return hasCollision ? info.nickname + "#" + String(peerID.id.prefix(4)) : info.nickname + } + + mutating func markDisconnected(_ peerID: PeerID) { + guard var info = peers[peerID] else { return } + info.isConnected = false + peers[peerID] = info + } + + mutating func updateLastSeen(_ peerID: PeerID, at date: Date) { + guard var peer = peers[peerID] else { return } + peer.lastSeen = date + peers[peerID] = peer + } + + mutating func upsertVerifiedAnnounce( + peerID: PeerID, + nickname: String, + noisePublicKey: Data, + signingPublicKey: Data?, + isConnected: Bool, + now: Date + ) -> BLEPeerAnnounceUpdate { + let existing = peers[peerID] + let update = BLEPeerAnnounceUpdate( + isNewPeer: existing == nil, + wasDisconnected: existing?.isConnected == false, + previousNickname: existing?.nickname + ) + + peers[peerID] = BLEPeerInfo( + peerID: existing?.peerID ?? peerID, + nickname: nickname, + isConnected: isConnected, + noisePublicKey: noisePublicKey, + signingPublicKey: signingPublicKey, + isVerifiedNickname: true, + lastSeen: now + ) + + return update + } + + mutating func reconcileConnectivity( + now: Date, + linkStates: [PeerID: BLEPeerLinkPresence] + ) -> BLEPeerConnectivityChanges { + var changes = BLEPeerConnectivityChanges() + + for (peerID, peer) in Array(peers) { + let age = now.timeIntervalSince(peer.lastSeen) + let retention: TimeInterval = peer.isVerifiedNickname + ? TransportConfig.bleReachabilityRetentionVerifiedSeconds + : TransportConfig.bleReachabilityRetentionUnverifiedSeconds + + if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds { + let state = linkStates[peerID] ?? BLEPeerLinkPresence(hasPeripheral: false, hasCentral: false) + if !state.hasPeripheral && !state.hasCentral { + var updated = peer + updated.isConnected = false + peers[peerID] = updated + changes.disconnectedPeerIDs.append(peerID) + } + } + + if !peer.isConnected && age > retention { + peers.removeValue(forKey: peerID) + changes.removedPeers.append(BLERemovedPeer(peerID: peerID, nickname: peer.nickname)) + } + } + + return changes + } +} diff --git a/bitchat/Services/BLE/BLEPeerSenderDisplayName.swift b/bitchat/Services/BLE/BLEPeerSenderDisplayName.swift new file mode 100644 index 00000000..c17d8d7e --- /dev/null +++ b/bitchat/Services/BLE/BLEPeerSenderDisplayName.swift @@ -0,0 +1,60 @@ +import BitFoundation +import Foundation + +enum BLEPeerSenderDisplayName { + static func resolveKnownPeer( + peerID: PeerID, + localPeerID: PeerID, + localNickname: String, + peers: [PeerID: BLEPeerInfo], + allowConnectedUnverified: Bool + ) -> String? { + if peerID == localPeerID { + return localNickname + } + + guard let info = peers[peerID] else { return nil } + + if info.isVerifiedNickname { + return collisionResolvedName( + displayName: info.nickname, + collisionNickname: info.nickname, + peerID: peerID, + localNickname: localNickname, + peers: peers + ) + } + + if allowConnectedUnverified, info.isConnected { + let displayName = info.nickname.isEmpty ? anonymousNickname(for: peerID) : info.nickname + return collisionResolvedName( + displayName: displayName, + collisionNickname: info.nickname, + peerID: peerID, + localNickname: localNickname, + peers: peers + ) + } + + return nil + } + + static func anonymousNickname(for peerID: PeerID) -> String { + "anon" + String(peerID.id.prefix(4)) + } + + private static func collisionResolvedName( + displayName: String, + collisionNickname: String, + peerID: PeerID, + localNickname: String, + peers: [PeerID: BLEPeerInfo] + ) -> String { + let hasCollision = peers.values.contains { + $0.isConnected && $0.nickname == collisionNickname && $0.peerID != peerID + } || localNickname == collisionNickname + + guard hasCollision else { return displayName } + return displayName + "#" + String(peerID.id.prefix(4)) + } +} diff --git a/bitchat/Services/BLE/BLEReceivePipeline.swift b/bitchat/Services/BLE/BLEReceivePipeline.swift new file mode 100644 index 00000000..d8b46119 --- /dev/null +++ b/bitchat/Services/BLE/BLEReceivePipeline.swift @@ -0,0 +1,84 @@ +import BitFoundation +import Foundation + +struct BLEReceivedPacketContext: Equatable { + let senderID: PeerID + let messageID: String + let messageType: MessageType? + let shouldDeduplicate: Bool + let logsHandlingDetails: Bool +} + +struct BLEReceivePipeline { + static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext { + let senderID = PeerID(hexData: packet.senderID) + let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)" + let messageType = MessageType(rawValue: packet.type) + let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID + let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay + + return BLEReceivedPacketContext( + senderID: senderID, + messageID: messageID, + messageType: messageType, + shouldDeduplicate: shouldDeduplicate, + logsHandlingDetails: messageType != .announce + ) + } + + static func shouldCancelScheduledRelayForDuplicate(connectedPeerCount: Int) -> Bool { + connectedPeerCount > 2 + } + + static func relayDecision( + for packet: BitchatPacket, + senderID: PeerID, + localPeerID: PeerID, + degree: Int, + highDegreeThreshold: Int + ) -> RelayDecision { + RelayController.decide( + ttl: packet.ttl, + senderIsSelf: senderID == localPeerID, + recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID, + isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue, + isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil, + isFragment: packet.type == MessageType.fragment.rawValue, + isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, + isHandshake: packet.type == MessageType.noiseHandshake.rawValue, + isAnnounce: packet.type == MessageType.announce.rawValue, + degree: degree, + highDegreeThreshold: highDegreeThreshold + ) + } +} + +struct BLERecentTrafficTracker: Equatable { + private var packetTimestamps: [Date] = [] + + var count: Int { + packetTimestamps.count + } + + mutating func removeAll() { + packetTimestamps.removeAll() + } + + mutating func recordPacket(at now: Date) { + packetTimestamps.append(now) + prune(at: now) + } + + func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool { + let cutoff = now.addingTimeInterval(-seconds) + return packetTimestamps.contains { $0 >= cutoff } + } + + private mutating func prune(at now: Date) { + let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds) + if packetTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount { + packetTimestamps.removeFirst(packetTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount) + } + packetTimestamps.removeAll { $0 < cutoff } + } +} diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 5460a63d..5c79448a 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -38,29 +38,11 @@ final class BLEService: NSObject { // 1. Consolidated BLE link tracking for both central and peripheral roles. private var linkStateStore = BLELinkStateStore() - // BCH-01-004: Rate-limiting for subscription-triggered announces - // Tracks subscription attempts per central to prevent enumeration attacks - private struct SubscriptionRateLimitState { - var lastAnnounceTime: Date - var attemptCount: Int - var currentBackoffSeconds: TimeInterval - } - private var centralSubscriptionRateLimits: [String: SubscriptionRateLimitState] = [:] // Central UUID -> rate limit state + // BCH-01-004: Rate-limiting for subscription-triggered announces. + private var subscriptionAnnounceLimiter = BLESubscriptionAnnounceLimiter() // 3. Peer Information (single source of truth) - private struct PeerInfo { - let peerID: PeerID - var nickname: String - var isConnected: Bool - var noisePublicKey: Data? - var signingPublicKey: Data? - var isVerifiedNickname: Bool - var lastSeen: Date - } - private var peers: [PeerID: PeerInfo] = [:] - private var currentPeerIDs: [PeerID] { - Array(peers.keys) - } + private var peerRegistry = BLEPeerRegistry() // 4. Efficient Message Deduplication private let messageDeduplicator = MessageDeduplicator() @@ -116,10 +98,11 @@ final class BLEService: NSObject { // Relay jitter scheduling to reduce redundant floods private var scheduledRelays: [String: DispatchWorkItem] = [:] // Track short-lived traffic bursts to adapt announces/scanning under load - private var recentPacketTimestamps: [Date] = [] + private var recentTrafficTracker = BLERecentTrafficTracker() // Ingress link tracking for duplicate and last-hop suppression private var ingressLinks = BLEIngressLinkRegistry() + private let logRateLimiter = BLELogRateLimiter(defaultMinimumInterval: 5) private var pendingPeripheralWrites = BLEOutboundWriteBuffer() // Debounce duplicate disconnect notifies @@ -306,7 +289,7 @@ final class BLEService: NSObject { fragmentAssemblyBuffer.removeAll() pendingDirectedRelays.removeAll() ingressLinks.removeAll() - recentPacketTimestamps.removeAll() + recentTrafficTracker.removeAll() scheduledRelays.values.forEach { $0.cancel() } scheduledRelays.removeAll() return transfers @@ -409,20 +392,7 @@ final class BLEService: NSObject { func currentPeerSnapshots() -> [TransportPeerSnapshot] { collectionsQueue.sync { - let snapshot = Array(peers.values) - let resolvedNames = PeerDisplayNameResolver.resolve( - snapshot.map { ($0.peerID, $0.nickname, $0.isConnected) }, - selfNickname: myNickname - ) - return snapshot.map { info in - TransportPeerSnapshot( - peerID: info.peerID, - nickname: resolvedNames[info.peerID] ?? info.nickname, - isConnected: info.isConnected, - noisePublicKey: info.noisePublicKey, - lastSeen: info.lastSeen - ) - } + peerRegistry.transportSnapshots(selfNickname: myNickname) } } @@ -526,7 +496,7 @@ final class BLEService: NSObject { // Clear all sessions and peers let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) { let entries = outboundFragmentTransfers.removeAll().map { ($0.id, $0.workItems) } - peers.removeAll() + peerRegistry.removeAll() fragmentAssemblyBuffer.removeAll() // Also clear pending message queues to avoid stale state across sessions pendingNoiseSessionQueues.removeAll() @@ -546,7 +516,7 @@ final class BLEService: NSObject { bleQueue.sync { linkStateStore.clearAll() connectionScheduler.reset() - centralSubscriptionRateLimits.removeAll() + subscriptionAnnounceLimiter.removeAll() } meshTopology.reset() } @@ -555,38 +525,25 @@ final class BLEService: NSObject { func isPeerConnected(_ peerID: PeerID) -> Bool { // Accept both 16-hex short IDs and 64-hex Noise keys - let shortID = peerID.toShort() - return collectionsQueue.sync { peers[shortID]?.isConnected ?? false } + return collectionsQueue.sync { peerRegistry.isConnected(peerID) } } func isPeerReachable(_ peerID: PeerID) -> Bool { // Accept both 16-hex short IDs and 64-hex Noise keys - let shortID = peerID.toShort() return collectionsQueue.sync { - // Must be mesh-attached: at least one live direct link to the mesh - let meshAttached = peers.values.contains { $0.isConnected } - guard let info = peers[shortID] else { return false } - if info.isConnected { return true } - guard meshAttached else { return false } - // Apply reachability retention window - let isVerified = info.isVerifiedNickname - let retention: TimeInterval = isVerified ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds - return Date().timeIntervalSince(info.lastSeen) <= retention + peerRegistry.isReachable(peerID, now: Date()) } } func peerNickname(peerID: PeerID) -> String? { collectionsQueue.sync { - guard let peer = peers[peerID], peer.isConnected else { return nil } - return peer.nickname + peerRegistry.nickname(for: peerID, connectedOnly: true) } } func getPeerNicknames() -> [PeerID: String] { return collectionsQueue.sync { - let connected = peers.filter { $0.value.isConnected } - let tuples = connected.map { ($0.key, $0.value.nickname, true) } - return PeerDisplayNameResolver.resolve(tuples, selfNickname: myNickname) + peerRegistry.displayNicknames(selfNickname: myNickname) } } @@ -594,7 +551,7 @@ final class BLEService: NSObject { func getFingerprint(for peerID: PeerID) -> String? { return collectionsQueue.sync { - return peers[peerID]?.noisePublicKey?.sha256Fingerprint() + peerRegistry.fingerprint(for: peerID) } } @@ -751,64 +708,77 @@ final class BLEService: NSObject { } } - private enum ConnectionSource { - case peripheral(String) - case central(String) - case unknown - } - - private func makeIngressPacketContext( + private func acceptedIngressContext( for packet: BitchatPacket, claimedSenderID: PeerID, boundPeerID: PeerID?, linkDescription: String ) -> BLEIngressPacketContext? { - switch BLEIngressLinkRegistry.packetContext( - for: packet, + switch BLEIngressPacketGuard.evaluate( + packet: packet, claimedSenderID: claimedSenderID, boundPeerID: boundPeerID, localPeerID: myPeerID, - directAnnounceTTL: messageTTL + directAnnounceTTL: messageTTL, + isValidSyncResponse: { [requestSyncManager] peerID in + requestSyncManager.isValidResponse(from: peerID, isRSR: true) + } ) { case .success(let context): + if packet.isRSR { + logValidRSR(from: context.validationPeerID) + } return context case .failure(.selfLoopback): - SecureLogger.debug("↩️ Dropping BLE self-loopback packet type \(packet.type) from \(linkDescription)", category: .session) + logSelfLoopback(packetType: packet.type, linkDescription: linkDescription) return nil case .failure(.directSenderMismatch(let boundPeerID, let claimedSenderID)): SecureLogger.warning("🚫 SECURITY: Sender ID spoofing attempt detected! \(linkDescription) claimed to be \(claimedSenderID.id.prefix(8))… but is bound to \(boundPeerID.id.prefix(8))…", category: .security) return nil + case .failure(.invalidRSR(let peerID)): + SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security) + return nil + case .failure(.timestampSkew(let peerID, let skewMs, let maxSkewMs)): + SecureLogger.warning("Packet timestamp skewed by \(skewMs)ms (max \(maxSkewMs)ms) from \(peerID.id.prefix(8))…", category: .security) + return nil } } - private func validatePacket(_ packet: BitchatPacket, from peerID: PeerID, connectionSource: ConnectionSource = .unknown) -> Bool { - let currentTime = UInt64(Date().timeIntervalSince1970 * 1000) - - let isRSR = packet.isRSR - var skipTimestampCheck = false - - if isRSR { - if requestSyncManager.isValidResponse(from: peerID, isRSR: true) { - SecureLogger.debug("Valid RSR packet from \(peerID.id.prefix(8))… - skipping timestamp check", category: .security) - skipTimestampCheck = true - } else { - SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security) - return false + private func isAcceptedIngressPayload(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { + switch BLEIngressPacketGuard.validatePayload( + packet, + from: peerID, + isValidSyncResponse: { [requestSyncManager] peerID in + requestSyncManager.isValidResponse(from: peerID, isRSR: true) } - } - - if !skipTimestampCheck { - let maxSkew: UInt64 = 120_000 - let packetTime = packet.timestamp - let skew = (packetTime > currentTime) ? (packetTime - currentTime) : (currentTime - packetTime) - - if skew > maxSkew { - SecureLogger.warning("Packet timestamp skewed by \(skew)ms (max \(maxSkew)ms) from \(peerID.id.prefix(8))…", category: .security) - return false + ) { + case .success: + if packet.isRSR { + logValidRSR(from: peerID) } + return true + case .failure(.invalidRSR(let peerID)): + SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security) + return false + case .failure(.timestampSkew(let peerID, let skewMs, let maxSkewMs)): + SecureLogger.warning("Packet timestamp skewed by \(skewMs)ms (max \(maxSkewMs)ms) from \(peerID.id.prefix(8))…", category: .security) + return false + case .failure(.selfLoopback), .failure(.directSenderMismatch): + return false } + } - return true + 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) + } + + private func logSelfLoopback(packetType: UInt8, linkDescription: String) { + guard logRateLimiter.shouldLog( + key: "self-loopback:\(packetType)", + minimumInterval: 30 + ) else { return } + SecureLogger.debug("↩️ Dropping BLE self-loopback packet type \(packetType) from \(linkDescription)", category: .session) } private func recordIngressIfNew(_ packet: BitchatPacket, link: BLEIngressLinkID, peerID: PeerID) -> Bool { @@ -1045,48 +1015,40 @@ final class BLEService: NSObject { } } + private func signedSenderDisplayName(for packet: BitchatPacket, from peerID: PeerID) -> String? { + guard let signature = packet.signature, + let packetData = packet.toBinaryDataForSigning() else { + return nil + } + + let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) + for candidate in candidates { + guard let signingKey = candidate.signingPublicKey, + noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) else { + continue + } + + if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) { + return social.localPetname ?? social.claimedNickname + } + + return BLEPeerSenderDisplayName.anonymousNickname(for: peerID) + } + + return nil + } + private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) { if peerID == myPeerID && packet.ttl != 0 { return } - var accepted = false - var senderNickname = "" - - let peersSnapshot = collectionsQueue.sync { peers } - - if peerID == myPeerID { - accepted = true - senderNickname = myNickname - } else if let info = peersSnapshot[peerID], info.isVerifiedNickname { - accepted = true - senderNickname = info.nickname - let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname) - if hasCollision { - senderNickname += "#" + String(peerID.id.prefix(4)) - } - } else if let info = peersSnapshot[peerID], info.isConnected { - accepted = true - senderNickname = info.nickname.isEmpty ? "anon" + String(peerID.id.prefix(4)) : info.nickname - let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname) - if hasCollision { - senderNickname += "#" + String(peerID.id.prefix(4)) - } - } else if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() { - let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) - for candidate in candidates { - if let signingKey = candidate.signingPublicKey, - noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) { - accepted = true - if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) { - senderNickname = social.localPetname ?? social.claimedNickname - } else { - senderNickname = "anon" + String(peerID.id.prefix(4)) - } - break - } - } - } - - guard accepted else { + let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID } + guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( + peerID: peerID, + localPeerID: myPeerID, + localNickname: myNickname, + peers: peersSnapshot, + allowConnectedUnverified: true + ) ?? signedSenderDisplayName(for: packet, from: peerID) else { SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security) return } @@ -1207,7 +1169,7 @@ final class BLEService: NSObject { private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) { _ = collectionsQueue.sync(flags: .barrier) { // Remove the peer when they leave - peers.removeValue(forKey: peerID) + peerRegistry.remove(peerID) } // Remove any stored announcement for sync purposes gossipSyncManager?.removeAnnouncementForPeer(peerID) @@ -1216,7 +1178,7 @@ final class BLEService: NSObject { guard let self = self else { return } // Get current peer list (after removal) - let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } + let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } self.deliverTransportEvent(.peerDisconnected(peerID)) self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) @@ -1243,7 +1205,7 @@ final class BLEService: NSObject { let signingPub = noiseService.getSigningPublicKeyData() // For signature verification let connectedPeerIDs: [Data] = collectionsQueue.sync { - peers.values.filter { $0.isConnected }.compactMap { $0.peerID.routingData } + peerRegistry.connectedRoutingData } let announcement = AnnouncementPacket( @@ -1316,7 +1278,7 @@ extension BLEService: GossipSyncManager.Delegate { func getConnectedPeers() -> [PeerID] { return collectionsQueue.sync { - peers.values.compactMap { $0.isConnected ? $0.peerID : nil } + peerRegistry.connectedPeerIDs } } } @@ -1507,10 +1469,7 @@ extension BLEService: CBCentralManagerDelegate { if let peerID { // Do not remove peer; mark as not connected but retain for reachability collectionsQueue.sync(flags: .barrier) { - if var info = peers[peerID] { - info.isConnected = false - peers[peerID] = info - } + peerRegistry.markDisconnected(peerID) } refreshLocalTopology() } @@ -1532,7 +1491,7 @@ extension BLEService: CBCentralManagerDelegate { guard let self = self else { return } // Get current peer list (after removal) - let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs } + let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } if let peerID { self.notifyPeerDisconnectedDebounced(peerID) @@ -1658,8 +1617,13 @@ extension BLEService { // Ensure the synthetic peer is known and marked verified for public-message tests let normalizedID = PeerID(hexData: packet.senderID) collectionsQueue.sync(flags: .barrier) { - if peers[normalizedID] == nil { - peers[normalizedID] = PeerInfo( + if var existing = peerRegistry.info(for: normalizedID) { + existing.isConnected = true + existing.isVerifiedNickname = true + existing.lastSeen = Date() + peerRegistry.upsert(existing) + } else { + peerRegistry.upsert(BLEPeerInfo( peerID: normalizedID, nickname: "TestPeer_\(fromPeerID.id.prefix(4))", isConnected: true, @@ -1667,13 +1631,7 @@ extension BLEService { signingPublicKey: nil, isVerifiedNickname: true, lastSeen: Date() - ) - } else { - var p = peers[normalizedID]! - p.isConnected = true - p.isVerifiedNickname = true - p.lastSeen = Date() - peers[normalizedID] = p + )) } } } @@ -1682,12 +1640,16 @@ extension BLEService { func _test_acceptsIngress(packet: BitchatPacket, boundPeerID: PeerID?) -> Bool { let claimedSenderID = PeerID(hexData: packet.senderID) - return makeIngressPacketContext( + guard case .success = BLEIngressLinkRegistry.packetContext( for: packet, claimedSenderID: claimedSenderID, boundPeerID: boundPeerID, - linkDescription: "TestLink" - ) != nil + localPeerID: myPeerID, + directAnnounceTTL: messageTTL + ) else { + return false + } + return true } func _test_recordIngressIfNew(packet: BitchatPacket, linkID: String) -> Bool { @@ -1837,7 +1799,7 @@ extension BLEService: CBPeripheralDelegate { } let claimedSenderID = PeerID(hexData: packet.senderID) - let context = makeIngressPacketContext( + let context = acceptedIngressContext( for: packet, claimedSenderID: claimedSenderID, boundPeerID: boundPeerID, @@ -1846,10 +1808,6 @@ extension BLEService: CBPeripheralDelegate { guard let context else { continue } - if !validatePacket(packet, from: context.validationPeerID, connectionSource: .peripheral(peripheralUUID)) { - continue - } - // If this is a direct-link announce, bind immediately for the remainder of this batch. if boundPeerID == nil, packet.type == MessageType.announce.rawValue, @@ -1901,7 +1859,9 @@ extension BLEService: CBPeripheralDelegate { func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { // Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again - SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session) + if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") { + SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session) + } drainPendingWrites(for: peripheral) } @@ -1974,7 +1934,7 @@ extension BLEService: CBPeripheralManagerDelegate { peripheral.stopAdvertising() // Clear subscribed centrals (they are now invalid) let centralPeerIDs = linkStateStore.clearCentrals() - centralSubscriptionRateLimits.removeAll() + subscriptionAnnounceLimiter.removeAll() characteristic = nil // Notify UI of disconnections for peerID in centralPeerIDs { @@ -1988,7 +1948,7 @@ extension BLEService: CBPeripheralManagerDelegate { SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session) peripheral.stopAdvertising() _ = linkStateStore.clearCentrals() - centralSubscriptionRateLimits.removeAll() + subscriptionAnnounceLimiter.removeAll() characteristic = nil case .unsupported: @@ -2055,61 +2015,22 @@ extension BLEService: CBPeripheralManagerDelegate { // BCH-01-004: Rate-limit subscription-triggered announces to prevent enumeration attacks let now = Date() - var state = centralSubscriptionRateLimits[centralUUID] - - // Clean up stale entries periodically - cleanupStaleSubscriptionRateLimits() - - // Check if this central is rate-limited - if let existingState = state { - let timeSinceLastAnnounce = now.timeIntervalSince(existingState.lastAnnounceTime) - - // If within backoff period, skip the announce - if timeSinceLastAnnounce < existingState.currentBackoffSeconds { - SecureLogger.warning("🛡️ BCH-01-004: Rate-limited announce for central \(centralUUID.prefix(8))... (backoff: \(Int(existingState.currentBackoffSeconds))s, attempts: \(existingState.attemptCount))", category: .security) - - // Increment attempt count and increase backoff - // Update lastAnnounceTime to 'now' so each blocked attempt extends the suppression window - // This prevents attackers from waiting out the backoff while spamming attempts - let newAttemptCount = existingState.attemptCount + 1 - let newBackoff = min( - existingState.currentBackoffSeconds * TransportConfig.bleSubscriptionRateLimitBackoffFactor, - TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds - ) - centralSubscriptionRateLimits[centralUUID] = SubscriptionRateLimitState( - lastAnnounceTime: now, // Reset timer on each blocked attempt - attemptCount: newAttemptCount, - currentBackoffSeconds: newBackoff - ) - - // If too many rapid attempts, this is likely an enumeration attack - don't respond - if newAttemptCount >= TransportConfig.bleSubscriptionRateLimitMaxAttempts { - SecureLogger.warning("🚨 BCH-01-004: Possible enumeration attack from central \(centralUUID.prefix(8))... - suppressing announce", category: .security) - return - } - - // Still flush directed packets for legitimate mesh operation - messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in - self?.flushDirectedSpool() - } + switch subscriptionAnnounceLimiter.decision(for: centralUUID, now: now) { + case .allowed: + break + case let .rateLimited(backoffSeconds, attemptCount, suppressAnnounce): + SecureLogger.warning("🛡️ BCH-01-004: Rate-limited announce for central \(centralUUID.prefix(8))... (backoff: \(Int(backoffSeconds))s, attempts: \(attemptCount))", category: .security) + if suppressAnnounce { + SecureLogger.warning("🚨 BCH-01-004: Possible enumeration attack from central \(centralUUID.prefix(8))... - suppressing announce", category: .security) return } - // Outside backoff period - allow announce but track it - state = SubscriptionRateLimitState( - lastAnnounceTime: now, - attemptCount: 1, - currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds - ) - } else { - // First subscription from this central - track it - state = SubscriptionRateLimitState( - lastAnnounceTime: now, - attemptCount: 1, - currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds - ) + // Still flush directed packets for legitimate mesh operation + messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in + self?.flushDirectedSpool() + } + return } - centralSubscriptionRateLimits[centralUUID] = state // Send announce to the newly subscribed central after a small delay messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in @@ -2118,15 +2039,6 @@ extension BLEService: CBPeripheralManagerDelegate { self?.flushDirectedSpool() } } - - /// BCH-01-004: Clean up stale rate-limit entries to prevent memory growth - private func cleanupStaleSubscriptionRateLimits() { - let now = Date() - let windowSeconds = TransportConfig.bleSubscriptionRateLimitWindowSeconds - centralSubscriptionRateLimits = centralSubscriptionRateLimits.filter { _, state in - now.timeIntervalSince(state.lastAnnounceTime) < windowSeconds - } - } func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { SecureLogger.debug("📤 Central unsubscribed: \(central.identifier.uuidString.prefix(8))…", category: .session) @@ -2142,10 +2054,7 @@ extension BLEService: CBPeripheralManagerDelegate { if let peerID = removedPeerID { // Mark peer as not connected; retain for reachability collectionsQueue.sync(flags: .barrier) { - if var info = peers[peerID] { - info.isConnected = false - peers[peerID] = info - } + peerRegistry.markDisconnected(peerID) } refreshLocalTopology() @@ -2155,7 +2064,7 @@ extension BLEService: CBPeripheralManagerDelegate { guard let self = self else { return } // Get current peer list (after removal) - let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs } + let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } self.notifyPeerDisconnectedDebounced(peerID) // Publish snapshots so UnifiedPeerService can refresh icons promptly @@ -2280,7 +2189,7 @@ extension BLEService: CBPeripheralManagerDelegate { private func processDecodedCentralWrite(_ packet: BitchatPacket, centralUUID: String, central: CBCentral) { let claimedSenderID = PeerID(hexData: packet.senderID) - let context = makeIngressPacketContext( + let context = acceptedIngressContext( for: packet, claimedSenderID: claimedSenderID, boundPeerID: linkStateStore.peerID(forCentralUUID: centralUUID), @@ -2288,10 +2197,6 @@ extension BLEService: CBPeripheralManagerDelegate { ) guard let context else { return } - guard validatePacket(packet, from: context.validationPeerID, connectionSource: .central(centralUUID)) else { - return - } - if packet.type != MessageType.announce.rawValue { SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(claimedSenderID.id.prefix(8))…", category: .session) } @@ -2377,8 +2282,8 @@ extension BLEService { let peerSummary = collectionsQueue.sync { ( - connected: peers.values.filter { $0.isConnected }.count, - known: peers.count, + connected: peerRegistry.connectedCount, + known: peerRegistry.count, candidates: connectionScheduler.candidateCount ) } @@ -2412,7 +2317,7 @@ extension BLEService { private func refreshLocalTopology() { let neighbors: [Data] = collectionsQueue.sync { - peers.values.filter { $0.isConnected }.compactMap { $0.peerID.routingData } + peerRegistry.connectedRoutingData } meshTopology.updateNeighbors(for: myPeerIDData, neighbors: neighbors) } @@ -2844,8 +2749,6 @@ extension BLEService { } private func startFragmentedPacket(_ request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?) { - let packet = request.packet - let releaseReservedSlot: (String) -> Void = { [weak self] id in guard let self = self else { return } TransferProgressManager.shared.cancel(id: id) @@ -2857,34 +2760,11 @@ extension BLEService { } } - guard let fullData = packet.toBinaryData(padding: request.pad) else { - if let id = reservedTransferId { - releaseReservedSlot(id) - } - return - } - // Fragment the unpadded frame; each fragment will be encoded independently - let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) }) - // Dynamic Fragment Sizing (Source Routing v2) - // See docs/SOURCE_ROUTING.md Section 5.1 - var fragmentVersion: UInt8 = 1 - var calculatedChunk = defaultFragmentSize - - if let route = packet.route, !route.isEmpty { - fragmentVersion = 2 - // RouteSize = 1 + (Hops * 8) - let routeSize = 1 + (route.count * 8) - // Overhead = HeaderV2(16) + SenderID(8) + RecipientID(8) + RouteSize + FragmentHeader(13) + PaddingBuffer(16) - let overhead = 16 + 8 + 8 + routeSize + 13 + 16 - calculatedChunk = max(64, bleMaxMTU - overhead) - } - - let chunk = request.maxChunk ?? calculatedChunk - let safeChunk = max(64, chunk) - let fragments = stride(from: 0, to: fullData.count, by: safeChunk).map { offset in - Data(fullData[offset.. 4 { + if plan.shouldPauseScanning { bleQueue.async { [weak self] in guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return } if c.isScanning { c.stopScan() } + let totalFragments = plan.totalFragments let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, totalFragments * TransportConfig.bleExpectedWritePerFragmentMs) self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in self?.startScanning() } } } - let perFragMs = (request.directedPeer != nil || packet.recipientID != nil) ? TransportConfig.bleFragmentSpacingDirectedMs : TransportConfig.bleFragmentSpacingMs let transferIdentifier: String? = { guard let id = reservedTransferId else { return nil } collectionsQueue.sync(flags: .barrier) { - _ = self.outboundFragmentTransfers.activateReservedTransfer(id: id, totalFragments: totalFragments, workItems: []) + _ = self.outboundFragmentTransfers.activateReservedTransfer(id: id, totalFragments: plan.totalFragments, workItems: []) } - TransferProgressManager.shared.start(id: id, totalFragments: totalFragments) + TransferProgressManager.shared.start(id: id, totalFragments: plan.totalFragments) return id }() var scheduledItems: [(item: DispatchWorkItem, index: Int)] = [] - for (index, fragment) in fragments.enumerated() { - var payload = Data() - payload.append(fragmentID) - payload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) }) - payload.append(contentsOf: withUnsafeBytes(of: UInt16(fragments.count).bigEndian) { Data($0) }) - payload.append(packet.type) - payload.append(fragment) - - let fragmentRecipient: Data? = { - if let only = request.directedPeer { return Data(hexString: only.id) } - return packet.recipientID - }() - - let fragmentPacket = BitchatPacket( - type: MessageType.fragment.rawValue, - senderID: packet.senderID, - recipientID: fragmentRecipient, - timestamp: packet.timestamp, - payload: payload, - signature: nil, - ttl: packet.ttl, - version: fragmentVersion, - route: packet.route, - isRSR: packet.isRSR - ) - + for (index, fragmentPacket) in plan.fragmentPackets.enumerated() { let workItem = DispatchWorkItem { [weak self] in guard let self = self else { return } if let transferId = transferIdentifier { let isActive = self.collectionsQueue.sync { self.outboundFragmentTransfers.isActive(transferId) } guard isActive else { return } } - if fragmentRecipient == nil || fragmentRecipient?.allSatisfy({ $0 == 0xFF }) == true { + if fragmentPacket.recipientID == nil || fragmentPacket.recipientID?.allSatisfy({ $0 == 0xFF }) == true { self.gossipSyncManager?.onPublicPacketSeen(fragmentPacket) } self.broadcastPacket(fragmentPacket) @@ -2969,7 +2823,7 @@ extension BLEService { } for (workItem, index) in scheduledItems { - let delayMs = index * perFragMs + let delayMs = index * plan.spacingMs messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem) } } @@ -3043,7 +2897,7 @@ extension BLEService { // Reassembled packet validation let innerSender = PeerID(hexData: originalPacket.senderID) - if !validatePacket(originalPacket, from: innerSender) { + if !isAcceptedIngressPayload(originalPacket, from: innerSender) { // Cleanup below } else { SecureLogger.debug("✅ Reassembled packet id=\(completedHeader.idLogString) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session) @@ -3091,33 +2945,26 @@ extension BLEService { return } - - // Deduplication (thread-safe) - let senderID = PeerID(hexData: packet.senderID) - // Include packet type in message ID to prevent collisions between different packet types - let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)" + let context = BLEReceivePipeline.context(for: packet, localPeerID: myPeerID) + let senderID = context.senderID + let messageID = context.messageID // Only log non-announce packets to reduce noise - if packet.type != MessageType.announce.rawValue { + if context.logsHandlingDetails { // Log packet details for debugging SecureLogger.debug("📦 Handling packet type \(packet.type) from \(senderID.id.prefix(8))…, messageID: \(messageID.prefix(24))…", category: .session) } - // Efficient deduplication - // Important: do not dedup fragment packets globally (each piece must pass) - // Special case: allow our own packets recovered via sync (TTL==0) to pass - // through even if we've marked them as seen at send time. - let allowSelfSyncReplay = (packet.ttl == 0) && (senderID == myPeerID) - if packet.type != MessageType.fragment.rawValue && !allowSelfSyncReplay && messageDeduplicator.isDuplicate(messageID) { + if context.shouldDeduplicate && messageDeduplicator.isDuplicate(messageID) { // Announce packets (type 1) are sent every 10 seconds for peer discovery // It's normal to see these as duplicates - don't log them to reduce noise - if packet.type != MessageType.announce.rawValue { + if context.logsHandlingDetails { SecureLogger.debug("⚠️ Duplicate packet ignored: \(messageID.prefix(24))…", category: .session) } // In sparse graphs (<=2 neighbors), keep the pending relay to ensure bridging. // In denser graphs, cancel the pending relay to reduce redundant floods. - let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count } - if connectedCount > 2 { + let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount } + if BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: connectedCount) { collectionsQueue.async(flags: .barrier) { [weak self] in if let task = self?.scheduledRelays.removeValue(forKey: messageID) { task.cancel() @@ -3133,19 +2980,12 @@ extension BLEService { // Track recent traffic timestamps for adaptive behavior collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } - let now = Date() - self.recentPacketTimestamps.append(now) - // keep last N timestamps within window - let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds) - if self.recentPacketTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount { - self.recentPacketTimestamps.removeFirst(self.recentPacketTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount) - } - self.recentPacketTimestamps.removeAll { $0 < cutoff } + self.recentTrafficTracker.recordPacket(at: Date()) } // Process by type - switch MessageType(rawValue: packet.type) { + switch context.messageType { case .announce: handleAnnounce(packet, from: senderID) @@ -3182,17 +3022,11 @@ extension BLEService { // Relay if TTL > 1 and we're not the original sender // Relay decision and scheduling (extracted via RelayController) do { - let degree = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count } - let decision = RelayController.decide( - ttl: packet.ttl, - senderIsSelf: senderID == myPeerID, - recipientIsSelf: PeerID(hexData: packet.recipientID) == myPeerID, - isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue, - isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil), - isFragment: packet.type == MessageType.fragment.rawValue, - isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, - isHandshake: packet.type == MessageType.noiseHandshake.rawValue, - isAnnounce: packet.type == MessageType.announce.rawValue, + let degree = collectionsQueue.sync { peerRegistry.connectedCount } + let decision = BLEReceivePipeline.relayDecision( + for: packet, + senderID: senderID, + localPeerID: myPeerID, degree: degree, highDegreeThreshold: highDegreeThreshold ) @@ -3250,7 +3084,7 @@ extension BLEService { // Suppress announce logs to reduce noise // Precompute signature verification outside barrier to reduce contention - let existingPeerForVerify = collectionsQueue.sync { peers[peerID] } + let existingPeerForVerify = collectionsQueue.sync { peerRegistry.info(for: peerID) } var verifiedAnnounce = false if packet.signature != nil { verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey) @@ -3263,35 +3097,17 @@ extension BLEService { verifiedAnnounce = false } - // Track if this is a new or reconnected peer var isNewPeer = false var isReconnectedPeer = false let directLinkState = linkState(for: peerID) collectionsQueue.sync(flags: .barrier) { - // Check if we have an actual BLE connection to this peer let hasPeripheralConnection = directLinkState.hasPeripheral - - // Check if this peer is subscribed to us as a central - // Note: We can't identify which specific central is which peer without additional mapping let hasCentralSubscription = directLinkState.hasCentral - - // Direct announces arrive with full TTL (no prior hop) let isDirectAnnounce = (packet.ttl == messageTTL) - - // Check if we already have this peer (might be reconnecting) - let existingPeer = peers[peerID] - let wasDisconnected = existingPeer?.isConnected == false - - // Set flags for use outside the sync block - isNewPeer = (existingPeer == nil) - isReconnectedPeer = wasDisconnected - - // Use precomputed verification result - let verified = verifiedAnnounce // Require verified announce; ignore otherwise (no backward compatibility) - if !verified { + if !verifiedAnnounce { SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.id.prefix(8))…", category: .security) // Reset flags to prevent post-barrier code from acting on unverified announces isNewPeer = false @@ -3299,37 +3115,23 @@ extension BLEService { return } - // Update or create peer info - if let existing = existingPeer, existing.isConnected { - // Update lastSeen and identity info - peers[peerID] = PeerInfo( - peerID: existing.peerID, - nickname: announcement.nickname, - isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, - noisePublicKey: announcement.noisePublicKey, - signingPublicKey: announcement.signingPublicKey, - isVerifiedNickname: true, - lastSeen: Date() - ) - } else { - // New peer or reconnecting peer - peers[peerID] = PeerInfo( - peerID: peerID, - nickname: announcement.nickname, - isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, - noisePublicKey: announcement.noisePublicKey, - signingPublicKey: announcement.signingPublicKey, - isVerifiedNickname: true, - lastSeen: Date() - ) - } + let update = peerRegistry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: announcement.nickname, + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, + now: Date() + ) + isNewPeer = update.isNewPeer + isReconnectedPeer = update.wasDisconnected // Log connection status only for direct connectivity changes; debounce to reduce spam if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription { let now = Date() - if existingPeer == nil { + if update.isNewPeer { SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session) - } else if wasDisconnected { + } else if update.wasDisconnected { // Debounce 'reconnected' logs within short window if let last = lastReconnectLogAt[peerID], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds { // Skip duplicate log @@ -3337,8 +3139,8 @@ extension BLEService { SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session) lastReconnectLogAt[peerID] = now } - } else if existingPeer?.nickname != announcement.nickname { - SecureLogger.debug("🔄 Peer \(peerID.id.prefix(8))… changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: .session) + } else if let previousNickname = update.previousNickname, previousNickname != announcement.nickname { + SecureLogger.debug("🔄 Peer \(peerID.id.prefix(8))… changed nickname: \(previousNickname) -> \(announcement.nickname)", category: .session) } } } @@ -3361,7 +3163,7 @@ extension BLEService { guard let self = self else { return } // Get current peer list (after addition) - let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs } + let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } // Only notify of connection for new or reconnected peers when it is a direct announce if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) { @@ -3436,47 +3238,16 @@ extension BLEService { } } - var accepted = false - var senderNickname: String = "" // Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks. - let peersSnapshot = collectionsQueue.sync { peers } + let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID } - // If the packet is from ourselves (e.g., recovered via sync TTL==0), accept immediately - if peerID == myPeerID { - accepted = true - senderNickname = myNickname - } - else if let info = peersSnapshot[peerID], info.isVerifiedNickname { - // Known verified peer path - accepted = true - senderNickname = info.nickname - // Handle nickname collisions - let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname) - if hasCollision { - senderNickname += "#" + String(peerID.id.prefix(4)) - } - } else { - // Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix - if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() { - // Find candidate identities by peerID prefix (16 hex) - let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) - for candidate in candidates { - if let signingKey = candidate.signingPublicKey, - noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) { - accepted = true - // Prefer persisted social petname or claimed nickname - if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) { - senderNickname = social.localPetname ?? social.claimedNickname - } else { - senderNickname = "anon" + String(peerID.id.prefix(4)) - } - break - } - } - } - } - - guard accepted else { + guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( + peerID: peerID, + localPeerID: myPeerID, + localNickname: myNickname, + peers: peersSnapshot, + allowConnectedUnverified: false + ) ?? signedSenderDisplayName(for: packet, from: peerID) else { SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security) return } @@ -3644,10 +3415,7 @@ extension BLEService { private func updatePeerLastSeen(_ peerID: PeerID) { // Use async to avoid deadlock - we don't need immediate consistency for last seen updates collectionsQueue.async(flags: .barrier) { - if var peer = self.peers[peerID] { - peer.lastSeen = Date() - self.peers[peerID] = peer - } + self.peerRegistry.updateLastSeen(peerID, at: Date()) } } @@ -3667,24 +3435,7 @@ extension BLEService { // NEW: Publish peer snapshots to subscribers and notify Transport delegates private func publishFullPeerData() { let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync { - // Compute nickname collision counts for connected peers - let connected = peers.values.filter { $0.isConnected } - var counts: [String: Int] = [:] - for p in connected { counts[p.nickname, default: 0] += 1 } - counts[myNickname, default: 0] += 1 - return peers.values.map { info in - var display = info.nickname - if info.isConnected, (counts[info.nickname] ?? 0) > 1 { - display += "#" + String(info.peerID.id.prefix(4)) - } - return TransportPeerSnapshot( - peerID: info.peerID, - nickname: display, - isConnected: info.isConnected, - noisePublicKey: info.noisePublicKey, - lastSeen: info.lastSeen - ) - } + peerRegistry.transportSnapshots(selfNickname: myNickname) } // Notify non-UI listeners peerSnapshotSubject.send(transportPeers) @@ -3701,7 +3452,7 @@ extension BLEService { // Adaptive announce: reduce frequency when we have connected peers let now = Date() - let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count } + let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount } let elapsed = now.timeIntervalSince(lastAnnounceSent) if connectedCount == 0 { // Discovery mode: keep frequent announces @@ -3719,15 +3470,15 @@ extension BLEService { // Activity-driven quick-announce: if we've seen any packet in last 5s and it has // been >=10s since the last announce, send a presence nudge. let recentSeen = collectionsQueue.sync { () -> Bool in - let cutoff = now.addingTimeInterval(-5.0) - return recentPacketTimestamps.contains(where: { $0 >= cutoff }) + recentTrafficTracker.hasTraffic(within: 5.0, now: now) } if recentSeen && elapsed >= 10.0 { sendAnnounce(forceSend: true) } // If we have no peers, ensure we're scanning and advertising - if peers.isEmpty { + let hasNoPeers = collectionsQueue.sync { peerRegistry.isEmpty } + if hasNoPeers { // Ensure we're advertising as peripheral if let pm = peripheralManager, pm.state == .poweredOn && !pm.isAdvertising { pm.startAdvertising(buildAdvertisementData()) @@ -3766,54 +3517,33 @@ extension BLEService { private func checkPeerConnectivity() { let now = Date() - var disconnectedPeers: [PeerID] = [] - let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { Array(peers.keys) } - var cachedLinkStates: [PeerID: (hasPeripheral: Bool, hasCentral: Bool)] = [:] + let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { peerRegistry.peerIDs } + var cachedLinkStates: [PeerID: BLEPeerLinkPresence] = [:] for peerID in peerIDsForLinkState { - cachedLinkStates[peerID] = linkState(for: peerID) + let state = linkState(for: peerID) + cachedLinkStates[peerID] = BLEPeerLinkPresence( + hasPeripheral: state.hasPeripheral, + hasCentral: state.hasCentral + ) } - var removedOfflineCount = 0 - collectionsQueue.sync(flags: .barrier) { - for (peerID, peer) in peers { - let age = now.timeIntervalSince(peer.lastSeen) - let retention: TimeInterval = peer.isVerifiedNickname ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds - if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds { - // Check if we still have an active BLE connection to this peer - let state = cachedLinkStates[peerID] ?? (hasPeripheral: false, hasCentral: false) - let hasPeripheralConnection = state.hasPeripheral - let hasCentralConnection = state.hasCentral - - // If direct link is gone, mark as not connected (retain entry for reachability) - if !hasPeripheralConnection && !hasCentralConnection { - var updated = peer - updated.isConnected = false - peers[peerID] = updated - disconnectedPeers.append(peerID) - } - } - // Cleanup: remove peers that are not connected and past reachability retention - if !peer.isConnected { - if age > retention { - SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(peerID.id.prefix(8))… (\(peer.nickname))", category: .session) - // Also remove any stored announcement from sync candidates - gossipSyncManager?.removeAnnouncementForPeer(peerID) - peers.removeValue(forKey: peerID) - removedOfflineCount += 1 - } - } - } + let changes = collectionsQueue.sync(flags: .barrier) { + peerRegistry.reconcileConnectivity(now: now, linkStates: cachedLinkStates) + } + for removedPeer in changes.removedPeers { + SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(removedPeer.peerID.id.prefix(8))… (\(removedPeer.nickname))", category: .session) + gossipSyncManager?.removeAnnouncementForPeer(removedPeer.peerID) } // Update UI if there were direct disconnections or offline removals - if !disconnectedPeers.isEmpty || removedOfflineCount > 0 { + if !changes.disconnectedPeerIDs.isEmpty || !changes.removedPeers.isEmpty { notifyUI { [weak self] in guard let self else { return } // Get current peer list (after removal) - let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs } + let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } - for peerID in disconnectedPeers { + for peerID in changes.disconnectedPeerIDs { self.deliverTransportEvent(.peerDisconnected(peerID)) } // Publish snapshots so UnifiedPeerService updates connection/reachability icons @@ -3891,8 +3621,10 @@ extension BLEService { #endif // Force full-time scanning if we have very few neighbors or very recent traffic let hasRecentTraffic: Bool = collectionsQueue.sync { - let cutoff = Date().addingTimeInterval(-TransportConfig.bleRecentTrafficForceScanSeconds) - return recentPacketTimestamps.contains(where: { $0 >= cutoff }) + recentTrafficTracker.hasTraffic( + within: TransportConfig.bleRecentTrafficForceScanSeconds, + now: Date() + ) } let forceScanOn = (connectedCount <= 2) || hasRecentTraffic let shouldDuty = dutyEnabled && active && connectedCount > 0 && !forceScanOn diff --git a/bitchat/Services/BLE/BLESubscriptionAnnounceLimiter.swift b/bitchat/Services/BLE/BLESubscriptionAnnounceLimiter.swift new file mode 100644 index 00000000..9846e22b --- /dev/null +++ b/bitchat/Services/BLE/BLESubscriptionAnnounceLimiter.swift @@ -0,0 +1,71 @@ +import Foundation + +enum BLESubscriptionAnnounceDecision: Equatable { + case allowed + case rateLimited(backoffSeconds: TimeInterval, attemptCount: Int, suppressAnnounce: Bool) +} + +struct BLESubscriptionAnnounceLimiter { + private struct State { + var lastAnnounceTime: Date + var attemptCount: Int + var currentBackoffSeconds: TimeInterval + } + + private var states: [String: State] = [:] + + var trackedCentralCount: Int { + states.count + } + + mutating func removeAll() { + states.removeAll() + } + + mutating func decision(for centralID: String, now: Date) -> BLESubscriptionAnnounceDecision { + pruneStaleEntries(now: now) + + guard let existing = states[centralID] else { + recordAllowedAttempt(for: centralID, now: now) + return .allowed + } + + let timeSinceLastAnnounce = now.timeIntervalSince(existing.lastAnnounceTime) + guard timeSinceLastAnnounce < existing.currentBackoffSeconds else { + recordAllowedAttempt(for: centralID, now: now) + return .allowed + } + + let newAttemptCount = existing.attemptCount + 1 + let newBackoff = min( + existing.currentBackoffSeconds * TransportConfig.bleSubscriptionRateLimitBackoffFactor, + TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds + ) + states[centralID] = State( + lastAnnounceTime: now, + attemptCount: newAttemptCount, + currentBackoffSeconds: newBackoff + ) + + return .rateLimited( + backoffSeconds: existing.currentBackoffSeconds, + attemptCount: existing.attemptCount, + suppressAnnounce: newAttemptCount >= TransportConfig.bleSubscriptionRateLimitMaxAttempts + ) + } + + private mutating func recordAllowedAttempt(for centralID: String, now: Date) { + states[centralID] = State( + lastAnnounceTime: now, + attemptCount: 1, + currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds + ) + } + + private mutating func pruneStaleEntries(now: Date) { + let windowSeconds = TransportConfig.bleSubscriptionRateLimitWindowSeconds + states = states.filter { _, state in + now.timeIntervalSince(state.lastAnnounceTime) < windowSeconds + } + } +} diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index 13934872..d5f95b8f 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -910,7 +910,7 @@ struct ChatViewModelMediaTransferTests { viewModel.selectedPrivateChatPeer = peerID viewModel.sendImage(from: sourceURL) - let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 1.0) + let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0) #expect(didSend) #expect(transport.sentPrivateFiles.first?.peerID == peerID) #expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg") diff --git a/bitchatTests/Services/BLEIngressPacketGuardTests.swift b/bitchatTests/Services/BLEIngressPacketGuardTests.swift new file mode 100644 index 00000000..754bc585 --- /dev/null +++ b/bitchatTests/Services/BLEIngressPacketGuardTests.swift @@ -0,0 +1,148 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("BLE ingress packet guard tests") +struct BLEIngressPacketGuardTests { + @Test("valid packets return the received and validation peer context") + func validPacketsReturnContext() throws { + let local = PeerID(str: "0011223344556677") + let bound = PeerID(str: "1122334455667788") + let sender = PeerID(str: "8899aabbccddeeff") + let packet = makePacket(sender: sender, timestamp: 1_000) + + let context = try #require(success(BLEIngressPacketGuard.evaluate( + packet: packet, + claimedSenderID: sender, + boundPeerID: bound, + localPeerID: local, + directAnnounceTTL: 7, + nowMs: 1_000, + isValidSyncResponse: { _ in false } + ))) + + #expect(context.receivedFromPeerID == bound) + #expect(context.validationPeerID == sender) + } + + @Test("self loopback and direct announce spoofing are rejected before timestamp checks") + func linkBindingRejectionsWinBeforeTimestampChecks() { + let local = PeerID(str: "0011223344556677") + let bound = PeerID(str: "1122334455667788") + let claimed = PeerID(str: "8899aabbccddeeff") + let selfPacket = makePacket(sender: local, timestamp: 0) + let spoofedAnnounce = makePacket(type: .announce, sender: claimed, timestamp: 0, ttl: 7) + + let selfResult = BLEIngressPacketGuard.evaluate( + packet: selfPacket, + claimedSenderID: local, + boundPeerID: bound, + localPeerID: local, + directAnnounceTTL: 7, + nowMs: 1_000_000, + isValidSyncResponse: { _ in false } + ) + let spoofResult = BLEIngressPacketGuard.evaluate( + packet: spoofedAnnounce, + claimedSenderID: claimed, + boundPeerID: bound, + localPeerID: local, + directAnnounceTTL: 7, + nowMs: 1_000_000, + isValidSyncResponse: { _ in false } + ) + + #expect(selfResult == .failure(.selfLoopback(packetType: MessageType.message.rawValue))) + #expect(spoofResult == .failure(.directSenderMismatch(boundPeerID: bound, claimedSenderID: claimed))) + } + + @Test("timestamp skew outside the window is rejected") + func timestampSkewIsRejected() { + let local = PeerID(str: "0011223344556677") + let sender = PeerID(str: "1122334455667788") + let packet = makePacket(sender: sender, timestamp: 1_000) + + let result = BLEIngressPacketGuard.evaluate( + packet: packet, + claimedSenderID: sender, + boundPeerID: nil, + localPeerID: local, + directAnnounceTTL: 7, + nowMs: 200_000, + maxTimestampSkewMs: 120_000, + isValidSyncResponse: { _ in false } + ) + + #expect(result == .failure(.timestampSkew( + peerID: sender, + skewMs: 199_000, + maxSkewMs: 120_000 + ))) + } + + @Test("valid RSR packets use bound peer validation and bypass timestamp skew") + func validRSRUsesBoundPeerAndBypassesTimestamp() throws { + let local = PeerID(str: "0011223344556677") + let bound = PeerID(str: "1122334455667788") + let sender = PeerID(str: "8899aabbccddeeff") + var packet = makePacket(sender: sender, timestamp: 1) + packet.isRSR = true + + let context = try #require(success(BLEIngressPacketGuard.evaluate( + packet: packet, + claimedSenderID: sender, + boundPeerID: bound, + localPeerID: local, + directAnnounceTTL: 7, + nowMs: 1_000_000, + isValidSyncResponse: { $0 == bound } + ))) + + #expect(context.receivedFromPeerID == bound) + #expect(context.validationPeerID == bound) + } + + @Test("invalid RSR packets are rejected") + func invalidRSRIsRejected() { + let local = PeerID(str: "0011223344556677") + let bound = PeerID(str: "1122334455667788") + let sender = PeerID(str: "8899aabbccddeeff") + var packet = makePacket(sender: sender, timestamp: 1) + packet.isRSR = true + + let result = BLEIngressPacketGuard.evaluate( + packet: packet, + claimedSenderID: sender, + boundPeerID: bound, + localPeerID: local, + directAnnounceTTL: 7, + nowMs: 1_000_000, + isValidSyncResponse: { _ in false } + ) + + #expect(result == .failure(.invalidRSR(peerID: bound))) + } + + private func makePacket( + type: MessageType = .message, + sender: PeerID, + timestamp: UInt64, + ttl: UInt8 = 3 + ) -> BitchatPacket { + BitchatPacket( + type: type.rawValue, + senderID: Data(hexString: sender.id) ?? Data(), + recipientID: nil, + timestamp: timestamp, + payload: Data([0x01, 0x02, 0x03]), + signature: nil, + ttl: ttl + ) + } + + private func success(_ result: Result) -> BLEIngressPacketContext? { + guard case .success(let context) = result else { return nil } + return context + } +} diff --git a/bitchatTests/Services/BLELogRateLimiterTests.swift b/bitchatTests/Services/BLELogRateLimiterTests.swift new file mode 100644 index 00000000..9185b7d0 --- /dev/null +++ b/bitchatTests/Services/BLELogRateLimiterTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing +@testable import bitchat + +@Suite("BLE log rate limiter tests") +struct BLELogRateLimiterTests { + @Test("repeated log keys are suppressed until the default interval elapses") + func suppressesRepeatedKeysUntilIntervalElapses() { + let limiter = BLELogRateLimiter(defaultMinimumInterval: 5) + let now = Date(timeIntervalSince1970: 100) + + #expect(limiter.shouldLog(key: "ready:peer-a", now: now)) + #expect(!limiter.shouldLog(key: "ready:peer-a", now: now.addingTimeInterval(4.9))) + #expect(limiter.shouldLog(key: "ready:peer-a", now: now.addingTimeInterval(5))) + } + + @Test("different log keys are tracked independently") + func tracksKeysIndependently() { + let limiter = BLELogRateLimiter(defaultMinimumInterval: 5) + let now = Date(timeIntervalSince1970: 100) + + #expect(limiter.shouldLog(key: "ready:peer-a", now: now)) + #expect(limiter.shouldLog(key: "ready:peer-b", now: now)) + #expect(!limiter.shouldLog(key: "ready:peer-a", now: now.addingTimeInterval(1))) + } + + @Test("call sites can override the default interval") + func supportsPerCallIntervals() { + let limiter = BLELogRateLimiter(defaultMinimumInterval: 5) + let now = Date(timeIntervalSince1970: 100) + + #expect(limiter.shouldLog(key: "self-loopback", now: now, minimumInterval: 30)) + #expect(!limiter.shouldLog(key: "self-loopback", now: now.addingTimeInterval(29), minimumInterval: 30)) + #expect(limiter.shouldLog(key: "self-loopback", now: now.addingTimeInterval(30), minimumInterval: 30)) + } +} diff --git a/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift b/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift new file mode 100644 index 00000000..d89260ed --- /dev/null +++ b/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift @@ -0,0 +1,136 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("BLE outbound fragment planner tests") +struct BLEOutboundFragmentPlannerTests { + @Test("planner splits packets and preserves reassembled payload") + func plannerSplitsAndReassemblesPacket() throws { + let packet = makePacket(payload: makePayload(count: 384)) + let request = BLEOutboundFragmentTransferRequest( + packet: packet, + pad: false, + maxChunk: 128, + directedPeer: nil, + transferId: nil + ) + + let plan = try #require(BLEOutboundFragmentPlanner.makePlan( + for: request, + defaultChunkSize: 256, + bleMaxMTU: 512, + fragmentID: Data(repeating: 0xA1, count: 8) + )) + let headers = try plan.fragmentPackets.map { try #require(BLEFragmentHeader(packet: $0)) } + let reassembled = headers.reduce(into: Data()) { data, header in + data.append(header.fragmentData) + } + let decoded = try #require(BinaryProtocol.decode(reassembled)) + + #expect(plan.fragmentVersion == 1) + #expect(plan.chunkSize == 128) + #expect(plan.spacingMs == TransportConfig.bleFragmentSpacingMs) + #expect(headers.map(\.index) == Array(0.. BitchatPacket { + BitchatPacket( + type: MessageType.message.rawValue, + senderID: Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]), + recipientID: nil, + timestamp: 0x0102030405, + payload: payload, + signature: nil, + ttl: 3, + route: route, + isRSR: isRSR + ) + } + + private func makePayload(count: Int, seed: UInt64 = 0xABCD_1234) -> Data { + var state = seed + return Data((0..> 32) + }) + } +} diff --git a/bitchatTests/Services/BLEPeerRegistryTests.swift b/bitchatTests/Services/BLEPeerRegistryTests.swift new file mode 100644 index 00000000..01b69eae --- /dev/null +++ b/bitchatTests/Services/BLEPeerRegistryTests.swift @@ -0,0 +1,111 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("BLE peer registry tests") +struct BLEPeerRegistryTests { + @Test("upserted announces track new, reconnect, and rename transitions") + func upsertVerifiedAnnounceTracksTransitions() { + var registry = BLEPeerRegistry() + let peerID = PeerID(str: "1122334455667788") + let firstSeen = Date(timeIntervalSince1970: 100) + + let first = registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: "alice", + noisePublicKey: Data([1, 2, 3]), + signingPublicKey: Data([4, 5, 6]), + isConnected: true, + now: firstSeen + ) + + #expect(first.isNewPeer) + #expect(!first.wasDisconnected) + #expect(first.previousNickname == nil) + #expect(registry.connectedPeerIDs == [peerID]) + #expect(registry.nickname(for: peerID, connectedOnly: true) == "alice") + + registry.markDisconnected(peerID) + let reconnect = registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: "alice-renamed", + noisePublicKey: Data([1, 2, 3]), + signingPublicKey: Data([4, 5, 6]), + isConnected: true, + now: firstSeen.addingTimeInterval(1) + ) + + #expect(!reconnect.isNewPeer) + #expect(reconnect.wasDisconnected) + #expect(reconnect.previousNickname == "alice") + #expect(registry.info(for: peerID)?.nickname == "alice-renamed") + } + + @Test("reachability keeps recent verified offline peers only when mesh is attached") + func reachabilityRequiresMeshAttachmentForOfflinePeers() { + let offlinePeer = PeerID(str: "1122334455667788") + let connectedPeer = PeerID(str: "8877665544332211") + let now = Date() + + var isolatedRegistry = BLEPeerRegistry() + isolatedRegistry.upsert(BLEPeerInfo( + peerID: offlinePeer, + nickname: "offline", + isConnected: false, + noisePublicKey: nil, + signingPublicKey: nil, + isVerifiedNickname: true, + lastSeen: now + )) + + #expect(!isolatedRegistry.isReachable(offlinePeer, now: now)) + + var attachedRegistry = isolatedRegistry + attachedRegistry.upsert(BLEPeerInfo( + peerID: connectedPeer, + nickname: "connected", + isConnected: true, + noisePublicKey: nil, + signingPublicKey: nil, + isVerifiedNickname: true, + lastSeen: now + )) + + #expect(attachedRegistry.isReachable(offlinePeer, now: now)) + } + + @Test("connectivity reconciliation disconnects inactive peers and prunes expired offline peers") + func reconcileConnectivityUpdatesAndPrunesPeerState() { + var registry = BLEPeerRegistry() + let inactiveConnectedPeer = PeerID(str: "1122334455667788") + let expiredOfflinePeer = PeerID(str: "8877665544332211") + let now = Date() + + registry.upsert(BLEPeerInfo( + peerID: inactiveConnectedPeer, + nickname: "inactive", + isConnected: true, + noisePublicKey: nil, + signingPublicKey: nil, + isVerifiedNickname: true, + lastSeen: now.addingTimeInterval(-TransportConfig.blePeerInactivityTimeoutSeconds - 1) + )) + registry.upsert(BLEPeerInfo( + peerID: expiredOfflinePeer, + nickname: "expired", + isConnected: false, + noisePublicKey: nil, + signingPublicKey: nil, + isVerifiedNickname: true, + lastSeen: .distantPast + )) + + let changes = registry.reconcileConnectivity(now: now, linkStates: [:]) + + #expect(changes.disconnectedPeerIDs == [inactiveConnectedPeer]) + #expect(changes.removedPeers.map(\.peerID) == [expiredOfflinePeer]) + #expect(registry.info(for: inactiveConnectedPeer)?.isConnected == false) + #expect(registry.info(for: expiredOfflinePeer) == nil) + } +} diff --git a/bitchatTests/Services/BLEPeerSenderDisplayNameTests.swift b/bitchatTests/Services/BLEPeerSenderDisplayNameTests.swift new file mode 100644 index 00000000..9bbf0c51 --- /dev/null +++ b/bitchatTests/Services/BLEPeerSenderDisplayNameTests.swift @@ -0,0 +1,78 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("BLE peer sender display name tests") +struct BLEPeerSenderDisplayNameTests { + @Test("local peer resolves to local nickname") + func localPeerUsesLocalNickname() { + let local = PeerID(str: "1122334455667788") + + #expect(BLEPeerSenderDisplayName.resolveKnownPeer( + peerID: local, + localPeerID: local, + localNickname: "me", + peers: [:], + allowConnectedUnverified: false + ) == "me") + } + + @Test("verified nickname collisions add peer suffix") + func verifiedCollisionAddsSuffix() { + let local = PeerID(str: "1122334455667788") + let peer = PeerID(str: "8877665544332211") + let peers = [ + peer: makeInfo(peerID: peer, nickname: "sam", isConnected: false, isVerifiedNickname: true) + ] + + #expect(BLEPeerSenderDisplayName.resolveKnownPeer( + peerID: peer, + localPeerID: local, + localNickname: "sam", + peers: peers, + allowConnectedUnverified: false + ) == "sam#8877") + } + + @Test("connected unverified fallback is opt in") + func connectedUnverifiedFallbackIsOptIn() { + let local = PeerID(str: "1122334455667788") + let peer = PeerID(str: "8877665544332211") + let peers = [ + peer: makeInfo(peerID: peer, nickname: "", isConnected: true, isVerifiedNickname: false) + ] + + #expect(BLEPeerSenderDisplayName.resolveKnownPeer( + peerID: peer, + localPeerID: local, + localNickname: "me", + peers: peers, + allowConnectedUnverified: false + ) == nil) + #expect(BLEPeerSenderDisplayName.resolveKnownPeer( + peerID: peer, + localPeerID: local, + localNickname: "me", + peers: peers, + allowConnectedUnverified: true + ) == "anon8877") + } + + private func makeInfo( + peerID: PeerID, + nickname: String, + isConnected: Bool, + isVerifiedNickname: Bool + ) -> BLEPeerInfo { + BLEPeerInfo( + peerID: peerID, + nickname: nickname, + isConnected: isConnected, + noisePublicKey: nil, + signingPublicKey: nil, + isVerifiedNickname: isVerifiedNickname, + lastSeen: Date(timeIntervalSince1970: 0) + ) + } +} diff --git a/bitchatTests/Services/BLEReceivePipelineTests.swift b/bitchatTests/Services/BLEReceivePipelineTests.swift new file mode 100644 index 00000000..91a63efd --- /dev/null +++ b/bitchatTests/Services/BLEReceivePipelineTests.swift @@ -0,0 +1,105 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("BLE receive pipeline tests") +struct BLEReceivePipelineTests { + @Test("context includes sender, type-scoped message ID, and logging policy") + func contextBuildsTypeScopedMessageID() { + let sender = PeerID(str: "1122334455667788") + let local = PeerID(str: "8877665544332211") + let packet = makePacket(type: .message, sender: sender, timestamp: 1234) + + let context = BLEReceivePipeline.context(for: packet, localPeerID: local) + + #expect(context.senderID == sender) + #expect(context.messageID == "\(sender)-1234-\(MessageType.message.rawValue)") + #expect(context.messageType == .message) + #expect(context.shouldDeduplicate) + #expect(context.logsHandlingDetails) + } + + @Test("fragments and self sync replays bypass global deduplication") + func contextBypassesDeduplicationForFragmentsAndSelfSyncReplay() { + let local = PeerID(str: "1122334455667788") + let remote = PeerID(str: "8877665544332211") + + let fragment = BLEReceivePipeline.context( + for: makePacket(type: .fragment, sender: remote), + localPeerID: local + ) + let selfReplay = BLEReceivePipeline.context( + for: makePacket(type: .message, sender: local, ttl: 0), + localPeerID: local + ) + + #expect(!fragment.shouldDeduplicate) + #expect(!selfReplay.shouldDeduplicate) + } + + @Test("dense duplicate traffic cancels pending relays but sparse traffic does not") + func duplicateRelayCancellationUsesGraphDensity() { + #expect(!BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: 2)) + #expect(BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: 3)) + } + + @Test("relay decision maps packet context and suppresses local recipient traffic") + func relayDecisionSuppressesLocalRecipientTraffic() { + let sender = PeerID(str: "1122334455667788") + let local = PeerID(str: "8877665544332211") + let packet = makePacket( + type: .noiseEncrypted, + sender: sender, + recipient: local, + ttl: 7 + ) + + let decision = BLEReceivePipeline.relayDecision( + for: packet, + senderID: sender, + localPeerID: local, + degree: 3, + highDegreeThreshold: TransportConfig.bleHighDegreeThreshold + ) + + #expect(!decision.shouldRelay) + } + + @Test("recent traffic tracker prunes by count and time window") + func recentTrafficTrackerPrunesByCountAndWindow() { + var tracker = BLERecentTrafficTracker() + let now = Date(timeIntervalSince1970: 1_000) + + tracker.recordPacket(at: now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds - 1)) + #expect(!tracker.hasTraffic(within: TransportConfig.bleRecentPacketWindowSeconds, now: now)) + + for index in 0...TransportConfig.bleRecentPacketWindowMaxCount { + tracker.recordPacket(at: now.addingTimeInterval(Double(index) * 0.001)) + } + + #expect(tracker.count == TransportConfig.bleRecentPacketWindowMaxCount) + #expect(tracker.hasTraffic(within: 1.0, now: now.addingTimeInterval(0.1))) + + tracker.removeAll() + #expect(tracker.count == 0) + } + + private func makePacket( + type: MessageType, + sender: PeerID, + recipient: PeerID? = nil, + timestamp: UInt64 = 1, + ttl: UInt8 = 7 + ) -> BitchatPacket { + BitchatPacket( + type: type.rawValue, + senderID: Data(hexString: sender.id) ?? Data(), + recipientID: recipient.flatMap { Data(hexString: $0.id) }, + timestamp: timestamp, + payload: Data([0x01, 0x02]), + signature: nil, + ttl: ttl + ) + } +} diff --git a/bitchatTests/Services/BLESubscriptionAnnounceLimiterTests.swift b/bitchatTests/Services/BLESubscriptionAnnounceLimiterTests.swift new file mode 100644 index 00000000..4f6966c3 --- /dev/null +++ b/bitchatTests/Services/BLESubscriptionAnnounceLimiterTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +@testable import bitchat + +@Suite("BLE subscription announce limiter tests") +struct BLESubscriptionAnnounceLimiterTests { + @Test("first subscription is allowed and repeated subscriptions are rate limited") + func repeatedSubscriptionsAreRateLimited() { + var limiter = BLESubscriptionAnnounceLimiter() + let centralID = "central-a" + let now = Date(timeIntervalSince1970: 100) + + #expect(limiter.decision(for: centralID, now: now) == .allowed) + #expect(limiter.trackedCentralCount == 1) + + let second = limiter.decision(for: centralID, now: now.addingTimeInterval(0.1)) + #expect(second == .rateLimited( + backoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds, + attemptCount: 1, + suppressAnnounce: false + )) + } + + @Test("rapid subscription attempts eventually suppress announces") + func rapidAttemptsSuppressAnnouncesAtThreshold() { + var limiter = BLESubscriptionAnnounceLimiter() + let centralID = "central-a" + let now = Date(timeIntervalSince1970: 100) + + #expect(limiter.decision(for: centralID, now: now) == .allowed) + + var decision = BLESubscriptionAnnounceDecision.allowed + for attempt in 2...TransportConfig.bleSubscriptionRateLimitMaxAttempts { + decision = limiter.decision( + for: centralID, + now: now.addingTimeInterval(Double(attempt) * 0.01) + ) + } + + if case let .rateLimited(_, _, suppressAnnounce) = decision { + #expect(suppressAnnounce) + } else { + Issue.record("Expected rate-limited decision at suppression threshold") + } + } + + @Test("stale limiter entries are pruned on the next decision") + func staleEntriesArePruned() { + var limiter = BLESubscriptionAnnounceLimiter() + let staleCentralID = "central-a" + let freshCentralID = "central-b" + let now = Date(timeIntervalSince1970: 100) + + #expect(limiter.decision(for: staleCentralID, now: now) == .allowed) + #expect(limiter.trackedCentralCount == 1) + + let afterWindow = now.addingTimeInterval(TransportConfig.bleSubscriptionRateLimitWindowSeconds + 1) + #expect(limiter.decision(for: freshCentralID, now: afterWindow) == .allowed) + #expect(limiter.trackedCentralCount == 1) + } +}