From 99896bcded49f0743a0b2f4baa7353d23248f01c Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 12 Jan 2026 10:43:36 -1000 Subject: [PATCH 1/2] fix BCH-01-004: rate-limit subscription-triggered announces Adds rate-limiting to prevent device enumeration attacks via rapid subscription/disconnect cycles. Without this fix, an attacker could enumerate ~120 bitchat devices per minute by connecting, subscribing, capturing the announce packet, then disconnecting and moving to the next. Key changes: - Add per-central rate-limit tracking with exponential backoff - Minimum 2-second interval between announces per central - Exponential backoff (2x) up to 30 seconds for rapid reconnects - After 5 rapid attempts, suppress announces entirely (likely attack) - Clean up stale rate-limit entries after 60-second window - Clear rate-limit state during panic mode Configuration: - bleSubscriptionRateLimitMinSeconds: 2.0 - bleSubscriptionRateLimitBackoffFactor: 2.0 - bleSubscriptionRateLimitMaxBackoffSeconds: 30.0 - bleSubscriptionRateLimitWindowSeconds: 60.0 - bleSubscriptionRateLimitMaxAttempts: 5 Security audit reference: Cure53 BCH-01-004 (Medium severity) Co-Authored-By: Claude Opus 4.5 --- bitchat/Services/BLE/BLEService.swift | 84 ++++++++++++++++- bitchat/Services/TransportConfig.swift | 8 ++ bitchatTests/SubscriptionRateLimitTests.swift | 89 +++++++++++++++++++ 3 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 bitchatTests/SubscriptionRateLimitTests.swift diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 2d5f0642..7d6243ee 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 @@ -2179,9 +2191,68 @@ 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 + let newAttemptCount = existingState.attemptCount + 1 + let newBackoff = min( + existingState.currentBackoffSeconds * TransportConfig.bleSubscriptionRateLimitBackoffFactor, + TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds + ) + centralSubscriptionRateLimits[centralUUID] = SubscriptionRateLimitState( + lastAnnounceTime: existingState.lastAnnounceTime, + 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 @@ -2190,6 +2261,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") + } +} From 9cd955ae2f22a8d2bcec093dc3bee7c5addbd5a8 Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 12 Jan 2026 11:00:36 -1000 Subject: [PATCH 2/2] fix: extend backoff window on blocked attempts (Codex P2 feedback) Update lastAnnounceTime to 'now' when rate-limiting subscription attempts. This ensures each blocked attempt extends the suppression window, preventing attackers from waiting out the backoff while continuously spamming attempts. Previously, the backoff timer was only based on the last successful announce, allowing attackers to receive an announce as soon as the initial backoff expired regardless of how many blocked attempts occurred in between. Co-Authored-By: Claude Opus 4.5 --- bitchat/Services/BLE/BLEService.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 7d6243ee..9ddcd64f 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -2211,13 +2211,15 @@ extension BLEService: CBPeripheralManagerDelegate { 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: existingState.lastAnnounceTime, + lastAnnounceTime: now, // Reset timer on each blocked attempt attemptCount: newAttemptCount, currentBackoffSeconds: newBackoff )