Files
bitchat/bitchat/Services/BluetoothMeshService.swift
T
jack 3070a4d307 Implement Noise Protocol Framework and peer ID rotation for enhanced security and privacy
This major update replaces the basic encryption with the Noise Protocol Framework
and adds ephemeral peer ID rotation for enhanced privacy.

Key Changes:

Security Infrastructure:
- Implemented Noise Protocol Framework (XX handshake pattern)
- End-to-end encryption with forward secrecy and identity hiding
- Session management with automatic rekey support
- Channel encryption with password-derived keys

Privacy Enhancements:
- Ephemeral peer ID rotation (5-15 minute random intervals)
- Persistent identity through public key fingerprints
- Favorites and verification persist across ID rotations
- Block list based on fingerprints, not ephemeral IDs

Core Components Added:
- NoiseEncryptionService: Main encryption service
- NoiseSession: Individual peer session management
- NoiseChannelEncryption: Password-protected channel support
- SecureIdentityStateManager: Persistent identity storage
- FingerprintView: Visual fingerprint verification UI

Bug Fixes:
- Fixed handshake storm with tie-breaker mechanism
- Fixed missing connect messages during peer rotation
- Fixed delivery ACK compression issues
- Fixed race conditions in message queue
- Fixed nickname resolution for rotated peer IDs

Testing:
- Comprehensive test suite for Noise implementation
- Security validator tests
- Channel encryption tests
- Identity persistence tests
- Rate limiter tests

Documentation:
- BRING_THE_NOISE.md: Technical implementation details
- Updated WHITEPAPER.md: Simplified and focused on core innovations
- Removed temporary debug documentation

The implementation maintains backward compatibility while significantly
improving security and privacy. All existing features (channels, private
messages, favorites, blocking) work seamlessly with the new system.
2025-07-15 13:15:31 +02:00

3823 lines
162 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
// Extension for hex encoding/decoding
extension Data {
func hexEncodedString() -> String {
if self.isEmpty {
return ""
}
return self.map { String(format: "%02x", $0) }.joined()
}
init?(hexString: String) {
let len = hexString.count / 2
var data = Data(capacity: len)
var index = hexString.startIndex
for _ in 0..<len {
let nextIndex = hexString.index(index, offsetBy: 2)
guard let byte = UInt8(String(hexString[index..<nextIndex]), radix: 16) else {
return nil
}
data.append(byte)
index = nextIndex
}
self = data
}
}
// Extension for TimeInterval to Data conversion
extension TimeInterval {
var data: Data {
var value = self
return Data(bytes: &value, count: MemoryLayout<TimeInterval>.size)
}
}
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 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
private var loggedCryptoErrors = Set<String>() // Track which peers we've logged crypto errors for
// 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
weak var delegate: BitchatDelegate?
private let noiseService = NoiseEncryptionService()
func getNoiseService() -> NoiseEncryptionService {
return noiseService
}
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 intentionalDisconnects = Set<String>() // Track peripherals we're disconnecting intentionally
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()
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 = 500 // Optimized for BLE 5.0 extended data length
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 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],
peripheral.state == .connected,
let characteristic = self.peripheralCharacteristics[peripheral] {
if let data = messages[0].toBinaryData() {
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
}
}
} 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() {
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
}
}
}
}
}
}
// 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
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
}
}
// Add new mapping
self.peerIDToFingerprint[newPeerID] = fingerprint
self.fingerprintToPeerID[fingerprint] = newPeerID
self.peerIdentityBindings[fingerprint] = binding
// Also update nickname from binding
self.peerNicknames[newPeerID] = binding.nickname
// Notify about the change if it's a rotation
if let oldID = oldPeerID {
self.notifyPeerIDChange(oldPeerID: oldID, newPeerID: newPeerID, fingerprint: fingerprint)
}
}
}
// 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
}
// 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()
// 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()
}
// 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)
}
}
// 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()
// Clear announcement tracking
announcedToPeers.removeAll()
// Clear last seen timestamps
peerLastSeenTimestamps.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] = [], channel: String? = nil, 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,
channel: channel
)
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 sendChannelLeaveNotification(_ channel: String) {
messageQueue.async { [weak self] in
guard let self = self else { return }
// Create a leave packet with channel hashtag as payload
let packet = BitchatPacket(
type: MessageType.leave.rawValue,
senderID: Data(hexString: self.myPeerID) ?? Data(),
recipientID: SpecialRecipients.broadcast, // Broadcast to all
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(channel.utf8), // Channel hashtag as payload
signature: nil,
ttl: 3 // Short TTL for leave notifications
)
self.broadcastPacket(packet)
}
}
func sendDeliveryAck(_ ack: DeliveryAck, to recipientID: String) {
messageQueue.async { [weak self] in
guard let self = self else { return }
// Encode the ACK
guard let ackData = ack.encode() else {
return
}
// 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 {
SecurityLogger.log("Failed to encrypt delivery ACK via Noise for \(recipientID): \(error)",
category: SecurityLogger.encryption, level: .error)
}
} else {
// Fall back to legacy encryption
let encryptedPayload: Data
do {
encryptedPayload = try self.noiseService.encrypt(ackData, for: recipientID)
} catch {
SecurityLogger.log("Failed to encrypt delivery ACK for \(recipientID): \(error)",
category: SecurityLogger.encryption, level: .error)
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)
}
}
}
func sendReadReceipt(_ receipt: ReadReceipt, to recipientID: String) {
messageQueue.async { [weak self] in
guard let self = self else { return }
// Encode the receipt
guard let receiptData = receipt.encode() else {
return
}
// 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
)
self.broadcastPacket(outerPacket)
}
} catch {
SecurityLogger.log("Failed to encrypt read receipt via Noise for \(recipientID): \(error)",
category: SecurityLogger.encryption, level: .error)
}
} else {
// Fall back to legacy encryption
let encryptedPayload: Data
do {
encryptedPayload = try self.noiseService.encrypt(receiptData, for: recipientID)
} catch {
return
}
// Create read receipt packet with direct routing to original sender
let packet = BitchatPacket(
type: MessageType.readReceipt.rawValue,
senderID: Data(hexString: self.myPeerID) ?? Data(),
recipientID: Data(hexString: recipientID) ?? Data(),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encryptedPayload,
signature: nil, // Read receipts don't need signatures
ttl: 3 // Limited TTL for receipts
)
// Send immediately without delay
self.broadcastPacket(packet)
}
}
}
func announcePasswordProtectedChannel(_ channel: String, isProtected: Bool = true, creatorID: String? = nil, keyCommitment: String? = nil) {
messageQueue.async { [weak self] in
guard let self = self else { return }
// Payload format: channel|isProtected|creatorID|keyCommitment
let protectedFlag = isProtected ? "1" : "0"
let creator = creatorID ?? self.myPeerID
let commitment = keyCommitment ?? ""
let payload = "\(channel)|\(protectedFlag)|\(creator)|\(commitment)"
let packet = BitchatPacket(
type: MessageType.channelAnnounce.rawValue,
senderID: Data(hexString: self.myPeerID) ?? Data(),
recipientID: SpecialRecipients.broadcast,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(payload.utf8),
signature: nil,
ttl: 5 // Allow wider propagation for channel announcements
)
self.broadcastPacket(packet)
}
}
func sendChannelMetadata(_ metadata: ChannelMetadata) {
messageQueue.async { [weak self] in
guard let self = self else { return }
guard let metadataData = metadata.encode() else { return }
let packet = BitchatPacket(
type: MessageType.channelMetadata.rawValue,
senderID: Data(hexString: self.myPeerID) ?? Data(),
recipientID: SpecialRecipients.broadcast,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: metadataData,
signature: nil,
ttl: 5 // Allow wider propagation for channel metadata
)
self.broadcastPacket(packet)
}
}
func sendChannelRetentionAnnouncement(_ channel: String, enabled: Bool) {
messageQueue.async { [weak self] in
guard let self = self else { return }
// Payload format: channel|enabled|creatorID
let enabledFlag = enabled ? "1" : "0"
let payload = "\(channel)|\(enabledFlag)|\(self.myPeerID)"
let packet = BitchatPacket(
type: MessageType.channelRetention.rawValue,
senderID: Data(hexString: self.myPeerID) ?? Data(),
recipientID: SpecialRecipients.broadcast,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(payload.utf8),
signature: nil,
ttl: 5 // Allow wider propagation for channel announcements
)
self.broadcastPacket(packet)
}
}
func sendEncryptedChannelMessage(_ content: String, mentions: [String], channel: String, channelKey: SymmetricKey, messageID: String? = nil, timestamp: Date? = nil) {
messageQueue.async { [weak self] in
guard let self = self else { return }
let nickname = self.delegate as? ChatViewModel
let senderNick = nickname?.nickname ?? self.myPeerID
// Encrypt the content
guard let contentData = content.data(using: .utf8) else { return }
// Debug logging removed
do {
let sealedBox = try AES.GCM.seal(contentData, using: channelKey)
guard let encryptedData = sealedBox.combined else {
// Encryption failed to produce combined data
return
}
// Create message with encrypted content
let message = BitchatMessage(
id: messageID,
sender: senderNick,
content: "", // Empty placeholder since actual content is encrypted
timestamp: timestamp ?? Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: self.myPeerID,
mentions: mentions.isEmpty ? nil : mentions,
channel: channel,
encryptedContent: encryptedData,
isEncrypted: true
)
if let messageData = message.toBinaryPayload() {
let packet = BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: self.myPeerID) ?? Data(),
recipientID: SpecialRecipients.broadcast,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: messageData,
signature: nil,
ttl: self.adaptiveTTL
)
self.broadcastPacket(packet)
}
} catch {
}
}
}
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 }) {
let writeType: CBCharacteristicWriteType = characteristic.properties.contains(.write) ? .withResponse : .withoutResponse
peripheral.writeValue(data, for: characteristic, type: writeType)
} 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] {
// Create a copy with default values for connected peers without RSSI
var rssiWithDefaults = peerRSSI
// For any active peer without RSSI, assume decent signal (-60)
// This handles centrals where we can't read RSSI
for peerID in activePeers {
if rssiWithDefaults[peerID] == nil {
rssiWithDefaults[peerID] = NSNumber(value: -60) // Good signal default
}
}
return rssiWithDefaults
}
// Emergency disconnect for panic situations
func emergencyDisconnectAll() {
// Stop advertising immediately
if peripheralManager?.isAdvertising == true {
peripheralManager?.stopAdvertising()
}
// Stop scanning
centralManager?.stopScan()
scanDutyCycleTimer?.invalidate()
scanDutyCycleTimer = nil
// Disconnect all peripherals
for (_, peripheral) in connectedPeripherals {
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()
processedMessages.removeAll()
incomingFragments.removeAll()
fragmentMetadata.removeAll()
// Clear persistent identity
noiseService.clearPersistentIdentity()
}
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
}
activePeers.remove(peerID)
peerLastSeenTimestamps.remove(peerID)
// Clean up all associated data
connectedPeripherals.removeValue(forKey: peerID)
peerRSSI.removeValue(forKey: peerID)
announcedPeers.remove(peerID)
announcedToPeers.remove(peerID)
peerNicknames.removeValue(forKey: peerID)
actuallyRemoved.append(peerID)
// Removed stale peer
}
return actuallyRemoved
}
if !peersToRemove.isEmpty {
notifyPeerListUpdate()
}
}
// 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 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) {
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
}
}
}
// 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,
channel: message.channel,
isPrivate: message.isPrivate,
recipientPeerID: nil,
recipientNickname: message.recipientNickname,
channelKey: nil,
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 (_, peripheral) in connectedPeripherals {
if let characteristic = peripheralCharacteristics[peripheral] {
// Check if peripheral is connected before writing
if peripheral.state == .connected {
// Use withoutResponse for faster transmission when possible
// Only use withResponse for critical messages or when MTU negotiation needed
let writeType: CBCharacteristicWriteType = data.count > 512 ? .withResponse : .withoutResponse
// Additional safety check for characteristic properties
if characteristic.properties.contains(.write) ||
characteristic.properties.contains(.writeWithoutResponse) {
peripheral.writeValue(data, for: characteristic, type: writeType)
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) {
// For encrypted channel messages, we need to preserve the channel key
var channelKeyData: Data? = nil
if let channel = message.channel, message.isEncrypted {
// This is an encrypted channel message
if let viewModel = delegate as? ChatViewModel,
let channelKey = viewModel.channelKeys[channel] {
channelKeyData = channelKey.withUnsafeBytes { Data($0) }
}
}
MessageRetryService.shared.addMessageForRetry(
content: message.content,
mentions: message.mentions,
channel: message.channel,
isPrivate: message.isPrivate,
recipientPeerID: nil,
recipientNickname: message.recipientNickname,
channelKey: channelKeyData,
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 }
// 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
let senderID = packet.senderID.hexEncodedString()
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
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) {
return
} else {
// False positive from Bloom filter
}
}
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
}
// Handle encrypted channel messages
var finalContent = message.content
if message.isEncrypted, let channel = message.channel, let encryptedData = message.encryptedContent {
// Try to decrypt the content
if let decryptedContent = self.delegate?.decryptChannelMessage(encryptedData, channel: channel) {
finalContent = decryptedContent
} else {
// Unable to decrypt - show placeholder
finalContent = "[Encrypted message - password required]"
}
}
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,
channel: message.channel,
encryptedContent: message.encryptedContent,
isEncrypted: message.isEncrypted
)
// 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 channel messages if we're mentioned or it's a small channel
let viewModel = self.delegate as? ChatViewModel
let myNickname = viewModel?.nickname ?? self.myPeerID
if let _ = message.channel,
let mentions = message.mentions,
(mentions.contains(myNickname) || self.activePeers.count < 10) {
if let ack = DeliveryTracker.shared.generateAck(
for: messageWithPeerID,
myPeerID: self.myPeerID,
myNickname: myNickname,
hopCount: UInt8(self.maxTTL - packet.ttl)
) {
self.sendDeliveryAck(ack, to: senderID)
}
}
}
// 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 {
// 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 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,
channel: message.channel,
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 {
SecurityLogger.log("Failed to parse private message from binary payload, payload size: \(decryptedPayload.count)",
category: SecurityLogger.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
SecurityLogger.log("Message packet with no recipient ID from \(senderID)",
category: SecurityLogger.encryption, 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)
// 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
// 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 {
SecurityLogger.log("Blocked self from being added to activePeers", category: SecurityLogger.noise, level: .error)
return false
}
let result = self.activePeers.insert(senderID).inserted
return result
}
if wasInserted {
// Added peer \(senderID) (\(nickname)) to active peers
}
// 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)
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()
// Check if payload contains a channel hashtag
if let channel = String(data: packet.payload, encoding: .utf8),
channel.hasPrefix("#") {
// Channel leave notification
DispatchQueue.main.async {
self.delegate?.didReceiveChannelLeave(channel, from: senderID)
}
// Relay if TTL > 0
if packet.ttl > 1 {
var relayPacket = packet
relayPacket.ttl -= 1
self.broadcastPacket(relayPacket)
}
} else {
// 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) {
self.activePeers.remove(senderID)
self.peerNicknames.removeValue(forKey: senderID)
}
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 .channelAnnounce:
if let payloadStr = String(data: packet.payload, encoding: .utf8) {
// Parse payload: channel|isProtected|creatorID|keyCommitment
let components = payloadStr.split(separator: "|").map(String.init)
if components.count >= 3 {
let channel = components[0]
let isProtected = components[1] == "1"
let creatorID = components[2]
let keyCommitment = components.count >= 4 ? components[3] : nil
DispatchQueue.main.async {
self.delegate?.didReceivePasswordProtectedChannelAnnouncement(channel, isProtected: isProtected, creatorID: creatorID, keyCommitment: keyCommitment)
}
// Relay announcement
if packet.ttl > 1 {
var relayPacket = packet
relayPacket.ttl -= 1
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.decode(from: packet.payload) {
// Already decrypted - process directly
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.decode(from: decryptedData) {
// Process the ACK
DeliveryTracker.shared.processDeliveryAck(ack)
// Notify delegate
DispatchQueue.main.async {
self.delegate?.didReceiveDeliveryAck(ack)
}
}
} catch {
SecurityLogger.log("Failed to decrypt delivery ACK from \(senderID): \(error)",
category: SecurityLogger.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 .channelRetention:
if let payloadStr = String(data: packet.payload, encoding: .utf8) {
// Parse payload: channel|enabled|creatorID
let components = payloadStr.split(separator: "|").map(String.init)
if components.count >= 3 {
let channel = components[0]
let enabled = components[1] == "1"
let creatorID = components[2]
DispatchQueue.main.async {
self.delegate?.didReceiveChannelRetentionAnnouncement(channel, enabled: enabled, creatorID: creatorID)
}
// Relay announcement
if packet.ttl > 1 {
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()
// Check if payload is already decrypted (came through Noise)
if let receipt = ReadReceipt.decode(from: packet.payload) {
// Already decrypted - process directly
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.decode(from: decryptedData) {
// Process the read receipt
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()
if senderID != myPeerID && !isPeerIDOurs(senderID) {
// Decode the announcement
guard let announcement = NoiseIdentityAnnouncement.decode(from: packet.payload) else {
return
}
// Verify the signature (currently always returns true for compatibility)
let bindingData = announcement.peerID.data(using: .utf8)! + announcement.publicKey + announcement.timestamp.timeIntervalSince1970.data
if !noiseService.verifySignature(announcement.signature, for: bindingData, publicKey: announcement.publicKey) {
// Log but don't reject - signature verification is temporarily disabled
SecurityLogger.log("Signature verification skipped for \(senderID)", category: SecurityLogger.noise, level: .debug)
}
// Calculate fingerprint from public key
let hash = SHA256.hash(data: announcement.publicKey)
let fingerprint = hash.map { String(format: "%02x", $0) }.joined()
// Create the binding
let binding = PeerIdentityBinding(
currentPeerID: announcement.peerID,
fingerprint: fingerprint,
publicKey: announcement.publicKey,
nickname: announcement.nickname,
bindingTimestamp: announcement.timestamp,
signature: announcement.signature
)
// Update our mappings
updatePeerBinding(announcement.peerID, fingerprint: fingerprint, binding: binding)
// 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
if myPeerID < announcement.peerID {
initiateNoiseHandshake(with: announcement.peerID)
} else {
// Send our identity back so they know we're ready
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()
// Check if this handshake is for us or broadcast
if let recipientID = packet.recipientID,
!isPeerIDOurs(recipientID.hexEncodedString()) {
// Not for us, ignore
return
}
if !isPeerIDOurs(senderID) {
handleNoiseHandshakeMessage(from: senderID, message: packet.payload, isInitiation: true)
}
case .noiseHandshakeResp:
// Handle Noise handshake response
let senderID = packet.senderID.hexEncodedString()
// Check if this handshake response is for us
if let recipientID = packet.recipientID,
!isPeerIDOurs(recipientID.hexEncodedString()) {
// Not for us, ignore
return
}
if !isPeerIDOurs(senderID) {
handleNoiseHandshakeMessage(from: senderID, message: packet.payload, isInitiation: false)
}
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 .channelKeyVerifyRequest:
// Handle channel key verification request
let senderID = packet.senderID.hexEncodedString()
if !isPeerIDOurs(senderID) {
handleChannelKeyVerifyRequest(from: senderID, data: packet.payload)
}
case .channelKeyVerifyResponse:
// Handle channel key verification response
let senderID = packet.senderID.hexEncodedString()
if !isPeerIDOurs(senderID) {
handleChannelKeyVerifyResponse(from: senderID, data: packet.payload)
}
case .channelPasswordUpdate:
// Handle channel password update from owner
let senderID = packet.senderID.hexEncodedString()
if !isPeerIDOurs(senderID) {
handleChannelPasswordUpdate(from: senderID, data: packet.payload)
}
case .channelMetadata:
// Handle channel metadata announcement
let senderID = packet.senderID.hexEncodedString()
if !isPeerIDOurs(senderID) {
handleChannelMetadata(from: senderID, data: packet.payload)
}
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
}
peerRSSI[peerID] = RSSI
// Discovered potential peer
SecurityLogger.log("Discovered peer with ID: \(peerID), self ID: \(myPeerID)", category: SecurityLogger.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) {
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) {
peripheral.delegate = self
peripheral.discoverServices([BluetoothMeshService.serviceUUID])
// Store peripheral by its system ID temporarily until we get the real peer ID
let tempID = peripheral.identifier.uuidString
connectedPeripherals[tempID] = peripheral
// Connected to peripheral
// 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)
// Don't process this disconnect further
return
}
// 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)
}
// 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)
// 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 {
}
announcedPeers.remove(peerID)
announcedToPeers.remove(peerID)
} else {
}
// Clear cached messages tracking for this peer to allow re-sending if they reconnect
cachedMessagesSentToPeer.remove(peerID)
// Peer disconnected
return (removed, peerNicknames[peerID])
}
if removed {
DispatchQueue.main.async {
self.delegate?.didDisconnectFromPeer(peerID)
}
}
self.notifyPeerListUpdate()
}
// 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 {
SecurityLogger.log("Error discovering services: \(error)",
category: SecurityLogger.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 {
SecurityLogger.log("Error discovering characteristics: \(error)",
category: SecurityLogger.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)
// Send Noise identity announcement once
self.sendNoiseIdentityAnnounce()
// Send announce packet after a short delay to avoid overwhelming the connection
// 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() {
let writeType: CBCharacteristicWriteType = characteristic.properties.contains(.write) ? .withResponse : .withoutResponse
peripheral.writeValue(data, for: characteristic, type: writeType)
}
}
}
}
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard let data = characteristic.value else {
return
}
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 }
// 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
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
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 {
SecurityLogger.log("Preventing self from being added as peer (peripheral manager)", category: SecurityLogger.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,
let pendingMessages = self.pendingPrivateMessages[peerID] else { return }
// Clear pending messages for this peer
self.pendingPrivateMessages.removeValue(forKey: peerID)
// Send each pending message
for (content, recipientNickname, messageID) in pendingMessages {
// 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 initiateNoiseHandshake(with peerID: String) {
// Use noiseService directly
SecurityLogger.log("Initiating Noise handshake with \(peerID)", category: SecurityLogger.noise, level: .info)
// Check if we've recently tried to handshake with this peer
if let lastAttempt = handshakeAttemptTimes[peerID],
Date().timeIntervalSince(lastAttempt) < handshakeTimeout {
SecurityLogger.log("Skipping handshake with \(peerID) - too recent", category: SecurityLogger.noise, level: .debug)
return
}
handshakeAttemptTimes[peerID] = Date()
do {
// Generate handshake initiation message
let handshakeData = try noiseService.initiateHandshake(with: peerID)
// 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: 3 // Moderate TTL for handshakes
)
// Use broadcastPacket instead of sendPacket to ensure it goes through the mesh
broadcastPacket(packet)
} catch NoiseSessionError.alreadyEstablished {
// Session already established, no need to handshake
} catch {
// Failed to initiate handshake silently
}
}
private func handleNoiseHandshakeMessage(from peerID: String, message: Data, isInitiation: Bool) {
// Use noiseService directly
do {
// Process handshake message
if let response = try noiseService.processHandshakeMessage(from: peerID, message: message) {
// 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: 3
)
// Use broadcastPacket instead of sendPacket to ensure it goes through the mesh
broadcastPacket(packet)
}
// Check if handshake is complete
if noiseService.hasEstablishedSession(with: peerID) {
// Unlock rotation now that handshake is complete
unlockRotation()
// Session established successfully
// Clear handshake attempt time on success
handshakeAttemptTimes.removeValue(forKey: peerID)
// 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
} catch {
// Handshake failed
}
}
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
let decryptedData = try noiseService.decrypt(encryptedData, from: peerID)
// 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
if let ack = DeliveryAck.decode(from: ackData) {
// Process the ACK
DeliveryTracker.shared.processDeliveryAck(ack)
// Notify delegate
DispatchQueue.main.async {
self.delegate?.didReceiveDeliveryAck(ack)
}
return
}
}
}
// Try to parse as a full inner packet (for backward compatibility and other message types)
if let innerPacket = BitchatPacket.from(decryptedData) {
// 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)
}
} catch {
// Failed to decrypt - might need to re-establish session
if !noiseService.hasEstablishedSession(with: peerID) {
initiateNoiseHandshake(with: peerID)
}
}
}
private func handleChannelKeyVerifyRequest(from peerID: String, data: Data) {
guard let request = ChannelKeyVerifyRequest.decode(from: data) else { return }
// Forward to delegate (ChatViewModel) to handle
DispatchQueue.main.async { [weak self] in
self?.delegate?.didReceiveChannelKeyVerifyRequest(request, from: peerID)
}
}
private func handleChannelKeyVerifyResponse(from peerID: String, data: Data) {
guard let response = ChannelKeyVerifyResponse.decode(from: data) else { return }
// Forward to delegate (ChatViewModel) to handle
DispatchQueue.main.async { [weak self] in
self?.delegate?.didReceiveChannelKeyVerifyResponse(response, from: peerID)
}
}
private func handleChannelPasswordUpdate(from peerID: String, data: Data) {
// First decrypt the data using Noise session
// Use noiseService directly
do {
// Decrypt the outer message
let decryptedData = try noiseService.decrypt(data, from: peerID)
// Parse the password update
guard let update = ChannelPasswordUpdate.decode(from: decryptedData) else { return }
// Forward to delegate (ChatViewModel) to handle
DispatchQueue.main.async { [weak self] in
self?.delegate?.didReceiveChannelPasswordUpdate(update, from: peerID)
}
} catch {
}
}
private func handleChannelMetadata(from peerID: String, data: Data) {
// Channel metadata is broadcast unencrypted (like channel announcements)
guard let metadata = ChannelMetadata.decode(from: data) else { return }
// Forward to delegate (ChatViewModel) to handle
DispatchQueue.main.async { [weak self] in
self?.delegate?.didReceiveChannelMetadata(metadata, from: peerID)
}
}
func sendChannelKeyVerifyRequest(_ request: ChannelKeyVerifyRequest, to peers: [String]) {
guard let requestData = request.encode() else { return }
// Send to each peer
for peerID in peers {
let packet = BitchatPacket(
type: MessageType.channelKeyVerifyRequest.rawValue,
senderID: Data(myPeerID.utf8),
recipientID: Data(peerID.utf8),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: requestData,
signature: nil,
ttl: 3 // Limited TTL for verification requests
)
broadcastPacket(packet)
}
}
func sendChannelKeyVerifyResponse(_ response: ChannelKeyVerifyResponse, to peerID: String) {
guard let responseData = response.encode() else { return }
let packet = BitchatPacket(
type: MessageType.channelKeyVerifyResponse.rawValue,
senderID: Data(myPeerID.utf8),
recipientID: Data(peerID.utf8),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: responseData,
signature: nil,
ttl: 3 // Limited TTL for responses
)
broadcastPacket(packet)
}
func sendChannelPasswordUpdate(_ password: String, channel: String, newCommitment: String, to peerID: String) {
// Use noiseService directly
// Check if we have a Noise session with this peer
if !noiseService.hasEstablishedSession(with: peerID) {
return
}
// Get our fingerprint
let myFingerprint = noiseService.getIdentityFingerprint()
// Create password update with encrypted password field
let update = ChannelPasswordUpdate(
channel: channel,
ownerID: myPeerID, // Keep for backward compatibility
ownerFingerprint: myFingerprint,
encryptedPassword: Data(password.utf8), // Will be encrypted as whole message
newKeyCommitment: newCommitment
)
guard let updateData = update.encode() else { return }
do {
// Encrypt the entire update message
let encryptedData = try noiseService.encrypt(updateData, for: peerID)
let packet = BitchatPacket(
type: MessageType.channelPasswordUpdate.rawValue,
senderID: Data(myPeerID.utf8),
recipientID: Data(peerID.utf8),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encryptedData,
signature: nil,
ttl: 3 // Limited TTL for password updates
)
broadcastPacket(packet)
} catch {
}
}
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
let staticKey = noiseService.getStaticPublicKeyData()
// Get nickname from delegate
let nickname = (delegate as? ChatViewModel)?.nickname ?? "Anonymous"
// Create the binding data to sign
let bindingData = myPeerID.data(using: .utf8)! + staticKey + now.timeIntervalSince1970.data
// Sign the binding with our private key
let signature = noiseService.signData(bindingData) ?? Data()
// Create the identity announcement
let announcement = NoiseIdentityAnnouncement(
peerID: myPeerID,
publicKey: staticKey,
nickname: nickname,
previousPeerID: previousPeerID,
signature: signature
)
// Encode the announcement
guard let announcementData = announcement.encode() else {
return
}
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
)
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 noiseService directly
// Check if we have a Noise session with this peer
if !noiseService.hasEstablishedSession(with: recipientPeerID) {
SecurityLogger.log("No Noise session with \(recipientPeerID), initiating handshake", category: SecurityLogger.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))
SecurityLogger.log("Queued private message for \(recipientPeerID), \(self.pendingPrivateMessages[recipientPeerID]?.count ?? 0) messages pending", category: SecurityLogger.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 = collectionsQueue.sync(flags: .barrier) {
if recentlySentMessages.contains(sendKey) {
return true
}
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
let encryptedData = try noiseService.encrypt(innerData, for: recipientPeerID)
// 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
)
broadcastPacket(outerPacket)
} catch {
// Failed to encrypt message
}
}
}