Fix iOS BLE mesh authentication bypass chain in BLEService

- Bind sender IDs to BLE connection UUIDs for peripherals and centrals to prevent spoofing
- Enforce explicit RSR request/response validation and remove legacy TTL==0 RSR path
- Remove TTL==0 unconditional acceptance for messages and file transfers
- Ensure gossip sync caching only occurs after a packet is accepted
- Preserve self‑sync TTL==0 dedup exception without weakening authentication
This commit is contained in:
Ovi
2026-01-27 15:14:21 +00:00
parent 1e5a52f39f
commit af7a664685
+48 -41
View File
@@ -7,7 +7,6 @@ import CryptoKit
import UIKit import UIKit
#endif #endif
/// BLEService Bluetooth Mesh Transport /// BLEService Bluetooth Mesh Transport
/// - Emits events exclusively via `BitchatDelegate` for UI. /// - Emits events exclusively via `BitchatDelegate` for UI.
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`). /// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
@@ -820,21 +819,22 @@ final class BLEService: NSObject {
} }
} }
private func validatePacket(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { private enum ConnectionSource {
case peripheral(String)
case central(String)
case unknown
}
private func validatePacket(_ packet: BitchatPacket, from peerID: PeerID, connectionSource: ConnectionSource = .unknown) -> Bool {
let currentTime = UInt64(Date().timeIntervalSince1970 * 1000) let currentTime = UInt64(Date().timeIntervalSince1970 * 1000)
let messageType = MessageType(rawValue: packet.type) let messageType = MessageType(rawValue: packet.type)
// 1. Timestamp Validation (Skipped for valid RSR packets)
let isRSR = packet.isRSR let isRSR = packet.isRSR
// Treat TTL=0 as legacy RSR if not REQUEST_SYNC
// (Legacy clients send responses with TTL=0 but no flag)
let isLegacyRSR = packet.ttl == 0 && messageType != .requestSync
var skipTimestampCheck = false var skipTimestampCheck = false
if isRSR || isLegacyRSR { if isRSR {
// We check both explicit RSR flag AND legacy TTL=0 packets
if requestSyncManager.isValidResponse(from: peerID, isRSR: true) { if requestSyncManager.isValidResponse(from: peerID, isRSR: true) {
SecureLogger.debug("Valid RSR packet (legacy=\(isLegacyRSR)) from \(peerID.id.prefix(8))… - skipping timestamp check", category: .security) SecureLogger.debug("Valid RSR packet from \(peerID.id.prefix(8))… - skipping timestamp check", category: .security)
skipTimestampCheck = true skipTimestampCheck = true
} else { } else {
SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security) SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security)
@@ -843,8 +843,7 @@ final class BLEService: NSObject {
} }
if !skipTimestampCheck { if !skipTimestampCheck {
// Enforce timestamp check for normal packets (2 minutes skew) let maxSkew: UInt64 = 120_000
let maxSkew: UInt64 = 120_000 // 2 minutes in ms
let packetTime = packet.timestamp let packetTime = packet.timestamp
let skew = (packetTime > currentTime) ? (packetTime - currentTime) : (currentTime - packetTime) let skew = (packetTime > currentTime) ? (packetTime - currentTime) : (currentTime - packetTime)
@@ -1165,13 +1164,6 @@ final class BLEService: NSObject {
break break
} }
} }
if !accepted && packet.ttl == 0 {
accepted = true
senderNickname = "anon" + String(peerID.id.prefix(4))
}
} else if packet.ttl == 0 {
accepted = true
senderNickname = "anon" + String(peerID.id.prefix(4))
} }
guard accepted else { guard accepted else {
@@ -2199,9 +2191,21 @@ extension BLEService: CBPeripheralDelegate {
SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session) SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session)
continue continue
} }
// Validate packet (Timestamp/RSR) before processing
let senderID = PeerID(hexData: packet.senderID) let claimedSenderID = PeerID(hexData: packet.senderID)
if !validatePacket(packet, from: senderID) {
let trustedSenderID: PeerID?
if let knownPeerID = state.peerID {
if knownPeerID != claimedSenderID {
SecureLogger.warning("🚫 SECURITY: Sender ID spoofing attempt detected! Peripheral \(peripheralUUID.prefix(8))… claimed to be \(claimedSenderID.id.prefix(8))… but is bound to \(knownPeerID.id.prefix(8))", category: .security)
continue
}
trustedSenderID = knownPeerID
} else {
trustedSenderID = nil
}
if !validatePacket(packet, from: trustedSenderID ?? claimedSenderID, connectionSource: .peripheral(peripheralUUID)) {
continue continue
} }
processNotificationPacket(packet, from: peripheral, peripheralUUID: peripheralUUID) processNotificationPacket(packet, from: peripheral, peripheralUUID: peripheralUUID)
@@ -2618,22 +2622,33 @@ extension BLEService: CBPeripheralManagerDelegate {
if let packet = BinaryProtocol.decode(combined) { if let packet = BinaryProtocol.decode(combined) {
// Clear buffer on success // Clear buffer on success
pendingWriteBuffers.removeValue(forKey: centralUUID) pendingWriteBuffers.removeValue(forKey: centralUUID)
let senderID = PeerID(hexData: packet.senderID)
// Validate packet (Timestamp/RSR) let claimedSenderID = PeerID(hexData: packet.senderID)
if !validatePacket(packet, from: senderID) {
let trustedSenderID: PeerID?
if let knownPeerID = centralToPeerID[centralUUID] {
if knownPeerID != claimedSenderID {
SecureLogger.warning("🚫 SECURITY: Sender ID spoofing attempt detected! Central \(centralUUID.prefix(8))… claimed to be \(claimedSenderID.id.prefix(8))… but is bound to \(knownPeerID.id.prefix(8))", category: .security)
continue
}
trustedSenderID = knownPeerID
} else {
trustedSenderID = nil
}
if !validatePacket(packet, from: trustedSenderID ?? claimedSenderID, connectionSource: .central(centralUUID)) {
continue continue
} }
if packet.type != MessageType.announce.rawValue { if packet.type != MessageType.announce.rawValue {
SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(senderID)", category: .session) SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(claimedSenderID)", category: .session)
} }
if !subscribedCentrals.contains(sorted[0].central) { if !subscribedCentrals.contains(sorted[0].central) {
subscribedCentrals.append(sorted[0].central) subscribedCentrals.append(sorted[0].central)
} }
if packet.type == MessageType.announce.rawValue { if packet.type == MessageType.announce.rawValue {
if packet.ttl == messageTTL { if packet.ttl == messageTTL {
centralToPeerID[centralUUID] = senderID centralToPeerID[centralUUID] = claimedSenderID
refreshLocalTopology() refreshLocalTopology()
} }
// Record ingress link for last-hop suppression then process // Record ingress link for last-hop suppression then process
@@ -2641,14 +2656,14 @@ extension BLEService: CBPeripheralManagerDelegate {
collectionsQueue.async(flags: .barrier) { [weak self] in collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date()) self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
} }
handleReceivedPacket(packet, from: senderID) handleReceivedPacket(packet, from: claimedSenderID)
} else { } else {
// Record ingress link for last-hop suppression then process // Record ingress link for last-hop suppression then process
let msgID = makeMessageID(for: packet) let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date()) self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
} }
handleReceivedPacket(packet, from: senderID) handleReceivedPacket(packet, from: claimedSenderID)
} }
} else { } else {
// If buffer grows suspiciously large, reset to avoid memory leak // If buffer grows suspiciously large, reset to avoid memory leak
@@ -4057,16 +4072,13 @@ extension BLEService {
} }
} }
} }
// If still not accepted and this is a sync-returned packet (TTL==0),
// accept with a generic nickname so history can be restored even for
// peers we haven't verified yet.
if !accepted && packet.ttl == 0 {
accepted = true
senderNickname = "anon" + String(peerID.id.prefix(4))
}
} }
// Track broadcast messages for sync (treat nil or 0xFF..0xFF as broadcast) guard accepted else {
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return
}
let isBroadcastRecipient: Bool = { let isBroadcastRecipient: Bool = {
guard let r = packet.recipientID else { return true } guard let r = packet.recipientID else { return true }
return r.count == 8 && r.allSatisfy { $0 == 0xFF } return r.count == 8 && r.allSatisfy { $0 == 0xFF }
@@ -4075,11 +4087,6 @@ extension BLEService {
gossipSyncManager?.onPublicPacketSeen(packet) gossipSyncManager?.onPublicPacketSeen(packet)
} }
guard accepted else {
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return
}
guard let content = String(data: packet.payload, encoding: .utf8) else { guard let content = String(data: packet.payload, encoding: .utf8) else {
SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session) SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session)
return return