mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 10:25:18 +00:00
- Prevent didDisconnectFromPeer delegate call when peer gracefully left - This prevents duplicate 'rick disconnected' messages in chat UI
5226 lines
237 KiB
Swift
5226 lines
237 KiB
Swift
//
|
|
// BluetoothMeshService.swift
|
|
// bitchat
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import Foundation
|
|
import CoreBluetooth
|
|
import Combine
|
|
import CryptoKit
|
|
import os.log
|
|
#if os(macOS)
|
|
import AppKit
|
|
import IOKit.ps
|
|
#else
|
|
import UIKit
|
|
#endif
|
|
|
|
// Hex encoding/decoding is now in BinaryEncodingUtils.swift
|
|
|
|
// Extension for TimeInterval to Data conversion
|
|
extension TimeInterval {
|
|
var data: Data {
|
|
var value = self
|
|
return Data(bytes: &value, count: MemoryLayout<TimeInterval>.size)
|
|
}
|
|
}
|
|
|
|
// Version negotiation state
|
|
enum VersionNegotiationState {
|
|
case none
|
|
case helloSent
|
|
case ackReceived(version: UInt8)
|
|
case failed(reason: String)
|
|
}
|
|
|
|
// Peer connection state tracking
|
|
enum PeerConnectionState: CustomStringConvertible {
|
|
case disconnected
|
|
case connecting
|
|
case connected // BLE connected but not authenticated
|
|
case authenticating // Performing handshake
|
|
case authenticated // Handshake complete, ready for messages
|
|
|
|
var isAvailable: Bool {
|
|
switch self {
|
|
case .authenticated:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .disconnected: return "disconnected"
|
|
case .connecting: return "connecting"
|
|
case .connected: return "connected"
|
|
case .authenticating: return "authenticating"
|
|
case .authenticated: return "authenticated"
|
|
}
|
|
}
|
|
}
|
|
|
|
class BluetoothMeshService: NSObject {
|
|
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
|
|
static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
|
|
|
|
|
|
private var centralManager: CBCentralManager?
|
|
private var peripheralManager: CBPeripheralManager?
|
|
private var discoveredPeripherals: [CBPeripheral] = []
|
|
private var connectedPeripherals: [String: CBPeripheral] = [:]
|
|
private var peripheralCharacteristics: [CBPeripheral: CBCharacteristic] = [:]
|
|
private var lastConnectionTime: [String: Date] = [:] // Track when peers last connected
|
|
private var lastSuccessfulMessageTime: [String: Date] = [:] // Track last successful message exchange
|
|
private var lastHeardFromPeer: [String: Date] = [:] // Track last time we received ANY packet from peer
|
|
|
|
// Peer availability tracking
|
|
private var peerAvailabilityState: [String: Bool] = [:] // true = available, false = unavailable
|
|
private let peerAvailabilityTimeout: TimeInterval = 30.0 // Mark unavailable after 30s of no response
|
|
private var availabilityCheckTimer: Timer?
|
|
|
|
private var characteristic: CBMutableCharacteristic?
|
|
private var subscribedCentrals: [CBCentral] = []
|
|
// Thread-safe collections using concurrent queues
|
|
private let collectionsQueue = DispatchQueue(label: "bitchat.collections", attributes: .concurrent)
|
|
private var peerNicknames: [String: String] = [:]
|
|
private var activePeers: Set<String> = [] // Track all active peers
|
|
private var peerRSSI: [String: NSNumber] = [:] // Track RSSI values for peers
|
|
private var peripheralRSSI: [String: NSNumber] = [:] // Track RSSI by peripheral ID during discovery
|
|
|
|
// Per-peer encryption queues to prevent nonce desynchronization
|
|
private var peerEncryptionQueues: [String: DispatchQueue] = [:]
|
|
private let encryptionQueuesLock = NSLock()
|
|
|
|
// MARK: - Peer Identity Rotation
|
|
// Mappings between ephemeral peer IDs and permanent fingerprints
|
|
private var peerIDToFingerprint: [String: String] = [:] // PeerID -> Fingerprint
|
|
private var fingerprintToPeerID: [String: String] = [:] // Fingerprint -> Current PeerID
|
|
private var peerIdentityBindings: [String: PeerIdentityBinding] = [:] // Fingerprint -> Full binding
|
|
private var previousPeerID: String? // Our previous peer ID for grace period
|
|
private var rotationTimestamp: Date? // When we last rotated
|
|
private let rotationGracePeriod: TimeInterval = 60.0 // 1 minute grace period
|
|
private var rotationLocked = false // Prevent rotation during critical operations
|
|
private var rotationTimer: Timer? // Timer for scheduled rotations
|
|
|
|
// MARK: - Identity Cache
|
|
// In-memory cache for peer public keys to avoid keychain lookups
|
|
private var peerPublicKeyCache: [String: Data] = [:] // PeerID -> Public Key Data
|
|
private var peerSigningKeyCache: [String: Data] = [:] // PeerID -> Signing Key Data
|
|
private let identityCacheTTL: TimeInterval = 3600.0 // 1 hour TTL
|
|
private var identityCacheTimestamps: [String: Date] = [:] // Track when entries were cached
|
|
|
|
weak var delegate: BitchatDelegate?
|
|
private let noiseService = NoiseEncryptionService()
|
|
private let handshakeCoordinator = NoiseHandshakeCoordinator()
|
|
|
|
// Protocol version negotiation state
|
|
private var versionNegotiationState: [String: VersionNegotiationState] = [:]
|
|
private var negotiatedVersions: [String: UInt8] = [:] // peerID -> agreed version
|
|
|
|
// MARK: - Write Queue for Disconnected Peripherals
|
|
private struct QueuedWrite {
|
|
let data: Data
|
|
let peripheralID: String
|
|
let peerID: String?
|
|
let timestamp: Date
|
|
let retryCount: Int
|
|
}
|
|
|
|
private var writeQueue: [String: [QueuedWrite]] = [:] // PeripheralID -> Queue of writes
|
|
private let writeQueueLock = NSLock()
|
|
private let maxWriteQueueSize = 50 // Max queued writes per peripheral
|
|
private let maxWriteRetries = 3
|
|
private let writeQueueTTL: TimeInterval = 60.0 // Expire queued writes after 1 minute
|
|
private var writeQueueTimer: Timer? // Timer for processing expired writes
|
|
|
|
// MARK: - Connection Pooling
|
|
private let maxConnectedPeripherals = 10 // Limit simultaneous connections
|
|
private let maxScanningDuration: TimeInterval = 5.0 // Stop scanning after 5 seconds to save battery
|
|
|
|
func getNoiseService() -> NoiseEncryptionService {
|
|
return noiseService
|
|
}
|
|
|
|
// MARK: - Identity Cache Methods
|
|
|
|
func getCachedPublicKey(for peerID: String) -> Data? {
|
|
return collectionsQueue.sync {
|
|
// Check if cache entry exists and is not expired
|
|
if let timestamp = identityCacheTimestamps[peerID],
|
|
Date().timeIntervalSince(timestamp) < identityCacheTTL {
|
|
return peerPublicKeyCache[peerID]
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func getCachedSigningKey(for peerID: String) -> Data? {
|
|
return collectionsQueue.sync {
|
|
// Check if cache entry exists and is not expired
|
|
if let timestamp = identityCacheTimestamps[peerID],
|
|
Date().timeIntervalSince(timestamp) < identityCacheTTL {
|
|
return peerSigningKeyCache[peerID]
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func cleanExpiredIdentityCache() {
|
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
let now = Date()
|
|
var expiredPeerIDs: [String] = []
|
|
|
|
for (peerID, timestamp) in self.identityCacheTimestamps {
|
|
if now.timeIntervalSince(timestamp) >= self.identityCacheTTL {
|
|
expiredPeerIDs.append(peerID)
|
|
}
|
|
}
|
|
|
|
for peerID in expiredPeerIDs {
|
|
self.peerPublicKeyCache.removeValue(forKey: peerID)
|
|
self.peerSigningKeyCache.removeValue(forKey: peerID)
|
|
self.identityCacheTimestamps.removeValue(forKey: peerID)
|
|
}
|
|
|
|
if !expiredPeerIDs.isEmpty {
|
|
SecureLogger.log("Cleaned \(expiredPeerIDs.count) expired identity cache entries",
|
|
category: SecureLogger.session, level: .debug)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Write Queue Management
|
|
|
|
private func writeToPeripheral(_ data: Data, peripheral: CBPeripheral, characteristic: CBCharacteristic, peerID: String? = nil) {
|
|
let peripheralID = peripheral.identifier.uuidString
|
|
|
|
// Double check the peripheral state to avoid API misuse
|
|
guard peripheral.state == .connected else {
|
|
// Queue write for disconnected peripheral
|
|
queueWrite(data: data, peripheralID: peripheralID, peerID: peerID)
|
|
return
|
|
}
|
|
|
|
// Verify characteristic is valid and writable
|
|
guard characteristic.properties.contains(.write) || characteristic.properties.contains(.writeWithoutResponse) else {
|
|
SecureLogger.log("Characteristic does not support writing for peripheral \(peripheralID)",
|
|
category: SecureLogger.session, level: .warning)
|
|
return
|
|
}
|
|
|
|
// Direct write if connected
|
|
let writeType: CBCharacteristicWriteType = data.count > 512 ? .withResponse : .withoutResponse
|
|
peripheral.writeValue(data, for: characteristic, type: writeType)
|
|
|
|
// Update activity tracking
|
|
updatePeripheralActivity(peripheralID)
|
|
}
|
|
|
|
private func queueWrite(data: Data, peripheralID: String, peerID: String?) {
|
|
writeQueueLock.lock()
|
|
defer { writeQueueLock.unlock() }
|
|
|
|
// Check backpressure - drop oldest if queue is full
|
|
var queue = writeQueue[peripheralID] ?? []
|
|
|
|
if queue.count >= maxWriteQueueSize {
|
|
// Remove oldest entries
|
|
let removeCount = queue.count - maxWriteQueueSize + 1
|
|
queue.removeFirst(removeCount)
|
|
SecureLogger.log("Write queue full for \(peripheralID), dropped \(removeCount) oldest writes",
|
|
category: SecureLogger.session, level: .warning)
|
|
}
|
|
|
|
let queuedWrite = QueuedWrite(
|
|
data: data,
|
|
peripheralID: peripheralID,
|
|
peerID: peerID,
|
|
timestamp: Date(),
|
|
retryCount: 0
|
|
)
|
|
|
|
queue.append(queuedWrite)
|
|
writeQueue[peripheralID] = queue
|
|
|
|
SecureLogger.log("Queued write for disconnected peripheral \(peripheralID), queue size: \(queue.count)",
|
|
category: SecureLogger.session, level: .debug)
|
|
}
|
|
|
|
private func processWriteQueue(for peripheral: CBPeripheral) {
|
|
guard peripheral.state == .connected,
|
|
let characteristic = peripheralCharacteristics[peripheral] else { return }
|
|
|
|
let peripheralID = peripheral.identifier.uuidString
|
|
|
|
writeQueueLock.lock()
|
|
let queue = writeQueue[peripheralID] ?? []
|
|
writeQueue[peripheralID] = []
|
|
writeQueueLock.unlock()
|
|
|
|
if !queue.isEmpty {
|
|
SecureLogger.log("Processing \(queue.count) queued writes for \(peripheralID)",
|
|
category: SecureLogger.session, level: .info)
|
|
}
|
|
|
|
// Process queued writes with small delay between them
|
|
for (index, queuedWrite) in queue.enumerated() {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.01) { [weak self] in
|
|
guard let self = self,
|
|
peripheral.state == .connected,
|
|
characteristic.properties.contains(.write) || characteristic.properties.contains(.writeWithoutResponse) else {
|
|
return
|
|
}
|
|
|
|
let writeType: CBCharacteristicWriteType = queuedWrite.data.count > 512 ? .withResponse : .withoutResponse
|
|
peripheral.writeValue(queuedWrite.data, for: characteristic, type: writeType)
|
|
|
|
// Update activity tracking
|
|
self.updatePeripheralActivity(peripheralID)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func cleanExpiredWriteQueues() {
|
|
writeQueueLock.lock()
|
|
defer { writeQueueLock.unlock() }
|
|
|
|
let now = Date()
|
|
var expiredWrites = 0
|
|
|
|
for (peripheralID, queue) in writeQueue {
|
|
let filteredQueue = queue.filter { write in
|
|
now.timeIntervalSince(write.timestamp) < writeQueueTTL
|
|
}
|
|
|
|
expiredWrites += queue.count - filteredQueue.count
|
|
|
|
if filteredQueue.isEmpty {
|
|
writeQueue.removeValue(forKey: peripheralID)
|
|
} else {
|
|
writeQueue[peripheralID] = filteredQueue
|
|
}
|
|
}
|
|
|
|
if expiredWrites > 0 {
|
|
SecureLogger.log("Cleaned \(expiredWrites) expired queued writes",
|
|
category: SecureLogger.session, level: .debug)
|
|
}
|
|
}
|
|
private let messageQueue = DispatchQueue(label: "bitchat.messageQueue", attributes: .concurrent) // Concurrent queue with barriers
|
|
private let processedMessages = BoundedSet<String>(maxSize: 1000) // Bounded to prevent memory growth
|
|
private let maxTTL: UInt8 = 7 // Maximum hops for long-distance delivery
|
|
private var announcedToPeers = Set<String>() // Track which peers we've announced to
|
|
private var announcedPeers = Set<String>() // Track peers who have already been announced
|
|
private var hasNotifiedNetworkAvailable = false // Track if we've notified about network availability
|
|
private var lastNetworkNotificationTime: Date? // Track when we last sent a network notification
|
|
private var networkBecameEmptyTime: Date? // Track when the network became empty
|
|
private let networkNotificationCooldown: TimeInterval = 300 // 5 minutes between notifications
|
|
private let networkEmptyResetDelay: TimeInterval = 60 // 1 minute before resetting notification flag
|
|
private var intentionalDisconnects = Set<String>() // Track peripherals we're disconnecting intentionally
|
|
private var gracefullyLeftPeers = Set<String>() // Track peers that sent leave messages
|
|
private var peerLastSeenTimestamps = LRUCache<String, Date>(maxSize: 100) // Bounded cache for peer timestamps
|
|
private var cleanupTimer: Timer? // Timer to clean up stale peers
|
|
|
|
// Store-and-forward message cache
|
|
private struct StoredMessage {
|
|
let packet: BitchatPacket
|
|
let timestamp: Date
|
|
let messageID: String
|
|
let isForFavorite: Bool // Messages for favorites stored indefinitely
|
|
}
|
|
private var messageCache: [StoredMessage] = []
|
|
private let messageCacheTimeout: TimeInterval = 43200 // 12 hours for regular peers
|
|
private let maxCachedMessages = 100 // For regular peers
|
|
private let maxCachedMessagesForFavorites = 1000 // Much larger cache for favorites
|
|
private var favoriteMessageQueue: [String: [StoredMessage]] = [:] // Per-favorite message queues
|
|
private let deliveredMessages = BoundedSet<String>(maxSize: 5000) // Bounded to prevent memory growth
|
|
private var cachedMessagesSentToPeer = Set<String>() // Track which peers have already received cached messages
|
|
private let receivedMessageTimestamps = LRUCache<String, Date>(maxSize: 1000) // Bounded cache
|
|
private let recentlySentMessages = BoundedSet<String>(maxSize: 500) // Short-term bounded cache
|
|
private let lastMessageFromPeer = LRUCache<String, Date>(maxSize: 100) // Bounded cache
|
|
private let processedNoiseMessages = BoundedSet<String>(maxSize: 1000) // Bounded cache
|
|
|
|
// Battery and range optimizations
|
|
private var scanDutyCycleTimer: Timer?
|
|
private var isActivelyScanning = true
|
|
private var activeScanDuration: TimeInterval = 5.0 // will be adjusted based on battery
|
|
private var scanPauseDuration: TimeInterval = 10.0 // will be adjusted based on battery
|
|
private var lastRSSIUpdate: [String: Date] = [:] // Throttle RSSI updates
|
|
private var batteryMonitorTimer: Timer?
|
|
private var currentBatteryLevel: Float = 1.0 // Default to full battery
|
|
|
|
// Battery optimizer integration
|
|
private let batteryOptimizer = BatteryOptimizer.shared
|
|
private var batteryOptimizerCancellables = Set<AnyCancellable>()
|
|
|
|
// Peer list update debouncing
|
|
private var peerListUpdateTimer: Timer?
|
|
private let peerListUpdateDebounceInterval: TimeInterval = 0.1 // 100ms debounce for more responsive updates
|
|
|
|
// Track when we last sent identity announcements to prevent flooding
|
|
private var lastIdentityAnnounceTimes: [String: Date] = [:]
|
|
private let identityAnnounceMinInterval: TimeInterval = 2.0 // Minimum 2 seconds between announcements per peer
|
|
|
|
// Track handshake attempts to handle timeouts
|
|
private var handshakeAttemptTimes: [String: Date] = [:]
|
|
private let handshakeTimeout: TimeInterval = 5.0 // 5 seconds before retrying
|
|
|
|
// Pending private messages waiting for handshake
|
|
private var pendingPrivateMessages: [String: [(content: String, recipientNickname: String, messageID: String)]] = [:]
|
|
|
|
// Cover traffic for privacy
|
|
private var coverTrafficTimer: Timer?
|
|
private let coverTrafficPrefix = "☂DUMMY☂" // Prefix to identify dummy messages after decryption
|
|
private var lastCoverTrafficTime = Date()
|
|
|
|
// Connection state tracking
|
|
private var peerConnectionStates: [String: PeerConnectionState] = [:]
|
|
private let connectionStateQueue = DispatchQueue(label: "chat.bitchat.connectionState", attributes: .concurrent)
|
|
|
|
// Protocol-level ACK tracking
|
|
private var pendingAcks: [String: (packet: BitchatPacket, timestamp: Date, retries: Int)] = [:]
|
|
private let ackTimeout: TimeInterval = 5.0 // 5 seconds to receive ACK
|
|
private let maxAckRetries = 3
|
|
private var ackTimer: Timer?
|
|
private var advertisingTimer: Timer? // Timer for interval-based advertising
|
|
|
|
// Timing randomization for privacy
|
|
private let minMessageDelay: TimeInterval = 0.01 // 10ms minimum for faster sync
|
|
private let maxMessageDelay: TimeInterval = 0.1 // 100ms maximum for faster sync
|
|
|
|
// Fragment handling with security limits
|
|
private var incomingFragments: [String: [Int: Data]] = [:] // fragmentID -> [index: data]
|
|
private var fragmentMetadata: [String: (originalType: UInt8, totalFragments: Int, timestamp: Date)] = [:]
|
|
private let maxFragmentSize = 469 // 512 bytes max MTU - 43 bytes for headers and metadata
|
|
private let maxConcurrentFragmentSessions = 20 // Limit concurrent fragment sessions to prevent DoS
|
|
private let fragmentTimeout: TimeInterval = 30 // 30 seconds timeout for incomplete fragments
|
|
|
|
var myPeerID: String
|
|
|
|
// ===== SCALING OPTIMIZATIONS =====
|
|
|
|
// Connection pooling
|
|
private var connectionPool: [String: CBPeripheral] = [:]
|
|
private var connectionAttempts: [String: Int] = [:]
|
|
private var connectionBackoff: [String: TimeInterval] = [:]
|
|
private var lastActivityByPeripheralID: [String: Date] = [:] // Track last activity for LRU
|
|
private var peerIDByPeripheralID: [String: String] = [:] // Map peripheral ID to peer ID
|
|
private let maxConnectionAttempts = 3
|
|
private let baseBackoffInterval: TimeInterval = 1.0
|
|
|
|
// Probabilistic flooding
|
|
private var relayProbability: Double = 1.0 // Start at 100%, decrease with peer count
|
|
private let minRelayProbability: Double = 0.4 // Minimum 40% relay chance - ensures coverage
|
|
|
|
// Message aggregation
|
|
private var pendingMessages: [(message: BitchatPacket, destination: String?)] = []
|
|
private var aggregationTimer: Timer?
|
|
private var aggregationWindow: TimeInterval = 0.1 // 100ms window
|
|
private let maxAggregatedMessages = 5
|
|
|
|
// Optimized Bloom filter for efficient duplicate detection
|
|
private var messageBloomFilter = OptimizedBloomFilter(expectedItems: 2000, falsePositiveRate: 0.01)
|
|
private var bloomFilterResetTimer: Timer?
|
|
|
|
// Network size estimation
|
|
private var estimatedNetworkSize: Int {
|
|
return max(activePeers.count, connectedPeripherals.count)
|
|
}
|
|
|
|
// Adaptive parameters based on network size
|
|
private var adaptiveTTL: UInt8 {
|
|
// Keep TTL high enough for messages to travel far
|
|
let networkSize = estimatedNetworkSize
|
|
if networkSize <= 20 {
|
|
return 6 // Small networks: max distance
|
|
} else if networkSize <= 50 {
|
|
return 5 // Medium networks: still good reach
|
|
} else if networkSize <= 100 {
|
|
return 4 // Large networks: reasonable reach
|
|
} else {
|
|
return 3 // Very large networks: minimum viable
|
|
}
|
|
}
|
|
|
|
private var adaptiveRelayProbability: Double {
|
|
// Keep relay probability high enough to ensure delivery
|
|
let networkSize = estimatedNetworkSize
|
|
if networkSize <= 10 {
|
|
return 1.0 // 100% for small networks
|
|
} else if networkSize <= 30 {
|
|
return 0.85 // 85% - most nodes relay
|
|
} else if networkSize <= 50 {
|
|
return 0.7 // 70% - still high probability
|
|
} else if networkSize <= 100 {
|
|
return 0.55 // 55% - over half relay
|
|
} else {
|
|
return 0.4 // 40% minimum - never go below this
|
|
}
|
|
}
|
|
|
|
// BLE advertisement for lightweight presence
|
|
private var advertisementData: [String: Any] = [:]
|
|
private var isAdvertising = false
|
|
|
|
// ===== MESSAGE AGGREGATION =====
|
|
|
|
private func startAggregationTimer() {
|
|
aggregationTimer?.invalidate()
|
|
aggregationTimer = Timer.scheduledTimer(withTimeInterval: aggregationWindow, repeats: false) { [weak self] _ in
|
|
self?.flushPendingMessages()
|
|
}
|
|
}
|
|
|
|
private func flushPendingMessages() {
|
|
guard !pendingMessages.isEmpty else { return }
|
|
|
|
messageQueue.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// Group messages by destination
|
|
var messagesByDestination: [String?: [BitchatPacket]] = [:]
|
|
|
|
for (message, destination) in self.pendingMessages {
|
|
if messagesByDestination[destination] == nil {
|
|
messagesByDestination[destination] = []
|
|
}
|
|
messagesByDestination[destination]?.append(message)
|
|
}
|
|
|
|
// Send aggregated messages
|
|
for (destination, messages) in messagesByDestination {
|
|
if messages.count == 1 {
|
|
// Single message, send normally
|
|
if destination == nil {
|
|
self.broadcastPacket(messages[0])
|
|
} else if let dest = destination,
|
|
let peripheral = self.connectedPeripherals[dest],
|
|
let characteristic = self.peripheralCharacteristics[peripheral] {
|
|
if let data = messages[0].toBinaryData() {
|
|
self.writeToPeripheral(data, peripheral: peripheral, characteristic: characteristic, peerID: dest)
|
|
}
|
|
}
|
|
} else {
|
|
// Multiple messages - could aggregate into a single packet
|
|
// For now, send with minimal delay between them
|
|
for (index, message) in messages.enumerated() {
|
|
let delay = Double(index) * 0.02 // 20ms between messages
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
if destination == nil {
|
|
self?.broadcastPacket(message)
|
|
} else if let dest = destination,
|
|
let peripheral = self?.connectedPeripherals[dest],
|
|
peripheral.state == .connected,
|
|
let characteristic = self?.peripheralCharacteristics[peripheral] {
|
|
if let data = message.toBinaryData() {
|
|
self?.writeToPeripheral(data, peripheral: peripheral, characteristic: characteristic, peerID: dest)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear pending messages
|
|
self.pendingMessages.removeAll()
|
|
}
|
|
}
|
|
|
|
// Removed getPublicKeyFingerprint - no longer needed with Noise
|
|
|
|
// Get peer's fingerprint (replaces getPeerPublicKey)
|
|
func getPeerFingerprint(_ peerID: String) -> String? {
|
|
return noiseService.getPeerFingerprint(peerID)
|
|
}
|
|
|
|
// MARK: - Peer Identity Mapping
|
|
|
|
// Update peer identity binding when receiving announcements
|
|
func updatePeerBinding(_ newPeerID: String, fingerprint: String, binding: PeerIdentityBinding) {
|
|
// Use async to ensure we're not blocking during view updates
|
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
var oldPeerID: String? = nil
|
|
|
|
// Remove old peer ID mapping if exists
|
|
if let existingPeerID = self.fingerprintToPeerID[fingerprint], existingPeerID != newPeerID {
|
|
oldPeerID = existingPeerID
|
|
SecureLogger.log("Peer ID rotation detected: \(existingPeerID) -> \(newPeerID) for fingerprint \(fingerprint)", category: SecureLogger.security, level: .info)
|
|
|
|
self.peerIDToFingerprint.removeValue(forKey: existingPeerID)
|
|
|
|
// Transfer nickname if known
|
|
if let nickname = self.peerNicknames[existingPeerID] {
|
|
self.peerNicknames[newPeerID] = nickname
|
|
self.peerNicknames.removeValue(forKey: existingPeerID)
|
|
}
|
|
|
|
// Update active peers set
|
|
if self.activePeers.contains(existingPeerID) {
|
|
self.activePeers.remove(existingPeerID)
|
|
// Don't pre-insert the new peer ID - let the announce packet handle it
|
|
// This ensures the connect message logic works properly
|
|
}
|
|
|
|
// Transfer any connected peripherals
|
|
if let peripheral = self.connectedPeripherals[existingPeerID] {
|
|
self.connectedPeripherals.removeValue(forKey: existingPeerID)
|
|
self.connectedPeripherals[newPeerID] = peripheral
|
|
}
|
|
|
|
// Transfer RSSI data
|
|
if let rssi = self.peerRSSI[existingPeerID] {
|
|
self.peerRSSI.removeValue(forKey: existingPeerID)
|
|
self.peerRSSI[newPeerID] = rssi
|
|
}
|
|
|
|
// Transfer lastHeardFromPeer tracking
|
|
if let lastHeard = self.lastHeardFromPeer[existingPeerID] {
|
|
self.lastHeardFromPeer.removeValue(forKey: existingPeerID)
|
|
self.lastHeardFromPeer[newPeerID] = lastHeard
|
|
}
|
|
}
|
|
|
|
// Add new mapping
|
|
self.peerIDToFingerprint[newPeerID] = fingerprint
|
|
self.fingerprintToPeerID[fingerprint] = newPeerID
|
|
self.peerIdentityBindings[fingerprint] = binding
|
|
|
|
// Cache public keys to avoid keychain lookups
|
|
self.peerPublicKeyCache[newPeerID] = binding.publicKey
|
|
self.peerSigningKeyCache[newPeerID] = binding.signingPublicKey
|
|
self.identityCacheTimestamps[newPeerID] = Date()
|
|
|
|
// Also update nickname from binding
|
|
self.peerNicknames[newPeerID] = binding.nickname
|
|
|
|
// Notify about the change if it's a rotation
|
|
if let oldID = oldPeerID {
|
|
// Clear the old session instead of migrating it
|
|
// This ensures both peers do a fresh handshake after ID rotation
|
|
self.cleanupPeerCryptoState(oldID)
|
|
self.handshakeCoordinator.resetHandshakeState(for: newPeerID)
|
|
|
|
// Log the peer ID rotation
|
|
SecureLogger.log("Cleared session for peer ID rotation: \(oldID) -> \(newPeerID), will establish fresh handshake",
|
|
category: SecureLogger.handshake, level: .info)
|
|
|
|
self.notifyPeerIDChange(oldPeerID: oldID, newPeerID: newPeerID, fingerprint: fingerprint)
|
|
|
|
// Trigger handshake after a short delay to allow the peer to settle
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
self?.initiateNoiseHandshake(with: newPeerID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get current peer ID for a fingerprint
|
|
func getCurrentPeerID(for fingerprint: String) -> String? {
|
|
return collectionsQueue.sync {
|
|
fingerprintToPeerID[fingerprint]
|
|
}
|
|
}
|
|
|
|
// Get fingerprint for a peer ID
|
|
func getFingerprint(for peerID: String) -> String? {
|
|
return collectionsQueue.sync {
|
|
peerIDToFingerprint[peerID]
|
|
}
|
|
}
|
|
|
|
// Check if a peer ID belongs to us (current or previous)
|
|
func isPeerIDOurs(_ peerID: String) -> Bool {
|
|
if peerID == myPeerID {
|
|
return true
|
|
}
|
|
|
|
// Check if it's our previous ID within grace period
|
|
if let previousID = previousPeerID,
|
|
peerID == previousID,
|
|
let rotationTime = rotationTimestamp,
|
|
Date().timeIntervalSince(rotationTime) < rotationGracePeriod {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// Update peer connection state
|
|
private func updatePeerConnectionState(_ peerID: String, state: PeerConnectionState) {
|
|
connectionStateQueue.async(flags: .barrier) { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
let previousState = self.peerConnectionStates[peerID]
|
|
self.peerConnectionStates[peerID] = state
|
|
|
|
SecureLogger.log("Peer \(peerID) connection state: \(previousState?.description ?? "nil") -> \(state)",
|
|
category: SecureLogger.session, level: .debug)
|
|
|
|
// Update activePeers based on authentication state
|
|
self.collectionsQueue.async(flags: .barrier) {
|
|
switch state {
|
|
case .authenticated:
|
|
if !self.activePeers.contains(peerID) {
|
|
self.activePeers.insert(peerID)
|
|
SecureLogger.log("Added \(peerID) to activePeers (authenticated)",
|
|
category: SecureLogger.session, level: .info)
|
|
}
|
|
case .disconnected:
|
|
if self.activePeers.contains(peerID) {
|
|
self.activePeers.remove(peerID)
|
|
SecureLogger.log("Removed \(peerID) from activePeers (disconnected)",
|
|
category: SecureLogger.session, level: .info)
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
|
|
// Always notify peer list update when connection state changes
|
|
DispatchQueue.main.async {
|
|
self.notifyPeerListUpdate(immediate: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get peer connection state
|
|
func getPeerConnectionState(_ peerID: String) -> PeerConnectionState {
|
|
return connectionStateQueue.sync {
|
|
peerConnectionStates[peerID] ?? .disconnected
|
|
}
|
|
}
|
|
|
|
// MARK: - Peer ID Rotation
|
|
|
|
private func generateNewPeerID() -> String {
|
|
// Generate 8 random bytes (64 bits) for strong collision resistance
|
|
var randomBytes = [UInt8](repeating: 0, count: 8)
|
|
let result = SecRandomCopyBytes(kSecRandomDefault, 8, &randomBytes)
|
|
|
|
// If SecRandomCopyBytes fails, use alternative randomization
|
|
if result != errSecSuccess {
|
|
for i in 0..<8 {
|
|
randomBytes[i] = UInt8.random(in: 0...255)
|
|
}
|
|
}
|
|
|
|
// Add timestamp entropy to ensure uniqueness
|
|
// Use lower 32 bits of timestamp in milliseconds to avoid overflow
|
|
let timestampMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
let timestamp = UInt32(timestampMs & 0xFFFFFFFF)
|
|
randomBytes[4] = UInt8((timestamp >> 24) & 0xFF)
|
|
randomBytes[5] = UInt8((timestamp >> 16) & 0xFF)
|
|
randomBytes[6] = UInt8((timestamp >> 8) & 0xFF)
|
|
randomBytes[7] = UInt8(timestamp & 0xFF)
|
|
|
|
return randomBytes.map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
|
|
func rotatePeerID() {
|
|
guard !rotationLocked else {
|
|
// Schedule rotation for later
|
|
scheduleRotation(delay: 30.0)
|
|
return
|
|
}
|
|
|
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// Save current peer ID as previous
|
|
let oldID = self.myPeerID
|
|
self.previousPeerID = oldID
|
|
self.rotationTimestamp = Date()
|
|
|
|
// Generate new peer ID
|
|
self.myPeerID = self.generateNewPeerID()
|
|
|
|
SecureLogger.log("Peer ID rotated from \(oldID) to \(self.myPeerID)", category: SecureLogger.security, level: .info)
|
|
|
|
// Update advertising with new peer ID
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.updateAdvertisement()
|
|
}
|
|
|
|
// Send identity announcement with new peer ID
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
self?.sendNoiseIdentityAnnounce()
|
|
}
|
|
|
|
// Schedule next rotation
|
|
self.scheduleNextRotation()
|
|
}
|
|
}
|
|
|
|
private func scheduleRotation(delay: TimeInterval) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.rotationTimer?.invalidate()
|
|
self?.rotationTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in
|
|
self?.rotatePeerID()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func scheduleNextRotation() {
|
|
// Base interval: 1-6 hours
|
|
let baseInterval = TimeInterval.random(in: 3600...21600)
|
|
|
|
// Add jitter: ±30 minutes
|
|
let jitter = TimeInterval.random(in: -1800...1800)
|
|
|
|
// Additional random delay to prevent synchronization
|
|
let networkDelay = TimeInterval.random(in: 0...300) // 0-5 minutes
|
|
|
|
let nextRotation = baseInterval + jitter + networkDelay
|
|
|
|
scheduleRotation(delay: nextRotation)
|
|
}
|
|
|
|
private func updateAdvertisement() {
|
|
guard isAdvertising else { return }
|
|
|
|
peripheralManager?.stopAdvertising()
|
|
|
|
// Update advertisement data with new peer ID
|
|
advertisementData = [
|
|
CBAdvertisementDataServiceUUIDsKey: [BluetoothMeshService.serviceUUID],
|
|
CBAdvertisementDataLocalNameKey: myPeerID
|
|
]
|
|
|
|
peripheralManager?.startAdvertising(advertisementData)
|
|
}
|
|
|
|
func lockRotation() {
|
|
rotationLocked = true
|
|
}
|
|
|
|
func unlockRotation() {
|
|
rotationLocked = false
|
|
}
|
|
|
|
override init() {
|
|
// Generate ephemeral peer ID for each session to prevent tracking
|
|
self.myPeerID = ""
|
|
super.init()
|
|
self.myPeerID = generateNewPeerID()
|
|
|
|
centralManager = CBCentralManager(delegate: self, queue: nil)
|
|
peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
|
|
|
|
// Start bloom filter reset timer (reset every 5 minutes)
|
|
bloomFilterResetTimer = Timer.scheduledTimer(withTimeInterval: 300.0, repeats: true) { [weak self] _ in
|
|
self?.messageQueue.async(flags: .barrier) {
|
|
guard let self = self else { return }
|
|
|
|
// Adapt Bloom filter size based on network size
|
|
let networkSize = self.estimatedNetworkSize
|
|
self.messageBloomFilter = OptimizedBloomFilter.adaptive(for: networkSize)
|
|
|
|
// Clear other duplicate detection sets
|
|
self.processedMessages.removeAll()
|
|
|
|
}
|
|
}
|
|
|
|
// Start stale peer cleanup timer (every 30 seconds)
|
|
cleanupTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in
|
|
self?.cleanupStalePeers()
|
|
}
|
|
|
|
// Start ACK timeout checking timer (every 2 seconds for timely retries)
|
|
Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
|
|
self?.checkAckTimeouts()
|
|
}
|
|
|
|
// Start peer availability checking timer (every 5 seconds)
|
|
availabilityCheckTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
|
self?.checkPeerAvailability()
|
|
}
|
|
|
|
// Start write queue cleanup timer (every 30 seconds)
|
|
writeQueueTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in
|
|
self?.cleanExpiredWriteQueues()
|
|
}
|
|
|
|
// Start identity cache cleanup timer (every hour)
|
|
Timer.scheduledTimer(withTimeInterval: 3600.0, repeats: true) { [weak self] _ in
|
|
self?.cleanExpiredIdentityCache()
|
|
}
|
|
|
|
// Log handshake states periodically for debugging and clean up stale states
|
|
#if DEBUG
|
|
Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in
|
|
guard let self = self else { return }
|
|
|
|
// Clean up stale handshakes
|
|
let stalePeerIDs = self.handshakeCoordinator.cleanupStaleHandshakes()
|
|
if !stalePeerIDs.isEmpty {
|
|
for peerID in stalePeerIDs {
|
|
// Also remove from noise service
|
|
self.cleanupPeerCryptoState(peerID)
|
|
SecureLogger.log("Cleaned up stale handshake for \(peerID)", category: SecureLogger.handshake, level: .info)
|
|
}
|
|
}
|
|
|
|
self.handshakeCoordinator.logHandshakeStates()
|
|
}
|
|
#endif
|
|
|
|
// Schedule first peer ID rotation
|
|
scheduleNextRotation()
|
|
|
|
// Setup noise callbacks
|
|
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
|
// Get peer's public key data from noise service
|
|
if let publicKeyData = self?.noiseService.getPeerPublicKeyData(peerID) {
|
|
// Register with ChatViewModel for verification tracking
|
|
DispatchQueue.main.async {
|
|
(self?.delegate as? ChatViewModel)?.registerPeerPublicKey(peerID: peerID, publicKeyData: publicKeyData)
|
|
|
|
// Force UI to update encryption status for this specific peer
|
|
(self?.delegate as? ChatViewModel)?.updateEncryptionStatusForPeer(peerID)
|
|
}
|
|
}
|
|
|
|
// Send regular announce packet when authenticated to trigger connect message
|
|
// This covers the case where we're the responder in the handshake
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
|
self?.sendAnnouncementToPeer(peerID)
|
|
}
|
|
}
|
|
|
|
// Register for app termination notifications
|
|
#if os(macOS)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(appWillTerminate),
|
|
name: NSApplication.willTerminateNotification,
|
|
object: nil
|
|
)
|
|
#else
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(appWillTerminate),
|
|
name: UIApplication.willTerminateNotification,
|
|
object: nil
|
|
)
|
|
#endif
|
|
}
|
|
|
|
deinit {
|
|
cleanup()
|
|
scanDutyCycleTimer?.invalidate()
|
|
batteryMonitorTimer?.invalidate()
|
|
coverTrafficTimer?.invalidate()
|
|
bloomFilterResetTimer?.invalidate()
|
|
aggregationTimer?.invalidate()
|
|
cleanupTimer?.invalidate()
|
|
rotationTimer?.invalidate()
|
|
}
|
|
|
|
@objc private func appWillTerminate() {
|
|
cleanup()
|
|
}
|
|
|
|
private func cleanup() {
|
|
// Send leave announcement before disconnecting
|
|
sendLeaveAnnouncement()
|
|
|
|
// Give the leave message time to send
|
|
Thread.sleep(forTimeInterval: 0.2)
|
|
|
|
// First, disconnect all peripherals which will trigger disconnect delegates
|
|
for (_, peripheral) in connectedPeripherals {
|
|
centralManager?.cancelPeripheralConnection(peripheral)
|
|
}
|
|
|
|
// Stop advertising
|
|
if peripheralManager?.isAdvertising == true {
|
|
peripheralManager?.stopAdvertising()
|
|
}
|
|
|
|
// Stop scanning
|
|
centralManager?.stopScan()
|
|
|
|
// Remove all services - this will disconnect any connected centrals
|
|
if peripheralManager?.state == .poweredOn {
|
|
peripheralManager?.removeAllServices()
|
|
}
|
|
|
|
// Clear all tracking
|
|
connectedPeripherals.removeAll()
|
|
subscribedCentrals.removeAll()
|
|
collectionsQueue.sync(flags: .barrier) {
|
|
activePeers.removeAll()
|
|
}
|
|
announcedPeers.removeAll()
|
|
// For normal disconnect, respect the timing
|
|
networkBecameEmptyTime = Date()
|
|
|
|
// Clear announcement tracking
|
|
announcedToPeers.removeAll()
|
|
|
|
// Clear last seen timestamps
|
|
peerLastSeenTimestamps.removeAll()
|
|
|
|
// Clear all encryption queues
|
|
encryptionQueuesLock.lock()
|
|
peerEncryptionQueues.removeAll()
|
|
encryptionQueuesLock.unlock()
|
|
|
|
// Clear peer tracking
|
|
lastHeardFromPeer.removeAll()
|
|
}
|
|
|
|
func startServices() {
|
|
// Starting services
|
|
// Start both central and peripheral services
|
|
if centralManager?.state == .poweredOn {
|
|
startScanning()
|
|
}
|
|
if peripheralManager?.state == .poweredOn {
|
|
setupPeripheral()
|
|
startAdvertising()
|
|
}
|
|
|
|
// Send initial announces after services are ready
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
self?.sendBroadcastAnnounce()
|
|
}
|
|
|
|
// Setup battery optimizer
|
|
setupBatteryOptimizer()
|
|
|
|
// Start cover traffic for privacy
|
|
startCoverTraffic()
|
|
}
|
|
|
|
func sendBroadcastAnnounce() {
|
|
guard let vm = delegate as? ChatViewModel else { return }
|
|
|
|
|
|
let announcePacket = BitchatPacket(
|
|
type: MessageType.announce.rawValue,
|
|
ttl: 3, // Increase TTL so announce reaches all peers
|
|
senderID: myPeerID,
|
|
payload: Data(vm.nickname.utf8)
|
|
)
|
|
|
|
|
|
// Initial send with random delay
|
|
let initialDelay = self.randomDelay()
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + initialDelay) { [weak self] in
|
|
self?.broadcastPacket(announcePacket)
|
|
|
|
// Also send Noise identity announcement
|
|
self?.sendNoiseIdentityAnnounce()
|
|
}
|
|
|
|
// Send multiple times for reliability with jittered delays
|
|
for baseDelay in [0.2, 0.5, 1.0] {
|
|
let jitteredDelay = baseDelay + self.randomDelay()
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + jitteredDelay) { [weak self] in
|
|
guard let self = self else { return }
|
|
self.broadcastPacket(announcePacket)
|
|
}
|
|
}
|
|
}
|
|
|
|
func startAdvertising() {
|
|
guard peripheralManager?.state == .poweredOn else {
|
|
return
|
|
}
|
|
|
|
// Use generic advertising to avoid identification
|
|
// No identifying prefixes or app names for activist safety
|
|
|
|
// Only use allowed advertisement keys
|
|
advertisementData = [
|
|
CBAdvertisementDataServiceUUIDsKey: [BluetoothMeshService.serviceUUID],
|
|
// Use only peer ID without any identifying prefix
|
|
CBAdvertisementDataLocalNameKey: myPeerID
|
|
]
|
|
|
|
isAdvertising = true
|
|
peripheralManager?.startAdvertising(advertisementData)
|
|
}
|
|
|
|
func startScanning() {
|
|
guard centralManager?.state == .poweredOn else {
|
|
return
|
|
}
|
|
|
|
// Enable duplicate detection for RSSI tracking
|
|
let scanOptions: [String: Any] = [
|
|
CBCentralManagerScanOptionAllowDuplicatesKey: true
|
|
]
|
|
|
|
centralManager?.scanForPeripherals(
|
|
withServices: [BluetoothMeshService.serviceUUID],
|
|
options: scanOptions
|
|
)
|
|
|
|
// Update scan parameters based on battery before starting
|
|
updateScanParametersForBattery()
|
|
|
|
// Implement scan duty cycling for battery efficiency
|
|
scheduleScanDutyCycle()
|
|
}
|
|
|
|
private func scheduleScanDutyCycle() {
|
|
guard scanDutyCycleTimer == nil else { return }
|
|
|
|
// Start with active scanning
|
|
isActivelyScanning = true
|
|
|
|
scanDutyCycleTimer = Timer.scheduledTimer(withTimeInterval: activeScanDuration, repeats: true) { [weak self] _ in
|
|
guard let self = self else { return }
|
|
|
|
if self.isActivelyScanning {
|
|
// Pause scanning to save battery
|
|
self.centralManager?.stopScan()
|
|
self.isActivelyScanning = false
|
|
|
|
// Schedule resume
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + self.scanPauseDuration) { [weak self] in
|
|
guard let self = self else { return }
|
|
if self.centralManager?.state == .poweredOn {
|
|
self.centralManager?.scanForPeripherals(
|
|
withServices: [BluetoothMeshService.serviceUUID],
|
|
options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
|
|
)
|
|
self.isActivelyScanning = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setupPeripheral() {
|
|
let characteristic = CBMutableCharacteristic(
|
|
type: BluetoothMeshService.characteristicUUID,
|
|
properties: [.read, .write, .writeWithoutResponse, .notify],
|
|
value: nil,
|
|
permissions: [.readable, .writeable]
|
|
)
|
|
|
|
let service = CBMutableService(type: BluetoothMeshService.serviceUUID, primary: true)
|
|
service.characteristics = [characteristic]
|
|
|
|
peripheralManager?.add(service)
|
|
self.characteristic = characteristic
|
|
}
|
|
|
|
func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) {
|
|
// Defensive check for empty content
|
|
guard !content.isEmpty else { return }
|
|
messageQueue.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
let nickname = self.delegate as? ChatViewModel
|
|
let senderNick = nickname?.nickname ?? self.myPeerID
|
|
|
|
let message = BitchatMessage(
|
|
id: messageID,
|
|
sender: senderNick,
|
|
content: content,
|
|
timestamp: timestamp ?? Date(),
|
|
isRelay: false,
|
|
originalSender: nil,
|
|
isPrivate: false,
|
|
recipientNickname: nil,
|
|
senderPeerID: self.myPeerID,
|
|
mentions: mentions.isEmpty ? nil : mentions
|
|
)
|
|
|
|
if let messageData = message.toBinaryPayload() {
|
|
|
|
|
|
// Use unified message type with broadcast recipient
|
|
let packet = BitchatPacket(
|
|
type: MessageType.message.rawValue,
|
|
senderID: Data(hexString: self.myPeerID) ?? Data(),
|
|
recipientID: SpecialRecipients.broadcast, // Special broadcast ID
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), // milliseconds
|
|
payload: messageData,
|
|
signature: nil,
|
|
ttl: self.adaptiveTTL
|
|
)
|
|
|
|
// Track this message to prevent duplicate sends
|
|
let msgID = "\(packet.timestamp)-\(self.myPeerID)-\(packet.payload.prefix(32).hashValue)"
|
|
|
|
let shouldSend = !self.recentlySentMessages.contains(msgID)
|
|
if shouldSend {
|
|
self.recentlySentMessages.insert(msgID)
|
|
}
|
|
|
|
if shouldSend {
|
|
// Clean up old entries after 10 seconds
|
|
self.messageQueue.asyncAfter(deadline: .now() + 10.0) { [weak self] in
|
|
guard let self = self else { return }
|
|
self.recentlySentMessages.remove(msgID)
|
|
}
|
|
|
|
// Add random delay before initial send
|
|
let initialDelay = self.randomDelay()
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + initialDelay) { [weak self] in
|
|
self?.broadcastPacket(packet)
|
|
}
|
|
|
|
// Single retry for reliability
|
|
let retryDelay = 0.3 + self.randomDelay()
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) { [weak self] in
|
|
self?.broadcastPacket(packet)
|
|
// Re-sending message
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String, messageID: String? = nil) {
|
|
// Defensive checks
|
|
guard !content.isEmpty, !recipientPeerID.isEmpty, !recipientNickname.isEmpty else {
|
|
return
|
|
}
|
|
|
|
let msgID = messageID ?? UUID().uuidString
|
|
|
|
messageQueue.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// Check if this is an old peer ID that has rotated
|
|
var targetPeerID = recipientPeerID
|
|
|
|
// If we have a fingerprint for this peer ID, check if there's a newer peer ID
|
|
if let fingerprint = self.collectionsQueue.sync(execute: { self.peerIDToFingerprint[recipientPeerID] }),
|
|
let currentPeerID = self.collectionsQueue.sync(execute: { self.fingerprintToPeerID[fingerprint] }),
|
|
currentPeerID != recipientPeerID {
|
|
// Use the current peer ID instead
|
|
targetPeerID = currentPeerID
|
|
}
|
|
|
|
// Always use Noise encryption
|
|
self.sendPrivateMessageViaNoise(content, to: targetPeerID, recipientNickname: recipientNickname, messageID: msgID)
|
|
}
|
|
}
|
|
|
|
// Public method to get current peer ID for a fingerprint
|
|
func getCurrentPeerIDForFingerprint(_ fingerprint: String) -> String? {
|
|
return collectionsQueue.sync {
|
|
return fingerprintToPeerID[fingerprint]
|
|
}
|
|
}
|
|
|
|
// Public method to get all current peer IDs for known fingerprints
|
|
func getCurrentPeerIDs() -> [String: String] {
|
|
return collectionsQueue.sync {
|
|
return fingerprintToPeerID
|
|
}
|
|
}
|
|
|
|
// Notify delegate when peer ID changes
|
|
private func notifyPeerIDChange(oldPeerID: String, newPeerID: String, fingerprint: String) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
// Remove old peer ID from active peers and announcedPeers
|
|
self?.collectionsQueue.sync(flags: .barrier) {
|
|
_ = self?.activePeers.remove(oldPeerID)
|
|
// Don't pre-insert the new peer ID - let the announce packet handle it
|
|
// This ensures the connect message logic works properly
|
|
}
|
|
|
|
// Also remove from announcedPeers so the new ID can trigger a connect message
|
|
self?.announcedPeers.remove(oldPeerID)
|
|
|
|
// Update peer list
|
|
self?.notifyPeerListUpdate(immediate: true)
|
|
|
|
// Don't send disconnect/connect messages for peer ID rotation
|
|
// The peer didn't actually disconnect, they just rotated their ID
|
|
// This prevents confusing messages like "3a7e1c2c0d8943b9 disconnected"
|
|
|
|
// Instead, notify the delegate about the peer ID change if needed
|
|
// (Could add a new delegate method for this in the future)
|
|
}
|
|
}
|
|
|
|
|
|
func sendDeliveryAck(_ ack: DeliveryAck, to recipientID: String) {
|
|
// Use per-peer encryption queue to prevent nonce desynchronization
|
|
let encryptionQueue = getEncryptionQueue(for: recipientID)
|
|
|
|
encryptionQueue.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// Encode the ACK
|
|
let ackData = ack.toBinaryData()
|
|
|
|
// Check if we have a Noise session with this peer
|
|
// Use noiseService directly
|
|
if self.noiseService.hasEstablishedSession(with: recipientID) {
|
|
// Use Noise encryption - encrypt only the ACK payload directly
|
|
do {
|
|
// Create a special payload that indicates this is a delivery ACK
|
|
// Format: [1 byte type marker] + [ACK JSON data]
|
|
var ackPayload = Data()
|
|
ackPayload.append(MessageType.deliveryAck.rawValue) // Type marker
|
|
ackPayload.append(ackData) // ACK JSON
|
|
|
|
// Encrypt only the payload (not a full packet)
|
|
let encryptedPayload = try noiseService.encrypt(ackPayload, for: recipientID)
|
|
|
|
// Create outer Noise packet with the encrypted payload
|
|
let outerPacket = BitchatPacket(
|
|
type: MessageType.noiseEncrypted.rawValue,
|
|
senderID: Data(hexString: self.myPeerID) ?? Data(),
|
|
recipientID: Data(hexString: recipientID) ?? Data(),
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: encryptedPayload,
|
|
signature: nil,
|
|
ttl: 3
|
|
)
|
|
|
|
self.broadcastPacket(outerPacket)
|
|
} catch {
|
|
SecureLogger.logError(error, context: "Failed to encrypt delivery ACK via Noise for \(recipientID)", category: SecureLogger.encryption)
|
|
}
|
|
} else {
|
|
// Fall back to legacy encryption
|
|
let encryptedPayload: Data
|
|
do {
|
|
encryptedPayload = try self.noiseService.encrypt(ackData, for: recipientID)
|
|
} catch {
|
|
SecureLogger.logError(error, context: "Failed to encrypt delivery ACK for \(recipientID)", category: SecureLogger.encryption)
|
|
return
|
|
}
|
|
|
|
// Create ACK packet with direct routing to original sender
|
|
let packet = BitchatPacket(
|
|
type: MessageType.deliveryAck.rawValue,
|
|
senderID: Data(hexString: self.myPeerID) ?? Data(),
|
|
recipientID: Data(hexString: recipientID) ?? Data(),
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: encryptedPayload,
|
|
signature: nil, // ACKs don't need signatures
|
|
ttl: 3 // Limited TTL for ACKs
|
|
)
|
|
|
|
// Send immediately without delay (ACKs should be fast)
|
|
self.broadcastPacket(packet)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func getEncryptionQueue(for peerID: String) -> DispatchQueue {
|
|
encryptionQueuesLock.lock()
|
|
defer { encryptionQueuesLock.unlock() }
|
|
|
|
if let queue = peerEncryptionQueues[peerID] {
|
|
return queue
|
|
}
|
|
|
|
let queue = DispatchQueue(label: "bitchat.encryption.\(peerID)", qos: .userInitiated)
|
|
peerEncryptionQueues[peerID] = queue
|
|
return queue
|
|
}
|
|
|
|
private func removeEncryptionQueue(for peerID: String) {
|
|
encryptionQueuesLock.lock()
|
|
defer { encryptionQueuesLock.unlock() }
|
|
|
|
peerEncryptionQueues.removeValue(forKey: peerID)
|
|
}
|
|
|
|
// Centralized cleanup for peer crypto state
|
|
private func cleanupPeerCryptoState(_ peerID: String) {
|
|
noiseService.removePeer(peerID)
|
|
handshakeCoordinator.resetHandshakeState(for: peerID)
|
|
removeEncryptionQueue(for: peerID)
|
|
}
|
|
|
|
func sendReadReceipt(_ receipt: ReadReceipt, to recipientID: String) {
|
|
// Use per-peer encryption queue to prevent nonce desynchronization
|
|
let encryptionQueue = getEncryptionQueue(for: recipientID)
|
|
|
|
encryptionQueue.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// Encode the receipt
|
|
let receiptData = receipt.toBinaryData()
|
|
|
|
// Check if we have a Noise session with this peer
|
|
// Use noiseService directly
|
|
if self.noiseService.hasEstablishedSession(with: recipientID) {
|
|
// Use Noise encryption - send as Noise encrypted message
|
|
do {
|
|
// Create inner read receipt packet
|
|
let innerPacket = BitchatPacket(
|
|
type: MessageType.readReceipt.rawValue,
|
|
senderID: Data(hexString: self.myPeerID) ?? Data(),
|
|
recipientID: Data(hexString: recipientID) ?? Data(),
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: receiptData,
|
|
signature: nil,
|
|
ttl: 3
|
|
)
|
|
|
|
// Encrypt the entire inner packet
|
|
if let innerData = innerPacket.toBinaryData() {
|
|
let encryptedInnerData = try noiseService.encrypt(innerData, for: recipientID)
|
|
|
|
// Create outer Noise packet
|
|
let outerPacket = BitchatPacket(
|
|
type: MessageType.noiseEncrypted.rawValue,
|
|
senderID: Data(hexString: self.myPeerID) ?? Data(),
|
|
recipientID: Data(hexString: recipientID) ?? Data(),
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: encryptedInnerData,
|
|
signature: nil,
|
|
ttl: 3
|
|
)
|
|
|
|
SecureLogger.log("Sending encrypted read receipt for message \(receipt.originalMessageID) to \(recipientID)", category: SecureLogger.noise, level: .info)
|
|
self.broadcastPacket(outerPacket)
|
|
}
|
|
} catch {
|
|
SecureLogger.logError(error, context: "Failed to encrypt read receipt via Noise for \(recipientID)", category: SecureLogger.encryption)
|
|
}
|
|
} else {
|
|
// No session - initiate handshake and queue the read receipt
|
|
SecureLogger.log("No Noise session with \(recipientID) for read receipt, initiating handshake", category: SecureLogger.noise, level: .info)
|
|
|
|
// Initiate handshake regardless of our role if we need to send data
|
|
self.initiateNoiseHandshake(with: recipientID)
|
|
|
|
// Queue the read receipt as a pending message
|
|
// Create a synthetic message ID for the read receipt
|
|
let readReceiptMessageID = "READ_RECEIPT_\(receipt.originalMessageID)"
|
|
|
|
collectionsQueue.sync(flags: .barrier) {
|
|
if self.pendingPrivateMessages[recipientID] == nil {
|
|
self.pendingPrivateMessages[recipientID] = []
|
|
}
|
|
|
|
// Store the read receipt data as a pending "message"
|
|
self.pendingPrivateMessages[recipientID]?.append((
|
|
content: "READ_RECEIPT:\(receipt.originalMessageID)",
|
|
recipientNickname: receipt.readerNickname,
|
|
messageID: readReceiptMessageID
|
|
))
|
|
|
|
let count = self.pendingPrivateMessages[recipientID]?.count ?? 0
|
|
SecureLogger.log("Queued read receipt for \(recipientID), pending messages: \(count)", category: SecureLogger.noise, level: .info)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func sendAnnouncementToPeer(_ peerID: String) {
|
|
guard let vm = delegate as? ChatViewModel else { return }
|
|
|
|
|
|
// Always send announce, don't check if already announced
|
|
// This ensures peers get our nickname even if they reconnect
|
|
|
|
let packet = BitchatPacket(
|
|
type: MessageType.announce.rawValue,
|
|
ttl: 3, // Allow relay for better reach
|
|
senderID: myPeerID,
|
|
payload: Data(vm.nickname.utf8)
|
|
)
|
|
|
|
if let data = packet.toBinaryData() {
|
|
// Try both broadcast and targeted send
|
|
broadcastPacket(packet)
|
|
|
|
// Also try targeted send if we have the peripheral
|
|
if let peripheral = connectedPeripherals[peerID],
|
|
peripheral.state == .connected,
|
|
let characteristic = peripheral.services?.first(where: { $0.uuid == BluetoothMeshService.serviceUUID })?.characteristics?.first(where: { $0.uuid == BluetoothMeshService.characteristicUUID }) {
|
|
writeToPeripheral(data, peripheral: peripheral, characteristic: characteristic, peerID: peerID)
|
|
} else {
|
|
}
|
|
} else {
|
|
}
|
|
|
|
announcedToPeers.insert(peerID)
|
|
}
|
|
|
|
private func sendLeaveAnnouncement() {
|
|
guard let vm = delegate as? ChatViewModel else { return }
|
|
|
|
let packet = BitchatPacket(
|
|
type: MessageType.leave.rawValue,
|
|
ttl: 1, // Don't relay leave messages
|
|
senderID: myPeerID,
|
|
payload: Data(vm.nickname.utf8)
|
|
)
|
|
|
|
broadcastPacket(packet)
|
|
}
|
|
|
|
|
|
func getPeerNicknames() -> [String: String] {
|
|
return collectionsQueue.sync {
|
|
return peerNicknames
|
|
}
|
|
}
|
|
|
|
func getPeerRSSI() -> [String: NSNumber] {
|
|
// Return actual RSSI values only - no fake defaults
|
|
// UI should handle missing values gracefully
|
|
return peerRSSI
|
|
}
|
|
|
|
// Emergency disconnect for panic situations
|
|
func emergencyDisconnectAll() {
|
|
SecureLogger.log("Emergency disconnect triggered", category: SecureLogger.security, level: .warning)
|
|
|
|
// Stop advertising immediately
|
|
if peripheralManager?.isAdvertising == true {
|
|
peripheralManager?.stopAdvertising()
|
|
}
|
|
|
|
// Stop scanning
|
|
centralManager?.stopScan()
|
|
scanDutyCycleTimer?.invalidate()
|
|
scanDutyCycleTimer = nil
|
|
|
|
// Disconnect all peripherals
|
|
for (peerID, peripheral) in connectedPeripherals {
|
|
SecureLogger.log("Emergency disconnect peer: \(peerID)", category: SecureLogger.session, level: .warning)
|
|
centralManager?.cancelPeripheralConnection(peripheral)
|
|
}
|
|
|
|
// Clear all peer data
|
|
connectedPeripherals.removeAll()
|
|
peripheralCharacteristics.removeAll()
|
|
discoveredPeripherals.removeAll()
|
|
subscribedCentrals.removeAll()
|
|
peerNicknames.removeAll()
|
|
activePeers.removeAll()
|
|
peerRSSI.removeAll()
|
|
peripheralRSSI.removeAll()
|
|
announcedToPeers.removeAll()
|
|
announcedPeers.removeAll()
|
|
// For emergency/panic, reset immediately
|
|
hasNotifiedNetworkAvailable = false
|
|
networkBecameEmptyTime = nil
|
|
lastNetworkNotificationTime = nil
|
|
processedMessages.removeAll()
|
|
incomingFragments.removeAll()
|
|
|
|
// Clear all encryption queues
|
|
encryptionQueuesLock.lock()
|
|
peerEncryptionQueues.removeAll()
|
|
encryptionQueuesLock.unlock()
|
|
fragmentMetadata.removeAll()
|
|
|
|
// Clear peer tracking
|
|
lastHeardFromPeer.removeAll()
|
|
|
|
// Clear persistent identity
|
|
noiseService.clearPersistentIdentity()
|
|
|
|
// Clear all handshake coordinator states
|
|
handshakeCoordinator.clearAllHandshakeStates()
|
|
|
|
// Clear handshake attempt times
|
|
handshakeAttemptTimes.removeAll()
|
|
|
|
// Notify UI that all peers are disconnected
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.delegate?.didUpdatePeerList([])
|
|
}
|
|
}
|
|
|
|
private func getAllConnectedPeerIDs() -> [String] {
|
|
// Return all valid active peers
|
|
let peersCopy = collectionsQueue.sync {
|
|
return activePeers
|
|
}
|
|
|
|
|
|
let validPeers = peersCopy.filter { peerID in
|
|
// Ensure peerID is valid and not self
|
|
let isEmpty = peerID.isEmpty
|
|
let isUnknown = peerID == "unknown"
|
|
let isSelf = peerID == self.myPeerID
|
|
|
|
return !isEmpty && !isUnknown && !isSelf
|
|
}
|
|
|
|
let result = Array(validPeers).sorted()
|
|
return result
|
|
}
|
|
|
|
// Debounced peer list update notification
|
|
private func notifyPeerListUpdate(immediate: Bool = false) {
|
|
if immediate {
|
|
// For initial connections, update immediately
|
|
let connectedPeerIDs = self.getAllConnectedPeerIDs()
|
|
|
|
DispatchQueue.main.async {
|
|
self.delegate?.didUpdatePeerList(connectedPeerIDs)
|
|
}
|
|
} else {
|
|
// Must schedule timer on main thread
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// Cancel any pending update
|
|
self.peerListUpdateTimer?.invalidate()
|
|
|
|
// Schedule a new update after debounce interval
|
|
self.peerListUpdateTimer = Timer.scheduledTimer(withTimeInterval: self.peerListUpdateDebounceInterval, repeats: false) { [weak self] _ in
|
|
guard let self = self else { return }
|
|
|
|
let connectedPeerIDs = self.getAllConnectedPeerIDs()
|
|
|
|
self.delegate?.didUpdatePeerList(connectedPeerIDs)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up stale peers that haven't been seen in a while
|
|
private func cleanupStalePeers() {
|
|
let staleThreshold: TimeInterval = 180.0 // 3 minutes - increased for better stability
|
|
let now = Date()
|
|
|
|
let peersToRemove = collectionsQueue.sync(flags: .barrier) {
|
|
let toRemove = activePeers.filter { peerID in
|
|
if let lastSeen = peerLastSeenTimestamps.get(peerID) {
|
|
return now.timeIntervalSince(lastSeen) > staleThreshold
|
|
}
|
|
return false // Keep peers we haven't tracked yet
|
|
}
|
|
|
|
var actuallyRemoved: [String] = []
|
|
|
|
for peerID in toRemove {
|
|
// Check if this peer has an active peripheral connection
|
|
if let peripheral = connectedPeripherals[peerID], peripheral.state == .connected {
|
|
// Skipping removal - still has active connection
|
|
// Update last seen time to prevent immediate re-removal
|
|
peerLastSeenTimestamps.set(peerID, value: Date())
|
|
continue
|
|
}
|
|
|
|
let nickname = peerNicknames[peerID] ?? "unknown"
|
|
activePeers.remove(peerID)
|
|
peerLastSeenTimestamps.remove(peerID)
|
|
SecureLogger.log("📴 Removed stale peer from network: \(peerID) (\(nickname))", category: SecureLogger.session, level: .info)
|
|
|
|
// Clean up all associated data
|
|
connectedPeripherals.removeValue(forKey: peerID)
|
|
peerRSSI.removeValue(forKey: peerID)
|
|
announcedPeers.remove(peerID)
|
|
announcedToPeers.remove(peerID)
|
|
peerNicknames.removeValue(forKey: peerID)
|
|
lastHeardFromPeer.removeValue(forKey: peerID)
|
|
|
|
actuallyRemoved.append(peerID)
|
|
// Removed stale peer
|
|
}
|
|
return actuallyRemoved
|
|
}
|
|
|
|
if !peersToRemove.isEmpty {
|
|
notifyPeerListUpdate()
|
|
|
|
// Mark when network became empty, but don't reset flag immediately
|
|
let currentNetworkSize = collectionsQueue.sync { activePeers.count }
|
|
if currentNetworkSize == 0 && networkBecameEmptyTime == nil {
|
|
networkBecameEmptyTime = Date()
|
|
}
|
|
}
|
|
|
|
// Check if we should reset the notification flag
|
|
if let emptyTime = networkBecameEmptyTime {
|
|
let currentNetworkSize = collectionsQueue.sync { activePeers.count }
|
|
if currentNetworkSize == 0 {
|
|
// Network is still empty, check if enough time has passed
|
|
let timeSinceEmpty = Date().timeIntervalSince(emptyTime)
|
|
if timeSinceEmpty >= networkEmptyResetDelay {
|
|
// Reset the flag after network has been empty for the delay period
|
|
hasNotifiedNetworkAvailable = false
|
|
// Keep the empty time set so we don't immediately notify again
|
|
}
|
|
} else {
|
|
// Network is no longer empty, clear the empty time
|
|
networkBecameEmptyTime = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Store-and-Forward Methods
|
|
|
|
private func cacheMessage(_ packet: BitchatPacket, messageID: String) {
|
|
messageQueue.async(flags: .barrier) { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// Don't cache certain message types
|
|
guard packet.type != MessageType.announce.rawValue,
|
|
packet.type != MessageType.leave.rawValue,
|
|
packet.type != MessageType.fragmentStart.rawValue,
|
|
packet.type != MessageType.fragmentContinue.rawValue,
|
|
packet.type != MessageType.fragmentEnd.rawValue else {
|
|
return
|
|
}
|
|
|
|
// Don't cache broadcast messages
|
|
if let recipientID = packet.recipientID,
|
|
recipientID == SpecialRecipients.broadcast {
|
|
return // Never cache broadcast messages
|
|
}
|
|
|
|
// Check if this is a private message for a favorite
|
|
var isForFavorite = false
|
|
if packet.type == MessageType.message.rawValue,
|
|
let recipientID = packet.recipientID {
|
|
let recipientPeerID = recipientID.hexEncodedString()
|
|
// Check if recipient is a favorite via their public key fingerprint
|
|
if let fingerprint = self.noiseService.getPeerFingerprint(recipientPeerID) {
|
|
isForFavorite = self.delegate?.isFavorite(fingerprint: fingerprint) ?? false
|
|
}
|
|
}
|
|
|
|
// Create stored message with original packet timestamp preserved
|
|
let storedMessage = StoredMessage(
|
|
packet: packet,
|
|
timestamp: Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000.0), // convert from milliseconds
|
|
messageID: messageID,
|
|
isForFavorite: isForFavorite
|
|
)
|
|
|
|
|
|
if isForFavorite {
|
|
if let recipientID = packet.recipientID {
|
|
let recipientPeerID = recipientID.hexEncodedString()
|
|
if self.favoriteMessageQueue[recipientPeerID] == nil {
|
|
self.favoriteMessageQueue[recipientPeerID] = []
|
|
}
|
|
self.favoriteMessageQueue[recipientPeerID]?.append(storedMessage)
|
|
|
|
// Limit favorite queue size
|
|
if let count = self.favoriteMessageQueue[recipientPeerID]?.count,
|
|
count > self.maxCachedMessagesForFavorites {
|
|
self.favoriteMessageQueue[recipientPeerID]?.removeFirst()
|
|
}
|
|
|
|
}
|
|
} else {
|
|
// Clean up old messages first (only for regular cache)
|
|
self.cleanupMessageCache()
|
|
|
|
// Add to regular cache
|
|
self.messageCache.append(storedMessage)
|
|
|
|
// Limit cache size
|
|
if self.messageCache.count > self.maxCachedMessages {
|
|
self.messageCache.removeFirst()
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
private func cleanupMessageCache() {
|
|
let cutoffTime = Date().addingTimeInterval(-messageCacheTimeout)
|
|
// Only remove non-favorite messages that are older than timeout
|
|
messageCache.removeAll { !$0.isForFavorite && $0.timestamp < cutoffTime }
|
|
|
|
// Clean up delivered messages set periodically (keep recent 1000 entries)
|
|
if deliveredMessages.count > 1000 {
|
|
// Clear older entries while keeping recent ones
|
|
deliveredMessages.removeAll()
|
|
}
|
|
}
|
|
|
|
private func sendCachedMessages(to peerID: String) {
|
|
messageQueue.async { [weak self] in
|
|
guard let self = self,
|
|
let peripheral = self.connectedPeripherals[peerID],
|
|
let characteristic = self.peripheralCharacteristics[peripheral] else {
|
|
return
|
|
}
|
|
|
|
|
|
// Check if we've already sent cached messages to this peer in this session
|
|
if self.cachedMessagesSentToPeer.contains(peerID) {
|
|
return // Already sent cached messages to this peer in this session
|
|
}
|
|
|
|
// Mark that we're sending cached messages to this peer
|
|
self.cachedMessagesSentToPeer.insert(peerID)
|
|
|
|
// Clean up old messages first
|
|
self.cleanupMessageCache()
|
|
|
|
var messagesToSend: [StoredMessage] = []
|
|
|
|
// First, check if this peer has any favorite messages waiting
|
|
if let favoriteMessages = self.favoriteMessageQueue[peerID] {
|
|
// Filter out already delivered messages
|
|
let undeliveredFavoriteMessages = favoriteMessages.filter { !self.deliveredMessages.contains($0.messageID) }
|
|
messagesToSend.append(contentsOf: undeliveredFavoriteMessages)
|
|
// Clear the favorite queue after adding to send list
|
|
self.favoriteMessageQueue[peerID] = nil
|
|
}
|
|
|
|
// Filter regular cached messages for this specific recipient
|
|
let recipientMessages = self.messageCache.filter { storedMessage in
|
|
if self.deliveredMessages.contains(storedMessage.messageID) {
|
|
return false
|
|
}
|
|
if let recipientID = storedMessage.packet.recipientID {
|
|
let recipientPeerID = recipientID.hexEncodedString()
|
|
return recipientPeerID == peerID
|
|
}
|
|
return false // Don't forward broadcast messages
|
|
}
|
|
messagesToSend.append(contentsOf: recipientMessages)
|
|
|
|
|
|
// Sort messages by timestamp to ensure proper ordering
|
|
messagesToSend.sort { $0.timestamp < $1.timestamp }
|
|
|
|
if !messagesToSend.isEmpty {
|
|
}
|
|
|
|
// Mark messages as delivered immediately to prevent duplicates
|
|
let messageIDsToRemove = messagesToSend.map { $0.messageID }
|
|
for messageID in messageIDsToRemove {
|
|
self.deliveredMessages.insert(messageID)
|
|
}
|
|
|
|
// Send cached messages with slight delay between each
|
|
for (index, storedMessage) in messagesToSend.enumerated() {
|
|
let delay = Double(index) * 0.02 // 20ms between messages for faster sync
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self, weak peripheral] in
|
|
guard let peripheral = peripheral,
|
|
peripheral.state == .connected else {
|
|
return
|
|
}
|
|
|
|
// Send the original packet with preserved timestamp
|
|
let packetToSend = storedMessage.packet
|
|
|
|
if let data = packetToSend.toBinaryData(),
|
|
characteristic.properties.contains(.writeWithoutResponse) {
|
|
self?.writeToPeripheral(data, peripheral: peripheral, characteristic: characteristic, peerID: peerID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove sent messages immediately
|
|
if !messageIDsToRemove.isEmpty {
|
|
self.messageQueue.async(flags: .barrier) {
|
|
// Remove only the messages we sent to this specific peer
|
|
self.messageCache.removeAll { message in
|
|
messageIDsToRemove.contains(message.messageID)
|
|
}
|
|
|
|
// Also remove from favorite queue if any
|
|
if var favoriteQueue = self.favoriteMessageQueue[peerID] {
|
|
favoriteQueue.removeAll { message in
|
|
messageIDsToRemove.contains(message.messageID)
|
|
}
|
|
self.favoriteMessageQueue[peerID] = favoriteQueue.isEmpty ? nil : favoriteQueue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func estimateDistance(rssi: Int) -> Int {
|
|
// Rough distance estimation based on RSSI
|
|
// Using path loss formula: RSSI = TxPower - 10 * n * log10(distance)
|
|
// Assuming TxPower = -59 dBm at 1m, n = 2.0 (free space)
|
|
let txPower = -59.0
|
|
let pathLossExponent = 2.0
|
|
|
|
let ratio = (txPower - Double(rssi)) / (10.0 * pathLossExponent)
|
|
let distance = pow(10.0, ratio)
|
|
|
|
return Int(distance)
|
|
}
|
|
|
|
private func broadcastPacket(_ packet: BitchatPacket) {
|
|
// CRITICAL CHECK: Never send unencrypted JSON
|
|
if packet.type == MessageType.deliveryAck.rawValue {
|
|
// Check if payload looks like JSON
|
|
if let jsonCheck = String(data: packet.payload.prefix(1), encoding: .utf8), jsonCheck == "{" {
|
|
// Block unencrypted JSON in delivery ACKs
|
|
return
|
|
}
|
|
}
|
|
|
|
|
|
guard let data = packet.toBinaryData() else {
|
|
// Failed to convert packet - add to retry queue if it's our message
|
|
let senderID = packet.senderID.hexEncodedString()
|
|
if senderID == self.myPeerID,
|
|
packet.type == MessageType.message.rawValue,
|
|
let message = BitchatMessage.fromBinaryPayload(packet.payload) {
|
|
MessageRetryService.shared.addMessageForRetry(
|
|
content: message.content,
|
|
mentions: message.mentions,
|
|
isPrivate: message.isPrivate,
|
|
recipientPeerID: nil,
|
|
recipientNickname: message.recipientNickname,
|
|
originalMessageID: message.id,
|
|
originalTimestamp: message.timestamp
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Check if fragmentation is needed for large packets
|
|
if data.count > 512 && packet.type != MessageType.fragmentStart.rawValue &&
|
|
packet.type != MessageType.fragmentContinue.rawValue &&
|
|
packet.type != MessageType.fragmentEnd.rawValue {
|
|
sendFragmentedPacket(packet)
|
|
return
|
|
}
|
|
|
|
// Send to connected peripherals (as central)
|
|
var sentToPeripherals = 0
|
|
for (peerID, peripheral) in connectedPeripherals {
|
|
if let characteristic = peripheralCharacteristics[peripheral] {
|
|
// Check if peripheral is connected before writing
|
|
if peripheral.state == .connected {
|
|
// Additional safety check for characteristic properties
|
|
if characteristic.properties.contains(.write) ||
|
|
characteristic.properties.contains(.writeWithoutResponse) {
|
|
writeToPeripheral(data, peripheral: peripheral, characteristic: characteristic, peerID: peerID)
|
|
sentToPeripherals += 1
|
|
}
|
|
} else {
|
|
if let peerID = connectedPeripherals.first(where: { $0.value == peripheral })?.key {
|
|
connectedPeripherals.removeValue(forKey: peerID)
|
|
peripheralCharacteristics.removeValue(forKey: peripheral)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Send to subscribed centrals (as peripheral)
|
|
var sentToCentrals = 0
|
|
if let char = characteristic, !subscribedCentrals.isEmpty {
|
|
// Send to all subscribed centrals
|
|
// Note: Large packets should already be fragmented by the check at the beginning of broadcastPacket
|
|
let success = peripheralManager?.updateValue(data, for: char, onSubscribedCentrals: nil) ?? false
|
|
if success {
|
|
sentToCentrals = subscribedCentrals.count
|
|
}
|
|
}
|
|
|
|
// If no peers received the message, add to retry queue ONLY if it's our own message
|
|
if sentToPeripherals == 0 && sentToCentrals == 0 {
|
|
// Check if this packet originated from us
|
|
let senderID = packet.senderID.hexEncodedString()
|
|
if senderID == self.myPeerID {
|
|
// This is our own message that failed to send
|
|
if packet.type == MessageType.message.rawValue,
|
|
let message = BitchatMessage.fromBinaryPayload(packet.payload) {
|
|
MessageRetryService.shared.addMessageForRetry(
|
|
content: message.content,
|
|
mentions: message.mentions,
|
|
isPrivate: message.isPrivate,
|
|
recipientPeerID: nil,
|
|
recipientNickname: message.recipientNickname,
|
|
originalMessageID: message.id,
|
|
originalTimestamp: message.timestamp
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: String, peripheral: CBPeripheral? = nil) {
|
|
messageQueue.async(flags: .barrier) { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// Track that we heard from this peer
|
|
let senderID = packet.senderID.hexEncodedString()
|
|
if !senderID.isEmpty && senderID != self.myPeerID {
|
|
// Update peer availability
|
|
self.updatePeerAvailability(senderID)
|
|
|
|
// IMPORTANT: Update peripheral mapping for ALL message types including handshakes
|
|
// This ensures disconnect messages work even if peer disconnects right after handshake
|
|
if let peripheral = peripheral {
|
|
let peripheralID = peripheral.identifier.uuidString
|
|
|
|
// Check if we need to update the mapping
|
|
if peerIDByPeripheralID[peripheralID] != senderID {
|
|
SecureLogger.log("Updating peripheral mapping: \(peripheralID) -> \(senderID)",
|
|
category: SecureLogger.session, level: .debug)
|
|
peerIDByPeripheralID[peripheralID] = senderID
|
|
|
|
// Also ensure connectedPeripherals has the correct mapping
|
|
// Remove any temp ID mapping if it exists
|
|
let tempIDToRemove = connectedPeripherals.first(where: { $0.value == peripheral && $0.key != senderID })?.key
|
|
if let tempID = tempIDToRemove {
|
|
connectedPeripherals.removeValue(forKey: tempID)
|
|
}
|
|
connectedPeripherals[senderID] = peripheral
|
|
}
|
|
}
|
|
|
|
// Check if this is a reconnection after a long silence
|
|
let wasReconnection: Bool
|
|
if let lastHeard = self.lastHeardFromPeer[senderID] {
|
|
let timeSinceLastHeard = Date().timeIntervalSince(lastHeard)
|
|
wasReconnection = timeSinceLastHeard > 30.0
|
|
} else {
|
|
// First time hearing from this peer
|
|
wasReconnection = true
|
|
}
|
|
|
|
self.lastHeardFromPeer[senderID] = Date()
|
|
|
|
// If this is a reconnection, send our identity announcement
|
|
if wasReconnection && packet.type != MessageType.noiseIdentityAnnounce.rawValue {
|
|
SecureLogger.log("Detected reconnection from \(senderID) after silence, sending identity announcement", category: SecureLogger.noise, level: .info)
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.sendNoiseIdentityAnnounce(to: senderID)
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// Log specific Noise packet types
|
|
|
|
guard packet.ttl > 0 else {
|
|
return
|
|
}
|
|
|
|
// Validate packet has payload
|
|
guard !packet.payload.isEmpty else {
|
|
return
|
|
}
|
|
|
|
// Update last seen timestamp for this peer
|
|
if senderID != "unknown" && senderID != self.myPeerID {
|
|
peerLastSeenTimestamps.set(senderID, value: Date())
|
|
}
|
|
|
|
// Replay attack protection: Check timestamp is within reasonable window (5 minutes)
|
|
let currentTime = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds
|
|
let timeDiff = abs(Int64(currentTime) - Int64(packet.timestamp))
|
|
if timeDiff > 300000 { // 5 minutes in milliseconds
|
|
SecureLogger.log("Replay attack detected - timestamp from \(senderID)", category: SecureLogger.security, level: .warning)
|
|
SecureLogger.log("Dropped message with stale timestamp. Age: \(timeDiff/1000)s from \(senderID)", category: SecureLogger.security, level: .warning)
|
|
return
|
|
}
|
|
|
|
// For fragments, include packet type in messageID to avoid dropping CONTINUE/END fragments
|
|
let messageID: String
|
|
if packet.type == MessageType.fragmentStart.rawValue ||
|
|
packet.type == MessageType.fragmentContinue.rawValue ||
|
|
packet.type == MessageType.fragmentEnd.rawValue {
|
|
// Include both type and payload hash for fragments to ensure uniqueness
|
|
messageID = "\(packet.timestamp)-\(packet.senderID.hexEncodedString())-\(packet.type)-\(packet.payload.hashValue)"
|
|
} else {
|
|
// Include payload hash for absolute uniqueness (handles same-second messages)
|
|
messageID = "\(packet.timestamp)-\(packet.senderID.hexEncodedString())-\(packet.payload.prefix(64).hashValue)"
|
|
}
|
|
|
|
// Use bloom filter for efficient duplicate detection
|
|
if messageBloomFilter.contains(messageID) {
|
|
// Also check exact set for accuracy (bloom filter can have false positives)
|
|
if processedMessages.contains(messageID) {
|
|
SecureLogger.log("Dropped duplicate message: \(messageID.prefix(20))... from \(senderID)", category: SecureLogger.security, level: .debug)
|
|
return
|
|
} else {
|
|
// False positive from Bloom filter
|
|
SecureLogger.log("Bloom filter false positive for message: \(messageID.prefix(20))...", category: SecureLogger.security, level: .debug)
|
|
}
|
|
}
|
|
|
|
messageBloomFilter.insert(messageID)
|
|
processedMessages.insert(messageID)
|
|
|
|
// Log statistics periodically
|
|
if messageBloomFilter.insertCount % 100 == 0 {
|
|
_ = messageBloomFilter.estimatedFalsePositiveRate
|
|
}
|
|
|
|
// Bloom filter will be reset by timer, processedMessages is now bounded
|
|
|
|
// let _ = packet.senderID.hexEncodedString()
|
|
|
|
|
|
// Note: We'll decode messages in the switch statement below, not here
|
|
|
|
switch MessageType(rawValue: packet.type) {
|
|
case .message:
|
|
// Unified message handler for both broadcast and private messages
|
|
// Convert binary senderID back to hex string
|
|
let senderID = packet.senderID.hexEncodedString()
|
|
if senderID.isEmpty {
|
|
return
|
|
}
|
|
|
|
|
|
// Ignore our own messages
|
|
if senderID == myPeerID {
|
|
return
|
|
}
|
|
|
|
// Check if this is a broadcast or private message
|
|
if let recipientID = packet.recipientID {
|
|
if recipientID == SpecialRecipients.broadcast {
|
|
// BROADCAST MESSAGE
|
|
|
|
// No signature verification - broadcasts are not authenticated
|
|
|
|
// Parse broadcast message (not encrypted)
|
|
if let message = BitchatMessage.fromBinaryPayload(packet.payload) {
|
|
|
|
// Store nickname mapping
|
|
collectionsQueue.sync(flags: .barrier) {
|
|
self.peerNicknames[senderID] = message.sender
|
|
}
|
|
|
|
let finalContent = message.content
|
|
|
|
let messageWithPeerID = BitchatMessage(
|
|
id: message.id, // Preserve the original message ID
|
|
sender: message.sender,
|
|
content: finalContent,
|
|
timestamp: message.timestamp,
|
|
isRelay: message.isRelay,
|
|
originalSender: message.originalSender,
|
|
isPrivate: false,
|
|
recipientNickname: nil,
|
|
senderPeerID: senderID,
|
|
mentions: message.mentions
|
|
)
|
|
|
|
// Track last message time from this peer
|
|
let peerID = packet.senderID.hexEncodedString()
|
|
self.lastMessageFromPeer.set(peerID, value: Date())
|
|
|
|
DispatchQueue.main.async {
|
|
self.delegate?.didReceiveMessage(messageWithPeerID)
|
|
}
|
|
|
|
}
|
|
|
|
// Relay broadcast messages
|
|
var relayPacket = packet
|
|
relayPacket.ttl -= 1
|
|
if relayPacket.ttl > 0 {
|
|
// Probabilistic flooding with smart relay decisions
|
|
let relayProb = self.adaptiveRelayProbability
|
|
|
|
// Always relay if TTL is high (fresh messages need to spread)
|
|
// or if we have few peers (ensure coverage in sparse networks)
|
|
let shouldRelay = relayPacket.ttl >= 4 ||
|
|
self.activePeers.count <= 3 ||
|
|
Double.random(in: 0...1) < relayProb
|
|
|
|
if shouldRelay {
|
|
SecureLogger.log("Relaying broadcast from \(senderID), TTL: \(relayPacket.ttl), peers: \(self.activePeers.count)", category: SecureLogger.noise, level: .debug)
|
|
// Add random delay to prevent collision storms
|
|
let delay = Double.random(in: minMessageDelay...maxMessageDelay)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
self?.broadcastPacket(relayPacket)
|
|
}
|
|
} else {
|
|
SecureLogger.log("Dropped broadcast relay from \(senderID), TTL: \(relayPacket.ttl), prob: \(relayProb)", category: SecureLogger.noise, level: .debug)
|
|
}
|
|
}
|
|
|
|
} else if isPeerIDOurs(recipientID.hexEncodedString()) {
|
|
// PRIVATE MESSAGE FOR US
|
|
|
|
|
|
// No signature verification - broadcasts are not authenticated
|
|
|
|
// Private messages should only come through Noise now
|
|
// If we're getting a private message here, it must already be decrypted from Noise
|
|
let decryptedPayload = packet.payload
|
|
|
|
// Parse the message
|
|
if let message = BitchatMessage.fromBinaryPayload(decryptedPayload) {
|
|
|
|
// Check if this is a dummy message for cover traffic
|
|
if message.content.hasPrefix(self.coverTrafficPrefix) {
|
|
return // Silently discard dummy messages
|
|
}
|
|
|
|
// Check if we've seen this exact message recently (within 5 seconds)
|
|
let messageKey = "\(senderID)-\(message.content)-\(message.timestamp)"
|
|
if let lastReceived = self.receivedMessageTimestamps.get(messageKey) {
|
|
let timeSinceLastReceived = Date().timeIntervalSince(lastReceived)
|
|
if timeSinceLastReceived < 5.0 {
|
|
}
|
|
}
|
|
self.receivedMessageTimestamps.set(messageKey, value: Date())
|
|
|
|
// LRU cache handles cleanup automatically
|
|
|
|
collectionsQueue.sync(flags: .barrier) {
|
|
if self.peerNicknames[senderID] == nil {
|
|
self.peerNicknames[senderID] = message.sender
|
|
}
|
|
}
|
|
|
|
let messageWithPeerID = BitchatMessage(
|
|
id: message.id, // Preserve the original message ID
|
|
sender: message.sender,
|
|
content: message.content,
|
|
timestamp: message.timestamp,
|
|
isRelay: message.isRelay,
|
|
originalSender: message.originalSender,
|
|
isPrivate: message.isPrivate,
|
|
recipientNickname: message.recipientNickname,
|
|
senderPeerID: senderID,
|
|
mentions: message.mentions,
|
|
deliveryStatus: nil // Will be set to .delivered in ChatViewModel
|
|
)
|
|
|
|
// Track last message time from this peer
|
|
let peerID = packet.senderID.hexEncodedString()
|
|
self.lastMessageFromPeer.set(peerID, value: Date())
|
|
|
|
DispatchQueue.main.async {
|
|
self.delegate?.didReceiveMessage(messageWithPeerID)
|
|
}
|
|
|
|
// Generate and send ACK for private messages
|
|
let viewModel = self.delegate as? ChatViewModel
|
|
let myNickname = viewModel?.nickname ?? self.myPeerID
|
|
if let ack = DeliveryTracker.shared.generateAck(
|
|
for: messageWithPeerID,
|
|
myPeerID: self.myPeerID,
|
|
myNickname: myNickname,
|
|
hopCount: UInt8(self.maxTTL - packet.ttl)
|
|
) {
|
|
self.sendDeliveryAck(ack, to: senderID)
|
|
}
|
|
} else {
|
|
SecureLogger.log("Failed to parse private message from binary, size: \(decryptedPayload.count)", category: SecureLogger.encryption, level: .error)
|
|
}
|
|
|
|
} else if packet.ttl > 0 {
|
|
// RELAY PRIVATE MESSAGE (not for us)
|
|
var relayPacket = packet
|
|
relayPacket.ttl -= 1
|
|
|
|
// Check if this message is for an offline favorite and cache it
|
|
let recipientIDString = recipientID.hexEncodedString()
|
|
if let fingerprint = self.noiseService.getPeerFingerprint(recipientIDString) {
|
|
// Only cache if recipient is a favorite AND is currently offline
|
|
if (self.delegate?.isFavorite(fingerprint: fingerprint) ?? false) && !self.activePeers.contains(recipientIDString) {
|
|
self.cacheMessage(relayPacket, messageID: messageID)
|
|
}
|
|
}
|
|
|
|
// Private messages are important - use higher relay probability
|
|
let relayProb = min(self.adaptiveRelayProbability + 0.15, 1.0) // Boost by 15%
|
|
|
|
// Always relay if TTL is high or we have few peers
|
|
let shouldRelay = relayPacket.ttl >= 4 ||
|
|
self.activePeers.count <= 3 ||
|
|
Double.random(in: 0...1) < relayProb
|
|
|
|
if shouldRelay {
|
|
// Add random delay to prevent collision storms
|
|
let delay = Double.random(in: minMessageDelay...maxMessageDelay)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
self?.broadcastPacket(relayPacket)
|
|
}
|
|
}
|
|
} else {
|
|
// Message has recipient ID but not for us and TTL is 0
|
|
// Message not for us - will be relayed if TTL > 0
|
|
}
|
|
} else {
|
|
// No recipient ID - this shouldn't happen for messages
|
|
SecureLogger.log("Message packet with no recipient ID from \(senderID)", category: SecureLogger.security, level: .warning)
|
|
}
|
|
|
|
// Note: 0x02 was legacy keyExchange - removed
|
|
|
|
case .announce:
|
|
if let nickname = String(data: packet.payload, encoding: .utf8) {
|
|
let senderID = packet.senderID.hexEncodedString()
|
|
|
|
// Ignore if it's from ourselves (including previous peer IDs)
|
|
if isPeerIDOurs(senderID) {
|
|
return
|
|
}
|
|
|
|
// Check if we've already announced this peer
|
|
let isFirstAnnounce = !announcedPeers.contains(senderID)
|
|
|
|
// Clean up stale peer IDs with the same nickname
|
|
collectionsQueue.sync(flags: .barrier) {
|
|
var stalePeerIDs: [String] = []
|
|
for (existingPeerID, existingNickname) in self.peerNicknames {
|
|
if existingNickname == nickname && existingPeerID != senderID {
|
|
// Check if this peer was seen very recently (within 10 seconds)
|
|
let wasRecentlySeen = self.peerLastSeenTimestamps.get(existingPeerID).map { Date().timeIntervalSince($0) < 10.0 } ?? false
|
|
if !wasRecentlySeen {
|
|
// Found a stale peer ID with the same nickname
|
|
stalePeerIDs.append(existingPeerID)
|
|
// Found stale peer ID
|
|
} else {
|
|
// Peer was seen recently, keeping both
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove stale peer IDs
|
|
for stalePeerID in stalePeerIDs {
|
|
// Removing stale peer
|
|
self.peerNicknames.removeValue(forKey: stalePeerID)
|
|
|
|
// Also remove from active peers
|
|
self.activePeers.remove(stalePeerID)
|
|
|
|
// Remove from announced peers
|
|
self.announcedPeers.remove(stalePeerID)
|
|
self.announcedToPeers.remove(stalePeerID)
|
|
|
|
// Clear tracking data
|
|
self.lastHeardFromPeer.removeValue(forKey: stalePeerID)
|
|
|
|
// Disconnect any peripherals associated with stale ID
|
|
if let peripheral = self.connectedPeripherals[stalePeerID] {
|
|
self.intentionalDisconnects.insert(peripheral.identifier.uuidString)
|
|
self.centralManager?.cancelPeripheralConnection(peripheral)
|
|
self.connectedPeripherals.removeValue(forKey: stalePeerID)
|
|
self.peripheralCharacteristics.removeValue(forKey: peripheral)
|
|
}
|
|
|
|
// Remove RSSI data
|
|
self.peerRSSI.removeValue(forKey: stalePeerID)
|
|
|
|
// Clear cached messages tracking
|
|
self.cachedMessagesSentToPeer.remove(stalePeerID)
|
|
|
|
// Remove from last seen timestamps
|
|
self.peerLastSeenTimestamps.remove(stalePeerID)
|
|
|
|
// No longer tracking key exchanges
|
|
}
|
|
|
|
// If we had stale peers, notify the UI immediately
|
|
if !stalePeerIDs.isEmpty {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.notifyPeerListUpdate(immediate: true)
|
|
}
|
|
}
|
|
|
|
// Now add the new peer ID with the nickname
|
|
self.peerNicknames[senderID] = nickname
|
|
}
|
|
|
|
// Update peripheral mapping if we have it
|
|
if let peripheral = peripheral {
|
|
// Find and remove any temp ID mapping for this peripheral
|
|
var tempIDToRemove: String? = nil
|
|
for (id, per) in self.connectedPeripherals {
|
|
if per == peripheral && id != senderID {
|
|
tempIDToRemove = id
|
|
break
|
|
}
|
|
}
|
|
|
|
if let tempID = tempIDToRemove {
|
|
// Remove temp mapping
|
|
self.connectedPeripherals.removeValue(forKey: tempID)
|
|
// Add real peer ID mapping
|
|
self.connectedPeripherals[senderID] = peripheral
|
|
// Update peripheral ID to peer ID mapping
|
|
self.peerIDByPeripheralID[peripheral.identifier.uuidString] = senderID
|
|
|
|
// IMPORTANT: Remove old peer ID from activePeers to prevent duplicates
|
|
collectionsQueue.sync(flags: .barrier) {
|
|
if self.activePeers.contains(tempID) {
|
|
_ = self.activePeers.remove(tempID)
|
|
}
|
|
}
|
|
|
|
// Don't notify about disconnect - this is just cleanup of temporary ID
|
|
}
|
|
}
|
|
|
|
// Add to active peers if not already there
|
|
if senderID != "unknown" && senderID != self.myPeerID {
|
|
// Check for duplicate nicknames and remove old peer IDs
|
|
collectionsQueue.sync(flags: .barrier) {
|
|
// Find any existing peers with the same nickname
|
|
var oldPeerIDsToRemove: [String] = []
|
|
for existingPeerID in self.activePeers {
|
|
if existingPeerID != senderID {
|
|
let existingNickname = self.peerNicknames[existingPeerID] ?? ""
|
|
if existingNickname == nickname && !existingNickname.isEmpty && existingNickname != "unknown" {
|
|
oldPeerIDsToRemove.append(existingPeerID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove old peer IDs with same nickname
|
|
for oldPeerID in oldPeerIDsToRemove {
|
|
self.activePeers.remove(oldPeerID)
|
|
self.peerNicknames.removeValue(forKey: oldPeerID)
|
|
self.connectedPeripherals.removeValue(forKey: oldPeerID)
|
|
|
|
// Don't notify about disconnect - this is just cleanup of duplicate
|
|
}
|
|
}
|
|
|
|
let wasInserted = collectionsQueue.sync(flags: .barrier) {
|
|
// Final safety check
|
|
if senderID == self.myPeerID {
|
|
SecureLogger.log("Blocked self from being added to activePeers", category: SecureLogger.noise, level: .error)
|
|
return false
|
|
}
|
|
let result = self.activePeers.insert(senderID).inserted
|
|
return result
|
|
}
|
|
if wasInserted {
|
|
SecureLogger.log("📡 Peer joined network: \(senderID) (\(nickname))", category: SecureLogger.session, level: .info)
|
|
}
|
|
|
|
// Show join message only for first announce AND if we actually added the peer
|
|
if isFirstAnnounce && wasInserted {
|
|
announcedPeers.insert(senderID)
|
|
|
|
// Delay the connect message slightly to allow identity announcement to be processed
|
|
// This helps ensure fingerprint mappings are available for nickname resolution
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
self.delegate?.didConnectToPeer(senderID)
|
|
}
|
|
self.notifyPeerListUpdate(immediate: true)
|
|
|
|
// Send network available notification if appropriate
|
|
let currentNetworkSize = collectionsQueue.sync { self.activePeers.count }
|
|
if currentNetworkSize > 0 {
|
|
// Clear empty time since network is active
|
|
networkBecameEmptyTime = nil
|
|
|
|
if !hasNotifiedNetworkAvailable {
|
|
// Check if enough time has passed since last notification
|
|
let now = Date()
|
|
var shouldSendNotification = true
|
|
|
|
if let lastNotification = lastNetworkNotificationTime {
|
|
let timeSinceLastNotification = now.timeIntervalSince(lastNotification)
|
|
if timeSinceLastNotification < networkNotificationCooldown {
|
|
// Too soon to send another notification
|
|
shouldSendNotification = false
|
|
}
|
|
}
|
|
|
|
if shouldSendNotification {
|
|
hasNotifiedNetworkAvailable = true
|
|
lastNetworkNotificationTime = now
|
|
NotificationService.shared.sendNetworkAvailableNotification(peerCount: currentNetworkSize)
|
|
}
|
|
}
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
// Check if this is a favorite peer and send notification
|
|
// Note: This might not work immediately if key exchange hasn't happened yet
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// Check if this is a favorite using their public key fingerprint
|
|
if let fingerprint = self.noiseService.getPeerFingerprint(senderID) {
|
|
if self.delegate?.isFavorite(fingerprint: fingerprint) ?? false {
|
|
NotificationService.shared.sendFavoriteOnlineNotification(nickname: nickname)
|
|
|
|
// Send any cached messages for this favorite
|
|
self.sendCachedMessages(to: senderID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Just update the peer list
|
|
self.notifyPeerListUpdate()
|
|
}
|
|
} else {
|
|
}
|
|
|
|
// Relay announce if TTL > 0
|
|
if packet.ttl > 1 {
|
|
var relayPacket = packet
|
|
relayPacket.ttl -= 1
|
|
|
|
// Add small delay to prevent collision
|
|
let delay = Double.random(in: 0.1...0.3)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
self?.broadcastPacket(relayPacket)
|
|
}
|
|
}
|
|
} else {
|
|
}
|
|
|
|
case .leave:
|
|
let senderID = packet.senderID.hexEncodedString()
|
|
// Legacy peer disconnect (keeping for backwards compatibility)
|
|
if String(data: packet.payload, encoding: .utf8) != nil {
|
|
// Remove from active peers with proper locking
|
|
collectionsQueue.sync(flags: .barrier) {
|
|
let wasRemoved = self.activePeers.remove(senderID) != nil
|
|
let nickname = self.peerNicknames.removeValue(forKey: senderID) ?? "unknown"
|
|
|
|
if wasRemoved {
|
|
// Mark as gracefully left to prevent duplicate disconnect message
|
|
self.gracefullyLeftPeers.insert(senderID)
|
|
|
|
// Remove after a delay to handle race conditions with physical disconnect
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in
|
|
self?.gracefullyLeftPeers.remove(senderID)
|
|
}
|
|
|
|
SecureLogger.log("📴 Peer left network: \(senderID) (\(nickname))", category: SecureLogger.session, level: .info)
|
|
}
|
|
}
|
|
|
|
announcedPeers.remove(senderID)
|
|
|
|
// Show leave message
|
|
DispatchQueue.main.async {
|
|
self.delegate?.didDisconnectFromPeer(senderID)
|
|
}
|
|
self.notifyPeerListUpdate()
|
|
}
|
|
|
|
case .fragmentStart, .fragmentContinue, .fragmentEnd:
|
|
// let fragmentTypeStr = packet.type == MessageType.fragmentStart.rawValue ? "START" :
|
|
// (packet.type == MessageType.fragmentContinue.rawValue ? "CONTINUE" : "END")
|
|
|
|
// Validate fragment has minimum required size
|
|
if packet.payload.count < 13 {
|
|
return
|
|
}
|
|
|
|
handleFragment(packet, from: peerID)
|
|
|
|
// Relay fragments if TTL > 0
|
|
var relayPacket = packet
|
|
relayPacket.ttl -= 1
|
|
if relayPacket.ttl > 0 {
|
|
self.broadcastPacket(relayPacket)
|
|
}
|
|
|
|
|
|
case .deliveryAck:
|
|
// Handle delivery acknowledgment
|
|
if let recipientIDData = packet.recipientID,
|
|
isPeerIDOurs(recipientIDData.hexEncodedString()) {
|
|
// This ACK is for us
|
|
let senderID = packet.senderID.hexEncodedString()
|
|
// Check if payload is already decrypted (came through Noise)
|
|
if let ack = DeliveryAck.fromBinaryData(packet.payload) {
|
|
// Already decrypted - process directly
|
|
DeliveryTracker.shared.processDeliveryAck(ack)
|
|
|
|
|
|
// Notify delegate
|
|
DispatchQueue.main.async {
|
|
self.delegate?.didReceiveDeliveryAck(ack)
|
|
}
|
|
} else if let ack = DeliveryAck.decode(from: packet.payload) {
|
|
// Fallback to JSON for backward compatibility
|
|
DeliveryTracker.shared.processDeliveryAck(ack)
|
|
|
|
// Notify delegate
|
|
DispatchQueue.main.async {
|
|
self.delegate?.didReceiveDeliveryAck(ack)
|
|
}
|
|
} else {
|
|
// Try legacy decryption
|
|
do {
|
|
let decryptedData = try noiseService.decrypt(packet.payload, from: senderID)
|
|
if let ack = DeliveryAck.fromBinaryData(decryptedData) {
|
|
// Process the ACK
|
|
DeliveryTracker.shared.processDeliveryAck(ack)
|
|
|
|
|
|
// Notify delegate
|
|
DispatchQueue.main.async {
|
|
self.delegate?.didReceiveDeliveryAck(ack)
|
|
}
|
|
} else if let ack = DeliveryAck.decode(from: decryptedData) {
|
|
// Fallback to JSON
|
|
DeliveryTracker.shared.processDeliveryAck(ack)
|
|
|
|
// Notify delegate
|
|
DispatchQueue.main.async {
|
|
self.delegate?.didReceiveDeliveryAck(ack)
|
|
}
|
|
}
|
|
} catch {
|
|
SecureLogger.log("Failed to decrypt delivery ACK from \(senderID): \(error)",
|
|
category: SecureLogger.encryption, level: .error)
|
|
}
|
|
}
|
|
} else if packet.ttl > 0 {
|
|
// Relay the ACK if not for us
|
|
|
|
// SAFETY CHECK: Never relay unencrypted JSON
|
|
if let jsonCheck = String(data: packet.payload.prefix(1), encoding: .utf8), jsonCheck == "{" {
|
|
return
|
|
}
|
|
|
|
var relayPacket = packet
|
|
relayPacket.ttl -= 1
|
|
self.broadcastPacket(relayPacket)
|
|
}
|
|
|
|
case .readReceipt:
|
|
// Handle read receipt
|
|
if let recipientIDData = packet.recipientID,
|
|
isPeerIDOurs(recipientIDData.hexEncodedString()) {
|
|
// This read receipt is for us
|
|
let senderID = packet.senderID.hexEncodedString()
|
|
SecureLogger.log("Received read receipt from \(senderID)", category: SecureLogger.session, level: .info)
|
|
// Check if payload is already decrypted (came through Noise)
|
|
if let receipt = ReadReceipt.fromBinaryData(packet.payload) {
|
|
// Already decrypted - process directly
|
|
SecureLogger.log("Processing read receipt for message \(receipt.originalMessageID) from \(receipt.readerID)", category: SecureLogger.session, level: .info)
|
|
DispatchQueue.main.async {
|
|
self.delegate?.didReceiveReadReceipt(receipt)
|
|
}
|
|
} else if let receipt = ReadReceipt.decode(from: packet.payload) {
|
|
// Fallback to JSON for backward compatibility
|
|
SecureLogger.log("Processing read receipt (JSON) for message \(receipt.originalMessageID) from \(receipt.readerID)", category: SecureLogger.session, level: .info)
|
|
DispatchQueue.main.async {
|
|
self.delegate?.didReceiveReadReceipt(receipt)
|
|
}
|
|
} else {
|
|
// Try legacy decryption
|
|
do {
|
|
let decryptedData = try noiseService.decrypt(packet.payload, from: senderID)
|
|
if let receipt = ReadReceipt.fromBinaryData(decryptedData) {
|
|
// Process the read receipt
|
|
DispatchQueue.main.async {
|
|
self.delegate?.didReceiveReadReceipt(receipt)
|
|
}
|
|
} else if let receipt = ReadReceipt.decode(from: decryptedData) {
|
|
// Fallback to JSON
|
|
DispatchQueue.main.async {
|
|
self.delegate?.didReceiveReadReceipt(receipt)
|
|
}
|
|
}
|
|
} catch {
|
|
// Failed to decrypt read receipt - might be from unknown sender
|
|
}
|
|
}
|
|
} else if packet.ttl > 0 {
|
|
// Relay the read receipt if not for us
|
|
var relayPacket = packet
|
|
relayPacket.ttl -= 1
|
|
self.broadcastPacket(relayPacket)
|
|
}
|
|
|
|
case .noiseIdentityAnnounce:
|
|
// Handle Noise identity announcement
|
|
let senderID = packet.senderID.hexEncodedString()
|
|
|
|
// Check if this identity announce is targeted to someone else
|
|
if let recipientID = packet.recipientID,
|
|
!isPeerIDOurs(recipientID.hexEncodedString()) {
|
|
// Not for us, relay if TTL > 0
|
|
if packet.ttl > 0 {
|
|
SecureLogger.log("Relaying identity announce packet to \(recipientID.hexEncodedString()), TTL: \(packet.ttl)",
|
|
category: SecureLogger.session, level: .debug)
|
|
var relayPacket = packet
|
|
relayPacket.ttl -= 1
|
|
broadcastPacket(relayPacket)
|
|
}
|
|
return
|
|
}
|
|
|
|
if senderID != myPeerID && !isPeerIDOurs(senderID) {
|
|
// Create defensive copy and validate
|
|
let payloadCopy = Data(packet.payload)
|
|
|
|
guard !payloadCopy.isEmpty else {
|
|
SecureLogger.log("Received empty NoiseIdentityAnnouncement from \(senderID)", category: SecureLogger.noise, level: .error)
|
|
return
|
|
}
|
|
|
|
// Decode the announcement
|
|
let announcement: NoiseIdentityAnnouncement?
|
|
if let firstByte = payloadCopy.first, firstByte == 0x7B { // '{' character - JSON
|
|
announcement = NoiseIdentityAnnouncement.decode(from: payloadCopy) ?? NoiseIdentityAnnouncement.fromBinaryData(payloadCopy)
|
|
} else {
|
|
announcement = NoiseIdentityAnnouncement.fromBinaryData(payloadCopy) ?? NoiseIdentityAnnouncement.decode(from: payloadCopy)
|
|
}
|
|
|
|
guard let announcement = announcement else {
|
|
SecureLogger.log("Failed to decode NoiseIdentityAnnouncement from \(senderID), size: \(payloadCopy.count)", category: SecureLogger.noise, level: .error)
|
|
return
|
|
}
|
|
|
|
// Verify the signature using the signing public key
|
|
let timestampData = String(Int64(announcement.timestamp.timeIntervalSince1970 * 1000)).data(using: .utf8)!
|
|
let bindingData = announcement.peerID.data(using: .utf8)! + announcement.publicKey + timestampData
|
|
if !noiseService.verifySignature(announcement.signature, for: bindingData, publicKey: announcement.signingPublicKey) {
|
|
SecureLogger.log("Signature verification failed for \(senderID)", category: SecureLogger.noise, level: .warning)
|
|
return // Reject announcements with invalid signatures
|
|
}
|
|
|
|
// Calculate fingerprint from public key
|
|
let hash = SHA256.hash(data: announcement.publicKey)
|
|
let fingerprint = hash.map { String(format: "%02x", $0) }.joined()
|
|
|
|
// Log receipt of identity announce
|
|
SecureLogger.log("Received identity announce from \(announcement.peerID) (\(announcement.nickname))",
|
|
category: SecureLogger.noise, level: .info)
|
|
|
|
// Create the binding
|
|
let binding = PeerIdentityBinding(
|
|
currentPeerID: announcement.peerID,
|
|
fingerprint: fingerprint,
|
|
publicKey: announcement.publicKey,
|
|
signingPublicKey: announcement.signingPublicKey,
|
|
nickname: announcement.nickname,
|
|
bindingTimestamp: announcement.timestamp,
|
|
signature: announcement.signature
|
|
)
|
|
|
|
SecureLogger.log("Creating identity binding for \(announcement.peerID) -> \(fingerprint)", category: SecureLogger.security, level: .info)
|
|
|
|
// Update our mappings
|
|
updatePeerBinding(announcement.peerID, fingerprint: fingerprint, binding: binding)
|
|
|
|
// Update connection state only if we're not already authenticated
|
|
let currentState = peerConnectionStates[announcement.peerID] ?? .disconnected
|
|
if currentState != .authenticated {
|
|
updatePeerConnectionState(announcement.peerID, state: .connected)
|
|
}
|
|
|
|
// Register the peer's public key with ChatViewModel for verification tracking
|
|
DispatchQueue.main.async { [weak self] in
|
|
(self?.delegate as? ChatViewModel)?.registerPeerPublicKey(peerID: announcement.peerID, publicKeyData: announcement.publicKey)
|
|
}
|
|
|
|
// If we don't have a session yet, check if we should initiate
|
|
if !noiseService.hasEstablishedSession(with: announcement.peerID) {
|
|
// Lock rotation during handshake
|
|
lockRotation()
|
|
|
|
// Use lexicographic comparison as tie-breaker to prevent simultaneous handshakes
|
|
// Only the peer with the "lower" ID initiates
|
|
// Use coordinator to determine if we should initiate
|
|
let shouldInitiate = handshakeCoordinator.determineHandshakeRole(
|
|
myPeerID: myPeerID,
|
|
remotePeerID: announcement.peerID
|
|
) == .initiator
|
|
|
|
if shouldInitiate {
|
|
// Add small delay on fresh startup to let connections stabilize
|
|
let lastConnection = lastConnectionTime[announcement.peerID] ?? Date.distantPast
|
|
let timeSinceConnection = Date().timeIntervalSince(lastConnection)
|
|
|
|
if timeSinceConnection > 60.0 { // Fresh connection
|
|
// Delay handshake initiation slightly for connection stability
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
SecureLogger.log("Initiating handshake after identity announce from \(announcement.peerID)",
|
|
category: SecureLogger.noise, level: .info)
|
|
self?.attemptHandshakeIfNeeded(with: announcement.peerID, forceIfStale: true)
|
|
}
|
|
} else {
|
|
// Quick reconnection, initiate immediately
|
|
SecureLogger.log("Quick reconnection - initiating handshake with \(announcement.peerID)",
|
|
category: SecureLogger.noise, level: .info)
|
|
attemptHandshakeIfNeeded(with: announcement.peerID, forceIfStale: true)
|
|
}
|
|
} else {
|
|
// Send our identity back so they know we're ready
|
|
SecureLogger.log("Responding to identity announce from \(announcement.peerID) with our own",
|
|
category: SecureLogger.noise, level: .info)
|
|
sendNoiseIdentityAnnounce(to: announcement.peerID)
|
|
}
|
|
} else {
|
|
// We already have a session, but ensure ChatViewModel knows about the fingerprint
|
|
// This handles the case where handshake completed before identity announcement
|
|
DispatchQueue.main.async { [weak self] in
|
|
if let publicKeyData = self?.noiseService.getPeerPublicKeyData(announcement.peerID) {
|
|
(self?.delegate as? ChatViewModel)?.registerPeerPublicKey(peerID: announcement.peerID, publicKeyData: publicKeyData)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
case .noiseHandshakeInit:
|
|
// Handle incoming Noise handshake initiation
|
|
let senderID = packet.senderID.hexEncodedString()
|
|
SecureLogger.logHandshake("initiation received", peerID: senderID, success: true)
|
|
|
|
// Check if this handshake is for us or broadcast
|
|
if let recipientID = packet.recipientID,
|
|
!isPeerIDOurs(recipientID.hexEncodedString()) {
|
|
// Not for us, relay if TTL > 0
|
|
if packet.ttl > 0 {
|
|
SecureLogger.log("Relaying handshake init packet, TTL: \(packet.ttl)", category: SecureLogger.session, level: .debug)
|
|
var relayPacket = packet
|
|
relayPacket.ttl -= 1
|
|
broadcastPacket(relayPacket)
|
|
}
|
|
return
|
|
}
|
|
if !isPeerIDOurs(senderID) {
|
|
// Check if we already have an established session
|
|
if noiseService.hasEstablishedSession(with: senderID) {
|
|
// Determine who should be initiator based on peer ID comparison
|
|
let shouldBeInitiator = myPeerID < senderID
|
|
|
|
if shouldBeInitiator {
|
|
// We should be initiator but peer is initiating - likely they had a session failure
|
|
SecureLogger.log("Received handshake init from \(senderID) who should be responder - likely session mismatch, clearing and accepting", category: SecureLogger.noise, level: .warning)
|
|
cleanupPeerCryptoState(senderID)
|
|
} else {
|
|
// Check if we've heard from this peer recently
|
|
let lastHeard = lastHeardFromPeer[senderID] ?? Date.distantPast
|
|
let timeSinceLastHeard = Date().timeIntervalSince(lastHeard)
|
|
|
|
// Check session validity before clearing
|
|
let lastSuccess = lastSuccessfulMessageTime[senderID] ?? Date.distantPast
|
|
let sessionAge = Date().timeIntervalSince(lastSuccess)
|
|
|
|
// If the peer is initiating a handshake despite us having a valid session,
|
|
// they must have cleared their session for a good reason (e.g., decryption failure).
|
|
// We should always accept the handshake to re-establish encryption.
|
|
SecureLogger.log("Received handshake init from \(senderID) with existing session (age: \(Int(sessionAge))s, last heard: \(Int(timeSinceLastHeard))s ago) - accepting to re-establish encryption",
|
|
category: SecureLogger.handshake, level: .info)
|
|
cleanupPeerCryptoState(senderID)
|
|
}
|
|
}
|
|
|
|
// If we have a handshaking session, reset it to allow new handshake
|
|
if noiseService.hasSession(with: senderID) && !noiseService.hasEstablishedSession(with: senderID) {
|
|
SecureLogger.log("Received handshake init from \(senderID) while already handshaking - resetting to allow new handshake", category: SecureLogger.noise, level: .info)
|
|
cleanupPeerCryptoState(senderID)
|
|
}
|
|
|
|
// Check if we've completed version negotiation with this peer
|
|
if negotiatedVersions[senderID] == nil {
|
|
// Legacy peer - assume version 1 for backward compatibility
|
|
SecureLogger.log("Received Noise handshake from \(senderID) without version negotiation, assuming v1",
|
|
category: SecureLogger.session, level: .debug)
|
|
negotiatedVersions[senderID] = 1
|
|
versionNegotiationState[senderID] = .ackReceived(version: 1)
|
|
}
|
|
handleNoiseHandshakeMessage(from: senderID, message: packet.payload, isInitiation: true)
|
|
|
|
// Send protocol ACK for successfully processed handshake initiation
|
|
sendProtocolAck(for: packet, to: senderID)
|
|
}
|
|
|
|
case .noiseHandshakeResp:
|
|
// Handle Noise handshake response
|
|
let senderID = packet.senderID.hexEncodedString()
|
|
SecureLogger.logHandshake("response received", peerID: senderID, success: true)
|
|
|
|
// Check if this handshake response is for us
|
|
if let recipientID = packet.recipientID {
|
|
let recipientIDStr = recipientID.hexEncodedString()
|
|
SecureLogger.log("Response targeted to: \(recipientIDStr), is us: \(isPeerIDOurs(recipientIDStr))", category: SecureLogger.noise, level: .debug)
|
|
if !isPeerIDOurs(recipientIDStr) {
|
|
// Not for us, relay if TTL > 0
|
|
if packet.ttl > 0 {
|
|
SecureLogger.log("Relaying handshake response packet, TTL: \(packet.ttl)", category: SecureLogger.session, level: .debug)
|
|
var relayPacket = packet
|
|
relayPacket.ttl -= 1
|
|
broadcastPacket(relayPacket)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
if !isPeerIDOurs(senderID) {
|
|
// Check our current handshake state
|
|
let currentState = handshakeCoordinator.getHandshakeState(for: senderID)
|
|
SecureLogger.log("Processing handshake response from \(senderID), current state: \(currentState)", category: SecureLogger.noise, level: .info)
|
|
|
|
// Process the response - this could be message 2 or message 3 in the XX pattern
|
|
handleNoiseHandshakeMessage(from: senderID, message: packet.payload, isInitiation: false)
|
|
|
|
// Send protocol ACK for successfully processed handshake response
|
|
sendProtocolAck(for: packet, to: senderID)
|
|
}
|
|
|
|
case .noiseEncrypted:
|
|
// Handle Noise encrypted message
|
|
let senderID = packet.senderID.hexEncodedString()
|
|
if !isPeerIDOurs(senderID) {
|
|
_ = packet.recipientID?.hexEncodedString()
|
|
handleNoiseEncryptedMessage(from: senderID, encryptedData: packet.payload, originalPacket: packet)
|
|
}
|
|
|
|
case .versionHello:
|
|
// Handle version negotiation hello
|
|
let senderID = packet.senderID.hexEncodedString()
|
|
if !isPeerIDOurs(senderID) {
|
|
handleVersionHello(from: senderID, data: packet.payload, peripheral: peripheral)
|
|
}
|
|
|
|
case .versionAck:
|
|
// Handle version negotiation acknowledgment
|
|
let senderID = packet.senderID.hexEncodedString()
|
|
if !isPeerIDOurs(senderID) {
|
|
handleVersionAck(from: senderID, data: packet.payload)
|
|
}
|
|
|
|
case .protocolAck:
|
|
// Handle protocol-level acknowledgment
|
|
let senderID = packet.senderID.hexEncodedString()
|
|
if !isPeerIDOurs(senderID) {
|
|
handleProtocolAck(from: senderID, data: packet.payload)
|
|
}
|
|
|
|
case .protocolNack:
|
|
// Handle protocol-level negative acknowledgment
|
|
let senderID = packet.senderID.hexEncodedString()
|
|
if !isPeerIDOurs(senderID) {
|
|
handleProtocolNack(from: senderID, data: packet.payload)
|
|
}
|
|
|
|
case .systemValidation:
|
|
// Handle system validation ping (for session sync verification)
|
|
let senderID = packet.senderID.hexEncodedString()
|
|
if !isPeerIDOurs(senderID) {
|
|
// Try to decrypt the validation ping
|
|
do {
|
|
let decrypted = try noiseService.decrypt(packet.payload, from: senderID)
|
|
SecureLogger.log("Successfully validated session with \(senderID) - ping: \(String(data: decrypted, encoding: .utf8) ?? "?")",
|
|
category: SecureLogger.session, level: .debug)
|
|
|
|
// Session is valid, update last successful message time
|
|
lastSuccessfulMessageTime[senderID] = Date()
|
|
} catch {
|
|
// Validation failed - session is out of sync
|
|
SecureLogger.log("Session validation failed with \(senderID): \(error)",
|
|
category: SecureLogger.session, level: .warning)
|
|
|
|
// Send NACK to trigger session re-establishment
|
|
sendProtocolNack(for: packet, to: senderID,
|
|
reason: "Session validation failed",
|
|
errorCode: .decryptionFailed)
|
|
}
|
|
}
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sendFragmentedPacket(_ packet: BitchatPacket) {
|
|
guard let fullData = packet.toBinaryData() else { return }
|
|
|
|
// Generate a fixed 8-byte fragment ID
|
|
var fragmentID = Data(count: 8)
|
|
fragmentID.withUnsafeMutableBytes { bytes in
|
|
arc4random_buf(bytes.baseAddress, 8)
|
|
}
|
|
|
|
let fragments = stride(from: 0, to: fullData.count, by: maxFragmentSize).map { offset in
|
|
fullData[offset..<min(offset + maxFragmentSize, fullData.count)]
|
|
}
|
|
|
|
// Splitting into fragments
|
|
|
|
// Optimize fragment transmission for speed
|
|
// Use minimal delay for BLE 5.0 which supports better throughput
|
|
let delayBetweenFragments: TimeInterval = 0.02 // 20ms between fragments for faster transmission
|
|
|
|
for (index, fragmentData) in fragments.enumerated() {
|
|
var fragmentPayload = Data()
|
|
|
|
// Fragment header: fragmentID (8) + index (2) + total (2) + originalType (1) + data
|
|
fragmentPayload.append(fragmentID)
|
|
fragmentPayload.append(UInt8((index >> 8) & 0xFF))
|
|
fragmentPayload.append(UInt8(index & 0xFF))
|
|
fragmentPayload.append(UInt8((fragments.count >> 8) & 0xFF))
|
|
fragmentPayload.append(UInt8(fragments.count & 0xFF))
|
|
fragmentPayload.append(packet.type)
|
|
fragmentPayload.append(fragmentData)
|
|
|
|
let fragmentType: MessageType
|
|
if index == 0 {
|
|
fragmentType = .fragmentStart
|
|
} else if index == fragments.count - 1 {
|
|
fragmentType = .fragmentEnd
|
|
} else {
|
|
fragmentType = .fragmentContinue
|
|
}
|
|
|
|
let fragmentPacket = BitchatPacket(
|
|
type: fragmentType.rawValue,
|
|
senderID: packet.senderID, // Use original packet's senderID (already Data)
|
|
recipientID: packet.recipientID, // Preserve recipient if any
|
|
timestamp: packet.timestamp, // Use original timestamp
|
|
payload: fragmentPayload,
|
|
signature: nil, // Fragments don't need signatures
|
|
ttl: packet.ttl
|
|
)
|
|
|
|
// Send fragments with linear delay
|
|
let totalDelay = Double(index) * delayBetweenFragments
|
|
|
|
// Send fragments on background queue with calculated delay
|
|
messageQueue.asyncAfter(deadline: .now() + totalDelay) { [weak self] in
|
|
self?.broadcastPacket(fragmentPacket)
|
|
}
|
|
}
|
|
|
|
let _ = Double(fragments.count - 1) * delayBetweenFragments
|
|
}
|
|
|
|
private func handleFragment(_ packet: BitchatPacket, from peerID: String) {
|
|
// Handling fragment
|
|
|
|
guard packet.payload.count >= 13 else {
|
|
return
|
|
}
|
|
|
|
// Convert to array for safer access
|
|
let payloadArray = Array(packet.payload)
|
|
var offset = 0
|
|
|
|
// Extract fragment ID as binary data (8 bytes)
|
|
guard payloadArray.count >= 8 else {
|
|
return
|
|
}
|
|
|
|
let fragmentIDData = Data(payloadArray[0..<8])
|
|
let fragmentID = fragmentIDData.hexEncodedString()
|
|
offset = 8
|
|
|
|
// Safely extract index
|
|
guard payloadArray.count >= offset + 2 else {
|
|
// Not enough data for index
|
|
return
|
|
}
|
|
let index = Int(payloadArray[offset]) << 8 | Int(payloadArray[offset + 1])
|
|
offset += 2
|
|
|
|
// Safely extract total
|
|
guard payloadArray.count >= offset + 2 else {
|
|
// Not enough data for total
|
|
return
|
|
}
|
|
let total = Int(payloadArray[offset]) << 8 | Int(payloadArray[offset + 1])
|
|
offset += 2
|
|
|
|
// Safely extract original type
|
|
guard payloadArray.count >= offset + 1 else {
|
|
// Not enough data for type
|
|
return
|
|
}
|
|
let originalType = payloadArray[offset]
|
|
offset += 1
|
|
|
|
// Extract fragment data
|
|
let fragmentData: Data
|
|
if payloadArray.count > offset {
|
|
fragmentData = Data(payloadArray[offset...])
|
|
} else {
|
|
fragmentData = Data()
|
|
}
|
|
|
|
|
|
// Initialize fragment collection if needed
|
|
if incomingFragments[fragmentID] == nil {
|
|
// Check if we've reached the concurrent session limit
|
|
if incomingFragments.count >= maxConcurrentFragmentSessions {
|
|
// Clean up oldest fragments first
|
|
cleanupOldFragments()
|
|
|
|
// If still at limit, reject new session to prevent DoS
|
|
if incomingFragments.count >= maxConcurrentFragmentSessions {
|
|
return
|
|
}
|
|
}
|
|
|
|
incomingFragments[fragmentID] = [:]
|
|
fragmentMetadata[fragmentID] = (originalType, total, Date())
|
|
}
|
|
|
|
incomingFragments[fragmentID]?[index] = fragmentData
|
|
|
|
|
|
// Check if we have all fragments
|
|
if let fragments = incomingFragments[fragmentID],
|
|
fragments.count == total {
|
|
|
|
// Reassemble the original packet
|
|
var reassembledData = Data()
|
|
for i in 0..<total {
|
|
if let fragment = fragments[i] {
|
|
reassembledData.append(fragment)
|
|
} else {
|
|
// Missing fragment
|
|
return
|
|
}
|
|
}
|
|
|
|
// Successfully reassembled fragments
|
|
|
|
// Parse and handle the reassembled packet
|
|
if let reassembledPacket = BitchatPacket.from(reassembledData) {
|
|
// Clean up
|
|
incomingFragments.removeValue(forKey: fragmentID)
|
|
fragmentMetadata.removeValue(forKey: fragmentID)
|
|
|
|
// Handle the reassembled packet
|
|
handleReceivedPacket(reassembledPacket, from: peerID, peripheral: nil)
|
|
}
|
|
}
|
|
|
|
// Periodic cleanup of old fragments
|
|
cleanupOldFragments()
|
|
}
|
|
|
|
private func cleanupOldFragments() {
|
|
let cutoffTime = Date().addingTimeInterval(-fragmentTimeout)
|
|
var fragmentsToRemove: [String] = []
|
|
|
|
for (fragID, metadata) in fragmentMetadata {
|
|
if metadata.timestamp < cutoffTime {
|
|
fragmentsToRemove.append(fragID)
|
|
}
|
|
}
|
|
|
|
// Remove expired fragments
|
|
for fragID in fragmentsToRemove {
|
|
incomingFragments.removeValue(forKey: fragID)
|
|
fragmentMetadata.removeValue(forKey: fragID)
|
|
}
|
|
|
|
// Also enforce memory bounds - if we have too many fragment bytes, remove oldest
|
|
var totalFragmentBytes = 0
|
|
let maxFragmentBytes = 10 * 1024 * 1024 // 10MB max for all fragments
|
|
|
|
for (_, fragments) in incomingFragments {
|
|
for (_, data) in fragments {
|
|
totalFragmentBytes += data.count
|
|
}
|
|
}
|
|
|
|
if totalFragmentBytes > maxFragmentBytes {
|
|
// Remove oldest fragments until under limit
|
|
let sortedFragments = fragmentMetadata.sorted { $0.value.timestamp < $1.value.timestamp }
|
|
for (fragID, _) in sortedFragments {
|
|
incomingFragments.removeValue(forKey: fragID)
|
|
fragmentMetadata.removeValue(forKey: fragID)
|
|
|
|
// Recalculate total
|
|
totalFragmentBytes = 0
|
|
for (_, fragments) in incomingFragments {
|
|
for (_, data) in fragments {
|
|
totalFragmentBytes += data.count
|
|
}
|
|
}
|
|
|
|
if totalFragmentBytes <= maxFragmentBytes {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension BluetoothMeshService: CBCentralManagerDelegate {
|
|
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
// Central manager state updated
|
|
switch central.state {
|
|
case .unknown: break
|
|
case .resetting: break
|
|
case .unsupported: break
|
|
case .unauthorized: break
|
|
case .poweredOff: break
|
|
case .poweredOn: break
|
|
@unknown default: break
|
|
}
|
|
|
|
if central.state == .unsupported {
|
|
} else if central.state == .unauthorized {
|
|
} else if central.state == .poweredOff {
|
|
} else if central.state == .poweredOn {
|
|
startScanning()
|
|
|
|
// Send announces when central manager is ready
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
self?.sendBroadcastAnnounce()
|
|
}
|
|
}
|
|
}
|
|
|
|
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
|
|
// Optimize for 300m range - only connect to strong enough signals
|
|
let rssiValue = RSSI.intValue
|
|
|
|
// Filter out very weak signals (below -90 dBm) to save battery
|
|
guard rssiValue > -90 else {
|
|
// Ignoring peripheral due to very weak signal
|
|
return
|
|
}
|
|
|
|
// Throttle RSSI updates to save CPU
|
|
let peripheralID = peripheral.identifier.uuidString
|
|
if let lastUpdate = lastRSSIUpdate[peripheralID],
|
|
Date().timeIntervalSince(lastUpdate) < 1.0 {
|
|
return // Skip update if less than 1 second since last update
|
|
}
|
|
lastRSSIUpdate[peripheralID] = Date()
|
|
|
|
// Store RSSI by peripheral ID for later use
|
|
peripheralRSSI[peripheralID] = RSSI
|
|
|
|
// Extract peer ID from name (no prefix for stealth)
|
|
// Peer IDs are 8 bytes = 16 hex characters
|
|
if let name = peripheral.name, name.count == 16 {
|
|
// Assume 16-character hex names are peer IDs
|
|
let peerID = name
|
|
|
|
// Don't process our own advertisements (including previous peer IDs)
|
|
if isPeerIDOurs(peerID) {
|
|
return
|
|
}
|
|
|
|
// Validate RSSI before storing
|
|
let rssiValue = RSSI.intValue
|
|
if rssiValue != 127 && rssiValue >= -100 && rssiValue <= 0 {
|
|
peerRSSI[peerID] = RSSI
|
|
}
|
|
// Discovered potential peer
|
|
SecureLogger.log("Discovered peer with ID: \(peerID), self ID: \(myPeerID)", category: SecureLogger.noise, level: .debug)
|
|
}
|
|
|
|
// Connection pooling with exponential backoff
|
|
// peripheralID already declared above
|
|
|
|
// Check if we should attempt connection (considering backoff)
|
|
if let backoffTime = connectionBackoff[peripheralID],
|
|
Date().timeIntervalSince1970 < backoffTime {
|
|
// Still in backoff period, skip connection
|
|
return
|
|
}
|
|
|
|
// Check if we already have this peripheral in our pool
|
|
if let pooledPeripheral = connectionPool[peripheralID] {
|
|
// Reuse existing peripheral from pool
|
|
if pooledPeripheral.state == CBPeripheralState.disconnected {
|
|
// Reconnect if disconnected
|
|
central.connect(pooledPeripheral, options: [
|
|
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
|
|
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
|
|
CBConnectPeripheralOptionNotifyOnNotificationKey: true
|
|
])
|
|
}
|
|
return
|
|
}
|
|
|
|
// New peripheral - add to pool and connect
|
|
if !discoveredPeripherals.contains(peripheral) {
|
|
// Check connection pool limits
|
|
let connectedCount = connectionPool.values.filter { $0.state == .connected }.count
|
|
if connectedCount >= maxConnectedPeripherals {
|
|
// Connection pool is full - find least recently used peripheral to disconnect
|
|
if let lruPeripheralID = findLeastRecentlyUsedPeripheral() {
|
|
if let lruPeripheral = connectionPool[lruPeripheralID] {
|
|
SecureLogger.log("Connection pool full, disconnecting LRU peripheral: \(lruPeripheralID)",
|
|
category: SecureLogger.session, level: .debug)
|
|
central.cancelPeripheralConnection(lruPeripheral)
|
|
connectionPool.removeValue(forKey: lruPeripheralID)
|
|
}
|
|
}
|
|
}
|
|
|
|
discoveredPeripherals.append(peripheral)
|
|
peripheral.delegate = self
|
|
connectionPool[peripheralID] = peripheral
|
|
|
|
// Track connection attempts
|
|
let attempts = connectionAttempts[peripheralID] ?? 0
|
|
connectionAttempts[peripheralID] = attempts + 1
|
|
|
|
// Only attempt if under max attempts
|
|
if attempts < maxConnectionAttempts {
|
|
// Use optimized connection parameters for better range
|
|
let connectionOptions: [String: Any] = [
|
|
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
|
|
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
|
|
CBConnectPeripheralOptionNotifyOnNotificationKey: true
|
|
]
|
|
|
|
central.connect(peripheral, options: connectionOptions)
|
|
}
|
|
}
|
|
}
|
|
|
|
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
let tempID = peripheral.identifier.uuidString
|
|
SecureLogger.log("Peripheral connected: \(tempID)", category: SecureLogger.session, level: .info)
|
|
|
|
// Log current mapping state for debugging
|
|
SecureLogger.log("Current peerIDByPeripheralID mappings: \(peerIDByPeripheralID.keys.joined(separator: ", "))",
|
|
category: SecureLogger.session, level: .debug)
|
|
|
|
peripheral.delegate = self
|
|
peripheral.discoverServices([BluetoothMeshService.serviceUUID])
|
|
|
|
// Store peripheral by its system ID temporarily until we get the real peer ID
|
|
connectedPeripherals[tempID] = peripheral
|
|
|
|
// Update connection state to connected (but not authenticated yet)
|
|
// We don't know the real peer ID yet, so we can't update the state
|
|
|
|
// Don't show connected message yet - wait for key exchange
|
|
// This prevents the connect/disconnect/connect pattern
|
|
|
|
// Request RSSI reading
|
|
peripheral.readRSSI()
|
|
|
|
// iOS 11+ BLE 5.0: Request 2M PHY for better range and speed
|
|
if #available(iOS 11.0, macOS 10.14, *) {
|
|
// 2M PHY provides better range than 1M PHY
|
|
// This is a hint - system will use best available
|
|
}
|
|
}
|
|
|
|
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
|
let peripheralID = peripheral.identifier.uuidString
|
|
|
|
// Check if this was an intentional disconnect
|
|
if intentionalDisconnects.contains(peripheralID) {
|
|
intentionalDisconnects.remove(peripheralID)
|
|
SecureLogger.log("Intentional disconnect: \(peripheralID)", category: SecureLogger.session, level: .debug)
|
|
// Don't process this disconnect further
|
|
return
|
|
}
|
|
|
|
// Log disconnect with error if present
|
|
if let error = error {
|
|
SecureLogger.logError(error, context: "Peripheral disconnected: \(peripheralID)", category: SecureLogger.session)
|
|
} else {
|
|
SecureLogger.log("Peripheral disconnected normally: \(peripheralID)", category: SecureLogger.session, level: .info)
|
|
}
|
|
|
|
// Find the real peer ID for this peripheral
|
|
var realPeerID: String? = nil
|
|
|
|
// First check our peripheral ID to peer ID mapping
|
|
if let peerID = peerIDByPeripheralID[peripheralID] {
|
|
realPeerID = peerID
|
|
SecureLogger.log("Found peer ID \(peerID) from peerIDByPeripheralID for peripheral \(peripheralID)",
|
|
category: SecureLogger.session, level: .debug)
|
|
} else {
|
|
SecureLogger.log("No mapping in peerIDByPeripheralID for peripheral \(peripheralID), checking connectedPeripherals",
|
|
category: SecureLogger.session, level: .debug)
|
|
|
|
// Fallback: check if we have a direct mapping from peripheral to peer ID
|
|
for (peerID, connectedPeripheral) in connectedPeripherals {
|
|
if connectedPeripheral.identifier == peripheral.identifier {
|
|
// Check if this is a real peer ID (16 hex chars) not a temp ID
|
|
if peerID.count == 16 && peerID.allSatisfy({ $0.isHexDigit }) {
|
|
realPeerID = peerID
|
|
SecureLogger.log("Found peer ID \(peerID) from connectedPeripherals fallback",
|
|
category: SecureLogger.session, level: .debug)
|
|
break
|
|
} else {
|
|
SecureLogger.log("Skipping non-peer ID '\(peerID)' (length: \(peerID.count))",
|
|
category: SecureLogger.session, level: .debug)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update connection state immediately if we have a real peer ID
|
|
if let peerID = realPeerID {
|
|
// Update peer connection state
|
|
updatePeerConnectionState(peerID, state: .disconnected)
|
|
|
|
// Clear pending messages for disconnected peer to prevent retry loops
|
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
|
if let pendingCount = self?.pendingPrivateMessages[peerID]?.count, pendingCount > 0 {
|
|
SecureLogger.log("Clearing \(pendingCount) pending messages for disconnected peer \(peerID)",
|
|
category: SecureLogger.session, level: .info)
|
|
self?.pendingPrivateMessages[peerID]?.removeAll()
|
|
}
|
|
}
|
|
|
|
// Reset handshake state to prevent stuck handshakes
|
|
handshakeCoordinator.resetHandshakeState(for: peerID)
|
|
|
|
// Notify delegate immediately about disconnect (unless peer gracefully left)
|
|
if !gracefullyLeftPeers.contains(peerID) {
|
|
DispatchQueue.main.async {
|
|
self.delegate?.didDisconnectFromPeer(peerID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Implement exponential backoff for failed connections
|
|
if error != nil {
|
|
let attempts = connectionAttempts[peripheralID] ?? 0
|
|
if attempts >= maxConnectionAttempts {
|
|
// Max attempts reached, apply long backoff
|
|
let backoffDuration = baseBackoffInterval * pow(2.0, Double(attempts))
|
|
connectionBackoff[peripheralID] = Date().timeIntervalSince1970 + backoffDuration
|
|
}
|
|
} else {
|
|
// Clean disconnect, reset attempts
|
|
connectionAttempts[peripheralID] = 0
|
|
connectionBackoff.removeValue(forKey: peripheralID)
|
|
}
|
|
|
|
// Clean up peripheral tracking
|
|
peerIDByPeripheralID.removeValue(forKey: peripheralID)
|
|
lastActivityByPeripheralID.removeValue(forKey: peripheralID)
|
|
|
|
// Find peer ID for this peripheral (could be temp ID or real ID)
|
|
var foundPeerID: String? = nil
|
|
for (id, per) in connectedPeripherals {
|
|
if per == peripheral {
|
|
foundPeerID = id
|
|
break
|
|
}
|
|
}
|
|
|
|
if let peerID = foundPeerID {
|
|
connectedPeripherals.removeValue(forKey: peerID)
|
|
peripheralCharacteristics.removeValue(forKey: peripheral)
|
|
|
|
// Don't clear Noise session on disconnect - sessions should survive disconnects
|
|
// The Noise protocol is designed to maintain sessions across network interruptions
|
|
// Only clear sessions on authentication failure
|
|
if peerID.count == 16 { // Real peer ID
|
|
// Clear connection time and last heard tracking on disconnect to properly detect stale sessions
|
|
lastConnectionTime.removeValue(forKey: peerID)
|
|
lastHeardFromPeer.removeValue(forKey: peerID)
|
|
// Keep lastSuccessfulMessageTime to validate session on reconnect
|
|
let lastSuccess = lastSuccessfulMessageTime[peerID] ?? Date.distantPast
|
|
let sessionAge = Date().timeIntervalSince(lastSuccess)
|
|
SecureLogger.log("Peer disconnected: \(peerID), keeping Noise session (age: \(Int(sessionAge))s)", category: SecureLogger.noise, level: .info)
|
|
}
|
|
|
|
// Only remove from active peers if it's not a temp ID
|
|
// Temp IDs shouldn't be in activePeers anyway
|
|
let (removed, _) = collectionsQueue.sync(flags: .barrier) {
|
|
var removed = false
|
|
if peerID.count == 16 { // Real peer ID (8 bytes = 16 hex chars)
|
|
removed = activePeers.remove(peerID) != nil
|
|
if removed {
|
|
// Only log disconnect if peer didn't gracefully leave
|
|
if !self.gracefullyLeftPeers.contains(peerID) {
|
|
let nickname = self.peerNicknames[peerID] ?? "unknown"
|
|
SecureLogger.log("📴 Peer disconnected from network: \(peerID) (\(nickname))", category: SecureLogger.session, level: .info)
|
|
} else {
|
|
// Peer gracefully left, just clean up the tracking
|
|
self.gracefullyLeftPeers.remove(peerID)
|
|
}
|
|
}
|
|
|
|
_ = announcedPeers.remove(peerID)
|
|
_ = announcedToPeers.remove(peerID)
|
|
} else {
|
|
}
|
|
|
|
// Clear cached messages tracking for this peer to allow re-sending if they reconnect
|
|
cachedMessagesSentToPeer.remove(peerID)
|
|
|
|
// Clear version negotiation state
|
|
versionNegotiationState.removeValue(forKey: peerID)
|
|
negotiatedVersions.removeValue(forKey: peerID)
|
|
|
|
// Peer disconnected
|
|
|
|
return (removed, peerNicknames[peerID])
|
|
}
|
|
|
|
// Always notify peer list update on disconnect, regardless of whether peer was in activePeers
|
|
// This ensures UI stays in sync even if there was a state mismatch
|
|
self.notifyPeerListUpdate(immediate: true)
|
|
|
|
if removed {
|
|
// Mark when network became empty, but don't reset flag immediately
|
|
let currentNetworkSize = collectionsQueue.sync { activePeers.count }
|
|
if currentNetworkSize == 0 && networkBecameEmptyTime == nil {
|
|
networkBecameEmptyTime = Date()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keep in pool but remove from discovered list
|
|
discoveredPeripherals.removeAll { $0 == peripheral }
|
|
|
|
// Continue scanning for reconnection
|
|
if centralManager?.state == .poweredOn {
|
|
// Stop and restart to ensure clean state
|
|
centralManager?.stopScan()
|
|
centralManager?.scanForPeripherals(withServices: [BluetoothMeshService.serviceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
|
|
}
|
|
}
|
|
}
|
|
|
|
extension BluetoothMeshService: CBPeripheralDelegate {
|
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
|
if let error = error {
|
|
SecureLogger.log("Error discovering services: \(error)",
|
|
category: SecureLogger.encryption, level: .error)
|
|
return
|
|
}
|
|
|
|
guard let services = peripheral.services else { return }
|
|
|
|
|
|
for service in services {
|
|
peripheral.discoverCharacteristics([BluetoothMeshService.characteristicUUID], for: service)
|
|
}
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
|
if let error = error {
|
|
SecureLogger.log("Error discovering characteristics: \(error)",
|
|
category: SecureLogger.encryption, level: .error)
|
|
return
|
|
}
|
|
|
|
guard let characteristics = service.characteristics else { return }
|
|
|
|
|
|
for characteristic in characteristics {
|
|
if characteristic.uuid == BluetoothMeshService.characteristicUUID {
|
|
peripheral.setNotifyValue(true, for: characteristic)
|
|
peripheralCharacteristics[peripheral] = characteristic
|
|
|
|
// Request maximum MTU for faster data transfer
|
|
// iOS supports up to 512 bytes with BLE 5.0
|
|
peripheral.maximumWriteValueLength(for: .withoutResponse)
|
|
|
|
// Start version negotiation instead of immediately sending Noise identity
|
|
self.sendVersionHello(to: peripheral)
|
|
|
|
// Send announce packet after version negotiation completes
|
|
// Send multiple times for reliability
|
|
if let vm = self.delegate as? ChatViewModel {
|
|
// Send announces multiple times with delays
|
|
for delay in [0.3, 0.8, 1.5] {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
guard let self = self else { return }
|
|
let announcePacket = BitchatPacket(
|
|
type: MessageType.announce.rawValue,
|
|
ttl: 3,
|
|
senderID: self.myPeerID,
|
|
payload: Data(vm.nickname.utf8)
|
|
)
|
|
self.broadcastPacket(announcePacket)
|
|
}
|
|
}
|
|
|
|
// Also send targeted announce to this specific peripheral
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self, weak peripheral] in
|
|
guard let self = self,
|
|
let peripheral = peripheral,
|
|
peripheral.state == .connected,
|
|
let characteristic = peripheral.services?.first(where: { $0.uuid == BluetoothMeshService.serviceUUID })?.characteristics?.first(where: { $0.uuid == BluetoothMeshService.characteristicUUID }) else { return }
|
|
|
|
let announcePacket = BitchatPacket(
|
|
type: MessageType.announce.rawValue,
|
|
ttl: 3,
|
|
senderID: self.myPeerID,
|
|
payload: Data(vm.nickname.utf8)
|
|
)
|
|
if let data = announcePacket.toBinaryData() {
|
|
self.writeToPeripheral(data, peripheral: peripheral, characteristic: characteristic, peerID: nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
guard let data = characteristic.value else {
|
|
return
|
|
}
|
|
|
|
// Update activity tracking for this peripheral
|
|
updatePeripheralActivity(peripheral.identifier.uuidString)
|
|
|
|
|
|
guard let packet = BitchatPacket.from(data) else {
|
|
return
|
|
}
|
|
|
|
|
|
// Use the sender ID from the packet, not our local mapping which might still be a temp ID
|
|
let _ = connectedPeripherals.first(where: { $0.value == peripheral })?.key ?? "unknown"
|
|
let packetSenderID = packet.senderID.hexEncodedString()
|
|
|
|
|
|
// Always handle received packets
|
|
handleReceivedPacket(packet, from: packetSenderID, peripheral: peripheral)
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
|
if let error = error {
|
|
// Log error but don't spam for common errors
|
|
let errorCode = (error as NSError).code
|
|
if errorCode != 242 { // Don't log the common "Unknown ATT error"
|
|
}
|
|
} else {
|
|
}
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
|
|
peripheral.discoverServices([BluetoothMeshService.serviceUUID])
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
|
// Handle notification state updates if needed
|
|
}
|
|
|
|
func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) {
|
|
guard error == nil else { return }
|
|
|
|
// Validate RSSI value - 127 means no RSSI available
|
|
let rssiValue = RSSI.intValue
|
|
|
|
// Only store valid RSSI values
|
|
if rssiValue == 127 || rssiValue < -100 || rssiValue > 0 {
|
|
SecureLogger.log("Invalid RSSI value \(rssiValue) from peripheral, will retry", category: SecureLogger.session, level: .debug)
|
|
|
|
// Retry sooner if we got an invalid value
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak peripheral] in
|
|
guard let peripheral = peripheral, peripheral.state == .connected else { return }
|
|
peripheral.readRSSI()
|
|
}
|
|
return
|
|
}
|
|
|
|
// Find the peer ID for this peripheral
|
|
if let peerID = connectedPeripherals.first(where: { $0.value == peripheral })?.key {
|
|
// Handle both temp IDs and real peer IDs
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
if peerID.count != 16 {
|
|
// It's a temp ID, store RSSI temporarily
|
|
self.peripheralRSSI[peerID] = RSSI
|
|
// Keep trying to read RSSI until we get real peer ID
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak peripheral] in
|
|
guard let peripheral = peripheral, peripheral.state == .connected else { return }
|
|
peripheral.readRSSI()
|
|
}
|
|
} else {
|
|
// It's a real peer ID, store it
|
|
self.peerRSSI[peerID] = RSSI
|
|
// Force UI update when we have a real peer ID
|
|
self.notifyPeerListUpdate()
|
|
}
|
|
}
|
|
|
|
// Periodically update RSSI
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak peripheral] in
|
|
guard let peripheral = peripheral, peripheral.state == .connected else { return }
|
|
peripheral.readRSSI()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension BluetoothMeshService: CBPeripheralManagerDelegate {
|
|
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
|
|
// Peripheral manager state updated
|
|
switch peripheral.state {
|
|
case .unknown: break
|
|
case .resetting: break
|
|
case .unsupported: break
|
|
case .unauthorized: break
|
|
case .poweredOff: break
|
|
case .poweredOn: break
|
|
@unknown default: break
|
|
}
|
|
|
|
switch peripheral.state {
|
|
case .unsupported:
|
|
break
|
|
case .unauthorized:
|
|
break
|
|
case .poweredOff:
|
|
break
|
|
case .poweredOn:
|
|
setupPeripheral()
|
|
startAdvertising()
|
|
|
|
// Send announces when peripheral manager is ready
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
self?.sendBroadcastAnnounce()
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
|
|
// Service added
|
|
}
|
|
|
|
func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) {
|
|
// Advertising state changed
|
|
}
|
|
|
|
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
|
|
for request in requests {
|
|
if let data = request.value {
|
|
|
|
if let packet = BitchatPacket.from(data) {
|
|
|
|
// Log specific Noise packet types
|
|
switch packet.type {
|
|
case MessageType.noiseHandshakeInit.rawValue:
|
|
break
|
|
case MessageType.noiseHandshakeResp.rawValue:
|
|
break
|
|
case MessageType.noiseEncrypted.rawValue:
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
|
|
// Try to identify peer from packet
|
|
let peerID = packet.senderID.hexEncodedString()
|
|
|
|
// Store the central for updates
|
|
if !subscribedCentrals.contains(request.central) {
|
|
subscribedCentrals.append(request.central)
|
|
}
|
|
|
|
// Track this peer as connected
|
|
if peerID != "unknown" && peerID != myPeerID {
|
|
// Double-check we're not adding ourselves
|
|
if peerID == self.myPeerID {
|
|
SecureLogger.log("Preventing self from being added as peer (peripheral manager)", category: SecureLogger.noise, level: .warning)
|
|
peripheral.respond(to: request, withResult: .success)
|
|
return
|
|
}
|
|
|
|
// Note: Legacy keyExchange (0x02) no longer handled
|
|
|
|
self.notifyPeerListUpdate()
|
|
}
|
|
|
|
handleReceivedPacket(packet, from: peerID)
|
|
peripheral.respond(to: request, withResult: .success)
|
|
} else {
|
|
peripheral.respond(to: request, withResult: .invalidPdu)
|
|
}
|
|
} else {
|
|
peripheral.respond(to: request, withResult: .invalidPdu)
|
|
}
|
|
}
|
|
}
|
|
|
|
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
|
|
if !subscribedCentrals.contains(central) {
|
|
subscribedCentrals.append(central)
|
|
|
|
// Send Noise identity announcement to newly connected central
|
|
sendNoiseIdentityAnnounce()
|
|
|
|
// Update peer list to show we're connected (even without peer ID yet)
|
|
self.notifyPeerListUpdate()
|
|
}
|
|
}
|
|
|
|
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
|
|
subscribedCentrals.removeAll { $0 == central }
|
|
|
|
// Don't aggressively remove peers when centrals unsubscribe
|
|
// Peers may be connected through multiple paths
|
|
|
|
// Ensure advertising continues for reconnection
|
|
if peripheralManager?.state == .poweredOn && peripheralManager?.isAdvertising == false {
|
|
startAdvertising()
|
|
}
|
|
}
|
|
|
|
// MARK: - Battery Monitoring
|
|
|
|
private func setupBatteryOptimizer() {
|
|
// Subscribe to power mode changes
|
|
batteryOptimizer.$currentPowerMode
|
|
.sink { [weak self] powerMode in
|
|
self?.handlePowerModeChange(powerMode)
|
|
}
|
|
.store(in: &batteryOptimizerCancellables)
|
|
|
|
// Subscribe to battery level changes
|
|
batteryOptimizer.$batteryLevel
|
|
.sink { [weak self] level in
|
|
self?.currentBatteryLevel = level
|
|
}
|
|
.store(in: &batteryOptimizerCancellables)
|
|
|
|
// Initial update
|
|
handlePowerModeChange(batteryOptimizer.currentPowerMode)
|
|
}
|
|
|
|
private func handlePowerModeChange(_ powerMode: PowerMode) {
|
|
let params = batteryOptimizer.scanParameters
|
|
activeScanDuration = params.duration
|
|
scanPauseDuration = params.pause
|
|
|
|
// Update max connections
|
|
let maxConnections = powerMode.maxConnections
|
|
|
|
// If we have too many connections, disconnect from the least important ones
|
|
if connectedPeripherals.count > maxConnections {
|
|
disconnectLeastImportantPeripherals(keepCount: maxConnections)
|
|
}
|
|
|
|
// Update message aggregation window
|
|
aggregationWindow = powerMode.messageAggregationWindow
|
|
|
|
// If we're currently scanning, restart with new parameters
|
|
if scanDutyCycleTimer != nil {
|
|
scanDutyCycleTimer?.invalidate()
|
|
scheduleScanDutyCycle()
|
|
}
|
|
|
|
// Handle advertising intervals
|
|
if powerMode.advertisingInterval > 0 {
|
|
// Stop continuous advertising and use interval-based
|
|
scheduleAdvertisingCycle(interval: powerMode.advertisingInterval)
|
|
} else {
|
|
// Continuous advertising for performance mode
|
|
startAdvertising()
|
|
}
|
|
}
|
|
|
|
private func disconnectLeastImportantPeripherals(keepCount: Int) {
|
|
// Disconnect peripherals with lowest activity/importance
|
|
let sortedPeripherals = connectedPeripherals.values
|
|
.sorted { peer1, peer2 in
|
|
// Keep peripherals we've recently communicated with
|
|
let peer1Activity = lastMessageFromPeer.get(peer1.identifier.uuidString) ?? Date.distantPast
|
|
let peer2Activity = lastMessageFromPeer.get(peer2.identifier.uuidString) ?? Date.distantPast
|
|
return peer1Activity > peer2Activity
|
|
}
|
|
|
|
// Disconnect the least active ones
|
|
let toDisconnect = sortedPeripherals.dropFirst(keepCount)
|
|
for peripheral in toDisconnect {
|
|
centralManager?.cancelPeripheralConnection(peripheral)
|
|
}
|
|
}
|
|
|
|
private func scheduleAdvertisingCycle(interval: TimeInterval) {
|
|
advertisingTimer?.invalidate()
|
|
|
|
// Stop advertising
|
|
if isAdvertising {
|
|
peripheralManager?.stopAdvertising()
|
|
isAdvertising = false
|
|
}
|
|
|
|
// Schedule next advertising burst
|
|
advertisingTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
self?.advertiseBurst()
|
|
}
|
|
}
|
|
|
|
private func advertiseBurst() {
|
|
guard batteryOptimizer.currentPowerMode != .ultraLowPower || !batteryOptimizer.isInBackground else {
|
|
return // Skip advertising in ultra low power + background
|
|
}
|
|
|
|
startAdvertising()
|
|
|
|
// Stop advertising after a short burst (1 second)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
|
if self?.batteryOptimizer.currentPowerMode.advertisingInterval ?? 0 > 0 {
|
|
self?.peripheralManager?.stopAdvertising()
|
|
self?.isAdvertising = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// Legacy battery monitoring methods - kept for compatibility
|
|
// Now handled by BatteryOptimizer
|
|
private func updateBatteryLevel() {
|
|
// This method is now handled by BatteryOptimizer
|
|
// Keeping empty implementation for compatibility
|
|
}
|
|
|
|
private func updateScanParametersForBattery() {
|
|
// This method is now handled by BatteryOptimizer through handlePowerModeChange
|
|
// Keeping empty implementation for compatibility
|
|
}
|
|
|
|
// MARK: - Privacy Utilities
|
|
|
|
private func randomDelay() -> TimeInterval {
|
|
// Generate random delay between min and max for timing obfuscation
|
|
return TimeInterval.random(in: minMessageDelay...maxMessageDelay)
|
|
}
|
|
|
|
// MARK: - Cover Traffic
|
|
|
|
private func startCoverTraffic() {
|
|
// Start cover traffic with random interval
|
|
scheduleCoverTraffic()
|
|
}
|
|
|
|
private func scheduleCoverTraffic() {
|
|
// Random interval between 30-120 seconds
|
|
let interval = TimeInterval.random(in: 30...120)
|
|
|
|
coverTrafficTimer?.invalidate()
|
|
coverTrafficTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
|
|
self?.sendDummyMessage()
|
|
self?.scheduleCoverTraffic() // Schedule next dummy message
|
|
}
|
|
}
|
|
|
|
private func sendDummyMessage() {
|
|
// Only send dummy messages if we have connected peers
|
|
let peers = getAllConnectedPeerIDs()
|
|
guard !peers.isEmpty else { return }
|
|
|
|
// Skip if battery is low
|
|
if currentBatteryLevel < 0.2 {
|
|
return
|
|
}
|
|
|
|
// Pick a random peer to send to
|
|
guard let randomPeer = peers.randomElement() else { return }
|
|
|
|
// Generate random dummy content
|
|
let dummyContent = generateDummyContent()
|
|
|
|
// Sending cover traffic
|
|
|
|
// Send as a private message so it's encrypted
|
|
let recipientNickname = collectionsQueue.sync {
|
|
return peerNicknames[randomPeer] ?? "unknown"
|
|
}
|
|
|
|
sendPrivateMessage(dummyContent, to: randomPeer, recipientNickname: recipientNickname)
|
|
}
|
|
|
|
private func generateDummyContent() -> String {
|
|
// Generate realistic-looking dummy messages
|
|
let templates = [
|
|
"hey",
|
|
"ok",
|
|
"got it",
|
|
"sure",
|
|
"sounds good",
|
|
"thanks",
|
|
"np",
|
|
"see you there",
|
|
"on my way",
|
|
"running late",
|
|
"be there soon",
|
|
"👍",
|
|
"✓",
|
|
"meeting at the usual spot",
|
|
"confirmed",
|
|
"roger that"
|
|
]
|
|
|
|
// Prefix with dummy marker (will be encrypted)
|
|
return coverTrafficPrefix + (templates.randomElement() ?? "ok")
|
|
}
|
|
|
|
|
|
private func updatePeerLastSeen(_ peerID: String) {
|
|
peerLastSeenTimestamps.set(peerID, value: Date())
|
|
}
|
|
|
|
private func sendPendingPrivateMessages(to peerID: String) {
|
|
messageQueue.async(flags: .barrier) { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// Get pending messages with proper queue synchronization
|
|
let pendingMessages = self.collectionsQueue.sync {
|
|
return self.pendingPrivateMessages[peerID]
|
|
}
|
|
|
|
guard let messages = pendingMessages else { return }
|
|
|
|
SecureLogger.log("Sending \(messages.count) pending private messages to \(peerID)", category: SecureLogger.session, level: .info)
|
|
|
|
// Clear pending messages for this peer
|
|
self.collectionsQueue.sync(flags: .barrier) {
|
|
_ = self.pendingPrivateMessages.removeValue(forKey: peerID)
|
|
}
|
|
|
|
// Send each pending message
|
|
for (content, recipientNickname, messageID) in messages {
|
|
// Check if this is a read receipt
|
|
if content.hasPrefix("READ_RECEIPT:") {
|
|
// Extract the original message ID
|
|
let originalMessageID = String(content.dropFirst("READ_RECEIPT:".count))
|
|
SecureLogger.log("Sending queued read receipt for message \(originalMessageID) to \(peerID)", category: SecureLogger.session, level: .debug)
|
|
|
|
// Create and send the actual read receipt
|
|
let receipt = ReadReceipt(
|
|
originalMessageID: originalMessageID,
|
|
readerID: self.myPeerID,
|
|
readerNickname: recipientNickname // This is actually the reader's nickname
|
|
)
|
|
|
|
// Send the read receipt using the normal method
|
|
DispatchQueue.global().async { [weak self] in
|
|
self?.sendReadReceipt(receipt, to: peerID)
|
|
}
|
|
} else {
|
|
// Regular message
|
|
SecureLogger.log("Sending pending message \(messageID) to \(peerID)", category: SecureLogger.session, level: .debug)
|
|
// Use async to avoid blocking the queue
|
|
DispatchQueue.global().async { [weak self] in
|
|
self?.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Noise Protocol Support
|
|
|
|
private func attemptHandshakeIfNeeded(with peerID: String, forceIfStale: Bool = false) {
|
|
// Check if we already have an established session
|
|
if noiseService.hasEstablishedSession(with: peerID) {
|
|
SecureLogger.log("Already have established session with \(peerID), skipping handshake",
|
|
category: SecureLogger.handshake, level: .debug)
|
|
return
|
|
}
|
|
|
|
// Check if we should initiate using the handshake coordinator
|
|
if !handshakeCoordinator.shouldInitiateHandshake(
|
|
myPeerID: myPeerID,
|
|
remotePeerID: peerID,
|
|
forceIfStale: forceIfStale
|
|
) {
|
|
SecureLogger.log("Should not initiate handshake with \(peerID) at this time",
|
|
category: SecureLogger.handshake, level: .debug)
|
|
return
|
|
}
|
|
|
|
// Initiate the handshake
|
|
initiateNoiseHandshake(with: peerID)
|
|
}
|
|
|
|
// Validate an existing Noise session by sending an encrypted ping
|
|
private func validateNoiseSession(with peerID: String) {
|
|
let encryptionQueue = getEncryptionQueue(for: peerID)
|
|
|
|
encryptionQueue.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// Create a ping packet with minimal data
|
|
let pingData = "ping:\(Date().timeIntervalSince1970)".data(using: .utf8)!
|
|
|
|
do {
|
|
// Try to encrypt a small ping message
|
|
let encrypted = try self.noiseService.encrypt(pingData, for: peerID)
|
|
|
|
// Create a validation packet (won't be displayed to user)
|
|
let packet = BitchatPacket(
|
|
type: MessageType.systemValidation.rawValue,
|
|
senderID: Data(hexString: self.myPeerID) ?? Data(),
|
|
recipientID: Data(hexString: peerID),
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: encrypted,
|
|
signature: nil,
|
|
ttl: 1
|
|
)
|
|
|
|
self.broadcastPacket(packet)
|
|
|
|
SecureLogger.log("Sent session validation ping to \(peerID)",
|
|
category: SecureLogger.session, level: .debug)
|
|
} catch {
|
|
// Encryption failed - session is invalid
|
|
SecureLogger.log("Session validation failed for \(peerID): \(error)",
|
|
category: SecureLogger.session, level: .warning)
|
|
|
|
// Clear the invalid session
|
|
self.cleanupPeerCryptoState(peerID)
|
|
|
|
// Initiate fresh handshake
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.attemptHandshakeIfNeeded(with: peerID, forceIfStale: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func initiateNoiseHandshake(with peerID: String) {
|
|
// Use noiseService directly
|
|
|
|
SecureLogger.log("Initiating Noise handshake with \(peerID)", category: SecureLogger.noise, level: .info)
|
|
|
|
// Check if we already have an established session
|
|
if noiseService.hasEstablishedSession(with: peerID) {
|
|
SecureLogger.log("Already have established session with \(peerID)", category: SecureLogger.noise, level: .debug)
|
|
// Clear any lingering handshake attempt time
|
|
handshakeAttemptTimes.removeValue(forKey: peerID)
|
|
handshakeCoordinator.recordHandshakeSuccess(peerID: peerID)
|
|
|
|
// Update connection state to authenticated
|
|
updatePeerConnectionState(peerID, state: .authenticated)
|
|
|
|
// Force UI update since we have an existing session
|
|
DispatchQueue.main.async { [weak self] in
|
|
(self?.delegate as? ChatViewModel)?.updateEncryptionStatusForPeers()
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Check if we have pending messages
|
|
let hasPendingMessages = collectionsQueue.sync {
|
|
return pendingPrivateMessages[peerID]?.isEmpty == false
|
|
}
|
|
|
|
// Check with coordinator if we should initiate
|
|
if !handshakeCoordinator.shouldInitiateHandshake(myPeerID: myPeerID, remotePeerID: peerID, forceIfStale: hasPendingMessages) {
|
|
SecureLogger.log("Coordinator says we should not initiate handshake with \(peerID)", category: SecureLogger.handshake, level: .debug)
|
|
|
|
if hasPendingMessages {
|
|
// Check if peer is still connected before retrying
|
|
let connectionState = collectionsQueue.sync { peerConnectionStates[peerID] ?? .disconnected }
|
|
|
|
if connectionState == .disconnected {
|
|
// Peer is disconnected - clear pending messages and stop retrying
|
|
SecureLogger.log("Peer \(peerID) is disconnected, clearing pending messages", category: SecureLogger.handshake, level: .info)
|
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
|
self?.pendingPrivateMessages[peerID]?.removeAll()
|
|
}
|
|
handshakeCoordinator.resetHandshakeState(for: peerID)
|
|
} else {
|
|
// Peer is still connected but handshake is stuck
|
|
// Send identity announce to prompt them to initiate if they have lower ID
|
|
SecureLogger.log("Handshake stuck with connected peer \(peerID), sending identity announce", category: SecureLogger.handshake, level: .info)
|
|
sendNoiseIdentityAnnounce(to: peerID)
|
|
|
|
// Only retry if we haven't retried too many times
|
|
let retryCount = handshakeCoordinator.getRetryCount(for: peerID)
|
|
if retryCount < 3 {
|
|
handshakeCoordinator.incrementRetryCount(for: peerID)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
|
self?.initiateNoiseHandshake(with: peerID)
|
|
}
|
|
} else {
|
|
SecureLogger.log("Max retries reached for \(peerID), clearing pending messages", category: SecureLogger.handshake, level: .warning)
|
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
|
self?.pendingPrivateMessages[peerID]?.removeAll()
|
|
}
|
|
handshakeCoordinator.resetHandshakeState(for: peerID)
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// Check if there's a retry delay
|
|
if let retryDelay = handshakeCoordinator.getRetryDelay(for: peerID), retryDelay > 0 {
|
|
SecureLogger.log("Waiting \(retryDelay)s before retrying handshake with \(peerID)", category: SecureLogger.handshake, level: .debug)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) { [weak self] in
|
|
self?.initiateNoiseHandshake(with: peerID)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Record that we're initiating
|
|
handshakeCoordinator.recordHandshakeInitiation(peerID: peerID)
|
|
handshakeAttemptTimes[peerID] = Date()
|
|
|
|
// Update connection state to authenticating
|
|
updatePeerConnectionState(peerID, state: .authenticating)
|
|
|
|
do {
|
|
// Generate handshake initiation message
|
|
let handshakeData = try noiseService.initiateHandshake(with: peerID)
|
|
SecureLogger.logHandshake("initiated", peerID: peerID, success: true)
|
|
|
|
// Send handshake initiation
|
|
let packet = BitchatPacket(
|
|
type: MessageType.noiseHandshakeInit.rawValue,
|
|
senderID: Data(hexString: myPeerID) ?? Data(),
|
|
recipientID: Data(hexString: peerID) ?? Data(), // Add recipient ID for targeted delivery
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: handshakeData,
|
|
signature: nil,
|
|
ttl: 6 // Increased TTL for better delivery on startup
|
|
)
|
|
|
|
// Track packet for ACK
|
|
trackPacketForAck(packet)
|
|
|
|
// Use broadcastPacket instead of sendPacket to ensure it goes through the mesh
|
|
broadcastPacket(packet)
|
|
|
|
// Schedule a retry check after 5 seconds
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in
|
|
guard let self = self else { return }
|
|
// Check if handshake completed
|
|
if !self.noiseService.hasEstablishedSession(with: peerID) {
|
|
let state = self.handshakeCoordinator.getHandshakeState(for: peerID)
|
|
if case .initiating = state {
|
|
SecureLogger.log("Handshake with \(peerID) not completed after 5s, will retry", category: SecureLogger.handshake, level: .warning)
|
|
// The handshake coordinator will handle retry logic
|
|
}
|
|
}
|
|
}
|
|
|
|
} catch NoiseSessionError.alreadyEstablished {
|
|
// Session already established, no need to handshake
|
|
handshakeCoordinator.recordHandshakeSuccess(peerID: peerID)
|
|
} catch {
|
|
// Failed to initiate handshake
|
|
handshakeCoordinator.recordHandshakeFailure(peerID: peerID, reason: error.localizedDescription)
|
|
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
|
}
|
|
}
|
|
|
|
private func handleNoiseHandshakeMessage(from peerID: String, message: Data, isInitiation: Bool) {
|
|
// Use noiseService directly
|
|
SecureLogger.logHandshake("processing \(isInitiation ? "init" : "response")", peerID: peerID, success: true)
|
|
|
|
// Get current handshake state before processing
|
|
let currentState = handshakeCoordinator.getHandshakeState(for: peerID)
|
|
let hasEstablishedSession = noiseService.hasEstablishedSession(with: peerID)
|
|
SecureLogger.log("Current handshake state for \(peerID): \(currentState), hasEstablishedSession: \(hasEstablishedSession)", category: SecureLogger.noise, level: .info)
|
|
|
|
// Check for duplicate handshake messages
|
|
if handshakeCoordinator.isDuplicateHandshakeMessage(message) {
|
|
SecureLogger.log("Duplicate handshake message from \(peerID), ignoring", category: SecureLogger.handshake, level: .debug)
|
|
return
|
|
}
|
|
|
|
// If this is an initiation, check if we should accept it
|
|
if isInitiation {
|
|
if !handshakeCoordinator.shouldAcceptHandshakeInitiation(myPeerID: myPeerID, remotePeerID: peerID) {
|
|
SecureLogger.log("Coordinator says we should not accept handshake from \(peerID)", category: SecureLogger.handshake, level: .debug)
|
|
return
|
|
}
|
|
// Record that we're responding
|
|
handshakeCoordinator.recordHandshakeResponse(peerID: peerID)
|
|
|
|
// Update connection state to authenticating
|
|
updatePeerConnectionState(peerID, state: .authenticating)
|
|
}
|
|
|
|
do {
|
|
// Process handshake message
|
|
if let response = try noiseService.processHandshakeMessage(from: peerID, message: message) {
|
|
SecureLogger.log("Handshake processing returned response of size \(response.count), sending back to \(peerID)", category: SecureLogger.noise, level: .info)
|
|
|
|
// Always send responses as handshake response type
|
|
let packet = BitchatPacket(
|
|
type: MessageType.noiseHandshakeResp.rawValue,
|
|
senderID: Data(hexString: myPeerID) ?? Data(),
|
|
recipientID: Data(hexString: peerID) ?? Data(), // Add recipient ID for targeted delivery
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: response,
|
|
signature: nil,
|
|
ttl: 6 // Increased TTL for better delivery on startup
|
|
)
|
|
|
|
// Track packet for ACK
|
|
trackPacketForAck(packet)
|
|
|
|
// Use broadcastPacket instead of sendPacket to ensure it goes through the mesh
|
|
broadcastPacket(packet)
|
|
} else {
|
|
SecureLogger.log("No response needed from processHandshakeMessage (isInitiation: \(isInitiation))", category: SecureLogger.noise, level: .debug)
|
|
}
|
|
|
|
// Check if handshake is complete
|
|
let sessionEstablished = noiseService.hasEstablishedSession(with: peerID)
|
|
let newState = handshakeCoordinator.getHandshakeState(for: peerID)
|
|
SecureLogger.log("After processing handshake message - sessionEstablished: \(sessionEstablished), newState: \(newState)", category: SecureLogger.noise, level: .info)
|
|
|
|
if sessionEstablished {
|
|
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
|
// Unlock rotation now that handshake is complete
|
|
unlockRotation()
|
|
|
|
// Session established successfully
|
|
handshakeCoordinator.recordHandshakeSuccess(peerID: peerID)
|
|
|
|
// Update connection state to authenticated
|
|
updatePeerConnectionState(peerID, state: .authenticated)
|
|
|
|
// Clear handshake attempt time on success
|
|
handshakeAttemptTimes.removeValue(forKey: peerID)
|
|
|
|
// Initialize last successful message time
|
|
lastSuccessfulMessageTime[peerID] = Date()
|
|
SecureLogger.log("Initialized lastSuccessfulMessageTime for \(peerID)", category: SecureLogger.noise, level: .debug)
|
|
|
|
// Send identity announcement to this specific peer
|
|
sendNoiseIdentityAnnounce(to: peerID)
|
|
|
|
// Also broadcast to ensure all peers get it
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
self?.sendNoiseIdentityAnnounce()
|
|
}
|
|
|
|
// Send regular announce packet after handshake to trigger connect message
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
|
|
self?.sendAnnouncementToPeer(peerID)
|
|
}
|
|
|
|
// Send any pending private messages
|
|
self.sendPendingPrivateMessages(to: peerID)
|
|
|
|
// Send any cached store-and-forward messages
|
|
sendCachedMessages(to: peerID)
|
|
}
|
|
} catch NoiseSessionError.alreadyEstablished {
|
|
// Session already established, ignore handshake
|
|
SecureLogger.log("Handshake already established with \(peerID)", category: SecureLogger.noise, level: .info)
|
|
handshakeCoordinator.recordHandshakeSuccess(peerID: peerID)
|
|
} catch {
|
|
// Handshake failed
|
|
handshakeCoordinator.recordHandshakeFailure(peerID: peerID, reason: error.localizedDescription)
|
|
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
|
SecureLogger.log("Handshake failed with \(peerID): \(error)", category: SecureLogger.noise, level: .error)
|
|
|
|
// If handshake failed due to authentication error, clear the session to allow retry
|
|
if case NoiseError.authenticationFailure = error {
|
|
SecureLogger.log("Handshake failed with \(peerID): authenticationFailure - clearing session", category: SecureLogger.noise, level: .warning)
|
|
cleanupPeerCryptoState(peerID)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleNoiseEncryptedMessage(from peerID: String, encryptedData: Data, originalPacket: BitchatPacket) {
|
|
// Use noiseService directly
|
|
|
|
// For Noise encrypted messages, we need to decrypt first to check the inner packet
|
|
// The outer packet's recipientID might be for routing, not the final recipient
|
|
|
|
// Create unique identifier for this encrypted message
|
|
let messageHash = encryptedData.prefix(32).hexEncodedString() // Use first 32 bytes as identifier
|
|
let messageKey = "\(peerID)-\(messageHash)"
|
|
|
|
// Check if we've already processed this exact encrypted message
|
|
let alreadyProcessed = collectionsQueue.sync(flags: .barrier) {
|
|
if processedNoiseMessages.contains(messageKey) {
|
|
return true
|
|
}
|
|
processedNoiseMessages.insert(messageKey)
|
|
return false
|
|
}
|
|
|
|
if alreadyProcessed {
|
|
return
|
|
}
|
|
|
|
do {
|
|
// Decrypt the message
|
|
SecureLogger.log("Attempting to decrypt Noise message from \(peerID), encrypted size: \(encryptedData.count)", category: SecureLogger.encryption, level: .debug)
|
|
let decryptedData = try noiseService.decrypt(encryptedData, from: peerID)
|
|
SecureLogger.log("Successfully decrypted message from \(peerID), decrypted size: \(decryptedData.count)", category: SecureLogger.encryption, level: .debug)
|
|
|
|
// Update last successful message time
|
|
lastSuccessfulMessageTime[peerID] = Date()
|
|
|
|
// Send protocol ACK after successful decryption
|
|
sendProtocolAck(for: originalPacket, to: peerID)
|
|
|
|
// If we can decrypt messages from this peer, they should be in activePeers
|
|
let wasAdded = collectionsQueue.sync(flags: .barrier) {
|
|
if !self.activePeers.contains(peerID) {
|
|
SecureLogger.log("Adding \(peerID) to activePeers after successful decryption", category: SecureLogger.noise, level: .info)
|
|
return self.activePeers.insert(peerID).inserted
|
|
}
|
|
return false
|
|
}
|
|
|
|
if wasAdded {
|
|
// Notify about peer list update
|
|
self.notifyPeerListUpdate()
|
|
}
|
|
|
|
// Check if this is a special format message (type marker + payload)
|
|
if decryptedData.count > 1 {
|
|
let typeMarker = decryptedData[0]
|
|
|
|
// Check if this is a delivery ACK with the new format
|
|
if typeMarker == MessageType.deliveryAck.rawValue {
|
|
// Extract the ACK JSON data (skip the type marker)
|
|
let ackData = decryptedData.dropFirst()
|
|
|
|
// Decode the delivery ACK - try binary first, then JSON
|
|
if let ack = DeliveryAck.fromBinaryData(ackData) {
|
|
SecureLogger.log("Received binary delivery ACK via Noise: \(ack.originalMessageID) from \(ack.recipientNickname)", category: SecureLogger.session, level: .debug)
|
|
|
|
// Process the ACK
|
|
DeliveryTracker.shared.processDeliveryAck(ack)
|
|
|
|
// Notify delegate
|
|
DispatchQueue.main.async {
|
|
self.delegate?.didReceiveDeliveryAck(ack)
|
|
}
|
|
return
|
|
} else if let ack = DeliveryAck.decode(from: ackData) {
|
|
SecureLogger.log("Received JSON delivery ACK via Noise: \(ack.originalMessageID) from \(ack.recipientNickname)", category: SecureLogger.session, level: .debug)
|
|
|
|
// Process the ACK
|
|
DeliveryTracker.shared.processDeliveryAck(ack)
|
|
|
|
// Notify delegate
|
|
DispatchQueue.main.async {
|
|
self.delegate?.didReceiveDeliveryAck(ack)
|
|
}
|
|
return
|
|
} else {
|
|
SecureLogger.log("Failed to decode delivery ACK via Noise - data size: \(ackData.count)", category: SecureLogger.session, level: .warning)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to parse as a full inner packet (for backward compatibility and other message types)
|
|
if let innerPacket = BitchatPacket.from(decryptedData) {
|
|
SecureLogger.log("Successfully parsed inner packet - type: \(MessageType(rawValue: innerPacket.type)?.description ?? "unknown"), from: \(innerPacket.senderID.hexEncodedString()), to: \(innerPacket.recipientID?.hexEncodedString() ?? "broadcast")", category: SecureLogger.session, level: .debug)
|
|
|
|
// Process the decrypted inner packet
|
|
// The packet will be handled according to its recipient ID
|
|
// If it's for us, it won't be relayed
|
|
handleReceivedPacket(innerPacket, from: peerID)
|
|
} else {
|
|
SecureLogger.log("Failed to parse inner packet from decrypted data", category: SecureLogger.encryption, level: .warning)
|
|
}
|
|
} catch {
|
|
// Failed to decrypt - might need to re-establish session
|
|
SecureLogger.log("Failed to decrypt Noise message from \(peerID): \(error)", category: SecureLogger.encryption, level: .error)
|
|
if !noiseService.hasEstablishedSession(with: peerID) {
|
|
SecureLogger.log("No Noise session with \(peerID), attempting handshake", category: SecureLogger.noise, level: .info)
|
|
attemptHandshakeIfNeeded(with: peerID, forceIfStale: true)
|
|
} else {
|
|
SecureLogger.log("Have session with \(peerID) but decryption failed", category: SecureLogger.encryption, level: .warning)
|
|
|
|
// Send a NACK to inform peer their session is out of sync
|
|
sendProtocolNack(for: originalPacket, to: peerID,
|
|
reason: "Decryption failed - session out of sync",
|
|
errorCode: .decryptionFailed)
|
|
|
|
// The NACK handler will take care of clearing sessions and re-establishing
|
|
// Don't initiate anything here to avoid race conditions
|
|
|
|
// Update UI to show encryption is broken
|
|
DispatchQueue.main.async { [weak self] in
|
|
if let chatVM = self?.delegate as? ChatViewModel {
|
|
chatVM.updateEncryptionStatusForPeer(peerID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: - Protocol Version Negotiation
|
|
|
|
private func handleVersionHello(from peerID: String, data: Data, peripheral: CBPeripheral? = nil) {
|
|
// Create a copy to avoid potential race conditions
|
|
let dataCopy = Data(data)
|
|
|
|
// Safety check for empty data
|
|
guard !dataCopy.isEmpty else {
|
|
SecureLogger.log("Received empty version hello data from \(peerID)", category: SecureLogger.session, level: .error)
|
|
return
|
|
}
|
|
|
|
// Check if this peer is reconnecting after disconnect
|
|
if let lastConnected = lastConnectionTime[peerID] {
|
|
let timeSinceLastConnection = Date().timeIntervalSince(lastConnected)
|
|
// Only clear truly stale sessions, not on every reconnect
|
|
if timeSinceLastConnection > 86400.0 { // More than 24 hours since last connection
|
|
// Clear any stale Noise session
|
|
if noiseService.hasEstablishedSession(with: peerID) {
|
|
SecureLogger.log("Peer \(peerID) reconnecting after \(Int(timeSinceLastConnection))s - clearing stale session", category: SecureLogger.noise, level: .info)
|
|
cleanupPeerCryptoState(peerID)
|
|
}
|
|
} else if timeSinceLastConnection > 5.0 {
|
|
// Just log the reconnection, don't clear the session
|
|
SecureLogger.log("Peer \(peerID) reconnecting after \(Int(timeSinceLastConnection))s - keeping existing session", category: SecureLogger.noise, level: .info)
|
|
}
|
|
}
|
|
|
|
// Update last connection time
|
|
lastConnectionTime[peerID] = Date()
|
|
|
|
// Try JSON first if it looks like JSON
|
|
let hello: VersionHello?
|
|
if let firstByte = dataCopy.first, firstByte == 0x7B { // '{' character
|
|
SecureLogger.log("Version hello from \(peerID) appears to be JSON (size: \(dataCopy.count))", category: SecureLogger.session, level: .debug)
|
|
hello = VersionHello.decode(from: dataCopy) ?? VersionHello.fromBinaryData(dataCopy)
|
|
} else {
|
|
SecureLogger.log("Version hello from \(peerID) appears to be binary (size: \(dataCopy.count), first byte: \(dataCopy.first?.description ?? "nil"))", category: SecureLogger.session, level: .debug)
|
|
hello = VersionHello.fromBinaryData(dataCopy) ?? VersionHello.decode(from: dataCopy)
|
|
}
|
|
|
|
guard let hello = hello else {
|
|
SecureLogger.log("Failed to decode version hello from \(peerID)", category: SecureLogger.session, level: .error)
|
|
return
|
|
}
|
|
|
|
SecureLogger.log("Received version hello from \(peerID): supported versions \(hello.supportedVersions), preferred \(hello.preferredVersion)",
|
|
category: SecureLogger.session, level: .debug)
|
|
|
|
// Find the best common version
|
|
let ourVersions = Array(ProtocolVersion.supportedVersions)
|
|
if let agreedVersion = ProtocolVersion.negotiateVersion(clientVersions: hello.supportedVersions, serverVersions: ourVersions) {
|
|
// We can communicate! Send ACK
|
|
SecureLogger.log("Version negotiation agreed with \(peerID): v\(agreedVersion) (client: \(hello.clientVersion), platform: \(hello.platform))", category: SecureLogger.session, level: .info)
|
|
negotiatedVersions[peerID] = agreedVersion
|
|
versionNegotiationState[peerID] = .ackReceived(version: agreedVersion)
|
|
|
|
let ack = VersionAck(
|
|
agreedVersion: agreedVersion,
|
|
serverVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0",
|
|
platform: getPlatformString()
|
|
)
|
|
|
|
sendVersionAck(ack, to: peerID)
|
|
|
|
// Proceed with Noise handshake after successful version negotiation
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// First announce our identity
|
|
self.sendNoiseIdentityAnnounce()
|
|
|
|
// Then check if we should initiate handshake
|
|
// If we already have a valid session, skip handshake
|
|
if self.noiseService.hasEstablishedSession(with: peerID) {
|
|
SecureLogger.log("Already have session with \(peerID) after version negotiation, skipping handshake",
|
|
category: SecureLogger.handshake, level: .info)
|
|
|
|
// Force a session validation by sending a small encrypted ping
|
|
self.validateNoiseSession(with: peerID)
|
|
} else {
|
|
// Use attemptHandshakeIfNeeded to coordinate properly
|
|
self.attemptHandshakeIfNeeded(with: peerID)
|
|
}
|
|
}
|
|
} else {
|
|
// No compatible version
|
|
SecureLogger.log("Version negotiation failed with \(peerID): No compatible version (client supports: \(hello.supportedVersions))", category: SecureLogger.session, level: .warning)
|
|
versionNegotiationState[peerID] = .failed(reason: "No compatible protocol version")
|
|
|
|
let ack = VersionAck(
|
|
agreedVersion: 0,
|
|
serverVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0",
|
|
platform: getPlatformString(),
|
|
rejected: true,
|
|
reason: "No compatible protocol version. Client supports: \(hello.supportedVersions), server supports: \(ourVersions)"
|
|
)
|
|
|
|
sendVersionAck(ack, to: peerID)
|
|
|
|
// Disconnect after a short delay
|
|
if let peripheral = peripheral {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
|
self?.centralManager?.cancelPeripheralConnection(peripheral)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleVersionAck(from peerID: String, data: Data) {
|
|
// Create a copy to avoid potential race conditions
|
|
let dataCopy = Data(data)
|
|
|
|
// Safety check for empty data
|
|
guard !dataCopy.isEmpty else {
|
|
SecureLogger.log("Received empty version ack data from \(peerID)", category: SecureLogger.session, level: .error)
|
|
return
|
|
}
|
|
|
|
// Try JSON first if it looks like JSON
|
|
let ack: VersionAck?
|
|
if let firstByte = dataCopy.first, firstByte == 0x7B { // '{' character
|
|
ack = VersionAck.decode(from: dataCopy) ?? VersionAck.fromBinaryData(dataCopy)
|
|
} else {
|
|
ack = VersionAck.fromBinaryData(dataCopy) ?? VersionAck.decode(from: dataCopy)
|
|
}
|
|
|
|
guard let ack = ack else {
|
|
SecureLogger.log("Failed to decode version ack from \(peerID)", category: SecureLogger.session, level: .error)
|
|
return
|
|
}
|
|
|
|
if ack.rejected {
|
|
SecureLogger.log("Version negotiation rejected by \(peerID): \(ack.reason ?? "Unknown reason")",
|
|
category: SecureLogger.session, level: .error)
|
|
versionNegotiationState[peerID] = .failed(reason: ack.reason ?? "Version rejected")
|
|
|
|
// Clean up state for incompatible peer
|
|
collectionsQueue.sync(flags: .barrier) {
|
|
_ = self.activePeers.remove(peerID)
|
|
_ = self.peerNicknames.removeValue(forKey: peerID)
|
|
_ = self.lastHeardFromPeer.removeValue(forKey: peerID)
|
|
}
|
|
announcedPeers.remove(peerID)
|
|
|
|
// Clean up any Noise session
|
|
cleanupPeerCryptoState(peerID)
|
|
|
|
// Notify delegate about incompatible peer disconnection
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.delegate?.didDisconnectFromPeer(peerID)
|
|
}
|
|
} else {
|
|
SecureLogger.log("Version negotiation successful with \(peerID): agreed on v\(ack.agreedVersion) (server: \(ack.serverVersion), platform: \(ack.platform))",
|
|
category: SecureLogger.session, level: .info)
|
|
negotiatedVersions[peerID] = ack.agreedVersion
|
|
versionNegotiationState[peerID] = .ackReceived(version: ack.agreedVersion)
|
|
|
|
// If we were the initiator (sent hello first), proceed with Noise handshake
|
|
// Note: Since we're handling their ACK, they initiated, so we should not initiate again
|
|
// The peer who sent hello will initiate the Noise handshake
|
|
}
|
|
}
|
|
|
|
private func sendVersionHello(to peripheral: CBPeripheral? = nil) {
|
|
let hello = VersionHello(
|
|
clientVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0",
|
|
platform: getPlatformString()
|
|
)
|
|
|
|
let helloData = hello.toBinaryData()
|
|
|
|
let packet = BitchatPacket(
|
|
type: MessageType.versionHello.rawValue,
|
|
ttl: 1, // Version negotiation is direct, no relay
|
|
senderID: myPeerID,
|
|
payload: helloData
|
|
)
|
|
|
|
// Mark that we initiated version negotiation
|
|
// We don't know the peer ID yet from peripheral, so we'll track it when we get the response
|
|
|
|
if let peripheral = peripheral,
|
|
let characteristic = peripheralCharacteristics[peripheral] {
|
|
// Send directly to specific peripheral
|
|
if let data = packet.toBinaryData() {
|
|
writeToPeripheral(data, peripheral: peripheral, characteristic: characteristic, peerID: nil)
|
|
}
|
|
} else {
|
|
// Broadcast to all
|
|
broadcastPacket(packet)
|
|
}
|
|
}
|
|
|
|
private func sendVersionAck(_ ack: VersionAck, to peerID: String) {
|
|
let ackData = ack.toBinaryData()
|
|
|
|
let packet = BitchatPacket(
|
|
type: MessageType.versionAck.rawValue,
|
|
senderID: Data(myPeerID.utf8),
|
|
recipientID: Data(peerID.utf8),
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: ackData,
|
|
signature: nil,
|
|
ttl: 1 // Direct response, no relay
|
|
)
|
|
|
|
broadcastPacket(packet)
|
|
}
|
|
|
|
private func getPlatformString() -> String {
|
|
#if os(iOS)
|
|
return "iOS"
|
|
#elseif os(macOS)
|
|
return "macOS"
|
|
#else
|
|
return "Unknown"
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Protocol ACK/NACK Handling
|
|
|
|
private func handleProtocolAck(from peerID: String, data: Data) {
|
|
guard let ack = ProtocolAck.fromBinaryData(data) else {
|
|
SecureLogger.log("Failed to decode protocol ACK from \(peerID)", category: SecureLogger.session, level: .error)
|
|
return
|
|
}
|
|
|
|
SecureLogger.log("Received protocol ACK from \(peerID) for packet \(ack.originalPacketID), type: \(ack.packetType)",
|
|
category: SecureLogger.session, level: .debug)
|
|
|
|
// Remove from pending ACKs
|
|
_ = collectionsQueue.sync(flags: .barrier) {
|
|
pendingAcks.removeValue(forKey: ack.originalPacketID)
|
|
}
|
|
|
|
// Handle specific packet types that need ACK confirmation
|
|
if let messageType = MessageType(rawValue: ack.packetType) {
|
|
switch messageType {
|
|
case .noiseHandshakeInit, .noiseHandshakeResp:
|
|
SecureLogger.log("Handshake packet \(ack.originalPacketID) confirmed by \(peerID)",
|
|
category: SecureLogger.handshake, level: .info)
|
|
case .noiseEncrypted:
|
|
SecureLogger.log("Encrypted message \(ack.originalPacketID) confirmed by \(peerID)",
|
|
category: SecureLogger.encryption, level: .debug)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleProtocolNack(from peerID: String, data: Data) {
|
|
guard let nack = ProtocolNack.fromBinaryData(data) else {
|
|
SecureLogger.log("Failed to decode protocol NACK from \(peerID)", category: SecureLogger.session, level: .error)
|
|
return
|
|
}
|
|
|
|
SecureLogger.log("Received protocol NACK from \(peerID) for packet \(nack.originalPacketID): \(nack.reason)",
|
|
category: SecureLogger.session, level: .warning)
|
|
|
|
// Remove from pending ACKs
|
|
_ = collectionsQueue.sync(flags: .barrier) {
|
|
pendingAcks.removeValue(forKey: nack.originalPacketID)
|
|
}
|
|
|
|
// Handle specific error codes
|
|
if let errorCode = ProtocolNack.ErrorCode(rawValue: nack.errorCode) {
|
|
switch errorCode {
|
|
case .decryptionFailed:
|
|
// Session is out of sync - both sides need to clear and re-establish
|
|
SecureLogger.log("Decryption failed at \(peerID), clearing session and re-establishing",
|
|
category: SecureLogger.encryption, level: .warning)
|
|
|
|
// Clear our session state and handshake coordinator state
|
|
cleanupPeerCryptoState(peerID)
|
|
handshakeCoordinator.resetHandshakeState(for: peerID)
|
|
|
|
// Update connection state
|
|
updatePeerConnectionState(peerID, state: .connected)
|
|
|
|
// Use deterministic role assignment to prevent race conditions
|
|
let shouldInitiate = handshakeCoordinator.determineHandshakeRole(
|
|
myPeerID: myPeerID,
|
|
remotePeerID: peerID
|
|
) == .initiator
|
|
|
|
if shouldInitiate {
|
|
// Small delay to ensure both sides have cleared state
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
guard let self = self else { return }
|
|
SecureLogger.log("Initiating handshake after decryption failure with \(peerID)",
|
|
category: SecureLogger.session, level: .info)
|
|
self.attemptHandshakeIfNeeded(with: peerID, forceIfStale: true)
|
|
}
|
|
} else {
|
|
// Send identity announcement to signal we're ready
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
guard let self = self else { return }
|
|
SecureLogger.log("Sending identity announcement after decryption failure to \(peerID)",
|
|
category: SecureLogger.session, level: .info)
|
|
self.sendNoiseIdentityAnnounce(to: peerID)
|
|
}
|
|
}
|
|
case .sessionExpired:
|
|
// Clear session and re-handshake
|
|
SecureLogger.log("Session expired at \(peerID), clearing and re-handshaking",
|
|
category: SecureLogger.session, level: .warning)
|
|
cleanupPeerCryptoState(peerID)
|
|
handshakeCoordinator.resetHandshakeState(for: peerID)
|
|
attemptHandshakeIfNeeded(with: peerID, forceIfStale: true)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Send protocol ACK for important packets
|
|
private func sendProtocolAck(for packet: BitchatPacket, to peerID: String, hopCount: UInt8 = 0) {
|
|
// Generate packet ID from packet content hash
|
|
let packetID = generatePacketID(for: packet)
|
|
|
|
let ack = ProtocolAck(
|
|
originalPacketID: packetID,
|
|
senderID: packet.senderID.hexEncodedString(),
|
|
receiverID: myPeerID,
|
|
packetType: packet.type,
|
|
hopCount: hopCount
|
|
)
|
|
|
|
let ackPacket = BitchatPacket(
|
|
type: MessageType.protocolAck.rawValue,
|
|
senderID: Data(hexString: myPeerID) ?? Data(),
|
|
recipientID: Data(hexString: peerID) ?? Data(),
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: ack.toBinaryData(),
|
|
signature: nil,
|
|
ttl: 3 // ACKs don't need to travel far
|
|
)
|
|
|
|
broadcastPacket(ackPacket)
|
|
}
|
|
|
|
// Send protocol NACK for failed packets
|
|
private func sendProtocolNack(for packet: BitchatPacket, to peerID: String, reason: String, errorCode: ProtocolNack.ErrorCode) {
|
|
let packetID = generatePacketID(for: packet)
|
|
|
|
let nack = ProtocolNack(
|
|
originalPacketID: packetID,
|
|
senderID: packet.senderID.hexEncodedString(),
|
|
receiverID: myPeerID,
|
|
packetType: packet.type,
|
|
reason: reason,
|
|
errorCode: errorCode
|
|
)
|
|
|
|
let nackPacket = BitchatPacket(
|
|
type: MessageType.protocolNack.rawValue,
|
|
senderID: Data(hexString: myPeerID) ?? Data(),
|
|
recipientID: Data(hexString: peerID) ?? Data(),
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: nack.toBinaryData(),
|
|
signature: nil,
|
|
ttl: 3 // NACKs don't need to travel far
|
|
)
|
|
|
|
broadcastPacket(nackPacket)
|
|
}
|
|
|
|
// Generate unique packet ID from packet content
|
|
private func generatePacketID(for packet: BitchatPacket) -> String {
|
|
// Use hash of packet data for unique ID
|
|
if let data = packet.toBinaryData() {
|
|
let hash = SHA256.hash(data: data)
|
|
return hash.map { String(format: "%02x", $0) }.prefix(16).joined()
|
|
}
|
|
return UUID().uuidString
|
|
}
|
|
|
|
// Track packets that need ACKs
|
|
private func trackPacketForAck(_ packet: BitchatPacket) {
|
|
let packetID = generatePacketID(for: packet)
|
|
|
|
collectionsQueue.sync(flags: .barrier) {
|
|
pendingAcks[packetID] = (packet: packet, timestamp: Date(), retries: 0)
|
|
}
|
|
|
|
// Schedule timeout check
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + ackTimeout) { [weak self] in
|
|
self?.checkAckTimeout(for: packetID)
|
|
}
|
|
}
|
|
|
|
// Check for ACK timeout and retry if needed
|
|
private func checkAckTimeout(for packetID: String) {
|
|
collectionsQueue.sync(flags: .barrier) { [weak self] in
|
|
guard let self = self,
|
|
let pending = self.pendingAcks[packetID] else { return }
|
|
|
|
if pending.retries < self.maxAckRetries {
|
|
// Retry sending the packet
|
|
SecureLogger.log("ACK timeout for packet \(packetID), retrying (attempt \(pending.retries + 1))",
|
|
category: SecureLogger.session, level: .warning)
|
|
|
|
self.pendingAcks[packetID] = (packet: pending.packet,
|
|
timestamp: Date(),
|
|
retries: pending.retries + 1)
|
|
|
|
// Resend the packet
|
|
DispatchQueue.main.async {
|
|
self.broadcastPacket(pending.packet)
|
|
}
|
|
|
|
// Schedule next timeout check
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + self.ackTimeout) {
|
|
self.checkAckTimeout(for: packetID)
|
|
}
|
|
} else {
|
|
// Max retries reached, give up
|
|
SecureLogger.log("Max ACK retries reached for packet \(packetID), giving up",
|
|
category: SecureLogger.session, level: .error)
|
|
self.pendingAcks.removeValue(forKey: packetID)
|
|
|
|
// Could notify upper layer about delivery failure here
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check all pending ACKs for timeouts (called by timer)
|
|
private func checkAckTimeouts() {
|
|
let now = Date()
|
|
var timedOutPackets: [String] = []
|
|
|
|
collectionsQueue.sync {
|
|
for (packetID, pending) in pendingAcks {
|
|
if now.timeIntervalSince(pending.timestamp) > ackTimeout {
|
|
timedOutPackets.append(packetID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process timeouts outside the sync block
|
|
for packetID in timedOutPackets {
|
|
checkAckTimeout(for: packetID)
|
|
}
|
|
}
|
|
|
|
// Check peer availability based on last heard time
|
|
private func checkPeerAvailability() {
|
|
let now = Date()
|
|
var stateChanges: [(peerID: String, available: Bool)] = []
|
|
|
|
collectionsQueue.sync(flags: .barrier) {
|
|
// Check all active peers
|
|
for peerID in activePeers {
|
|
let lastHeard = lastHeardFromPeer[peerID] ?? Date.distantPast
|
|
let timeSinceLastHeard = now.timeIntervalSince(lastHeard)
|
|
let wasAvailable = peerAvailabilityState[peerID] ?? true
|
|
|
|
// Check connection state
|
|
let connectionState = peerConnectionStates[peerID] ?? .disconnected
|
|
let hasConnection = connectionState == .connected || connectionState == .authenticating || connectionState == .authenticated
|
|
|
|
// Peer is available if:
|
|
// 1. We have an active connection AND heard from them recently, OR
|
|
// 2. We're authenticated and heard from them within timeout period
|
|
let isAvailable = (hasConnection && timeSinceLastHeard < 60.0) ||
|
|
(connectionState == .authenticated && timeSinceLastHeard < peerAvailabilityTimeout)
|
|
|
|
if wasAvailable != isAvailable {
|
|
peerAvailabilityState[peerID] = isAvailable
|
|
stateChanges.append((peerID: peerID, available: isAvailable))
|
|
}
|
|
}
|
|
|
|
// Remove availability state for peers no longer active
|
|
let inactivePeers = peerAvailabilityState.keys.filter { !activePeers.contains($0) }
|
|
for peerID in inactivePeers {
|
|
peerAvailabilityState.removeValue(forKey: peerID)
|
|
}
|
|
}
|
|
|
|
// Notify about availability changes
|
|
for change in stateChanges {
|
|
SecureLogger.log("Peer \(change.peerID) availability changed to: \(change.available)",
|
|
category: SecureLogger.session, level: .info)
|
|
|
|
// Notify delegate about availability change
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.delegate?.peerAvailabilityChanged(change.peerID, available: change.available)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update peer availability when we hear from them
|
|
private func updatePeerAvailability(_ peerID: String) {
|
|
collectionsQueue.sync(flags: .barrier) {
|
|
lastHeardFromPeer[peerID] = Date()
|
|
|
|
// If peer wasn't available, mark as available now
|
|
if peerAvailabilityState[peerID] != true {
|
|
peerAvailabilityState[peerID] = true
|
|
|
|
SecureLogger.log("Peer \(peerID) marked as available after hearing from them",
|
|
category: SecureLogger.session, level: .info)
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.delegate?.peerAvailabilityChanged(peerID, available: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if a peer is currently available
|
|
func isPeerAvailable(_ peerID: String) -> Bool {
|
|
return collectionsQueue.sync {
|
|
return peerAvailabilityState[peerID] ?? false
|
|
}
|
|
}
|
|
|
|
private func sendNoiseIdentityAnnounce(to specificPeerID: String? = nil) {
|
|
// Rate limit identity announcements
|
|
let now = Date()
|
|
|
|
// If targeting a specific peer, check rate limit
|
|
if let peerID = specificPeerID {
|
|
if let lastTime = lastIdentityAnnounceTimes[peerID],
|
|
now.timeIntervalSince(lastTime) < identityAnnounceMinInterval {
|
|
// Too soon, skip this announcement
|
|
return
|
|
}
|
|
lastIdentityAnnounceTimes[peerID] = now
|
|
} else {
|
|
// Broadcasting to all - check global rate limit
|
|
if let lastTime = lastIdentityAnnounceTimes["*broadcast*"],
|
|
now.timeIntervalSince(lastTime) < identityAnnounceMinInterval {
|
|
return
|
|
}
|
|
lastIdentityAnnounceTimes["*broadcast*"] = now
|
|
}
|
|
|
|
// Get our Noise static public key and signing public key
|
|
let staticKey = noiseService.getStaticPublicKeyData()
|
|
let signingKey = noiseService.getSigningPublicKeyData()
|
|
|
|
// Get nickname from delegate
|
|
let nickname = (delegate as? ChatViewModel)?.nickname ?? "Anonymous"
|
|
|
|
// Create the binding data to sign (peerID + publicKey + timestamp)
|
|
let timestampData = String(Int64(now.timeIntervalSince1970 * 1000)).data(using: .utf8)!
|
|
let bindingData = myPeerID.data(using: .utf8)! + staticKey + timestampData
|
|
|
|
// Sign the binding with our Ed25519 signing key
|
|
let signature = noiseService.signData(bindingData) ?? Data()
|
|
|
|
// Create the identity announcement
|
|
let announcement = NoiseIdentityAnnouncement(
|
|
peerID: myPeerID,
|
|
publicKey: staticKey,
|
|
signingPublicKey: signingKey,
|
|
nickname: nickname,
|
|
timestamp: now,
|
|
previousPeerID: previousPeerID,
|
|
signature: signature
|
|
)
|
|
|
|
// Encode the announcement
|
|
let announcementData = announcement.toBinaryData()
|
|
|
|
let packet = BitchatPacket(
|
|
type: MessageType.noiseIdentityAnnounce.rawValue,
|
|
senderID: Data(hexString: myPeerID) ?? Data(),
|
|
recipientID: specificPeerID.flatMap { Data(hexString: $0) }, // Targeted or broadcast
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: announcementData,
|
|
signature: nil,
|
|
ttl: adaptiveTTL
|
|
)
|
|
|
|
if let targetPeer = specificPeerID {
|
|
SecureLogger.log("Sending targeted identity announce to \(targetPeer)",
|
|
category: SecureLogger.noise, level: .info)
|
|
} else {
|
|
SecureLogger.log("Broadcasting identity announce to all peers",
|
|
category: SecureLogger.noise, level: .info)
|
|
}
|
|
|
|
broadcastPacket(packet)
|
|
}
|
|
|
|
// Removed sendPacket method - all packets should use broadcastPacket to ensure mesh delivery
|
|
|
|
// Send private message using Noise Protocol
|
|
private func sendPrivateMessageViaNoise(_ content: String, to recipientPeerID: String, recipientNickname: String, messageID: String? = nil) {
|
|
// Use per-peer encryption queue to prevent nonce desynchronization
|
|
let encryptionQueue = getEncryptionQueue(for: recipientPeerID)
|
|
|
|
encryptionQueue.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// Use noiseService directly
|
|
|
|
// Check if we have a Noise session with this peer
|
|
let hasSession = self.noiseService.hasEstablishedSession(with: recipientPeerID)
|
|
|
|
// Check if session is stale (no successful communication for a while)
|
|
var sessionIsStale = false
|
|
if hasSession {
|
|
let lastSuccess = lastSuccessfulMessageTime[recipientPeerID] ?? Date.distantPast
|
|
let sessionAge = Date().timeIntervalSince(lastSuccess)
|
|
// Increase session validity to 24 hours - sessions should persist across temporary disconnects
|
|
if sessionAge > 86400.0 { // More than 24 hours since last successful message
|
|
sessionIsStale = true
|
|
SecureLogger.log("Session with \(recipientPeerID) is stale (last success: \(Int(sessionAge))s ago), will re-establish", category: SecureLogger.noise, level: .info)
|
|
}
|
|
}
|
|
|
|
if !hasSession || sessionIsStale {
|
|
if sessionIsStale {
|
|
// Clear stale session first
|
|
cleanupPeerCryptoState(recipientPeerID)
|
|
}
|
|
SecureLogger.log("No valid Noise session with \(recipientPeerID), initiating handshake", category: SecureLogger.noise, level: .info)
|
|
|
|
// Apply tie-breaker logic for handshake initiation
|
|
if myPeerID < recipientPeerID {
|
|
// We have lower ID, initiate handshake
|
|
initiateNoiseHandshake(with: recipientPeerID)
|
|
} else {
|
|
// We have higher ID, send targeted identity announce to prompt them to initiate
|
|
sendNoiseIdentityAnnounce(to: recipientPeerID)
|
|
}
|
|
|
|
// Queue message for sending after handshake completes
|
|
messageQueue.async(flags: .barrier) { [weak self] in
|
|
guard let self = self else { return }
|
|
if self.pendingPrivateMessages[recipientPeerID] == nil {
|
|
self.pendingPrivateMessages[recipientPeerID] = []
|
|
}
|
|
self.pendingPrivateMessages[recipientPeerID]?.append((content, recipientNickname, messageID ?? UUID().uuidString))
|
|
let count = self.pendingPrivateMessages[recipientPeerID]?.count ?? 0
|
|
SecureLogger.log("Queued private message for \(recipientPeerID), \(count) messages pending", category: SecureLogger.noise, level: .info)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Use provided message ID or generate a new one
|
|
let msgID = messageID ?? UUID().uuidString
|
|
|
|
// Check if we're already processing this message
|
|
let sendKey = "\(msgID)-\(recipientPeerID)"
|
|
let alreadySending = self.collectionsQueue.sync(flags: .barrier) {
|
|
if self.recentlySentMessages.contains(sendKey) {
|
|
return true
|
|
}
|
|
self.recentlySentMessages.insert(sendKey)
|
|
// Clean up old entries after 10 seconds
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak self] in
|
|
self?.collectionsQueue.sync(flags: .barrier) {
|
|
_ = self?.recentlySentMessages.remove(sendKey)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
if alreadySending {
|
|
return
|
|
}
|
|
|
|
|
|
// Get sender nickname from delegate
|
|
let nickname = self.delegate as? ChatViewModel
|
|
let senderNick = nickname?.nickname ?? self.myPeerID
|
|
|
|
// Create the inner message
|
|
let message = BitchatMessage(
|
|
id: msgID,
|
|
sender: senderNick,
|
|
content: content,
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: recipientNickname,
|
|
senderPeerID: myPeerID
|
|
)
|
|
|
|
// Use binary payload format to match the receiver's expectations
|
|
guard let messageData = message.toBinaryPayload() else {
|
|
return
|
|
}
|
|
|
|
// Create inner packet
|
|
let innerPacket = BitchatPacket(
|
|
type: MessageType.message.rawValue,
|
|
senderID: Data(hexString: myPeerID) ?? Data(),
|
|
recipientID: Data(hexString: recipientPeerID) ?? Data(),
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: messageData,
|
|
signature: nil,
|
|
ttl: self.adaptiveTTL // Inner packet needs valid TTL for processing after decryption
|
|
)
|
|
|
|
guard let innerData = innerPacket.toBinaryData() else { return }
|
|
|
|
do {
|
|
// Encrypt with Noise
|
|
SecureLogger.log("Encrypting private message \(msgID) for \(recipientPeerID)", category: SecureLogger.encryption, level: .debug)
|
|
let encryptedData = try noiseService.encrypt(innerData, for: recipientPeerID)
|
|
SecureLogger.log("Successfully encrypted message, size: \(encryptedData.count)", category: SecureLogger.encryption, level: .debug)
|
|
|
|
// Update last successful message time
|
|
lastSuccessfulMessageTime[recipientPeerID] = Date()
|
|
|
|
// Send as Noise encrypted message
|
|
let outerPacket = BitchatPacket(
|
|
type: MessageType.noiseEncrypted.rawValue,
|
|
senderID: Data(hexString: myPeerID) ?? Data(),
|
|
recipientID: Data(hexString: recipientPeerID) ?? Data(),
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: encryptedData,
|
|
signature: nil,
|
|
ttl: adaptiveTTL
|
|
)
|
|
|
|
SecureLogger.log("Broadcasting encrypted private message \(msgID) to \(recipientPeerID)", category: SecureLogger.session, level: .info)
|
|
|
|
// Track packet for ACK
|
|
trackPacketForAck(outerPacket)
|
|
|
|
broadcastPacket(outerPacket)
|
|
} catch {
|
|
// Failed to encrypt message
|
|
SecureLogger.log("Failed to encrypt private message \(msgID) for \(recipientPeerID): \(error)", category: SecureLogger.encryption, level: .error)
|
|
}
|
|
} // End of encryptionQueue.async
|
|
}
|
|
|
|
// MARK: - Connection Pool Management
|
|
|
|
private func findLeastRecentlyUsedPeripheral() -> String? {
|
|
var lruPeripheralID: String?
|
|
var oldestActivityTime = Date()
|
|
|
|
for (peripheralID, peripheral) in connectionPool {
|
|
// Only consider connected peripherals
|
|
guard peripheral.state == .connected else { continue }
|
|
|
|
// Skip if this peripheral has an active peer connection
|
|
if let peerID = peerIDByPeripheralID[peripheralID],
|
|
activePeers.contains(peerID) {
|
|
continue
|
|
}
|
|
|
|
// Find the least recently used peripheral based on last activity
|
|
if let lastActivity = lastActivityByPeripheralID[peripheralID],
|
|
lastActivity < oldestActivityTime {
|
|
oldestActivityTime = lastActivity
|
|
lruPeripheralID = peripheralID
|
|
} else if lastActivityByPeripheralID[peripheralID] == nil {
|
|
// If no activity recorded, it's a candidate for removal
|
|
lruPeripheralID = peripheralID
|
|
break
|
|
}
|
|
}
|
|
|
|
return lruPeripheralID
|
|
}
|
|
|
|
// Track activity for peripherals
|
|
private func updatePeripheralActivity(_ peripheralID: String) {
|
|
lastActivityByPeripheralID[peripheralID] = Date()
|
|
}
|
|
}
|