Skip to content

Commit 74cf0d8

Browse files
0x0v1jackjackbitsclaude
authored
Fix iOS BLE mesh authentication issues in BLEService (#998)
* 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 * fix: toctou in boundPeerID identified by codex * fix: Remove unused variable and bump version to 1.5.1 - Remove unused messageType variable (compiler warning fix) - Bump marketing version to 1.5.1 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com> Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b4b6aa5 commit 74cf0d8

File tree

2 files changed

+72
-50
lines changed

2 files changed

+72
-50
lines changed

Configs/Release.xcconfig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
MARKETING_VERSION = 1.5.0
1+
MARKETING_VERSION = 1.5.1
22
CURRENT_PROJECT_VERSION = 1
33

44
IPHONEOS_DEPLOYMENT_TARGET = 16.0

bitchat/Services/BLE/BLEService.swift

Lines changed: 71 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import CryptoKit
77
import UIKit
88
#endif
99

10-
1110
/// BLEService — Bluetooth Mesh Transport
1211
/// - Emits events exclusively via `BitchatDelegate` for UI.
1312
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
@@ -820,40 +819,39 @@ final class BLEService: NSObject {
820819
}
821820
}
822821

823-
private func validatePacket(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
822+
private enum ConnectionSource {
823+
case peripheral(String)
824+
case central(String)
825+
case unknown
826+
}
827+
828+
private func validatePacket(_ packet: BitchatPacket, from peerID: PeerID, connectionSource: ConnectionSource = .unknown) -> Bool {
824829
let currentTime = UInt64(Date().timeIntervalSince1970 * 1000)
825-
let messageType = MessageType(rawValue: packet.type)
826-
827-
// 1. Timestamp Validation (Skipped for valid RSR packets)
830+
828831
let isRSR = packet.isRSR
829-
// Treat TTL=0 as legacy RSR if not REQUEST_SYNC
830-
// (Legacy clients send responses with TTL=0 but no flag)
831-
let isLegacyRSR = packet.ttl == 0 && messageType != .requestSync
832832
var skipTimestampCheck = false
833-
834-
if isRSR || isLegacyRSR {
835-
// We check both explicit RSR flag AND legacy TTL=0 packets
833+
834+
if isRSR {
836835
if requestSyncManager.isValidResponse(from: peerID, isRSR: true) {
837-
SecureLogger.debug("Valid RSR packet (legacy=\(isLegacyRSR)) from \(peerID.id.prefix(8))… - skipping timestamp check", category: .security)
836+
SecureLogger.debug("Valid RSR packet from \(peerID.id.prefix(8))… - skipping timestamp check", category: .security)
838837
skipTimestampCheck = true
839838
} else {
840839
SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security)
841840
return false
842841
}
843842
}
844-
843+
845844
if !skipTimestampCheck {
846-
// Enforce timestamp check for normal packets (2 minutes skew)
847-
let maxSkew: UInt64 = 120_000 // 2 minutes in ms
845+
let maxSkew: UInt64 = 120_000
848846
let packetTime = packet.timestamp
849847
let skew = (packetTime > currentTime) ? (packetTime - currentTime) : (currentTime - packetTime)
850-
848+
851849
if skew > maxSkew {
852850
SecureLogger.warning("Packet timestamp skewed by \(skew)ms (max \(maxSkew)ms) from \(peerID.id.prefix(8))", category: .security)
853851
return false
854852
}
855853
}
856-
854+
857855
return true
858856
}
859857

@@ -1165,13 +1163,6 @@ final class BLEService: NSObject {
11651163
break
11661164
}
11671165
}
1168-
if !accepted && packet.ttl == 0 {
1169-
accepted = true
1170-
senderNickname = "anon" + String(peerID.id.prefix(4))
1171-
}
1172-
} else if packet.ttl == 0 {
1173-
accepted = true
1174-
senderNickname = "anon" + String(peerID.id.prefix(4))
11751166
}
11761167

11771168
guard accepted else {
@@ -2192,18 +2183,46 @@ extension BLEService: CBPeripheralDelegate {
21922183
if result.reset {
21932184
SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session)
21942185
}
2186+
2187+
// Codex review identified TOCTOU in this patch.
2188+
// Enforce per-link sender binding immediately within the same notification batch.
2189+
// NOTE: `processNotificationPacket` may bind `peripherals[peripheralUUID].peerID` when an announce
2190+
// is processed, but `state` above is a snapshot. Track a local binding that we update as soon as
2191+
// we see a binding-eligible announce so subsequent frames can't spoof a different sender.
2192+
var boundPeerID: PeerID? = state.peerID
21952193

21962194
for frame in result.frames {
21972195
guard let packet = BinaryProtocol.decode(frame) else {
21982196
let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
21992197
SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session)
22002198
continue
22012199
}
2202-
// Validate packet (Timestamp/RSR) before processing
2203-
let senderID = PeerID(hexData: packet.senderID)
2204-
if !validatePacket(packet, from: senderID) {
2200+
2201+
let claimedSenderID = PeerID(hexData: packet.senderID)
2202+
2203+
let trustedSenderID: PeerID?
2204+
if let knownPeerID = boundPeerID {
2205+
if knownPeerID != claimedSenderID {
2206+
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)
2207+
continue
2208+
}
2209+
trustedSenderID = knownPeerID
2210+
} else {
2211+
trustedSenderID = nil
2212+
}
2213+
2214+
if !validatePacket(packet, from: trustedSenderID ?? claimedSenderID, connectionSource: .peripheral(peripheralUUID)) {
22052215
continue
22062216
}
2217+
2218+
// If this is a direct-link announce, bind immediately for the remainder of this batch.
2219+
if boundPeerID == nil,
2220+
packet.type == MessageType.announce.rawValue,
2221+
packet.ttl == messageTTL {
2222+
boundPeerID = claimedSenderID
2223+
state.peerID = claimedSenderID
2224+
peripherals[peripheralUUID] = state
2225+
}
22072226
processNotificationPacket(packet, from: peripheral, peripheralUUID: peripheralUUID)
22082227
}
22092228
}
@@ -2618,37 +2637,48 @@ extension BLEService: CBPeripheralManagerDelegate {
26182637
if let packet = BinaryProtocol.decode(combined) {
26192638
// Clear buffer on success
26202639
pendingWriteBuffers.removeValue(forKey: centralUUID)
2621-
let senderID = PeerID(hexData: packet.senderID)
2622-
2623-
// Validate packet (Timestamp/RSR)
2624-
if !validatePacket(packet, from: senderID) {
2640+
2641+
let claimedSenderID = PeerID(hexData: packet.senderID)
2642+
2643+
let trustedSenderID: PeerID?
2644+
if let knownPeerID = centralToPeerID[centralUUID] {
2645+
if knownPeerID != claimedSenderID {
2646+
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)
2647+
continue
2648+
}
2649+
trustedSenderID = knownPeerID
2650+
} else {
2651+
trustedSenderID = nil
2652+
}
2653+
2654+
if !validatePacket(packet, from: trustedSenderID ?? claimedSenderID, connectionSource: .central(centralUUID)) {
26252655
continue
26262656
}
2627-
2657+
26282658
if packet.type != MessageType.announce.rawValue {
2629-
SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(senderID)", category: .session)
2659+
SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(claimedSenderID)", category: .session)
26302660
}
26312661
if !subscribedCentrals.contains(sorted[0].central) {
26322662
subscribedCentrals.append(sorted[0].central)
26332663
}
26342664
if packet.type == MessageType.announce.rawValue {
26352665
if packet.ttl == messageTTL {
2636-
centralToPeerID[centralUUID] = senderID
2666+
centralToPeerID[centralUUID] = claimedSenderID
26372667
refreshLocalTopology()
26382668
}
26392669
// Record ingress link for last-hop suppression then process
26402670
let msgID = makeMessageID(for: packet)
26412671
collectionsQueue.async(flags: .barrier) { [weak self] in
26422672
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
26432673
}
2644-
handleReceivedPacket(packet, from: senderID)
2674+
handleReceivedPacket(packet, from: claimedSenderID)
26452675
} else {
26462676
// Record ingress link for last-hop suppression then process
26472677
let msgID = makeMessageID(for: packet)
26482678
collectionsQueue.async(flags: .barrier) { [weak self] in
26492679
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
26502680
}
2651-
handleReceivedPacket(packet, from: senderID)
2681+
handleReceivedPacket(packet, from: claimedSenderID)
26522682
}
26532683
} else {
26542684
// If buffer grows suspiciously large, reset to avoid memory leak
@@ -4057,16 +4087,13 @@ extension BLEService {
40574087
}
40584088
}
40594089
}
4060-
// If still not accepted and this is a sync-returned packet (TTL==0),
4061-
// accept with a generic nickname so history can be restored even for
4062-
// peers we haven't verified yet.
4063-
if !accepted && packet.ttl == 0 {
4064-
accepted = true
4065-
senderNickname = "anon" + String(peerID.id.prefix(4))
4066-
}
40674090
}
40684091

4069-
// Track broadcast messages for sync (treat nil or 0xFF..0xFF as broadcast)
4092+
guard accepted else {
4093+
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
4094+
return
4095+
}
4096+
40704097
let isBroadcastRecipient: Bool = {
40714098
guard let r = packet.recipientID else { return true }
40724099
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
@@ -4075,11 +4102,6 @@ extension BLEService {
40754102
gossipSyncManager?.onPublicPacketSeen(packet)
40764103
}
40774104

4078-
guard accepted else {
4079-
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
4080-
return
4081-
}
4082-
40834105
guard let content = String(data: packet.payload, encoding: .utf8) else {
40844106
SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session)
40854107
return

0 commit comments

Comments
 (0)