mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 09:05:20 +00:00
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:
@@ -53,6 +53,15 @@ final class BLEService: NSObject {
|
|||||||
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 {
|
||||||
let peerID: PeerID
|
let peerID: PeerID
|
||||||
@@ -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
|
||||||
@@ -2191,6 +2262,15 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
subscribedCentrals.removeAll { $0.identifier == central.identifier }
|
subscribedCentrals.removeAll { $0.identifier == central.identifier }
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user