BLE privacy: derive peerID from Noise fingerprint; remove BLE Local Name from advertising; verify announces against key-derived ID; auto-initiate Noise handshake when encrypted message arrives without session; drop rotating alias option. (#447)

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-08-17 02:20:49 +02:00
committed by GitHub
co-authored by jack
parent 845ffc601b
commit fb1988ac27
@@ -1,6 +1,7 @@
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import Combine import Combine
import CryptoKit
#if os(iOS) #if os(iOS)
import UIKit import UIKit
#endif #endif
@@ -74,6 +75,9 @@ final class SimplifiedBluetoothService: NSObject {
var myPeerID: String = "" var myPeerID: String = ""
var myNickname: String = "Anonymous" var myNickname: String = "Anonymous"
private let noiseService = NoiseEncryptionService() private let noiseService = NoiseEncryptionService()
// MARK: - Advertising Privacy
// No Local Name by default for maximum privacy. No rotating alias.
// MARK: - Queues // MARK: - Queues
@@ -137,8 +141,9 @@ final class SimplifiedBluetoothService: NSObject {
override init() { override init() {
super.init() super.init()
// Generate ephemeral peer ID (8 random bytes as hex) // Derive stable peer ID from Noise static public key fingerprint (first 8 bytes 16 hex chars)
self.myPeerID = (0..<8).map { _ in String(format: "%02x", UInt8.random(in: 0...255)) }.joined() let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars
self.myPeerID = String(fingerprint.prefix(16))
// Set queue key for identification // Set queue key for identification
messageQueue.setSpecific(key: messageQueueKey, value: ()) messageQueue.setSpecific(key: messageQueueKey, value: ())
@@ -199,6 +204,8 @@ final class SimplifiedBluetoothService: NSObject {
// Send announce to notify peers of nickname change (force send) // Send announce to notify peers of nickname change (force send)
sendAnnounce(forceSend: true) sendAnnounce(forceSend: true)
} }
// No advertising policy to set; we never include Local Name in adverts.
deinit { deinit {
maintenanceTimer?.invalidate() maintenanceTimer?.invalidate()
@@ -219,6 +226,7 @@ final class SimplifiedBluetoothService: NSObject {
centralManager?.stopScan() centralManager?.stopScan()
startScanning() startScanning()
} }
// No Local Name; nothing to refresh for advertising policy
} }
@objc private func appDidEnterBackground() { @objc private func appDidEnterBackground() {
@@ -228,6 +236,7 @@ final class SimplifiedBluetoothService: NSObject {
centralManager?.stopScan() centralManager?.stopScan()
startScanning() startScanning()
} }
// No Local Name; nothing to refresh for advertising policy
} }
#endif #endif
@@ -972,6 +981,16 @@ final class SimplifiedBluetoothService: NSObject {
return return
} }
// Verify that the sender's derived ID from the announced public key matches the packet senderID
// This helps detect relayed or spoofed announces. Only warn in release; assert in debug.
let derivedFromKey = Self.derivePeerID(fromPublicKey: announcement.publicKey)
if derivedFromKey != peerID {
SecureLogger.log("⚠️ Announce sender mismatch: derived \(derivedFromKey.prefix(8))… vs packet \(peerID.prefix(8))", category: SecureLogger.security, level: .warning)
#if DEBUG
assertionFailure("Announce senderID does not match key-derived ID")
#endif
}
// Don't add ourselves as a peer // Don't add ourselves as a peer
if peerID == myPeerID { if peerID == myPeerID {
return return
@@ -1254,6 +1273,14 @@ final class SimplifiedBluetoothService: NSObject {
default: default:
SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning) SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning)
} }
} catch NoiseEncryptionError.sessionNotEstablished {
// We received an encrypted message before establishing a session with this peer.
// Trigger a handshake so future messages can be decrypted.
SecureLogger.log("🔑 Encrypted message from \(peerID) without session; initiating handshake",
category: SecureLogger.noise, level: .info)
if !noiseService.hasSession(with: peerID) {
initiateNoiseHandshake(with: peerID)
}
} catch { } catch {
SecureLogger.log("❌ Failed to decrypt message from \(peerID): \(error)", SecureLogger.log("❌ Failed to decrypt message from \(peerID): \(error)",
category: SecureLogger.noise, level: .error) category: SecureLogger.noise, level: .error)
@@ -1395,10 +1422,7 @@ final class SimplifiedBluetoothService: NSObject {
if peers.isEmpty { if peers.isEmpty {
// Ensure we're advertising as peripheral // Ensure we're advertising as peripheral
if let pm = peripheralManager, pm.state == .poweredOn && !pm.isAdvertising { if let pm = peripheralManager, pm.state == .poweredOn && !pm.isAdvertising {
pm.startAdvertising([ pm.startAdvertising(buildAdvertisementData())
CBAdvertisementDataServiceUUIDsKey: [SimplifiedBluetoothService.serviceUUID],
CBAdvertisementDataLocalNameKey: myPeerID
])
} }
} }
@@ -1412,6 +1436,8 @@ final class SimplifiedBluetoothService: NSObject {
performCleanup() performCleanup()
} }
// No rotating alias: nothing to refresh
// Reset counter to prevent overflow (every 60 seconds) // Reset counter to prevent overflow (every 60 seconds)
if maintenanceCounter >= 6 { if maintenanceCounter >= 6 {
maintenanceCounter = 0 maintenanceCounter = 0
@@ -1871,13 +1897,11 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
SecureLogger.log("✅ Service added successfully, starting advertising", category: SecureLogger.session, level: .info) SecureLogger.log("✅ Service added successfully, starting advertising", category: SecureLogger.session, level: .info)
// Now start advertising after service is confirmed added // Start advertising after service is confirmed added
peripheral.startAdvertising([ let adData = buildAdvertisementData()
CBAdvertisementDataServiceUUIDsKey: [SimplifiedBluetoothService.serviceUUID], peripheral.startAdvertising(adData)
CBAdvertisementDataLocalNameKey: myPeerID
])
SecureLogger.log("📡 Started advertising as peripheral with ID: \(myPeerID)", category: SecureLogger.session, level: .info) SecureLogger.log("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.prefix(8))…)", category: SecureLogger.session, level: .info)
} }
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
@@ -1895,10 +1919,7 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
// Ensure we're still advertising for other devices to find us // Ensure we're still advertising for other devices to find us
if peripheral.isAdvertising == false { if peripheral.isAdvertising == false {
SecureLogger.log("📡 Restarting advertising after central unsubscribed", category: SecureLogger.session, level: .info) SecureLogger.log("📡 Restarting advertising after central unsubscribed", category: SecureLogger.session, level: .info)
peripheral.startAdvertising([ peripheral.startAdvertising(buildAdvertisementData())
CBAdvertisementDataServiceUUIDsKey: [SimplifiedBluetoothService.serviceUUID],
CBAdvertisementDataLocalNameKey: myPeerID
])
} }
// Find and disconnect the peer associated with this central // Find and disconnect the peer associated with this central
@@ -2058,6 +2079,26 @@ extension SimplifiedBluetoothService: CBPeripheralManagerDelegate {
} }
} }
// MARK: - Advertising Builders & Alias Rotation
extension SimplifiedBluetoothService {
static func derivePeerID(fromPublicKey publicKey: Data) -> String {
let digest = SHA256.hash(data: publicKey)
let hex = digest.map { String(format: "%02x", $0) }.joined()
return String(hex.prefix(16))
}
private func buildAdvertisementData() -> [String: Any] {
var data: [String: Any] = [
CBAdvertisementDataServiceUUIDsKey: [SimplifiedBluetoothService.serviceUUID]
]
// No Local Name for privacy
return data
}
// No alias rotation or advertising restarts required.
}
// MARK: - Message Deduplicator // MARK: - Message Deduplicator
/// Efficient message deduplication with time-based cleanup /// Efficient message deduplication with time-based cleanup