diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index c96aeb0e..a5d7b3cc 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -202,8 +202,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey) cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData) } catch { - // Log error but continue with empty cache - SecureLogger.error(error, context: "Failed to load identity cache", category: .security) + cache = IdentityCache() + let deleted = keychain.deleteIdentityKey(forKey: cacheKey) + SecureLogger.warning( + "Discarded unreadable identity cache; starting fresh (deleted=\(deleted), error=\(error.localizedDescription))", + category: .security + ) } } diff --git a/bitchat/Services/BLE/BLEFanoutSelector.swift b/bitchat/Services/BLE/BLEFanoutSelector.swift new file mode 100644 index 00000000..7232c642 --- /dev/null +++ b/bitchat/Services/BLE/BLEFanoutSelector.swift @@ -0,0 +1,109 @@ +import BitFoundation +import CryptoKit +import Foundation + +struct BLEFanoutSelection: Equatable { + let peripheralIDs: Set + let centralIDs: Set +} + +enum BLEFanoutSelector { + static func selectLinks( + peripheralIDs: [String], + centralIDs: [String], + ingressLink: BLEIngressLinkID?, + excludedLinks: Set = [], + directedPeerHint: PeerID?, + packetType: UInt8, + messageID: String + ) -> BLEFanoutSelection { + let allowed = allowedLinks( + peripheralIDs: peripheralIDs, + centralIDs: centralIDs, + ingressLink: ingressLink, + excludedLinks: excludedLinks + ) + + guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else { + return BLEFanoutSelection( + peripheralIDs: Set(allowed.peripheralIDs), + centralIDs: Set(allowed.centralIDs) + ) + } + + return BLEFanoutSelection( + peripheralIDs: deterministicSubset( + ids: allowed.peripheralIDs, + k: subsetSize(for: allowed.peripheralIDs.count), + seed: messageID + ), + centralIDs: deterministicSubset( + ids: allowed.centralIDs, + k: subsetSize(for: allowed.centralIDs.count), + seed: messageID + ) + ) + } + + private static func allowedLinks( + peripheralIDs: [String], + centralIDs: [String], + ingressLink: BLEIngressLinkID?, + excludedLinks: Set + ) -> (peripheralIDs: [String], centralIDs: [String]) { + var allowedPeripheralIDs = peripheralIDs + var allowedCentralIDs = centralIDs + var blockedLinks = excludedLinks + + if let ingressLink { + blockedLinks.insert(ingressLink) + } + + allowedPeripheralIDs.removeAll { blockedLinks.contains(.peripheral($0)) } + allowedCentralIDs.removeAll { blockedLinks.contains(.central($0)) } + + return (allowedPeripheralIDs, allowedCentralIDs) + } + + private static func shouldSubset(packetType: UInt8, directedPeerHint: PeerID?) -> Bool { + directedPeerHint == nil + && packetType != MessageType.fragment.rawValue + && packetType != MessageType.announce.rawValue + && packetType != MessageType.requestSync.rawValue + } + + private static func subsetSize(for count: Int) -> Int { + guard count > 0 else { return 0 } + if count <= 2 { return count } + + var value = count - 1 + var bits = 0 + while value > 0 { + value >>= 1 + bits += 1 + } + return min(count, max(1, bits + 1)) + } + + private static func deterministicSubset(ids: [String], k: Int, seed: String) -> Set { + guard k > 0 && ids.count > k else { return Set(ids) } + + var scored: [(score: [UInt8], id: String)] = [] + for id in ids { + let data = (seed + "::" + id).data(using: .utf8) ?? Data() + let digest = Array(SHA256.hash(data: data)) + scored.append((digest, id)) + } + + scored.sort { lhs, rhs in + for index in 0..= 13 else { return nil } + + var senderU64: UInt64 = 0 + for byte in packet.senderID.prefix(8) { + senderU64 = (senderU64 << 8) | UInt64(byte) + } + + var fragmentU64: UInt64 = 0 + for byte in packet.payload.prefix(8) { + fragmentU64 = (fragmentU64 << 8) | UInt64(byte) + } + + let index = Int((UInt16(packet.payload[8]) << 8) | UInt16(packet.payload[9])) + let total = Int((UInt16(packet.payload[10]) << 8) | UInt16(packet.payload[11])) + + guard total > 0 && total <= 10_000 && index >= 0 && index < total else { + return nil + } + + let isBroadcastFragment: Bool = { + guard let recipient = packet.recipientID else { return true } + return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF } + }() + + self.key = BLEFragmentKey(sender: senderU64, id: fragmentU64) + self.index = index + self.total = total + self.originalType = packet.payload[12] + self.fragmentData = Data(packet.payload.suffix(from: 13)) + self.isBroadcastFragment = isBroadcastFragment + } +} + +struct BLEFragmentAssemblyBuffer { + enum AppendResult: Equatable { + case stored(header: BLEFragmentHeader, started: Bool) + case complete(header: BLEFragmentHeader, reassembledData: Data, started: Bool) + case oversized(header: BLEFragmentHeader, projectedSize: Int, limit: Int, started: Bool) + } + + private struct Metadata { + let type: UInt8 + let total: Int + let timestamp: Date + } + + private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:] + private var metadataByKey: [BLEFragmentKey: Metadata] = [:] + + mutating func removeAll() { + fragmentsByKey.removeAll() + metadataByKey.removeAll() + } + + @discardableResult + mutating func removeExpired(before cutoff: Date) -> Int { + let expiredKeys = metadataByKey + .filter { $0.value.timestamp < cutoff } + .map(\.key) + + for key in expiredKeys { + fragmentsByKey.removeValue(forKey: key) + metadataByKey.removeValue(forKey: key) + } + + return expiredKeys.count + } + + mutating func append( + _ header: BLEFragmentHeader, + maxInFlightAssemblies: Int, + now: Date = Date() + ) -> AppendResult { + let started = startAssemblyIfNeeded(for: header, maxInFlightAssemblies: maxInFlightAssemblies, now: now) + + let currentSize = fragmentsByKey[header.key]?.values.reduce(0) { $0 + $1.count } ?? 0 + let limit = Self.assemblyLimit(for: header.originalType) + let projectedSize = currentSize + header.fragmentData.count + + guard projectedSize <= limit else { + fragmentsByKey.removeValue(forKey: header.key) + metadataByKey.removeValue(forKey: header.key) + return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started) + } + + fragmentsByKey[header.key]?[header.index] = header.fragmentData + + guard let fragments = fragmentsByKey[header.key], + fragments.count == header.total else { + return .stored(header: header, started: started) + } + + let reassembled = (0.. Bool { + guard fragmentsByKey[header.key] == nil else { return false } + + if fragmentsByKey.count >= maxInFlightAssemblies, + let oldest = metadataByKey.min(by: { $0.value.timestamp < $1.value.timestamp })?.key { + fragmentsByKey.removeValue(forKey: oldest) + metadataByKey.removeValue(forKey: oldest) + } + + fragmentsByKey[header.key] = [:] + metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now) + return true + } + + private static func assemblyLimit(for originalType: UInt8) -> Int { + if originalType == MessageType.fileTransfer.rawValue { + // Allow headroom for TLV metadata and binary framing overhead. + return FileTransferLimits.maxFramedFileBytes + } + + return FileTransferLimits.maxPayloadBytes + } +} diff --git a/bitchat/Services/BLE/BLEInboundWriteBuffer.swift b/bitchat/Services/BLE/BLEInboundWriteBuffer.swift new file mode 100644 index 00000000..155a16d6 --- /dev/null +++ b/bitchat/Services/BLE/BLEInboundWriteBuffer.swift @@ -0,0 +1,70 @@ +import BitFoundation +import Foundation + +struct BLEInboundWriteChunk: Equatable { + let offset: Int + let data: Data +} + +struct BLEInboundWriteAppendMetadata: Equatable { + let accumulatedBytes: Int + let appendedBytes: Int + let offsets: [Int] + let packetType: UInt8? +} + +struct BLEInboundWriteBuffer { + enum AppendResult { + case decoded(packet: BitchatPacket, metadata: BLEInboundWriteAppendMetadata) + case waiting(metadata: BLEInboundWriteAppendMetadata) + case oversized(metadata: BLEInboundWriteAppendMetadata) + } + + private var buffersByCentralID: [String: Data] = [:] + + mutating func removeAll() { + buffersByCentralID.removeAll() + } + + mutating func append( + chunks: [BLEInboundWriteChunk], + for centralID: String, + capBytes: Int + ) -> AppendResult { + var combined = buffersByCentralID[centralID] ?? Data() + var appendedBytes = 0 + var offsets: [Int] = [] + + for chunk in chunks where !chunk.data.isEmpty { + offsets.append(chunk.offset) + let end = chunk.offset + chunk.data.count + + if combined.count < end { + combined.append(Data(repeating: 0, count: end - combined.count)) + } + + combined.replaceSubrange(chunk.offset..= 2 ? combined[1] : nil + ) + + if let packet = BinaryProtocol.decode(combined) { + buffersByCentralID.removeValue(forKey: centralID) + return .decoded(packet: packet, metadata: metadata) + } + + guard combined.count <= capBytes else { + buffersByCentralID.removeValue(forKey: centralID) + return .oversized(metadata: metadata) + } + + buffersByCentralID[centralID] = combined + return .waiting(metadata: metadata) + } +} diff --git a/bitchat/Services/BLE/BLEIngressLinkRegistry.swift b/bitchat/Services/BLE/BLEIngressLinkRegistry.swift new file mode 100644 index 00000000..ba41648c --- /dev/null +++ b/bitchat/Services/BLE/BLEIngressLinkRegistry.swift @@ -0,0 +1,108 @@ +import BitFoundation +import Foundation + +enum BLEIngressLinkID: Hashable, Equatable { + case peripheral(String) + case central(String) +} + +struct BLEIngressPacketContext: Equatable { + let receivedFromPeerID: PeerID + let validationPeerID: PeerID +} + +struct BLEIngressLinkRecord: Equatable { + let link: BLEIngressLinkID + let peerID: PeerID + let timestamp: Date +} + +enum BLEIngressRejection: Error, Equatable { + case selfLoopback(packetType: UInt8) + case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID) +} + +struct BLEIngressLinkRegistry { + private var ingressByMessageID: [String: BLEIngressLinkRecord] = [:] + + var isEmpty: Bool { + ingressByMessageID.isEmpty + } + + mutating func removeAll() { + ingressByMessageID.removeAll() + } + + func record(for packet: BitchatPacket) -> BLEIngressLinkRecord? { + ingressByMessageID[Self.messageID(for: packet)] + } + + func link(for packet: BitchatPacket) -> BLEIngressLinkID? { + record(for: packet)?.link + } + + func peerID(for packet: BitchatPacket) -> PeerID? { + record(for: packet)?.peerID + } + + mutating func recordIfNew( + _ packet: BitchatPacket, + link: BLEIngressLinkID, + peerID: PeerID, + now: Date = Date(), + lifetime: TimeInterval + ) -> Bool { + let messageID = Self.messageID(for: packet) + if let existing = ingressByMessageID[messageID], + now.timeIntervalSince(existing.timestamp) <= lifetime { + return false + } + + ingressByMessageID[messageID] = BLEIngressLinkRecord(link: link, peerID: peerID, timestamp: now) + return true + } + + mutating func prune(before cutoff: Date) { + ingressByMessageID = ingressByMessageID.filter { $0.value.timestamp >= cutoff } + } + + static func packetContext( + for packet: BitchatPacket, + claimedSenderID: PeerID, + boundPeerID: PeerID?, + localPeerID: PeerID, + directAnnounceTTL: UInt8 + ) -> Result { + if claimedSenderID == localPeerID, + !isSelfAuthoredSyncResponse(packet) { + return .failure(.selfLoopback(packetType: packet.type)) + } + + if let boundPeerID, + boundPeerID != claimedSenderID, + requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) { + return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID)) + } + + let receivedFromPeerID = boundPeerID ?? claimedSenderID + let validationPeerID = packet.isRSR ? receivedFromPeerID : claimedSenderID + return .success(BLEIngressPacketContext( + receivedFromPeerID: receivedFromPeerID, + validationPeerID: validationPeerID + )) + } + + static func messageID(for packet: BitchatPacket) -> String { + let senderID = packet.senderID.hexEncodedString() + let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString() + return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)" + } + + private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool { + packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL + } + + private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool { + packet.isRSR && packet.ttl == 0 + } +} diff --git a/bitchat/Services/BLE/BLEOutboundNotificationBuffer.swift b/bitchat/Services/BLE/BLEOutboundNotificationBuffer.swift new file mode 100644 index 00000000..6ccb4346 --- /dev/null +++ b/bitchat/Services/BLE/BLEOutboundNotificationBuffer.swift @@ -0,0 +1,47 @@ +import Foundation + +struct BLEPendingNotification { + let data: Data + let targets: [Target]? +} + +struct BLEOutboundNotificationBuffer { + enum EnqueueResult { + case enqueued(count: Int) + case full(count: Int) + } + + private var notifications: [BLEPendingNotification] = [] + + var count: Int { + notifications.count + } + + var isEmpty: Bool { + notifications.isEmpty + } + + mutating func removeAll() { + notifications.removeAll() + } + + mutating func enqueue(data: Data, targets: [Target]?, capCount: Int) -> EnqueueResult { + guard notifications.count < capCount else { + return .full(count: notifications.count) + } + + notifications.append(BLEPendingNotification(data: data, targets: targets)) + return .enqueued(count: notifications.count) + } + + mutating func takeAll() -> [BLEPendingNotification] { + let pending = notifications + notifications.removeAll() + return pending + } + + mutating func prepend(_ pending: [BLEPendingNotification]) { + guard !pending.isEmpty else { return } + notifications.insert(contentsOf: pending, at: 0) + } +} diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index be764f9b..cf4b3e37 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -3,7 +3,6 @@ import BitFoundation import Foundation import CoreBluetooth import Combine -import CryptoKit #if os(iOS) import UIKit #endif @@ -88,9 +87,7 @@ final class BLEService: NSObject { private let meshTopology = MeshTopologyTracker() // 5. Fragment Reassembly (necessary for messages > MTU) - private struct FragmentKey: Hashable { let sender: UInt64; let id: UInt64 } - private var incomingFragments: [FragmentKey: [Int: Data]] = [:] - private var fragmentMetadata: [FragmentKey: (type: UInt8, total: Int, timestamp: Date)] = [:] + private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer() private struct ActiveTransferState { let totalFragments: Int var sentFragments: Int @@ -139,21 +136,17 @@ final class BLEService: NSObject { // Noise typed payloads (ACKs, read receipts, etc.) pending handshake private var pendingNoisePayloadsAfterHandshake: [PeerID: [Data]] = [:] // Queue for notifications that failed due to full queue - private var pendingNotifications: [(data: Data, centrals: [CBCentral]?)] = [] + private var pendingNotifications = BLEOutboundNotificationBuffer() // Accumulate long write chunks per central until a full frame decodes - private var pendingWriteBuffers: [String: Data] = [:] + private var pendingWriteBuffers = BLEInboundWriteBuffer() // 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] = [] - // Ingress link tracking for last-hop suppression - private enum LinkID: Hashable { - case peripheral(String) - case central(String) - } - private var ingressByMessageID: [String: (link: LinkID, timestamp: Date)] = [:] + // Ingress link tracking for duplicate and last-hop suppression + private var ingressLinks = BLEIngressLinkRegistry() private struct PendingFragmentTransfer { let packet: BitchatPacket @@ -359,8 +352,9 @@ final class BLEService: NSObject { pendingPeripheralWrites.removeAll() pendingFragmentTransfers.removeAll() pendingNotifications.removeAll() + fragmentAssemblyBuffer.removeAll() pendingDirectedRelays.removeAll() - ingressByMessageID.removeAll() + ingressLinks.removeAll() recentPacketTimestamps.removeAll() scheduledRelays.values.forEach { $0.cancel() } scheduledRelays.removeAll() @@ -576,8 +570,7 @@ final class BLEService: NSObject { let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) { let entries = activeTransfers.map { ($0.key, $0.value.workItems) } peers.removeAll() - incomingFragments.removeAll() - fragmentMetadata.removeAll() + fragmentAssemblyBuffer.removeAll() activeTransfers.removeAll() // Also clear pending message queues to avoid stale state across sessions pendingMessagesAfterHandshake.removeAll() @@ -819,42 +812,28 @@ final class BLEService: NSObject { case unknown } - private struct IngressPacketContext { - let receivedFromPeerID: PeerID - let validationPeerID: PeerID - } - - private func requiresDirectSenderBinding(_ packet: BitchatPacket) -> Bool { - packet.type == MessageType.announce.rawValue && packet.ttl == messageTTL - } - - private func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool { - packet.isRSR && packet.ttl == 0 - } - private func makeIngressPacketContext( for packet: BitchatPacket, claimedSenderID: PeerID, boundPeerID: PeerID?, linkDescription: String - ) -> IngressPacketContext? { - if claimedSenderID == myPeerID, - !isSelfAuthoredSyncResponse(packet) { + ) -> BLEIngressPacketContext? { + switch BLEIngressLinkRegistry.packetContext( + for: packet, + claimedSenderID: claimedSenderID, + boundPeerID: boundPeerID, + localPeerID: myPeerID, + directAnnounceTTL: messageTTL + ) { + case .success(let context): + return context + case .failure(.selfLoopback): SecureLogger.debug("â†Šī¸ Dropping BLE self-loopback packet type \(packet.type) from \(linkDescription)", category: .session) return nil - } - - if let boundPeerID, boundPeerID != claimedSenderID, requiresDirectSenderBinding(packet) { + 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 } - - let receivedFromPeerID = boundPeerID ?? claimedSenderID - let validationPeerID = packet.isRSR ? receivedFromPeerID : claimedSenderID - return IngressPacketContext( - receivedFromPeerID: receivedFromPeerID, - validationPeerID: validationPeerID - ) } private func validatePacket(_ packet: BitchatPacket, from peerID: PeerID, connectionSource: ConnectionSource = .unknown) -> Bool { @@ -887,18 +866,14 @@ final class BLEService: NSObject { return true } - private func recordIngressIfNew(_ packet: BitchatPacket, link: LinkID) -> Bool { - let messageID = makeMessageID(for: packet) - let now = Date() - + private func recordIngressIfNew(_ packet: BitchatPacket, link: BLEIngressLinkID, peerID: PeerID) -> Bool { return collectionsQueue.sync(flags: .barrier) { - if let existing = ingressByMessageID[messageID], - now.timeIntervalSince(existing.timestamp) <= TransportConfig.bleIngressRecordLifetimeSeconds { - return false - } - - ingressByMessageID[messageID] = (link, now) - return true + ingressLinks.recordIfNew( + packet, + link: link, + peerID: peerID, + lifetime: TransportConfig.bleIngressRecordLifetimeSeconds + ) } } @@ -1005,9 +980,14 @@ final class BLEService: NSObject { private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) { collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } - if self.pendingNotifications.count < TransportConfig.blePendingNotificationsCapCount { - self.pendingNotifications.append((data: data, centrals: centrals)) - SecureLogger.debug("📋 Queued \(context) packet for retry (pending=\(self.pendingNotifications.count))", category: .session) + let result = self.pendingNotifications.enqueue( + data: data, + targets: centrals, + capCount: TransportConfig.blePendingNotificationsCapCount + ) + + if case let .enqueued(count) = result { + SecureLogger.debug("📋 Queued \(context) packet for retry (pending=\(count))", category: .session) return } @@ -1025,9 +1005,10 @@ final class BLEService: NSObject { } private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: PeerID?) { - // Determine last-hop link for this message to avoid echoing back + // Determine the last-hop peer/link for this message to avoid echoing back over either BLE role. let messageID = makeMessageID(for: packet) - let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link } + let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) } + let excludedPeerLinks = links(to: ingressRecord?.peerID) let directedPeerHint: PeerID? = { if let explicit = directedOnlyPeer { return explicit } if let recipient = PeerID(str: packet.recipientID?.hexEncodedString()), !recipient.isEmpty { @@ -1073,35 +1054,19 @@ final class BLEService: NSObject { subscribedCentrals = [] } - // Exclude ingress link - var allowedPeripheralIDs = connectedPeripheralIDs - var allowedCentralIDs = centralIDs - if let ingress = ingressLink { - switch ingress { - case .peripheral(let id): - allowedPeripheralIDs.removeAll { $0 == id } - case .central(let id): - allowedCentralIDs.removeAll { $0 == id } - } - } - - // For broadcast (no directed peer) and non-fragment, choose a subset deterministically - // Special-case control/presence messages: do NOT subset to maximize immediate coverage - var selectedPeripheralIDs = Set(allowedPeripheralIDs) - var selectedCentralIDs = Set(allowedCentralIDs) - if directedPeerHint == nil - && packet.type != MessageType.fragment.rawValue - && packet.type != MessageType.announce.rawValue - && packet.type != MessageType.requestSync.rawValue { - let kp = subsetSizeForFanout(allowedPeripheralIDs.count) - let kc = subsetSizeForFanout(allowedCentralIDs.count) - selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID) - selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID) - } + let selectedLinks = BLEFanoutSelector.selectLinks( + peripheralIDs: connectedPeripheralIDs, + centralIDs: centralIDs, + ingressLink: ingressRecord?.link, + excludedLinks: excludedPeerLinks, + directedPeerHint: directedPeerHint, + packetType: packet.type, + messageID: messageID + ) // If directed and we currently have no links to forward on, spool for a short window if let only = directedPeerHint, - selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty, + selectedLinks.peripheralIDs.isEmpty && selectedLinks.centralIDs.isEmpty, (packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) { spoolDirectedPacket(packet, recipientPeerID: only) } @@ -1109,14 +1074,14 @@ final class BLEService: NSObject { // Writes to selected connected peripherals for s in states where s.isConnected { let pid = s.peripheral.identifier.uuidString - guard selectedPeripheralIDs.contains(pid) else { continue } + guard selectedLinks.peripheralIDs.contains(pid) else { continue } if let ch = s.characteristic { writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority) } } // Notify selected subscribed centrals if let ch = characteristic { - let targets = subscribedCentrals.filter { selectedCentralIDs.contains($0.identifier.uuidString) } + let targets = subscribedCentrals.filter { selectedLinks.centralIDs.contains($0.identifier.uuidString) } if !targets.isEmpty { let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false if !success { @@ -2055,6 +2020,15 @@ extension BLEService { } } +private extension BLEService { + static func shouldRediscoverBitChatService( + invalidatedServiceUUIDs: [CBUUID], + cachedServiceUUIDs: [CBUUID]? + ) -> Bool { + invalidatedServiceUUIDs.contains(serviceUUID) || cachedServiceUUIDs?.contains(serviceUUID) != true + } +} + #if DEBUG // Test-only helper to inject packets into the receive pipeline extension BLEService { @@ -2096,7 +2070,17 @@ extension BLEService { } func _test_recordIngressIfNew(packet: BitchatPacket, linkID: String) -> Bool { - recordIngressIfNew(packet, link: .central(linkID)) + recordIngressIfNew(packet, link: .central(linkID), peerID: PeerID(hexData: packet.senderID)) + } + + static func _test_shouldRediscoverBitChatService( + invalidatedServiceUUIDs: [CBUUID], + cachedServiceUUIDs: [CBUUID]? + ) -> Bool { + shouldRediscoverBitChatService( + invalidatedServiceUUIDs: invalidatedServiceUUIDs, + cachedServiceUUIDs: cachedServiceUUIDs + ) } } #endif @@ -2257,7 +2241,7 @@ extension BLEService: CBPeripheralDelegate { peripherals[peripheralUUID] = state } - if !recordIngressIfNew(packet, link: .peripheral(peripheralUUID)) { + if !recordIngressIfNew(packet, link: .peripheral(peripheralUUID), peerID: context.receivedFromPeerID) { continue } processNotificationPacket( @@ -2309,18 +2293,23 @@ extension BLEService: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { SecureLogger.warning("âš ī¸ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session) - - // Check if our service was invalidated (peer app quit) - let hasOurService = peripheral.services?.contains { $0.uuid == BLEService.serviceUUID } ?? false - - if !hasOurService { - // Service is gone - disconnect - SecureLogger.warning("❌ BitChat service removed - disconnecting from \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session) - centralManager?.cancelPeripheralConnection(peripheral) - } else { - // Try to rediscover - peripheral.discoverServices([BLEService.serviceUUID]) + + let shouldRediscover = BLEService.shouldRediscoverBitChatService( + invalidatedServiceUUIDs: invalidatedServices.map(\.uuid), + cachedServiceUUIDs: peripheral.services?.map(\.uuid) + ) + + guard shouldRediscover else { return } + + let peripheralID = peripheral.identifier.uuidString + if var state = peripherals[peripheralID] { + state.characteristic = nil + state.assembler = NotificationStreamAssembler() + peripherals[peripheralID] = state } + + SecureLogger.debug("🔄 BitChat service changed for \(peripheral.name ?? peripheral.identifier.uuidString), rediscovering", category: .session) + peripheral.discoverServices([BLEService.serviceUUID]) } func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { @@ -2571,55 +2560,51 @@ extension BLEService: CBPeripheralManagerDelegate { func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { SecureLogger.debug("📤 Peripheral manager ready to send more notifications", category: .session) - - // Retry pending notifications now that queue has space + + drainPendingNotifications(logPrefix: "✅ Sent") + } + + private func drainPendingNotifications(logPrefix: String) { collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self, let characteristic = self.characteristic, !self.pendingNotifications.isEmpty else { return } - - let pending = self.pendingNotifications - self.pendingNotifications.removeAll() - - // Try to send pending notifications - var sentCount = 0 - for (index, (data, centrals)) in pending.enumerated() { - if let centrals = centrals { - // Send to specific centrals - let success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: centrals) ?? false - if !success { - // Still full, re-queue this and all remaining items - let remaining = pending.dropFirst(index) - self.pendingNotifications.append(contentsOf: remaining) - SecureLogger.debug("âš ī¸ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items", category: .session) - break // Stop trying, wait for next ready callback - } else { - sentCount += 1 - } - } else { - // Broadcast to all - let success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false - if !success { - // Still full, re-queue this and all remaining items - let remaining = pending.dropFirst(index) - self.pendingNotifications.append(contentsOf: remaining) - SecureLogger.debug("âš ī¸ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items", category: .session) - break - } else { - sentCount += 1 - } - } - } + + let pending = self.pendingNotifications.takeAll() + let sentCount = self.sendPendingNotifications(pending, characteristic: characteristic) if sentCount > 0 { - SecureLogger.debug("✅ Sent \(sentCount) pending notifications from retry queue", category: .session) + SecureLogger.debug("\(logPrefix) \(sentCount) pending notifications from retry queue", category: .session) } - + if !self.pendingNotifications.isEmpty { SecureLogger.debug("📋 Still have \(self.pendingNotifications.count) pending notifications", category: .session) } } } + + private func sendPendingNotifications(_ pending: [BLEPendingNotification], characteristic: CBMutableCharacteristic) -> Int { + var sentCount = 0 + + for (index, notification) in pending.enumerated() { + let success = peripheralManager?.updateValue( + notification.data, + for: characteristic, + onSubscribedCentrals: notification.targets + ) ?? false + + guard success else { + let remaining = Array(pending.dropFirst(index)) + pendingNotifications.prepend(remaining) + SecureLogger.debug("âš ī¸ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items", category: .session) + break + } + + sentCount += 1 + } + + return sentCount + } func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { // Suppress logs for single write requests to reduce noise @@ -2641,85 +2626,85 @@ extension BLEService: CBPeripheralManagerDelegate { // Sort by offset ascending let sorted = group.sorted { $0.offset < $1.offset } let hasMultiple = sorted.count > 1 || (sorted.first?.offset ?? 0) > 0 - - // Always merge into a persistent per-central buffer to handle multi-callback long writes - var combined = pendingWriteBuffers[centralUUID] ?? Data() - var appendedBytes = 0 - var offsets: [Int] = [] - for r in sorted { - guard let chunk = r.value, !chunk.isEmpty else { continue } - offsets.append(r.offset) - let end = r.offset + chunk.count - if combined.count < end { - combined.append(Data(repeating: 0, count: end - combined.count)) - } - // Write chunk into the correct position (supports out-of-order and overlapping writes) - combined.replaceSubrange(r.offset..= 2 { - let peekType = combined[1] - if peekType != MessageType.announce.rawValue { - SecureLogger.debug("đŸ“Ĩ Accumulated write from central \(centralUUID): size=\(combined.count) (+\(appendedBytes)) bytes (type=\(peekType)), offsets=\(offsets)", category: .session) - } + let chunks = sorted.compactMap { request -> BLEInboundWriteChunk? in + guard let data = request.value, !data.isEmpty else { return nil } + return BLEInboundWriteChunk(offset: request.offset, data: data) } - // Try decode the accumulated buffer - if let packet = BinaryProtocol.decode(combined) { - // Clear buffer on success - pendingWriteBuffers.removeValue(forKey: centralUUID) + let result = pendingWriteBuffers.append( + chunks: chunks, + for: centralUUID, + capBytes: TransportConfig.blePendingWriteBufferCapBytes + ) - let claimedSenderID = PeerID(hexData: packet.senderID) - let context = makeIngressPacketContext( - for: packet, - claimedSenderID: claimedSenderID, - boundPeerID: centralToPeerID[centralUUID], - linkDescription: "Central \(centralUUID.prefix(8))â€Ļ" - ) - guard let context else { continue } + switch result { + case let .decoded(packet, metadata): + logAccumulatedCentralWrite(metadata, centralUUID: centralUUID) + processDecodedCentralWrite(packet, centralUUID: centralUUID, central: sorted[0].central) - if !validatePacket(packet, from: context.validationPeerID, connectionSource: .central(centralUUID)) { - continue - } + case let .waiting(metadata): + logAccumulatedCentralWrite(metadata, centralUUID: centralUUID) + logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted) - if packet.type != MessageType.announce.rawValue { - SecureLogger.debug("đŸ“Ļ Decoded (combined) packet type: \(packet.type) from sender: \(claimedSenderID)", category: .session) - } - if !subscribedCentrals.contains(sorted[0].central) { - subscribedCentrals.append(sorted[0].central) - } - if packet.type == MessageType.announce.rawValue { - if packet.ttl == messageTTL { - centralToPeerID[centralUUID] = claimedSenderID - refreshLocalTopology() - } - if !recordIngressIfNew(packet, link: .central(centralUUID)) { - continue - } - handleReceivedPacket(packet, from: context.receivedFromPeerID) - } else { - if !recordIngressIfNew(packet, link: .central(centralUUID)) { - continue - } - handleReceivedPacket(packet, from: context.receivedFromPeerID) - } - } else { - // If buffer grows suspiciously large, reset to avoid memory leak - if combined.count > TransportConfig.blePendingWriteBufferCapBytes { // cap for safety - pendingWriteBuffers.removeValue(forKey: centralUUID) - SecureLogger.warning("âš ī¸ Dropping oversized pending write buffer (\(combined.count) bytes) for central \(centralUUID)", category: .session) - } - // If this was a single short write and still failed, log the raw chunk for debugging - if !hasMultiple, let only = sorted.first, let raw = only.value { - let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") - SecureLogger.error("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: .session) - } + case let .oversized(metadata): + logAccumulatedCentralWrite(metadata, centralUUID: centralUUID) + SecureLogger.warning("âš ī¸ Dropping oversized pending write buffer (\(metadata.accumulatedBytes) bytes) for central \(centralUUID)", category: .session) + logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted) } } - } + } + + private func logAccumulatedCentralWrite(_ metadata: BLEInboundWriteAppendMetadata, centralUUID: String) { + guard let packetType = metadata.packetType, + packetType != MessageType.announce.rawValue else { return } + + SecureLogger.debug( + "đŸ“Ĩ Accumulated write from central \(centralUUID): size=\(metadata.accumulatedBytes) (+\(metadata.appendedBytes)) bytes (type=\(packetType)), offsets=\(metadata.offsets)", + category: .session + ) + } + + private func logFailedSingleWriteIfNeeded(hasMultiple: Bool, sortedRequests: [CBATTRequest]) { + guard !hasMultiple, let raw = sortedRequests.first?.value else { return } + + let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") + SecureLogger.error("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: .session) + } + + private func processDecodedCentralWrite(_ packet: BitchatPacket, centralUUID: String, central: CBCentral) { + let claimedSenderID = PeerID(hexData: packet.senderID) + let context = makeIngressPacketContext( + for: packet, + claimedSenderID: claimedSenderID, + boundPeerID: centralToPeerID[centralUUID], + linkDescription: "Central \(centralUUID.prefix(8))â€Ļ" + ) + 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)", category: .session) + } + + if !subscribedCentrals.contains(central) { + subscribedCentrals.append(central) + } + + if packet.type == MessageType.announce.rawValue, + packet.ttl == messageTTL { + centralToPeerID[centralUUID] = claimedSenderID + refreshLocalTopology() + } + + guard recordIngressIfNew(packet, link: .central(centralUUID), peerID: context.receivedFromPeerID) else { + return + } + + handleReceivedPacket(packet, from: context.receivedFromPeerID) + } } // MARK: - Advertising Builders & Alias Rotation @@ -2860,6 +2845,10 @@ extension BLEService { } private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool { + if PeerID(hexData: packet.recipientID) == myPeerID { + return true + } + guard let route = packet.route, !route.isEmpty else { return false } let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData) guard let selfData = myRoutingData else { return false } @@ -2923,6 +2912,27 @@ extension BLEService { return bleQueue.sync { computeState() } } } + + private func links(to peerID: PeerID?) -> Set { + guard let peerID else { return [] } + + let computeLinks = { () -> Set in + var links: Set = [] + if let peripheralUUID = self.peerToPeripheralUUID[peerID] { + links.insert(.peripheral(peripheralUUID)) + } + for (centralUUID, mappedPeerID) in self.centralToPeerID where mappedPeerID == peerID { + links.insert(.central(centralUUID)) + } + return links + } + + if DispatchQueue.getSpecific(key: bleQueueKey) != nil { + return computeLinks() + } else { + return bleQueue.sync { computeLinks() } + } + } private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) { service.onPeerAuthenticated = { [weak self] peerID, fingerprint in @@ -2997,37 +3007,7 @@ extension BLEService { // MARK: Helpers: IDs, selection, and write backpressure private func makeMessageID(for packet: BitchatPacket) -> String { - let senderID = packet.senderID.hexEncodedString() - let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString() - return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)" - } - - private func subsetSizeForFanout(_ n: Int) -> Int { - guard n > 0 else { return 0 } - if n <= 2 { return n } - // approx ceil(log2(n)) + 1 without floating point - var v = n - 1 - var bits = 0 - while v > 0 { v >>= 1; bits += 1 } - return min(n, max(1, bits + 1)) - } - - private func selectDeterministicSubset(ids: [String], k: Int, seed: String) -> Set { - guard k > 0 && ids.count > k else { return Set(ids) } - // Stable order by SHA256(seed || "::" || id) - var scored: [(score: [UInt8], id: String)] = [] - for id in ids { - let msg = (seed + "::" + id).data(using: .utf8) ?? Data() - let digest = Array(SHA256.hash(data: msg)) - scored.append((digest, id)) - } - scored.sort { a, b in - for i in 0.. BLEOutboundWritePriority { @@ -3116,37 +3096,7 @@ extension BLEService { /// Periodically try to drain pending notifications as a backup mechanism private func drainPendingNotificationsIfPossible() { - collectionsQueue.async(flags: .barrier) { [weak self] in - guard let self = self, - let characteristic = self.characteristic, - !self.pendingNotifications.isEmpty else { return } - - let pending = self.pendingNotifications - self.pendingNotifications.removeAll() - - var sentCount = 0 - for (index, (data, centrals)) in pending.enumerated() { - let success: Bool - if let centrals = centrals { - success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: centrals) ?? false - } else { - success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false - } - - if !success { - // Re-queue this and all remaining items - let remaining = pending.dropFirst(index) - self.pendingNotifications.append(contentsOf: remaining) - break - } else { - sentCount += 1 - } - } - - if sentCount > 0 { - SecureLogger.debug("🔄 Periodic drain: sent \(sentCount) pending notifications", category: .session) - } - } + drainPendingNotifications(logPrefix: "🔄 Periodic drain: sent") } /// Periodically try to drain pending writes for all connected peripherals @@ -3601,102 +3551,19 @@ extension BLEService { return } - // Minimum header: 8 bytes ID + 2 index + 2 total + 1 type - guard packet.payload.count >= 13 else { return } + guard let header = BLEFragmentHeader(packet: packet) else { return } - // Compute compact fragment key (sender: 8 bytes, id: 8 bytes), big-endian - var senderU64: UInt64 = 0 - for b in packet.senderID.prefix(8) { senderU64 = (senderU64 << 8) | UInt64(b) } - var fragU64: UInt64 = 0 - for b in packet.payload.prefix(8) { fragU64 = (fragU64 << 8) | UInt64(b) } - // Parse big-endian UInt16 safely without alignment assumptions - let idxHi = UInt16(packet.payload[8]) - let idxLo = UInt16(packet.payload[9]) - let index = Int((idxHi << 8) | idxLo) - let totHi = UInt16(packet.payload[10]) - let totLo = UInt16(packet.payload[11]) - let total = Int((totHi << 8) | totLo) - let originalType = packet.payload[12] - let fragmentData = packet.payload.suffix(from: 13) - - // Sanity checks - add reasonable upper bound on total to prevent DoS - guard total > 0 && total <= 10000 && index >= 0 && index < total else { return } - - let isBroadcastFragment: Bool = { - guard let recipient = packet.recipientID else { return true } - return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF } - }() - if isBroadcastFragment { + if header.isBroadcastFragment { gossipSyncManager?.onPublicPacketSeen(packet) } - // Compute fragment key for this assembly - let key = FragmentKey(sender: senderU64, id: fragU64) - - // Critical section: Store fragment and check completion status - var shouldReassemble: Bool = false - var fragmentsToReassemble: [Int: Data]? = nil - - collectionsQueue.sync(flags: .barrier) { - if incomingFragments[key] == nil { - // Cap in-flight assemblies to prevent memory/battery blowups - if incomingFragments.count >= maxInFlightAssemblies { - // Evict the oldest assembly by timestamp - if let oldest = fragmentMetadata.min(by: { $0.value.timestamp < $1.value.timestamp })?.key { - incomingFragments.removeValue(forKey: oldest) - fragmentMetadata.removeValue(forKey: oldest) - } - } - incomingFragments[key] = [:] - fragmentMetadata[key] = (originalType, total, Date()) - SecureLogger.debug("đŸ“Ļ Started fragment assembly id=\(String(format: "%016llx", fragU64)) total=\(total)", category: .session) - } - - // Check cumulative size before storing this fragment - let currentSize = incomingFragments[key]?.values.reduce(0) { $0 + $1.count } ?? 0 - let assemblyLimit: Int = { - if originalType == MessageType.fileTransfer.rawValue { - // Allow headroom for TLV metadata and binary framing overhead. - return FileTransferLimits.maxFramedFileBytes - } - return FileTransferLimits.maxPayloadBytes - }() - let projectedSize = currentSize + fragmentData.count - guard projectedSize <= assemblyLimit else { - // Exceeds size limit - evict this assembly - SecureLogger.warning( - "đŸšĢ Fragment assembly exceeds size limit (\(projectedSize) bytes > \(assemblyLimit)), evicting. Type=\(originalType) Index=\(index)/\(total)", - category: .security - ) - incomingFragments.removeValue(forKey: key) - fragmentMetadata.removeValue(forKey: key) - shouldReassemble = false - fragmentsToReassemble = nil - return - } - - incomingFragments[key]?[index] = Data(fragmentData) - SecureLogger.debug("đŸ“Ļ Fragment \(index + 1)/\(total) (len=\(fragmentData.count)) for id=\(String(format: "%016llx", fragU64))", category: .session) - - // Check if complete - if let fragments = incomingFragments[key], fragments.count == total { - shouldReassemble = true - fragmentsToReassemble = fragments - } else { - shouldReassemble = false - fragmentsToReassemble = nil - } + let assemblyResult = collectionsQueue.sync(flags: .barrier) { + fragmentAssemblyBuffer.append(header, maxInFlightAssemblies: maxInFlightAssemblies) } - // Heavy work outside lock: reassemble and decode - guard shouldReassemble, let fragments = fragmentsToReassemble else { return } + logFragmentAssemblyResult(assemblyResult) - var reassembled = Data() - for i in 0.. \(limit)), evicting. Type=\(header.originalType) Index=\(header.index)/\(header.total)", + category: .security + ) } } @@ -3827,6 +3713,7 @@ extension BLEService { 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, @@ -4193,8 +4080,6 @@ extension BLEService { } private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) { - SecureLogger.debug("🔐 handleNoiseEncrypted called for packet from \(peerID)") - guard let recipientID = PeerID(hexData: packet.recipientID) else { SecureLogger.warning("âš ī¸ Encrypted message has no recipient ID", category: .session) return @@ -4215,8 +4100,15 @@ extension BLEService { // First byte indicates the payload type let payloadType = decrypted[0] let payloadData = decrypted.dropFirst() - - switch NoisePayloadType(rawValue: payloadType) { + + guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else { + SecureLogger.warning("âš ī¸ Unknown noise payload type: \(payloadType)") + return + } + + SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID)", category: .session) + + switch noisePayloadType { case .privateMessage: let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) notifyUI { [weak self] in @@ -4242,8 +4134,6 @@ extension BLEService { notifyUI { [weak self] in self?.deliverTransportEvent(.noisePayloadReceived(peerID: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)) } - case .none: - SecureLogger.warning("âš ī¸ Unknown noise payload type: \(payloadType)") } } catch NoiseEncryptionError.sessionNotEstablished { // We received an encrypted message before establishing a session with this peer. @@ -4486,11 +4376,7 @@ extension BLEService { // Clean old fragments (> configured seconds old) collectionsQueue.sync(flags: .barrier) { let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds) - let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key } - for fragmentID in oldFragments { - incomingFragments.removeValue(forKey: fragmentID) - fragmentMetadata.removeValue(forKey: fragmentID) - } + fragmentAssemblyBuffer.removeExpired(before: cutoff) } // Clean old connection timeout backoff entries (> window) @@ -4512,8 +4398,8 @@ extension BLEService { collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } let cutoff = now.addingTimeInterval(-TransportConfig.bleIngressRecordLifetimeSeconds) - if !self.ingressByMessageID.isEmpty { - self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff } + if !self.ingressLinks.isEmpty { + self.ingressLinks.prune(before: cutoff) } // Clean expired directed spooled items if !self.pendingDirectedRelays.isEmpty { diff --git a/bitchat/Services/RelayController.swift b/bitchat/Services/RelayController.swift index e7593b44..83773120 100644 --- a/bitchat/Services/RelayController.swift +++ b/bitchat/Services/RelayController.swift @@ -11,6 +11,7 @@ struct RelayDecision { struct RelayController { static func decide(ttl: UInt8, senderIsSelf: Bool, + recipientIsSelf: Bool = false, isEncrypted: Bool, isDirectedEncrypted: Bool, isFragment: Bool, @@ -22,7 +23,7 @@ struct RelayController { let ttlCap = min(ttl, TransportConfig.messageTTLDefault) // Suppress obvious non-relays - if ttlCap <= 1 || senderIsSelf { + if ttlCap <= 1 || senderIsSelf || recipientIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0) } diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 69df87c1..9b5155b2 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -163,6 +163,36 @@ struct BLEServiceCoreTests { #expect(ble._test_recordIngressIfNew(packet: packet, linkID: "central-a")) #expect(!ble._test_recordIngressIfNew(packet: packet, linkID: "central-b")) } + + @Test + func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws { + let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB") + + #expect(BLEService._test_shouldRediscoverBitChatService( + invalidatedServiceUUIDs: [BLEService.serviceUUID], + cachedServiceUUIDs: [otherService] + )) + } + + @Test + func modifiedServices_rediscoverWhenCachedServicesNoLongerIncludeBitChat() async throws { + let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB") + + #expect(BLEService._test_shouldRediscoverBitChatService( + invalidatedServiceUUIDs: [otherService], + cachedServiceUUIDs: [otherService] + )) + } + + @Test + func modifiedServices_ignoreUnrelatedInvalidationWhenBitChatIsStillCached() async throws { + let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB") + + #expect(!BLEService._test_shouldRediscoverBitChatService( + invalidatedServiceUUIDs: [otherService], + cachedServiceUUIDs: [BLEService.serviceUUID, otherService] + )) + } } private func makeService() -> BLEService { diff --git a/bitchatTests/Services/BLEFanoutSelectorTests.swift b/bitchatTests/Services/BLEFanoutSelectorTests.swift new file mode 100644 index 00000000..feec13db --- /dev/null +++ b/bitchatTests/Services/BLEFanoutSelectorTests.swift @@ -0,0 +1,95 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLEFanoutSelectorTests { + @Test + func directedSendUsesAllNonIngressLinks() { + let selection = BLEFanoutSelector.selectLinks( + peripheralIDs: ["p1", "p2"], + centralIDs: ["c1", "c2"], + ingressLink: .central("c1"), + directedPeerHint: PeerID(str: "1122334455667788"), + packetType: MessageType.noiseEncrypted.rawValue, + messageID: "message-1" + ) + + #expect(selection.peripheralIDs == Set(["p1", "p2"])) + #expect(selection.centralIDs == Set(["c2"])) + } + + @Test + func directedSendExcludesAllLinksToIngressPeer() { + let selection = BLEFanoutSelector.selectLinks( + peripheralIDs: ["p1", "p2"], + centralIDs: ["c1", "c2"], + ingressLink: .central("c1"), + excludedLinks: [.peripheral("p2"), .central("c2")], + directedPeerHint: PeerID(str: "1122334455667788"), + packetType: MessageType.noiseEncrypted.rawValue, + messageID: "message-1" + ) + + #expect(selection.peripheralIDs == Set(["p1"])) + #expect(selection.centralIDs.isEmpty) + } + + @Test + func controlPacketsUseAllNonIngressLinks() { + let selection = BLEFanoutSelector.selectLinks( + peripheralIDs: ["p1", "p2", "p3"], + centralIDs: ["c1", "c2", "c3"], + ingressLink: .peripheral("p2"), + directedPeerHint: nil, + packetType: MessageType.requestSync.rawValue, + messageID: "message-1" + ) + + #expect(selection.peripheralIDs == Set(["p1", "p3"])) + #expect(selection.centralIDs == Set(["c1", "c2", "c3"])) + } + + @Test + func broadcastPacketsUseDeterministicSubsetAfterIngressExclusion() { + let peripherals = (1...8).map { "p\($0)" } + let centrals = (1...8).map { "c\($0)" } + + let first = BLEFanoutSelector.selectLinks( + peripheralIDs: peripherals, + centralIDs: centrals, + ingressLink: .peripheral("p4"), + directedPeerHint: nil, + packetType: MessageType.message.rawValue, + messageID: "message-1" + ) + let second = BLEFanoutSelector.selectLinks( + peripheralIDs: peripherals, + centralIDs: centrals, + ingressLink: .peripheral("p4"), + directedPeerHint: nil, + packetType: MessageType.message.rawValue, + messageID: "message-1" + ) + + #expect(first == second) + #expect(!first.peripheralIDs.contains("p4")) + #expect(first.peripheralIDs.count == 4) + #expect(first.centralIDs.count == 4) + } + + @Test + func broadcastWithTwoLinksKeepsBothAfterIngressExclusion() { + let selection = BLEFanoutSelector.selectLinks( + peripheralIDs: ["p1", "p2"], + centralIDs: ["c1", "c2"], + ingressLink: nil, + directedPeerHint: nil, + packetType: MessageType.message.rawValue, + messageID: "message-1" + ) + + #expect(selection.peripheralIDs == Set(["p1", "p2"])) + #expect(selection.centralIDs == Set(["c1", "c2"])) + } +} diff --git a/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift b/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift new file mode 100644 index 00000000..ed7c66a1 --- /dev/null +++ b/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift @@ -0,0 +1,205 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLEFragmentAssemblyBufferTests { + @Test + func appendCompletesOutOfOrderFragments() throws { + var buffer = BLEFragmentAssemblyBuffer() + let original = makePacket(payload: makePayload(count: 512)) + let fragmentPackets = try makeFragments(for: original, chunkSize: 128, fragmentID: Data(repeating: 0x01, count: 8)) + let headers = try fragmentPackets.reversed().map { try #require(BLEFragmentHeader(packet: $0)) } + var result: BLEFragmentAssemblyBuffer.AppendResult? + + for header in headers { + result = buffer.append(header, maxInFlightAssemblies: 8) + } + + if case let .complete(header, reassembledData, started) = result { + #expect(header.total == fragmentPackets.count) + #expect(!started) + #expect(BinaryProtocol.decode(reassembledData)?.payload == original.payload) + } else { + Issue.record("Expected final fragment to complete reassembly") + } + } + + @Test + func appendDuplicateFragmentDoesNotCompleteEarly() throws { + var buffer = BLEFragmentAssemblyBuffer() + let original = makePacket(payload: makePayload(count: 384)) + let fragmentPackets = try makeFragments(for: original, chunkSize: 128, fragmentID: Data(repeating: 0x02, count: 8)) + let first = try #require(BLEFragmentHeader(packet: fragmentPackets[0])) + let second = try #require(BLEFragmentHeader(packet: fragmentPackets[1])) + + if case let .stored(_, started) = buffer.append(first, maxInFlightAssemblies: 8) { + #expect(started) + } else { + Issue.record("Expected first fragment to be stored") + } + + if case .stored = buffer.append(first, maxInFlightAssemblies: 8) { + // Duplicate should replace the same index, not increase completion count. + } else { + Issue.record("Expected duplicate fragment to remain incomplete") + } + + if case .stored = buffer.append(second, maxInFlightAssemblies: 8) { + // Still missing at least one fragment. + } else { + Issue.record("Expected assembly to remain incomplete") + } + } + + @Test + func appendEvictsOldestAssemblyWhenCapIsReached() throws { + var buffer = BLEFragmentAssemblyBuffer() + let oldPacket = makePacket(payload: makePayload(count: 256, seed: 1), timestamp: 1) + let newPacket = makePacket(payload: makePayload(count: 256, seed: 2), timestamp: 2) + let oldFragments = try makeFragments(for: oldPacket, chunkSize: 128, fragmentID: Data(repeating: 0x03, count: 8)) + let newFragments = try makeFragments(for: newPacket, chunkSize: 128, fragmentID: Data(repeating: 0x04, count: 8)) + let oldFirst = try #require(BLEFragmentHeader(packet: oldFragments[0])) + let oldSecond = try #require(BLEFragmentHeader(packet: oldFragments[1])) + let newFirst = try #require(BLEFragmentHeader(packet: newFragments[0])) + let newSecond = try #require(BLEFragmentHeader(packet: newFragments[1])) + + _ = buffer.append(oldFirst, maxInFlightAssemblies: 1, now: Date(timeIntervalSince1970: 1)) + _ = buffer.append(newFirst, maxInFlightAssemblies: 1, now: Date(timeIntervalSince1970: 2)) + + if case let .stored(_, started) = buffer.append(oldSecond, maxInFlightAssemblies: 1) { + #expect(started) + } else { + Issue.record("Expected evicted assembly to restart when old fragment arrives") + } + + if case let .stored(_, started) = buffer.append(newSecond, maxInFlightAssemblies: 1) { + #expect(started) + } else { + Issue.record("Expected new assembly to restart after old one consumed the only slot") + } + } + + @Test + func appendOversizedAssemblyDropsPartialState() throws { + var buffer = BLEFragmentAssemblyBuffer() + let fragmentID = Data(repeating: 0x05, count: 8) + let first = try #require(BLEFragmentHeader(packet: makeFragmentPacket( + fragmentID: fragmentID, + index: 0, + total: 2, + originalType: MessageType.message.rawValue, + fragmentData: Data(repeating: 0x01, count: FileTransferLimits.maxPayloadBytes) + ))) + let oversized = try #require(BLEFragmentHeader(packet: makeFragmentPacket( + fragmentID: fragmentID, + index: 1, + total: 2, + originalType: MessageType.message.rawValue, + fragmentData: Data([0x02]) + ))) + + _ = buffer.append(first, maxInFlightAssemblies: 8) + let result = buffer.append(oversized, maxInFlightAssemblies: 8) + + if case let .oversized(_, projectedSize, limit, started) = result { + #expect(projectedSize == FileTransferLimits.maxPayloadBytes + 1) + #expect(limit == FileTransferLimits.maxPayloadBytes) + #expect(!started) + } else { + Issue.record("Expected oversized fragment assembly to be evicted") + } + + if case let .stored(_, started) = buffer.append(oversized, maxInFlightAssemblies: 8) { + #expect(started) + } else { + Issue.record("Expected later fragment to start a clean assembly") + } + } + + @Test + func removeExpiredDropsOldAssemblies() throws { + var buffer = BLEFragmentAssemblyBuffer() + let packet = makePacket(payload: makePayload(count: 256)) + let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: Data(repeating: 0x06, count: 8)) + let first = try #require(BLEFragmentHeader(packet: fragments[0])) + let second = try #require(BLEFragmentHeader(packet: fragments[1])) + + _ = buffer.append(first, maxInFlightAssemblies: 8, now: Date(timeIntervalSince1970: 1)) + #expect(buffer.removeExpired(before: Date(timeIntervalSince1970: 2)) == 1) + + if case let .stored(_, started) = buffer.append(second, maxInFlightAssemblies: 8) { + #expect(started) + } else { + Issue.record("Expected expired assembly to be gone") + } + } + + private func makePacket(payload: Data, timestamp: UInt64 = 0x0102030405) -> BitchatPacket { + BitchatPacket( + type: MessageType.message.rawValue, + senderID: Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]), + recipientID: nil, + timestamp: timestamp, + payload: payload, + signature: nil, + ttl: 3 + ) + } + + private func makePayload(count: Int, seed: UInt64 = 0x1234ABCD) -> Data { + var state = seed + return Data((0..> 32) + }) + } + + private func makeFragments(for packet: BitchatPacket, chunkSize: Int, fragmentID: Data) throws -> [BitchatPacket] { + let fullData = try #require(packet.toBinaryData(padding: false)) + let chunks = stride(from: 0, to: fullData.count, by: chunkSize).map { offset in + Data(fullData[offset.. 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(originalType) + payload.append(fragmentData) + + return BitchatPacket( + type: MessageType.fragment.rawValue, + senderID: senderID, + recipientID: recipientID, + timestamp: timestamp, + payload: payload, + signature: nil, + ttl: 3 + ) + } +} diff --git a/bitchatTests/Services/BLEInboundWriteBufferTests.swift b/bitchatTests/Services/BLEInboundWriteBufferTests.swift new file mode 100644 index 00000000..636e2abf --- /dev/null +++ b/bitchatTests/Services/BLEInboundWriteBufferTests.swift @@ -0,0 +1,140 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLEInboundWriteBufferTests { + @Test + func appendDecodesPacketAcrossChunkedWrites() throws { + var buffer = BLEInboundWriteBuffer() + let packet = makePacket() + let frame = try #require(packet.toBinaryData(padding: false)) + let splitIndex = max(1, frame.count / 2) + + let firstResult = buffer.append( + chunks: [BLEInboundWriteChunk(offset: 0, data: frame.prefix(splitIndex))], + for: "central-1", + capBytes: 1024 + ) + + if case let .waiting(metadata) = firstResult { + #expect(metadata.accumulatedBytes == splitIndex) + #expect(metadata.appendedBytes == splitIndex) + #expect(metadata.offsets == [0]) + #expect(metadata.packetType == MessageType.message.rawValue) + } else { + Issue.record("Expected first chunk to wait for more data") + } + + let secondResult = buffer.append( + chunks: [BLEInboundWriteChunk(offset: splitIndex, data: frame.suffix(from: splitIndex))], + for: "central-1", + capBytes: 1024 + ) + + if case let .decoded(decoded, metadata) = secondResult { + #expect(decoded.type == packet.type) + #expect(decoded.senderID == packet.senderID) + #expect(decoded.payload == packet.payload) + #expect(metadata.accumulatedBytes == frame.count) + #expect(metadata.offsets == [splitIndex]) + } else { + Issue.record("Expected complete frame to decode") + } + } + + @Test + func appendMergesMultipleOffsetChunksInOneCall() throws { + var buffer = BLEInboundWriteBuffer() + let packet = makePacket(timestamp: 0xABC) + let frame = try #require(packet.toBinaryData(padding: false)) + let splitIndex = max(1, frame.count / 3) + + let result = buffer.append( + chunks: [ + BLEInboundWriteChunk(offset: 0, data: frame.prefix(splitIndex)), + BLEInboundWriteChunk(offset: splitIndex, data: frame.suffix(from: splitIndex)) + ], + for: "central-1", + capBytes: 1024 + ) + + if case let .decoded(decoded, metadata) = result { + #expect(decoded.timestamp == packet.timestamp) + #expect(metadata.appendedBytes == frame.count) + #expect(metadata.offsets == [0, splitIndex]) + } else { + Issue.record("Expected merged chunks to decode") + } + } + + @Test + func appendDropsOversizedBufferAndAllowsLaterDecode() throws { + var buffer = BLEInboundWriteBuffer() + let oversized = buffer.append( + chunks: [BLEInboundWriteChunk(offset: 0, data: Data(repeating: 0xAA, count: 8))], + for: "central-1", + capBytes: 4 + ) + + if case let .oversized(metadata) = oversized { + #expect(metadata.accumulatedBytes == 8) + #expect(metadata.appendedBytes == 8) + } else { + Issue.record("Expected oversized buffer to be dropped") + } + + let packet = makePacket(timestamp: 0xDEF) + let frame = try #require(packet.toBinaryData(padding: false)) + let decoded = buffer.append( + chunks: [BLEInboundWriteChunk(offset: 0, data: frame)], + for: "central-1", + capBytes: 1024 + ) + + if case let .decoded(decodedPacket, _) = decoded { + #expect(decodedPacket.timestamp == packet.timestamp) + } else { + Issue.record("Expected later clean frame to decode") + } + } + + @Test + func removeAllClearsPartialWrites() throws { + var buffer = BLEInboundWriteBuffer() + let packet = makePacket() + let frame = try #require(packet.toBinaryData(padding: false)) + let splitIndex = max(1, frame.count / 2) + + _ = buffer.append( + chunks: [BLEInboundWriteChunk(offset: 0, data: frame.prefix(splitIndex))], + for: "central-1", + capBytes: 1024 + ) + buffer.removeAll() + + let result = buffer.append( + chunks: [BLEInboundWriteChunk(offset: splitIndex, data: frame.suffix(from: splitIndex))], + for: "central-1", + capBytes: 1024 + ) + + if case .waiting = result { + // Expected: the first half was cleared, so the second half alone cannot decode. + } else { + Issue.record("Expected buffer reset to discard the earlier partial write") + } + } + + private func makePacket(timestamp: UInt64 = 0x0102030405) -> BitchatPacket { + BitchatPacket( + type: MessageType.message.rawValue, + senderID: Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]), + recipientID: nil, + timestamp: timestamp, + payload: Data([0xDE, 0xAD, 0xBE, 0xEF]), + signature: nil, + ttl: 3 + ) + } +} diff --git a/bitchatTests/Services/BLEIngressLinkRegistryTests.swift b/bitchatTests/Services/BLEIngressLinkRegistryTests.swift new file mode 100644 index 00000000..af8c2777 --- /dev/null +++ b/bitchatTests/Services/BLEIngressLinkRegistryTests.swift @@ -0,0 +1,178 @@ +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct BLEIngressLinkRegistryTests { + @Test + func recordIfNewSuppressesDuplicateWithinLifetime() { + var registry = BLEIngressLinkRegistry() + let packet = makePacket(sender: PeerID(str: "1122334455667788"), timestamp: 1) + let peer = PeerID(str: "1122334455667788") + let now = Date() + + let firstRecord = registry.recordIfNew(packet, link: .central("central-a"), peerID: peer, now: now, lifetime: 3.0) + let duplicateRecord = registry.recordIfNew(packet, link: .peripheral("peripheral-b"), peerID: peer, now: now.addingTimeInterval(1.0), lifetime: 3.0) + + #expect(firstRecord) + #expect(!duplicateRecord) + #expect(registry.link(for: packet) == .central("central-a")) + #expect(registry.peerID(for: packet) == peer) + } + + @Test + func recordIfNewAllowsExpiredDuplicateAndUpdatesLink() { + var registry = BLEIngressLinkRegistry() + let packet = makePacket(sender: PeerID(str: "1122334455667788"), timestamp: 1) + let peer = PeerID(str: "1122334455667788") + let now = Date() + + let firstRecord = registry.recordIfNew(packet, link: .central("central-a"), peerID: peer, now: now, lifetime: 3.0) + let expiredRecord = registry.recordIfNew(packet, link: .peripheral("peripheral-b"), peerID: peer, now: now.addingTimeInterval(4.0), lifetime: 3.0) + + #expect(firstRecord) + #expect(expiredRecord) + #expect(registry.link(for: packet) == .peripheral("peripheral-b")) + #expect(registry.peerID(for: packet) == peer) + } + + @Test + func pruneRemovesExpiredIngressLinks() { + var registry = BLEIngressLinkRegistry() + let packet = makePacket(sender: PeerID(str: "1122334455667788"), timestamp: 1) + let peer = PeerID(str: "1122334455667788") + let now = Date() + + let firstRecord = registry.recordIfNew(packet, link: .central("central-a"), peerID: peer, now: now, lifetime: 3.0) + #expect(firstRecord) + registry.prune(before: now.addingTimeInterval(4.0)) + + #expect(registry.isEmpty) + #expect(registry.link(for: packet) == nil) + #expect(registry.peerID(for: packet) == nil) + } + + @Test + func packetContextRejectsSelfLoopback() { + let localPeer = PeerID(str: "1122334455667788") + let packet = makePacket(sender: localPeer, timestamp: 1) + + let result = BLEIngressLinkRegistry.packetContext( + for: packet, + claimedSenderID: localPeer, + boundPeerID: nil, + localPeerID: localPeer, + directAnnounceTTL: 7 + ) + + #expect(result == .failure(.selfLoopback(packetType: MessageType.message.rawValue))) + } + + @Test + func packetContextAllowsRelayedSenderOnBoundLink() throws { + let localPeer = PeerID(str: "0011223344556677") + let boundPeer = PeerID(str: "1122334455667788") + let relayedSender = PeerID(str: "8899aabbccddeeff") + let packet = makePacket(sender: relayedSender, timestamp: 1) + + let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext( + for: packet, + claimedSenderID: relayedSender, + boundPeerID: boundPeer, + localPeerID: localPeer, + directAnnounceTTL: 7 + ))) + + #expect(context.receivedFromPeerID == boundPeer) + #expect(context.validationPeerID == relayedSender) + } + + @Test + func packetContextRejectsDirectAnnounceMismatchOnBoundLink() { + let localPeer = PeerID(str: "0011223344556677") + let boundPeer = PeerID(str: "1122334455667788") + let claimedPeer = PeerID(str: "8899aabbccddeeff") + let packet = makeAnnouncePacket(sender: claimedPeer, ttl: 7) + + let result = BLEIngressLinkRegistry.packetContext( + for: packet, + claimedSenderID: claimedPeer, + boundPeerID: boundPeer, + localPeerID: localPeer, + directAnnounceTTL: 7 + ) + + #expect(result == .failure(.directSenderMismatch(boundPeerID: boundPeer, claimedSenderID: claimedPeer))) + } + + @Test + func packetContextUsesBoundPeerForRSRValidation() throws { + let localPeer = PeerID(str: "0011223344556677") + let boundPeer = PeerID(str: "1122334455667788") + let relayedSender = PeerID(str: "8899aabbccddeeff") + var packet = makePacket(sender: relayedSender, timestamp: 1) + packet.isRSR = true + + let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext( + for: packet, + claimedSenderID: relayedSender, + boundPeerID: boundPeer, + localPeerID: localPeer, + directAnnounceTTL: 7 + ))) + + #expect(context.receivedFromPeerID == boundPeer) + #expect(context.validationPeerID == boundPeer) + } + + @Test + func packetContextAllowsSelfAuthoredRSRWithTTLZeroFromBoundPeer() throws { + let localPeer = PeerID(str: "0011223344556677") + let boundPeer = PeerID(str: "1122334455667788") + var packet = makePacket(sender: localPeer, timestamp: 1) + packet.isRSR = true + packet.ttl = 0 + + let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext( + for: packet, + claimedSenderID: localPeer, + boundPeerID: boundPeer, + localPeerID: localPeer, + directAnnounceTTL: 7 + ))) + + #expect(context.receivedFromPeerID == boundPeer) + #expect(context.validationPeerID == boundPeer) + } +} + +private func makePacket(sender: PeerID, timestamp: UInt64) -> BitchatPacket { + BitchatPacket( + type: MessageType.message.rawValue, + senderID: Data(hexString: sender.id) ?? Data(), + recipientID: nil, + timestamp: timestamp, + payload: Data("hello".utf8), + signature: nil, + ttl: 3 + ) +} + +private func makeAnnouncePacket(sender: PeerID, ttl: UInt8) -> BitchatPacket { + BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: sender.id) ?? Data(), + recipientID: nil, + timestamp: 1, + payload: Data(), + signature: nil, + ttl: ttl + ) +} + +private func trySuccess(_ result: Result) -> BLEIngressPacketContext? { + guard case .success(let context) = result else { + return nil + } + return context +} diff --git a/bitchatTests/Services/BLEOutboundNotificationBufferTests.swift b/bitchatTests/Services/BLEOutboundNotificationBufferTests.swift new file mode 100644 index 00000000..4dced392 --- /dev/null +++ b/bitchatTests/Services/BLEOutboundNotificationBufferTests.swift @@ -0,0 +1,70 @@ +import Foundation +import Testing +@testable import bitchat + +struct BLEOutboundNotificationBufferTests { + @Test + func enqueueStoresNotificationsUntilTaken() { + var buffer = BLEOutboundNotificationBuffer() + + let result = buffer.enqueue( + data: Data([0x01]), + targets: ["central-1"], + capCount: 2 + ) + + if case let .enqueued(count) = result { + #expect(count == 1) + } else { + Issue.record("Expected notification to be enqueued") + } + + let pending = buffer.takeAll() + + #expect(pending.count == 1) + #expect(pending.first?.data == Data([0x01])) + #expect(pending.first?.targets == ["central-1"]) + #expect(buffer.isEmpty) + } + + @Test + func enqueueRejectsWhenCapIsFull() { + var buffer = BLEOutboundNotificationBuffer() + + _ = buffer.enqueue(data: Data([0x01]), targets: nil, capCount: 1) + let result = buffer.enqueue(data: Data([0x02]), targets: nil, capCount: 1) + + if case let .full(count) = result { + #expect(count == 1) + } else { + Issue.record("Expected full notification buffer") + } + + #expect(buffer.count == 1) + } + + @Test + func prependRestoresUnsentNotificationsAheadOfNewerItems() { + var buffer = BLEOutboundNotificationBuffer() + let unsent = [ + BLEPendingNotification(data: Data([0x01]), targets: ["old"]) + ] + + _ = buffer.enqueue(data: Data([0x02]), targets: ["new"], capCount: 4) + buffer.prepend(unsent) + + let pending = buffer.takeAll() + + #expect(pending.map { Int($0.data.first ?? 0) } == [0x01, 0x02]) + } + + @Test + func removeAllClearsBufferedNotifications() { + var buffer = BLEOutboundNotificationBuffer() + + _ = buffer.enqueue(data: Data([0x01]), targets: nil, capCount: 4) + buffer.removeAll() + + #expect(buffer.isEmpty) + } +} diff --git a/bitchatTests/Services/NostrTransportTests.swift b/bitchatTests/Services/NostrTransportTests.swift index 9541f3b8..8cde7bf8 100644 --- a/bitchatTests/Services/NostrTransportTests.swift +++ b/bitchatTests/Services/NostrTransportTests.swift @@ -289,9 +289,10 @@ struct NostrTransportTests { transport.sendReadReceipt(first, to: fullPeerID) transport.sendReadReceipt(second, to: fullPeerID) - let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 1.5) + let readReceiptTimeout: TimeInterval = 5.0 + let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count >= 1 }, timeout: readReceiptTimeout) try #require(sentFirst, "Expected first queued read receipt event") - let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: 1.5) + let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: readReceiptTimeout) try #require(scheduledThrottle, "Expected queued throttle action after first read receipt") let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt event") let firstPayload = try decodeEmbeddedPayload(from: firstEvent, recipient: recipient).payload @@ -300,7 +301,7 @@ struct NostrTransportTests { try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt") - let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 1.5) + let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count >= 2 }, timeout: readReceiptTimeout) try #require(sentSecond, "Expected second read receipt after running throttle action") let secondEvent = try #require(probe.sentEvents.last, "Expected second queued read receipt event") let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload diff --git a/bitchatTests/Services/RelayControllerTests.swift b/bitchatTests/Services/RelayControllerTests.swift index 71553ab8..ef5b5ff2 100644 --- a/bitchatTests/Services/RelayControllerTests.swift +++ b/bitchatTests/Services/RelayControllerTests.swift @@ -50,6 +50,26 @@ struct RelayControllerTests { #expect(decision.delayMs >= 10 && decision.delayMs <= 35) } + @Test + func localRecipientDoesNotRelayDirectedTraffic() async { + let decision = RelayController.decide( + ttl: 7, + senderIsSelf: false, + recipientIsSelf: true, + isEncrypted: true, + isDirectedEncrypted: true, + isFragment: false, + isDirectedFragment: false, + isHandshake: false, + isAnnounce: false, + degree: 3, + highDegreeThreshold: TransportConfig.bleHighDegreeThreshold + ) + + #expect(!decision.shouldRelay) + #expect(decision.newTTL == 7) + } + @Test func fragment_relaysWithFragmentCap() async { let decision = RelayController.decide( diff --git a/bitchatTests/Services/SecureIdentityStateManagerTests.swift b/bitchatTests/Services/SecureIdentityStateManagerTests.swift index 6f40558f..27e57fdf 100644 --- a/bitchatTests/Services/SecureIdentityStateManagerTests.swift +++ b/bitchatTests/Services/SecureIdentityStateManagerTests.swift @@ -374,6 +374,7 @@ final class SecureIdentityStateManagerTests: XCTestCase { XCTAssertTrue(manager.getFavorites().isEmpty) XCTAssertTrue(manager.getVerifiedFingerprints().isEmpty) XCTAssertTrue(manager.getBlockedNostrPubkeys().isEmpty) + XCTAssertNil(keychain.getIdentityKey(forKey: "bitchat.identityCache.v2")) } func test_clearAllIdentityData_removesCachedState() async { diff --git a/docs/ARCHITECTURE_V2.md b/docs/ARCHITECTURE_V2.md index 6a6de351..0284e830 100644 --- a/docs/ARCHITECTURE_V2.md +++ b/docs/ARCHITECTURE_V2.md @@ -44,3 +44,5 @@ This branch starts a larger simplification effort focused on performance, reliab The Bluetooth architecture branch begins step 2 by adding a typed `TransportEvent` boundary while preserving the legacy `BitchatDelegate` bridge. New transport code should emit typed events first, with delegate forwarding used only as a compatibility adapter during migration. The branch also starts carving performance-sensitive BLE scheduling state out of `BLEService`: pending write backpressure now lives in `BLEOutboundWriteBuffer`, giving the outbound hot path a focused, unit-tested component before deeper fragmentation and link-scheduler work. + +The next transport slice continues that path by extracting ingress link memory and outbound fanout selection. `BLEIngressLinkRegistry` now owns duplicate/last-hop tracking, ingress peer memory, and direct-link sender binding decisions, while `BLEFanoutSelector` owns deterministic broadcast subsetting and ingress-peer/link exclusion. `BLEService` still coordinates CoreBluetooth callbacks, but these hot-path decisions are now pure, covered units instead of inline dictionary logic.