diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 05288f4f..add13781 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -52,6 +52,15 @@ final class BLEService: NSObject { // 2. BLE Centrals (when acting as peripheral) private var subscribedCentrals: [CBCentral] = [] private var centralToPeerID: [String: PeerID] = [:] // Central UUID -> Peer ID mapping + + // BCH-01-004: Rate-limiting for subscription-triggered announces + // Tracks subscription attempts per central to prevent enumeration attacks + private struct SubscriptionRateLimitState { + var lastAnnounceTime: Date + var attemptCount: Int + var currentBackoffSeconds: TimeInterval + } + private var centralSubscriptionRateLimits: [String: SubscriptionRateLimitState] = [:] // Central UUID -> rate limit state // 3. Peer Information (single source of truth) private struct PeerInfo { @@ -575,6 +584,9 @@ final class BLEService: NSObject { subscribedCentrals.removeAll() centralToPeerID.removeAll() meshTopology.reset() + + // BCH-01-004: Clear rate-limit state + centralSubscriptionRateLimits.removeAll() } // MARK: Connectivity and peers @@ -2248,9 +2260,70 @@ extension BLEService: CBPeripheralManagerDelegate { } func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { - SecureLogger.debug("📥 Central subscribed: \(central.identifier.uuidString)", category: .session) + let centralUUID = central.identifier.uuidString + SecureLogger.debug("📥 Central subscribed: \(centralUUID)", category: .session) subscribedCentrals.append(central) - // Send announce to the newly subscribed central after a small delay to avoid overwhelming + + // BCH-01-004: Rate-limit subscription-triggered announces to prevent enumeration attacks + let now = Date() + var state = centralSubscriptionRateLimits[centralUUID] + + // Clean up stale entries periodically + cleanupStaleSubscriptionRateLimits() + + // Check if this central is rate-limited + if let existingState = state { + let timeSinceLastAnnounce = now.timeIntervalSince(existingState.lastAnnounceTime) + + // If within backoff period, skip the announce + if timeSinceLastAnnounce < existingState.currentBackoffSeconds { + SecureLogger.warning("🛡️ BCH-01-004: Rate-limited announce for central \(centralUUID.prefix(8))... (backoff: \(Int(existingState.currentBackoffSeconds))s, attempts: \(existingState.attemptCount))", category: .security) + + // Increment attempt count and increase backoff + // Update lastAnnounceTime to 'now' so each blocked attempt extends the suppression window + // This prevents attackers from waiting out the backoff while spamming attempts + let newAttemptCount = existingState.attemptCount + 1 + let newBackoff = min( + existingState.currentBackoffSeconds * TransportConfig.bleSubscriptionRateLimitBackoffFactor, + TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds + ) + centralSubscriptionRateLimits[centralUUID] = SubscriptionRateLimitState( + lastAnnounceTime: now, // Reset timer on each blocked attempt + attemptCount: newAttemptCount, + currentBackoffSeconds: newBackoff + ) + + // If too many rapid attempts, this is likely an enumeration attack - don't respond + if newAttemptCount >= TransportConfig.bleSubscriptionRateLimitMaxAttempts { + SecureLogger.warning("🚨 BCH-01-004: Possible enumeration attack from central \(centralUUID.prefix(8))... - suppressing announce", category: .security) + return + } + + // Still flush directed packets and rebroadcast for legitimate mesh operation + messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in + self?.flushDirectedSpool() + self?.rebroadcastRecentAnnounces() + } + return + } + + // Outside backoff period - allow announce but track it + state = SubscriptionRateLimitState( + lastAnnounceTime: now, + attemptCount: 1, + currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds + ) + } else { + // First subscription from this central - track it + state = SubscriptionRateLimitState( + lastAnnounceTime: now, + attemptCount: 1, + currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds + ) + } + centralSubscriptionRateLimits[centralUUID] = state + + // Send announce to the newly subscribed central after a small delay messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in self?.sendAnnounce(forceSend: true) // Flush any spooled directed packets now that we have a central subscribed @@ -2259,6 +2332,15 @@ extension BLEService: CBPeripheralManagerDelegate { self?.rebroadcastRecentAnnounces() } } + + /// BCH-01-004: Clean up stale rate-limit entries to prevent memory growth + private func cleanupStaleSubscriptionRateLimits() { + let now = Date() + let windowSeconds = TransportConfig.bleSubscriptionRateLimitWindowSeconds + centralSubscriptionRateLimits = centralSubscriptionRateLimits.filter { _, state in + now.timeIntervalSince(state.lastAnnounceTime) < windowSeconds + } + } func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) { SecureLogger.debug("📤 Central unsubscribed: \(central.identifier.uuidString)", category: .session) diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 72690e4a..49d01928 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -162,6 +162,14 @@ enum TransportConfig { static let blePostAnnounceDelaySeconds: TimeInterval = 0.4 static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15 + // BCH-01-004: Rate-limiting for subscription-triggered announces + // Prevents rapid enumeration attacks by rate-limiting announce responses + static let bleSubscriptionRateLimitMinSeconds: TimeInterval = 2.0 // Minimum interval between announces per central + static let bleSubscriptionRateLimitBackoffFactor: Double = 2.0 // Exponential backoff multiplier + static let bleSubscriptionRateLimitMaxBackoffSeconds: TimeInterval = 30.0 // Maximum backoff period + static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts + static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown + // Store-and-forward for directed packets at relays static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0 diff --git a/bitchatTests/SubscriptionRateLimitTests.swift b/bitchatTests/SubscriptionRateLimitTests.swift new file mode 100644 index 00000000..7ba6557d --- /dev/null +++ b/bitchatTests/SubscriptionRateLimitTests.swift @@ -0,0 +1,89 @@ +// +// SubscriptionRateLimitTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import bitchat + +/// Tests for BCH-01-004 fix: Rate-limiting subscription-triggered announces +/// to prevent device enumeration attacks +struct SubscriptionRateLimitTests { + + @Test("Rate limit configuration values are sensible") + func rateLimitConfigurationValues() { + // Minimum interval should be at least 1 second to slow enumeration + #expect(TransportConfig.bleSubscriptionRateLimitMinSeconds >= 1.0) + + // Backoff factor should be > 1 for exponential backoff + #expect(TransportConfig.bleSubscriptionRateLimitBackoffFactor > 1.0) + + // Max backoff should be reasonable (not hours) + #expect(TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds <= 60.0) + #expect(TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds >= TransportConfig.bleSubscriptionRateLimitMinSeconds) + + // Window should be long enough to track repeated attempts + #expect(TransportConfig.bleSubscriptionRateLimitWindowSeconds >= 30.0) + + // Max attempts before suppression should be > 1 to allow legitimate reconnects + #expect(TransportConfig.bleSubscriptionRateLimitMaxAttempts >= 2) + } + + @Test("Exponential backoff calculation is correct") + func exponentialBackoffCalculation() { + let minInterval = TransportConfig.bleSubscriptionRateLimitMinSeconds + let factor = TransportConfig.bleSubscriptionRateLimitBackoffFactor + let maxBackoff = TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds + + // Simulate backoff progression + var currentBackoff = minInterval + var iterations = 0 + let maxIterations = 10 + + while currentBackoff < maxBackoff && iterations < maxIterations { + let nextBackoff = min(currentBackoff * factor, maxBackoff) + #expect(nextBackoff >= currentBackoff, "Backoff should increase or stay at max") + currentBackoff = nextBackoff + iterations += 1 + } + + // Should reach max within reasonable iterations + #expect(iterations <= maxIterations, "Backoff should reach max within \(maxIterations) iterations") + #expect(currentBackoff == maxBackoff, "Final backoff should equal max") + } + + @Test("Rate limiting would significantly slow enumeration attacks") + func rateLimitingSlowsEnumeration() { + // Without rate limiting: ~120 devices/minute (0.5 seconds per device) + // With rate limiting: minimum interval enforced + + let minInterval = TransportConfig.bleSubscriptionRateLimitMinSeconds + let devicesPerMinuteWithRateLimit = 60.0 / minInterval + + // Should be significantly slower than 120 devices/minute + #expect(devicesPerMinuteWithRateLimit < 60, "Rate limiting should significantly slow enumeration") + + // With 2-second minimum interval, max ~30 devices/minute per connection + // And with backoff, repeated attempts are even slower + #expect(devicesPerMinuteWithRateLimit <= 30, "With 2s minimum, should be <=30/min") + } + + @Test("Max attempts threshold prevents complete enumeration") + func maxAttemptsThresholdPreventsEnumeration() { + let maxAttempts = TransportConfig.bleSubscriptionRateLimitMaxAttempts + let minInterval = TransportConfig.bleSubscriptionRateLimitMinSeconds + + // After max attempts within window, announces are suppressed entirely + // This means an attacker gets at most maxAttempts announces per window + #expect(maxAttempts >= 2, "Should allow at least 2 attempts for legitimate reconnects") + #expect(maxAttempts <= 10, "Should cap attempts to prevent enumeration") + + // With 5 attempts max and 2s minimum interval, attacker gets limited info + let maxAnnounces = maxAttempts + #expect(maxAnnounces <= 10, "Max announces per window should be limited") + } +}