import BitFoundation import BitLogger import Foundation /// Narrow environment for `BLEAnnounceHandler`. /// /// All queue hops (collections barrier, BLE-queue link-state reads, main-actor /// UI notification, delayed re-announce) live inside the closures supplied by /// `BLEService`, keeping the handler queue-agnostic and synchronously testable. struct BLEAnnounceHandlerEnvironment { /// Local peer identity at the time the announce is handled. let localPeerID: () -> PeerID /// TTL value used for direct (non-relayed) packets. let messageTTL: UInt8 /// Current time source. let now: () -> Date /// Noise public key already recorded for the peer, if any (registry read). let existingNoisePublicKey: (PeerID) -> Data? /// Ed25519 key previously bound to this Noise identity by an authenticated /// peer-state payload, if any (persistent identity-state read). let authenticatedSigningPublicKey: (_ noisePublicKey: Data) -> Data? /// Verifies the packet signature against the announced signing key. let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool /// Direct link state for the peer (BLE-queue read). let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) /// Whether the link this packet arrived on is already bound to a /// different peer ID (ingress-registry + BLE-queue read). Directness /// rides on the unsigned TTL, so a replayed announce can look "direct" /// on the replayer's link; that link must not shortcut an absent peer /// into "connected". let linkBoundToOtherPeer: (_ packet: BitchatPacket, _ peerID: PeerID) -> Bool /// Runs the registry mutation phase under the collections barrier. let withRegistryBarrier: (() -> Void) -> Void /// Upserts the verified announce into the peer registry. /// Must only be called from inside `withRegistryBarrier`. let upsertVerifiedAnnounce: ( _ peerID: PeerID, _ announcement: AnnouncementPacket, _ isConnected: Bool, _ now: Date ) -> BLEPeerAnnounceUpdate /// Debounced reconnect-log decision. /// Must only be called from inside `withRegistryBarrier`. let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool /// Records verified direct-neighbor claims in the mesh topology. let updateTopology: (_ peerID: PeerID, _ neighbors: [Data]) -> Void /// Persists the announced cryptographic identity for offline verification. let persistIdentity: (AnnouncementPacket) -> Void /// Announce-back dedup check. let dedupContains: (String) -> Bool /// Announce-back dedup marking. let dedupMarkProcessed: (String) -> Void /// Delivers the announce UI events as one ordered main-actor hop: /// `.peerConnected` (if flagged) → initial gossip sync scheduling (if /// flagged) → peer-ID snapshot + data publish + `.peerListUpdated`. /// A single closure keeps the original in-order delivery guarantee that /// separate unstructured tasks would not provide. let deliverAnnounceUIEvents: ( _ peerID: PeerID, _ notifyPeerConnected: Bool, _ scheduleInitialSync: Bool ) -> Void /// Tracks the announce packet for gossip sync. let trackPacketSeen: (BitchatPacket) -> Void /// Reciprocates the announce for bidirectional discovery. let sendAnnounceBack: () -> Void /// Schedules a delayed re-announce (afterglow) after the given delay. let scheduleAfterglow: (TimeInterval) -> Void } /// Outcome of an accepted announce, surfaced so the service can run /// follow-up work (e.g. courier handover) that keys off the announce. struct BLEAnnounceHandlingResult { let peerID: PeerID let announcement: AnnouncementPacket let isDirectAnnounce: Bool let isVerified: Bool } /// Orchestrates inbound announce packets: preflight validation, signature /// trust, registry/topology updates, identity persistence, UI notification, /// gossip tracking, and the reciprocal announce response. final class BLEAnnounceHandler { private let environment: BLEAnnounceHandlerEnvironment init(environment: BLEAnnounceHandlerEnvironment) { self.environment = environment } @discardableResult func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? { let env = environment let now = env.now() let preflight = BLEAnnouncePreflightPolicy.evaluate( packet: packet, from: peerID, localPeerID: env.localPeerID(), now: now ) let announcement: AnnouncementPacket switch preflight { case .accept(let acceptance): announcement = acceptance.announcement case .reject(.malformed): SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session) return nil case .reject(.senderMismatch(let derivedFromKey)): SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security) return nil case .reject(.selfAnnounce): return nil case .reject(.stale(let ageSeconds)): SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session) return nil } // Suppress announce logs to reduce noise // Precompute signature verification outside barrier to reduce contention let existingNoisePublicKey = env.existingNoisePublicKey(peerID) let hasSignature = packet.signature != nil let signatureValid: Bool if hasSignature { signatureValid = env.verifySignature(packet, announcement.signingPublicKey) if !signatureValid { SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.id.prefix(8))", category: .security) } } else { signatureValid = false } let trustDecision = BLEAnnounceTrustPolicy.evaluate( hasSignature: hasSignature, signatureValid: signatureValid, existingNoisePublicKey: existingNoisePublicKey, announcedNoisePublicKey: announcement.noisePublicKey, authenticatedSigningPublicKey: env.authenticatedSigningPublicKey( announcement.noisePublicKey ), announcedSigningPublicKey: announcement.signingPublicKey ) if case .reject(.keyMismatch) = trustDecision { SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security) } if case .reject(.authenticatedSigningKeyMismatch) = trustDecision { SecureLogger.warning( "⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))…", category: .security ) } let verifiedAnnounce = trustDecision.isVerified var isNewPeer = false var isReconnectedPeer = false let directLinkState = env.linkState(peerID) let isDirectAnnounce = packet.ttl == env.messageTTL // A "direct" announce arriving on a link that another peer already // owns is either a rotation heal or a replay with its TTL restored; // both are ambiguous, so only the rebind (which containment-checks // the claimed identity) may promote it — never this shortcut. // // Known limitation: denying the shortcut cannot prevent forged // presence outright. A rebind that passes the containment checks // promotes the claimed peer to connected — it must, or a legitimate // rotation on an open link would read as disconnected — so a replay // that wins the rebind (absent victim, cooldown clear) still forges // presence. That residue is presence display only: DMs stay gated on // canDeliverSecurely (no Noise session means retain + courier, see // MessageRouter.sendPrivate). What this check buys: the ambiguous // announce alone never flips presence — forging requires winning the // containment-checked rebind (never steals an identity that owns a // live link; at most one rebind per link per cooldown window). let linkBoundToOtherPeer = isDirectAnnounce && env.linkBoundToOtherPeer(packet, peerID) env.withRegistryBarrier { let hasPeripheralConnection = directLinkState.hasPeripheral let hasCentralSubscription = directLinkState.hasCentral // Require verified announce; ignore otherwise (no backward compatibility) if !verifiedAnnounce { SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.id.prefix(8))…", category: .security) // Reset flags to prevent post-barrier code from acting on unverified announces isNewPeer = false isReconnectedPeer = false return } let update = env.upsertVerifiedAnnounce( peerID, announcement, hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer), now ) isNewPeer = update.isNewPeer isReconnectedPeer = update.wasDisconnected // Log connection status only for direct connectivity changes; debounce to reduce spam if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription { let now = env.now() if update.isNewPeer { SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session) } else if update.wasDisconnected { if env.shouldEmitReconnectLog(peerID, now) { SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session) } } else if let previousNickname = update.previousNickname, previousNickname != announcement.nickname { SecureLogger.debug("🔄 Peer \(peerID.id.prefix(8))… changed nickname: \(previousNickname) -> \(announcement.nickname)", category: .session) } } } // Update topology with verified neighbor claims (only for authenticated announces) if verifiedAnnounce, let neighbors = announcement.directNeighbors { env.updateTopology(peerID, neighbors) } // Persist cryptographic identity and signing key for robust offline // verification — only for verified announces. Persisting unverified // announces would let an attacker who replays a victim's noisePublicKey // overwrite the victim's stored signing key/nickname (identity poisoning). if verifiedAnnounce { env.persistIdentity(announcement) } let announceBackID = "announce-back-\(peerID)" let shouldSendBack = !env.dedupContains(announceBackID) if shouldSendBack { env.dedupMarkProcessed(announceBackID) } let responsePlan = BLEAnnounceResponsePolicy.plan( isDirectAnnounce: isDirectAnnounce, isNewPeer: isNewPeer, isReconnectedPeer: isReconnectedPeer, shouldSendAnnounceBack: shouldSendBack ) // Only notify of connection for new or reconnected peers when it is a // direct announce; the list update always follows in the same hop. env.deliverAnnounceUIEvents( peerID, responsePlan.shouldNotifyPeerConnected, responsePlan.shouldNotifyPeerConnected && responsePlan.shouldScheduleInitialSync ) // Track for sync (include our own and others' announces) env.trackPacketSeen(packet) if responsePlan.shouldSendAnnounceBack { // Reciprocate announce for bidirectional discovery // Force send to ensure the peer receives our announce env.sendAnnounceBack() } // Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop if responsePlan.shouldScheduleAfterglow { let delay = Double.random(in: 0.3...0.6) env.scheduleAfterglow(delay) } return BLEAnnounceHandlingResult( peerID: peerID, announcement: announcement, isDirectAnnounce: isDirectAnnounce, isVerified: verifiedAnnounce ) } }