From 524828fb4724416c8e635082ecee09186fcbf15d Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Wed, 12 Aug 2026 00:44:17 +0200 Subject: [PATCH 1/4] fix: only answer HOLDS_ONE for registered peripherals The HOLDS_ONE handler validated MAC syntax then queried live Bluetooth state for any address, letting an authenticated peer probe arbitrary MACs for connection presence. Gate on membership in the registered peripheral list, mirroring the .connectOne/.unregisterOne handlers; unregistered addresses get OP_FAILED. --- Magic Switch/Manager/IncomingConnection.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Magic Switch/Manager/IncomingConnection.swift b/Magic Switch/Manager/IncomingConnection.swift index 48efcdf..e2c24e3 100644 --- a/Magic Switch/Manager/IncomingConnection.swift +++ b/Magic Switch/Manager/IncomingConnection.swift @@ -356,16 +356,24 @@ final class IncomingConnection { sendString(DeviceCommand.operationFailed.rawValue) break } + let store = bluetoothStore + let address = message // Read-only query: OP_SUCCESS only if we have a live connection to it, // so the peer's wake-time reclaim won't grab a peripheral we're using. // The BT check completes on the Bluetooth queue; hop back to the // connection queue so all sealed sends stay serialized there (the send // counter isn't synchronized across queues). - bluetoothStore.isHoldingPeripheral(address: message) { [weak self] held in + DispatchQueue.main.async { [weak self] in guard let self = self else { return } - self.queue.async { - self.sendString( - (held ? DeviceCommand.operationSuccess : DeviceCommand.operationFailed).rawValue) + guard store.peripherals.contains(where: { $0.id == address }) else { + self.queue.async { self.sendString(DeviceCommand.operationFailed.rawValue) } + return + } + store.isHoldingPeripheral(address: address) { held in + self.queue.async { + self.sendString( + (held ? DeviceCommand.operationSuccess : DeviceCommand.operationFailed).rawValue) + } } } case .adoptReleased: From fe02333fb448e70c4686c89473bd030d8caf0657 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Wed, 12 Aug 2026 00:44:28 +0200 Subject: [PATCH 2/4] fix: refuse Bluetooth pairing confirmations this Mac didn't initiate devicePairingUserConfirmationRequest replied replyUserConfirmation(true) unconditionally, auto-accepting SSP numeric comparison for any pairing, including one an unsolicited device initiates toward this Mac. Only confirm when the request matches an in-flight pair this Mac started (pendingPairs === pair); otherwise refuse. Handoff/connect flows still auto-confirm, so the automatic switching UX is unchanged. --- .../Model/Store/BluetoothPeripheralStore.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift index 3e0b80f..186777f 100644 --- a/Magic Switch/Model/Store/BluetoothPeripheralStore.swift +++ b/Magic Switch/Model/Store/BluetoothPeripheralStore.swift @@ -1558,8 +1558,14 @@ final class BluetoothPeripheralStore: NSObject, ObservableObject, BluetoothPerip else { return } - print("Accepting Bluetooth pairing confirmation for \(address): \(numericValue)") - pair.replyUserConfirmation(true) + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + let initiated = self.pendingPairs[address] === pair + print( + "Bluetooth pairing confirmation for \(address) (\(numericValue)): " + + (initiated ? "accept" : "refuse")) + pair.replyUserConfirmation(initiated) + } } @objc func devicePairingPINCodeRequest(_ sender: Any!) { From 74ca2db7323d68e18c51b926f7c53dcaba8c4704 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Wed, 12 Aug 2026 00:44:41 +0200 Subject: [PATCH 3/4] fix: re-check the pairing key on every command from an in-flight peer Command dispatch only checked the per-connection authenticated flag, so a peer that authenticated before the user unpaired/re-paired kept full command control until its timers expired (up to the 5-minute budget). Re-verify the current PSK fingerprint against the one the handshake proved before handling each frame, and tear down when it no longer matches. Steady-state sessions are unaffected (the key doesn't change). --- Magic Switch/Manager/IncomingConnection.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Magic Switch/Manager/IncomingConnection.swift b/Magic Switch/Manager/IncomingConnection.swift index e2c24e3..1f4f98f 100644 --- a/Magic Switch/Manager/IncomingConnection.swift +++ b/Magic Switch/Manager/IncomingConnection.swift @@ -179,12 +179,22 @@ final class IncomingConnection { self.teardown() case .success(let data): self.resetIdleTimer() + guard self.currentlyAuthorized() else { + print("Dropping frame: pairing key changed or removed since handshake") + self.teardown() + return + } self.handleIncoming(data: data) self.readNext() } } } + private func currentlyAuthorized() -> Bool { + guard let key = pairingStore.currentKey() else { return false } + return PairingStore.fingerprint(forKey: key) == provedFingerprint + } + // MARK: - Command Handling private func handleIncoming(data: Data) { From 50e29627e13701adee490b97731c86c56ce0bac8 Mon Sep 17 00:00:00 2001 From: Joshua Rogers Date: Wed, 12 Aug 2026 00:45:31 +0200 Subject: [PATCH 4/4] fix: cap concurrent unauthenticated inbound connections The listener accepted and retained an IncomingConnection per socket with no concurrency limit, and a stalled handshake records no rate-limiter failure, so a LAN client could pin many pre-auth handshakes at once. Add per-IP and global caps on in-flight pre-auth connections in RateLimiter, reserved before the handshake and released on auth or teardown. Caps are generous, so the legitimate peer is never refused. --- Magic Switch/Manager/IncomingConnection.swift | 17 +++++++++++ Magic Switch/Manager/RateLimiter.swift | 29 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/Magic Switch/Manager/IncomingConnection.swift b/Magic Switch/Manager/IncomingConnection.swift index 1f4f98f..f65acc0 100644 --- a/Magic Switch/Manager/IncomingConnection.swift +++ b/Magic Switch/Manager/IncomingConnection.swift @@ -68,6 +68,7 @@ final class IncomingConnection { private var selfRef: IncomingConnection? private var authenticated = false private var finished = false + private var pendingCounted = false /// Fingerprint of the accept-time PSK snapshot the handshake proved. private var provedFingerprint: String? @@ -108,6 +109,14 @@ final class IncomingConnection { return } + guard rateLimiter.beginPending(endpoint: endpoint) else { + print("Rejecting connection: too many concurrent unauthenticated handshakes") + connection.cancel() + release() + return + } + pendingCounted = true + let channel = SecureChannel( connection: connection, role: .server, psk: psk, queue: queue ) @@ -132,6 +141,7 @@ final class IncomingConnection { switch result { case .success: self.authenticated = true + self.endPendingIfNeeded() // The peer has proved possession of the pairing key this handshake // ran with — strictly stronger evidence than the fingerprint it // advertises over cleartext mDNS. If a registered device is stuck @@ -492,6 +502,7 @@ final class IncomingConnection { private func teardown() { guard !finished else { return } finished = true + endPendingIfNeeded() idleTimer?.cancel() totalTimer?.cancel() idleTimer = nil @@ -501,6 +512,12 @@ final class IncomingConnection { release() } + private func endPendingIfNeeded() { + guard pendingCounted else { return } + pendingCounted = false + rateLimiter.endPending(endpoint: endpoint) + } + private func release() { queue.async { [weak self] in self?.selfRef = nil diff --git a/Magic Switch/Manager/RateLimiter.swift b/Magic Switch/Manager/RateLimiter.swift index 5883030..ab1234d 100644 --- a/Magic Switch/Manager/RateLimiter.swift +++ b/Magic Switch/Manager/RateLimiter.swift @@ -17,6 +17,8 @@ final class RateLimiter { private static let failureThreshold = 5 private static let blockDuration: TimeInterval = 15 * 60 private static let blocksKey = "com.magicswitch.ratelimiter.blocks" + private static let maxPendingPerIP = 8 + private static let maxPendingTotal = 64 // MARK: - State @@ -25,6 +27,9 @@ final class RateLimiter { private var failuresByIP: [String: [CFTimeInterval]] = [:] /// Monotonic deadline (seconds since boot) at which the block lifts. private var blocksByIP: [String: CFTimeInterval] = [:] + /// In-flight pre-auth connection counts, per IP and in total. + private var pendingByIP: [String: Int] = [:] + private var pendingTotal = 0 // MARK: - Init @@ -46,6 +51,30 @@ final class RateLimiter { } } + /// Reserve a pre-auth connection slot; false if the per-IP or global cap is hit. + func beginPending(endpoint: NWEndpoint?) -> Bool { + let key = Self.bucket(for: endpoint) + return queue.sync { + guard pendingTotal < Self.maxPendingTotal, + pendingByIP[key, default: 0] < Self.maxPendingPerIP + else { return false } + pendingByIP[key, default: 0] += 1 + pendingTotal += 1 + return true + } + } + + /// Release a slot reserved by `beginPending`. + func endPending(endpoint: NWEndpoint?) { + let key = Self.bucket(for: endpoint) + queue.sync { + if let count = pendingByIP[key] { + if count <= 1 { pendingByIP.removeValue(forKey: key) } else { pendingByIP[key] = count - 1 } + } + if pendingTotal > 0 { pendingTotal -= 1 } + } + } + /// Record an authentication failure for `endpoint`. func recordFailure(endpoint: NWEndpoint?) { let key = Self.bucket(for: endpoint)