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)
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)
+8
View File
@@ -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