mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 20:45:19 +00:00
7862 lines
351 KiB
Swift
7862 lines
351 KiB
Swift
import BitLogger
|
||
import BitFoundation
|
||
import Foundation
|
||
import CoreBluetooth
|
||
import Combine
|
||
#if os(iOS)
|
||
import UIKit
|
||
#endif
|
||
|
||
/// Linearizes app-private-media admission against cancellation before work is
|
||
/// handed to the fragment scheduler. A transfer starts here synchronously,
|
||
/// before its `messageQueue` work item is enqueued; cancel/delete can therefore
|
||
/// leave a tombstone that the deferred work must observe.
|
||
///
|
||
/// Active admissions and cancellation tombstones have independent count
|
||
/// bounds. Tombstones may age out or evict older tombstones; active entries
|
||
/// are never evicted under pressure. A one-hour active timeout is reported as
|
||
/// an explicit transfer failure and removes any handshake-queued payload.
|
||
private final class BLEPrivateMediaTransferAdmissionRegistry {
|
||
enum BeginResult: Equatable {
|
||
case admitted
|
||
case alreadyKnown
|
||
case capacityExhausted
|
||
}
|
||
|
||
private enum State: Equatable {
|
||
case active
|
||
case cancelled
|
||
}
|
||
|
||
private struct Entry {
|
||
var state: State
|
||
var updatedAt: Date
|
||
}
|
||
|
||
private let lock = NSLock()
|
||
private let maxActiveEntries = 512
|
||
private let maxCancelledTombstones = 512
|
||
private let lifetime: TimeInterval = 60 * 60
|
||
private let onActiveExpired: (String) -> Void
|
||
private var entries: [String: Entry] = [:]
|
||
|
||
init(onActiveExpired: @escaping (String) -> Void) {
|
||
self.onActiveExpired = onActiveExpired
|
||
}
|
||
|
||
func begin(_ transferId: String, now: Date = Date()) -> BeginResult {
|
||
guard !transferId.isEmpty else { return .alreadyKnown }
|
||
lock.lock()
|
||
let expiredActive = pruneLocked(now: now)
|
||
// Transfer IDs are invocation-unique. Never revive a cancellation or
|
||
// admit a duplicate invocation that reused an in-flight identifier.
|
||
let result: BeginResult
|
||
if entries[transferId] != nil {
|
||
result = .alreadyKnown
|
||
} else if activeCountLocked >= maxActiveEntries {
|
||
// Never evict an admitted transfer: doing so strands its UI
|
||
// placeholder with no completion event. Reject the newcomer and
|
||
// let the caller surface the bounded-pressure failure instead.
|
||
result = .capacityExhausted
|
||
} else {
|
||
entries[transferId] = Entry(state: .active, updatedAt: now)
|
||
result = .admitted
|
||
}
|
||
lock.unlock()
|
||
notifyExpired(expiredActive)
|
||
return result
|
||
}
|
||
|
||
func cancel(_ transferId: String, now: Date = Date()) {
|
||
guard !transferId.isEmpty else { return }
|
||
lock.lock()
|
||
// Cancel the requested active entry before expiry pruning so a user
|
||
// cancellation wins over a simultaneous timeout notification.
|
||
entries[transferId] = Entry(state: .cancelled, updatedAt: now)
|
||
let expiredActive = pruneLocked(now: now)
|
||
trimCancelledTombstonesLocked()
|
||
lock.unlock()
|
||
notifyExpired(expiredActive)
|
||
}
|
||
|
||
func isActive(_ transferId: String, now: Date = Date()) -> Bool {
|
||
lock.lock()
|
||
let expiredActive = pruneLocked(now: now)
|
||
let active = entries[transferId]?.state == .active
|
||
if active {
|
||
entries[transferId]?.updatedAt = now
|
||
}
|
||
lock.unlock()
|
||
notifyExpired(expiredActive)
|
||
return active
|
||
}
|
||
|
||
/// Runs `body` while holding the admission lock. Callers use this at the
|
||
/// collections-queue append/submit boundary so cancellation and admission
|
||
/// have one deterministic order: whichever acquires this lock first wins.
|
||
func withActive<Result>(
|
||
_ transferId: String,
|
||
now: Date = Date(),
|
||
_ body: () -> Result
|
||
) -> Result? {
|
||
lock.lock()
|
||
let expiredActive = pruneLocked(now: now)
|
||
guard entries[transferId]?.state == .active else {
|
||
lock.unlock()
|
||
notifyExpired(expiredActive)
|
||
return nil
|
||
}
|
||
entries[transferId]?.updatedAt = now
|
||
let result = body()
|
||
lock.unlock()
|
||
notifyExpired(expiredActive)
|
||
return result
|
||
}
|
||
|
||
func finish(_ transferId: String) {
|
||
lock.lock()
|
||
entries.removeValue(forKey: transferId)
|
||
lock.unlock()
|
||
}
|
||
|
||
var count: Int {
|
||
lock.lock()
|
||
let expiredActive = pruneLocked(now: Date())
|
||
let result = entries.count
|
||
lock.unlock()
|
||
notifyExpired(expiredActive)
|
||
return result
|
||
}
|
||
|
||
func prune(now: Date = Date()) {
|
||
lock.lock()
|
||
let expiredActive = pruneLocked(now: now)
|
||
lock.unlock()
|
||
notifyExpired(expiredActive)
|
||
}
|
||
|
||
private var activeCountLocked: Int {
|
||
entries.values.reduce(into: 0) { count, entry in
|
||
if entry.state == .active { count += 1 }
|
||
}
|
||
}
|
||
|
||
/// Removes stale tombstones silently and stale active admissions with a
|
||
/// caller-visible timeout notification. Must be called with `lock` held;
|
||
/// notifications are delivered only after the lock is released.
|
||
private func pruneLocked(now: Date) -> [String] {
|
||
var expiredActive: [String] = []
|
||
let expiredEntries = entries.filter {
|
||
now.timeIntervalSince($0.value.updatedAt) > lifetime
|
||
}
|
||
for (transferId, entry) in expiredEntries {
|
||
if entry.state == .active {
|
||
expiredActive.append(transferId)
|
||
}
|
||
entries.removeValue(forKey: transferId)
|
||
}
|
||
trimCancelledTombstonesLocked()
|
||
return expiredActive
|
||
}
|
||
|
||
private func trimCancelledTombstonesLocked() {
|
||
let cancelled = entries
|
||
.filter { $0.value.state == .cancelled }
|
||
.sorted { $0.value.updatedAt < $1.value.updatedAt }
|
||
let overflow = max(0, cancelled.count - maxCancelledTombstones)
|
||
for victim in cancelled.prefix(overflow) {
|
||
entries.removeValue(forKey: victim.key)
|
||
}
|
||
}
|
||
|
||
private func notifyExpired(_ transferIds: [String]) {
|
||
for transferId in transferIds {
|
||
onActiveExpired(transferId)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct BLEAuthenticatedPeerStateObservation {
|
||
let fingerprint: String
|
||
let sessionGeneration: UUID
|
||
let capabilities: PeerCapabilities
|
||
}
|
||
|
||
private struct BLEPrivateMediaProofTimeoutMarker {
|
||
let fingerprint: String
|
||
let sessionGeneration: UUID?
|
||
}
|
||
|
||
private struct BLEPrivateMediaProofWatchdog {
|
||
let fingerprint: String
|
||
let sessionGeneration: UUID
|
||
let timeoutNonce: UUID
|
||
}
|
||
|
||
private struct BLEPendingPrivateMediaPolicyResolution {
|
||
let fingerprint: String
|
||
var sessionGeneration: UUID?
|
||
var timeoutNonce: UUID
|
||
var completions: [UUID: @MainActor (PrivateMediaSendPolicy) -> Void]
|
||
}
|
||
|
||
private struct BLEAuthenticatedPeerStateSendProgress {
|
||
let sessionGeneration: UUID
|
||
var sentInitial = false
|
||
var sentEcho = false
|
||
}
|
||
|
||
/// BLEService — Bluetooth Mesh Transport
|
||
/// - Emits events exclusively via `BitchatDelegate` for UI.
|
||
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
|
||
final class BLEService: NSObject {
|
||
|
||
// MARK: - Constants
|
||
|
||
#if DEBUG
|
||
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5A") // testnet
|
||
#else
|
||
static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") // mainnet
|
||
#endif
|
||
static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
|
||
private static let centralRestorationID = "chat.bitchat.ble.central"
|
||
private static let peripheralRestorationID = "chat.bitchat.ble.peripheral"
|
||
|
||
// Default per-fragment chunk size when link limits are unknown
|
||
private let defaultFragmentSize = TransportConfig.bleDefaultFragmentSize
|
||
private let bleMaxMTU = 512
|
||
private let maxMessageLength = InputValidator.Limits.maxMessageLength
|
||
private let messageTTL: UInt8 = TransportConfig.messageTTLDefault
|
||
// Flood/battery controls
|
||
private let maxInFlightAssemblies = TransportConfig.bleMaxInFlightAssemblies // cap concurrent fragment assemblies
|
||
private let highDegreeThreshold = TransportConfig.bleHighDegreeThreshold // for adaptive TTL/probabilistic relays
|
||
|
||
// MARK: - Core State (5 Essential Collections)
|
||
|
||
// 1. Consolidated BLE link tracking for both central and peripheral roles.
|
||
private var linkStateStore = BLELinkStateStore()
|
||
|
||
// A peer ID can retain an established Noise session after its physical
|
||
// link disappears. Courier handover therefore needs the stronger fact
|
||
// that the session was established *on this current ingress link*, not
|
||
// merely that some session exists for the claimed ID. bleQueue-owned.
|
||
private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:]
|
||
private var noiseReconnectPolicy = BLENoiseReconnectPolicy()
|
||
|
||
// Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link
|
||
// store): entries older than the cooldown are pruned on insert.
|
||
private var lastLinkRebindAt: [String: Date] = [:]
|
||
|
||
// Redundant-link retirement cooldown per peer (bleQueue-owned): bounds
|
||
// how often a replayed announce could flip which duplicate link survives.
|
||
private var lastRedundantLinkRetirementAt: [PeerID: Date] = [:]
|
||
|
||
// BCH-01-004: Rate-limiting for subscription-triggered announces.
|
||
private var subscriptionAnnounceLimiter = BLESubscriptionAnnounceLimiter()
|
||
|
||
// 3. Peer Information (single source of truth)
|
||
private var peerRegistry = BLEPeerRegistry()
|
||
|
||
// 4. Efficient Message Deduplication
|
||
private let messageDeduplicator = MessageDeduplicator()
|
||
|
||
// Courier store-and-forward: envelopes this device carries for offline
|
||
// third parties, and the trust gate for accepting deposits. The policy
|
||
// maps (depositor key, announce-verified?) to a quota tier, or nil to
|
||
// reject. Injectable for tests; main-actor policy because favorites live
|
||
// on the main actor.
|
||
var courierStore: CourierStore = .shared
|
||
// Bulletin-board posts this device carries; injectable for tests.
|
||
var boardStore: BoardStore = .shared
|
||
var courierDepositPolicy: @MainActor (Data, Bool) -> CourierDepositTier? = { depositorNoiseKey, isVerifiedPeer in
|
||
if FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) { return .favorite }
|
||
return isVerifiedPeer ? .verified : nil
|
||
}
|
||
// Local-only store-and-forward counters; nil in unit tests.
|
||
var sfMetrics: StoreAndForwardMetrics?
|
||
|
||
// Verified one-time prekey bundles gossiped by other peers, used to seal
|
||
// courier mail forward-secretly. Injectable for tests.
|
||
var prekeyBundleStore: PrekeyBundleStore = .shared
|
||
// Throttle for re-broadcasting our own (unchanged) bundle; guarded by
|
||
// collectionsQueue barriers.
|
||
private var lastPrekeyBundleSentAt: Date?
|
||
// Prekey bundles that arrived before their owner's verified announce bound
|
||
// a signing key. The receive queue is concurrent, so a bundle can race
|
||
// ahead of the announce it depends on; we retain the latest such bundle per
|
||
// owner (bounded) and re-attempt attribution when the announce lands.
|
||
// Guarded by collectionsQueue barriers.
|
||
private var pendingPrekeyBundles: [PeerID: BitchatPacket] = [:]
|
||
private static let pendingPrekeyBundleCap = 64
|
||
// Gateway mode: sink for received nostrCarrier packets (set by app
|
||
// wiring, called on the main actor after transport-level checks) and the
|
||
// runtime-toggled capability bits ORed into `PeerCapabilities.localSupported`
|
||
// for every announce. `directedToUs` distinguishes an uplink deposit
|
||
// addressed to this device from a downlink broadcast.
|
||
var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)?
|
||
/// Fired (off-main) when a signature-verified announce is processed —
|
||
/// the bridge courier watch refreshes its tag set on new arrivals.
|
||
var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)?
|
||
private var runtimeCapabilities: PeerCapabilities = [] // collectionsQueue
|
||
private var localBridgeGeohash: String? // collectionsQueue
|
||
|
||
#if DEBUG
|
||
// Test-only tap on the outbound pipeline so multi-node tests can ferry
|
||
// packets between in-process service instances.
|
||
var _test_onOutboundPacket: ((BitchatPacket) -> Void)?
|
||
/// May block a synthetic CoreBluetooth receive callback immediately
|
||
/// before it hands a packet to `messageQueue`.
|
||
var _test_beforeReceivePacketHandoff: (() -> Void)?
|
||
var _test_onReceivePacketHandoff: (() -> Void)?
|
||
var _test_onPrivateMediaSessionReconciled: ((PeerID) -> Void)?
|
||
/// May block in tests to hold the serial message queue immediately before
|
||
/// the deferred private-media admission check.
|
||
var _test_beforePrivateMediaDeferredSend: ((String) -> Void)?
|
||
/// May block announce handling after verified-link rebind work is queued.
|
||
/// Tests use this boundary to prove rebind and reconnect are serialized.
|
||
var _test_afterVerifiedDirectRebindEnqueued: (() -> Void)?
|
||
/// May block the convergence-recovery callback on its global-queue thread
|
||
/// before it enqueues onto `messageQueue`. Tests use this boundary to
|
||
/// force the quarantine-restore handler to win the dispatch race.
|
||
var _test_beforeHandshakeRecoveryEnqueued: ((PeerID) -> Void)?
|
||
#endif
|
||
private var selfBroadcastTracker = BLESelfBroadcastTracker()
|
||
private let meshTopology = MeshTopologyTracker()
|
||
// Route health for originated source routes; guarded by collectionsQueue.
|
||
private var sourceRouteFailures = BLESourceRouteFailureCache()
|
||
|
||
// Mesh diagnostics: outstanding /ping probes keyed by nonce, plus the
|
||
// inbound ping budget — keyed by the ingress link (the directly connected
|
||
// peer that delivered the packet), since the unsigned claimed sender is
|
||
// spoofable — so a directed unencrypted probe cannot be turned into an
|
||
// amplification primitive. Both are owned by collectionsQueue barriers
|
||
// like the other mutable collections.
|
||
private struct PendingMeshPing {
|
||
let peerID: PeerID
|
||
let sentAt: Date
|
||
let lifecycleGeneration: UInt64
|
||
let completion: @MainActor (MeshPingResult?) -> Void
|
||
let timeout: DispatchWorkItem
|
||
}
|
||
private var pendingMeshPings: [Data: PendingMeshPing] = [:]
|
||
private var meshPingResponseLimiter = SyncResponseRateLimiter(
|
||
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
|
||
window: TransportConfig.meshPingInboundWindowSeconds
|
||
)
|
||
|
||
// 5. Fragment Reassembly (necessary for messages > MTU)
|
||
private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer()
|
||
private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler()
|
||
private lazy var privateMediaTransferAdmissions = BLEPrivateMediaTransferAdmissionRegistry { [weak self] transferId in
|
||
self?.handlePrivateMediaAdmissionExpiry(transferId)
|
||
}
|
||
// All six maps below are protected by `collectionsQueue`. A fresh Noise
|
||
// authentication rotates the generation UUID, so stale proof timers and
|
||
// proof packets cannot classify a replacement session.
|
||
private var privateMediaSessionGenerations: [PeerID: UUID] = [:]
|
||
private var authenticatedPeerStates: [PeerID: BLEAuthenticatedPeerStateObservation] = [:]
|
||
private var privateMediaProofTimeoutMarkers: [PeerID: BLEPrivateMediaProofTimeoutMarker] = [:]
|
||
private var privateMediaProofWatchdogs: [PeerID: BLEPrivateMediaProofWatchdog] = [:]
|
||
private var pendingPrivateMediaPolicyResolutions: [PeerID: BLEPendingPrivateMediaPolicyResolution] = [:]
|
||
private var authenticatedPeerStateSendProgress: [PeerID: BLEAuthenticatedPeerStateSendProgress] = [:]
|
||
private let incomingFileStore: BLEIncomingFileStore
|
||
|
||
// Simple announce throttling
|
||
private let announceThrottle = BLEAnnounceThrottle()
|
||
|
||
// Application state tracking (thread-safe)
|
||
#if os(iOS)
|
||
private var isAppActive: Bool = true // Assume active initially
|
||
/// Last `UIApplication.shared.backgroundTimeRemaining` sampled on the
|
||
/// main thread, cached so bleQueue status logs can read it without ever
|
||
/// dispatching to main (see `captureBluetoothStatus` for the invariant).
|
||
private let backgroundTimeLock = NSLock()
|
||
private var _cachedBackgroundTimeRemaining: TimeInterval = .greatestFiniteMagnitude
|
||
private var cachedBackgroundTimeRemaining: TimeInterval {
|
||
backgroundTimeLock.lock(); defer { backgroundTimeLock.unlock() }
|
||
return _cachedBackgroundTimeRemaining
|
||
}
|
||
#endif
|
||
|
||
// MARK: - Core BLE Objects
|
||
|
||
private var centralManager: CBCentralManager?
|
||
private var peripheralManager: CBPeripheralManager?
|
||
private var characteristic: CBMutableCharacteristic?
|
||
private let shouldInitializeBluetoothManagers: Bool
|
||
private let panicLifecycleLock = NSLock()
|
||
private var _isPanicSuspended: Bool
|
||
private var panicLifecycleGeneration: UInt64 = 0
|
||
|
||
// MARK: - Identity
|
||
|
||
private var noiseService: NoiseEncryptionService
|
||
/// Injected so tests can compress the quarantine/rollback window;
|
||
/// production always passes the security-constant default.
|
||
private let noiseResponderHandshakeTimeout: TimeInterval
|
||
private let identityManager: SecureIdentityStateManagerProtocol
|
||
private let keychain: KeychainManagerProtocol
|
||
private let idBridge: NostrIdentityBridge
|
||
private let localIdentityState = BLELocalIdentityStateStore()
|
||
|
||
// MARK: - Advertising Privacy
|
||
// No Local Name by default for maximum privacy. No rotating alias.
|
||
|
||
// MARK: - Queues
|
||
|
||
private let messageQueue = DispatchQueue(label: "mesh.message", attributes: .concurrent)
|
||
private let collectionsQueue = DispatchQueue(label: "mesh.collections", attributes: .concurrent)
|
||
private let messageQueueKey = DispatchSpecificKey<Void>()
|
||
private let bleQueue = DispatchQueue(label: "mesh.bluetooth", qos: .userInitiated)
|
||
private let bleQueueKey = DispatchSpecificKey<Void>()
|
||
|
||
// Noise messages and typed payloads pending handshake completion.
|
||
private var pendingNoiseSessionQueues = BLENoiseSessionQueues()
|
||
// Queue for notifications that failed due to full queue
|
||
private var pendingNotifications = BLEOutboundNotificationBuffer<CBCentral>()
|
||
// Backpressure logging fires per fragment during media transfers
|
||
// (hundreds of lines per image); sampled via this counter, which is
|
||
// only touched inside collectionsQueue barriers (no sync needed).
|
||
var notificationBackpressureLogCount = 0
|
||
|
||
// Accumulate long write chunks per central until a full frame decodes
|
||
private var pendingWriteBuffers = BLEInboundWriteBuffer()
|
||
// Relay jitter scheduling to reduce redundant floods
|
||
private var scheduledRelays = BLEScheduledRelayStore()
|
||
// Track short-lived traffic bursts to adapt announces/scanning under load
|
||
private var recentTrafficTracker = BLERecentTrafficTracker()
|
||
|
||
// Ingress link tracking for duplicate and last-hop suppression
|
||
private var ingressLinks = BLEIngressLinkRegistry()
|
||
// Inner message IDs of recently opened courier envelopes. Redundant
|
||
// copies of one message ride different envelopes (each seal uses a fresh
|
||
// ephemeral key, and bridge drops multiply across relays/couriers), so
|
||
// envelope-level dedup can't catch them; dedup on the inner ID before
|
||
// delivery so a duplicate costs one decrypt instead of a delivery + ack
|
||
// + handshake each. Owned by collectionsQueue barriers.
|
||
private var openedCourierMessageIDs = BoundedIDSet(capacity: TransportConfig.courierOpenedMessageIDCap)
|
||
private let logRateLimiter = BLELogRateLimiter(defaultMinimumInterval: 5)
|
||
|
||
private var pendingPeripheralWrites = BLEOutboundWriteBuffer()
|
||
// Debounce duplicate disconnect notifies
|
||
private var disconnectNotifyDebouncer = BLEPeerEventDebouncer()
|
||
// Store-and-forward for directed messages when we have no links
|
||
private var pendingDirectedRelays = BLEDirectedRelaySpool()
|
||
// Debounce for 'reconnected' logs
|
||
private var reconnectLogDebouncer = BLEPeerEventDebouncer()
|
||
// Announce-packet orchestration (queue hops stay in the environment closures)
|
||
private lazy var announceHandler = BLEAnnounceHandler(environment: makeAnnounceHandlerEnvironment())
|
||
// Public-message orchestration (queue hops stay in the environment closures)
|
||
private lazy var publicMessageHandler = BLEPublicMessageHandler(environment: makePublicMessageHandlerEnvironment())
|
||
// Noise handshake/encrypted orchestration (queue hops and crypto stay in the environment closures)
|
||
private lazy var noisePacketHandler = BLENoisePacketHandler(environment: makeNoisePacketHandlerEnvironment())
|
||
// Fragment-assembly orchestration (queue hops stay in the environment closures)
|
||
private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment())
|
||
// File-transfer orchestration (queue hops stay in the environment closures)
|
||
private lazy var fileTransferHandler = BLEFileTransferHandler(environment: makeFileTransferHandlerEnvironment())
|
||
|
||
// MARK: - Gossip Sync
|
||
private var gossipSyncManager: GossipSyncManager?
|
||
private let requestSyncManager = RequestSyncManager()
|
||
|
||
// MARK: - Maintenance Timer
|
||
|
||
private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks
|
||
private var maintenanceCounter = 0 // Track maintenance cycles
|
||
private var lastMaintenanceAt = Date.distantPast // bleQueue-confined; drives background-wake catch-up passes
|
||
/// Whether real CoreBluetooth managers were initialized. When false (unit
|
||
/// tests), periodic mesh background work is not started — the maintenance
|
||
/// timer and the gossip-sync timers only drain BLE writes/notifications,
|
||
/// re-announce, and sign/broadcast sync packets, all meaningless without
|
||
/// Bluetooth. Leaving them running in the test process is pure background
|
||
/// churn that aggravates flaky exit hangs.
|
||
private var meshBackgroundEnabled = false
|
||
|
||
// MARK: - Connection budget & scheduling (central role)
|
||
private var connectionScheduler = BLEConnectionScheduler<CBPeripheral>()
|
||
// Recently seen peripherals retained for background wake-on-proximity
|
||
// connects (bleQueue-confined, like the link state store)
|
||
private let recentPeripheralCache = BLERecentPeripheralCache<CBPeripheral>()
|
||
|
||
// MARK: - Adaptive scanning duty-cycle
|
||
private var scanDutyTimer: DispatchSourceTimer?
|
||
private var dutyEnabled: Bool = true
|
||
private var dutyOnDuration: TimeInterval = TransportConfig.bleDutyOnDuration
|
||
private var dutyOffDuration: TimeInterval = TransportConfig.bleDutyOffDuration
|
||
private var dutyActive: Bool = false
|
||
|
||
// Debounced publish to coalesce rapid changes
|
||
private var peerPublishCoalescer = BLEPeerPublishCoalescer()
|
||
private func requestPeerDataPublish() {
|
||
switch peerPublishCoalescer.requestPublish(now: Date()) {
|
||
case .publishNow:
|
||
publishFullPeerData()
|
||
case .schedule(let delay):
|
||
messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||
guard let self = self else { return }
|
||
self.peerPublishCoalescer.scheduledPublishFired(now: Date())
|
||
self.publishFullPeerData()
|
||
}
|
||
case .skip:
|
||
break
|
||
}
|
||
}
|
||
|
||
// MARK: - Initialization
|
||
|
||
init(
|
||
keychain: KeychainManagerProtocol,
|
||
idBridge: NostrIdentityBridge,
|
||
identityManager: SecureIdentityStateManagerProtocol,
|
||
initializeBluetoothManagers: Bool = true,
|
||
incomingFileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
|
||
startSuspendedForPanicRecovery: Bool = false,
|
||
noiseResponderHandshakeTimeout: TimeInterval =
|
||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
||
) {
|
||
self.keychain = keychain
|
||
self.idBridge = idBridge
|
||
self.incomingFileStore = incomingFileStore
|
||
self.shouldInitializeBluetoothManagers = initializeBluetoothManagers
|
||
self._isPanicSuspended = startSuspendedForPanicRecovery
|
||
self.noiseResponderHandshakeTimeout = noiseResponderHandshakeTimeout
|
||
noiseService = NoiseEncryptionService(
|
||
keychain: keychain,
|
||
ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout
|
||
)
|
||
self.identityManager = identityManager
|
||
super.init()
|
||
|
||
configureNoiseServiceCallbacks(for: noiseService)
|
||
refreshPeerIdentity()
|
||
|
||
// Set queue key for identification
|
||
messageQueue.setSpecific(key: messageQueueKey, value: ())
|
||
|
||
// Set up application state tracking (iOS only)
|
||
#if os(iOS)
|
||
// Check initial state on main thread. The background-budget cache is
|
||
// seeded here too: a background-restore launch captures Bluetooth
|
||
// status before any lifecycle notification fires, and the init-time
|
||
// sentinel would log a meaningless bgRemaining=∞ for exactly the
|
||
// wake window that matters.
|
||
if Thread.isMainThread {
|
||
isAppActive = UIApplication.shared.applicationState == .active
|
||
refreshCachedBackgroundTimeRemaining()
|
||
} else {
|
||
DispatchQueue.main.sync {
|
||
isAppActive = UIApplication.shared.applicationState == .active
|
||
refreshCachedBackgroundTimeRemaining()
|
||
}
|
||
}
|
||
|
||
// Observe application state changes
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(appDidBecomeActive),
|
||
name: UIApplication.didBecomeActiveNotification,
|
||
object: nil
|
||
)
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(appDidEnterBackground),
|
||
name: UIApplication.didEnterBackgroundNotification,
|
||
object: nil
|
||
)
|
||
#endif
|
||
|
||
// Tag BLE queue for re-entrancy detection
|
||
bleQueue.setSpecific(key: bleQueueKey, value: ())
|
||
// Link state is owned exclusively by bleQueue; debug builds trap
|
||
// any access from another queue (cross-queue reads use readLinkState).
|
||
linkStateStore.assumeOwnership(of: bleQueue)
|
||
|
||
if !startSuspendedForPanicRecovery {
|
||
initializeBluetoothManagersIfNeeded()
|
||
}
|
||
|
||
// Single maintenance timer for all periodic tasks (dispatch-based for
|
||
// determinism). Only run it when real Bluetooth managers exist.
|
||
meshBackgroundEnabled = initializeBluetoothManagers
|
||
if !startSuspendedForPanicRecovery {
|
||
startMaintenanceTimer()
|
||
}
|
||
|
||
// Publish initial empty state
|
||
requestPeerDataPublish()
|
||
|
||
// Initialize gossip sync manager
|
||
if !startSuspendedForPanicRecovery {
|
||
restartGossipManager()
|
||
}
|
||
}
|
||
|
||
private var isPanicSuspended: Bool {
|
||
panicLifecycleLock.lock()
|
||
defer { panicLifecycleLock.unlock() }
|
||
return _isPanicSuspended
|
||
}
|
||
|
||
private func setPanicSuspended(_ suspended: Bool) {
|
||
panicLifecycleLock.lock()
|
||
if suspended {
|
||
panicLifecycleGeneration &+= 1
|
||
}
|
||
_isPanicSuspended = suspended
|
||
panicLifecycleLock.unlock()
|
||
}
|
||
|
||
private func capturePanicLifecycleGeneration() -> UInt64? {
|
||
panicLifecycleLock.lock()
|
||
defer { panicLifecycleLock.unlock() }
|
||
return _isPanicSuspended ? nil : panicLifecycleGeneration
|
||
}
|
||
|
||
private func isCurrentPanicLifecycleGeneration(_ generation: UInt64) -> Bool {
|
||
panicLifecycleLock.lock()
|
||
defer { panicLifecycleLock.unlock() }
|
||
return !_isPanicSuspended && panicLifecycleGeneration == generation
|
||
}
|
||
|
||
private func initializeBluetoothManagersIfNeeded() {
|
||
guard shouldInitializeBluetoothManagers,
|
||
centralManager == nil,
|
||
peripheralManager == nil,
|
||
!isPanicSuspended else { return }
|
||
|
||
// Initialize BLE on its dedicated delegate queue. On iOS, retain the
|
||
// restoration identifiers even when construction was deferred by a
|
||
// pending panic-recovery latch.
|
||
#if os(iOS)
|
||
let centralOptions: [String: Any] = [
|
||
CBCentralManagerOptionRestoreIdentifierKey:
|
||
BLEService.centralRestorationID
|
||
]
|
||
centralManager = CBCentralManager(
|
||
delegate: self,
|
||
queue: bleQueue,
|
||
options: centralOptions
|
||
)
|
||
|
||
let peripheralOptions: [String: Any] = [
|
||
CBPeripheralManagerOptionRestoreIdentifierKey:
|
||
BLEService.peripheralRestorationID
|
||
]
|
||
peripheralManager = CBPeripheralManager(
|
||
delegate: self,
|
||
queue: bleQueue,
|
||
options: peripheralOptions
|
||
)
|
||
#else
|
||
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
|
||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
|
||
#endif
|
||
}
|
||
|
||
private func restartGossipManager() {
|
||
guard !isPanicSuspended else { return }
|
||
// Stop existing
|
||
gossipSyncManager?.stop()
|
||
|
||
let config = GossipSyncManager.Config(
|
||
seenCapacity: TransportConfig.syncSeenCapacity,
|
||
gcsMaxBytes: TransportConfig.syncGCSMaxBytes,
|
||
gcsTargetFpr: TransportConfig.syncGCSTargetFpr,
|
||
maxMessageAgeSeconds: TransportConfig.syncMaxMessageAgeSeconds,
|
||
publicMessageMaxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds,
|
||
maintenanceIntervalSeconds: TransportConfig.syncMaintenanceIntervalSeconds,
|
||
stalePeerCleanupIntervalSeconds: TransportConfig.syncStalePeerCleanupIntervalSeconds,
|
||
stalePeerTimeoutSeconds: TransportConfig.syncStalePeerTimeoutSeconds,
|
||
fragmentCapacity: TransportConfig.syncFragmentCapacity,
|
||
fileTransferCapacity: TransportConfig.syncFileTransferCapacity,
|
||
fragmentSyncIntervalSeconds: TransportConfig.syncFragmentIntervalSeconds,
|
||
fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds,
|
||
messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds,
|
||
responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses,
|
||
responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds,
|
||
prekeyBundleCapacity: TransportConfig.syncPrekeyBundleCapacity,
|
||
prekeyBundleSyncIntervalSeconds: TransportConfig.syncPrekeyBundleIntervalSeconds,
|
||
prekeyBundleMaxAgeSeconds: TransportConfig.syncPrekeyBundleMaxAgeSeconds
|
||
)
|
||
|
||
// Only real Bluetooth sessions archive to disk; unit tests stay hermetic.
|
||
let archive = meshBackgroundEnabled ? GossipMessageArchive() : nil
|
||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager, archive: archive)
|
||
manager.delegate = self
|
||
// Board posts sync from the board store (their retention owner) so
|
||
// deleted/expired posts drop out of rounds immediately. Real sessions
|
||
// only, matching the archive: unit tests stay hermetic.
|
||
if meshBackgroundEnabled {
|
||
manager.boardPacketsProvider = { [weak self] in
|
||
self?.boardStore.syncCandidates() ?? []
|
||
}
|
||
}
|
||
// Only start the periodic sync timers when real Bluetooth exists. In unit
|
||
// tests there is no mesh to sync with, and the periodic sign/broadcast
|
||
// churn just keeps the process busy and aggravates flaky exit hangs.
|
||
if meshBackgroundEnabled {
|
||
manager.start()
|
||
}
|
||
gossipSyncManager = manager
|
||
}
|
||
|
||
// No advertising policy to set; we never include Local Name in adverts.
|
||
|
||
deinit {
|
||
maintenanceTimer?.cancel()
|
||
scanDutyTimer?.cancel()
|
||
scanDutyTimer = nil
|
||
centralManager?.stopScan()
|
||
peripheralManager?.stopAdvertising()
|
||
#if os(iOS)
|
||
NotificationCenter.default.removeObserver(self)
|
||
#endif
|
||
}
|
||
|
||
/// Close radio admission before application state starts disappearing.
|
||
/// CoreBluetooth callbacks consult the same gate and cannot restart scan
|
||
/// or advertising while the full panic transaction is incomplete.
|
||
func suspendForPanicReset() {
|
||
setPanicSuspended(true)
|
||
gossipSyncManager?.stop()
|
||
gossipSyncManager = nil
|
||
// Stop the radio and drain CoreBluetooth's delegate queue first. A
|
||
// callback may already have passed its initial suspension check; the
|
||
// bleQueue drain forces its final messageQueue handoff to happen
|
||
// before the receive barrier below.
|
||
stopServicesImmediatelyForPanic()
|
||
// Drain every receive/send submitted by callbacks that finished ahead
|
||
// of the radio stop. Later callbacks observe the closed lifecycle, and
|
||
// generation-bound handoffs that raced this barrier reject themselves.
|
||
messageQueue.sync(flags: .barrier) {}
|
||
clearEmergencySessionState()
|
||
}
|
||
|
||
/// Reopen the radio only after media deletion and recovery-marker commit.
|
||
func completePanicReset(restartServices: Bool) {
|
||
setPanicSuspended(false)
|
||
guard restartServices else { return }
|
||
startServices()
|
||
sendAnnounce(forceSend: true)
|
||
}
|
||
|
||
func resetIdentityForPanic(
|
||
currentNickname: String,
|
||
restartServices: Bool = true
|
||
) {
|
||
gossipSyncManager?.stop()
|
||
gossipSyncManager = nil
|
||
// pendingNoiseSessionQueues is owned by collectionsQueue everywhere
|
||
// else, so clear it there too rather than on messageQueue.
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
pendingNoiseSessionQueues.removeAll()
|
||
}
|
||
|
||
let panicReset = collectionsQueue.sync(flags: .barrier) {
|
||
pendingPeripheralWrites.removeAll()
|
||
pendingNotifications.removeAll()
|
||
let transfers = outboundFragmentTransfers.removeAll()
|
||
fragmentAssemblyBuffer.removeAll()
|
||
pendingDirectedRelays.removeAll()
|
||
ingressLinks.removeAll()
|
||
recentTrafficTracker.removeAll()
|
||
scheduledRelays.cancelAll()
|
||
// These callbacks belong to pre-panic transfer state. Invoking
|
||
// them would let queued UI work recreate or resend wiped media.
|
||
pendingPrivateMediaPolicyResolutions.removeAll()
|
||
privateMediaSessionGenerations.removeAll()
|
||
authenticatedPeerStates.removeAll()
|
||
privateMediaProofTimeoutMarkers.removeAll()
|
||
privateMediaProofWatchdogs.removeAll()
|
||
authenticatedPeerStateSendProgress.removeAll()
|
||
// Let the post-panic identity publish its fresh bundle promptly.
|
||
lastPrekeyBundleSentAt = nil
|
||
return transfers
|
||
}
|
||
|
||
for entry in panicReset {
|
||
entry.workItems.forEach { $0.cancel() }
|
||
TransferProgressManager.shared.cancel(id: entry.id)
|
||
}
|
||
|
||
bleQueue.sync {
|
||
pendingWriteBuffers.removeAll()
|
||
noiseAuthenticatedLinkOwners.removeAll()
|
||
noiseReconnectPolicy.removeAll()
|
||
connectionScheduler.reset()
|
||
}
|
||
disconnectNotifyDebouncer.removeAll()
|
||
|
||
// The crypto-service replacement and the derived identity swap must be
|
||
// one atomic unit with respect to messageQueue senders: a queued send
|
||
// must never observe the new Noise service alongside the old peer ID
|
||
// (it would sign with the new identity while carrying the old sender).
|
||
// refreshPeerIdentity() executes inline here via its re-entrancy check.
|
||
messageQueue.sync(flags: .barrier) {
|
||
noiseService.clearEphemeralStateForPanic()
|
||
noiseService.clearPersistentIdentity()
|
||
|
||
let newNoise = NoiseEncryptionService(
|
||
keychain: keychain,
|
||
ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout
|
||
)
|
||
noiseService = newNoise
|
||
configureNoiseServiceCallbacks(for: newNoise)
|
||
refreshPeerIdentity()
|
||
}
|
||
// Keep the transport silent until the application-level transaction
|
||
// has also removed its media and committed both recovery markers.
|
||
// Set through the identity store directly (not setNickname(_:), which
|
||
// would force-send an announce and break that silence).
|
||
localIdentityState.setNickname(currentNickname)
|
||
messageDeduplicator.reset()
|
||
messageQueue.async(flags: .barrier) { [weak self] in
|
||
self?.selfBroadcastTracker.removeAll()
|
||
}
|
||
requestPeerDataPublish()
|
||
if restartServices {
|
||
restartGossipManager()
|
||
startServices()
|
||
sendAnnounce(forceSend: true)
|
||
}
|
||
}
|
||
|
||
// Ensure this runs on message queue to avoid main thread blocking
|
||
func sendMessage(_ content: String, mentions: [String] = [], to recipientID: PeerID? = nil, messageID: String? = nil, timestamp: Date? = nil) {
|
||
// Call directly if already on messageQueue, otherwise dispatch
|
||
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||
messageQueue.async { [weak self] in
|
||
self?.sendMessage(content, mentions: mentions, to: recipientID, messageID: messageID, timestamp: timestamp)
|
||
}
|
||
return
|
||
}
|
||
guard !isPanicSuspended else { return }
|
||
|
||
guard content.count <= maxMessageLength else {
|
||
SecureLogger.error("Message too long: \(content.count) chars", category: .session)
|
||
return
|
||
}
|
||
|
||
if let recipientID {
|
||
sendPrivateMessage(content, to: recipientID, messageID: messageID ?? UUID().uuidString)
|
||
return
|
||
}
|
||
|
||
// Public broadcast
|
||
// Create packet with explicit fields so we can sign it
|
||
let sendDate = timestamp ?? Date()
|
||
let sendTimestampMs = UInt64(sendDate.timeIntervalSince1970 * 1000)
|
||
let basePacket = BitchatPacket(
|
||
type: MessageType.message.rawValue,
|
||
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||
recipientID: nil,
|
||
timestamp: sendTimestampMs,
|
||
payload: Data(content.utf8),
|
||
signature: nil,
|
||
ttl: messageTTL
|
||
)
|
||
guard let signedPacket = noiseService.signPacket(basePacket) else {
|
||
SecureLogger.error("❌ Failed to sign public message", category: .security)
|
||
return
|
||
}
|
||
// Pre-mark our own broadcast as processed to avoid handling relayed self copy
|
||
let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket)
|
||
messageDeduplicator.markProcessed(dedupID)
|
||
if let messageID {
|
||
selfBroadcastTracker.record(messageID: messageID, packet: signedPacket, sentAt: sendDate)
|
||
}
|
||
// Call synchronously since we're already on background queue
|
||
broadcastPacket(signedPacket)
|
||
// Track our own broadcast for sync
|
||
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||
}
|
||
|
||
// MARK: - Transport Protocol Conformance
|
||
|
||
// MARK: Delegates
|
||
|
||
weak var delegate: BitchatDelegate?
|
||
weak var eventDelegate: TransportEventDelegate?
|
||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||
|
||
func currentPeerSnapshots() -> [TransportPeerSnapshot] {
|
||
collectionsQueue.sync {
|
||
peerRegistry.transportSnapshots(selfNickname: myNickname)
|
||
}
|
||
}
|
||
|
||
// MARK: Identity
|
||
|
||
/// Derived from the Noise identity fingerprint. Reads can originate from
|
||
/// the main actor, message queue, Bluetooth queue, and maintenance timer,
|
||
/// so all three local identity fields live in one lock-backed snapshot.
|
||
var myPeerID: PeerID { localIdentityState.snapshot().peerID }
|
||
var myNickname: String { localIdentityState.snapshot().nickname }
|
||
private var myPeerIDData: Data { localIdentityState.snapshot().peerIDData }
|
||
|
||
/// Sole mutator for `myNickname`: updates the stored value and force-sends
|
||
/// an announce so peers learn the new name.
|
||
func setNickname(_ nickname: String) {
|
||
localIdentityState.setNickname(nickname)
|
||
// Send announce to notify peers of nickname change (force send)
|
||
sendAnnounce(forceSend: true)
|
||
}
|
||
|
||
// MARK: Lifecycle
|
||
|
||
/// Creates and starts the periodic maintenance timer if it is not already
|
||
/// running. Idempotent so it can be called from both `init` and
|
||
/// `startServices()` — the latter matters after a panic reset, where
|
||
/// `stopServices()` cancels and nils the timer.
|
||
private func startMaintenanceTimer() {
|
||
guard !isPanicSuspended,
|
||
meshBackgroundEnabled,
|
||
maintenanceTimer == nil else { return }
|
||
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
||
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
|
||
repeating: TransportConfig.bleMaintenanceInterval,
|
||
leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds))
|
||
timer.setEventHandler { [weak self] in
|
||
self?.performMaintenance()
|
||
}
|
||
timer.resume()
|
||
maintenanceTimer = timer
|
||
}
|
||
|
||
func startServices() {
|
||
guard let lifecycleGeneration =
|
||
capturePanicLifecycleGeneration() else { return }
|
||
initializeBluetoothManagersIfNeeded()
|
||
if gossipSyncManager == nil {
|
||
restartGossipManager()
|
||
}
|
||
// Restart the maintenance timer if a prior stopServices() cancelled it
|
||
// (e.g. the panic flow), otherwise periodic announces, peer reconciliation
|
||
// and cache cleanup would never resume until app restart.
|
||
startMaintenanceTimer()
|
||
|
||
// Start BLE services if not already running
|
||
if centralManager?.state == .poweredOn {
|
||
centralManager?.scanForPeripherals(
|
||
withServices: [BLEService.serviceUUID],
|
||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
|
||
)
|
||
}
|
||
|
||
// Send initial announce after services are ready
|
||
// Use longer delay to avoid conflicts with other announces
|
||
messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
|
||
guard let self,
|
||
self.isCurrentPanicLifecycleGeneration(
|
||
lifecycleGeneration
|
||
) else { return }
|
||
self.sendAnnounce(forceSend: true)
|
||
}
|
||
}
|
||
|
||
func stopServices() {
|
||
let localIdentity = localIdentityState.snapshot()
|
||
// Send leave message synchronously to ensure delivery
|
||
var leavePacket = BitchatPacket(
|
||
type: MessageType.leave.rawValue,
|
||
senderID: localIdentity.peerIDData,
|
||
recipientID: nil,
|
||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||
payload: Data(),
|
||
signature: nil,
|
||
ttl: messageTTL
|
||
)
|
||
|
||
if let signed = noiseService.signPacket(leavePacket) {
|
||
leavePacket = signed
|
||
}
|
||
|
||
// Send immediately to all connected peers (synchronized access to BLE state)
|
||
if let data = leavePacket.toBinaryData(padding: false) {
|
||
let leavePriority = BLEOutboundPacketPolicy.priority(for: leavePacket, data: data)
|
||
|
||
// Snapshot BLE state under bleQueue to avoid races with delegate callbacks
|
||
let (peripheralStates, centralsCount, char) = bleQueue.sync {
|
||
(linkStateStore.peripheralStates, linkStateStore.subscribedCentralCount, characteristic)
|
||
}
|
||
|
||
// Send to peripherals we're connected to as central
|
||
for state in peripheralStates where state.isConnected {
|
||
if let characteristic = state.characteristic {
|
||
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic, priority: leavePriority)
|
||
}
|
||
}
|
||
|
||
// Send to centrals subscribed to us as peripheral
|
||
if centralsCount > 0, let ch = char {
|
||
peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: nil)
|
||
}
|
||
}
|
||
|
||
// Give leave message a moment to send (cooperative delay allows BLE callbacks to fire)
|
||
let deadline = Date().addingTimeInterval(TransportConfig.bleThreadSleepWriteShortDelaySeconds)
|
||
while Date() < deadline {
|
||
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
|
||
}
|
||
|
||
// Clear pending notifications
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
pendingNotifications.removeAll()
|
||
}
|
||
|
||
// Stop timer
|
||
maintenanceTimer?.cancel()
|
||
maintenanceTimer = nil
|
||
scanDutyTimer?.cancel()
|
||
scanDutyTimer = nil
|
||
|
||
centralManager?.stopScan()
|
||
peripheralManager?.stopAdvertising()
|
||
|
||
// Disconnect all peripherals (synchronized access)
|
||
let peripheralsToDisconnect = bleQueue.sync { linkStateStore.peripheralStates }
|
||
for state in peripheralsToDisconnect {
|
||
centralManager?.cancelPeripheralConnection(state.peripheral)
|
||
}
|
||
}
|
||
|
||
/// Panic cannot spend its security boundary sending a signed LEAVE or
|
||
/// pumping the main run loop. Close the radio and timers immediately;
|
||
/// the identity/session cleanup follows synchronously.
|
||
private func stopServicesImmediatelyForPanic() {
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
pendingNotifications.removeAll()
|
||
}
|
||
|
||
maintenanceTimer?.cancel()
|
||
maintenanceTimer = nil
|
||
scanDutyTimer?.cancel()
|
||
scanDutyTimer = nil
|
||
|
||
centralManager?.stopScan()
|
||
peripheralManager?.stopAdvertising()
|
||
|
||
let peripheralsToDisconnect = bleQueue.sync {
|
||
linkStateStore.peripheralStates
|
||
}
|
||
for state in peripheralsToDisconnect {
|
||
centralManager?.cancelPeripheralConnection(state.peripheral)
|
||
}
|
||
}
|
||
|
||
func emergencyDisconnectAll() {
|
||
stopServices()
|
||
clearEmergencySessionState()
|
||
}
|
||
|
||
private func clearEmergencySessionState() {
|
||
// Clear all sessions and peers
|
||
let cancelled = collectionsQueue.sync(flags: .barrier) {
|
||
let entries = outboundFragmentTransfers.removeAll().map {
|
||
(id: $0.id, items: $0.workItems)
|
||
}
|
||
let pingTimeouts = pendingMeshPings.values.map(\.timeout)
|
||
pendingMeshPings.removeAll()
|
||
meshPingResponseLimiter = SyncResponseRateLimiter(
|
||
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
|
||
window: TransportConfig.meshPingInboundWindowSeconds
|
||
)
|
||
peerRegistry.removeAll()
|
||
fragmentAssemblyBuffer.removeAll()
|
||
sourceRouteFailures = BLESourceRouteFailureCache()
|
||
// Also clear pending message queues to avoid stale state across sessions
|
||
pendingNoiseSessionQueues.removeAll()
|
||
pendingDirectedRelays.removeAll()
|
||
return (transfers: entries, pingTimeouts: pingTimeouts)
|
||
}
|
||
|
||
for entry in cancelled.transfers {
|
||
entry.items.forEach { $0.cancel() }
|
||
TransferProgressManager.shared.cancel(id: entry.id)
|
||
}
|
||
cancelled.pingTimeouts.forEach { $0.cancel() }
|
||
|
||
// Clear processed messages
|
||
messageDeduplicator.reset()
|
||
|
||
// Clear peripheral references (synchronized access to avoid races with BLE callbacks)
|
||
bleQueue.sync {
|
||
linkStateStore.clearAll()
|
||
noiseAuthenticatedLinkOwners.removeAll()
|
||
noiseReconnectPolicy.removeAll()
|
||
connectionScheduler.reset()
|
||
subscriptionAnnounceLimiter.removeAll()
|
||
}
|
||
meshTopology.reset()
|
||
}
|
||
|
||
// MARK: Connectivity and peers
|
||
|
||
func isPeerConnected(_ peerID: PeerID) -> Bool {
|
||
// Accept both 16-hex short IDs and 64-hex Noise keys
|
||
return collectionsQueue.sync { peerRegistry.isConnected(peerID) }
|
||
}
|
||
|
||
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
||
// Accept both 16-hex short IDs and 64-hex Noise keys
|
||
return collectionsQueue.sync {
|
||
peerRegistry.isReachable(peerID, now: Date())
|
||
}
|
||
}
|
||
|
||
func canDeliverSecurely(to peerID: PeerID) -> Bool {
|
||
// A live link binding alone is forgeable: the rotation heal rebinds a
|
||
// link on a signature-verified "direct" announce, but directness rides
|
||
// on the unsigned TTL, so a replayed announce can bind an absent
|
||
// peer's ID to the replayer's link. An established Noise session
|
||
// proves the other end of the link holds the peer's private key.
|
||
//
|
||
// Sessions are keyed by the short wire ID, so normalize like
|
||
// isPeerConnected does — a send keyed by the full 64-hex Noise key
|
||
// must not misread an established session as insecure.
|
||
noiseService.hasEstablishedSession(with: peerID.toShort())
|
||
}
|
||
|
||
func peerNickname(peerID: PeerID) -> String? {
|
||
collectionsQueue.sync {
|
||
peerRegistry.nickname(for: peerID, connectedOnly: true)
|
||
}
|
||
}
|
||
|
||
/// Capabilities the peer advertised in its last verified announce.
|
||
/// Empty for peers that predate the capabilities TLV.
|
||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities {
|
||
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
|
||
}
|
||
|
||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||
let normalizedPeerID = peerID.toShort()
|
||
let state: (
|
||
capabilities: PeerCapabilities,
|
||
fingerprint: String?,
|
||
sessionGeneration: UUID?,
|
||
authenticatedState: BLEAuthenticatedPeerStateObservation?,
|
||
timedOut: BLEPrivateMediaProofTimeoutMarker?
|
||
) = collectionsQueue.sync {
|
||
let info = peerRegistry.info(for: normalizedPeerID)
|
||
return (
|
||
info?.capabilities ?? [],
|
||
info?.noisePublicKey?.sha256Fingerprint(),
|
||
privateMediaSessionGenerations[normalizedPeerID],
|
||
authenticatedPeerStates[normalizedPeerID],
|
||
privateMediaProofTimeoutMarkers[normalizedPeerID]
|
||
)
|
||
}
|
||
let currentNoiseGeneration = noiseService.sessionGeneration(for: normalizedPeerID)
|
||
|
||
// A session replacement can happen before its authentication callback
|
||
// reaches messageQueue. Never reuse an observation from the previous
|
||
// transport generation during that window.
|
||
if state.sessionGeneration != currentNoiseGeneration {
|
||
return .awaitingCapabilityProof
|
||
}
|
||
|
||
guard let fingerprint = state.fingerprint else {
|
||
// A raw fallback must be bound to the stable Noise key from a
|
||
// verified registry entry; a routing ID alone can rotate or be
|
||
// spoofed. Without that key neither proof nor safe migration state
|
||
// can be attributed.
|
||
return .blockedDowngrade
|
||
}
|
||
|
||
let wasPreviouslyCapable = identityManager.hasObservedPrivateMediaCapability(
|
||
fingerprint: fingerprint
|
||
)
|
||
|
||
if let authenticated = state.authenticatedState,
|
||
authenticated.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame,
|
||
authenticated.sessionGeneration == state.sessionGeneration {
|
||
if authenticated.capabilities.contains(.privateMedia) {
|
||
return .encrypted
|
||
}
|
||
return wasPreviouslyCapable ? .blockedDowngrade : .legacyRequiresConsent
|
||
}
|
||
|
||
if let timedOut = state.timedOut,
|
||
timedOut.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame,
|
||
timedOut.sessionGeneration == state.sessionGeneration {
|
||
return wasPreviouslyCapable ? .blockedDowngrade : .legacyRequiresConsent
|
||
}
|
||
|
||
// The announce bit is a discovery hint only. It can trigger a Noise
|
||
// handshake, but it cannot select encrypted media or create a durable
|
||
// pin because anyone can copy a public Noise key into a self-signed
|
||
// announce. A prior pin also re-confirms on each replacement session
|
||
// so an authenticated no-bit response becomes a visible downgrade.
|
||
if state.capabilities.contains(.privateMedia) || wasPreviouslyCapable {
|
||
return .awaitingCapabilityProof
|
||
}
|
||
|
||
// Old clients that never advertised the bit remain eligible only for
|
||
// the explicit, invocation-scoped legacy consent path.
|
||
return .legacyRequiresConsent
|
||
}
|
||
|
||
func resolvePrivateMediaSendPolicy(
|
||
to peerID: PeerID,
|
||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||
) {
|
||
let normalizedPeerID = peerID.toShort()
|
||
messageQueue.async { [weak self] in
|
||
guard let self else { return }
|
||
let immediate = self.privateMediaSendPolicy(to: normalizedPeerID)
|
||
guard immediate == .awaitingCapabilityProof else {
|
||
self.completePrivateMediaPolicyResolution([completion], with: immediate)
|
||
return
|
||
}
|
||
|
||
let fingerprint: String? = self.collectionsQueue.sync {
|
||
self.peerRegistry.info(for: normalizedPeerID)?
|
||
.noisePublicKey?
|
||
.sha256Fingerprint()
|
||
}
|
||
guard let fingerprint else {
|
||
self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade)
|
||
return
|
||
}
|
||
|
||
let requestID = UUID()
|
||
let registration = self.collectionsQueue.sync(flags: .barrier) {
|
||
() -> (registered: Bool, shouldSchedule: Bool, nonce: UUID, generation: UUID?) in
|
||
let generation = self.privateMediaSessionGenerations[normalizedPeerID]
|
||
if var pending = self.pendingPrivateMediaPolicyResolutions[normalizedPeerID] {
|
||
guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame,
|
||
pending.completions.count
|
||
< TransportConfig.privateMediaCapabilityProofWaitersPerPeerCap else {
|
||
return (false, false, UUID(), generation)
|
||
}
|
||
pending.completions[requestID] = completion
|
||
self.pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending
|
||
return (true, false, pending.timeoutNonce, pending.sessionGeneration)
|
||
}
|
||
|
||
guard self.pendingPrivateMediaPolicyResolutions.count
|
||
< TransportConfig.privateMediaCapabilityProofPendingPeerCap else {
|
||
return (false, false, UUID(), generation)
|
||
}
|
||
let currentWatchdog = self.privateMediaProofWatchdogs[normalizedPeerID]
|
||
let reusesWatchdog = currentWatchdog?.fingerprint
|
||
.caseInsensitiveCompare(fingerprint) == .orderedSame
|
||
&& currentWatchdog?.sessionGeneration == generation
|
||
let nonce: UUID
|
||
if reusesWatchdog, let currentWatchdog {
|
||
nonce = currentWatchdog.timeoutNonce
|
||
} else {
|
||
nonce = UUID()
|
||
}
|
||
self.pendingPrivateMediaPolicyResolutions[normalizedPeerID] =
|
||
BLEPendingPrivateMediaPolicyResolution(
|
||
fingerprint: fingerprint,
|
||
sessionGeneration: generation,
|
||
timeoutNonce: nonce,
|
||
completions: [requestID: completion]
|
||
)
|
||
return (true, !reusesWatchdog, nonce, generation)
|
||
}
|
||
|
||
guard registration.registered else {
|
||
self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade)
|
||
return
|
||
}
|
||
if registration.shouldSchedule {
|
||
self.schedulePrivateMediaProofTimeout(
|
||
for: normalizedPeerID,
|
||
fingerprint: fingerprint,
|
||
sessionGeneration: registration.generation,
|
||
nonce: registration.nonce
|
||
)
|
||
}
|
||
|
||
if !self.noiseService.hasEstablishedSession(with: normalizedPeerID) {
|
||
self.initiateNoiseHandshake(with: normalizedPeerID)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func completePrivateMediaPolicyResolution(
|
||
_ completions: [@MainActor (PrivateMediaSendPolicy) -> Void],
|
||
with policy: PrivateMediaSendPolicy
|
||
) {
|
||
guard !completions.isEmpty else { return }
|
||
notifyUI {
|
||
completions.forEach { $0(policy) }
|
||
}
|
||
}
|
||
|
||
private func schedulePrivateMediaProofTimeout(
|
||
for peerID: PeerID,
|
||
fingerprint: String,
|
||
sessionGeneration: UUID?,
|
||
nonce: UUID
|
||
) {
|
||
messageQueue.asyncAfter(
|
||
deadline: .now() + TransportConfig.privateMediaCapabilityProofTimeoutSeconds
|
||
) { [weak self] in
|
||
self?.handlePrivateMediaProofTimeout(
|
||
for: peerID,
|
||
fingerprint: fingerprint,
|
||
sessionGeneration: sessionGeneration,
|
||
nonce: nonce
|
||
)
|
||
}
|
||
}
|
||
|
||
private func handlePrivateMediaProofTimeout(
|
||
for peerID: PeerID,
|
||
fingerprint: String,
|
||
sessionGeneration: UUID?,
|
||
nonce: UUID
|
||
) {
|
||
let expiration = collectionsQueue.sync(flags: .barrier) {
|
||
() -> (expired: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) in
|
||
let pending = pendingPrivateMediaPolicyResolutions[peerID]
|
||
let pendingMatches = pending?.timeoutNonce == nonce
|
||
&& pending?.sessionGeneration == sessionGeneration
|
||
&& pending?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame
|
||
let watchdog = privateMediaProofWatchdogs[peerID]
|
||
let watchdogMatches = sessionGeneration != nil
|
||
&& watchdog?.timeoutNonce == nonce
|
||
&& watchdog?.sessionGeneration == sessionGeneration
|
||
&& watchdog?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame
|
||
guard pendingMatches || watchdogMatches else {
|
||
return (false, [])
|
||
}
|
||
var completions: [@MainActor (PrivateMediaSendPolicy) -> Void] = []
|
||
if pendingMatches, let pending {
|
||
completions = Array(pending.completions.values)
|
||
}
|
||
if pendingMatches {
|
||
pendingPrivateMediaPolicyResolutions.removeValue(forKey: peerID)
|
||
}
|
||
if watchdogMatches {
|
||
privateMediaProofWatchdogs.removeValue(forKey: peerID)
|
||
}
|
||
privateMediaProofTimeoutMarkers[peerID] = BLEPrivateMediaProofTimeoutMarker(
|
||
fingerprint: fingerprint,
|
||
sessionGeneration: sessionGeneration
|
||
)
|
||
return (true, completions)
|
||
}
|
||
guard expiration.expired else { return }
|
||
let policy = privateMediaSendPolicy(to: peerID)
|
||
sendPendingNoisePayloadsAfterHandshake(for: peerID)
|
||
completePrivateMediaPolicyResolution(expiration.completions, with: policy)
|
||
}
|
||
|
||
/// Enables or disables a runtime-advertised capability bit (e.g. the
|
||
/// internet-gateway toggle) and re-announces so peers learn promptly.
|
||
/// Build-time bits stay in `PeerCapabilities.localSupported`.
|
||
func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool) {
|
||
let changed: Bool = collectionsQueue.sync(flags: .barrier) {
|
||
let before = runtimeCapabilities
|
||
if enabled {
|
||
runtimeCapabilities.insert(capability)
|
||
} else {
|
||
runtimeCapabilities.remove(capability)
|
||
}
|
||
return runtimeCapabilities != before
|
||
}
|
||
guard changed else { return }
|
||
sendAnnounce(forceSend: true)
|
||
}
|
||
|
||
/// Reachable peers currently advertising the `.gateway` capability.
|
||
func reachableGatewayPeers() -> [PeerID] {
|
||
let now = Date()
|
||
return collectionsQueue.sync {
|
||
peerRegistry.peers(advertising: .gateway)
|
||
.filter { peerRegistry.isReachable($0, now: now) }
|
||
}
|
||
}
|
||
|
||
/// Reachable peers currently advertising the `.bridge` capability.
|
||
func reachableBridgePeers() -> [PeerID] {
|
||
let now = Date()
|
||
return collectionsQueue.sync {
|
||
peerRegistry.peers(advertising: .bridge)
|
||
.filter { peerRegistry.isReachable($0, now: now) }
|
||
}
|
||
}
|
||
|
||
/// A rendezvous cell advertised by a bridge-capable peer's announce.
|
||
func advertisedBridgeGeohash() -> String? {
|
||
collectionsQueue.sync { peerRegistry.advertisedBridgeGeohash() }
|
||
}
|
||
|
||
/// The rendezvous cell this device advertises in its own announces while
|
||
/// bridging with the gateway toggle on. Set from the main actor; the
|
||
/// value rides the next (forced) announce.
|
||
func setLocalBridgeGeohash(_ cell: String?) {
|
||
let changed: Bool = collectionsQueue.sync(flags: .barrier) {
|
||
guard localBridgeGeohash != cell else { return false }
|
||
localBridgeGeohash = cell
|
||
return true
|
||
}
|
||
guard changed else { return }
|
||
sendAnnounce(forceSend: true)
|
||
}
|
||
|
||
func getPeerNicknames() -> [PeerID: String] {
|
||
return collectionsQueue.sync {
|
||
peerRegistry.displayNicknames(selfNickname: myNickname)
|
||
}
|
||
}
|
||
|
||
// MARK: Protocol utilities
|
||
|
||
func getFingerprint(for peerID: PeerID) -> String? {
|
||
return collectionsQueue.sync {
|
||
peerRegistry.fingerprint(for: peerID)
|
||
}
|
||
}
|
||
|
||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState {
|
||
if noiseService.hasEstablishedSession(with: peerID) {
|
||
return .established
|
||
} else if noiseService.hasSession(with: peerID) {
|
||
return .handshaking
|
||
} else {
|
||
return .none
|
||
}
|
||
}
|
||
|
||
func triggerHandshake(with peerID: PeerID) {
|
||
// Callers are on the main actor; the handshake broadcast sync-waits
|
||
// on bleQueue for link state, so hop off main first.
|
||
messageQueue.async { [weak self] in
|
||
self?.initiateNoiseHandshake(with: peerID)
|
||
}
|
||
}
|
||
|
||
// MARK: Noise identity/session access (narrow Transport wrappers)
|
||
|
||
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? {
|
||
noiseService.getPeerPublicKeyData(peerID)
|
||
}
|
||
|
||
func noiseIdentityFingerprint() -> String {
|
||
noiseService.getIdentityFingerprint()
|
||
}
|
||
|
||
func noiseStaticPublicKeyData() -> Data {
|
||
noiseService.getStaticPublicKeyData()
|
||
}
|
||
|
||
func noiseSigningPublicKeyData() -> Data {
|
||
noiseService.getSigningPublicKeyData()
|
||
}
|
||
|
||
func noiseSignData(_ data: Data) -> Data? {
|
||
noiseService.signData(data)
|
||
}
|
||
|
||
func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool {
|
||
noiseService.verifySignature(signature, for: data, publicKey: publicKey)
|
||
}
|
||
|
||
func installNoiseSessionCallbacks(
|
||
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
|
||
onHandshakeRequired: @escaping (PeerID) -> Void
|
||
) {
|
||
// `onPeerAuthenticated` is additive (the encryption service keeps an
|
||
// array of handlers); `onHandshakeRequired` is a single slot.
|
||
noiseService.onPeerAuthenticated = onPeerAuthenticated
|
||
noiseService.onHandshakeRequired = onHandshakeRequired
|
||
}
|
||
|
||
func getCurrentBluetoothState() -> CBManagerState {
|
||
return centralManager?.state ?? .unknown
|
||
}
|
||
|
||
// MARK: Messaging
|
||
|
||
private func handlePrivateMediaAdmissionExpiry(_ transferId: String) {
|
||
// Expiry can be discovered from the BLE maintenance queue or while a
|
||
// caller already owns collectionsQueue. Cleanup is therefore
|
||
// fire-and-forget; never synchronously re-enter the collections lock.
|
||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||
_ = self?.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId)
|
||
}
|
||
TransferProgressManager.shared.rejectBeforeStart(
|
||
id: transferId,
|
||
reason: String(
|
||
localized: "content.delivery.reason.private_media_admission_expired",
|
||
defaultValue: "Media transfer timed out before it could start",
|
||
comment: "Failure reason when private-media admission expires before fragment scheduling"
|
||
)
|
||
)
|
||
}
|
||
|
||
func cancelTransfer(_ transferId: String) {
|
||
// Cancellation must become visible synchronously. Scheduler/pending-
|
||
// Noise cleanup remains asynchronous, but deferred private-media work
|
||
// cannot pass another admission boundary after this returns.
|
||
privateMediaTransferAdmissions.cancel(transferId)
|
||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||
guard let self = self else { return }
|
||
|
||
switch self.outboundFragmentTransfers.cancelTransfer(transferId) {
|
||
case let .active(id, workItems):
|
||
workItems.forEach { $0.cancel() }
|
||
TransferProgressManager.shared.cancel(id: transferId)
|
||
SecureLogger.debug("🛑 Cancelled transfer \(id.prefix(8))…", category: .session)
|
||
self.messageQueue.async { [weak self] in
|
||
self?.startNextPendingTransferIfNeeded()
|
||
}
|
||
|
||
case let .pending(id):
|
||
TransferProgressManager.shared.cancel(id: transferId)
|
||
SecureLogger.debug("🛑 Removed pending transfer \(id.prefix(8))… before start", category: .session)
|
||
|
||
case .missing:
|
||
if self.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId) {
|
||
SecureLogger.debug("🛑 Removed handshake-queued transfer \(transferId.prefix(8))…", category: .session)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Transport protocol conformance helper: simplified public message send
|
||
func sendMessage(_ content: String, mentions: [String]) {
|
||
// Delegate to the full API with default routing
|
||
sendMessage(content, mentions: mentions, to: nil, messageID: nil, timestamp: nil)
|
||
}
|
||
|
||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||
sendMessage(content, mentions: mentions, to: nil, messageID: messageID, timestamp: timestamp)
|
||
}
|
||
|
||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||
sendPrivateMessage(content, to: peerID, messageID: messageID)
|
||
}
|
||
|
||
func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) {
|
||
messageQueue.async { [weak self] in
|
||
guard let self = self else { return }
|
||
guard !self.isPanicSuspended else { return }
|
||
guard let payload = filePacket.encode() else {
|
||
SecureLogger.error("❌ Failed to encode file packet for broadcast", category: .session)
|
||
return
|
||
}
|
||
|
||
var packet = BitchatPacket(
|
||
type: MessageType.fileTransfer.rawValue,
|
||
senderID: self.myPeerIDData,
|
||
recipientID: nil,
|
||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||
payload: payload,
|
||
signature: nil,
|
||
ttl: self.messageTTL,
|
||
version: 2
|
||
)
|
||
|
||
if let signed = self.noiseService.signPacket(packet) {
|
||
packet = signed
|
||
} else {
|
||
SecureLogger.error("❌ Failed to sign file broadcast packet", category: .security)
|
||
return
|
||
}
|
||
|
||
let senderHex = packet.senderID.hexEncodedString()
|
||
let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)"
|
||
self.messageDeduplicator.markProcessed(dedupID)
|
||
|
||
SecureLogger.debug("📁 Broadcasting file transfer payload bytes=\(payload.count)", category: .session)
|
||
self.broadcastPacket(packet, transferId: transferId)
|
||
self.gossipSyncManager?.onPublicPacketSeen(packet)
|
||
}
|
||
}
|
||
|
||
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||
sendFilePrivate(
|
||
filePacket,
|
||
to: peerID,
|
||
transferId: transferId,
|
||
allowLegacyFallback: false
|
||
)
|
||
}
|
||
|
||
func sendFilePrivate(
|
||
_ filePacket: BitchatFilePacket,
|
||
to peerID: PeerID,
|
||
transferId: String,
|
||
allowLegacyFallback: Bool
|
||
) {
|
||
// Register before enqueueing onto messageQueue. This closes the window
|
||
// where cancel/delete could run first, observe no scheduler state, and
|
||
// then be followed by a deferred clear-media send.
|
||
switch privateMediaTransferAdmissions.begin(transferId) {
|
||
case .admitted:
|
||
break
|
||
|
||
case .alreadyKnown:
|
||
SecureLogger.debug(
|
||
"Private media admission already cancelled or duplicated for \(transferId.prefix(8))…",
|
||
category: .security
|
||
)
|
||
return
|
||
|
||
case .capacityExhausted:
|
||
SecureLogger.warning(
|
||
"Private media admission capacity exhausted for \(transferId.prefix(8))…",
|
||
category: .security
|
||
)
|
||
TransferProgressManager.shared.rejectBeforeStart(
|
||
id: transferId,
|
||
reason: String(
|
||
localized: "content.delivery.reason.private_media_admission_full",
|
||
defaultValue: "Too many media transfers are waiting; try again shortly",
|
||
comment: "Failure reason when too many private-media transfers are awaiting admission"
|
||
)
|
||
)
|
||
return
|
||
}
|
||
messageQueue.async { [weak self] in
|
||
guard let self = self else { return }
|
||
#if DEBUG
|
||
self._test_beforePrivateMediaDeferredSend?(transferId)
|
||
#endif
|
||
guard !self.isPanicSuspended else {
|
||
self.privateMediaTransferAdmissions.finish(transferId)
|
||
return
|
||
}
|
||
guard self.privateMediaTransferAdmissions.isActive(transferId) else {
|
||
self.privateMediaTransferAdmissions.finish(transferId)
|
||
return
|
||
}
|
||
let targetID = peerID.toShort()
|
||
switch self.privateMediaSendPolicy(to: targetID) {
|
||
case .encrypted:
|
||
break
|
||
|
||
case .awaitingCapabilityProof:
|
||
// The UI coordinator resolves this state before calling the
|
||
// transport. Keep the transport guard fail-closed for direct
|
||
// callers and for a session replacement that races the call.
|
||
SecureLogger.warning(
|
||
"Private media held pending authenticated capability proof for \(targetID.id.prefix(8))…",
|
||
category: .security
|
||
)
|
||
TransferProgressManager.shared.rejectBeforeStart(
|
||
id: transferId,
|
||
reason: String(
|
||
localized: "content.delivery.reason.private_media_capability_unresolved",
|
||
defaultValue: "Could not confirm encrypted media support",
|
||
comment: "Failure reason when private-media capability negotiation did not resolve"
|
||
)
|
||
)
|
||
self.privateMediaTransferAdmissions.finish(transferId)
|
||
return
|
||
|
||
case .legacyRequiresConsent:
|
||
guard allowLegacyFallback else {
|
||
SecureLogger.warning(
|
||
"Private media blocked pending explicit legacy-clear consent for \(targetID.id.prefix(8))…",
|
||
category: .security
|
||
)
|
||
TransferProgressManager.shared.rejectBeforeStart(
|
||
id: transferId,
|
||
reason: String(
|
||
localized: "content.delivery.reason.legacy_media_consent_required",
|
||
defaultValue: "Confirmation required before sending without end-to-end encryption",
|
||
comment: "Failure reason when a legacy private-media send lacks per-send consent"
|
||
)
|
||
)
|
||
self.privateMediaTransferAdmissions.finish(transferId)
|
||
return
|
||
}
|
||
// Migration path accepted by current Android and used by older
|
||
// iOS releases: preserve the directed raw file-transfer wire
|
||
// shape, but require the signature the receive path verifies.
|
||
// The allow flag belongs to this invocation only and is
|
||
// consumed here; a retry must obtain fresh user consent.
|
||
self.sendSignedLegacyPrivateFile(
|
||
filePacket,
|
||
to: targetID,
|
||
transferId: transferId
|
||
)
|
||
return
|
||
|
||
case .blockedDowngrade:
|
||
SecureLogger.warning(
|
||
"Private media downgrade blocked for \(targetID.id.prefix(8))…",
|
||
category: .security
|
||
)
|
||
TransferProgressManager.shared.rejectBeforeStart(
|
||
id: transferId,
|
||
reason: String(
|
||
localized: "content.delivery.reason.private_media_downgrade_blocked",
|
||
defaultValue: "Encrypted media required; ask this contact to upgrade",
|
||
comment: "Failure reason when a peer that previously supported encrypted media appears to downgrade"
|
||
)
|
||
)
|
||
self.privateMediaTransferAdmissions.finish(transferId)
|
||
return
|
||
}
|
||
guard let typedPayload = BLENoisePayloadFactory.privateFile(filePacket) else {
|
||
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
||
TransferProgressManager.shared.rejectBeforeStart(
|
||
id: transferId,
|
||
reason: String(localized: "content.delivery.reason.media_encoding_failed", defaultValue: "Failed to prepare media", comment: "Failure reason when private media cannot be encoded")
|
||
)
|
||
self.privateMediaTransferAdmissions.finish(transferId)
|
||
return
|
||
}
|
||
guard self.noiseService.hasEstablishedSession(with: targetID) else {
|
||
let queued = self.collectionsQueue.sync(flags: .barrier) {
|
||
self.privateMediaTransferAdmissions.withActive(transferId) {
|
||
self.pendingNoiseSessionQueues.appendTypedPayload(
|
||
typedPayload,
|
||
transferId: transferId,
|
||
for: targetID
|
||
)
|
||
return true
|
||
} ?? false
|
||
}
|
||
guard queued else {
|
||
self.privateMediaTransferAdmissions.finish(transferId)
|
||
return
|
||
}
|
||
SecureLogger.debug("📥 Queued private file for \(targetID.id.prefix(8))… pending handshake", category: .session)
|
||
guard self.privateMediaTransferAdmissions.isActive(transferId) else {
|
||
self.collectionsQueue.sync(flags: .barrier) {
|
||
_ = self.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId)
|
||
}
|
||
self.privateMediaTransferAdmissions.finish(transferId)
|
||
return
|
||
}
|
||
self.initiateNoiseHandshake(with: targetID)
|
||
return
|
||
}
|
||
|
||
do {
|
||
guard self.privateMediaTransferAdmissions.isActive(transferId) else {
|
||
self.privateMediaTransferAdmissions.finish(transferId)
|
||
return
|
||
}
|
||
let packet = try self.makeEncryptedNoisePacket(typedPayload, to: targetID)
|
||
guard self.privateMediaTransferAdmissions.isActive(transferId) else {
|
||
self.privateMediaTransferAdmissions.finish(transferId)
|
||
return
|
||
}
|
||
SecureLogger.debug("📁 Sending encrypted private file to \(targetID.id.prefix(8))… plaintextBytes=\(typedPayload.count)", category: .session)
|
||
self.broadcastPacket(
|
||
packet,
|
||
transferId: transferId,
|
||
requiresPrivateMediaAdmission: true
|
||
)
|
||
} catch {
|
||
SecureLogger.error("❌ Failed to encrypt private file for \(targetID.id.prefix(8))…: \(error)", category: .security)
|
||
TransferProgressManager.shared.rejectBeforeStart(
|
||
id: transferId,
|
||
reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer")
|
||
)
|
||
self.privateMediaTransferAdmissions.finish(transferId)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Compatibility-only fallback for peers that have not advertised
|
||
/// encrypted private media. The payload is authenticated but visible to
|
||
/// relays, matching the pre-migration behavior until those clients upgrade.
|
||
private func sendSignedLegacyPrivateFile(
|
||
_ filePacket: BitchatFilePacket,
|
||
to targetID: PeerID,
|
||
transferId: String
|
||
) {
|
||
guard privateMediaTransferAdmissions.isActive(transferId) else {
|
||
privateMediaTransferAdmissions.finish(transferId)
|
||
return
|
||
}
|
||
guard let payload = filePacket.encode(),
|
||
let recipientData = Data(hexString: targetID.id) else {
|
||
SecureLogger.error("❌ Failed to encode legacy private file transfer", category: .session)
|
||
TransferProgressManager.shared.rejectBeforeStart(
|
||
id: transferId,
|
||
reason: String(localized: "content.delivery.reason.media_encoding_failed", defaultValue: "Failed to prepare media", comment: "Failure reason when private media cannot be encoded")
|
||
)
|
||
privateMediaTransferAdmissions.finish(transferId)
|
||
return
|
||
}
|
||
|
||
let unsigned = BitchatPacket(
|
||
type: MessageType.fileTransfer.rawValue,
|
||
senderID: myPeerIDData,
|
||
recipientID: recipientData,
|
||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||
payload: payload,
|
||
signature: nil,
|
||
ttl: messageTTL,
|
||
version: 2
|
||
)
|
||
guard let signed = noiseService.signPacket(unsigned) else {
|
||
SecureLogger.error("❌ Failed to sign legacy private file transfer", category: .security)
|
||
TransferProgressManager.shared.rejectBeforeStart(
|
||
id: transferId,
|
||
reason: String(localized: "content.delivery.reason.media_signing_failed", defaultValue: "Failed to authenticate media", comment: "Failure reason when a legacy private-media packet cannot be signed")
|
||
)
|
||
privateMediaTransferAdmissions.finish(transferId)
|
||
return
|
||
}
|
||
|
||
// Signing can be non-trivial; cancellation that won while it ran must
|
||
// still prevent the clear payload from reaching the broadcast path.
|
||
guard privateMediaTransferAdmissions.isActive(transferId) else {
|
||
privateMediaTransferAdmissions.finish(transferId)
|
||
return
|
||
}
|
||
|
||
SecureLogger.warning(
|
||
"📁 Sending signed legacy private file to \(targetID.id.prefix(8))…; peer has not advertised E2E media",
|
||
category: .security
|
||
)
|
||
broadcastPacket(
|
||
signed,
|
||
transferId: transferId,
|
||
requiresPrivateMediaAdmission: true
|
||
)
|
||
}
|
||
|
||
|
||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||
// Hop like sendMessage: callers are often on the main actor, and the
|
||
// send path sync-waits on bleQueue for link state — the main thread
|
||
// must never block on bleQueue (see captureBluetoothStatus).
|
||
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||
messageQueue.async { [weak self] in
|
||
self?.sendReadReceipt(receipt, to: peerID)
|
||
}
|
||
return
|
||
}
|
||
let payload = BLENoisePayloadFactory.readReceipt(originalMessageID: receipt.originalMessageID)
|
||
|
||
if noiseService.hasEstablishedSession(with: peerID) {
|
||
SecureLogger.debug("📤 Sending READ receipt id=\(receipt.originalMessageID.prefix(8))… to \(peerID.id.prefix(8))…", category: .session)
|
||
do {
|
||
broadcastPacket(try makeEncryptedNoisePacket(payload, to: peerID))
|
||
} catch {
|
||
SecureLogger.error("Failed to send read receipt: \(error)")
|
||
}
|
||
} else {
|
||
// Queue for after handshake; initiate only while the peer is
|
||
// around to answer (see sendDeliveryAck — absent senders must
|
||
// not turn queued acks into handshake floods).
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
pendingNoiseSessionQueues.appendTypedPayload(payload, for: peerID)
|
||
}
|
||
if !noiseService.hasSession(with: peerID), isPeerReachable(peerID) {
|
||
initiateNoiseHandshake(with: peerID)
|
||
}
|
||
SecureLogger.debug("🕒 Queued READ receipt for \(peerID.id.prefix(8))… until handshake completes", category: .session)
|
||
}
|
||
}
|
||
|
||
private func acceptedIngressContext(
|
||
for packet: BitchatPacket,
|
||
claimedSenderID: PeerID,
|
||
boundPeerID: PeerID?,
|
||
linkDescription: String
|
||
) -> BLEIngressPacketContext? {
|
||
switch BLEIngressPacketGuard.evaluate(
|
||
packet: packet,
|
||
claimedSenderID: claimedSenderID,
|
||
boundPeerID: boundPeerID,
|
||
localPeerID: myPeerID,
|
||
directAnnounceTTL: messageTTL,
|
||
isValidSyncResponse: { [requestSyncManager] peerID in
|
||
requestSyncManager.isValidResponse(from: peerID, isRSR: true)
|
||
}
|
||
) {
|
||
case .success(let context):
|
||
if packet.isRSR {
|
||
logValidRSR(from: context.validationPeerID)
|
||
}
|
||
return context
|
||
case .failure(.selfLoopback):
|
||
logSelfLoopback(packetType: packet.type, linkDescription: linkDescription)
|
||
return nil
|
||
case .failure(.directSenderMismatch(let boundPeerID, let claimedSenderID)):
|
||
SecureLogger.warning("🚫 SECURITY: Sender ID spoofing attempt detected! \(linkDescription) claimed to be \(claimedSenderID.id.prefix(8))… but is bound to \(boundPeerID.id.prefix(8))…", category: .security)
|
||
return nil
|
||
case .failure(.invalidRSR(let peerID)):
|
||
SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security)
|
||
return nil
|
||
case .failure(.timestampSkew(let peerID, let skewMs, let maxSkewMs)):
|
||
SecureLogger.warning("Packet timestamp skewed by \(skewMs)ms (max \(maxSkewMs)ms) from \(peerID.id.prefix(8))…", category: .security)
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private func isAcceptedIngressPayload(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||
switch BLEIngressPacketGuard.validatePayload(
|
||
packet,
|
||
from: peerID,
|
||
isValidSyncResponse: { [requestSyncManager] peerID in
|
||
requestSyncManager.isValidResponse(from: peerID, isRSR: true)
|
||
}
|
||
) {
|
||
case .success:
|
||
if packet.isRSR {
|
||
logValidRSR(from: peerID)
|
||
}
|
||
return true
|
||
case .failure(.invalidRSR(let peerID)):
|
||
SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security)
|
||
return false
|
||
case .failure(.timestampSkew(let peerID, let skewMs, let maxSkewMs)):
|
||
SecureLogger.warning("Packet timestamp skewed by \(skewMs)ms (max \(maxSkewMs)ms) from \(peerID.id.prefix(8))…", category: .security)
|
||
return false
|
||
case .failure(.selfLoopback), .failure(.directSenderMismatch):
|
||
return false
|
||
}
|
||
}
|
||
|
||
private func logValidRSR(from peerID: PeerID) {
|
||
guard logRateLimiter.shouldLog(key: "valid-rsr:\(peerID.id)") else { return }
|
||
SecureLogger.debug("Valid RSR packet from \(peerID.id.prefix(8))… - skipping timestamp check", category: .security)
|
||
}
|
||
|
||
private func logSelfLoopback(packetType: UInt8, linkDescription: String) {
|
||
guard logRateLimiter.shouldLog(
|
||
key: "self-loopback:\(packetType)",
|
||
minimumInterval: 30
|
||
) else { return }
|
||
SecureLogger.debug("↩️ Dropping BLE self-loopback packet type \(packetType) from \(linkDescription)", category: .session)
|
||
}
|
||
|
||
private func recordIngressIfNew(_ packet: BitchatPacket, link: BLEIngressLinkID, peerID: PeerID) -> Bool {
|
||
return collectionsQueue.sync(flags: .barrier) {
|
||
ingressLinks.recordIfNew(
|
||
packet,
|
||
link: link,
|
||
peerID: peerID,
|
||
lifetime: TransportConfig.bleIngressRecordLifetimeSeconds
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - Packet Broadcasting
|
||
|
||
private func broadcastPacket(
|
||
_ packet: BitchatPacket,
|
||
transferId: String? = nil,
|
||
requiresPrivateMediaAdmission: Bool = false
|
||
) {
|
||
guard !isPanicSuspended else {
|
||
if requiresPrivateMediaAdmission, let transferId {
|
||
privateMediaTransferAdmissions.finish(transferId)
|
||
}
|
||
return
|
||
}
|
||
if requiresPrivateMediaAdmission {
|
||
guard let transferId,
|
||
privateMediaTransferAdmissions.isActive(transferId) else {
|
||
if let transferId {
|
||
privateMediaTransferAdmissions.finish(transferId)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
// Apply route if recipient exists (centralized route application)
|
||
let packetToSend: BitchatPacket
|
||
if let recipientPeerID = PeerID(hexData: packet.recipientID) {
|
||
packetToSend = applyRouteIfAvailable(packet, to: recipientPeerID)
|
||
} else {
|
||
packetToSend = packet
|
||
}
|
||
|
||
// Encode once using a small per-type padding policy, then delegate by type
|
||
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
|
||
|
||
// The 256-fragment ceiling exists to protect *current Android*
|
||
// receivers, which only ever receive private media over the directed
|
||
// raw-file migration fallback (they do not implement the encrypted
|
||
// 0x20 path). Encrypted private media (`noiseEncrypted`) is sent only to
|
||
// peers that advertised the `.privateMedia` capability — modern clients
|
||
// that assemble up to the full receiver ceiling (see
|
||
// `BLEFragmentAssemblyBuffer`'s 10,000-fragment guard) — so forcing them
|
||
// down to Android's 256 cap would needlessly reject iOS→iOS photos in
|
||
// the ~120–512 KiB range that work today. Restrict the low cap to the
|
||
// migration fallback (directed `fileTransfer`); public media is
|
||
// unaffected. Run the same planner the scheduler will use, after route
|
||
// application, and reject before reserving a transfer slot or writing
|
||
// any fragment.
|
||
// TODO(#1434): negotiate an explicit per-peer fragment limit so a future
|
||
// Android client that adopts the encrypted 0x20 path but still caps its
|
||
// reassembler can advertise its own ceiling instead of relying on the
|
||
// capability/type proxy above.
|
||
if let transferId,
|
||
let recipientPeerID = PeerID(hexData: packetToSend.recipientID),
|
||
packetToSend.type == MessageType.fileTransfer.rawValue {
|
||
let compatibilityRequest = BLEOutboundFragmentTransferRequest(
|
||
packet: packetToSend,
|
||
pad: padForBLE,
|
||
maxChunk: nil,
|
||
directedPeer: recipientPeerID,
|
||
transferId: transferId
|
||
)
|
||
guard let plan = BLEOutboundFragmentPlanner.makePlan(
|
||
for: compatibilityRequest,
|
||
defaultChunkSize: defaultFragmentSize,
|
||
bleMaxMTU: bleMaxMTU
|
||
), BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(plan) else {
|
||
SecureLogger.warning(
|
||
"Private media rejected: exceeds cross-platform 256-fragment limit",
|
||
category: .security
|
||
)
|
||
TransferProgressManager.shared.rejectBeforeStart(
|
||
id: transferId,
|
||
reason: String(
|
||
localized: "content.delivery.reason.private_media_too_many_fragments",
|
||
defaultValue: "File is too large for this contact's client (more than 256 mesh fragments)",
|
||
comment: "Failure reason when private media exceeds the Android-compatible fragment limit"
|
||
)
|
||
)
|
||
if requiresPrivateMediaAdmission {
|
||
privateMediaTransferAdmissions.finish(transferId)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
// Route planning and fragment preflight can take enough time for a
|
||
// user cancellation to win. Recheck before exposing even the test tap,
|
||
// then check atomically with scheduler admission below.
|
||
if requiresPrivateMediaAdmission {
|
||
guard let transferId,
|
||
privateMediaTransferAdmissions.isActive(transferId) else {
|
||
if let transferId {
|
||
privateMediaTransferAdmissions.finish(transferId)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
#if DEBUG
|
||
_test_onOutboundPacket?(packetToSend)
|
||
#endif
|
||
|
||
if packetToSend.type == MessageType.fileTransfer.rawValue {
|
||
sendFragmentedPacket(
|
||
packetToSend,
|
||
pad: padForBLE,
|
||
maxChunk: nil,
|
||
directedOnlyPeer: nil,
|
||
transferId: transferId,
|
||
requiresPrivateMediaAdmission: requiresPrivateMediaAdmission
|
||
)
|
||
return
|
||
}
|
||
// App-initiated private media is already one opaque Noise ciphertext.
|
||
// Always fragment that outer packet so the existing transfer scheduler
|
||
// retains progress/cancel behavior without exposing the file TLVs.
|
||
if packetToSend.type == MessageType.noiseEncrypted.rawValue,
|
||
let transferId,
|
||
let recipientPeerID = PeerID(hexData: packetToSend.recipientID) {
|
||
sendFragmentedPacket(
|
||
packetToSend,
|
||
pad: padForBLE,
|
||
maxChunk: nil,
|
||
directedOnlyPeer: recipientPeerID,
|
||
transferId: transferId,
|
||
requiresPrivateMediaAdmission: requiresPrivateMediaAdmission
|
||
)
|
||
return
|
||
}
|
||
if requiresPrivateMediaAdmission {
|
||
if let transferId {
|
||
privateMediaTransferAdmissions.finish(transferId)
|
||
}
|
||
SecureLogger.error(
|
||
"Private media admission reached an unsupported non-directed packet shape",
|
||
category: .security
|
||
)
|
||
return
|
||
}
|
||
guard let data = packetToSend.toBinaryData(padding: padForBLE) else {
|
||
SecureLogger.error("❌ Failed to convert packet to binary data", category: .session)
|
||
return
|
||
}
|
||
if packetToSend.type == MessageType.noiseEncrypted.rawValue {
|
||
sendEncrypted(packetToSend, data: data, pad: padForBLE)
|
||
return
|
||
}
|
||
sendGenericBroadcast(packetToSend, data: data, pad: padForBLE)
|
||
}
|
||
|
||
private func sendEncrypted(_ packet: BitchatPacket, data: Data, pad: Bool) {
|
||
guard let recipientPeerID = PeerID(hexData: packet.recipientID) else { return }
|
||
var sentEncrypted = false
|
||
|
||
let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data)
|
||
|
||
// Per-link limits for the specific peer
|
||
let directPeripheralState = snapshotDirectPeripheralState(for: recipientPeerID)
|
||
let recipientCentral = snapshotSubscribedCentrals().central(for: recipientPeerID)
|
||
|
||
if let peripheralMaxLen = directPeripheralState?.peripheral.maximumWriteValueLength(for: .withoutResponse),
|
||
data.count > peripheralMaxLen {
|
||
let chunk = BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: peripheralMaxLen)
|
||
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID)
|
||
return
|
||
}
|
||
if let centralMaxLen = recipientCentral?.maximumUpdateValueLength,
|
||
data.count > centralMaxLen {
|
||
let chunk = BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: centralMaxLen)
|
||
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: recipientPeerID)
|
||
return
|
||
}
|
||
|
||
// Direct write via peripheral link
|
||
if let state = directPeripheralState,
|
||
state.isConnected,
|
||
let characteristic = state.characteristic {
|
||
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic, priority: outboundPriority)
|
||
sentEncrypted = true
|
||
}
|
||
|
||
// Notify via central link (dual-role)
|
||
if let characteristic = characteristic, !sentEncrypted, let recipientCentral {
|
||
let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [recipientCentral]) ?? false
|
||
if success {
|
||
sentEncrypted = true
|
||
} else {
|
||
enqueuePendingNotification(data: data, centrals: [recipientCentral], context: "encrypted")
|
||
}
|
||
}
|
||
|
||
if !sentEncrypted {
|
||
// Flood as last resort with recipient set; link aware
|
||
sendOnAllLinks(packet: packet, data: data, pad: pad, directedOnlyPeer: recipientPeerID)
|
||
}
|
||
}
|
||
|
||
private func sendGenericBroadcast(_ packet: BitchatPacket, data: Data, pad: Bool) {
|
||
sendOnAllLinks(packet: packet, data: data, pad: pad, directedOnlyPeer: nil)
|
||
}
|
||
|
||
private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) {
|
||
guard !isPanicSuspended else { return }
|
||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||
guard let self = self else { return }
|
||
guard !self.isPanicSuspended else { return }
|
||
let result = self.pendingNotifications.enqueue(
|
||
data: data,
|
||
targets: centrals,
|
||
capCount: TransportConfig.blePendingNotificationsCapCount
|
||
)
|
||
|
||
if case let .enqueued(count) = result {
|
||
self.logBackpressureSampled("📋 Queued \(context) packet for retry (pending=\(count))")
|
||
return
|
||
}
|
||
|
||
if attempt >= TransportConfig.bleNotificationRetryMaxAttempts {
|
||
SecureLogger.error("❌ Dropping \(context) packet after exhausting retry window (pending=\(self.pendingNotifications.count))", category: .session)
|
||
return
|
||
}
|
||
|
||
let backoff = TransportConfig.bleNotificationRetryDelayMs * max(1, attempt + 1)
|
||
let deadline = DispatchTime.now() + .milliseconds(backoff)
|
||
self.messageQueue.asyncAfter(deadline: deadline) { [weak self] in
|
||
self?.enqueuePendingNotification(data: data, centrals: centrals, context: context, attempt: attempt + 1)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Synchronously admits a notification to the link-specific retry queue.
|
||
/// Destructive courier handoff uses this result as its commit point, so a
|
||
/// full process-local queue must be reported as rejection, not success.
|
||
private func enqueuePendingNotificationIfAccepted(
|
||
data: Data,
|
||
centrals: [CBCentral],
|
||
context: String
|
||
) -> Bool {
|
||
let result = collectionsQueue.sync(flags: .barrier) {
|
||
pendingNotifications.enqueue(
|
||
data: data,
|
||
targets: centrals,
|
||
capCount: TransportConfig.blePendingNotificationsCapCount
|
||
)
|
||
}
|
||
switch result {
|
||
case let .enqueued(count):
|
||
SecureLogger.debug("📋 Queued \(context) packet for retry (pending=\(count))", category: .session)
|
||
return true
|
||
case let .full(count):
|
||
SecureLogger.warning("⚠️ Rejecting \(context) packet: notification queue full (pending=\(count))", category: .session)
|
||
return false
|
||
}
|
||
}
|
||
|
||
/// Serializes the final authenticated-link check with CoreBluetooth's
|
||
/// notification admission on `bleQueue`, closing the rebind/disconnect
|
||
/// race between fanout planning and the actual handoff.
|
||
private func notifyOrEnqueueIfAccepted(
|
||
data: Data,
|
||
centrals: [CBCentral],
|
||
characteristic: CBMutableCharacteristic,
|
||
context: String,
|
||
requiredAuthenticatedPeer: PeerID?
|
||
) -> Bool {
|
||
let accept = { [self] in
|
||
let eligible: [CBCentral]
|
||
if let peerID = requiredAuthenticatedPeer {
|
||
eligible = centrals.filter { central in
|
||
let link = BLEIngressLinkID.central(central.identifier.uuidString)
|
||
return noiseAuthenticatedLinkOwners[link] == peerID
|
||
&& linkStateStore.peerID(forCentralUUID: central.identifier.uuidString) == peerID
|
||
}
|
||
} else {
|
||
eligible = centrals
|
||
}
|
||
guard !eligible.isEmpty else { return false }
|
||
if peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: eligible) == true {
|
||
return true
|
||
}
|
||
return enqueuePendingNotificationIfAccepted(
|
||
data: data,
|
||
centrals: eligible,
|
||
context: context
|
||
)
|
||
}
|
||
|
||
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
|
||
return accept()
|
||
}
|
||
return bleQueue.sync(execute: accept)
|
||
}
|
||
|
||
/// Returns true only when the packet was accepted by at least one current
|
||
/// physical link (including its link-specific backpressure queue). A
|
||
/// process-local directed spool is deliberately not success: callers
|
||
/// that own a durable upstream copy must keep it retryable.
|
||
@discardableResult
|
||
private func sendOnAllLinks(
|
||
packet: BitchatPacket,
|
||
data: Data,
|
||
pad: Bool,
|
||
directedOnlyPeer: PeerID?,
|
||
requireDirectPeerLink: Bool = false,
|
||
requireNoiseAuthenticatedPeerLink: Bool = false
|
||
) -> Bool {
|
||
guard !isPanicSuspended else { return false }
|
||
let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
|
||
var excludedPeerLinks = links(to: ingressRecord?.peerID)
|
||
if requireNoiseAuthenticatedPeerLink {
|
||
guard let directedOnlyPeer else { return false }
|
||
let boundLinks = links(to: directedOnlyPeer)
|
||
let authenticatedLinks = currentNoiseAuthenticatedLinks(to: directedOnlyPeer)
|
||
guard !authenticatedLinks.isEmpty else { return false }
|
||
excludedPeerLinks.formUnion(boundLinks.subtracting(authenticatedLinks))
|
||
}
|
||
let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data)
|
||
|
||
let states = snapshotPeripheralStates()
|
||
// A link without a discovered characteristic cannot be written to
|
||
// (the write loop below skips it); offering it to the planner only
|
||
// wastes fanout slots — and a peer's single collapsed copy would be
|
||
// silently dropped if its bound link is still mid-rediscovery.
|
||
let connectedStates = states.filter { $0.isConnected && $0.characteristic != nil }
|
||
let centralSnapshot = snapshotSubscribedCentrals()
|
||
let subscribedCentrals = characteristic == nil ? [] : centralSnapshot.centrals
|
||
let connectedPeripheralIDs = connectedStates.map { $0.peripheral.identifier.uuidString }
|
||
let centralIDs = subscribedCentrals.map { $0.identifier.uuidString }
|
||
let peripheralPeerBindings = Dictionary(uniqueKeysWithValues: connectedStates.compactMap { state in
|
||
state.peerID.map { (state.peripheral.identifier.uuidString, $0) }
|
||
})
|
||
let plan = BLEOutboundLinkPlanner.plan(
|
||
packet: packet,
|
||
dataCount: data.count,
|
||
peripheralIDs: connectedPeripheralIDs,
|
||
peripheralWriteLimits: connectedStates.map { $0.peripheral.maximumWriteValueLength(for: .withoutResponse) },
|
||
centralIDs: centralIDs,
|
||
centralNotifyLimits: subscribedCentrals.map { $0.maximumUpdateValueLength },
|
||
ingressRecord: ingressRecord,
|
||
excludedLinks: excludedPeerLinks,
|
||
peripheralPeerBindings: peripheralPeerBindings,
|
||
centralPeerBindings: centralSnapshot.peerIDsByCentralUUID,
|
||
// Perf note: this is a third bleQueue hop per send; if send-path
|
||
// profiling ever flags it, fold it into snapshotPeripheralStates
|
||
// as a combined snapshot.
|
||
preferredPeripheralPerPeer: readLinkState { $0.preferredPeripheralBindings },
|
||
directAnnounceTTL: messageTTL,
|
||
directedOnlyPeer: directedOnlyPeer,
|
||
requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink
|
||
)
|
||
|
||
if let chunk = plan.fragmentChunkSize {
|
||
guard !plan.selectedLinks.peripheralIDs.isEmpty || !plan.selectedLinks.centralIDs.isEmpty else {
|
||
return false
|
||
}
|
||
return sendFragmentedPacket(
|
||
packet,
|
||
pad: pad,
|
||
maxChunk: chunk,
|
||
directedOnlyPeer: directedOnlyPeer,
|
||
requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink,
|
||
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
|
||
)
|
||
}
|
||
|
||
// If directed and we currently have no links to forward on, spool for a short window
|
||
if let only = plan.directedPeerHint,
|
||
plan.shouldSpoolDirectedPacket {
|
||
spoolDirectedPacket(packet, recipientPeerID: only)
|
||
}
|
||
|
||
var acceptedByPhysicalLink = false
|
||
|
||
// Writes to selected connected peripherals
|
||
for s in connectedStates {
|
||
let pid = s.peripheral.identifier.uuidString
|
||
guard plan.selectedLinks.peripheralIDs.contains(pid) else { continue }
|
||
if let ch = s.characteristic {
|
||
if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink {
|
||
acceptedByPhysicalLink = writeOrEnqueueIfAccepted(
|
||
data,
|
||
to: s.peripheral,
|
||
characteristic: ch,
|
||
priority: outboundPriority,
|
||
requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil
|
||
) || acceptedByPhysicalLink
|
||
} else {
|
||
writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority)
|
||
}
|
||
}
|
||
}
|
||
// Notify selected subscribed centrals
|
||
if let ch = characteristic {
|
||
let targets = subscribedCentrals.filter { plan.selectedLinks.centralIDs.contains($0.identifier.uuidString) }
|
||
if !targets.isEmpty {
|
||
if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink {
|
||
acceptedByPhysicalLink = notifyOrEnqueueIfAccepted(
|
||
data: data,
|
||
centrals: targets,
|
||
characteristic: ch,
|
||
context: "directed",
|
||
requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil
|
||
) || acceptedByPhysicalLink
|
||
} else {
|
||
let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false
|
||
if !success {
|
||
// Notification queue full - queue for retry to prevent silent packet loss
|
||
// This is critical for fragment delivery reliability
|
||
let context = packet.type == MessageType.fragment.rawValue ? "fragment" : "broadcast"
|
||
enqueuePendingNotification(data: data, centrals: targets, context: context)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink { return acceptedByPhysicalLink }
|
||
return !plan.selectedLinks.peripheralIDs.isEmpty || !plan.selectedLinks.centralIDs.isEmpty
|
||
}
|
||
|
||
// Directed send helper (unicast to a specific peerID) without altering packet contents
|
||
@discardableResult
|
||
private func sendPacketDirected(
|
||
_ packet: BitchatPacket,
|
||
to peerID: PeerID,
|
||
requireDirectPeerLink: Bool = false,
|
||
requireNoiseAuthenticatedPeerLink: Bool = false
|
||
) -> Bool {
|
||
#if DEBUG
|
||
_test_onOutboundPacket?(packet)
|
||
#endif
|
||
guard let data = packet.toBinaryData(padding: false) else { return false }
|
||
return sendOnAllLinks(
|
||
packet: packet,
|
||
data: data,
|
||
pad: false,
|
||
directedOnlyPeer: peerID,
|
||
requireDirectPeerLink: requireDirectPeerLink,
|
||
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
|
||
)
|
||
}
|
||
|
||
// MARK: - Directed store-and-forward
|
||
private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: PeerID) {
|
||
let msgID = BLEOutboundPacketPolicy.messageID(for: packet)
|
||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||
guard let self = self else { return }
|
||
if self.pendingDirectedRelays.enqueue(
|
||
packet: packet,
|
||
recipient: recipientPeerID,
|
||
messageID: msgID,
|
||
enqueuedAt: Date()
|
||
) {
|
||
SecureLogger.debug("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))…", category: .session)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func flushDirectedSpool() {
|
||
guard !isPanicSuspended else { return }
|
||
// Move items out and attempt broadcast; if still no links, they'll be re-spooled
|
||
let toSend = collectionsQueue.sync(flags: .barrier) {
|
||
pendingDirectedRelays.drainUnexpired(
|
||
now: Date(),
|
||
window: TransportConfig.bleDirectedSpoolWindowSeconds
|
||
)
|
||
}
|
||
guard !toSend.isEmpty else { return }
|
||
for entry in toSend {
|
||
messageQueue.async { [weak self] in self?.broadcastPacket(entry.packet) }
|
||
}
|
||
}
|
||
|
||
private func signedSenderDisplayName(for packet: BitchatPacket, from peerID: PeerID) -> String? {
|
||
guard let signature = packet.signature,
|
||
let packetData = packet.toBinaryDataForSigning() else {
|
||
return nil
|
||
}
|
||
|
||
let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
|
||
for candidate in candidates {
|
||
guard let signingKey = candidate.signingPublicKey,
|
||
noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) else {
|
||
continue
|
||
}
|
||
|
||
if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) {
|
||
return social.localPetname ?? social.claimedNickname
|
||
}
|
||
|
||
return BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// MARK: - Archived public messages ("heard here earlier")
|
||
|
||
func purgeArchivedPublicMessages(from peerID: PeerID) {
|
||
gossipSyncManager?.removePublicMessages(from: peerID)
|
||
}
|
||
|
||
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
|
||
guard let generation = capturePanicLifecycleGeneration() else {
|
||
return
|
||
}
|
||
guard let sync = gossipSyncManager else {
|
||
notifyUI { [weak self] in
|
||
guard let self,
|
||
self.isCurrentPanicLifecycleGeneration(generation) else {
|
||
return
|
||
}
|
||
completion([])
|
||
}
|
||
return
|
||
}
|
||
sync.collectPublicMessagePackets { [weak self] packets in
|
||
guard let self,
|
||
self.isCurrentPanicLifecycleGeneration(generation) else {
|
||
return
|
||
}
|
||
// Signature verification and registry lookups run on messageQueue
|
||
// like the live receive path.
|
||
self.messageQueue.async {
|
||
guard self.isCurrentPanicLifecycleGeneration(generation) else {
|
||
return
|
||
}
|
||
let decoded = packets
|
||
.compactMap { self.decodeArchivedPublicMessage($0) }
|
||
.sorted { $0.timestamp < $1.timestamp }
|
||
self.notifyUI { [weak self] in
|
||
guard let self,
|
||
self.isCurrentPanicLifecycleGeneration(generation) else {
|
||
return
|
||
}
|
||
completion(decoded)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func decodeArchivedPublicMessage(_ packet: BitchatPacket) -> ArchivedPublicMessage? {
|
||
guard packet.type == MessageType.message.rawValue,
|
||
let content = String(data: packet.payload, encoding: .utf8)?.trimmedOrNilIfEmpty
|
||
else { return nil }
|
||
let senderPeerID = PeerID(hexData: packet.senderID)
|
||
let peers = collectionsQueue.sync { peerRegistry.snapshotByID }
|
||
// Archived senders are usually long gone, so the signature-derived
|
||
// identity is the best shot at a name; a live registry entry is
|
||
// next; anonymous fallback matches the live path.
|
||
let nickname = signedSenderDisplayName(for: packet, from: senderPeerID)
|
||
?? BLEPeerSenderDisplayName.resolveKnownPeer(
|
||
peerID: senderPeerID,
|
||
localPeerID: myPeerID,
|
||
localNickname: myNickname,
|
||
peers: peers,
|
||
allowConnectedUnverified: false
|
||
)
|
||
?? BLEPeerSenderDisplayName.anonymousNickname(for: senderPeerID)
|
||
return ArchivedPublicMessage(
|
||
packetIdHex: PacketIdUtil.computeId(packet).hexEncodedString(),
|
||
senderPeerID: senderPeerID,
|
||
senderNickname: nickname,
|
||
content: content,
|
||
timestamp: Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
|
||
)
|
||
}
|
||
|
||
private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||
fileTransferHandler.handle(packet, from: peerID)
|
||
}
|
||
|
||
/// Builds the file-transfer handler environment. All queue hops stay here
|
||
/// so `BLEFileTransferHandler` remains queue-agnostic and synchronously
|
||
/// testable.
|
||
private func makeFileTransferHandlerEnvironment() -> BLEFileTransferHandlerEnvironment {
|
||
BLEFileTransferHandlerEnvironment(
|
||
localPeerID: { [weak self] in
|
||
self?.myPeerID ?? PeerID(str: "")
|
||
},
|
||
localNickname: { [weak self] in
|
||
self?.myNickname ?? ""
|
||
},
|
||
peersSnapshot: { [weak self] in
|
||
guard let self = self else { return [:] }
|
||
return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
|
||
},
|
||
verifyPacketSignature: { [weak self] packet, signingPublicKey in
|
||
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
||
},
|
||
localSigningPublicKey: { [weak self] in
|
||
self?.noiseService.getSigningPublicKeyData() ?? Data()
|
||
},
|
||
signedSenderDisplayName: { [weak self] packet, peerID in
|
||
self?.signedSenderDisplayName(for: packet, from: peerID)
|
||
},
|
||
trackPacketSeen: { [weak self] packet in
|
||
self?.gossipSyncManager?.onPublicPacketSeen(packet)
|
||
},
|
||
enforceStorageQuota: { [weak self] reservingBytes in
|
||
self?.incomingFileStore.enforceQuota(reservingBytes: reservingBytes)
|
||
},
|
||
saveIncomingFile: { [weak self] data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
|
||
self?.incomingFileStore.save(
|
||
data: data,
|
||
preferredName: preferredName,
|
||
subdirectory: subdirectory,
|
||
fallbackExtension: fallbackExtension,
|
||
defaultPrefix: defaultPrefix
|
||
)
|
||
},
|
||
updatePeerLastSeen: { [weak self] peerID in
|
||
self?.updatePeerLastSeen(peerID)
|
||
},
|
||
deliverMessage: { [weak self] message in
|
||
// Single main-actor hop delivering `.messageReceived`.
|
||
self?.emitTransportEvent(.messageReceived(message))
|
||
}
|
||
)
|
||
}
|
||
|
||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||
SecureLogger.debug("🔔 sendFavoriteNotification peer=\(peerID.id.prefix(8))… isFavorite=\(isFavorite)", category: .session)
|
||
|
||
// Include Nostr public key in the notification
|
||
var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]"
|
||
var includesNostrIdentity = false
|
||
|
||
// Add our Nostr public key if available
|
||
if let myNostrIdentity = try? idBridge.getCurrentNostrIdentity() {
|
||
content += ":" + myNostrIdentity.npub
|
||
includesNostrIdentity = true
|
||
SecureLogger.debug("📝 Favorite notification includes Nostr npub=\(myNostrIdentity.npub.prefix(16))…", category: .session)
|
||
}
|
||
|
||
SecureLogger.debug("📤 Sending favorite notification to \(peerID.id.prefix(8))… isFavorite=\(isFavorite) includesNostrIdentity=\(includesNostrIdentity)", category: .session)
|
||
sendPrivateMessage(content, to: peerID, messageID: UUID().uuidString)
|
||
}
|
||
|
||
func sendBroadcastAnnounce() {
|
||
sendAnnounce()
|
||
}
|
||
|
||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||
// Hop like sendMessage: callers are often on the main actor, and the
|
||
// send path sync-waits on bleQueue for link state — the main thread
|
||
// must never block on bleQueue (see captureBluetoothStatus).
|
||
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||
messageQueue.async { [weak self] in
|
||
self?.sendDeliveryAck(for: messageID, to: peerID)
|
||
}
|
||
return
|
||
}
|
||
let payload = BLENoisePayloadFactory.delivered(messageID: messageID)
|
||
|
||
if noiseService.hasEstablishedSession(with: peerID) {
|
||
do {
|
||
broadcastPacket(try makeEncryptedNoisePacket(payload, to: peerID))
|
||
} catch {
|
||
SecureLogger.error("Failed to send delivery ACK: \(error)")
|
||
}
|
||
} else {
|
||
// Queue for after handshake; initiate only while the peer is
|
||
// around to answer — couriered/bridged mail routinely arrives
|
||
// from absent (or rotated) identities, and every duplicate copy
|
||
// initiating a handshake broadcast turns one undeliverable ack
|
||
// into a mesh-wide flood. The queued ack flushes whenever a
|
||
// session eventually establishes.
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
pendingNoiseSessionQueues.appendTypedPayload(payload, for: peerID)
|
||
}
|
||
if !noiseService.hasSession(with: peerID), isPeerReachable(peerID) {
|
||
initiateNoiseHandshake(with: peerID)
|
||
}
|
||
SecureLogger.debug("🕒 Queued DELIVERED ack for \(peerID.id.prefix(8))… until handshake completes", category: .session)
|
||
}
|
||
}
|
||
|
||
/// Accept a leave only when the claimed sender proves possession of the
|
||
/// signing key bound by a verified announce. The persisted identity cache
|
||
/// keeps delayed/relayed leaves verifiable after the live registry entry
|
||
/// has aged out.
|
||
private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||
let registrySigningKey = collectionsQueue.sync {
|
||
peerRegistry.info(for: peerID)?.signingPublicKey
|
||
}
|
||
let verifiedViaRegistry = registrySigningKey.map {
|
||
noiseService.verifyPacketSignature(packet, publicKey: $0)
|
||
} ?? false
|
||
let verifiedViaPersistedIdentity = !verifiedViaRegistry
|
||
&& identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).contains { identity in
|
||
PeerID(publicKey: identity.publicKey) == peerID
|
||
&& identity.signingPublicKey.map {
|
||
noiseService.verifyPacketSignature(packet, publicKey: $0)
|
||
} == true
|
||
}
|
||
|
||
guard verifiedViaRegistry || verifiedViaPersistedIdentity else {
|
||
SecureLogger.warning(
|
||
"🚫 Dropping leave with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…",
|
||
category: .security
|
||
)
|
||
return false
|
||
}
|
||
|
||
// A valid departure retires transport state too; otherwise
|
||
// canDeliverSecurely could remain true for a peer we just removed.
|
||
clearNoiseSession(for: peerID)
|
||
readLinkState { _ in
|
||
let departedLinks = noiseAuthenticatedLinkOwners.compactMap { link, owner in
|
||
owner == peerID ? link : nil
|
||
}
|
||
for link in departedLinks {
|
||
noiseAuthenticatedLinkOwners.removeValue(forKey: link)
|
||
noiseReconnectPolicy.endLinkEpoch(link)
|
||
}
|
||
}
|
||
_ = collectionsQueue.sync(flags: .barrier) {
|
||
// Remove the peer when they leave
|
||
peerRegistry.remove(peerID)
|
||
}
|
||
// Remove any stored announcement for sync purposes
|
||
gossipSyncManager?.removeAnnouncementForPeer(peerID)
|
||
// Send on main thread
|
||
notifyUI { [weak self] in
|
||
guard let self = self else { return }
|
||
|
||
// Get current peer list (after removal)
|
||
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
|
||
|
||
self.deliverTransportEvent(.peerDisconnected(peerID))
|
||
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
||
}
|
||
return true
|
||
}
|
||
private func sendAnnounce(forceSend: Bool = false) {
|
||
guard !isPanicSuspended else { return }
|
||
// Announce construction reads the replaceable Noise service and several
|
||
// related state snapshots. Serialize the whole operation with identity
|
||
// rotation instead of letting CoreBluetooth and maintenance callbacks
|
||
// execute it directly on their own queues.
|
||
messageQueue.async(flags: .barrier) { [weak self] in
|
||
self?.sendAnnounceNow(forceSend: forceSend)
|
||
}
|
||
}
|
||
|
||
private func sendAnnounceNow(forceSend: Bool) {
|
||
// Re-check on the serialized queue: a panic suspend may have started
|
||
// after this announce was scheduled but before it runs.
|
||
guard !isPanicSuspended else { return }
|
||
// Throttle announces to prevent flooding
|
||
if !announceThrottle.shouldSend(force: forceSend, now: Date()) {
|
||
return
|
||
}
|
||
|
||
// Reduced logging - only log errors, not every announce
|
||
|
||
// Create announce payload with both noise and signing public keys
|
||
let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification
|
||
let signingPub = noiseService.getSigningPublicKeyData() // For signature verification
|
||
|
||
let (connectedPeerIDs, advertisedCapabilities, advertisedBridgeCell): ([Data], PeerCapabilities, String?) = collectionsQueue.sync {
|
||
(
|
||
peerRegistry.connectedRoutingData,
|
||
PeerCapabilities.localSupported.union(runtimeCapabilities),
|
||
runtimeCapabilities.contains(.bridge) ? localBridgeGeohash : nil
|
||
)
|
||
}
|
||
|
||
let localIdentity = localIdentityState.snapshot()
|
||
let announcement = AnnouncementPacket(
|
||
nickname: localIdentity.nickname,
|
||
noisePublicKey: noisePub,
|
||
signingPublicKey: signingPub,
|
||
directNeighbors: connectedPeerIDs,
|
||
capabilities: advertisedCapabilities,
|
||
bridgeGeohash: advertisedBridgeCell
|
||
)
|
||
|
||
guard let payload = announcement.encode() else {
|
||
SecureLogger.error("❌ Failed to encode announce packet", category: .session)
|
||
return
|
||
}
|
||
|
||
// Create packet with signature using the noise private key
|
||
let packet = BitchatPacket(
|
||
type: MessageType.announce.rawValue,
|
||
senderID: localIdentity.peerIDData,
|
||
recipientID: nil,
|
||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||
payload: payload,
|
||
signature: nil, // Will be set by signPacket below
|
||
ttl: messageTTL
|
||
)
|
||
|
||
// Sign the packet using the noise private key
|
||
guard let signedPacket = noiseService.signPacket(packet) else {
|
||
SecureLogger.error("❌ Failed to sign announce packet", category: .security)
|
||
return
|
||
}
|
||
|
||
broadcastPacket(signedPacket)
|
||
// Ensure our own announce is included in sync state
|
||
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||
|
||
// Keep our prekey bundle riding alongside presence (throttled; the
|
||
// send is a no-op when the bundle was refreshed recently).
|
||
sendPrekeyBundle()
|
||
}
|
||
|
||
// MARK: QR Verification over Noise
|
||
|
||
// MARK: Private Groups
|
||
|
||
/// Sends creator-signed group state (invite) 1:1 over the Noise session,
|
||
/// queueing behind a handshake when none is established yet.
|
||
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {
|
||
sendNoisePayload(NoisePayload(type: .groupInvite, data: statePayload).encode(), to: peerID)
|
||
}
|
||
|
||
/// Sends creator-signed group state (key rotation / roster update) 1:1
|
||
/// over the Noise session.
|
||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {
|
||
sendNoisePayload(NoisePayload(type: .groupKeyUpdate, data: statePayload).encode(), to: peerID)
|
||
}
|
||
|
||
/// Broadcasts a sealed group message (MessageType 0x25) like a public
|
||
/// message: fire-and-flood with gossip-sync backfill. The outer packet is
|
||
/// intentionally unsigned — receivers authenticate the sender's Ed25519
|
||
/// signature inside the ciphertext, which still verifies for backfilled
|
||
/// copies long after the sender's announce has expired.
|
||
func broadcastGroupMessage(_ envelope: Data) {
|
||
guard !envelope.isEmpty else { return }
|
||
messageQueue.async { [weak self] in
|
||
guard let self else { return }
|
||
let packet = BitchatPacket(
|
||
type: MessageType.groupMessage.rawValue,
|
||
senderID: Data(hexString: self.myPeerID.id) ?? Data(),
|
||
recipientID: nil,
|
||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||
payload: envelope,
|
||
signature: nil,
|
||
ttl: self.messageTTL
|
||
)
|
||
// Pre-mark our own broadcast as processed to avoid handling a
|
||
// relayed self copy.
|
||
let dedupID = BLESelfBroadcastTracker.dedupID(for: packet)
|
||
self.messageDeduplicator.markProcessed(dedupID)
|
||
self.broadcastPacket(packet)
|
||
// Track our own broadcast for gossip sync
|
||
self.gossipSyncManager?.onPublicPacketSeen(packet)
|
||
}
|
||
}
|
||
|
||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||
let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA)
|
||
sendNoisePayload(payload, to: peerID)
|
||
}
|
||
|
||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||
guard let payload = VerificationService.shared.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonceA) else { return }
|
||
sendNoisePayload(payload, to: peerID)
|
||
}
|
||
|
||
// MARK: Vouching over Noise
|
||
|
||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
|
||
sendNoisePayload(NoisePayload(type: .vouch, data: payload).encode(), to: peerID)
|
||
}
|
||
|
||
// MARK: Live Voice (PTT)
|
||
|
||
/// Sends one live voice-burst packet inside the Noise session. Unlike
|
||
/// `sendNoisePayload` this never queues behind a handshake: live audio is
|
||
/// only useful now, so without an established session frames are dropped.
|
||
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) {
|
||
messageQueue.async { [weak self] in
|
||
guard let self else { return }
|
||
guard self.noiseService.hasEstablishedSession(with: peerID) else {
|
||
SecureLogger.debug("PTT: dropping voice frame — no established session with \(peerID.id.prefix(8))…", category: .session)
|
||
return
|
||
}
|
||
do {
|
||
let typedPayload = NoisePayload(type: .voiceFrame, data: burstContent).encode()
|
||
self.broadcastPacket(try self.makeEncryptedNoisePacket(typedPayload, to: peerID))
|
||
} catch {
|
||
SecureLogger.error("Failed to send voice frame: \(error)", category: .session)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Broadcasts one live voice-burst packet to the public mesh, signed like
|
||
/// a public message so receivers can authenticate the talker. Ephemeral:
|
||
/// never tracked for gossip sync (stale audio is worthless to replay).
|
||
func sendVoiceFrameBroadcast(_ burstContent: Data) {
|
||
guard !burstContent.isEmpty else { return }
|
||
messageQueue.async { [weak self] in
|
||
guard let self else { return }
|
||
let packet = BitchatPacket(
|
||
type: MessageType.voiceFrame.rawValue,
|
||
senderID: self.myPeerIDData,
|
||
recipientID: nil,
|
||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||
payload: burstContent,
|
||
signature: nil,
|
||
ttl: self.messageTTL
|
||
)
|
||
guard let signedPacket = self.noiseService.signPacket(packet) else {
|
||
SecureLogger.error("❌ Failed to sign voice frame", category: .security)
|
||
return
|
||
}
|
||
// Pre-mark our own broadcast as processed to avoid handling a
|
||
// relayed self copy.
|
||
let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket)
|
||
self.messageDeduplicator.markProcessed(dedupID)
|
||
self.broadcastPacket(signedPacket)
|
||
}
|
||
}
|
||
|
||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
|
||
// Appends to the encryption service's handler array, so this never
|
||
// displaces the callbacks installed by installNoiseSessionCallbacks.
|
||
noiseService.addOnPeerAuthenticatedHandler(handler)
|
||
}
|
||
}
|
||
|
||
// MARK: - GossipSyncManager Delegate
|
||
extension BLEService: GossipSyncManager.Delegate {
|
||
func sendPacket(_ packet: BitchatPacket) {
|
||
broadcastPacket(packet)
|
||
}
|
||
|
||
func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
|
||
sendPacketDirected(packet, to: peerID)
|
||
}
|
||
|
||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
||
return noiseService.signPacket(packet) ?? packet
|
||
}
|
||
|
||
func getConnectedPeers() -> [PeerID] {
|
||
return collectionsQueue.sync {
|
||
peerRegistry.connectedPeerIDs
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - CBCentralManagerDelegate
|
||
|
||
extension BLEService: CBCentralManagerDelegate {
|
||
#if os(iOS)
|
||
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
||
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
|
||
guard !isPanicSuspended else {
|
||
central.stopScan()
|
||
restoredPeripherals.forEach {
|
||
central.cancelPeripheralConnection($0)
|
||
}
|
||
return
|
||
}
|
||
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
|
||
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
|
||
let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool
|
||
|
||
SecureLogger.info(
|
||
"♻️ Central restore: peripherals=\(restoredPeripherals.count) services=\(restoredServices.count) allowDuplicates=\(String(describing: allowDuplicates))",
|
||
category: .session
|
||
)
|
||
|
||
for peripheral in restoredPeripherals {
|
||
let identifier = peripheral.identifier.uuidString
|
||
peripheral.delegate = self
|
||
let existing = linkStateStore.state(forPeripheralID: identifier)
|
||
let assembler = existing?.assembler ?? NotificationStreamAssembler()
|
||
let characteristic = existing?.characteristic
|
||
let peerID = existing?.peerID
|
||
let wasConnecting = existing?.isConnecting ?? false
|
||
let wasConnected = existing?.isConnected ?? false
|
||
|
||
let restoredState = BLEPeripheralLinkState(
|
||
peripheral: peripheral,
|
||
characteristic: characteristic,
|
||
peerID: peerID,
|
||
isConnecting: wasConnecting || peripheral.state == .connecting,
|
||
isConnected: wasConnected || peripheral.state == .connected,
|
||
lastConnectionAttempt: existing?.lastConnectionAttempt,
|
||
assembler: assembler
|
||
)
|
||
linkStateStore.setPeripheralState(restoredState, for: identifier)
|
||
|
||
// Restored peripherals are the freshest wake-on-proximity
|
||
// candidates we have after a relaunch — without this the cache
|
||
// starts empty and backgrounding right after a restore arms
|
||
// nothing. Service rediscovery for restored-connected links waits
|
||
// for poweredOn: CoreBluetooth drops commands issued during
|
||
// restoration (API MISUSE warnings).
|
||
recentPeripheralCache.record(peripheral, peripheralID: identifier, at: Date())
|
||
}
|
||
|
||
// Via the sampler (not a direct capture): it refreshes the cached
|
||
// background budget on main first, so the restore log shows the real
|
||
// wake window instead of the init sentinel.
|
||
logBluetoothStatus("central-restore")
|
||
|
||
if central.state == .poweredOn {
|
||
startScanning()
|
||
}
|
||
}
|
||
#endif
|
||
|
||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||
emitTransportEvent(.bluetoothStateUpdated(central.state))
|
||
|
||
switch central.state {
|
||
case .poweredOn:
|
||
guard !isPanicSuspended else {
|
||
central.stopScan()
|
||
return
|
||
}
|
||
// Links restored as connected have no characteristic in the new
|
||
// process; without rediscovery they sit connected-but-unusable
|
||
// until the peer disconnects. Runs here (not willRestoreState)
|
||
// because commands issued before poweredOn are dropped.
|
||
for state in linkStateStore.peripheralStates where state.isConnected
|
||
&& state.characteristic == nil
|
||
&& state.peripheral.state == .connected {
|
||
SecureLogger.info("♻️ Rediscovering services on restored link: \(state.peripheral.identifier.uuidString.prefix(8))…", category: .session)
|
||
state.peripheral.discoverServices([BLEService.serviceUUID])
|
||
}
|
||
|
||
// Start scanning - use allow duplicates for faster discovery when active
|
||
startScanning()
|
||
|
||
case .poweredOff:
|
||
// CoreBluetooth has already transitioned out of poweredOn. Do
|
||
// not issue stop/cancel commands now; they are rejected as API
|
||
// misuse. Retire our link state locally instead.
|
||
SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session)
|
||
let peripheralStates = linkStateStore.peripheralStates
|
||
let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID)
|
||
for state in peripheralStates {
|
||
let peripheralID = state.peripheral.identifier.uuidString
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
pendingPeripheralWrites.discardAll(for: peripheralID)
|
||
}
|
||
noiseAuthenticatedLinkOwners.removeValue(
|
||
forKey: .peripheral(peripheralID)
|
||
)
|
||
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||
}
|
||
_ = linkStateStore.clearPeripherals()
|
||
// Notify UI of disconnections
|
||
for peerID in peerIDs {
|
||
notifyUI { [weak self] in
|
||
self?.notifyPeerDisconnectedDebounced(peerID)
|
||
}
|
||
}
|
||
|
||
case .unauthorized:
|
||
// User denied Bluetooth permission
|
||
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
|
||
_ = linkStateStore.clearPeripherals()
|
||
|
||
case .unsupported:
|
||
// Device doesn't support BLE
|
||
SecureLogger.error("❌ Bluetooth LE not supported on this device", category: .session)
|
||
|
||
case .resetting:
|
||
// Bluetooth stack is resetting - will get another state update when done
|
||
SecureLogger.info("🔄 Bluetooth stack resetting...", category: .session)
|
||
|
||
case .unknown:
|
||
// Initial state before we know the actual state
|
||
SecureLogger.debug("❓ Bluetooth state unknown (initializing)", category: .session)
|
||
|
||
@unknown default:
|
||
SecureLogger.warning("⚠️ Unknown Bluetooth state: \(central.state.rawValue)", category: .session)
|
||
}
|
||
}
|
||
|
||
private func startScanning() {
|
||
guard !isPanicSuspended,
|
||
let central = centralManager,
|
||
central.state == .poweredOn,
|
||
!central.isScanning else { return }
|
||
|
||
// Use allow duplicates = true for faster discovery in foreground
|
||
// This gives us discovery events immediately instead of coalesced
|
||
#if os(iOS)
|
||
let allowDuplicates = isAppActive // Use our tracked state (thread-safe)
|
||
#else
|
||
let allowDuplicates = true // macOS doesn't have background restrictions
|
||
#endif
|
||
|
||
central.scanForPeripherals(
|
||
withServices: [BLEService.serviceUUID],
|
||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates]
|
||
)
|
||
|
||
// Started BLE scanning
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||
guard !isPanicSuspended else { return }
|
||
let peripheralID = peripheral.identifier.uuidString
|
||
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…")
|
||
let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true
|
||
let rssiValue = RSSI.intValue
|
||
|
||
let candidate = BLEConnectionCandidate(
|
||
peripheral: peripheral,
|
||
peripheralID: peripheralID,
|
||
rssi: rssiValue,
|
||
name: String(advertisedName),
|
||
isConnectable: isConnectable,
|
||
discoveredAt: Date()
|
||
)
|
||
if isConnectable {
|
||
recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: candidate.discoveredAt)
|
||
}
|
||
let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init)
|
||
|
||
switch connectionScheduler.handleDiscovery(
|
||
candidate,
|
||
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
|
||
existingState: existingState,
|
||
peripheralState: peripheral.state.connectionSchedulerState,
|
||
now: candidate.discoveredAt
|
||
) {
|
||
case .ignore, .queued:
|
||
return
|
||
case .scheduleRetry(let delay):
|
||
bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||
self?.tryConnectFromQueue()
|
||
}
|
||
return
|
||
case .cancelStaleConnection:
|
||
central.cancelPeripheralConnection(peripheral)
|
||
return
|
||
case .connectNow:
|
||
beginCentralConnection(candidate, using: central, logPrefix: "📱 Connect")
|
||
}
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||
guard !isPanicSuspended else {
|
||
central.cancelPeripheralConnection(peripheral)
|
||
return
|
||
}
|
||
let peripheralID = peripheral.identifier.uuidString
|
||
|
||
#if os(iOS)
|
||
// A connect completing while backgrounded is the wake-on-proximity
|
||
// path doing its job — worth an info line for field verification.
|
||
if !isAppActive {
|
||
SecureLogger.info("🌙 Background wake: connected to \(peripheral.name ?? peripheralID) while backgrounded", category: .session)
|
||
}
|
||
#endif
|
||
|
||
// Update state to connected
|
||
linkStateStore.markConnected(peripheral)
|
||
|
||
// Reset backoff state on success
|
||
connectionScheduler.recordConnectionSuccess(peripheralID: peripheralID)
|
||
|
||
SecureLogger.debug("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: .session)
|
||
|
||
// Discover services
|
||
peripheral.discoverServices([BLEService.serviceUUID])
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||
let peripheralID = peripheral.identifier.uuidString
|
||
|
||
// Find the peer ID if we have it
|
||
let peerID = linkStateStore.peerID(forPeripheralID: peripheralID)
|
||
|
||
SecureLogger.debug("📱 Disconnect: \(peerID?.id ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session)
|
||
|
||
// If disconnect carried an error (often timeout), apply short backoff to avoid thrash
|
||
if error != nil {
|
||
connectionScheduler.recordDisconnectError(peripheralID: peripheralID, at: Date())
|
||
}
|
||
|
||
// Retain the handle: a dropped link is the best wake-on-proximity
|
||
// candidate if the app backgrounds before the peer returns.
|
||
recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: Date())
|
||
|
||
#if os(iOS)
|
||
// Link lost while backgrounded (peer walked away): re-arm a pending
|
||
// connect during this wake window so the peer's return wakes us again.
|
||
// Delayed past the disconnect-settle window to avoid reconnect thrash
|
||
// at range edge.
|
||
if !isAppActive {
|
||
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleDisconnectDiscoveryIgnoreSeconds) { [weak self] in
|
||
guard let self, !self.isAppActive else { return }
|
||
// Reserve 0: use the slot this disconnect freed even in a
|
||
// dense mesh, so the lost peer can wake us when it returns.
|
||
self.armPendingBackgroundConnects(slotReserve: 0)
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// Clean up references and peer mappings
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
pendingPeripheralWrites.discardAll(for: peripheralID)
|
||
}
|
||
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||
_ = linkStateStore.removePeripheral(peripheralID)
|
||
// A duplicate link can drop while the peer stays live on another
|
||
// (the dual-role central link, or a second bound link after a
|
||
// restore): peer-disconnect bookkeeping only runs once the peer's
|
||
// last live link is gone. removePeripheral just repaired the reverse
|
||
// map onto a connected survivor, so directLinkState is accurate
|
||
// here. The scan restart and connect-slot refill below stay
|
||
// unguarded — they respond to the physical drop regardless of
|
||
// remaining logical links.
|
||
let remainingLinks = peerID.map { linkStateStore.directLinkState(for: $0) }
|
||
let peerStillLinked = (remainingLinks?.hasPeripheral ?? false) || (remainingLinks?.hasCentral ?? false)
|
||
if let peerID, !peerStillLinked {
|
||
// Do not remove peer; mark as not connected but retain for reachability
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
peerRegistry.markDisconnected(peerID)
|
||
}
|
||
refreshLocalTopology()
|
||
}
|
||
|
||
|
||
// Restart scanning with allow duplicates for faster rediscovery
|
||
if centralManager?.state == .poweredOn {
|
||
// Stop and restart scanning to ensure we get fresh discovery events
|
||
centralManager?.stopScan()
|
||
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in
|
||
self?.startScanning()
|
||
}
|
||
}
|
||
// Attempt to fill freed slot from queue
|
||
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
|
||
|
||
// Notify delegate about disconnection on main thread (direct link dropped)
|
||
notifyUI { [weak self] in
|
||
guard let self = self else { return }
|
||
|
||
// Get current peer list (after removal)
|
||
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
|
||
|
||
if let peerID, !peerStillLinked {
|
||
self.notifyPeerDisconnectedDebounced(peerID)
|
||
}
|
||
self.requestPeerDataPublish()
|
||
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
||
}
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||
let peripheralID = peripheral.identifier.uuidString
|
||
|
||
// Clean up the references
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
pendingPeripheralWrites.discardAll(for: peripheralID)
|
||
}
|
||
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||
_ = linkStateStore.removePeripheral(peripheralID)
|
||
|
||
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
|
||
connectionScheduler.recordConnectionFailure(peripheralID: peripheralID)
|
||
// Try next candidate
|
||
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
|
||
}
|
||
}
|
||
|
||
// MARK: - Connection scheduling helpers
|
||
private extension BLEExistingConnectionState {
|
||
init(_ state: BLEPeripheralLinkState) {
|
||
self.init(
|
||
isConnecting: state.isConnecting,
|
||
isConnected: state.isConnected,
|
||
lastConnectionAttempt: state.lastConnectionAttempt
|
||
)
|
||
}
|
||
}
|
||
|
||
private extension CBPeripheralState {
|
||
var connectionSchedulerState: BLEPeripheralConnectionState {
|
||
switch self {
|
||
case .connected:
|
||
return .connected
|
||
case .connecting:
|
||
return .connecting
|
||
case .disconnected, .disconnecting:
|
||
return .disconnected
|
||
@unknown default:
|
||
return .disconnected
|
||
}
|
||
}
|
||
}
|
||
|
||
extension BLEService {
|
||
private func tryConnectFromQueue() {
|
||
guard !isPanicSuspended,
|
||
let central = centralManager,
|
||
central.state == .poweredOn else { return }
|
||
|
||
let decision = connectionScheduler.nextCandidate(
|
||
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
|
||
isAlreadyConnectingOrConnected: { [linkStateStore] peripheralID in
|
||
let state = linkStateStore.state(forPeripheralID: peripheralID)
|
||
return state?.isConnected == true || state?.isConnecting == true
|
||
},
|
||
now: Date()
|
||
)
|
||
|
||
switch decision {
|
||
case .none:
|
||
return
|
||
case .retryAfter(let delay):
|
||
bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() }
|
||
case .connect(let candidate):
|
||
beginCentralConnection(candidate, using: central, logPrefix: "⏩ Queue connect")
|
||
}
|
||
}
|
||
|
||
private func beginCentralConnection(
|
||
_ candidate: BLEConnectionCandidate<CBPeripheral>,
|
||
using central: CBCentralManager,
|
||
logPrefix: String
|
||
) {
|
||
guard !isPanicSuspended else { return }
|
||
let peripheral = candidate.peripheral
|
||
let peripheralID = candidate.peripheralID
|
||
linkStateStore.beginConnecting(to: peripheral, at: Date())
|
||
peripheral.delegate = self
|
||
let options: [String: Any] = [
|
||
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
|
||
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
|
||
CBConnectPeripheralOptionNotifyOnNotificationKey: true
|
||
]
|
||
central.connect(peripheral, options: options)
|
||
connectionScheduler.recordConnectionAttempt(at: Date())
|
||
SecureLogger.debug("\(logPrefix): \(candidate.name) [RSSI:\(candidate.rssi)]", category: .session)
|
||
|
||
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in
|
||
guard let self = self,
|
||
let state = self.linkStateStore.state(forPeripheralID: peripheralID),
|
||
state.isConnecting && !state.isConnected else { return }
|
||
|
||
guard peripheral.state != .connected else {
|
||
SecureLogger.debug("⏱️ Timeout fired but peripheral already connected: \(candidate.name)", category: .session)
|
||
return
|
||
}
|
||
|
||
#if os(iOS)
|
||
if !self.isAppActive {
|
||
// Backgrounded: leave the connect pending. iOS never expires
|
||
// it — the controller completes it whenever the peer comes
|
||
// back into range, waking the app (state restoration relaunches
|
||
// us if we were terminated). Foreground return cancels stale
|
||
// pendings via cancelStalePendingConnects().
|
||
SecureLogger.info("🌙 Connect timeout deferred while backgrounded, left pending for wake-on-proximity: \(candidate.name)", category: .session)
|
||
return
|
||
}
|
||
#endif
|
||
|
||
SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session)
|
||
central.cancelPeripheralConnection(peripheral)
|
||
self.collectionsQueue.sync(flags: .barrier) {
|
||
self.pendingPeripheralWrites.discardAll(for: peripheralID)
|
||
}
|
||
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||
self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||
_ = self.linkStateStore.removePeripheral(peripheralID)
|
||
self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
|
||
self.tryConnectFromQueue()
|
||
}
|
||
}
|
||
}
|
||
|
||
private extension BLEService {
|
||
static func shouldRediscoverBitChatService(
|
||
invalidatedServiceUUIDs: [CBUUID],
|
||
cachedServiceUUIDs: [CBUUID]?
|
||
) -> Bool {
|
||
invalidatedServiceUUIDs.contains(serviceUUID) || cachedServiceUUIDs?.contains(serviceUUID) != true
|
||
}
|
||
}
|
||
|
||
#if DEBUG
|
||
// Test-only helper to inject packets into the receive pipeline
|
||
extension BLEService {
|
||
/// Queues an event through the same MainActor hop as production receive
|
||
/// handlers so panic-boundary tests can deterministically invalidate it.
|
||
func _test_emitTransportEvent(_ event: TransportEvent) {
|
||
emitTransportEvent(event)
|
||
}
|
||
|
||
var _test_isPanicIngressOpen: Bool {
|
||
capturePanicLifecycleGeneration() != nil
|
||
}
|
||
|
||
/// Models a CoreBluetooth delegate callback without requiring a physical
|
||
/// peripheral. The callback itself runs on `bleQueue`, exactly where the
|
||
/// panic radio-stop barrier must linearize it.
|
||
func _test_handlePacketFromBLEQueue(
|
||
_ packet: BitchatPacket,
|
||
fromPeerID: PeerID
|
||
) {
|
||
bleQueue.async { [weak self] in
|
||
self?.handleReceivedPacket(packet, from: fromPeerID)
|
||
}
|
||
}
|
||
|
||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
|
||
if preseedPeer {
|
||
// Ensure the synthetic peer is known and marked verified for public-message tests
|
||
let normalizedID = PeerID(hexData: packet.senderID)
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
if var existing = peerRegistry.info(for: normalizedID) {
|
||
existing.isConnected = true
|
||
existing.isVerifiedNickname = true
|
||
if let signingPublicKey { existing.signingPublicKey = signingPublicKey }
|
||
existing.lastSeen = Date()
|
||
peerRegistry.upsert(existing)
|
||
} else {
|
||
peerRegistry.upsert(BLEPeerInfo(
|
||
peerID: normalizedID,
|
||
nickname: "TestPeer_\(fromPeerID.id.prefix(4))",
|
||
isConnected: true,
|
||
noisePublicKey: packet.senderID,
|
||
signingPublicKey: signingPublicKey,
|
||
isVerifiedNickname: true,
|
||
lastSeen: Date()
|
||
))
|
||
}
|
||
}
|
||
}
|
||
handleReceivedPacket(packet, from: fromPeerID)
|
||
}
|
||
|
||
/// Waits until fragment ingress already submitted by a test has finished
|
||
/// reassembly/reinjection and any resulting transport event has crossed
|
||
/// the MainActor delivery hop. This is a deterministic pipeline fence,
|
||
/// avoiding wall-clock sleeps that become flaky under a parallel suite.
|
||
func _test_drainFragmentPipeline() async {
|
||
await withCheckedContinuation { continuation in
|
||
messageQueue.async(flags: .barrier) {
|
||
// Reassembled packets are reinjected synchronously on
|
||
// `messageQueue`; their UI delivery task is therefore already
|
||
// enqueued before this later MainActor marker.
|
||
Task { @MainActor in
|
||
continuation.resume()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func _test_hasGossipPrekeyBundle(for peerID: PeerID) -> Bool {
|
||
gossipSyncManager?._hasPrekeyBundle(for: peerID) ?? false
|
||
}
|
||
|
||
func _test_acceptsIngress(packet: BitchatPacket, boundPeerID: PeerID?) -> Bool {
|
||
let claimedSenderID = PeerID(hexData: packet.senderID)
|
||
guard case .success = BLEIngressLinkRegistry.packetContext(
|
||
for: packet,
|
||
claimedSenderID: claimedSenderID,
|
||
boundPeerID: boundPeerID,
|
||
localPeerID: myPeerID,
|
||
directAnnounceTTL: messageTTL
|
||
) else {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func _test_recordIngressIfNew(packet: BitchatPacket, linkID: String) -> Bool {
|
||
recordIngressIfNew(packet, link: .central(linkID), peerID: PeerID(hexData: packet.senderID))
|
||
}
|
||
|
||
func _test_bindCentral(_ centralUUID: String, to peerID: PeerID) {
|
||
bleQueue.sync { linkStateStore.bindCentral(centralUUID, to: peerID) }
|
||
}
|
||
|
||
func _test_centralBinding(_ centralUUID: String) -> PeerID? {
|
||
bleQueue.sync { linkStateStore.peerID(forCentralUUID: centralUUID) }
|
||
}
|
||
|
||
func _test_markNoiseAuthenticatedCentral(_ centralUUID: String, to peerID: PeerID) {
|
||
bleQueue.sync {
|
||
guard linkStateStore.peerID(forCentralUUID: centralUUID) == peerID else { return }
|
||
noiseAuthenticatedLinkOwners[.central(centralUUID)] = peerID
|
||
}
|
||
}
|
||
|
||
func _test_isNoiseAuthenticatedCentral(_ centralUUID: String, for peerID: PeerID) -> Bool {
|
||
bleQueue.sync {
|
||
noiseAuthenticatedLinkOwners[.central(centralUUID)] == peerID
|
||
}
|
||
}
|
||
|
||
func _test_seedConnectedPeer(
|
||
_ peerID: PeerID,
|
||
nickname: String,
|
||
capabilities: PeerCapabilities? = nil,
|
||
noisePublicKey: Data? = nil
|
||
) {
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
peerRegistry.upsert(BLEPeerInfo(
|
||
peerID: peerID,
|
||
nickname: nickname,
|
||
isConnected: true,
|
||
noisePublicKey: noisePublicKey,
|
||
signingPublicKey: nil,
|
||
isVerifiedNickname: true,
|
||
lastSeen: Date(),
|
||
capabilities: capabilities ?? [],
|
||
capabilitiesWereExplicitlyAdvertised: capabilities != nil
|
||
))
|
||
}
|
||
}
|
||
|
||
/// Handshake plumbing for tests that need a real established Noise
|
||
/// session (e.g. canDeliverSecurely) without Bluetooth in the loop.
|
||
func _test_noiseInitiateHandshake(with peerID: PeerID) throws -> Data {
|
||
try noiseService.initiateHandshake(with: peerID)
|
||
}
|
||
|
||
func _test_noiseProcessHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
|
||
try noiseService.processHandshakeMessage(from: peerID, message: message)
|
||
}
|
||
|
||
func _test_enqueuePendingPrivateMessage(
|
||
content: String,
|
||
messageID: String,
|
||
for peerID: PeerID
|
||
) {
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
pendingNoiseSessionQueues.appendPrivateMessage(
|
||
content: content,
|
||
messageID: messageID,
|
||
for: peerID
|
||
)
|
||
}
|
||
}
|
||
|
||
func _test_enqueuePendingNoisePayload(
|
||
_ payload: Data,
|
||
transferId: String,
|
||
for peerID: PeerID
|
||
) {
|
||
guard privateMediaTransferAdmissions.begin(transferId) == .admitted else { return }
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
pendingNoiseSessionQueues.appendTypedPayload(
|
||
payload,
|
||
transferId: transferId,
|
||
for: peerID
|
||
)
|
||
}
|
||
}
|
||
|
||
func _test_sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) {
|
||
sendPendingNoisePayloadsAfterHandshake(for: peerID)
|
||
}
|
||
|
||
func _test_hasPendingPrivateMediaPolicyResolution(for peerID: PeerID) -> Bool {
|
||
collectionsQueue.sync {
|
||
pendingPrivateMediaPolicyResolutions[peerID.toShort()] != nil
|
||
}
|
||
}
|
||
|
||
func _test_forcePrivateMediaProofTimeout(for peerID: PeerID) {
|
||
let normalizedPeerID = peerID.toShort()
|
||
let target = collectionsQueue.sync {
|
||
() -> (fingerprint: String, generation: UUID?, nonce: UUID)? in
|
||
if let watchdog = privateMediaProofWatchdogs[normalizedPeerID] {
|
||
return (
|
||
watchdog.fingerprint,
|
||
watchdog.sessionGeneration,
|
||
watchdog.timeoutNonce
|
||
)
|
||
}
|
||
if let pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] {
|
||
return (
|
||
pending.fingerprint,
|
||
pending.sessionGeneration,
|
||
pending.timeoutNonce
|
||
)
|
||
}
|
||
return nil
|
||
}
|
||
guard let target else { return }
|
||
handlePrivateMediaProofTimeout(
|
||
for: normalizedPeerID,
|
||
fingerprint: target.fingerprint,
|
||
sessionGeneration: target.generation,
|
||
nonce: target.nonce
|
||
)
|
||
}
|
||
|
||
func _test_privateMediaTransferState(
|
||
transferId: String
|
||
) -> (admissionActive: Bool, pendingNoise: Bool, activeScheduler: Int, pendingScheduler: Int) {
|
||
let scheduler = collectionsQueue.sync {
|
||
(
|
||
pendingNoiseSessionQueues.containsTypedPayload(transferId: transferId),
|
||
outboundFragmentTransfers.activeCount,
|
||
outboundFragmentTransfers.pendingCount
|
||
)
|
||
}
|
||
return (
|
||
privateMediaTransferAdmissions.isActive(transferId),
|
||
scheduler.0,
|
||
scheduler.1,
|
||
scheduler.2
|
||
)
|
||
}
|
||
|
||
func _test_privateMediaAdmissionEntryCount() -> Int {
|
||
privateMediaTransferAdmissions.count
|
||
}
|
||
|
||
@discardableResult
|
||
func _test_beginPrivateMediaAdmission(_ transferId: String, now: Date) -> Bool {
|
||
privateMediaTransferAdmissions.begin(transferId, now: now) == .admitted
|
||
}
|
||
|
||
func _test_isPrivateMediaAdmissionActive(_ transferId: String, now: Date) -> Bool {
|
||
privateMediaTransferAdmissions.isActive(transferId, now: now)
|
||
}
|
||
|
||
func _test_finishPrivateMediaAdmission(_ transferId: String) {
|
||
privateMediaTransferAdmissions.finish(transferId)
|
||
}
|
||
|
||
func _test_drainPrivateMediaSendPipeline() async {
|
||
let collectionsQueue = self.collectionsQueue
|
||
await withCheckedContinuation { continuation in
|
||
messageQueue.async {
|
||
collectionsQueue.async(flags: .barrier) {
|
||
continuation.resume()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func _test_broadcastPrivateMediaPacket(
|
||
_ packet: BitchatPacket,
|
||
transferId: String
|
||
) {
|
||
broadcastPacket(
|
||
packet,
|
||
transferId: transferId,
|
||
requiresPrivateMediaAdmission: true
|
||
)
|
||
}
|
||
|
||
func _test_drainNoiseMessagePipeline() async {
|
||
let collectionsQueue = self.collectionsQueue
|
||
await withCheckedContinuation { continuation in
|
||
messageQueue.async(flags: .barrier) {
|
||
collectionsQueue.async(flags: .barrier) {
|
||
continuation.resume()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Replays the current generation's ready callback. Restore tests use
|
||
/// this to prove same-generation reconciliation is idempotent.
|
||
func _test_reconcileCurrentNoiseSession(for peerID: PeerID) {
|
||
let normalizedPeerID = peerID.toShort()
|
||
messageQueue.async(flags: .barrier) { [weak self] in
|
||
guard let self,
|
||
let generation = self.noiseService.sessionGeneration(
|
||
for: normalizedPeerID
|
||
),
|
||
let fingerprint = self.noiseService.getPeerFingerprint(
|
||
normalizedPeerID
|
||
) else {
|
||
return
|
||
}
|
||
self.handleNoisePeerAuthenticated(
|
||
peerID: normalizedPeerID,
|
||
fingerprint: fingerprint,
|
||
sessionGeneration: generation
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Builds an authenticated-session packet from an exact typed plaintext.
|
||
/// Compatibility tests use this to model Android's deployed 0x20 file
|
||
/// payload and the short-lived 0x09 prerelease payload without exposing a
|
||
/// production API that can emit the old value.
|
||
func _test_makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket {
|
||
try makeEncryptedNoisePacket(typedPayload, to: peerID)
|
||
}
|
||
|
||
static func _test_shouldRediscoverBitChatService(
|
||
invalidatedServiceUUIDs: [CBUUID],
|
||
cachedServiceUUIDs: [CBUUID]?
|
||
) -> Bool {
|
||
shouldRediscoverBitChatService(
|
||
invalidatedServiceUUIDs: invalidatedServiceUUIDs,
|
||
cachedServiceUUIDs: cachedServiceUUIDs
|
||
)
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// MARK: - CBPeripheralDelegate
|
||
|
||
extension BLEService: CBPeripheralDelegate {
|
||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||
guard !isPanicSuspended else { return }
|
||
if let error = error {
|
||
SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
||
// Retry service discovery after a delay
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||
guard peripheral.state == .connected else { return }
|
||
peripheral.discoverServices([BLEService.serviceUUID])
|
||
}
|
||
return
|
||
}
|
||
|
||
guard let services = peripheral.services else {
|
||
SecureLogger.warning("⚠️ No services discovered for \(peripheral.name ?? "Unknown")", category: .session)
|
||
return
|
||
}
|
||
|
||
guard let service = services.first(where: { $0.uuid == BLEService.serviceUUID }) else {
|
||
// Not a BitChat peer - disconnect
|
||
centralManager?.cancelPeripheralConnection(peripheral)
|
||
return
|
||
}
|
||
|
||
// Discovering BLE characteristics
|
||
peripheral.discoverCharacteristics([BLEService.characteristicUUID], for: service)
|
||
}
|
||
|
||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||
guard !isPanicSuspended else { return }
|
||
if let error = error {
|
||
SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
||
return
|
||
}
|
||
|
||
guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else {
|
||
SecureLogger.warning("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: .session)
|
||
return
|
||
}
|
||
|
||
// Found characteristic
|
||
|
||
// Log characteristic properties for debugging
|
||
var properties: [String] = []
|
||
if characteristic.properties.contains(.read) { properties.append("read") }
|
||
if characteristic.properties.contains(.write) { properties.append("write") }
|
||
if characteristic.properties.contains(.writeWithoutResponse) { properties.append("writeWithoutResponse") }
|
||
if characteristic.properties.contains(.notify) { properties.append("notify") }
|
||
if characteristic.properties.contains(.indicate) { properties.append("indicate") }
|
||
// Characteristic properties: \(properties.joined(separator: ", "))
|
||
|
||
// Verify characteristic supports reliable writes
|
||
if !characteristic.properties.contains(.write) {
|
||
SecureLogger.warning("⚠️ Characteristic doesn't support reliable writes (withResponse)!", category: .session)
|
||
}
|
||
|
||
// Store characteristic in our consolidated structure
|
||
let peripheralID = peripheral.identifier.uuidString
|
||
linkStateStore.updateCharacteristic(characteristic, forPeripheralID: peripheralID)
|
||
|
||
// Subscribe for notifications
|
||
if characteristic.properties.contains(.notify) {
|
||
peripheral.setNotifyValue(true, for: characteristic)
|
||
SecureLogger.debug("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: .session)
|
||
|
||
// Send announce after subscription is confirmed (force send for new connection)
|
||
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in
|
||
self?.sendAnnounce(forceSend: true)
|
||
// Try flushing any spooled directed packets now that we have a link
|
||
self?.flushDirectedSpool()
|
||
}
|
||
} else {
|
||
SecureLogger.warning("⚠️ Characteristic does not support notifications", category: .session)
|
||
}
|
||
}
|
||
|
||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||
guard !isPanicSuspended else { return }
|
||
if let error = error {
|
||
SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session)
|
||
return
|
||
}
|
||
|
||
guard let data = characteristic.value, !data.isEmpty else {
|
||
SecureLogger.warning("⚠️ No data in notification", category: .session)
|
||
return
|
||
}
|
||
|
||
bufferNotificationChunk(data, from: peripheral)
|
||
}
|
||
|
||
private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) {
|
||
let peripheralUUID = peripheral.identifier.uuidString
|
||
|
||
var state = linkStateStore.state(forPeripheralID: peripheralUUID) ?? BLEPeripheralLinkState(
|
||
peripheral: peripheral,
|
||
characteristic: nil,
|
||
peerID: nil,
|
||
isConnecting: false,
|
||
isConnected: peripheral.state == .connected,
|
||
lastConnectionAttempt: nil,
|
||
assembler: NotificationStreamAssembler()
|
||
)
|
||
|
||
var assembler = state.assembler
|
||
let result = assembler.append(chunk)
|
||
state.assembler = assembler
|
||
linkStateStore.setPeripheralState(state, for: peripheralUUID)
|
||
|
||
for byte in result.droppedPrefixes {
|
||
SecureLogger.warning("⚠️ Dropping byte from BLE stream (unexpected prefix \(String(format: "%02x", byte)))", category: .session)
|
||
}
|
||
|
||
if result.reset {
|
||
SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session)
|
||
}
|
||
|
||
// Codex review identified TOCTOU in this patch.
|
||
// Enforce per-link sender binding immediately within the same notification batch.
|
||
// NOTE: `processNotificationPacket` may bind the stored peer ID when an announce
|
||
// is processed, but `state` above is a snapshot. Track a local binding that we update as soon as
|
||
// we see a binding-eligible announce so subsequent frames can't spoof a different sender.
|
||
var boundPeerID: PeerID? = state.peerID
|
||
|
||
for frame in result.frames {
|
||
guard let packet = BinaryProtocol.decode(frame) else {
|
||
let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
|
||
SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session)
|
||
continue
|
||
}
|
||
|
||
let claimedSenderID = PeerID(hexData: packet.senderID)
|
||
let context = acceptedIngressContext(
|
||
for: packet,
|
||
claimedSenderID: claimedSenderID,
|
||
boundPeerID: boundPeerID,
|
||
linkDescription: "Peripheral \(peripheralUUID.prefix(8))…"
|
||
)
|
||
|
||
guard let context else { continue }
|
||
|
||
// If this is a direct-link announce, bind immediately for the remainder of this batch.
|
||
if boundPeerID == nil,
|
||
packet.type == MessageType.announce.rawValue,
|
||
packet.ttl == messageTTL {
|
||
boundPeerID = claimedSenderID
|
||
state.peerID = claimedSenderID
|
||
linkStateStore.bindPeripheral(peripheralUUID, to: claimedSenderID)
|
||
}
|
||
|
||
if !recordIngressIfNew(packet, link: .peripheral(peripheralUUID), peerID: context.receivedFromPeerID) {
|
||
continue
|
||
}
|
||
processNotificationPacket(
|
||
packet,
|
||
from: peripheral,
|
||
peripheralUUID: peripheralUUID,
|
||
receivedFrom: context.receivedFromPeerID
|
||
)
|
||
}
|
||
}
|
||
|
||
private func processNotificationPacket(_ packet: BitchatPacket, from _: CBPeripheral, peripheralUUID: String, receivedFrom peerID: PeerID) {
|
||
let senderID = PeerID(hexData: packet.senderID)
|
||
|
||
if packet.type != MessageType.announce.rawValue {
|
||
SecureLogger.debug("📦 Decoded notification packet type: \(packet.type) from sender: \(senderID.id.prefix(8))…", category: .session)
|
||
}
|
||
|
||
if packet.type == MessageType.announce.rawValue,
|
||
packet.ttl == messageTTL {
|
||
// Only bind an unbound link here: this runs before signature
|
||
// verification, so a bound link must not be re-bound by a raw
|
||
// announce (spoofable). Rotation rebinds happen after the announce
|
||
// verifies (rebindLinkAfterVerifiedDirectAnnounce).
|
||
let boundPeerID = linkStateStore.peerID(forPeripheralID: peripheralUUID)
|
||
if boundPeerID == nil || boundPeerID == senderID {
|
||
linkStateStore.bindPeripheral(peripheralUUID, to: senderID)
|
||
refreshLocalTopology()
|
||
}
|
||
}
|
||
|
||
handleReceivedPacket(packet, from: peerID)
|
||
}
|
||
|
||
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
||
if let error = error {
|
||
SecureLogger.error("❌ Write failed to \(peripheral.name ?? peripheral.identifier.uuidString): \(error.localizedDescription)", category: .session)
|
||
// Don't retry - just log the error
|
||
} else {
|
||
SecureLogger.debug("✅ Write confirmed to \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
|
||
}
|
||
}
|
||
|
||
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
||
guard !isPanicSuspended else { return }
|
||
// Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again
|
||
if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") {
|
||
SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session)
|
||
}
|
||
drainPendingWrites(for: peripheral)
|
||
}
|
||
|
||
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
|
||
guard !isPanicSuspended else { return }
|
||
SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
|
||
|
||
let shouldRediscover = BLEService.shouldRediscoverBitChatService(
|
||
invalidatedServiceUUIDs: invalidatedServices.map(\.uuid),
|
||
cachedServiceUUIDs: peripheral.services?.map(\.uuid)
|
||
)
|
||
|
||
guard shouldRediscover else { return }
|
||
|
||
let peripheralID = peripheral.identifier.uuidString
|
||
linkStateStore.updatePeripheral(peripheralID) {
|
||
$0.characteristic = nil
|
||
$0.assembler = NotificationStreamAssembler()
|
||
}
|
||
|
||
SecureLogger.debug("🔄 BitChat service changed for \(peripheral.name ?? peripheral.identifier.uuidString), rediscovering", category: .session)
|
||
peripheral.discoverServices([BLEService.serviceUUID])
|
||
}
|
||
|
||
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
||
guard !isPanicSuspended else { return }
|
||
if let error = error {
|
||
SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session)
|
||
} else {
|
||
SecureLogger.debug("🔔 Notification state updated for \(peripheral.name ?? peripheral.identifier.uuidString): \(characteristic.isNotifying ? "ON" : "OFF")", category: .session)
|
||
|
||
// If notifications are now on, send an announce to ensure this peer knows about us
|
||
if characteristic.isNotifying {
|
||
// Sending announce after subscription
|
||
self.sendAnnounce(forceSend: true)
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// MARK: - CBPeripheralManagerDelegate
|
||
|
||
extension BLEService: CBPeripheralManagerDelegate {
|
||
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
|
||
SecureLogger.debug("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: .session)
|
||
|
||
switch peripheral.state {
|
||
case .poweredOn:
|
||
guard !isPanicSuspended else {
|
||
peripheral.stopAdvertising()
|
||
peripheral.removeAllServices()
|
||
characteristic = nil
|
||
return
|
||
}
|
||
// Remove all services first to ensure clean state
|
||
peripheral.removeAllServices()
|
||
|
||
// Create characteristic
|
||
characteristic = CBMutableCharacteristic(
|
||
type: BLEService.characteristicUUID,
|
||
properties: [.notify, .write, .writeWithoutResponse, .read],
|
||
value: nil,
|
||
permissions: [.readable, .writeable]
|
||
)
|
||
|
||
// Create service
|
||
let service = CBMutableService(type: BLEService.serviceUUID, primary: true)
|
||
service.characteristics = [characteristic!]
|
||
|
||
// Add service (advertising will start in didAdd delegate)
|
||
SecureLogger.debug("🔧 Adding BLE service...", category: .session)
|
||
peripheral.add(service)
|
||
|
||
case .poweredOff:
|
||
// Bluetooth was turned off - clean up peripheral state
|
||
SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
|
||
// Clear subscribed centrals (they are now invalid)
|
||
let centralSnapshot = linkStateStore.subscribedCentralSnapshot
|
||
for central in centralSnapshot.centrals {
|
||
let centralID = central.identifier.uuidString
|
||
noiseAuthenticatedLinkOwners.removeValue(
|
||
forKey: .central(centralID)
|
||
)
|
||
noiseReconnectPolicy.endLinkEpoch(.central(centralID))
|
||
}
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
pendingNotifications.removeAll()
|
||
pendingWriteBuffers.removeAll()
|
||
}
|
||
let centralPeerIDs = linkStateStore.clearCentrals()
|
||
subscriptionAnnounceLimiter.removeAll()
|
||
characteristic = nil
|
||
// Notify UI of disconnections
|
||
for peerID in centralPeerIDs {
|
||
notifyUI { [weak self] in
|
||
self?.notifyPeerDisconnectedDebounced(peerID)
|
||
}
|
||
}
|
||
|
||
case .unauthorized:
|
||
// User denied Bluetooth permission
|
||
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
|
||
_ = linkStateStore.clearCentrals()
|
||
subscriptionAnnounceLimiter.removeAll()
|
||
characteristic = nil
|
||
|
||
case .unsupported:
|
||
// Device doesn't support BLE peripheral role
|
||
SecureLogger.error("❌ Bluetooth LE peripheral role not supported", category: .session)
|
||
|
||
case .resetting:
|
||
// Bluetooth stack is resetting
|
||
SecureLogger.info("🔄 Bluetooth peripheral stack resetting...", category: .session)
|
||
|
||
case .unknown:
|
||
SecureLogger.debug("❓ Peripheral Bluetooth state unknown (initializing)", category: .session)
|
||
|
||
@unknown default:
|
||
SecureLogger.warning("⚠️ Unknown peripheral Bluetooth state: \(peripheral.state.rawValue)", category: .session)
|
||
}
|
||
}
|
||
|
||
#if os(iOS)
|
||
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
|
||
guard !isPanicSuspended else {
|
||
peripheral.stopAdvertising()
|
||
peripheral.removeAllServices()
|
||
characteristic = nil
|
||
return
|
||
}
|
||
let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
|
||
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
|
||
|
||
SecureLogger.info(
|
||
"♻️ Peripheral restore: services=\(restoredServices.count) advertisingDataKeys=\(Array(restoredAdvertisement.keys))",
|
||
category: .session
|
||
)
|
||
|
||
// Attempt to recover characteristic from restored services
|
||
if characteristic == nil {
|
||
if let service = restoredServices.first(where: { $0.uuid == BLEService.serviceUUID }),
|
||
let restoredCharacteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) as? CBMutableCharacteristic {
|
||
characteristic = restoredCharacteristic
|
||
}
|
||
}
|
||
|
||
// Via the sampler for a fresh background budget (see central-restore).
|
||
logBluetoothStatus("peripheral-restore")
|
||
|
||
if peripheral.state == .poweredOn && !peripheral.isAdvertising {
|
||
peripheral.startAdvertising(buildAdvertisementData())
|
||
}
|
||
}
|
||
#endif
|
||
|
||
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
|
||
guard !isPanicSuspended else {
|
||
peripheral.stopAdvertising()
|
||
return
|
||
}
|
||
if let error = error {
|
||
SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session)
|
||
return
|
||
}
|
||
|
||
SecureLogger.debug("✅ Service added successfully, starting advertising", category: .session)
|
||
|
||
// Start advertising after service is confirmed added
|
||
let adData = buildAdvertisementData()
|
||
peripheral.startAdvertising(adData)
|
||
|
||
SecureLogger.debug("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.id.prefix(8))…)", category: .session)
|
||
}
|
||
|
||
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
|
||
guard !isPanicSuspended else { return }
|
||
let centralUUID = central.identifier.uuidString
|
||
SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))…", category: .session)
|
||
linkStateStore.addSubscribedCentral(central)
|
||
|
||
// BCH-01-004: Rate-limit subscription-triggered announces to prevent enumeration attacks
|
||
let now = Date()
|
||
switch subscriptionAnnounceLimiter.decision(for: centralUUID, now: now) {
|
||
case .allowed:
|
||
break
|
||
case let .rateLimited(backoffSeconds, attemptCount, suppressAnnounce):
|
||
SecureLogger.warning("🛡️ BCH-01-004: Rate-limited announce for central \(centralUUID.prefix(8))... (backoff: \(Int(backoffSeconds))s, attempts: \(attemptCount))", category: .security)
|
||
if suppressAnnounce {
|
||
SecureLogger.warning("🚨 BCH-01-004: Possible enumeration attack from central \(centralUUID.prefix(8))... - suppressing announce", category: .security)
|
||
return
|
||
}
|
||
|
||
// Still flush directed packets for legitimate mesh operation
|
||
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
|
||
self?.flushDirectedSpool()
|
||
}
|
||
return
|
||
}
|
||
|
||
// 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
|
||
self?.flushDirectedSpool()
|
||
}
|
||
}
|
||
|
||
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
|
||
let centralID = central.identifier.uuidString
|
||
SecureLogger.debug("📤 Central unsubscribed: \(centralID.prefix(8))…", category: .session)
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
|
||
}
|
||
noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID))
|
||
noiseReconnectPolicy.endLinkEpoch(.central(centralID))
|
||
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
|
||
|
||
// Ensure we're still advertising for other devices to find us
|
||
if !isPanicSuspended, peripheral.isAdvertising == false {
|
||
SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session)
|
||
peripheral.startAdvertising(buildAdvertisementData())
|
||
}
|
||
|
||
// Find and disconnect the peer associated with this central
|
||
if let peerID = removedPeerID {
|
||
// The remote side retiring a redundant duplicate connection
|
||
// arrives here as an unsubscribe while the peer stays live on
|
||
// its other links; only the peer's last link disconnecting
|
||
// counts. If every link truly dropped, the surviving-link
|
||
// callbacks (didDisconnectPeripheral, or this one again) run
|
||
// the bookkeeping.
|
||
guard linkStateStore.links(to: peerID).isEmpty else { return }
|
||
// Mark peer as not connected; retain for reachability
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
peerRegistry.markDisconnected(peerID)
|
||
}
|
||
|
||
refreshLocalTopology()
|
||
|
||
// Update UI immediately
|
||
notifyUI { [weak self] in
|
||
guard let self = self else { return }
|
||
|
||
// Get current peer list (after removal)
|
||
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
|
||
|
||
self.notifyPeerDisconnectedDebounced(peerID)
|
||
// Publish snapshots so UnifiedPeerService can refresh icons promptly
|
||
self.requestPeerDataPublish()
|
||
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
||
}
|
||
}
|
||
}
|
||
|
||
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
|
||
guard !isPanicSuspended else { return }
|
||
drainPendingNotifications(logPrefix: "✅ Sent")
|
||
}
|
||
|
||
private func logBackpressureSampled(_ message: @autoclosure () -> String) {
|
||
notificationBackpressureLogCount += 1
|
||
if notificationBackpressureLogCount == 1 ||
|
||
notificationBackpressureLogCount.isMultiple(of: TransportConfig.bleBackpressureLogInterval) {
|
||
SecureLogger.debug("\(message()) [backpressure event #\(notificationBackpressureLogCount)]", category: .session)
|
||
}
|
||
}
|
||
|
||
private func drainPendingNotifications(logPrefix: String) {
|
||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||
guard let self = self,
|
||
let characteristic = self.characteristic,
|
||
!self.pendingNotifications.isEmpty else { return }
|
||
|
||
let pending = self.pendingNotifications.takeAll()
|
||
let sentCount = self.sendPendingNotifications(pending, characteristic: characteristic)
|
||
|
||
if sentCount > 0 {
|
||
self.logBackpressureSampled("\(logPrefix) \(sentCount) pending notifications from retry queue (\(self.pendingNotifications.count) still pending)")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func sendPendingNotifications(_ pending: [BLEPendingNotification<CBCentral>], characteristic: CBMutableCharacteristic) -> Int {
|
||
var sentCount = 0
|
||
|
||
for (index, notification) in pending.enumerated() {
|
||
let success = peripheralManager?.updateValue(
|
||
notification.data,
|
||
for: characteristic,
|
||
onSubscribedCentrals: notification.targets
|
||
) ?? false
|
||
|
||
guard success else {
|
||
let remaining = Array(pending.dropFirst(index))
|
||
pendingNotifications.prepend(remaining)
|
||
logBackpressureSampled("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items")
|
||
break
|
||
}
|
||
|
||
sentCount += 1
|
||
}
|
||
|
||
return sentCount
|
||
}
|
||
|
||
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
|
||
// Suppress logs for single write requests to reduce noise
|
||
if requests.count > 1 {
|
||
SecureLogger.debug("📥 Received \(requests.count) write requests from central", category: .session)
|
||
}
|
||
|
||
// IMPORTANT: Respond immediately to prevent timeouts!
|
||
// We must respond within a few milliseconds or the central will timeout
|
||
for request in requests {
|
||
peripheral.respond(to: request, withResult: .success)
|
||
}
|
||
guard !isPanicSuspended else { return }
|
||
|
||
// Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets.
|
||
// Combine per-central request values by offset before decoding.
|
||
// Process directly on our message queue to match transport context
|
||
let grouped = Dictionary(grouping: requests, by: { $0.central.identifier.uuidString })
|
||
for (centralUUID, group) in grouped {
|
||
// Sort by offset ascending
|
||
let sorted = group.sorted { $0.offset < $1.offset }
|
||
let hasMultiple = sorted.count > 1 || (sorted.first?.offset ?? 0) > 0
|
||
let chunks = sorted.compactMap { request -> BLEInboundWriteChunk? in
|
||
guard let data = request.value, !data.isEmpty else { return nil }
|
||
return BLEInboundWriteChunk(offset: request.offset, data: data)
|
||
}
|
||
|
||
let result = pendingWriteBuffers.append(
|
||
chunks: chunks,
|
||
for: centralUUID,
|
||
capBytes: TransportConfig.blePendingWriteBufferCapBytes
|
||
)
|
||
|
||
switch result {
|
||
case let .decoded(packet, metadata):
|
||
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
|
||
processDecodedCentralWrite(packet, centralUUID: centralUUID, central: sorted[0].central)
|
||
|
||
case let .waiting(metadata):
|
||
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
|
||
logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted)
|
||
|
||
case let .oversized(metadata):
|
||
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
|
||
SecureLogger.warning("⚠️ Dropping oversized pending write buffer (\(metadata.accumulatedBytes) bytes) for central \(centralUUID.prefix(8))…", category: .session)
|
||
logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func logAccumulatedCentralWrite(_ metadata: BLEInboundWriteAppendMetadata, centralUUID: String) {
|
||
guard let packetType = metadata.packetType,
|
||
packetType != MessageType.announce.rawValue else { return }
|
||
|
||
SecureLogger.debug(
|
||
"📥 Accumulated write from central \(centralUUID.prefix(8))…: size=\(metadata.accumulatedBytes) (+\(metadata.appendedBytes)) bytes (type=\(packetType)), offsets=\(metadata.offsets)",
|
||
category: .session
|
||
)
|
||
}
|
||
|
||
private func logFailedSingleWriteIfNeeded(hasMultiple: Bool, sortedRequests: [CBATTRequest]) {
|
||
guard !hasMultiple, let raw = sortedRequests.first?.value else { return }
|
||
|
||
let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
|
||
SecureLogger.error("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: .session)
|
||
}
|
||
|
||
private func processDecodedCentralWrite(_ packet: BitchatPacket, centralUUID: String, central: CBCentral) {
|
||
let claimedSenderID = PeerID(hexData: packet.senderID)
|
||
let context = acceptedIngressContext(
|
||
for: packet,
|
||
claimedSenderID: claimedSenderID,
|
||
boundPeerID: linkStateStore.peerID(forCentralUUID: centralUUID),
|
||
linkDescription: "Central \(centralUUID.prefix(8))…"
|
||
)
|
||
guard let context else { return }
|
||
|
||
if packet.type != MessageType.announce.rawValue {
|
||
SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(claimedSenderID.id.prefix(8))…", category: .session)
|
||
}
|
||
|
||
linkStateStore.addSubscribedCentral(central)
|
||
|
||
if packet.type == MessageType.announce.rawValue,
|
||
packet.ttl == messageTTL {
|
||
// Same rule as the peripheral path: raw announces only bind
|
||
// unbound links; rotation rebinds require a verified announce.
|
||
let boundPeerID = linkStateStore.peerID(forCentralUUID: centralUUID)
|
||
if boundPeerID == nil || boundPeerID == claimedSenderID {
|
||
linkStateStore.bindCentral(centralUUID, to: claimedSenderID)
|
||
refreshLocalTopology()
|
||
}
|
||
}
|
||
|
||
guard recordIngressIfNew(packet, link: .central(centralUUID), peerID: context.receivedFromPeerID) else {
|
||
return
|
||
}
|
||
|
||
handleReceivedPacket(packet, from: context.receivedFromPeerID)
|
||
}
|
||
}
|
||
|
||
// MARK: - Advertising Builders & Alias Rotation
|
||
|
||
extension BLEService {
|
||
private func buildAdvertisementData() -> [String: Any] {
|
||
let data: [String: Any] = [
|
||
CBAdvertisementDataServiceUUIDsKey: [BLEService.serviceUUID]
|
||
]
|
||
// No Local Name for privacy
|
||
return data
|
||
}
|
||
|
||
// No alias rotation or advertising restarts required.
|
||
}
|
||
|
||
// MARK: - Private Helpers
|
||
|
||
extension BLEService {
|
||
|
||
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
|
||
private func notifyUI(_ block: @escaping @MainActor () -> Void) {
|
||
// Capture the panic lifecycle before queueing the MainActor hop. A
|
||
// receive callback can enqueue UI delivery immediately before panic
|
||
// clears application state; rechecking here prevents that stale work
|
||
// from repopulating the wiped conversation store afterward.
|
||
guard let generation = capturePanicLifecycleGeneration() else {
|
||
return
|
||
}
|
||
Task { @MainActor [weak self] in
|
||
guard let self,
|
||
self.isCurrentPanicLifecycleGeneration(generation) else {
|
||
return
|
||
}
|
||
block()
|
||
}
|
||
}
|
||
|
||
private func emitTransportEvent(_ event: TransportEvent) {
|
||
notifyUI { [weak self] in
|
||
self?.deliverTransportEvent(event)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func deliverTransportEvent(_ event: TransportEvent) {
|
||
if let eventDelegate {
|
||
eventDelegate.didReceiveTransportEvent(event)
|
||
} else {
|
||
delegate?.receiveTransportEvent(event)
|
||
}
|
||
}
|
||
|
||
private func logBluetoothStatus(_ context: String) {
|
||
scheduleBluetoothStatusSample(after: 0, context: context)
|
||
}
|
||
|
||
private func scheduleBluetoothStatusSample(after delay: TimeInterval, context: String) {
|
||
#if os(iOS)
|
||
// Sample the main-actor background budget first (async hop, never a
|
||
// sync wait), then log from bleQueue off the cache — bleQueue must
|
||
// never block on main (see captureBluetoothStatus).
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||
guard let self else { return }
|
||
self.refreshCachedBackgroundTimeRemaining()
|
||
self.bleQueue.async { self.captureBluetoothStatus(context: context) }
|
||
}
|
||
#else
|
||
bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||
self?.captureBluetoothStatus(context: context)
|
||
}
|
||
#endif
|
||
}
|
||
|
||
#if os(iOS)
|
||
/// Main thread only (reads main-actor UIApplication state).
|
||
private func refreshCachedBackgroundTimeRemaining() {
|
||
dispatchPrecondition(condition: .onQueue(.main))
|
||
let seconds = UIApplication.shared.backgroundTimeRemaining
|
||
backgroundTimeLock.lock()
|
||
_cachedBackgroundTimeRemaining = seconds
|
||
backgroundTimeLock.unlock()
|
||
}
|
||
#endif
|
||
|
||
private func captureBluetoothStatus(context: String) {
|
||
assert(DispatchQueue.getSpecific(key: bleQueueKey) != nil, "captureBluetoothStatus must run on bleQueue")
|
||
|
||
let centralState = centralManager?.state ?? .unknown
|
||
let isScanning = centralManager?.isScanning ?? false
|
||
let peripheralState = peripheralManager?.state ?? .unknown
|
||
let isAdvertising = peripheralManager?.isAdvertising ?? false
|
||
|
||
let peerSummary = collectionsQueue.sync {
|
||
(
|
||
connected: peerRegistry.connectedCount,
|
||
known: peerRegistry.count,
|
||
candidates: connectionScheduler.candidateCount
|
||
)
|
||
}
|
||
|
||
#if os(iOS)
|
||
// INVARIANT: bleQueue must NEVER sync-dispatch to the main thread.
|
||
// The main actor sync-waits on bleQueue along the send paths
|
||
// (readLinkState), so a main.sync here completes an ABBA deadlock —
|
||
// field-verified as a permanent freeze when a courier-drop storm put
|
||
// an ack send (main → bleQueue.sync) up against a status capture
|
||
// (bleQueue → main.sync). backgroundTimeRemaining is main-actor
|
||
// state, so it is sampled on main and cached.
|
||
let backgroundSeconds = cachedBackgroundTimeRemaining
|
||
let backgroundDescriptor: String
|
||
if backgroundSeconds == .greatestFiniteMagnitude {
|
||
backgroundDescriptor = " bgRemaining=∞"
|
||
} else {
|
||
backgroundDescriptor = String(format: " bgRemaining=%.1fs", backgroundSeconds)
|
||
}
|
||
let appPhase = isAppActive ? "foreground" : "background"
|
||
#else
|
||
let backgroundDescriptor = ""
|
||
let appPhase = "foreground"
|
||
#endif
|
||
|
||
SecureLogger.info(
|
||
"📊 BLE status [\(context)]: phase=\(appPhase) central=\(centralState) scanning=\(isScanning) peripheral=\(peripheralState) advertising=\(isAdvertising) connected=\(peerSummary.connected) known=\(peerSummary.known) candidates=\(peerSummary.candidates)\(backgroundDescriptor)",
|
||
category: .session
|
||
)
|
||
}
|
||
|
||
private func routingData(for peerID: PeerID) -> Data? {
|
||
peerID.toShort().routingData
|
||
}
|
||
|
||
private func refreshLocalTopology() {
|
||
let neighbors: [Data] = collectionsQueue.sync {
|
||
peerRegistry.connectedRoutingData
|
||
}
|
||
meshTopology.updateNeighbors(for: myPeerIDData, neighbors: neighbors)
|
||
}
|
||
|
||
private func computeRoute(to peerID: PeerID) -> [Data]? {
|
||
// Version-gated: every hop and the recipient must have been observed
|
||
// speaking v2, since a v1-only node drops v2 frames on decode.
|
||
meshTopology.computeRoute(
|
||
from: myPeerIDData,
|
||
to: routingData(for: peerID),
|
||
maxHops: TransportConfig.bleSourceRouteMaxIntermediateHops,
|
||
requiringVersion: 2
|
||
)
|
||
}
|
||
|
||
private func applyRouteIfAvailable(_ packet: BitchatPacket, to recipient: PeerID) -> BitchatPacket {
|
||
let now = Date()
|
||
let route = BLESourceRouteOriginationPolicy.route(
|
||
for: packet,
|
||
to: recipient,
|
||
localPeerIDData: myPeerIDData,
|
||
isRecipientConnected: { self.isPeerConnected($0) },
|
||
shouldAttemptRoute: { peer in
|
||
self.collectionsQueue.sync(flags: .barrier) {
|
||
self.sourceRouteFailures.shouldAttemptRoute(to: peer, now: now)
|
||
}
|
||
},
|
||
computeRoute: { self.computeRoute(to: $0) }
|
||
)
|
||
guard let route else { return packet }
|
||
// Create new packet with route applied and version upgraded to 2
|
||
let routedPacket = BitchatPacket(
|
||
type: packet.type,
|
||
senderID: packet.senderID,
|
||
recipientID: packet.recipientID,
|
||
timestamp: packet.timestamp,
|
||
payload: packet.payload,
|
||
signature: nil, // Will be re-signed below
|
||
ttl: packet.ttl,
|
||
version: 2,
|
||
route: route
|
||
)
|
||
// Re-sign the packet since route and version changed
|
||
guard let signedPacket = noiseService.signPacket(routedPacket) else {
|
||
SecureLogger.error("❌ Failed to re-sign packet with route", category: .security)
|
||
return packet // Return original packet if signing fails
|
||
}
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
sourceRouteFailures.noteRoutedSend(to: recipient, now: now)
|
||
}
|
||
return signedPacket
|
||
}
|
||
|
||
private func routingPeer(from data: Data) -> PeerID? {
|
||
PeerID(routingData: data)
|
||
}
|
||
|
||
// MARK: - Mesh Diagnostics (/ping, /trace, topology map)
|
||
|
||
/// Sends a directed unencrypted ping probe (8-byte nonce + origin TTL).
|
||
/// The completion fires exactly once on the main actor: with RTT/hops
|
||
/// when the matching pong returns, or nil after the timeout window.
|
||
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
|
||
guard let generation = capturePanicLifecycleGeneration() else {
|
||
return
|
||
}
|
||
messageQueue.async { [weak self] in
|
||
guard let self,
|
||
self.isCurrentPanicLifecycleGeneration(generation),
|
||
let recipientData = peerID.toShort().routingData,
|
||
let payload = MeshPingPayload(
|
||
nonce: Data((0..<MeshPingPayload.nonceLength).map { _ in UInt8.random(in: .min ... .max) }),
|
||
originTTL: self.messageTTL
|
||
) else {
|
||
self?.notifyUI { [weak self] in
|
||
guard let self,
|
||
self.isCurrentPanicLifecycleGeneration(generation) else {
|
||
return
|
||
}
|
||
completion(nil)
|
||
}
|
||
return
|
||
}
|
||
let nonce = payload.nonce
|
||
let packet = BitchatPacket(
|
||
type: MessageType.ping.rawValue,
|
||
senderID: self.myPeerIDData,
|
||
recipientID: recipientData,
|
||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||
payload: payload.encode(),
|
||
signature: nil,
|
||
ttl: self.messageTTL
|
||
)
|
||
let timeout = DispatchWorkItem { [weak self] in
|
||
guard let self else { return }
|
||
let expired = self.collectionsQueue.sync(flags: .barrier) {
|
||
self.pendingMeshPings.removeValue(forKey: nonce)
|
||
}
|
||
guard let expired else { return }
|
||
self.notifyUI { [weak self] in
|
||
guard let self,
|
||
self.isCurrentPanicLifecycleGeneration(
|
||
expired.lifecycleGeneration
|
||
) else {
|
||
return
|
||
}
|
||
expired.completion(nil)
|
||
}
|
||
}
|
||
self.collectionsQueue.sync(flags: .barrier) {
|
||
self.pendingMeshPings[nonce] = PendingMeshPing(
|
||
peerID: PeerID(hexData: recipientData),
|
||
sentAt: Date(),
|
||
lifecycleGeneration: generation,
|
||
completion: completion,
|
||
timeout: timeout
|
||
)
|
||
}
|
||
self.messageQueue.asyncAfter(
|
||
deadline: .now() + TransportConfig.meshPingTimeoutSeconds,
|
||
execute: timeout
|
||
)
|
||
self.broadcastPacket(packet)
|
||
}
|
||
}
|
||
|
||
/// Answers a ping addressed to us with a pong echoing its nonce; pings
|
||
/// addressed elsewhere are left to the generic directed-relay path.
|
||
///
|
||
/// `linkPeerID` is the directly connected peer that delivered the packet
|
||
/// (the ingress link), NOT the packet's claimed sender: pings are
|
||
/// unsigned, so `packet.senderID` is attacker-controlled, and keying the
|
||
/// response budget on it would let one connected peer rotate forged
|
||
/// sender IDs to emit unbounded pongs. The budget is per physical link;
|
||
/// the pong still goes to the claimed sender (that's the protocol).
|
||
private func handleMeshPing(_ packet: BitchatPacket, fromLink linkPeerID: PeerID) {
|
||
guard packet.recipientID == myPeerIDData else { return }
|
||
guard let ping = MeshPingPayload.decode(packet.payload) else {
|
||
SecureLogger.debug("⚠️ Malformed ping via \(linkPeerID.id.prefix(8))…", category: .session)
|
||
return
|
||
}
|
||
let allowed = collectionsQueue.sync(flags: .barrier) {
|
||
meshPingResponseLimiter.shouldRespond(to: linkPeerID, now: Date())
|
||
}
|
||
guard allowed else {
|
||
if logRateLimiter.shouldLog(key: "ping-limit:\(linkPeerID.id)") {
|
||
SecureLogger.warning("🚫 Rate-limiting pings via link \(linkPeerID.id.prefix(8))…", category: .security)
|
||
}
|
||
return
|
||
}
|
||
guard let pong = MeshPingPayload(nonce: ping.nonce, originTTL: messageTTL) else { return }
|
||
let reply = BitchatPacket(
|
||
type: MessageType.pong.rawValue,
|
||
senderID: myPeerIDData,
|
||
recipientID: packet.senderID,
|
||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||
payload: pong.encode(),
|
||
signature: nil,
|
||
ttl: messageTTL
|
||
)
|
||
broadcastPacket(reply)
|
||
}
|
||
|
||
/// Resolves a pong against its outstanding probe. The unguessable echoed
|
||
/// nonce plus the sender check bind the reply to the probed peer; hops
|
||
/// come from the pong's TTL decrements on the return path.
|
||
private func handleMeshPong(_ packet: BitchatPacket, from peerID: PeerID) {
|
||
guard packet.recipientID == myPeerIDData else { return }
|
||
guard let pong = MeshPingPayload.decode(packet.payload) else { return }
|
||
let pending = collectionsQueue.sync(flags: .barrier) { () -> PendingMeshPing? in
|
||
guard pendingMeshPings[pong.nonce]?.peerID == peerID else { return nil }
|
||
return pendingMeshPings.removeValue(forKey: pong.nonce)
|
||
}
|
||
guard let pending else { return }
|
||
pending.timeout.cancel()
|
||
let rttMs = Int((Date().timeIntervalSince(pending.sentAt) * 1000).rounded())
|
||
let result = MeshPingResult(
|
||
rttMs: max(0, rttMs),
|
||
hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl)
|
||
)
|
||
notifyUI { [weak self] in
|
||
guard let self,
|
||
self.isCurrentPanicLifecycleGeneration(
|
||
pending.lifecycleGeneration
|
||
) else {
|
||
return
|
||
}
|
||
pending.completion(result)
|
||
}
|
||
}
|
||
|
||
/// Estimated intermediate hops toward `peerID`, BFS over gossiped
|
||
/// bidirectionally-confirmed neighbor claims ([] = direct, nil = none).
|
||
func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
|
||
refreshLocalTopology()
|
||
if let route = computeRoute(to: peerID) {
|
||
return route.compactMap { PeerID(routingData: $0) }
|
||
}
|
||
// Confirmed claims can lag a brand-new link (the peer's next announce
|
||
// hasn't arrived yet); a live direct connection is still a known path.
|
||
return isPeerConnected(peerID) ? [] : nil
|
||
}
|
||
|
||
/// Mesh graph for the topology map. Edges are advisory: announces cap
|
||
/// neighbor lists at 10, so an edge claimed by either endpoint counts.
|
||
func currentMeshTopology() -> MeshTopologySnapshot? {
|
||
refreshLocalTopology()
|
||
let claims = meshTopology.adjacencySnapshot()
|
||
var nodes = Set<PeerID>()
|
||
var edges = Set<MeshTopologyEdge>()
|
||
for (source, neighbors) in claims {
|
||
guard let sourcePeer = PeerID(routingData: source) else { continue }
|
||
nodes.insert(sourcePeer)
|
||
for neighborData in neighbors {
|
||
guard let neighborPeer = PeerID(routingData: neighborData),
|
||
neighborPeer != sourcePeer else { continue }
|
||
nodes.insert(neighborPeer)
|
||
edges.insert(MeshTopologyEdge(sourcePeer, neighborPeer))
|
||
}
|
||
}
|
||
nodes.insert(myPeerID)
|
||
return MeshTopologySnapshot(
|
||
localPeerID: myPeerID,
|
||
nodes: nodes.sorted(),
|
||
edges: edges.sorted { ($0.a, $0.b) < ($1.a, $1.b) }
|
||
)
|
||
}
|
||
|
||
private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool {
|
||
let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData)
|
||
let plan = BLERouteForwardingPolicy.plan(
|
||
for: packet,
|
||
localPeerID: myPeerID,
|
||
localRoutingData: myRoutingData,
|
||
routingPeer: routingPeer(from:),
|
||
isPeerConnected: isPeerConnected(_:)
|
||
)
|
||
|
||
if let forwardPacket = plan.forwardPacket, let nextHop = plan.nextHop {
|
||
sendPacketDirected(forwardPacket, to: nextHop)
|
||
}
|
||
|
||
return plan.shouldSuppressFloodRelay
|
||
}
|
||
|
||
/// Safely fetch the current direct-link state for a peer using the BLE queue.
|
||
private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) {
|
||
let state = readLinkState { $0.directLinkState(for: peerID) }
|
||
return (state.hasPeripheral, state.hasCentral)
|
||
}
|
||
|
||
private func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
|
||
readLinkState { $0.links(to: peerID) }
|
||
}
|
||
|
||
private func boundPeerID(for link: BLEIngressLinkID, in store: BLELinkStateStore) -> PeerID? {
|
||
switch link {
|
||
case .peripheral(let peripheralUUID):
|
||
store.peerID(forPeripheralID: peripheralUUID)
|
||
case .central(let centralUUID):
|
||
store.peerID(forCentralUUID: centralUUID)
|
||
}
|
||
}
|
||
|
||
/// Marks the exact physical ingress link that completed a fresh Noise
|
||
/// handshake. An old session keyed only by peer ID is insufficient: a
|
||
/// replayed announce can rebind an attacker's link to that ID.
|
||
private func markNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) {
|
||
guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return }
|
||
readLinkState { store in
|
||
guard boundPeerID(for: link, in: store) == peerID else { return }
|
||
noiseAuthenticatedLinkOwners[link] = peerID
|
||
}
|
||
}
|
||
|
||
private func isNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) -> Bool {
|
||
guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return false }
|
||
return readLinkState { store in
|
||
noiseAuthenticatedLinkOwners[link] == peerID && boundPeerID(for: link, in: store) == peerID
|
||
}
|
||
}
|
||
|
||
private func hasCurrentNoiseAuthenticatedLink(to peerID: PeerID) -> Bool {
|
||
!currentNoiseAuthenticatedLinks(to: peerID).isEmpty
|
||
}
|
||
|
||
private func currentNoiseAuthenticatedLinks(to peerID: PeerID) -> Set<BLEIngressLinkID> {
|
||
readLinkState { store in
|
||
Set(noiseAuthenticatedLinkOwners.compactMap { link, owner in
|
||
owner == peerID && boundPeerID(for: link, in: store) == peerID ? link : nil
|
||
})
|
||
}
|
||
}
|
||
|
||
/// A peer-level session can outlive the physical link that established it.
|
||
/// Revalidate a fresh direct link with an ordinary XX exchange, retiring
|
||
/// cached sending keys atomically before message 1 can leave.
|
||
private func refreshNoiseSessionForVerifiedDirectLink(
|
||
_ packet: BitchatPacket,
|
||
peerID: PeerID
|
||
) {
|
||
guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else {
|
||
return
|
||
}
|
||
|
||
let hasEstablishedSession = noiseService.hasEstablishedSession(with: peerID)
|
||
let authenticatedPeerLinks = currentNoiseAuthenticatedLinks(to: peerID)
|
||
let shouldRevalidate = readLinkState { store in
|
||
guard boundPeerID(for: link, in: store) == peerID else {
|
||
return false
|
||
}
|
||
return noiseReconnectPolicy.shouldRevalidate(
|
||
on: link,
|
||
hasEstablishedSession: hasEstablishedSession,
|
||
isNoiseAuthenticatedLink: noiseAuthenticatedLinkOwners[link] == peerID,
|
||
hasAuthenticatedPeerLink: !authenticatedPeerLinks.isEmpty,
|
||
now: Date()
|
||
)
|
||
}
|
||
guard shouldRevalidate else { return }
|
||
|
||
SecureLogger.info(
|
||
"🔄 Revalidating cached Noise session on fresh direct link to \(peerID.id.prefix(8))…",
|
||
category: .session
|
||
)
|
||
initiateNoiseReconnectHandshake(with: peerID)
|
||
}
|
||
|
||
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
||
service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in
|
||
SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))…")
|
||
// Authentication can be reported while an initiator is still
|
||
// returning XX message 3. Serialize generation-bound state and
|
||
// every post-handshake drain behind the handshake packet handler.
|
||
self?.messageQueue.async(flags: .barrier) { [weak self] in
|
||
self?.handleNoisePeerAuthenticated(
|
||
peerID: peerID,
|
||
fingerprint: fingerprint,
|
||
sessionGeneration: generation
|
||
)
|
||
}
|
||
}
|
||
service.onRekeyHandshakeReady = {
|
||
[weak self, weak service] peerID, initiation in
|
||
self?.messageQueue.async(flags: .barrier) {
|
||
[weak self, weak service] in
|
||
guard let self,
|
||
let service,
|
||
self.noiseService === service else {
|
||
return
|
||
}
|
||
self.noteNoiseSessionCleared(for: peerID)
|
||
guard let message = service.claimHandshakeInitiation(
|
||
initiation,
|
||
for: peerID
|
||
) else {
|
||
return
|
||
}
|
||
self.broadcastNoiseHandshake(message, to: peerID)
|
||
}
|
||
}
|
||
service.onHandshakeRecoveryRequired = {
|
||
[weak self, weak service] request in
|
||
guard let self, let service else { return }
|
||
#if DEBUG
|
||
self._test_beforeHandshakeRecoveryEnqueued?(request.peerID)
|
||
#endif
|
||
self.messageQueue.async(flags: .barrier) {
|
||
[weak self, weak service] in
|
||
guard let self,
|
||
let service,
|
||
self.noiseService === service else {
|
||
return
|
||
}
|
||
let peerID = request.peerID
|
||
guard self.isPeerReachable(peerID) else {
|
||
service.cancelHandshakeRecovery(request)
|
||
return
|
||
}
|
||
|
||
do {
|
||
guard let preparation =
|
||
try service.prepareHandshakeRecovery(request) else {
|
||
return
|
||
}
|
||
switch preparation {
|
||
case .ordinary(let initiation):
|
||
self.noteNoiseSessionCleared(for: peerID)
|
||
guard let handshakeData =
|
||
service.claimHandshakeInitiation(
|
||
initiation,
|
||
for: peerID
|
||
) else {
|
||
return
|
||
}
|
||
self.broadcastNoiseHandshake(
|
||
handshakeData,
|
||
to: peerID
|
||
)
|
||
case .transferred:
|
||
return
|
||
}
|
||
} catch {
|
||
SecureLogger.error(
|
||
"Failed to prepare handshake recovery with \(peerID.id.prefix(8))…: \(error)",
|
||
category: .session
|
||
)
|
||
}
|
||
}
|
||
}
|
||
service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation, reason in
|
||
guard let self, let service else { return }
|
||
// The manager makes restored keys visible atomically. Reconcile
|
||
// transport state and queued sends as the next serialized phase.
|
||
self.messageQueue.async(flags: .barrier) { [weak self, weak service] in
|
||
guard let self,
|
||
let service,
|
||
self.noiseService === service,
|
||
let fingerprint = service.getPeerFingerprint(peerID) else {
|
||
return
|
||
}
|
||
SecureLogger.debug(
|
||
"🔐 Restored quarantined Noise session with \(peerID.id.prefix(8))…",
|
||
category: .session
|
||
)
|
||
// Re-enter the same generation-bound transition used after a
|
||
// successful handshake to restore authenticated protocol
|
||
// state. Only a terminal restore may also drain the PM and
|
||
// typed-payload queues: after a responder timeout the
|
||
// counterpart may have completed the replacement handshake
|
||
// and discarded the restored keys, so encrypting the queues
|
||
// under them would lose every message silently. The mandatory
|
||
// convergence retry that accompanies the restore drains them
|
||
// under the new session instead (any establishment does).
|
||
self.handleNoisePeerAuthenticated(
|
||
peerID: peerID,
|
||
fingerprint: fingerprint,
|
||
sessionGeneration: generation,
|
||
deferOutboundUntilConvergence: reason == .pendingConvergence
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func handleNoisePeerAuthenticated(
|
||
peerID: PeerID,
|
||
fingerprint: String,
|
||
sessionGeneration generation: UUID,
|
||
deferOutboundUntilConvergence: Bool = false
|
||
) {
|
||
let normalizedPeerID = peerID.toShort()
|
||
guard let transition = noiseService.withCurrentSessionGeneration(
|
||
for: normalizedPeerID,
|
||
expected: generation,
|
||
{
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
() -> (
|
||
watchdog: (fingerprint: String, nonce: UUID)?,
|
||
rejected: [@MainActor (PrivateMediaSendPolicy) -> Void]
|
||
) in
|
||
guard privateMediaSessionGenerations[normalizedPeerID] != generation else {
|
||
return (nil, [])
|
||
}
|
||
let watchdogNonce = UUID()
|
||
privateMediaSessionGenerations[normalizedPeerID] = generation
|
||
authenticatedPeerStates.removeValue(forKey: normalizedPeerID)
|
||
privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID)
|
||
privateMediaProofWatchdogs[normalizedPeerID] = BLEPrivateMediaProofWatchdog(
|
||
fingerprint: fingerprint,
|
||
sessionGeneration: generation,
|
||
timeoutNonce: watchdogNonce
|
||
)
|
||
authenticatedPeerStateSendProgress[normalizedPeerID] =
|
||
BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation)
|
||
|
||
guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else {
|
||
return ((fingerprint, watchdogNonce), [])
|
||
}
|
||
guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame else {
|
||
pendingPrivateMediaPolicyResolutions.removeValue(forKey: normalizedPeerID)
|
||
return ((fingerprint, watchdogNonce), Array(pending.completions.values))
|
||
}
|
||
pending.sessionGeneration = generation
|
||
pending.timeoutNonce = watchdogNonce
|
||
pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending
|
||
return ((pending.fingerprint, watchdogNonce), [])
|
||
}
|
||
}
|
||
) else { return }
|
||
|
||
guard let watchdog = transition.watchdog else {
|
||
// A quarantined transport restored the same cryptographic
|
||
// generation. Its capability proof and announce state never
|
||
// became stale; only work queued while outbound keys were paused
|
||
// needs one idempotent ready transition.
|
||
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID)
|
||
sendPendingMessagesAfterHandshake(for: normalizedPeerID)
|
||
sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID)
|
||
return
|
||
}
|
||
|
||
completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade)
|
||
schedulePrivateMediaProofTimeout(
|
||
for: normalizedPeerID,
|
||
fingerprint: watchdog.fingerprint,
|
||
sessionGeneration: generation,
|
||
nonce: watchdog.nonce
|
||
)
|
||
// Cross-link delivery can put ciphertext sent immediately after
|
||
// message 3 ahead of message 3 itself. Retry the bounded queue only
|
||
// after this generation's transport state has been fully installed.
|
||
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID)
|
||
|
||
if deferOutboundUntilConvergence {
|
||
// Timeout-restore: the session is back for receive purposes and
|
||
// the generation-bound protocol state above is rebuilt, but the
|
||
// counterpart may already hold replacement keys that discarded
|
||
// this generation's. Encrypting the pending queues here would
|
||
// lose them silently, so leave them parked: the restore's
|
||
// mandatory convergence retry — or any later handshake the
|
||
// reconnect policy initiates — re-enters this transition with a
|
||
// fresh generation and drains them under keys both sides hold.
|
||
#if DEBUG
|
||
_test_onPrivateMediaSessionReconciled?(normalizedPeerID)
|
||
#endif
|
||
return
|
||
}
|
||
|
||
// `onPeerAuthenticated` can fire while the initiator is returning XX
|
||
// message 3. This callback is queued behind the handshake handler, so
|
||
// message 3 is broadcast first. Both peers also send one idempotent
|
||
// echo after receiving the other's state to recover cross-link races.
|
||
sendAuthenticatedPeerState(to: normalizedPeerID, echo: false)
|
||
#if DEBUG
|
||
_test_onPrivateMediaSessionReconciled?(normalizedPeerID)
|
||
#endif
|
||
sendPendingMessagesAfterHandshake(for: normalizedPeerID)
|
||
sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID)
|
||
sendAnnounce(forceSend: true)
|
||
}
|
||
|
||
private func sendAuthenticatedPeerState(to peerID: PeerID, echo: Bool) {
|
||
let normalizedPeerID = peerID.toShort()
|
||
let shouldSend = collectionsQueue.sync(flags: .barrier) {
|
||
guard let generation = privateMediaSessionGenerations[normalizedPeerID],
|
||
var progress = authenticatedPeerStateSendProgress[normalizedPeerID],
|
||
progress.sessionGeneration == generation else { return false }
|
||
if echo {
|
||
guard !progress.sentEcho else { return false }
|
||
progress.sentEcho = true
|
||
} else {
|
||
guard !progress.sentInitial else { return false }
|
||
progress.sentInitial = true
|
||
}
|
||
authenticatedPeerStateSendProgress[normalizedPeerID] = progress
|
||
return true
|
||
}
|
||
guard shouldSend else { return }
|
||
|
||
let capabilities = collectionsQueue.sync {
|
||
PeerCapabilities.localSupported.union(runtimeCapabilities)
|
||
}
|
||
let state = AuthenticatedPeerStatePacket(
|
||
capabilities: capabilities,
|
||
signingPublicKey: noiseService.getSigningPublicKeyData()
|
||
)
|
||
guard let payload = BLENoisePayloadFactory.authenticatedPeerState(state) else {
|
||
SecureLogger.error("Failed to encode authenticated peer state", category: .security)
|
||
return
|
||
}
|
||
sendNoisePayload(payload, to: normalizedPeerID)
|
||
}
|
||
|
||
private func handleAuthenticatedPeerState(
|
||
_ payload: Data,
|
||
from peerID: PeerID,
|
||
sessionGeneration generation: UUID
|
||
) {
|
||
let normalizedPeerID = peerID.toShort()
|
||
guard let state = AuthenticatedPeerStatePacket.decode(from: payload) else {
|
||
SecureLogger.warning(
|
||
"Ignoring malformed authenticated peer state from \(normalizedPeerID.id.prefix(8))…",
|
||
category: .security
|
||
)
|
||
return
|
||
}
|
||
guard let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID),
|
||
let publicKey = noiseService.getPeerPublicKeyData(normalizedPeerID),
|
||
publicKey.sha256Fingerprint().caseInsensitiveCompare(fingerprint) == .orderedSame else {
|
||
SecureLogger.warning(
|
||
"Ignoring peer state without a matching authenticated Noise identity",
|
||
category: .security
|
||
)
|
||
return
|
||
}
|
||
guard let application = noiseService.withCurrentSessionGeneration(
|
||
for: normalizedPeerID,
|
||
expected: generation,
|
||
{
|
||
() -> (accepted: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) in
|
||
guard collectionsQueue.sync(execute: {
|
||
privateMediaSessionGenerations[normalizedPeerID] == generation
|
||
}) else {
|
||
return (false, [])
|
||
}
|
||
|
||
// The generation lease prevents rekey/session promotion from
|
||
// interleaving between validation and these durable mutations.
|
||
identityManager.bindAuthenticatedSigningPublicKey(
|
||
state.signingPublicKey,
|
||
fingerprint: fingerprint
|
||
)
|
||
identityManager.upsertCryptographicIdentity(
|
||
fingerprint: fingerprint,
|
||
noisePublicKey: publicKey,
|
||
signingPublicKey: state.signingPublicKey,
|
||
claimedNickname: nil
|
||
)
|
||
if state.capabilities.contains(.privateMedia) {
|
||
identityManager.markPrivateMediaCapable(fingerprint: fingerprint)
|
||
}
|
||
|
||
let completions = collectionsQueue.sync(flags: .barrier) {
|
||
() -> [@MainActor (PrivateMediaSendPolicy) -> Void] in
|
||
guard privateMediaSessionGenerations[normalizedPeerID] == generation else {
|
||
return []
|
||
}
|
||
peerRegistry.bindAuthenticatedSigningPublicKey(
|
||
state.signingPublicKey,
|
||
for: normalizedPeerID
|
||
)
|
||
authenticatedPeerStates[normalizedPeerID] = BLEAuthenticatedPeerStateObservation(
|
||
fingerprint: fingerprint,
|
||
sessionGeneration: generation,
|
||
capabilities: state.capabilities
|
||
)
|
||
privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID)
|
||
privateMediaProofWatchdogs.removeValue(forKey: normalizedPeerID)
|
||
guard let pending = pendingPrivateMediaPolicyResolutions.removeValue(
|
||
forKey: normalizedPeerID
|
||
), pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame,
|
||
pending.sessionGeneration == generation else {
|
||
return []
|
||
}
|
||
return Array(pending.completions.values)
|
||
}
|
||
return (true, completions)
|
||
}
|
||
), application.accepted else { return }
|
||
|
||
// One bounded echo makes initiator/responder proof ordering converge
|
||
// even when message 3 and the first proof take different mesh links.
|
||
sendAuthenticatedPeerState(to: normalizedPeerID, echo: true)
|
||
let policy = privateMediaSendPolicy(to: normalizedPeerID)
|
||
sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID)
|
||
completePrivateMediaPolicyResolution(application.completions, with: policy)
|
||
}
|
||
|
||
private func noteNoiseSessionCleared(for peerID: PeerID) {
|
||
let normalizedPeerID = peerID.toShort()
|
||
let reset = collectionsQueue.sync(flags: .barrier) {
|
||
() -> (fingerprint: String, nonce: UUID)? in
|
||
privateMediaSessionGenerations.removeValue(forKey: normalizedPeerID)
|
||
authenticatedPeerStates.removeValue(forKey: normalizedPeerID)
|
||
privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID)
|
||
privateMediaProofWatchdogs.removeValue(forKey: normalizedPeerID)
|
||
authenticatedPeerStateSendProgress.removeValue(forKey: normalizedPeerID)
|
||
guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else {
|
||
return nil
|
||
}
|
||
let nonce = UUID()
|
||
pending.sessionGeneration = nil
|
||
pending.timeoutNonce = nonce
|
||
pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending
|
||
return (pending.fingerprint, nonce)
|
||
}
|
||
if let reset {
|
||
schedulePrivateMediaProofTimeout(
|
||
for: normalizedPeerID,
|
||
fingerprint: reset.fingerprint,
|
||
sessionGeneration: nil,
|
||
nonce: reset.nonce
|
||
)
|
||
}
|
||
}
|
||
|
||
private func clearNoiseSession(for peerID: PeerID) {
|
||
noiseService.clearSession(for: peerID)
|
||
noteNoiseSessionCleared(for: peerID)
|
||
}
|
||
|
||
/// Swaps `myPeerID`/`myPeerIDData` to match the current Noise identity.
|
||
/// The swap runs as a `messageQueue` barrier so in-flight work items that
|
||
/// read the identity (e.g. `sendMessage` building packets) complete
|
||
/// against the old value and everything after sees the new one atomically.
|
||
/// Callers (init, panic reset on the main thread) are never on
|
||
/// `messageQueue`; the re-entrancy check keeps any future on-queue caller
|
||
/// from deadlocking.
|
||
private func refreshPeerIdentity() {
|
||
let swap = {
|
||
let fingerprint = self.noiseService.getIdentityFingerprint()
|
||
self.localIdentityState.replacePeerIdentity(
|
||
with: PeerID(str: fingerprint.prefix(16))
|
||
)
|
||
self.meshTopology.reset()
|
||
}
|
||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||
swap()
|
||
} else {
|
||
messageQueue.sync(flags: .barrier, execute: swap)
|
||
}
|
||
}
|
||
|
||
|
||
|
||
private func sendNoisePayload(_ typedPayload: Data, to peerID: PeerID) {
|
||
// Hop like sendMessage: the Transport-facing wrappers (verify/vouch/
|
||
// group payloads) call this from the main actor, and the send path
|
||
// sync-waits on bleQueue for link state.
|
||
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||
messageQueue.async { [weak self] in
|
||
self?.sendNoisePayload(typedPayload, to: peerID)
|
||
}
|
||
return
|
||
}
|
||
guard noiseService.hasEstablishedSession(with: peerID) else {
|
||
// No established session yet - queue the payload synchronously
|
||
// before initiating a handshake
|
||
// to prevent race where fast handshake completion drains empty queue
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
self.pendingNoiseSessionQueues.appendTypedPayload(typedPayload, for: peerID)
|
||
SecureLogger.debug("📥 Queued noise payload for \(peerID.id.prefix(8))… pending handshake", category: .session)
|
||
}
|
||
initiateNoiseHandshake(with: peerID)
|
||
return
|
||
}
|
||
do {
|
||
broadcastPacket(try makeEncryptedNoisePacket(typedPayload, to: peerID))
|
||
} catch {
|
||
SecureLogger.error("Failed to send verification payload: \(error)")
|
||
}
|
||
}
|
||
|
||
private func makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket {
|
||
let encrypted: Data
|
||
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first)
|
||
if isPrivateFile {
|
||
let provenGeneration: UUID? = collectionsQueue.sync {
|
||
() -> UUID? in
|
||
guard let generation = privateMediaSessionGenerations[peerID],
|
||
let authenticated = authenticatedPeerStates[peerID],
|
||
authenticated.sessionGeneration == generation,
|
||
authenticated.capabilities.contains(.privateMedia) else { return nil }
|
||
return generation
|
||
}
|
||
guard let provenGeneration else {
|
||
throw NoiseEncryptionError.sessionNotEstablished
|
||
}
|
||
encrypted = try noiseService.encryptPrivateFilePayload(
|
||
typedPayload,
|
||
for: peerID,
|
||
sessionGeneration: provenGeneration
|
||
)
|
||
} else {
|
||
encrypted = try noiseService.encrypt(typedPayload, for: peerID)
|
||
}
|
||
return BitchatPacket(
|
||
type: MessageType.noiseEncrypted.rawValue,
|
||
senderID: myPeerIDData,
|
||
recipientID: Data(hexString: peerID.id),
|
||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||
payload: encrypted,
|
||
signature: nil,
|
||
ttl: messageTTL,
|
||
// v1 has a 16-bit payload length; finalized media can exceed it.
|
||
version: isPrivateFile ? 2 : 1
|
||
)
|
||
}
|
||
|
||
// MARK: Courier Store-and-Forward
|
||
|
||
/// Seal `content` for the recipient and hand the envelope to the given
|
||
/// couriers for physical delivery. When a verified one-time prekey bundle
|
||
/// is cached for the recipient, sealing targets one of its prekeys
|
||
/// (forward secret, envelope v2); otherwise it falls back to their static
|
||
/// key (one-way Noise X, v1) exactly as before. Returns false when no
|
||
/// courier is connected, the payload cannot be built, or sealing fails;
|
||
/// link writes are queued asynchronously after the envelope is ready.
|
||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
|
||
let connected = couriers.filter { isPeerConnected($0) }
|
||
guard !connected.isEmpty,
|
||
let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
|
||
return false
|
||
}
|
||
|
||
let payload: Data
|
||
do {
|
||
let now = Date()
|
||
let sealed: Data
|
||
let prekeyID: UInt32?
|
||
if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) {
|
||
sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey)
|
||
prekeyID = prekey.id
|
||
} else {
|
||
sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
|
||
prekeyID = nil
|
||
}
|
||
let envelope = CourierEnvelope(
|
||
recipientTag: CourierEnvelope.recipientTag(
|
||
noiseStaticKey: recipientNoiseKey,
|
||
epochDay: CourierEnvelope.epochDay(for: now)
|
||
),
|
||
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
|
||
ciphertext: sealed,
|
||
copies: TransportConfig.courierInitialCopies,
|
||
prekeyID: prekeyID
|
||
)
|
||
guard let encoded = envelope.encode() else { return false }
|
||
payload = encoded
|
||
} catch {
|
||
SecureLogger.error("Failed to seal courier envelope: \(error)", category: .encryption)
|
||
return false
|
||
}
|
||
|
||
messageQueue.async { [weak self] in
|
||
guard let self else { return }
|
||
for courier in connected {
|
||
SecureLogger.debug("📦 Depositing courier envelope with \(courier.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||
self.sendPacketDirected(self.makeCourierPacket(payload, to: courier), to: courier)
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// MARK: Courier over the bridge
|
||
|
||
/// Seals `content` into a courier envelope for relay parking (a bridge
|
||
/// courier drop). Same sealing rules as `sendCourierMessage` — prekey
|
||
/// (v2) when a verified bundle is cached, static Noise X (v1) otherwise —
|
||
/// but carry-only: a relay copy never sprays.
|
||
func sealBridgeCourierEnvelope(_ content: String, messageID: String, recipientNoiseKey: Data) -> CourierEnvelope? {
|
||
guard let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
|
||
return nil
|
||
}
|
||
do {
|
||
let now = Date()
|
||
let sealed: Data
|
||
let prekeyID: UInt32?
|
||
if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) {
|
||
sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey)
|
||
prekeyID = prekey.id
|
||
} else {
|
||
sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
|
||
prekeyID = nil
|
||
}
|
||
return CourierEnvelope(
|
||
recipientTag: CourierEnvelope.recipientTag(
|
||
noiseStaticKey: recipientNoiseKey,
|
||
epochDay: CourierEnvelope.epochDay(for: now)
|
||
),
|
||
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
|
||
ciphertext: sealed,
|
||
copies: 1,
|
||
prekeyID: prekeyID
|
||
)
|
||
} catch {
|
||
SecureLogger.error("Failed to seal bridge courier envelope: \(error)", category: .encryption)
|
||
return nil
|
||
}
|
||
}
|
||
|
||
/// Opens a courier envelope that arrived as a bridge drop (relay fetch,
|
||
/// not a directed mesh packet). Returns false when the rotating tag does
|
||
/// not match our static key — a drop for someone else, or a stale tag.
|
||
/// The inner Noise X seal authenticates the sender; there is no packet
|
||
/// signature to check on this path.
|
||
@discardableResult
|
||
func openBridgedCourierEnvelope(_ envelope: CourierEnvelope) -> Bool {
|
||
guard !envelope.isExpired else { return false }
|
||
let myKey = noiseService.getStaticPublicKeyData()
|
||
guard CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) else {
|
||
return false
|
||
}
|
||
return openCourierEnvelope(envelope)
|
||
}
|
||
|
||
/// Hands a bridge-fetched envelope directly to the matching local peer
|
||
/// as a directed courier packet. Delivery-only by design: the recipient's
|
||
/// tag matched, so this never lands in a stranger's carry quota.
|
||
/// Returns true only if a current Noise-authenticated physical link
|
||
/// accepted the packet; a stale peer-level session, reachability record,
|
||
/// replay-rebound link, or process-local spool is not delivery.
|
||
@discardableResult
|
||
func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool {
|
||
guard hasCurrentNoiseAuthenticatedLink(to: peerID) else { return false }
|
||
guard let payload = envelope.encode() else { return false }
|
||
let packet = makeCourierPacket(payload, to: peerID)
|
||
let send = { [weak self] in
|
||
self?.sendPacketDirected(
|
||
packet,
|
||
to: peerID,
|
||
requireDirectPeerLink: true,
|
||
requireNoiseAuthenticatedPeerLink: true
|
||
) ?? false
|
||
}
|
||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||
return send()
|
||
}
|
||
return messageQueue.sync(execute: send)
|
||
}
|
||
|
||
/// Our own Noise static public key (for computing our courier tags).
|
||
func myNoiseStaticPublicKey() -> Data {
|
||
noiseService.getStaticPublicKeyData()
|
||
}
|
||
|
||
/// Verified reachable peers with known Noise keys — the set a bridge
|
||
/// gateway watches courier drops for.
|
||
func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)] {
|
||
let now = Date()
|
||
return collectionsQueue.sync {
|
||
peerRegistry.snapshotByID.values.compactMap { info in
|
||
guard info.isVerifiedNickname,
|
||
let key = info.noisePublicKey,
|
||
peerRegistry.isReachable(info.peerID, now: now) else { return nil }
|
||
return (info.peerID, key)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The prekey to seal a courier message with, or nil to fall back to
|
||
/// static sealing. The real signal is a verified, unexpired bundle with a
|
||
/// spare prekey; the advertised `.prekeys` capability only acts as a veto
|
||
/// for peers we currently see on the mesh (a cached bundle can outlive a
|
||
/// peer's downgrade to a build that no longer holds the privates).
|
||
/// Re-deposits of the same message reuse its assigned prekey, so one
|
||
/// message consumes exactly one prekey ID regardless of courier count.
|
||
private func assignRecipientPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? {
|
||
let shortID = PeerID(publicKey: recipientNoiseKey)
|
||
let knownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil }
|
||
if knownOnMesh, !peerCapabilities(shortID).contains(.prekeys) {
|
||
return nil
|
||
}
|
||
return prekeyBundleStore.assignPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey)
|
||
}
|
||
|
||
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
|
||
let packet = BitchatPacket(
|
||
type: MessageType.courierEnvelope.rawValue,
|
||
senderID: myPeerIDData,
|
||
recipientID: Data(hexString: peerID.id),
|
||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||
payload: payload,
|
||
signature: nil,
|
||
ttl: messageTTL
|
||
)
|
||
// Signed so a courier can authenticate the depositor before carrying
|
||
// mail under their quota. Handover to the recipient doesn't need the
|
||
// packet signature — the inner Noise X seal authenticates the sender.
|
||
return noiseService.signPacket(packet) ?? packet
|
||
}
|
||
|
||
/// Handles both courier roles for an incoming envelope addressed to us:
|
||
/// recipient (the rotating tag matches our static key → open and deliver)
|
||
/// or courier (a trusted peer is depositing mail for someone else → store).
|
||
private func handleCourierEnvelope(_ packet: BitchatPacket, from peerID: PeerID) {
|
||
// Directed packets only; envelopes addressed elsewhere ride the
|
||
// generic relay path untouched.
|
||
guard packet.recipientID == myPeerIDData else { return }
|
||
guard let envelope = CourierEnvelope.decode(packet.payload), !envelope.isExpired else { return }
|
||
|
||
let myKey = noiseService.getStaticPublicKeyData()
|
||
if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) {
|
||
openCourierEnvelope(envelope)
|
||
} else {
|
||
acceptCourierDeposit(envelope, from: peerID, packet: packet)
|
||
}
|
||
}
|
||
|
||
@discardableResult
|
||
private func openCourierEnvelope(_ envelope: CourierEnvelope) -> Bool {
|
||
do {
|
||
let typedPayload: Data
|
||
let senderStaticKey: Data
|
||
if let prekeyID = envelope.prekeyID {
|
||
// Envelope v2: sealed to one of our one-time prekeys. Opening
|
||
// consumes the prekey (48h redelivery grace), which shrinks our
|
||
// published bundle under a strictly newer generatedAt. Re-gossip
|
||
// so peers replace their cached copy and stop assigning the
|
||
// consumed ID before the grace lapses; force the broadcast when
|
||
// the batch also topped back up (low-water), otherwise let the
|
||
// rebroadcast throttle coalesce bursts.
|
||
let opened = try noiseService.openPrekeyPayload(envelope.ciphertext, prekeyID: prekeyID)
|
||
(typedPayload, senderStaticKey) = (opened.payload, opened.senderStaticKey)
|
||
if opened.consumedPrekey {
|
||
let replenished = noiseService.replenishPrekeysIfNeeded()
|
||
sendPrekeyBundle(force: replenished)
|
||
}
|
||
} else {
|
||
(typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
|
||
}
|
||
guard let typeRaw = typedPayload.first,
|
||
let payloadType = NoisePayloadType(rawValue: typeRaw),
|
||
payloadType == .privateMessage else {
|
||
SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session)
|
||
return true // decrypted but deterministically unsupported
|
||
}
|
||
let payload = Data(typedPayload.dropFirst())
|
||
guard let innerMessageID = PrivateMessagePacket.decode(from: payload)?.messageID else {
|
||
SecureLogger.warning("⚠️ Courier envelope carried undecodable private message", category: .session)
|
||
return true // decrypted but deterministically malformed
|
||
}
|
||
// Redundant copies of one message arrive as distinct envelopes
|
||
// (fresh seal each: mesh couriers, bridge drops across relays),
|
||
// so dedup here on the inner message ID — before delivery, ack,
|
||
// and handshake work. A duplicate costs only the decrypt above
|
||
// and at most one ack ever goes out per message ID.
|
||
let firstOpen = collectionsQueue.sync(flags: .barrier) {
|
||
openedCourierMessageIDs.insert(innerMessageID)
|
||
}
|
||
guard firstOpen else {
|
||
SecureLogger.debug("📦 Dropping duplicate courier envelope for message \(innerMessageID.prefix(8))…", category: .session)
|
||
return true
|
||
}
|
||
// Couriered mail arrives while the sender is absent, so the UI's
|
||
// block check can't resolve their fingerprint from a live session.
|
||
// Gate here, where the full static key is in hand.
|
||
guard !identityManager.isBlocked(fingerprint: senderStaticKey.sha256Fingerprint()) else {
|
||
SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security)
|
||
return true
|
||
}
|
||
// A present sender resolves to their live mesh thread via the
|
||
// derived short ID. An absent sender — the usual courier case —
|
||
// uses the full noise-key ID so the message lands on the stable
|
||
// favorite conversation instead of an unresolvable short-ID
|
||
// thread labeled "Unknown".
|
||
let shortID = PeerID(publicKey: senderStaticKey)
|
||
let isKnownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil }
|
||
let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey)
|
||
SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session)
|
||
sfMetrics?.record(.courierOpened)
|
||
notifyUI { [weak self] in
|
||
self?.deliverTransportEvent(.noisePayloadReceived(
|
||
peerID: senderPeerID,
|
||
type: payloadType,
|
||
payload: payload,
|
||
timestamp: Date()
|
||
))
|
||
}
|
||
return true
|
||
} catch {
|
||
// Tag collision or stale key: not addressed to us after all.
|
||
SecureLogger.debug("📦 Courier envelope failed to open: \(error)", category: .encryption)
|
||
return false
|
||
}
|
||
}
|
||
|
||
private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID, packet: BitchatPacket) {
|
||
// A deposit must come from its depositor over the direct link: the
|
||
// claimed sender has to be the ingress peer, and the packet signature
|
||
// has to verify against that peer's announced signing key. Otherwise
|
||
// an untrusted sender could route an envelope through any trusted
|
||
// neighbor and have us carry it under the neighbor's quota.
|
||
guard PeerID(hexData: packet.senderID) == peerID else {
|
||
SecureLogger.debug("📦 Courier deposit rejected: relayed envelope claims sender \(PeerID(hexData: packet.senderID).id.prefix(8))… but arrived from \(peerID.id.prefix(8))…", category: .security)
|
||
return
|
||
}
|
||
let depositorInfo = collectionsQueue.sync { peerRegistry.info(for: peerID) }
|
||
guard let depositorKey = depositorInfo?.noisePublicKey else {
|
||
SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session)
|
||
return
|
||
}
|
||
guard let signingKey = depositorInfo?.signingPublicKey,
|
||
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
|
||
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (missing/invalid signature)", category: .security)
|
||
return
|
||
}
|
||
let isVerifiedPeer = depositorInfo?.isVerifiedNickname ?? false
|
||
let store = courierStore
|
||
let policy = courierDepositPolicy
|
||
let metrics = sfMetrics
|
||
notifyUI {
|
||
guard let tier = policy(depositorKey, isVerifiedPeer) else {
|
||
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (neither favorite nor verified)", category: .session)
|
||
return
|
||
}
|
||
if store.deposit(envelope, from: depositorKey, tier: tier) {
|
||
SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))… (\(tier.rawValue))", category: .session)
|
||
metrics?.record(.courierAccepted)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Hand over any carried envelopes addressed to a peer we just heard from.
|
||
private func deliverCourierMail(to peerID: PeerID, noiseKey: Data) {
|
||
let metrics = sfMetrics
|
||
let accepted = courierStore.handoverEnvelopes(for: noiseKey) { [weak self] envelope in
|
||
guard let self,
|
||
let payload = envelope.encode(),
|
||
self.sendPacketDirected(
|
||
self.makeCourierPacket(payload, to: peerID),
|
||
to: peerID,
|
||
requireDirectPeerLink: true,
|
||
requireNoiseAuthenticatedPeerLink: true
|
||
) else {
|
||
return false
|
||
}
|
||
metrics?.record(.courierHandedOver)
|
||
return true
|
||
}
|
||
if accepted > 0 {
|
||
SecureLogger.debug("📦 Handed over \(accepted) courier envelope(s) to \(peerID.id.prefix(8))…", category: .session)
|
||
}
|
||
}
|
||
|
||
/// Speculative handover toward a recipient heard only via a relayed
|
||
/// announce: the envelope floods the mesh as a directed packet (relays
|
||
/// treat it like a directed DM). Non-destructive — the carried copy stays
|
||
/// until a direct handover or expiry, throttled per envelope so repeated
|
||
/// announces don't re-flood.
|
||
private func deliverCourierMailRemotely(to peerID: PeerID, noiseKey: Data) {
|
||
let envelopes = courierStore.envelopesForRemoteHandover(
|
||
recipientNoiseKey: noiseKey,
|
||
cooldown: TransportConfig.courierRemoteHandoverCooldownSeconds
|
||
)
|
||
guard !envelopes.isEmpty else { return }
|
||
SecureLogger.debug("📦 Remote handover: flooding \(envelopes.count) envelope(s) toward \(peerID.id.prefix(8))…", category: .session)
|
||
for envelope in envelopes {
|
||
guard let payload = envelope.encode() else { continue }
|
||
broadcastPacket(makeCourierPacket(payload, to: peerID))
|
||
sfMetrics?.record(.courierRemoteHandover)
|
||
}
|
||
}
|
||
|
||
/// Spray-and-wait: split copy budgets with another courier we just
|
||
/// encountered, so carried mail diffuses through a moving crowd instead
|
||
/// of riding a single carrier. Only favorites and verified peers qualify,
|
||
/// mirroring the deposit policy they would apply to us.
|
||
private func sprayCourierMail(to peerID: PeerID, noiseKey: Data, isVerifiedPeer: Bool) {
|
||
let store = courierStore
|
||
let metrics = sfMetrics
|
||
let sendSpray: () -> Void = { [weak self] in
|
||
guard let self else { return }
|
||
let accepted = store.transferSprayCopies(to: noiseKey) { envelope in
|
||
guard let payload = envelope.encode(),
|
||
self.sendPacketDirected(
|
||
self.makeCourierPacket(payload, to: peerID),
|
||
to: peerID,
|
||
requireDirectPeerLink: true,
|
||
requireNoiseAuthenticatedPeerLink: true
|
||
) else {
|
||
return false
|
||
}
|
||
metrics?.record(.courierSprayed)
|
||
return true
|
||
}
|
||
if accepted > 0 {
|
||
SecureLogger.debug("📦 Sprayed \(accepted) envelope copy(ies) to courier \(peerID.id.prefix(8))…", category: .session)
|
||
}
|
||
}
|
||
let policy = courierDepositPolicy
|
||
notifyUI {
|
||
// Same trust gate as deposits: don't hand mail to a peer who
|
||
// would reject it from us.
|
||
guard policy(noiseKey, isVerifiedPeer) != nil else { return }
|
||
sendSpray()
|
||
}
|
||
}
|
||
|
||
// MARK: One-Time Prekey Bundles
|
||
|
||
/// Broadcasts our signed prekey bundle and tracks it for gossip sync.
|
||
/// Unforced sends (piggybacked on announces) are throttled — gossip does
|
||
/// the spreading, the broadcast just keeps our own gossip entry fresh.
|
||
/// Forced sends (bundle changed after consumption) go immediately.
|
||
private func sendPrekeyBundle(force: Bool = false) {
|
||
let now = Date()
|
||
let shouldSend: Bool = collectionsQueue.sync(flags: .barrier) {
|
||
if !force,
|
||
let last = lastPrekeyBundleSentAt,
|
||
now.timeIntervalSince(last) < TransportConfig.prekeyBundleRebroadcastSeconds {
|
||
return false
|
||
}
|
||
lastPrekeyBundleSentAt = now
|
||
return true
|
||
}
|
||
guard shouldSend else { return }
|
||
guard let bundle = noiseService.currentPrekeyBundle(),
|
||
let payload = bundle.encode() else {
|
||
SecureLogger.error("❌ Failed to build prekey bundle", category: .security)
|
||
return
|
||
}
|
||
let packet = BitchatPacket(
|
||
type: MessageType.prekeyBundle.rawValue,
|
||
senderID: myPeerIDData,
|
||
recipientID: nil,
|
||
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
|
||
payload: payload,
|
||
signature: nil,
|
||
ttl: messageTTL
|
||
)
|
||
guard let signedPacket = noiseService.signPacket(packet) else {
|
||
SecureLogger.error("❌ Failed to sign prekey bundle packet", category: .security)
|
||
return
|
||
}
|
||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||
broadcastPacket(signedPacket)
|
||
} else {
|
||
messageQueue.async { [weak self] in
|
||
self?.broadcastPacket(signedPacket)
|
||
}
|
||
}
|
||
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||
}
|
||
|
||
/// Ingests a gossiped prekey bundle. Attribution is layered: the outer
|
||
/// packet must originate from the bundle owner (fabricated sender IDs, used
|
||
/// to multiply cache/gossip entries, are rejected), and BOTH the inner
|
||
/// bundle signature and the outer packet signature must verify against the
|
||
/// owner's announce-bound signing key. Verifying the outer packet — whose
|
||
/// signed bytes cover senderID and timestamp — stops a valid bundle from
|
||
/// being replayed under a fresh timestamp or spoofed sender to pass
|
||
/// freshness or poison attribution. Only after that does the packet enter
|
||
/// our own gossip store, so we never help spread a bundle we couldn't
|
||
/// attribute.
|
||
private func handlePrekeyBundle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||
guard let bundle = PrekeyBundle.decode(packet.payload) else {
|
||
SecureLogger.debug("🔑 Ignoring malformed prekey bundle from \(peerID.id.prefix(8))…", category: .security)
|
||
return
|
||
}
|
||
// Our own bundle is tracked at send time; a copy echoing back adds nothing.
|
||
guard bundle.noiseStaticPublicKey != noiseService.getStaticPublicKeyData() else { return }
|
||
let owner = PeerID(publicKey: bundle.noiseStaticPublicKey)
|
||
// The owner's genuine bundle (direct or relayed) always carries the
|
||
// owner's senderID + outer signature; gossip resends preserve both. A
|
||
// packet whose senderID isn't the owner can't be authenticated here.
|
||
guard PeerID(hexData: packet.senderID) == owner else {
|
||
SecureLogger.debug("🔑 Ignoring prekey bundle whose sender ≠ owner \(owner.id.prefix(8))…", category: .security)
|
||
return
|
||
}
|
||
// Look up the announce-bound signing key and stash-if-unbound in ONE
|
||
// barrier: the receive queue is concurrent, so this bundle can race
|
||
// ahead of the announce that binds the key. Reading the live registry
|
||
// and stashing atomically closes the check-then-act gap against
|
||
// handleAnnounce's drain (see drainPendingPrekeyBundles).
|
||
let signingKey: Data? = collectionsQueue.sync(flags: .barrier) {
|
||
if let info = peerRegistry.info(for: owner),
|
||
info.noisePublicKey == bundle.noiseStaticPublicKey,
|
||
let key = info.signingPublicKey {
|
||
return key
|
||
}
|
||
// Offline-verified identities are stable across this race.
|
||
for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(owner)
|
||
where candidate.publicKey == bundle.noiseStaticPublicKey {
|
||
if let key = candidate.signingPublicKey { return key }
|
||
}
|
||
// No binding yet: retain the latest bundle per owner, bounded, and
|
||
// retry once the verified announce lands.
|
||
if pendingPrekeyBundles[owner] != nil
|
||
|| pendingPrekeyBundles.count < Self.pendingPrekeyBundleCap {
|
||
pendingPrekeyBundles[owner] = packet
|
||
}
|
||
return nil
|
||
}
|
||
guard let signingKey else {
|
||
SecureLogger.debug("🔑 Deferring prekey bundle without a bound signing key (owner \(owner.id.prefix(8))…)", category: .security)
|
||
return
|
||
}
|
||
ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey)
|
||
}
|
||
|
||
/// Verify a bundle's inner + outer signatures against the owner's bound
|
||
/// signing key and, on success, cache it and let it enter our gossip store.
|
||
private func ingestVerifiedPrekeyBundle(_ bundle: PrekeyBundle, packet: BitchatPacket, owner: PeerID, signingKey: Data) {
|
||
guard noiseService.verifyPrekeyBundleSignature(bundle, signingPublicKey: signingKey),
|
||
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
|
||
SecureLogger.debug("🔑 Ignoring prekey bundle without verifiable signature (owner \(owner.id.prefix(8))…)", category: .security)
|
||
return
|
||
}
|
||
if prekeyBundleStore.ingest(bundle) {
|
||
SecureLogger.debug("🔑 Cached prekey bundle for \(owner.id.prefix(8))… (\(bundle.prekeys.count) prekeys)", category: .security)
|
||
}
|
||
gossipSyncManager?.onPublicPacketSeen(packet)
|
||
}
|
||
|
||
/// Re-attempt any prekey bundle that arrived before this owner's announce
|
||
/// bound a signing key. Called from handleAnnounce after a verified
|
||
/// announce, in a barrier ordered after the registry write, so a bundle
|
||
/// stashed before the write is always observed here.
|
||
private func drainPendingPrekeyBundles(for owner: PeerID) {
|
||
let pending: BitchatPacket? = collectionsQueue.sync(flags: .barrier) {
|
||
pendingPrekeyBundles.removeValue(forKey: owner)
|
||
}
|
||
guard let packet = pending,
|
||
let bundle = PrekeyBundle.decode(packet.payload),
|
||
let signingKey = announceBoundSigningKey(forNoiseKey: bundle.noiseStaticPublicKey) else { return }
|
||
ingestVerifiedPrekeyBundle(bundle, packet: packet, owner: owner, signingKey: signingKey)
|
||
}
|
||
|
||
/// Ed25519 signing key bound to a Noise static key by a verified
|
||
/// announce: from the live registry when the owner is on the mesh, else
|
||
/// from identities persisted for offline verification.
|
||
private func announceBoundSigningKey(forNoiseKey noiseKey: Data) -> Data? {
|
||
let shortID = PeerID(publicKey: noiseKey)
|
||
if let info = collectionsQueue.sync(execute: { peerRegistry.info(for: shortID) }),
|
||
info.noisePublicKey == noiseKey,
|
||
let signingKey = info.signingPublicKey {
|
||
return signingKey
|
||
}
|
||
for candidate in identityManager.getCryptoIdentitiesByPeerIDPrefix(shortID)
|
||
where candidate.publicKey == noiseKey {
|
||
if let signingKey = candidate.signingPublicKey {
|
||
return signingKey
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// MARK: Gateway carrier (nostrCarrier)
|
||
|
||
/// Sign and send an encoded `toGateway` carrier payload directed at a
|
||
/// gateway peer. The packet is signed so the gateway can key its uplink
|
||
/// quotas to an authenticated depositor; the carried Nostr event has its
|
||
/// own Schnorr signature for content authenticity. Returns false when
|
||
/// the gateway is not reachable or signing fails.
|
||
func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool {
|
||
guard isPeerReachable(gatewayPeer) else { return false }
|
||
let packet = BitchatPacket(
|
||
type: MessageType.nostrCarrier.rawValue,
|
||
senderID: myPeerIDData,
|
||
recipientID: Data(hexString: gatewayPeer.id),
|
||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||
payload: payload,
|
||
signature: nil,
|
||
ttl: messageTTL
|
||
)
|
||
guard let signed = noiseService.signPacket(packet) else { return false }
|
||
messageQueue.async { [weak self] in
|
||
// broadcastPacket applies a known route when one exists and
|
||
// otherwise floods the directed packet like a DM, so a gateway
|
||
// that is reachable but multi-hop still gets the deposit.
|
||
self?.broadcastPacket(signed)
|
||
}
|
||
return true
|
||
}
|
||
|
||
/// Broadcast an encoded `fromGateway` carrier payload on the mesh with
|
||
/// the default TTL. Unsigned at the packet layer — receivers verify the
|
||
/// carried event's own Schnorr signature.
|
||
func broadcastNostrCarrier(_ payload: Data) {
|
||
let packet = BitchatPacket(
|
||
type: MessageType.nostrCarrier.rawValue,
|
||
senderID: myPeerIDData,
|
||
recipientID: nil,
|
||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||
payload: payload,
|
||
signature: nil,
|
||
ttl: messageTTL
|
||
)
|
||
messageQueue.async { [weak self] in
|
||
self?.broadcastPacket(packet)
|
||
}
|
||
}
|
||
|
||
/// Transport-level handling for a received nostrCarrier packet; policy
|
||
/// (verification of the carried event, quotas, loop prevention) lives in
|
||
/// `GatewayService` behind `onNostrCarrierPacket`.
|
||
private func handleNostrCarrier(_ packet: BitchatPacket, from _: PeerID) {
|
||
let senderID = PeerID(hexData: packet.senderID)
|
||
let directedToUs: Bool
|
||
if let recipientID = packet.recipientID {
|
||
// Carriers addressed elsewhere ride the generic relay path untouched.
|
||
guard recipientID == myPeerIDData else { return }
|
||
// Uplink deposit: quotas are keyed by the depositor, so the
|
||
// packet signature must verify against the sender's announced
|
||
// signing key. Unlike courier deposits the depositor may be
|
||
// multi-hop away, so ingress-link identity is not required.
|
||
let signingKey = collectionsQueue.sync { peerRegistry.info(for: senderID)?.signingPublicKey }
|
||
guard let signingKey,
|
||
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
|
||
SecureLogger.debug("🌐 nostrCarrier uplink from \(senderID.id.prefix(8))… rejected (missing/invalid packet signature)", category: .security)
|
||
return
|
||
}
|
||
directedToUs = true
|
||
} else {
|
||
directedToUs = false
|
||
}
|
||
let payload = packet.payload
|
||
notifyUI { [weak self] in
|
||
self?.onNostrCarrierPacket?(payload, senderID, directedToUs)
|
||
}
|
||
}
|
||
|
||
// MARK: Link capability snapshots (thread-safe via bleQueue)
|
||
|
||
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
|
||
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
|
||
return body(linkStateStore)
|
||
} else {
|
||
return bleQueue.sync { body(linkStateStore) }
|
||
}
|
||
}
|
||
|
||
private func snapshotDirectPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
|
||
readLinkState { $0.directPeripheralState(for: peerID) }
|
||
}
|
||
|
||
private func snapshotPeripheralStates() -> [BLEPeripheralLinkState] {
|
||
readLinkState(\.peripheralStates)
|
||
}
|
||
|
||
private func snapshotSubscribedCentrals() -> BLESubscribedCentralSnapshot {
|
||
readLinkState(\.subscribedCentralSnapshot)
|
||
}
|
||
|
||
// MARK: Helpers: IDs, selection, and write backpressure
|
||
|
||
private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic, priority: BLEOutboundWritePriority) {
|
||
// BLE operations run on bleQueue; keep queue affinity
|
||
bleQueue.async { [weak self] in
|
||
guard let self = self else { return }
|
||
let uuid = peripheral.identifier.uuidString
|
||
if peripheral.canSendWriteWithoutResponse {
|
||
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||
} else {
|
||
self.collectionsQueue.async(flags: .barrier) {
|
||
let result = self.pendingPeripheralWrites.enqueue(
|
||
data: data,
|
||
for: uuid,
|
||
priority: priority,
|
||
capBytes: TransportConfig.blePendingWriteBufferCapBytes
|
||
)
|
||
|
||
switch result {
|
||
case .oversized(let bytes):
|
||
SecureLogger.warning("⚠️ Dropping oversized write chunk (\(bytes)B) for peripheral \(uuid)", category: .session)
|
||
case let .enqueued(trimmedBytes, remainingBytes) where trimmedBytes > 0:
|
||
SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(trimmedBytes)B to \(remainingBytes)B", category: .session)
|
||
case .enqueued:
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Writes immediately or synchronously admits the packet to this
|
||
/// peripheral's bounded retry queue. Unlike `writeOrEnqueue`, the return
|
||
/// value distinguishes a retained queue item from one rejected or trimmed
|
||
/// immediately, which lets durable courier state commit truthfully.
|
||
private func writeOrEnqueueIfAccepted(
|
||
_ data: Data,
|
||
to peripheral: CBPeripheral,
|
||
characteristic: CBCharacteristic,
|
||
priority: BLEOutboundWritePriority,
|
||
requiredAuthenticatedPeer: PeerID?
|
||
) -> Bool {
|
||
let accept = { [self] in
|
||
let uuid = peripheral.identifier.uuidString
|
||
guard let state = linkStateStore.state(forPeripheralID: uuid),
|
||
state.isConnected,
|
||
state.characteristic?.uuid == characteristic.uuid else {
|
||
return false
|
||
}
|
||
if let peerID = requiredAuthenticatedPeer {
|
||
let link = BLEIngressLinkID.peripheral(uuid)
|
||
guard state.peerID == peerID,
|
||
noiseAuthenticatedLinkOwners[link] == peerID else {
|
||
return false
|
||
}
|
||
}
|
||
|
||
if peripheral.canSendWriteWithoutResponse {
|
||
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
|
||
return true
|
||
}
|
||
|
||
let attempt = collectionsQueue.sync(flags: .barrier) {
|
||
pendingPeripheralWrites.enqueueReportingAcceptance(
|
||
data: data,
|
||
for: uuid,
|
||
priority: priority,
|
||
capBytes: TransportConfig.blePendingWriteBufferCapBytes
|
||
)
|
||
}
|
||
switch attempt.result {
|
||
case .oversized(let bytes):
|
||
SecureLogger.warning("⚠️ Rejecting oversized write chunk (\(bytes)B) for peripheral \(uuid)", category: .session)
|
||
case let .enqueued(trimmedBytes, remainingBytes) where trimmedBytes > 0:
|
||
SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(trimmedBytes)B to \(remainingBytes)B", category: .session)
|
||
case .enqueued:
|
||
break
|
||
}
|
||
return attempt.accepted
|
||
}
|
||
|
||
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
|
||
return accept()
|
||
}
|
||
return bleQueue.sync(execute: accept)
|
||
}
|
||
|
||
private func drainPendingWrites(for peripheral: CBPeripheral) {
|
||
let uuid = peripheral.identifier.uuidString
|
||
bleQueue.async { [weak self] in
|
||
guard let self = self else { return }
|
||
guard !self.isPanicSuspended else { return }
|
||
guard let state = self.linkStateStore.state(forPeripheralID: uuid), let ch = state.characteristic else { return }
|
||
|
||
// Atomically take all pending items from the queue to avoid race conditions
|
||
// where new items could be enqueued between read and update
|
||
let itemsToSend: [BLEPendingWrite] = self.collectionsQueue.sync(flags: .barrier) {
|
||
self.pendingPeripheralWrites.takeAll(for: uuid)
|
||
}
|
||
guard !itemsToSend.isEmpty else { return }
|
||
|
||
// Send as many as possible
|
||
var sent = 0
|
||
for item in itemsToSend {
|
||
if peripheral.canSendWriteWithoutResponse {
|
||
peripheral.writeValue(item.data, for: ch, type: .withoutResponse)
|
||
sent += 1
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
|
||
// Re-enqueue any items that couldn't be sent (maintaining order)
|
||
let unsent = Array(itemsToSend.dropFirst(sent))
|
||
if !unsent.isEmpty {
|
||
self.collectionsQueue.async(flags: .barrier) {
|
||
self.pendingPeripheralWrites.prepend(unsent, for: uuid)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Periodically try to drain pending notifications as a backup mechanism
|
||
private func drainPendingNotificationsIfPossible() {
|
||
drainPendingNotifications(logPrefix: "🔄 Periodic drain: sent")
|
||
}
|
||
|
||
/// Periodically try to drain pending writes for all connected peripherals
|
||
private func drainAllPendingWrites() {
|
||
let uuids = collectionsQueue.sync { pendingPeripheralWrites.peripheralIDs }
|
||
for uuid in uuids {
|
||
guard let state = linkStateStore.state(forPeripheralID: uuid), state.isConnected else { continue }
|
||
drainPendingWrites(for: state.peripheral)
|
||
}
|
||
}
|
||
|
||
// MARK: Application State Handlers (iOS)
|
||
|
||
#if os(iOS)
|
||
@objc private func appDidBecomeActive() {
|
||
isAppActive = true
|
||
refreshCachedBackgroundTimeRemaining()
|
||
// Restart scanning with allow duplicates when app becomes active
|
||
if centralManager?.state == .poweredOn {
|
||
centralManager?.stopScan()
|
||
startScanning()
|
||
}
|
||
cancelStalePendingConnects()
|
||
logBluetoothStatus("became-active")
|
||
scheduleBluetoothStatusSample(after: 5.0, context: "active-5s")
|
||
// No Local Name; nothing to refresh for advertising policy
|
||
}
|
||
|
||
@objc private func appDidEnterBackground() {
|
||
isAppActive = false
|
||
refreshCachedBackgroundTimeRemaining()
|
||
// Restart scanning without allow duplicates in background
|
||
if centralManager?.state == .poweredOn {
|
||
centralManager?.stopScan()
|
||
startScanning()
|
||
}
|
||
armPendingBackgroundConnects()
|
||
// Backgrounding may precede a kill; flush the public-history archive
|
||
// outside its 30s maintenance cadence.
|
||
gossipSyncManager?.persistNow()
|
||
logBluetoothStatus("entered-background")
|
||
scheduleBluetoothStatusSample(after: 15.0, context: "background-15s")
|
||
// No Local Name; nothing to refresh for advertising policy
|
||
}
|
||
|
||
/// Issue indefinite `connect()` requests to recently seen peripherals on
|
||
/// backgrounding. Pending connects live in the Bluetooth controller's
|
||
/// allowlist — no scanning and no app CPU — and complete whenever a peer
|
||
/// comes into range, waking (or relaunching) the app. A couple of central
|
||
/// slots stay reserved for connects driven by live background discovery —
|
||
/// except on the disconnect re-arm path, which may consume the slot the
|
||
/// disconnect itself just freed (a dense mesh with 4+ remaining links
|
||
/// would otherwise compute a zero budget and never re-arm the lost peer).
|
||
private func armPendingBackgroundConnects(
|
||
slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve
|
||
) {
|
||
bleQueue.async { [weak self] in
|
||
guard let self,
|
||
!self.isPanicSuspended,
|
||
let central = self.centralManager,
|
||
central.state == .poweredOn else { return }
|
||
let budget = TransportConfig.bleMaxCentralLinks
|
||
- slotReserve
|
||
- self.linkStateStore.connectedOrConnectingPeripheralCount
|
||
let now = Date()
|
||
let targets = self.recentPeripheralCache.reconnectTargets(now: now, limit: budget) { peripheralID in
|
||
let state = self.linkStateStore.state(forPeripheralID: peripheralID)
|
||
return state?.isConnected == true || state?.isConnecting == true
|
||
}
|
||
guard !targets.isEmpty else { return }
|
||
for target in targets {
|
||
// lastConnectionAttempt stays nil: an indefinite pending connect
|
||
// has no attempt clock, and nil marks it always-stale so
|
||
// cancelStalePendingConnects() reclaims it on foreground even
|
||
// after a quick background→foreground bounce.
|
||
self.linkStateStore.setPeripheralState(
|
||
BLEPeripheralLinkState(
|
||
peripheral: target.peripheral,
|
||
characteristic: nil,
|
||
peerID: nil,
|
||
isConnecting: true,
|
||
isConnected: false,
|
||
lastConnectionAttempt: nil,
|
||
assembler: NotificationStreamAssembler()
|
||
),
|
||
for: target.peripheralID
|
||
)
|
||
target.peripheral.delegate = self
|
||
central.connect(target.peripheral, options: [
|
||
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
|
||
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
|
||
CBConnectPeripheralOptionNotifyOnNotificationKey: true
|
||
])
|
||
}
|
||
SecureLogger.info("🌙 Armed \(targets.count) pending background connect(s) for wake-on-proximity", category: .session)
|
||
}
|
||
}
|
||
|
||
/// Foreground restores normal connection management: pending connects
|
||
/// older than the connect timeout (including ones rebuilt by state
|
||
/// restoration after a relaunch) are cancelled so live scanning and the
|
||
/// scheduler take over. Anything still nearby is rediscovered within
|
||
/// seconds by the allow-duplicates foreground scan.
|
||
private func cancelStalePendingConnects() {
|
||
bleQueue.async { [weak self] in
|
||
guard let self, let central = self.centralManager else { return }
|
||
let now = Date()
|
||
var cancelled = 0
|
||
for state in self.linkStateStore.peripheralStates where state.isConnecting && !state.isConnected {
|
||
let age = state.lastConnectionAttempt.map { now.timeIntervalSince($0) } ?? .infinity
|
||
guard age > TransportConfig.bleConnectTimeoutSeconds else { continue }
|
||
let peripheralID = state.peripheral.identifier.uuidString
|
||
central.cancelPeripheralConnection(state.peripheral)
|
||
self.collectionsQueue.sync(flags: .barrier) {
|
||
self.pendingPeripheralWrites.discardAll(for: peripheralID)
|
||
}
|
||
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||
self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||
_ = self.linkStateStore.removePeripheral(peripheralID)
|
||
cancelled += 1
|
||
}
|
||
if cancelled > 0 {
|
||
SecureLogger.info("🌅 Cancelled \(cancelled) stale pending connect(s) on foreground", category: .session)
|
||
self.tryConnectFromQueue()
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// MARK: Private Message Handling
|
||
|
||
private func sendPrivateMessage(_ content: String, to recipientID: PeerID, messageID: String) {
|
||
// Hop like sendMessage: the Transport-facing wrappers call this from
|
||
// the main actor (router sends, favorite notifications), and the send
|
||
// path sync-waits on bleQueue for link state.
|
||
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||
messageQueue.async { [weak self] in
|
||
self?.sendPrivateMessage(content, to: recipientID, messageID: messageID)
|
||
}
|
||
return
|
||
}
|
||
// Sessions and wire recipient IDs are keyed by the short 16-hex form;
|
||
// callers may pass the full 64-hex noise key (mirrors sendFilePrivate).
|
||
let recipientID = recipientID.toShort()
|
||
SecureLogger.debug("📨 Sending PM to \(recipientID.id.prefix(8))… id=\(messageID.prefix(8))… chars=\(content.count) bytes=\(content.utf8.count)", category: .session)
|
||
|
||
// Check if we have an established Noise session
|
||
if noiseService.hasEstablishedSession(with: recipientID) {
|
||
// Encrypt and send
|
||
do {
|
||
guard let messagePayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
|
||
SecureLogger.error("Failed to encode private message with TLV")
|
||
return
|
||
}
|
||
|
||
broadcastPacket(try makeEncryptedNoisePacket(messagePayload, to: recipientID))
|
||
|
||
// Notify delegate that message was sent
|
||
notifyUI { [weak self] in
|
||
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: messageID, status: .sent))
|
||
}
|
||
} catch {
|
||
SecureLogger.error("Failed to encrypt message: \(error)")
|
||
}
|
||
} else {
|
||
// Queue message for sending after handshake completes
|
||
SecureLogger.debug("🤝 No session with \(recipientID.id.prefix(8))…, initiating handshake and queueing message", category: .session)
|
||
|
||
// Queue the message (especially important for favorite notifications)
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
pendingNoiseSessionQueues.appendPrivateMessage(content: content, messageID: messageID, for: recipientID)
|
||
}
|
||
|
||
initiateNoiseHandshake(with: recipientID)
|
||
|
||
// Notify delegate that message is pending
|
||
notifyUI { [weak self] in
|
||
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: messageID, status: .sending))
|
||
}
|
||
}
|
||
}
|
||
|
||
private func initiateNoiseHandshake(with peerID: PeerID) {
|
||
let service = noiseService
|
||
do {
|
||
guard let initiation = try service.initiateHandshakeIfNeeded(
|
||
with: peerID,
|
||
retryOnTimeout: true
|
||
) else {
|
||
return
|
||
}
|
||
messageQueue.async(flags: .barrier) {
|
||
[weak self, weak service] in
|
||
guard let self,
|
||
let service,
|
||
self.noiseService === service,
|
||
let handshakeData = service.claimHandshakeInitiation(
|
||
initiation,
|
||
for: peerID
|
||
) else {
|
||
return
|
||
}
|
||
self.broadcastNoiseHandshake(handshakeData, to: peerID)
|
||
}
|
||
} catch {
|
||
SecureLogger.error("Failed to initiate handshake: \(error)")
|
||
}
|
||
}
|
||
|
||
private func broadcastNoiseHandshake(_ handshakeData: Data, to peerID: PeerID) {
|
||
let packet = BitchatPacket(
|
||
type: MessageType.noiseHandshake.rawValue,
|
||
senderID: myPeerIDData,
|
||
recipientID: Data(hexString: peerID.id),
|
||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||
payload: handshakeData,
|
||
signature: nil,
|
||
ttl: messageTTL
|
||
)
|
||
broadcastPacket(packet)
|
||
}
|
||
|
||
/// Starts a wire-compatible ordinary XX reconnect. The manager prepares
|
||
/// the initiator before atomically retiring the cached transport; the
|
||
/// one-shot claim prevents a crossed inbound message from making a stale
|
||
/// message 1 leave after this peer has already become responder.
|
||
private func initiateNoiseReconnectHandshake(with peerID: PeerID) {
|
||
let service = noiseService
|
||
do {
|
||
let initiation = try service.initiateReconnectHandshake(
|
||
with: peerID,
|
||
retryOnTimeout: true
|
||
)
|
||
messageQueue.async(flags: .barrier) { [weak self, weak service] in
|
||
guard let self,
|
||
let service,
|
||
self.noiseService === service else {
|
||
return
|
||
}
|
||
self.noteNoiseSessionCleared(for: peerID)
|
||
guard let handshakeData = service.claimHandshakeInitiation(
|
||
initiation,
|
||
for: peerID
|
||
) else {
|
||
return
|
||
}
|
||
self.broadcastNoiseHandshake(handshakeData, to: peerID)
|
||
}
|
||
} catch NoiseSessionError.notEstablished {
|
||
initiateNoiseHandshake(with: peerID)
|
||
} catch {
|
||
SecureLogger.error(
|
||
"Failed to initiate ordinary reconnect: \(error)",
|
||
category: .session
|
||
)
|
||
}
|
||
}
|
||
|
||
private func sendPendingMessagesAfterHandshake(for peerID: PeerID) {
|
||
// Atomically take all pending messages to process (prevents concurrent modification)
|
||
let pendingMessages = collectionsQueue.sync(flags: .barrier) { () -> [BLEPendingPrivateMessage] in
|
||
pendingNoiseSessionQueues.takePrivateMessages(for: peerID)
|
||
}
|
||
|
||
guard !pendingMessages.isEmpty else { return }
|
||
|
||
SecureLogger.debug("📤 Sending \(pendingMessages.count) pending messages after handshake to \(peerID.id.prefix(8))…", category: .session)
|
||
|
||
// Track failed messages for re-queuing
|
||
var failedMessages: [BLEPendingPrivateMessage] = []
|
||
|
||
// Send each pending message directly (we know session is established)
|
||
for message in pendingMessages {
|
||
do {
|
||
// Use the same TLV format as normal sends to keep receiver decoding consistent
|
||
guard let messagePayload = BLENoisePayloadFactory.privateMessage(content: message.content, messageID: message.messageID) else {
|
||
SecureLogger.error("Failed to encode pending private message TLV")
|
||
failedMessages.append(message)
|
||
continue
|
||
}
|
||
|
||
// We're already on messageQueue from the callback
|
||
broadcastPacket(try makeEncryptedNoisePacket(messagePayload, to: peerID))
|
||
|
||
// Notify delegate that message was sent
|
||
notifyUI { [weak self] in
|
||
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .sent))
|
||
}
|
||
|
||
SecureLogger.debug("✅ Sent pending message id=\(message.messageID.prefix(8))… to \(peerID.id.prefix(8))… after handshake", category: .session)
|
||
} catch {
|
||
SecureLogger.error("Failed to send pending message after handshake: \(error)")
|
||
failedMessages.append(message)
|
||
|
||
// Notify delegate of failure
|
||
notifyUI { [weak self] in
|
||
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer"))))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Re-queue any failed messages for retry on next handshake
|
||
if !failedMessages.isEmpty {
|
||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||
guard let self = self else { return }
|
||
// Prepend failed messages to maintain order
|
||
self.pendingNoiseSessionQueues.prependPrivateMessages(failedMessages, for: peerID)
|
||
SecureLogger.warning("⚠️ Re-queued \(failedMessages.count) failed messages for \(peerID.id.prefix(8))…", category: .session)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: Fragmentation (Required for messages > BLE MTU)
|
||
|
||
@discardableResult
|
||
private func sendFragmentedPacket(
|
||
_ packet: BitchatPacket,
|
||
pad: Bool,
|
||
maxChunk: Int? = nil,
|
||
directedOnlyPeer: PeerID? = nil,
|
||
transferId: String? = nil,
|
||
requireDirectPeerLink: Bool = false,
|
||
requireNoiseAuthenticatedPeerLink: Bool = false,
|
||
requiresPrivateMediaAdmission: Bool = false
|
||
) -> Bool {
|
||
let request = BLEOutboundFragmentTransferRequest(
|
||
packet: packet,
|
||
pad: pad,
|
||
maxChunk: maxChunk,
|
||
directedPeer: directedOnlyPeer,
|
||
transferId: transferId,
|
||
requireDirectPeerLink: requireDirectPeerLink,
|
||
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
|
||
)
|
||
|
||
let result: BLEOutboundFragmentTransferScheduler.SubmitResult? = collectionsQueue.sync(flags: .barrier) {
|
||
if requiresPrivateMediaAdmission {
|
||
guard let transferId else { return nil }
|
||
// This lock is taken while the scheduler is already protected
|
||
// by collectionsQueue. Cancellation takes the admission lock
|
||
// synchronously but never waits on collectionsQueue, avoiding
|
||
// lock inversion while giving submit/cancel one linear order.
|
||
return privateMediaTransferAdmissions.withActive(transferId) {
|
||
outboundFragmentTransfers.submit(
|
||
request,
|
||
maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers
|
||
)
|
||
}
|
||
}
|
||
return outboundFragmentTransfers.submit(
|
||
request,
|
||
maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers
|
||
)
|
||
}
|
||
guard let result else {
|
||
if let transferId, requiresPrivateMediaAdmission {
|
||
privateMediaTransferAdmissions.finish(transferId)
|
||
}
|
||
return false
|
||
}
|
||
if let transferId, requiresPrivateMediaAdmission {
|
||
// The scheduler now owns normal cancellation (active or pending).
|
||
privateMediaTransferAdmissions.finish(transferId)
|
||
}
|
||
return handleFragmentTransferSubmitResult(result)
|
||
}
|
||
|
||
@discardableResult
|
||
private func handleFragmentTransferSubmitResult(_ result: BLEOutboundFragmentTransferScheduler.SubmitResult) -> Bool {
|
||
switch result {
|
||
case let .start(request, reservedTransferId):
|
||
return startFragmentedPacket(request, reservedTransferId: reservedTransferId)
|
||
|
||
case let .queued(_, transferId, _):
|
||
if let transferId {
|
||
SecureLogger.debug("🚦 Queued media transfer \(transferId.prefix(8))… waiting for slot", category: .session)
|
||
} else {
|
||
SecureLogger.debug("🚦 Queued fragment transfer waiting for slot", category: .session)
|
||
}
|
||
return false
|
||
|
||
case let .rejectedStrict(_, transferId):
|
||
SecureLogger.debug(
|
||
"🚫 Strict directed fragment transfer \(transferId?.prefix(8) ?? "?")… rejected while scheduler busy",
|
||
category: .session
|
||
)
|
||
return false
|
||
|
||
case let .droppedDuplicate(_, activeTransferId):
|
||
SecureLogger.debug(
|
||
"🔁 Skipping duplicate outbound transfer — same content already in flight as \(activeTransferId?.prefix(8) ?? "?")…",
|
||
category: .session
|
||
)
|
||
return false
|
||
}
|
||
}
|
||
|
||
@discardableResult
|
||
private func startFragmentedPacket(
|
||
_ request: BLEOutboundFragmentTransferRequest,
|
||
reservedTransferId: String?
|
||
) -> Bool {
|
||
let releaseReservedSlot: (String) -> Void = { [weak self] id in
|
||
guard let self = self else { return }
|
||
TransferProgressManager.shared.cancel(id: id)
|
||
self.collectionsQueue.async(flags: .barrier) { [weak self] in
|
||
_ = self?.outboundFragmentTransfers.releaseReservation(id)
|
||
}
|
||
self.messageQueue.async { [weak self] in
|
||
self?.startNextPendingTransferIfNeeded()
|
||
}
|
||
}
|
||
|
||
guard let plan = BLEOutboundFragmentPlanner.makePlan(
|
||
for: request,
|
||
defaultChunkSize: defaultFragmentSize,
|
||
bleMaxMTU: bleMaxMTU
|
||
) else {
|
||
if let id = reservedTransferId {
|
||
releaseReservedSlot(id)
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Lightweight pacing to reduce floods and allow BLE buffers to drain
|
||
// Also briefly pause scanning during long fragment trains to save battery
|
||
if plan.shouldPauseScanning {
|
||
bleQueue.async { [weak self] in
|
||
guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return }
|
||
if c.isScanning { c.stopScan() }
|
||
let totalFragments = plan.totalFragments
|
||
let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, totalFragments * TransportConfig.bleExpectedWritePerFragmentMs)
|
||
self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in
|
||
self?.startScanning()
|
||
}
|
||
}
|
||
}
|
||
|
||
let transferIdentifier: String?
|
||
if let id = reservedTransferId {
|
||
let activated = collectionsQueue.sync(flags: .barrier) {
|
||
self.outboundFragmentTransfers.activateReservedTransfer(
|
||
id: id,
|
||
totalFragments: plan.totalFragments,
|
||
workItems: []
|
||
)
|
||
}
|
||
// Cancellation may remove the reservation between submit and plan
|
||
// construction. Treat that as cancellation, not as permission to
|
||
// schedule an untracked fragment train.
|
||
guard activated else { return false }
|
||
TransferProgressManager.shared.start(id: id, totalFragments: plan.totalFragments)
|
||
transferIdentifier = id
|
||
} else {
|
||
transferIdentifier = nil
|
||
}
|
||
|
||
let sendFragment: (BitchatPacket) -> Bool = { [weak self] fragmentPacket in
|
||
guard let self else { return false }
|
||
if request.requireDirectPeerLink, let directedPeer = request.directedPeer {
|
||
return self.sendPacketDirected(
|
||
fragmentPacket,
|
||
to: directedPeer,
|
||
requireDirectPeerLink: true,
|
||
requireNoiseAuthenticatedPeerLink: request.requireNoiseAuthenticatedPeerLink
|
||
)
|
||
}
|
||
self.broadcastPacket(fragmentPacket)
|
||
return true
|
||
}
|
||
|
||
// Strict courier handoff is transactional at the fragment-admission
|
||
// boundary: every fragment must enter the intended authenticated
|
||
// link or its bounded retry queue before the durable owner may commit.
|
||
// A partial train is harmlessly abandoned and the envelope stays
|
||
// retryable with a fresh fragment ID on the next encounter.
|
||
if request.requireDirectPeerLink {
|
||
let admitted = BLEStrictFragmentAdmission.admitAll(plan.fragmentPackets) { fragmentPacket in
|
||
guard sendFragment(fragmentPacket) else { return false }
|
||
if let transferId = transferIdentifier {
|
||
markFragmentSent(transferId: transferId)
|
||
}
|
||
return true
|
||
}
|
||
guard admitted else {
|
||
if let id = reservedTransferId {
|
||
releaseReservedSlot(id)
|
||
}
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
var scheduledItems: [(item: DispatchWorkItem, index: Int)] = []
|
||
|
||
for (index, fragmentPacket) in plan.fragmentPackets.enumerated() {
|
||
let workItem = DispatchWorkItem { [weak self] in
|
||
guard let self = self else { return }
|
||
if let transferId = transferIdentifier {
|
||
let isActive = self.collectionsQueue.sync { self.outboundFragmentTransfers.isActive(transferId) }
|
||
guard isActive else { return }
|
||
}
|
||
if fragmentPacket.recipientID == nil || fragmentPacket.recipientID?.allSatisfy({ $0 == 0xFF }) == true {
|
||
self.gossipSyncManager?.onPublicPacketSeen(fragmentPacket)
|
||
}
|
||
_ = sendFragment(fragmentPacket)
|
||
if let transferId = transferIdentifier {
|
||
self.markFragmentSent(transferId: transferId)
|
||
}
|
||
}
|
||
|
||
scheduledItems.append((item: workItem, index: index))
|
||
}
|
||
|
||
if let transferId = transferIdentifier {
|
||
let workItems = scheduledItems.map { $0.item }
|
||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||
_ = self?.outboundFragmentTransfers.updateWorkItems(workItems, for: transferId)
|
||
}
|
||
}
|
||
|
||
for (workItem, index) in scheduledItems {
|
||
let delayMs = index * plan.spacingMs
|
||
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem)
|
||
}
|
||
return true
|
||
}
|
||
|
||
// MARK: - Fragmentation (Required for messages > BLE MTU)
|
||
|
||
private func markFragmentSent(transferId: String) {
|
||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||
guard let self = self else { return }
|
||
|
||
switch self.outboundFragmentTransfers.markFragmentSent(transferId: transferId) {
|
||
case .progress, .complete:
|
||
TransferProgressManager.shared.recordFragmentSent(id: transferId)
|
||
|
||
case .missing:
|
||
return
|
||
}
|
||
|
||
if !self.outboundFragmentTransfers.isActive(transferId) {
|
||
self.messageQueue.async { [weak self] in
|
||
self?.startNextPendingTransferIfNeeded()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func startNextPendingTransferIfNeeded() {
|
||
let results = collectionsQueue.sync(flags: .barrier) {
|
||
outboundFragmentTransfers.reservePendingStarts(maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers)
|
||
}
|
||
|
||
for result in results {
|
||
messageQueue.async { [weak self] in
|
||
self?.handleFragmentTransferSubmitResult(result)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func handleFragment(_ packet: BitchatPacket, from peerID: PeerID) {
|
||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||
fragmentHandler.handle(packet, from: peerID)
|
||
} else {
|
||
messageQueue.async(flags: .barrier) { [weak self] in
|
||
self?.fragmentHandler.handle(packet, from: peerID)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Builds the fragment handler environment. All queue hops stay here so
|
||
/// `BLEFragmentHandler` remains queue-agnostic and synchronously testable.
|
||
private func makeFragmentHandlerEnvironment() -> BLEFragmentHandlerEnvironment {
|
||
BLEFragmentHandlerEnvironment(
|
||
localPeerID: { [weak self] in
|
||
self?.myPeerID ?? PeerID(str: "")
|
||
},
|
||
trackPacketSeen: { [weak self] packet in
|
||
self?.gossipSyncManager?.onPublicPacketSeen(packet)
|
||
},
|
||
appendFragment: { [weak self] header in
|
||
guard let self = self else {
|
||
return .stored(header: header, started: false)
|
||
}
|
||
return self.collectionsQueue.sync(flags: .barrier) {
|
||
self.fragmentAssemblyBuffer.append(header, maxInFlightAssemblies: self.maxInFlightAssemblies)
|
||
}
|
||
},
|
||
isAcceptedIngressPayload: { [weak self] packet, innerSender in
|
||
self?.isAcceptedIngressPayload(packet, from: innerSender) ?? false
|
||
},
|
||
processReassembledPacket: { [weak self] packet, peerID in
|
||
self?.handleReceivedPacket(packet, from: peerID)
|
||
}
|
||
)
|
||
}
|
||
|
||
// MARK: Packet Reception
|
||
|
||
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
|
||
let isNoisePacket = packet.type == MessageType.noiseHandshake.rawValue
|
||
|| packet.type == MessageType.noiseEncrypted.rawValue
|
||
|
||
// Capture the panic lifecycle at the first off-messageQueue handoff.
|
||
// Noise packets still enter through a barrier so handshake promotion,
|
||
// quarantine, and encrypted delivery share one ordered session.
|
||
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||
guard let lifecycleGeneration =
|
||
capturePanicLifecycleGeneration() else {
|
||
return
|
||
}
|
||
#if DEBUG
|
||
_test_beforeReceivePacketHandoff?()
|
||
#endif
|
||
let flags: DispatchWorkItemFlags = isNoisePacket ? .barrier : []
|
||
messageQueue.async(flags: flags) { [weak self] in
|
||
guard let self,
|
||
self.isCurrentPanicLifecycleGeneration(
|
||
lifecycleGeneration
|
||
) else {
|
||
return
|
||
}
|
||
#if DEBUG
|
||
self._test_onReceivePacketHandoff?()
|
||
#endif
|
||
self.handleReceivedPacketOnQueue(packet, from: peerID)
|
||
}
|
||
return
|
||
}
|
||
|
||
if isNoisePacket {
|
||
guard let lifecycleGeneration =
|
||
capturePanicLifecycleGeneration() else {
|
||
return
|
||
}
|
||
messageQueue.async(flags: .barrier) { [weak self] in
|
||
guard let self,
|
||
self.isCurrentPanicLifecycleGeneration(
|
||
lifecycleGeneration
|
||
) else {
|
||
return
|
||
}
|
||
self.handleReceivedPacketOnQueue(packet, from: peerID)
|
||
}
|
||
} else {
|
||
handleReceivedPacketOnQueue(packet, from: peerID)
|
||
}
|
||
}
|
||
|
||
private func handleReceivedPacketOnQueue(
|
||
_ packet: BitchatPacket,
|
||
from peerID: PeerID
|
||
) {
|
||
let context = BLEReceivePipeline.context(for: packet, localPeerID: myPeerID)
|
||
let senderID = context.senderID
|
||
let messageID = context.messageID
|
||
|
||
// Only log non-announce packets to reduce noise
|
||
if context.logsHandlingDetails {
|
||
// Log packet details for debugging
|
||
SecureLogger.debug("📦 Handling packet type \(packet.type) from \(senderID.id.prefix(8))…, messageID: \(messageID.prefix(24))…", category: .session)
|
||
}
|
||
|
||
if dropDuplicatePacketIfNeeded(context: context, messageID: messageID) { return }
|
||
|
||
// Update peer info without verbose logging - update the peer we received from, not the original sender
|
||
updatePeerLastSeen(peerID)
|
||
|
||
// Track recent traffic timestamps for adaptive behavior; the same
|
||
// barrier hop confirms route health for the packet's originator.
|
||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||
guard let self = self else { return }
|
||
self.recentTrafficTracker.recordPacket(at: Date())
|
||
self.sourceRouteFailures.noteInboundActivity(from: senderID)
|
||
}
|
||
|
||
// Per-peer protocol version: originated source routes only use hops
|
||
// observed speaking v2 (a v1-only node cannot decode v2 frames).
|
||
if packet.version >= 2 {
|
||
meshTopology.recordObservedVersion(packet.version, for: packet.senderID)
|
||
if peerID != senderID {
|
||
meshTopology.recordObservedVersion(packet.version, for: routingData(for: peerID))
|
||
}
|
||
}
|
||
|
||
#if os(iOS)
|
||
// The maintenance timer is suspended with the app, so a packet arriving
|
||
// while backgrounded means the radio woke us — use the wake window to
|
||
// run the announce/flush/drain pass the timer would have run.
|
||
if !isAppActive {
|
||
bleQueue.async { [weak self] in self?.performBackgroundWakeMaintenanceIfStale() }
|
||
}
|
||
#endif
|
||
|
||
|
||
// Process by type
|
||
switch context.messageType {
|
||
case .announce:
|
||
handleAnnounce(packet, from: senderID)
|
||
|
||
case .message:
|
||
handleMessage(packet, from: senderID)
|
||
|
||
case .requestSync:
|
||
handleRequestSync(packet, from: senderID)
|
||
|
||
case .noiseHandshake:
|
||
handleNoiseHandshake(packet, from: senderID)
|
||
|
||
case .noiseEncrypted:
|
||
handleNoiseEncrypted(packet, from: senderID)
|
||
|
||
case .fragment:
|
||
handleFragment(packet, from: senderID)
|
||
|
||
case .fileTransfer:
|
||
// Broadcast files that fail sender authentication must not spread
|
||
// to downstream (possibly older, ungated) nodes; skip the relay
|
||
// step below, like invalid board posts and voice frames.
|
||
guard handleFileTransfer(packet, from: senderID) else { return }
|
||
|
||
case .courierEnvelope:
|
||
handleCourierEnvelope(packet, from: peerID)
|
||
|
||
case .groupMessage:
|
||
handleGroupMessage(packet, from: senderID)
|
||
|
||
case .prekeyBundle:
|
||
handlePrekeyBundle(packet, from: senderID)
|
||
|
||
case .boardPost:
|
||
// Invalid or deleted posts must not spread; skip the relay step.
|
||
guard handleBoardPost(packet, from: senderID) else { return }
|
||
case .nostrCarrier:
|
||
handleNostrCarrier(packet, from: peerID)
|
||
|
||
case .voiceFrame:
|
||
// Rejected frames (unsigned/stale/spoofed) must not spread; skip
|
||
// the relay step below, like invalid board posts.
|
||
guard handleVoiceFrame(packet, from: senderID) else { return }
|
||
|
||
case .ping:
|
||
// Rate limiting must key on the ingress link (`peerID`), not the
|
||
// packet-claimed sender: pings are unsigned, so `senderID` is
|
||
// attacker-controlled and rotating it would reset the budget.
|
||
handleMeshPing(packet, fromLink: peerID)
|
||
|
||
case .pong:
|
||
handleMeshPong(packet, from: senderID)
|
||
|
||
case .leave:
|
||
// A forged leave must neither evict the claimed peer nor spread
|
||
// to downstream nodes.
|
||
guard handleLeave(packet, from: senderID) else { return }
|
||
|
||
case .none:
|
||
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
|
||
}
|
||
|
||
if forwardAlongRouteIfNeeded(packet) {
|
||
return
|
||
}
|
||
|
||
scheduleRelayIfNeeded(packet, senderID: senderID, messageID: messageID)
|
||
}
|
||
|
||
private func dropDuplicatePacketIfNeeded(context: BLEReceivedPacketContext, messageID: String) -> Bool {
|
||
guard context.shouldDeduplicate, messageDeduplicator.isDuplicate(messageID) else {
|
||
return false
|
||
}
|
||
|
||
if context.logsHandlingDetails {
|
||
SecureLogger.debug("⚠️ Duplicate packet ignored: \(messageID.prefix(24))…", category: .session)
|
||
}
|
||
|
||
let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount }
|
||
if BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: connectedCount) {
|
||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||
self?.scheduledRelays.cancel(messageID: messageID)
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
private func scheduleRelayIfNeeded(_ packet: BitchatPacket, senderID: PeerID, messageID: String) {
|
||
let degree = collectionsQueue.sync { peerRegistry.connectedCount }
|
||
let decision = BLEReceivePipeline.relayDecision(
|
||
for: packet,
|
||
senderID: senderID,
|
||
localPeerID: myPeerID,
|
||
degree: degree,
|
||
highDegreeThreshold: highDegreeThreshold
|
||
)
|
||
guard decision.shouldRelay else { return }
|
||
|
||
let work = DispatchWorkItem { [weak self] in
|
||
guard let self = self else { return }
|
||
self.collectionsQueue.async(flags: .barrier) { [weak self] in
|
||
self?.scheduledRelays.remove(messageID: messageID)
|
||
}
|
||
var relayPacket = packet
|
||
relayPacket.ttl = decision.newTTL
|
||
self.broadcastPacket(relayPacket)
|
||
}
|
||
|
||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||
self?.scheduledRelays.schedule(work, messageID: messageID)
|
||
}
|
||
messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work)
|
||
}
|
||
|
||
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
|
||
let result = announceHandler.handle(packet, from: peerID)
|
||
|
||
// A capability bit in the public announce is only a discovery hint.
|
||
// Start authentication promptly for a directly connected candidate,
|
||
// but never pin or pre-queue private bytes until encrypted 0x21 state
|
||
// arrives from the completed Noise session.
|
||
if let result,
|
||
result.isVerified,
|
||
result.isDirectAnnounce,
|
||
result.announcement.capabilities?.contains(.privateMedia) == true,
|
||
privateMediaSendPolicy(to: result.peerID) == .awaitingCapabilityProof,
|
||
!noiseService.hasSession(with: result.peerID) {
|
||
initiateNoiseHandshake(with: result.peerID)
|
||
}
|
||
|
||
// A verified announce is the moment a signing key becomes bound to this
|
||
// owner's noise key: retry any prekey bundle that raced ahead of it.
|
||
if let result, result.isVerified {
|
||
drainPendingPrekeyBundles(for: result.peerID)
|
||
}
|
||
|
||
// A verified direct announce proves the sender owns the link it came
|
||
// in on: heal any stale binding left by a peer-ID rotation, and
|
||
// consolidate duplicate same-role connections onto that link.
|
||
if let result, result.isVerified, result.isDirectAnnounce {
|
||
rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID)
|
||
#if DEBUG
|
||
_test_afterVerifiedDirectRebindEnqueued?()
|
||
#endif
|
||
retireRedundantPeripheralLinks(packet, to: result.peerID)
|
||
}
|
||
|
||
// Bridge courier watch: a verified announce may add a peer whose
|
||
// relay-parked drops we should start watching for.
|
||
if let result, result.isVerified {
|
||
onVerifiedPeerAnnounce?(result.peerID)
|
||
}
|
||
|
||
// Courier work: an announce is the moment we learn a peer's Noise
|
||
// static key, so check whether we're carrying mail addressed to them
|
||
// (or spray-able mail they could carry). Verified announces only.
|
||
guard !courierStore.isEmpty,
|
||
let result,
|
||
result.isVerified else { return }
|
||
let noiseKey = result.announcement.noisePublicKey
|
||
let authenticatedIngress = result.isDirectAnnounce
|
||
&& canDeliverSecurely(to: result.peerID)
|
||
&& isNoiseAuthenticatedIngressLink(for: packet, peerID: result.peerID)
|
||
if authenticatedIngress {
|
||
// The session was established on this still-bound ingress link.
|
||
// A peer-level Noise session alone is not enough: it can outlive
|
||
// its physical link while a replay rebinds an attacker's link to
|
||
// the victim's ID.
|
||
deliverCourierMail(to: result.peerID, noiseKey: noiseKey)
|
||
sprayCourierMail(to: result.peerID, noiseKey: noiseKey, isVerifiedPeer: true)
|
||
} else {
|
||
// Relayed announce, or a direct-looking announce that has not yet
|
||
// proved link ownership with Noise: push a speculative copy while
|
||
// retaining the durable carried original.
|
||
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
|
||
if result.isDirectAnnounce,
|
||
!hasCurrentNoiseAuthenticatedLink(to: result.peerID) {
|
||
// A cached session may predate this physical link.
|
||
// rebindLinkAfterVerifiedDirectAnnounce performs its atomic
|
||
// ordinary reconnect after the binding is published.
|
||
if !noiseService.hasSession(with: result.peerID) {
|
||
initiateNoiseHandshake(with: result.peerID)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// When a peer relaunches it rotates its ephemeral peer ID, but an
|
||
/// already-open BLE connection keeps its old peripheral/central→peerID
|
||
/// binding. Until that binding heals, the rotated peer shows up twice in
|
||
/// the peer list and its directed traffic on this link is dropped as
|
||
/// spoofed. A signature-verified direct announce proves the claimed
|
||
/// sender owns the link it arrived on, so rebind the link to the new ID
|
||
/// and retire the old identity.
|
||
private func rebindLinkAfterVerifiedDirectAnnounce(_ packet: BitchatPacket, to peerID: PeerID) {
|
||
guard let link = (collectionsQueue.sync { ingressLinks.link(for: packet) }) else { return }
|
||
bleQueue.async { [weak self] in
|
||
guard let self else { return }
|
||
let linkUUID: String
|
||
let previousPeerID: PeerID?
|
||
switch link {
|
||
case .peripheral(let peripheralUUID):
|
||
linkUUID = peripheralUUID
|
||
previousPeerID = self.linkStateStore.peerID(forPeripheralID: peripheralUUID)
|
||
case .central(let centralUUID):
|
||
linkUUID = centralUUID
|
||
previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID)
|
||
}
|
||
guard let previousPeerID else { return }
|
||
guard previousPeerID != peerID else {
|
||
self.refreshNoiseSessionForVerifiedDirectLink(
|
||
packet,
|
||
peerID: peerID
|
||
)
|
||
return
|
||
}
|
||
|
||
// The signature does not authenticate directness (TTL is excluded
|
||
// from signing because relays mutate it), so a "verified direct"
|
||
// announce can be a replay of another peer's fresh announce with
|
||
// its TTL restored. Contain what a forged rebind could do:
|
||
// never steal an identity another live link already owns, and
|
||
// allow at most one rebind per link per cooldown window so two
|
||
// identities can't fight over a link in a replay flip-flop.
|
||
guard self.linkStateStore.links(to: peerID).isEmpty else {
|
||
SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: identity already owns another live link", category: .security)
|
||
return
|
||
}
|
||
let now = Date()
|
||
self.lastLinkRebindAt = self.lastLinkRebindAt.filter {
|
||
now.timeIntervalSince($0.value) < TransportConfig.bleLinkRebindCooldownSeconds
|
||
}
|
||
guard self.lastLinkRebindAt[linkUUID] == nil else {
|
||
SecureLogger.warning("🚫 Refusing link rebind to \(peerID.id.prefix(8))…: rebind cooldown active for this link", category: .security)
|
||
return
|
||
}
|
||
self.lastLinkRebindAt[linkUUID] = now
|
||
|
||
// A Noise proof belongs to the old physical binding. Never carry
|
||
// it across an announce-driven rebind, whose direct TTL is
|
||
// replayable; the new owner must complete a fresh handshake.
|
||
self.noiseAuthenticatedLinkOwners.removeValue(forKey: link)
|
||
self.noiseReconnectPolicy.endLinkEpoch(link)
|
||
switch link {
|
||
case .peripheral(let peripheralUUID):
|
||
self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
|
||
case .central(let centralUUID):
|
||
self.linkStateStore.bindCentral(centralUUID, to: peerID)
|
||
}
|
||
// Keep the rebind and reconnect decision in one bleQueue critical
|
||
// section. No observer may see the new binding while a cached
|
||
// peer-level sender is still considered established.
|
||
self.refreshNoiseSessionForVerifiedDirectLink(
|
||
packet,
|
||
peerID: peerID
|
||
)
|
||
SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))…", category: .session)
|
||
self.refreshLocalTopology()
|
||
// The announce that triggered this rebind was upserted as
|
||
// disconnected: the registry ran while the link still belonged
|
||
// to the previous ID (the ambiguous state BLEAnnounceHandler
|
||
// denies the connected shortcut). The rebind has now
|
||
// containment-checked the claim and the identity owns a live
|
||
// link, so promote it — otherwise a healed rotation leaves a
|
||
// live link that reads as disconnected until the next announce.
|
||
self.messageQueue.async { [weak self] in
|
||
self?.promoteReboundPeerToConnected(peerID)
|
||
}
|
||
// Any other peripheral links still bound to the rotated-away ID
|
||
// are stale duplicates of the same physical device (its restored
|
||
// connections outlived the relaunch that rotated the ID): cancel
|
||
// them now instead of leaving ghost links that spray duplicate
|
||
// traffic until the inactivity timeout.
|
||
self.cancelBoundPeripheralLinks(to: previousPeerID, keeping: linkUUID)
|
||
// Retire the rotated-away ID only once its last link is gone; a
|
||
// remaining stale link heals the same way or ages out.
|
||
guard self.linkStateStore.links(to: previousPeerID).isEmpty else { return }
|
||
self.messageQueue.async { [weak self] in
|
||
self?.retireRotatedPeer(previousPeerID)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// After a restore relaunch the same phone can reappear under a fresh
|
||
/// peripheral UUID while its restored connection lives on, leaving
|
||
/// several live central-role connections to one peer that each carry
|
||
/// every packet (field-verified: every voice frame arrived 2-3x). A
|
||
/// verified direct announce is the consolidation point: keep the link it
|
||
/// proves live (or the peer's most recently bound one) and cancel the
|
||
/// rest. Only same-role duplicates are touched — one connection per role
|
||
/// is the normal dual-role topology — and only connections we own as
|
||
/// central: the peer's central subscriptions on our peripheral manager
|
||
/// are its connections to cancel, and it runs this same policy.
|
||
///
|
||
/// Directness is forgeable (TTL is unsigned), so a replayed announce
|
||
/// could nominate the replayer's link as the survivor. Containment
|
||
/// mirrors the rotation rebind: only links already BOUND to the peer are
|
||
/// retired (announce-evidenced, never pre-announce links), at most one
|
||
/// retirement per peer per cooldown window, and the peer keeps a live
|
||
/// link either way.
|
||
private func retireRedundantPeripheralLinks(_ packet: BitchatPacket, to peerID: PeerID) {
|
||
let ingressLink = collectionsQueue.sync { ingressLinks.link(for: packet) }
|
||
bleQueue.async { [weak self] in
|
||
guard let self else { return }
|
||
let now = Date()
|
||
self.lastRedundantLinkRetirementAt = self.lastRedundantLinkRetirementAt.filter {
|
||
now.timeIntervalSince($0.value) < TransportConfig.bleLinkRebindCooldownSeconds
|
||
}
|
||
guard self.lastRedundantLinkRetirementAt[peerID] == nil else { return }
|
||
|
||
var ingressPeripheralUUID: String?
|
||
if case .peripheral(let uuid) = ingressLink {
|
||
ingressPeripheralUUID = uuid
|
||
}
|
||
guard let keptUUID = BLERedundantLinkPolicy.keptPeripheralUUID(
|
||
ingressPeripheralUUID: ingressPeripheralUUID,
|
||
mostRecentlyBoundUUID: self.linkStateStore.preferredPeripheralBindings[peerID],
|
||
links: self.peripheralLinkPolicySnapshot(),
|
||
peerID: peerID
|
||
) else { return }
|
||
|
||
self.lastRedundantLinkRetirementAt[peerID] = now
|
||
// The survivor becomes the peer's reverse-mapped link so directed
|
||
// sends follow the consolidation.
|
||
self.linkStateStore.bindPeripheral(keptUUID, to: peerID)
|
||
self.cancelBoundPeripheralLinks(to: peerID, keeping: keptUUID)
|
||
self.refreshLocalTopology()
|
||
}
|
||
}
|
||
|
||
/// Cancels our central-role connections whose link is bound to `peerID`,
|
||
/// except `keptUUID`. bleQueue only. Each entry is removed from the link
|
||
/// store BEFORE cancelling so didDisconnectPeripheral sees no peer
|
||
/// binding and skips its peer-disconnect bookkeeping — the peer is still
|
||
/// live (on the kept link, or under its rotated identity).
|
||
private func cancelBoundPeripheralLinks(to peerID: PeerID, keeping keptUUID: String?) {
|
||
let retiring = BLERedundantLinkPolicy.peripheralUUIDsToRetire(
|
||
links: peripheralLinkPolicySnapshot(),
|
||
peerID: peerID,
|
||
keeping: keptUUID ?? ""
|
||
)
|
||
for uuid in retiring {
|
||
guard let state = linkStateStore.state(forPeripheralID: uuid) else { continue }
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
pendingPeripheralWrites.discardAll(for: uuid)
|
||
}
|
||
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid))
|
||
noiseReconnectPolicy.endLinkEpoch(.peripheral(uuid))
|
||
_ = linkStateStore.removePeripheral(uuid)
|
||
SecureLogger.info(
|
||
"🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))…\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")",
|
||
category: .session
|
||
)
|
||
centralManager?.cancelPeripheralConnection(state.peripheral)
|
||
}
|
||
}
|
||
|
||
/// bleQueue only (reads the link store).
|
||
private func peripheralLinkPolicySnapshot() -> [BLERedundantLinkPolicy.PeripheralLink] {
|
||
linkStateStore.peripheralStates.map {
|
||
BLERedundantLinkPolicy.PeripheralLink(
|
||
uuid: $0.peripheral.identifier.uuidString,
|
||
peerID: $0.peerID,
|
||
isConnected: $0.isConnected,
|
||
hasCharacteristic: $0.characteristic != nil
|
||
)
|
||
}
|
||
}
|
||
|
||
/// After a successful verified rebind the new identity owns a live link,
|
||
/// but its announce was stored disconnected (the link was still bound to
|
||
/// the rotated-away ID when the registry upsert ran). Flip it to
|
||
/// connected and republish so routing and the peer list see the healed
|
||
/// link. The `.peerConnected` UI event already fired from the announce
|
||
/// path (new/reconnected + direct), so only list state needs refreshing.
|
||
private func promoteReboundPeerToConnected(_ peerID: PeerID) {
|
||
let promoted = collectionsQueue.sync(flags: .barrier) {
|
||
peerRegistry.markConnected(peerID)
|
||
}
|
||
guard promoted else { return }
|
||
refreshLocalTopology()
|
||
publishFullPeerData()
|
||
notifyUI { [weak self] in
|
||
guard let self else { return }
|
||
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
|
||
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
||
}
|
||
}
|
||
|
||
/// Rotation is an implicit leave of the old identity: drop it immediately
|
||
/// instead of letting a ghost duplicate linger for the reachability
|
||
/// retention window.
|
||
private func retireRotatedPeer(_ peerID: PeerID) {
|
||
let removed = collectionsQueue.sync(flags: .barrier) {
|
||
peerRegistry.remove(peerID) != nil
|
||
}
|
||
guard removed else { return }
|
||
gossipSyncManager?.removeAnnouncementForPeer(peerID)
|
||
refreshLocalTopology()
|
||
notifyUI { [weak self] in
|
||
guard let self else { return }
|
||
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
|
||
self.deliverTransportEvent(.peerDisconnected(peerID))
|
||
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
||
}
|
||
}
|
||
|
||
/// Builds the announce handler environment. All queue hops stay here so
|
||
/// `BLEAnnounceHandler` remains queue-agnostic and synchronously testable.
|
||
private func makeAnnounceHandlerEnvironment() -> BLEAnnounceHandlerEnvironment {
|
||
BLEAnnounceHandlerEnvironment(
|
||
localPeerID: { [weak self] in
|
||
self?.myPeerID ?? PeerID(str: "")
|
||
},
|
||
messageTTL: messageTTL,
|
||
now: { Date() },
|
||
existingPeerKeys: { [weak self] peerID in
|
||
guard let self = self else { return (nil, nil) }
|
||
return self.collectionsQueue.sync {
|
||
let info = self.peerRegistry.info(for: peerID)
|
||
return (info?.noisePublicKey, info?.signingPublicKey)
|
||
}
|
||
},
|
||
persistedSigningPublicKey: { [weak self] peerID in
|
||
// Same synchronous identity-manager read pattern as
|
||
// signedSenderDisplayName(for:from:); the manager serializes
|
||
// access on its own internal queue.
|
||
guard let self = self else { return nil }
|
||
return self.identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
|
||
.compactMap { $0.signingPublicKey }
|
||
.first
|
||
},
|
||
authenticatedSigningPublicKey: { [weak self] noisePublicKey in
|
||
self?.identityManager.authenticatedSigningPublicKey(
|
||
forFingerprint: noisePublicKey.sha256Fingerprint()
|
||
)
|
||
},
|
||
verifySignature: { [weak self] packet, signingPublicKey in
|
||
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
||
},
|
||
linkState: { [weak self] peerID in
|
||
self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false)
|
||
},
|
||
linkBoundToOtherPeer: { [weak self] packet, peerID in
|
||
// Reads the CURRENT binding — i.e. the state before
|
||
// rebindLinkAfterVerifiedDirectAnnounce (which runs after the
|
||
// handler) may steal the link and promote the new owner to
|
||
// connected. See the caller in BLEAnnounceHandler for why the
|
||
// residual forged-presence window this leaves is accepted.
|
||
guard let self else { return false }
|
||
guard let link = (self.collectionsQueue.sync { self.ingressLinks.link(for: packet) }) else { return false }
|
||
let boundPeerID: PeerID? = self.readLinkState { store in
|
||
switch link {
|
||
case .peripheral(let peripheralUUID):
|
||
return store.peerID(forPeripheralID: peripheralUUID)
|
||
case .central(let centralUUID):
|
||
return store.peerID(forCentralUUID: centralUUID)
|
||
}
|
||
}
|
||
guard let boundPeerID else { return false }
|
||
return boundPeerID != peerID
|
||
},
|
||
withRegistryBarrier: { [weak self] body in
|
||
self?.collectionsQueue.sync(flags: .barrier) { body() }
|
||
},
|
||
upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
|
||
// Called from inside withRegistryBarrier; access registry directly.
|
||
guard let self = self else {
|
||
return BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||
}
|
||
return self.peerRegistry.upsertVerifiedAnnounce(
|
||
peerID: peerID,
|
||
nickname: announcement.nickname,
|
||
noisePublicKey: announcement.noisePublicKey,
|
||
signingPublicKey: announcement.signingPublicKey,
|
||
isConnected: isConnected,
|
||
// Propagate `nil` (registry refused the announce because it
|
||
// carries a signing key different from the pinned one) so
|
||
// the handler's guard rejects it instead of overwriting the
|
||
// pinned identity. Main's capabilities/bridgeGeohash are
|
||
// preserved.
|
||
now: now,
|
||
capabilities: announcement.capabilities,
|
||
bridgeGeohash: announcement.bridgeGeohash
|
||
)
|
||
},
|
||
shouldEmitReconnectLog: { [weak self] peerID, now in
|
||
// Called from inside withRegistryBarrier; access debouncer directly.
|
||
self?.reconnectLogDebouncer.shouldEmit(
|
||
peerID: peerID,
|
||
now: now,
|
||
minimumInterval: TransportConfig.bleReconnectLogDebounceSeconds
|
||
) ?? false
|
||
},
|
||
updateTopology: { [weak self] peerID, neighbors in
|
||
self?.meshTopology.updateNeighbors(for: peerID.routingData, neighbors: neighbors)
|
||
},
|
||
persistIdentity: { [weak self] announcement in
|
||
self?.identityManager.upsertCryptographicIdentity(
|
||
fingerprint: announcement.noisePublicKey.sha256Fingerprint(),
|
||
noisePublicKey: announcement.noisePublicKey,
|
||
signingPublicKey: announcement.signingPublicKey,
|
||
claimedNickname: announcement.nickname
|
||
)
|
||
},
|
||
dedupContains: { [weak self] id in
|
||
self?.messageDeduplicator.contains(id) ?? true
|
||
},
|
||
dedupMarkProcessed: { [weak self] id in
|
||
self?.messageDeduplicator.markProcessed(id)
|
||
},
|
||
deliverAnnounceUIEvents: { [weak self] peerID, notifyPeerConnected, scheduleInitialSync in
|
||
// Single main-actor hop so event order is guaranteed:
|
||
// .peerConnected → initial sync scheduling → .peerListUpdated.
|
||
self?.notifyUI { [weak self] in
|
||
guard let self = self else { return }
|
||
if notifyPeerConnected {
|
||
self.deliverTransportEvent(.peerConnected(peerID))
|
||
}
|
||
if scheduleInitialSync {
|
||
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
|
||
}
|
||
// Get current peer list (after addition)
|
||
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
|
||
self.requestPeerDataPublish()
|
||
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
||
}
|
||
},
|
||
trackPacketSeen: { [weak self] packet in
|
||
self?.gossipSyncManager?.onPublicPacketSeen(packet)
|
||
},
|
||
sendAnnounceBack: { [weak self] in
|
||
self?.sendAnnounce(forceSend: true)
|
||
},
|
||
scheduleAfterglow: { [weak self] delay in
|
||
self?.messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||
self?.sendAnnounce(forceSend: true)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
// MARK: - Board (geohash bulletin board)
|
||
|
||
/// Validates and stores an incoming board post or tombstone. Returns
|
||
/// whether the packet is worth relaying onward.
|
||
private func handleBoardPost(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||
guard let wire = BoardWire.decode(from: packet.payload) else {
|
||
SecureLogger.warning("⚠️ Malformed board packet from \(peerID.id.prefix(8))…", category: .session)
|
||
return false
|
||
}
|
||
// Posts are self-authenticating: the payload embeds the author's
|
||
// Ed25519 key and signature, so verification does not depend on the
|
||
// author still being around to announce.
|
||
guard wire.verifySignature() else {
|
||
if logRateLimiter.shouldLog(key: "board-sig:\(peerID.id)") {
|
||
SecureLogger.warning("🚫 Dropping board packet with invalid signature from \(peerID.id.prefix(8))…", category: .security)
|
||
}
|
||
return false
|
||
}
|
||
switch boardStore.ingest(wire, packet: packet) {
|
||
case .accepted, .duplicate:
|
||
return true
|
||
case .rejected:
|
||
return false
|
||
}
|
||
}
|
||
|
||
/// Broadcasts a pre-signed board payload (post or tombstone) built by the
|
||
/// board manager, and ingests it locally so it shows up on our own board
|
||
/// and joins gossip sync immediately.
|
||
func sendBoardPayload(_ payload: Data) {
|
||
guard let wire = BoardWire.decode(from: payload), wire.verifySignature() else {
|
||
SecureLogger.error("❌ Refusing to send invalid board payload", category: .session)
|
||
return
|
||
}
|
||
messageQueue.async { [weak self] in
|
||
guard let self = self else { return }
|
||
let basePacket = BitchatPacket(
|
||
type: MessageType.boardPost.rawValue,
|
||
senderID: Data(hexString: self.myPeerID.id) ?? Data(),
|
||
recipientID: nil,
|
||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||
payload: payload,
|
||
signature: nil,
|
||
ttl: self.messageTTL
|
||
)
|
||
guard let signedPacket = self.noiseService.signPacket(basePacket) else {
|
||
SecureLogger.error("❌ Failed to sign board packet", category: .security)
|
||
return
|
||
}
|
||
// Pre-mark our own broadcast as processed to avoid handling a relayed self copy
|
||
let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket)
|
||
self.messageDeduplicator.markProcessed(dedupID)
|
||
self.boardStore.ingest(wire, packet: signedPacket)
|
||
self.broadcastPacket(signedPacket)
|
||
}
|
||
}
|
||
|
||
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
|
||
private func handleRequestSync(_ packet: BitchatPacket, from peerID: PeerID) {
|
||
// REQUEST_SYNC is link-local by design (always sent with ttl 0): a
|
||
// nonzero TTL means a crafted or relayed request, and answering one
|
||
// would let a single small packet fan a full store replay out of
|
||
// every node it reaches.
|
||
guard packet.ttl == 0 else {
|
||
if logRateLimiter.shouldLog(key: "sync-ttl:\(peerID.id)") {
|
||
SecureLogger.warning("🚫 Dropping REQUEST_SYNC with nonzero TTL from \(peerID.id.prefix(8))…", category: .security)
|
||
}
|
||
return
|
||
}
|
||
// A response can replay the entire gossip store, so require proof the
|
||
// requester owns the claimed sender ID: the request must verify
|
||
// against the signing key from that peer's announce.
|
||
let signingKey = collectionsQueue.sync { peerRegistry.info(for: peerID)?.signingPublicKey }
|
||
guard let signingKey, noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
|
||
if logRateLimiter.shouldLog(key: "sync-sig:\(peerID.id)") {
|
||
SecureLogger.warning("🚫 Dropping REQUEST_SYNC without verifiable signature from \(peerID.id.prefix(8))…", category: .security)
|
||
}
|
||
return
|
||
}
|
||
guard let req = RequestSyncPacket.decode(from: packet.payload) else {
|
||
SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .session)
|
||
return
|
||
}
|
||
gossipSyncManager?.handleRequestSync(from: peerID, request: req)
|
||
}
|
||
|
||
// Mention parsing moved to ChatViewModel
|
||
|
||
private func handleMessage(_ packet: BitchatPacket, from peerID: PeerID) {
|
||
publicMessageHandler.handle(packet, from: peerID)
|
||
}
|
||
|
||
/// Builds the public-message handler environment. All queue hops stay here
|
||
/// so `BLEPublicMessageHandler` remains queue-agnostic and synchronously
|
||
/// testable.
|
||
private func makePublicMessageHandlerEnvironment() -> BLEPublicMessageHandlerEnvironment {
|
||
BLEPublicMessageHandlerEnvironment(
|
||
localPeerID: { [weak self] in
|
||
self?.myPeerID ?? PeerID(str: "")
|
||
},
|
||
localNickname: { [weak self] in
|
||
self?.myNickname ?? ""
|
||
},
|
||
now: { Date() },
|
||
peersSnapshot: { [weak self] in
|
||
guard let self = self else { return [:] }
|
||
return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
|
||
},
|
||
verifyPacketSignature: { [weak self] packet, signingPublicKey in
|
||
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
||
},
|
||
signedSenderDisplayName: { [weak self] packet, peerID in
|
||
self?.signedSenderDisplayName(for: packet, from: peerID)
|
||
},
|
||
trackPacketSeen: { [weak self] packet in
|
||
self?.gossipSyncManager?.onPublicPacketSeen(packet)
|
||
},
|
||
linkState: { [weak self] peerID in
|
||
self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false)
|
||
},
|
||
takeSelfBroadcastMessageID: { [weak self] packet in
|
||
// Caller is on messageQueue, where the tracker is owned.
|
||
self?.selfBroadcastTracker.takeMessageID(for: packet)
|
||
},
|
||
deliverPublicMessage: { [weak self] peerID, nickname, content, timestamp, messageID in
|
||
// Single main-actor hop delivering `.publicMessageReceived`.
|
||
self?.notifyUI { [weak self] in
|
||
self?.deliverTransportEvent(
|
||
.publicMessageReceived(
|
||
peerID: peerID,
|
||
nickname: nickname,
|
||
content: content,
|
||
timestamp: timestamp,
|
||
messageID: messageID
|
||
)
|
||
)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
/// Group broadcasts are opaque ciphertext to this layer: track them for
|
||
/// gossip backfill and hand the payload to the UI layer, where the group
|
||
/// coordinator decrypts and authenticates against the roster. Non-members
|
||
/// still relay (generic broadcast relay path) but never decode.
|
||
private func handleGroupMessage(_ packet: BitchatPacket, from _: PeerID) {
|
||
let isBroadcastRecipient: Bool = {
|
||
guard let recipient = packet.recipientID else { return true }
|
||
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
|
||
}()
|
||
guard isBroadcastRecipient, !packet.payload.isEmpty else { return }
|
||
|
||
gossipSyncManager?.onPublicPacketSeen(packet)
|
||
|
||
let payload = packet.payload
|
||
let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
|
||
notifyUI { [weak self] in
|
||
self?.deliverTransportEvent(.groupMessageReceived(payload: payload, timestamp: timestamp))
|
||
}
|
||
}
|
||
|
||
/// Inbound public live-voice packet: broadcast-only, freshness-gated, and
|
||
/// signature-verified against the claimed sender's announce (mirrors the
|
||
/// public-message identity gate — `senderID` is attacker-controlled, so a
|
||
/// valid packet signature is required before any audio reaches the UI).
|
||
/// Returns whether the packet was accepted; rejected packets must not be
|
||
/// relayed either, or spoofed 0x29 floods would still amplify.
|
||
private func handleVoiceFrame(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||
guard peerID != myPeerID else { return false }
|
||
guard BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID) else { return false }
|
||
guard !BLEPacketFreshnessPolicy.isStale(
|
||
timestampMilliseconds: packet.timestamp,
|
||
now: Date(),
|
||
maxAgeSeconds: TransportConfig.pttPublicFrameMaxAgeSeconds
|
||
) else { return false }
|
||
|
||
let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID }
|
||
let registrySigningKey = peersSnapshot[peerID]?.signingPublicKey
|
||
let verifiedViaRegistry = registrySigningKey.map { noiseService.verifyPacketSignature(packet, publicKey: $0) } ?? false
|
||
let signedDisplayName = verifiedViaRegistry ? nil : signedSenderDisplayName(for: packet, from: peerID)
|
||
guard verifiedViaRegistry || signedDisplayName != nil else {
|
||
SecureLogger.warning("🚫 Dropping voice frame with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…", category: .security)
|
||
return false
|
||
}
|
||
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||
peerID: peerID,
|
||
localPeerID: myPeerID,
|
||
localNickname: myNickname,
|
||
peers: peersSnapshot,
|
||
allowConnectedUnverified: false
|
||
) ?? signedDisplayName else {
|
||
return false
|
||
}
|
||
|
||
let payload = packet.payload
|
||
let timestamp = Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000)
|
||
notifyUI { [weak self] in
|
||
self?.deliverTransportEvent(.publicVoiceFrameReceived(
|
||
peerID: peerID,
|
||
nickname: senderNickname,
|
||
payload: payload,
|
||
timestamp: timestamp
|
||
))
|
||
}
|
||
return true
|
||
}
|
||
|
||
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||
let result = noisePacketHandler.handleHandshakeWithResult(
|
||
packet,
|
||
from: peerID
|
||
)
|
||
// An inbound message 1 quarantines the old transport receive-only.
|
||
// Keep its generation-bound BLE state intact: the manager's new
|
||
// handshaking generation already gates every outbound policy, while
|
||
// a rollback can become ready again without repeating capability
|
||
// proof or announce side effects. Only the exact handshake candidate's
|
||
// authenticated completion may promote the physical ingress link.
|
||
if result.didEstablishAuthenticatedSession {
|
||
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
|
||
}
|
||
}
|
||
|
||
private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
||
noisePacketHandler.handleEncrypted(packet, from: peerID)
|
||
}
|
||
|
||
/// Builds the Noise packet handler environment. All queue hops and
|
||
/// `noiseService` crypto calls stay here so `BLENoisePacketHandler`
|
||
/// remains queue-agnostic and synchronously testable.
|
||
private func makeNoisePacketHandlerEnvironment() -> BLENoisePacketHandlerEnvironment {
|
||
BLENoisePacketHandlerEnvironment(
|
||
localPeerID: { [weak self] in
|
||
self?.myPeerID ?? PeerID(str: "")
|
||
},
|
||
localPeerIDData: { [weak self] in
|
||
self?.myPeerIDData ?? Data()
|
||
},
|
||
messageTTL: messageTTL,
|
||
now: { Date() },
|
||
processHandshakeMessage: { [weak self] peerID, message in
|
||
guard let self else {
|
||
return NoiseHandshakeProcessingResult(
|
||
response: nil,
|
||
didEstablishAuthenticatedSession: false
|
||
)
|
||
}
|
||
return try self.noiseService.processHandshakeMessageWithResult(
|
||
from: peerID,
|
||
message: message
|
||
)
|
||
},
|
||
hasNoiseSession: { [weak self] peerID in
|
||
self?.noiseService.hasSession(with: peerID) ?? false
|
||
},
|
||
isAwaitingResponderHandshakeCompletion: { [weak self] peerID in
|
||
self?.noiseService.isAwaitingResponderHandshakeCompletion(
|
||
with: peerID
|
||
) ?? false
|
||
},
|
||
initiateHandshake: { [weak self] peerID in
|
||
self?.initiateNoiseHandshake(with: peerID)
|
||
},
|
||
broadcastPacket: { [weak self] packet in
|
||
self?.broadcastPacket(packet)
|
||
},
|
||
updatePeerLastSeen: { [weak self] peerID in
|
||
self?.updatePeerLastSeen(peerID)
|
||
},
|
||
decrypt: { [weak self] payload, peerID in
|
||
guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished }
|
||
let result = try self.noiseService.decryptWithSessionGeneration(
|
||
payload,
|
||
from: peerID,
|
||
establishedGenerationIsReady: { generation in
|
||
self.collectionsQueue.sync {
|
||
self.privateMediaSessionGenerations[
|
||
peerID.toShort()
|
||
] == generation
|
||
}
|
||
}
|
||
)
|
||
return BLENoiseDecryptionResult(
|
||
plaintext: result.plaintext,
|
||
sessionGeneration: result.sessionGeneration
|
||
)
|
||
},
|
||
clearSession: { [weak self] peerID in
|
||
self?.clearNoiseSession(for: peerID)
|
||
},
|
||
handleAuthenticatedPeerState: { [weak self] peerID, payload, generation in
|
||
self?.handleAuthenticatedPeerState(
|
||
payload,
|
||
from: peerID,
|
||
sessionGeneration: generation
|
||
)
|
||
},
|
||
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
|
||
if type == .privateFile {
|
||
self?.fileTransferHandler.handlePrivatePayload(
|
||
payload,
|
||
from: peerID,
|
||
timestamp: timestamp
|
||
)
|
||
return
|
||
}
|
||
// Single main-actor hop delivering `.noisePayloadReceived`.
|
||
self?.notifyUI { [weak self] in
|
||
self?.deliverTransportEvent(.noisePayloadReceived(
|
||
peerID: peerID,
|
||
type: type,
|
||
payload: payload,
|
||
timestamp: timestamp
|
||
))
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
// MARK: Helper Functions
|
||
|
||
private func sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) {
|
||
let payloads = collectionsQueue.sync(flags: .barrier) { () -> [BLEPendingTypedPayload] in
|
||
pendingNoiseSessionQueues.takeTypedPayloads(for: peerID)
|
||
}
|
||
guard !payloads.isEmpty else { return }
|
||
SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID.id.prefix(8))… after handshake", category: .session)
|
||
for pending in payloads {
|
||
let isPrivateMedia = NoisePayloadType.isPrivateFile(rawValue: pending.payload.first)
|
||
let privateMediaTransferId = isPrivateMedia ? pending.transferId : nil
|
||
|
||
if isPrivateMedia {
|
||
switch privateMediaSendPolicy(to: peerID) {
|
||
case .encrypted:
|
||
break
|
||
|
||
case .awaitingCapabilityProof:
|
||
// Handshake completion alone is insufficient. Put the
|
||
// exact payload back until authenticated 0x21 state
|
||
// arrives; that handler calls this drain again.
|
||
collectionsQueue.sync(flags: .barrier) {
|
||
pendingNoiseSessionQueues.appendTypedPayload(
|
||
pending.payload,
|
||
transferId: pending.transferId,
|
||
for: peerID
|
||
)
|
||
}
|
||
continue
|
||
|
||
case .legacyRequiresConsent, .blockedDowngrade:
|
||
if let transferId = pending.transferId {
|
||
TransferProgressManager.shared.rejectBeforeStart(
|
||
id: transferId,
|
||
reason: String(
|
||
localized: "content.delivery.reason.private_media_capability_unresolved",
|
||
defaultValue: "Could not confirm encrypted media support",
|
||
comment: "Failure reason when queued private media cannot be authenticated after handshake"
|
||
)
|
||
)
|
||
privateMediaTransferAdmissions.finish(transferId)
|
||
}
|
||
continue
|
||
}
|
||
}
|
||
if let transferId = privateMediaTransferId,
|
||
!privateMediaTransferAdmissions.isActive(transferId) {
|
||
privateMediaTransferAdmissions.finish(transferId)
|
||
continue
|
||
}
|
||
do {
|
||
if let transferId = privateMediaTransferId,
|
||
!privateMediaTransferAdmissions.isActive(transferId) {
|
||
privateMediaTransferAdmissions.finish(transferId)
|
||
continue
|
||
}
|
||
let packet = try makeEncryptedNoisePacket(pending.payload, to: peerID)
|
||
if let transferId = privateMediaTransferId,
|
||
!privateMediaTransferAdmissions.isActive(transferId) {
|
||
privateMediaTransferAdmissions.finish(transferId)
|
||
continue
|
||
}
|
||
broadcastPacket(
|
||
packet,
|
||
transferId: pending.transferId,
|
||
requiresPrivateMediaAdmission: privateMediaTransferId != nil
|
||
)
|
||
} catch {
|
||
SecureLogger.error("❌ Failed to send pending noise payload to \(peerID.id.prefix(8))…: \(error)")
|
||
if let transferId = pending.transferId {
|
||
TransferProgressManager.shared.rejectBeforeStart(
|
||
id: transferId,
|
||
reason: String(
|
||
localized: "content.delivery.reason.encryption_failed",
|
||
defaultValue: "Failed to encrypt media",
|
||
comment: "Failure reason shown when queued private media cannot be encrypted after handshake"
|
||
)
|
||
)
|
||
privateMediaTransferAdmissions.finish(transferId)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func updatePeerLastSeen(_ peerID: PeerID) {
|
||
// Use async to avoid deadlock - we don't need immediate consistency for last seen updates
|
||
collectionsQueue.async(flags: .barrier) {
|
||
self.peerRegistry.updateLastSeen(peerID, at: Date())
|
||
}
|
||
}
|
||
|
||
// Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window
|
||
@MainActor
|
||
private func notifyPeerDisconnectedDebounced(_ peerID: PeerID) {
|
||
if disconnectNotifyDebouncer.shouldEmit(
|
||
peerID: peerID,
|
||
now: Date(),
|
||
minimumInterval: TransportConfig.bleDisconnectNotifyDebounceSeconds
|
||
) {
|
||
deliverTransportEvent(.peerDisconnected(peerID))
|
||
}
|
||
}
|
||
|
||
// NEW: Publish peer snapshots to subscribers and notify Transport delegates
|
||
private func publishFullPeerData() {
|
||
let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync {
|
||
peerRegistry.transportSnapshots(selfNickname: myNickname)
|
||
}
|
||
notifyUI { [weak self] in
|
||
self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers)
|
||
}
|
||
}
|
||
|
||
// MARK: Consolidated Maintenance
|
||
|
||
private func performMaintenance() {
|
||
guard !isPanicSuspended else { return }
|
||
maintenanceCounter += 1
|
||
lastMaintenanceAt = Date()
|
||
|
||
let now = Date()
|
||
let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount }
|
||
let elapsed = announceThrottle.elapsed(since: now)
|
||
let recentSeen = collectionsQueue.sync { () -> Bool in
|
||
recentTrafficTracker.hasTraffic(within: 5.0, now: now)
|
||
}
|
||
let hasNoPeers = collectionsQueue.sync { peerRegistry.isEmpty }
|
||
let plan = BLEMaintenancePolicy.plan(
|
||
cycle: maintenanceCounter,
|
||
connectedCount: connectedCount,
|
||
peerRegistryIsEmpty: hasNoPeers,
|
||
elapsedSinceLastAnnounce: elapsed,
|
||
hasRecentTraffic: recentSeen
|
||
)
|
||
|
||
if plan.shouldSendAnnounce {
|
||
sendAnnounce(forceSend: true)
|
||
}
|
||
|
||
if plan.shouldEnsureAdvertising {
|
||
// Ensure we're advertising as peripheral
|
||
if let pm = peripheralManager, pm.state == .poweredOn && !pm.isAdvertising {
|
||
pm.startAdvertising(buildAdvertisementData())
|
||
}
|
||
}
|
||
|
||
// Update scanning duty-cycle based on connectivity
|
||
updateScanningDutyCycle(connectedCount: connectedCount)
|
||
updateRSSIThreshold(connectedCount: connectedCount)
|
||
|
||
// Drain the connection candidate queue. Weak-RSSI discoveries are
|
||
// enqueued rather than connected immediately, and the event-driven
|
||
// drains (disconnect/failure/timeout) never fire when we're idle —
|
||
// without this, an isolated node surrounded only by weak (distant)
|
||
// peers would queue them all and never connect to anyone.
|
||
tryConnectFromQueue()
|
||
|
||
// Check peer connectivity every cycle for snappier UI updates
|
||
checkPeerConnectivity()
|
||
|
||
// Every 30 seconds (3 cycles): Cleanup
|
||
if plan.shouldRunCleanup {
|
||
performCleanup()
|
||
}
|
||
|
||
// Attempt to flush any spooled directed messages periodically (~every 5 seconds)
|
||
if plan.shouldFlushDirectedSpool {
|
||
flushDirectedSpool()
|
||
}
|
||
|
||
// Periodically attempt to drain pending notifications and writes as backup
|
||
// in case callbacks are missed or delayed (every maintenance cycle = 5 seconds)
|
||
drainPendingNotificationsIfPossible()
|
||
drainAllPendingWrites()
|
||
|
||
// No rotating alias: nothing to refresh
|
||
|
||
// Reset counter to prevent overflow (every 60 seconds)
|
||
if plan.shouldResetCounter {
|
||
maintenanceCounter = 0
|
||
}
|
||
}
|
||
|
||
#if os(iOS)
|
||
/// Catch-up maintenance for background wake windows (bleQueue-confined).
|
||
/// Rate-limited to the normal maintenance cadence so a burst of inbound
|
||
/// packets during one wake still runs at most one extra pass.
|
||
private func performBackgroundWakeMaintenanceIfStale() {
|
||
guard meshBackgroundEnabled,
|
||
!isAppActive,
|
||
Date().timeIntervalSince(lastMaintenanceAt) >= TransportConfig.bleMaintenanceInterval else { return }
|
||
performMaintenance()
|
||
}
|
||
#endif
|
||
|
||
private func checkPeerConnectivity() {
|
||
let now = Date()
|
||
let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { peerRegistry.peerIDs }
|
||
var cachedLinkStates: [PeerID: BLEPeerLinkPresence] = [:]
|
||
for peerID in peerIDsForLinkState {
|
||
let state = linkState(for: peerID)
|
||
cachedLinkStates[peerID] = BLEPeerLinkPresence(
|
||
hasPeripheral: state.hasPeripheral,
|
||
hasCentral: state.hasCentral
|
||
)
|
||
}
|
||
|
||
let changes = collectionsQueue.sync(flags: .barrier) {
|
||
peerRegistry.reconcileConnectivity(now: now, linkStates: cachedLinkStates)
|
||
}
|
||
for removedPeer in changes.removedPeers {
|
||
SecureLogger.debug("🗑️ Removing stale peer after reachability window: \(removedPeer.peerID.id.prefix(8))… (\(removedPeer.nickname))", category: .session)
|
||
gossipSyncManager?.removeAnnouncementForPeer(removedPeer.peerID)
|
||
}
|
||
|
||
// Update UI if there were direct disconnections or offline removals
|
||
if !changes.disconnectedPeerIDs.isEmpty || !changes.removedPeers.isEmpty {
|
||
notifyUI { [weak self] in
|
||
guard let self else { return }
|
||
|
||
// Get current peer list (after removal)
|
||
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
|
||
|
||
for peerID in changes.disconnectedPeerIDs {
|
||
self.deliverTransportEvent(.peerDisconnected(peerID))
|
||
}
|
||
// Publish snapshots so UnifiedPeerService updates connection/reachability icons
|
||
self.requestPeerDataPublish()
|
||
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
||
}
|
||
}
|
||
|
||
// Refresh local topology to keep our own entry fresh and sync any changes
|
||
refreshLocalTopology()
|
||
// Prune stale topology nodes (using safe retention window)
|
||
meshTopology.prune(olderThan: 60.0)
|
||
}
|
||
|
||
private func performCleanup() {
|
||
let now = Date()
|
||
|
||
// Admission expiry is a visible transfer failure, never a silent
|
||
// eviction. The registry delivers notifications after releasing its
|
||
// lock, so this maintenance pass cannot deadlock a concurrent cancel.
|
||
privateMediaTransferAdmissions.prune(now: now)
|
||
|
||
// Clean old processed messages efficiently
|
||
messageDeduplicator.cleanup()
|
||
|
||
// Clean old fragments (> configured seconds old), then ask peers for
|
||
// the specific fragment streams whose reassembly has stalled instead
|
||
// of waiting for the next periodic GCS fragment round.
|
||
let stalledFragmentIDs = collectionsQueue.sync(flags: .barrier) { () -> [Data] in
|
||
let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds)
|
||
fragmentAssemblyBuffer.removeExpired(before: cutoff)
|
||
sourceRouteFailures.prune(now: now)
|
||
return fragmentAssemblyBuffer.stalledBroadcastFragmentIDs(
|
||
stalledAfter: TransportConfig.bleFragmentResyncStallSeconds,
|
||
retryAfter: TransportConfig.bleFragmentResyncRetrySeconds,
|
||
now: now
|
||
)
|
||
}
|
||
if !stalledFragmentIDs.isEmpty {
|
||
gossipSyncManager?.requestMissingFragments(fragmentIDs: stalledFragmentIDs)
|
||
}
|
||
|
||
// Clean old connection timeout backoff entries (> window)
|
||
let timeoutCutoff = now.addingTimeInterval(-TransportConfig.bleConnectTimeoutBackoffWindowSeconds)
|
||
connectionScheduler.pruneConnectionTimeouts(before: timeoutCutoff)
|
||
|
||
// Clean up stale scheduled relays that somehow persisted (> 2s)
|
||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||
guard let self = self else { return }
|
||
// Nothing to compare times to; just cap the size defensively
|
||
self.scheduledRelays.removeAllIfOverCapacity(512)
|
||
}
|
||
|
||
// Clean ingress link records older than configured seconds
|
||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||
guard let self = self else { return }
|
||
let cutoff = now.addingTimeInterval(-TransportConfig.bleIngressRecordLifetimeSeconds)
|
||
if !self.ingressLinks.isEmpty {
|
||
self.ingressLinks.prune(before: cutoff)
|
||
}
|
||
// Clean expired directed spooled items
|
||
self.pendingDirectedRelays.pruneExpired(
|
||
now: now,
|
||
window: TransportConfig.bleDirectedSpoolWindowSeconds
|
||
)
|
||
}
|
||
|
||
messageQueue.async(flags: .barrier) { [weak self] in
|
||
guard let self = self else { return }
|
||
guard !self.selfBroadcastTracker.isEmpty else { return }
|
||
let cutoff = now.addingTimeInterval(-TransportConfig.messageDedupMaxAgeSeconds)
|
||
self.selfBroadcastTracker.prune(before: cutoff)
|
||
}
|
||
}
|
||
|
||
private func updateScanningDutyCycle(connectedCount: Int) {
|
||
guard let central = centralManager, central.state == .poweredOn else { return }
|
||
// Duty cycle only when app is active and at least one peer connected
|
||
#if os(iOS)
|
||
let active = isAppActive
|
||
#else
|
||
let active = true
|
||
#endif
|
||
// Force full-time scanning if we have very few neighbors or very recent traffic
|
||
let hasRecentTraffic: Bool = collectionsQueue.sync {
|
||
recentTrafficTracker.hasTraffic(
|
||
within: TransportConfig.bleRecentTrafficForceScanSeconds,
|
||
now: Date()
|
||
)
|
||
}
|
||
let scanPlan = BLEScanDutyPolicy.plan(
|
||
dutyEnabled: dutyEnabled,
|
||
appIsActive: active,
|
||
connectedCount: connectedCount,
|
||
hasRecentTraffic: hasRecentTraffic
|
||
)
|
||
|
||
switch scanPlan {
|
||
case .dutyCycle(let onDuration, let offDuration):
|
||
let durationsChanged = dutyOnDuration != onDuration || dutyOffDuration != offDuration
|
||
dutyOnDuration = onDuration
|
||
dutyOffDuration = offDuration
|
||
|
||
if scanDutyTimer == nil {
|
||
// Start timer to toggle scanning on/off
|
||
let t = DispatchSource.makeTimerSource(queue: bleQueue)
|
||
// Start with scanning ON; we'll turn OFF after onDuration
|
||
if !central.isScanning { startScanning() }
|
||
dutyActive = true
|
||
t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration)
|
||
t.setEventHandler { [weak self] in
|
||
guard let self = self, let c = self.centralManager else { return }
|
||
if self.dutyActive {
|
||
// Turn OFF scanning for offDuration
|
||
if c.isScanning { c.stopScan() }
|
||
self.dutyActive = false
|
||
// Schedule turning back ON after offDuration
|
||
self.bleQueue.asyncAfter(deadline: .now() + self.dutyOffDuration) {
|
||
if self.centralManager?.state == .poweredOn { self.startScanning() }
|
||
self.dutyActive = true
|
||
}
|
||
}
|
||
}
|
||
t.resume()
|
||
scanDutyTimer = t
|
||
} else if durationsChanged {
|
||
scanDutyTimer?.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration)
|
||
if !central.isScanning { startScanning() }
|
||
dutyActive = true
|
||
}
|
||
case .continuous:
|
||
// Cancel duty cycle and ensure scanning is ON for discovery
|
||
scanDutyTimer?.cancel()
|
||
scanDutyTimer = nil
|
||
if !central.isScanning { startScanning() }
|
||
}
|
||
}
|
||
|
||
private func updateRSSIThreshold(connectedCount: Int) {
|
||
connectionScheduler.updateRSSIThreshold(
|
||
connectedCount: connectedCount,
|
||
connectedOrConnectingLinkCount: linkStateStore.connectedOrConnectingPeripheralCount,
|
||
now: Date()
|
||
)
|
||
}
|
||
}
|