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 <noreply@anthropic.com>
This commit is contained in:
jack
2026-01-12 10:43:36 -10:00
co-authored by Claude Opus 4.5
parent b84c36c6fa
commit 99896bcded
3 changed files with 179 additions and 2 deletions
+82 -2
View File
@@ -52,6 +52,15 @@ final class BLEService: NSObject {
// 2. BLE Centrals (when acting as peripheral) // 2. BLE Centrals (when acting as peripheral)
private var subscribedCentrals: [CBCentral] = [] private var subscribedCentrals: [CBCentral] = []
private var centralToPeerID: [String: PeerID] = [:] // Central UUID -> Peer ID mapping 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) // 3. Peer Information (single source of truth)
private struct PeerInfo { private struct PeerInfo {
@@ -575,6 +584,9 @@ final class BLEService: NSObject {
subscribedCentrals.removeAll() subscribedCentrals.removeAll()
centralToPeerID.removeAll() centralToPeerID.removeAll()
meshTopology.reset() meshTopology.reset()
// BCH-01-004: Clear rate-limit state
centralSubscriptionRateLimits.removeAll()
} }
// MARK: Connectivity and peers // MARK: Connectivity and peers
@@ -2179,9 +2191,68 @@ extension BLEService: CBPeripheralManagerDelegate {
} }
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { 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) 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 messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
self?.sendAnnounce(forceSend: true) self?.sendAnnounce(forceSend: true)
// Flush any spooled directed packets now that we have a central subscribed // Flush any spooled directed packets now that we have a central subscribed
@@ -2190,6 +2261,15 @@ extension BLEService: CBPeripheralManagerDelegate {
self?.rebroadcastRecentAnnounces() 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) { func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
SecureLogger.debug("📤 Central unsubscribed: \(central.identifier.uuidString)", category: .session) SecureLogger.debug("📤 Central unsubscribed: \(central.identifier.uuidString)", category: .session)
+8
View File
@@ -162,6 +162,14 @@ enum TransportConfig {
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4 static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15 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 // Store-and-forward for directed packets at relays
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0 static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
@@ -0,0 +1,89 @@
//
// SubscriptionRateLimitTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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")
}
}