Extract BLE packet handlers and migrate coordinators to narrow contexts

BLEService's per-packet-type orchestration moves into owned, tested
components (BLEAnnounceHandler, BLEPublicMessageHandler,
BLENoisePacketHandler, BLEFileTransferHandler, BLEFragmentHandler),
each taking an environment struct of closures so every queue hop stays
in BLEService and the handlers are synchronously testable. Behavior is
preserved verbatim, including Noise session recovery on decrypt failure
and single-block UI event ordering. handleLeave/handleRequestSync stay
in place as already-thin delegations. BLEService drops to 3393 lines.

Four coordinators (delivery, private conversation, Nostr, public
conversation) drop their unowned/weak ChatViewModel back-references for
narrow @MainActor context protocols, with ChatViewModel conformances as
single shared witnesses for overlapping members. Their true coupling is
now an explicit, reviewable surface, and each gains a mock-context test
suite covering flows previously testable only through the full view
model. Delivery/read acks now also clear the router's retained-send
outbox via the delivery context.

New LargeTopologyTests exercise production-shaped meshes with the
in-memory harness: an 8-peer relay chain with per-hop TTL decay, a
14-peer cyclic mesh with exactly-once delivery, partition/heal, and
topology churn.

App-layer runtime/model files updated alongside.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-10 16:22:52 +01:00
co-authored by Claude Fable 5
parent 6bda919dd4
commit 3a995e20b6
32 changed files with 5685 additions and 953 deletions
@@ -0,0 +1,209 @@
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?
/// 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)
/// 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
}
/// 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
}
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
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
case .reject(.senderMismatch(let derivedFromKey)):
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security)
return
case .reject(.selfAnnounce):
return
case .reject(.stale(let ageSeconds)):
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
return
}
// 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
)
if case .reject(.keyMismatch) = trustDecision {
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
}
let verifiedAnnounce = trustDecision.isVerified
var isNewPeer = false
var isReconnectedPeer = false
let directLinkState = env.linkState(peerID)
let isDirectAnnounce = packet.ttl == env.messageTTL
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,
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
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
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)
}
}
}
@@ -0,0 +1,123 @@
import BitFoundation
import BitLogger
import Foundation
/// Narrow environment for `BLEFileTransferHandler`.
///
/// All queue hops (collections registry reads/writes, main-actor UI
/// notification) live inside the closures supplied by `BLEService`, keeping
/// the handler queue-agnostic and synchronously testable.
struct BLEFileTransferHandlerEnvironment {
/// Local peer identity at the time the transfer is handled.
let localPeerID: () -> PeerID
/// Local nickname used for sender resolution and collision checks.
let localNickname: () -> String
/// Snapshot of known peers keyed by ID (registry read).
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
/// Resolves a display name from a verified packet signature for peers missing from the registry.
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
/// Tracks the broadcast file packet for gossip sync.
let trackPacketSeen: (BitchatPacket) -> Void
/// Enforces the incoming-media storage quota before saving (BCH-01-002).
let enforceStorageQuota: (_ reservingBytes: Int) -> Void
/// Persists the validated file to the incoming-media store; returns the destination URL.
let saveIncomingFile: (
_ data: Data,
_ preferredName: String?,
_ subdirectory: String,
_ fallbackExtension: String?,
_ defaultPrefix: String
) -> URL?
/// Updates the registry last-seen timestamp for the peer (async barrier write).
let updatePeerLastSeen: (PeerID) -> Void
/// Delivers `.messageReceived` to the UI as one main-actor hop.
let deliverMessage: (BitchatMessage) -> Void
}
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
/// resolution, delivery planning, payload validation, quota-checked storage,
/// and UI delivery.
final class BLEFileTransferHandler {
private let environment: BLEFileTransferHandlerEnvironment
init(environment: BLEFileTransferHandlerEnvironment) {
self.environment = environment
}
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
let peersSnapshot = env.peersSnapshot()
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peersSnapshot,
allowConnectedUnverified: true
) ?? env.signedSenderDisplayName(packet, peerID) else {
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return
}
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
return
}
if deliveryPlan.shouldTrackForSync {
env.trackPacketSeen(packet)
}
let filePacket: BitchatFilePacket
let mime: MimeType
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
case .success(let acceptance):
filePacket = acceptance.filePacket
mime = acceptance.mime
case .failure(.malformedPayload):
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
return
case .failure(.payloadTooLarge(let bytes)):
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
return
case .failure(.unsupportedMime(let mimeType, let bytes)):
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
return
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
return
}
// BCH-01-002: Enforce storage quota before saving
env.enforceStorageQuota(filePacket.content.count)
guard let destination = env.saveIncomingFile(
filePacket.content,
filePacket.fileName,
"\(mime.category.mediaDir)/incoming",
mime.defaultExtension,
mime.category.rawValue
) else {
return
}
if deliveryPlan.isPrivateMessage {
env.updatePeerLastSeen(peerID)
}
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
let message = BitchatMessage(
sender: senderNickname,
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
timestamp: ts,
isRelay: false,
originalSender: nil,
isPrivate: deliveryPlan.isPrivateMessage,
recipientNickname: nil,
senderPeerID: peerID
)
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
env.deliverMessage(message)
}
}
@@ -0,0 +1,94 @@
import BitFoundation
import BitLogger
import Foundation
/// Narrow environment for `BLEFragmentHandler`.
///
/// All queue hops (the message-queue entry hop and the collections barrier
/// around the assembly buffer) live on the `BLEService` side the entry hop
/// in `BLEService.handleFragment`, the barrier inside the supplied closures
/// keeping the handler queue-agnostic and synchronously testable.
struct BLEFragmentHandlerEnvironment {
/// Local peer identity at the time the fragment is handled.
let localPeerID: () -> PeerID
/// Tracks broadcast fragments for gossip sync.
let trackPacketSeen: (BitchatPacket) -> Void
/// Appends the fragment to the assembly buffer (collections barrier write).
let appendFragment: (BLEFragmentHeader) -> BLEFragmentAssemblyBuffer.AppendResult
/// Ingress acceptance check for the reassembled inner packet.
let isAcceptedIngressPayload: (_ packet: BitchatPacket, _ innerSender: PeerID) -> Bool
/// Re-enters the receive pipeline with the reassembled packet (TTL already zeroed).
let processReassembledPacket: (_ packet: BitchatPacket, _ from: PeerID) -> Void
}
/// Orchestrates inbound fragments: self-fragment suppression, gossip tracking,
/// assembly-buffer appends, and reassembled-packet validation and re-injection
/// into the receive pipeline.
final class BLEFragmentHandler {
private let environment: BLEFragmentHandlerEnvironment
init(environment: BLEFragmentHandlerEnvironment) {
self.environment = environment
}
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
// Don't process our own fragments
if peerID == env.localPeerID() {
return
}
guard let header = BLEFragmentHeader(packet: packet) else { return }
if header.isBroadcastFragment {
env.trackPacketSeen(packet)
}
let assemblyResult = env.appendFragment(header)
logFragmentAssemblyResult(assemblyResult)
guard case let .complete(completedHeader, reassembled, _) = assemblyResult else { return }
// Decode the original packet bytes we reassembled, so flags/compression are preserved
if var originalPacket = BinaryProtocol.decode(reassembled) {
// Reassembled packet validation
let innerSender = PeerID(hexData: originalPacket.senderID)
if !env.isAcceptedIngressPayload(originalPacket, innerSender) {
// Cleanup below
} else {
SecureLogger.debug("✅ Reassembled packet id=\(completedHeader.idLogString) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session)
originalPacket.ttl = 0
env.processReassembledPacket(originalPacket, peerID)
}
} else {
SecureLogger.error("❌ Failed to decode reassembled packet (type=\(completedHeader.originalType), total=\(completedHeader.total))", category: .session)
}
}
private func logFragmentAssemblyResult(_ result: BLEFragmentAssemblyBuffer.AppendResult) {
func logStartedIfNeeded(header: BLEFragmentHeader, started: Bool) {
if started {
SecureLogger.debug("📦 Started fragment assembly id=\(header.idLogString) total=\(header.total)", category: .session)
}
}
switch result {
case let .stored(header, started):
logStartedIfNeeded(header: header, started: started)
SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session)
case let .complete(header, _, started):
logStartedIfNeeded(header: header, started: started)
SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session)
case let .oversized(header, projectedSize, limit, started):
logStartedIfNeeded(header: header, started: started)
SecureLogger.warning(
"🚫 Fragment assembly exceeds size limit (\(projectedSize) bytes > \(limit)), evicting. Type=\(header.originalType) Index=\(header.index)/\(header.total)",
category: .security
)
}
}
}
@@ -0,0 +1,132 @@
import BitFoundation
import BitLogger
import Foundation
/// Narrow environment for `BLENoisePacketHandler`.
///
/// All queue hops (collections barrier writes, main-actor UI notification)
/// and every `noiseService.*` crypto call live inside the closures supplied by
/// `BLEService`, keeping the handler queue-agnostic and synchronously testable.
struct BLENoisePacketHandlerEnvironment {
/// Local peer identity at the time the packet is handled.
let localPeerID: () -> PeerID
/// Local peer ID bytes used as the sender of handshake responses.
let localPeerIDData: () -> Data
/// TTL value used for direct (non-relayed) packets.
let messageTTL: UInt8
/// Current time source.
let now: () -> Date
/// Processes an inbound handshake message, returning an optional response payload (crypto).
let processHandshakeMessage: (_ peerID: PeerID, _ message: Data) throws -> Data?
/// Whether any Noise session (established or pending) exists for the peer (crypto).
let hasNoiseSession: (PeerID) -> Bool
/// Initiates a fresh Noise handshake with the peer (crypto + send).
let initiateHandshake: (PeerID) -> Void
/// Broadcasts a packet on the mesh (caller is already on the message queue).
let broadcastPacket: (BitchatPacket) -> Void
/// Updates the registry last-seen timestamp for the peer (async barrier write).
let updatePeerLastSeen: (PeerID) -> Void
/// Decrypts an encrypted payload from the peer (crypto).
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data
/// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto).
let clearSession: (PeerID) -> Void
/// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
let deliverNoisePayload: (
_ peerID: PeerID,
_ type: NoisePayloadType,
_ payload: Data,
_ timestamp: Date
) -> Void
}
/// Orchestrates the Noise session domain for inbound packets: handshake
/// processing (with response), encrypted payload decryption and dispatch,
/// and session recovery on decrypt failure.
final class BLENoisePacketHandler {
private let environment: BLENoisePacketHandlerEnvironment
init(environment: BLENoisePacketHandlerEnvironment) {
self.environment = environment
}
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
// Use NoiseEncryptionService for handshake processing
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
// Handshake is for us
do {
if let response = try env.processHandshakeMessage(peerID, packet.payload) {
// Send response
let responsePacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: env.localPeerIDData(),
recipientID: Data(hexString: peerID.id),
timestamp: UInt64(env.now().timeIntervalSince1970 * 1000),
payload: response,
signature: nil,
ttl: env.messageTTL
)
// We're on messageQueue from delegate callback
env.broadcastPacket(responsePacket)
}
// Session establishment will trigger onPeerAuthenticated callback
// which will send any pending messages at the right time
} catch {
SecureLogger.error("Failed to process handshake: \(error)")
// Try initiating a new handshake
if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID)
}
}
}
}
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
guard let recipientID = PeerID(hexData: packet.recipientID) else {
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
return
}
if recipientID != env.localPeerID() {
SecureLogger.debug("🔐 Encrypted message not for me (for \(recipientID.id.prefix(8))…, I am \(env.localPeerID().id.prefix(8))…)", category: .session)
return
}
// Update lastSeen for the peer we received from (important for private messages)
env.updatePeerLastSeen(peerID)
do {
let decrypted = try env.decrypt(packet.payload, peerID)
guard decrypted.count > 0 else { return }
// First byte indicates the payload type
let payloadType = decrypted[0]
let payloadData = decrypted.dropFirst()
guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else {
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
return
}
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))", category: .session)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
} catch NoiseEncryptionError.sessionNotEstablished {
// We received an encrypted message before establishing a session with this peer.
// Trigger a handshake so future messages can be decrypted.
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID)
}
} catch {
// Decryption failed - clear the corrupted session and re-initiate handshake
// This handles cases where session state got out of sync (nonce mismatch, etc.)
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
env.clearSession(peerID)
env.initiateHandshake(peerID)
}
}
}
@@ -0,0 +1,104 @@
import BitFoundation
import BitLogger
import Foundation
/// Narrow environment for `BLEPublicMessageHandler`.
///
/// All queue hops (collections registry reads, BLE-queue link-state reads,
/// main-actor UI notification) live inside the closures supplied by
/// `BLEService`, keeping the handler queue-agnostic and synchronously testable.
struct BLEPublicMessageHandlerEnvironment {
/// Local peer identity at the time the message is handled.
let localPeerID: () -> PeerID
/// Local nickname used for sender resolution and collision checks.
let localNickname: () -> String
/// Current time source.
let now: () -> Date
/// Snapshot of known peers keyed by ID (registry read).
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
/// Resolves a display name from a verified packet signature for peers missing from the registry.
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
/// Tracks the broadcast message packet for gossip sync.
let trackPacketSeen: (BitchatPacket) -> Void
/// Direct link state for the peer (BLE-queue read).
let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool)
/// Resolves and consumes the original message ID for our own re-broadcast.
let takeSelfBroadcastMessageID: (BitchatPacket) -> String?
/// Delivers `.publicMessageReceived` to the UI as one main-actor hop.
let deliverPublicMessage: (
_ peerID: PeerID,
_ nickname: String,
_ content: String,
_ timestamp: Date,
_ messageID: String?
) -> Void
}
/// Orchestrates inbound public (broadcast) messages: freshness/self-echo
/// policy, sender display-name resolution, gossip tracking, payload decoding,
/// and UI delivery.
final class BLEPublicMessageHandler {
private let environment: BLEPublicMessageHandlerEnvironment
init(environment: BLEPublicMessageHandlerEnvironment) {
self.environment = environment
}
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
let now = env.now()
let messageDecision = BLEPublicMessagePolicy.evaluate(
packet: packet,
from: peerID,
localPeerID: env.localPeerID(),
now: now
)
let messagePolicy: BLEPublicMessageAcceptance
switch messageDecision {
case .accept(let acceptance):
messagePolicy = acceptance
case .reject(.selfEcho):
return
case .reject(.staleBroadcast(let ageSeconds)):
SecureLogger.debug("⏰ Ignoring stale broadcast message from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
return
}
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
let peersSnapshot = env.peersSnapshot()
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peersSnapshot,
allowConnectedUnverified: false
) ?? env.signedSenderDisplayName(packet, peerID) else {
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return
}
if messagePolicy.shouldTrackForSync {
env.trackPacketSeen(packet)
}
guard let content = String(data: packet.payload, encoding: .utf8) else {
SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session)
return
}
// Determine if we have a direct link to the sender
let directLink = env.linkState(peerID)
let hasDirectLink = directLink.hasPeripheral || directLink.hasCentral
let pathTag = hasDirectLink ? "direct" : "mesh"
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
var resolvedSelfMessageID: String? = nil
if peerID == env.localPeerID() {
resolvedSelfMessageID = env.takeSelfBroadcastMessageID(packet)
}
env.deliverPublicMessage(peerID, senderNickname, content, ts, resolvedSelfMessageID)
}
}
+262 -428
View File
@@ -110,6 +110,16 @@ final class BLEService: NSObject {
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?
@@ -195,6 +205,9 @@ final class BLEService: NSObject {
// 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 initializeBluetoothManagers {
// Initialize BLE on background queue to prevent main thread blocking.
@@ -1005,79 +1018,50 @@ final class BLEService: NSObject {
}
private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) {
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: myPeerID) { return }
fileTransferHandler.handle(packet, from: peerID)
}
let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID }
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: myPeerID,
localNickname: myNickname,
peers: peersSnapshot,
allowConnectedUnverified: true
) ?? signedSenderDisplayName(for: packet, from: peerID) else {
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return
}
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: myPeerID) else {
return
}
if deliveryPlan.shouldTrackForSync {
gossipSyncManager?.onPublicPacketSeen(packet)
}
let filePacket: BitchatFilePacket
let mime: MimeType
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
case .success(let acceptance):
filePacket = acceptance.filePacket
mime = acceptance.mime
case .failure(.malformedPayload):
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
return
case .failure(.payloadTooLarge(let bytes)):
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
return
case .failure(.unsupportedMime(let mimeType, let bytes)):
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
return
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
return
}
// BCH-01-002: Enforce storage quota before saving
incomingFileStore.enforceQuota(reservingBytes: filePacket.content.count)
guard let destination = incomingFileStore.save(
data: filePacket.content,
preferredName: filePacket.fileName,
subdirectory: "\(mime.category.mediaDir)/incoming",
fallbackExtension: mime.defaultExtension,
defaultPrefix: mime.category.rawValue
) else {
return
}
if deliveryPlan.isPrivateMessage {
updatePeerLastSeen(peerID)
}
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
let message = BitchatMessage(
sender: senderNickname,
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
timestamp: ts,
isRelay: false,
originalSender: nil,
isPrivate: deliveryPlan.isPrivateMessage,
recipientNickname: nil,
senderPeerID: 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 }
},
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))
}
)
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
emitTransportEvent(.messageReceived(message))
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
@@ -2775,74 +2759,39 @@ extension BLEService {
private func handleFragment(_ packet: BitchatPacket, from peerID: PeerID) {
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
_handleFragment(packet, from: peerID)
fragmentHandler.handle(packet, from: peerID)
} else {
messageQueue.async(flags: .barrier) { [weak self] in
self?._handleFragment(packet, from: peerID)
self?.fragmentHandler.handle(packet, from: peerID)
}
}
}
private func _handleFragment(_ packet: BitchatPacket, from peerID: PeerID) {
// Don't process our own fragments
if peerID == myPeerID {
return
}
guard let header = BLEFragmentHeader(packet: packet) else { return }
if header.isBroadcastFragment {
gossipSyncManager?.onPublicPacketSeen(packet)
}
let assemblyResult = collectionsQueue.sync(flags: .barrier) {
fragmentAssemblyBuffer.append(header, maxInFlightAssemblies: maxInFlightAssemblies)
}
logFragmentAssemblyResult(assemblyResult)
guard case let .complete(completedHeader, reassembled, _) = assemblyResult else { return }
// Decode the original packet bytes we reassembled, so flags/compression are preserved
if var originalPacket = BinaryProtocol.decode(reassembled) {
// Reassembled packet validation
let innerSender = PeerID(hexData: originalPacket.senderID)
if !isAcceptedIngressPayload(originalPacket, from: innerSender) {
// Cleanup below
} else {
SecureLogger.debug("✅ Reassembled packet id=\(completedHeader.idLogString) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session)
originalPacket.ttl = 0
handleReceivedPacket(originalPacket, 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)
}
} else {
SecureLogger.error("❌ Failed to decode reassembled packet (type=\(completedHeader.originalType), total=\(completedHeader.total))", category: .session)
}
}
private func logFragmentAssemblyResult(_ result: BLEFragmentAssemblyBuffer.AppendResult) {
func logStartedIfNeeded(header: BLEFragmentHeader, started: Bool) {
if started {
SecureLogger.debug("📦 Started fragment assembly id=\(header.idLogString) total=\(header.total)", category: .session)
}
}
switch result {
case let .stored(header, started):
logStartedIfNeeded(header: header, started: started)
SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session)
case let .complete(header, _, started):
logStartedIfNeeded(header: header, started: started)
SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session)
case let .oversized(header, projectedSize, limit, started):
logStartedIfNeeded(header: header, started: started)
SecureLogger.warning(
"🚫 Fragment assembly exceeds size limit (\(projectedSize) bytes > \(limit)), evicting. Type=\(header.originalType) Index=\(header.index)/\(header.total)",
category: .security
)
}
)
}
// MARK: Packet Reception
@@ -2963,165 +2912,96 @@ extension BLEService {
}
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
let now = Date()
let preflight = BLEAnnouncePreflightPolicy.evaluate(
packet: packet,
from: peerID,
localPeerID: myPeerID,
now: now
)
announceHandler.handle(packet, from: peerID)
}
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
case .reject(.senderMismatch(let derivedFromKey)):
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security)
return
case .reject(.selfAnnounce):
return
case .reject(.stale(let ageSeconds)):
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
return
}
// Suppress announce logs to reduce noise
// Precompute signature verification outside barrier to reduce contention
let existingPeerForVerify = collectionsQueue.sync { peerRegistry.info(for: peerID) }
let hasSignature = packet.signature != nil
let signatureValid: Bool
if hasSignature {
signatureValid = noiseService.verifyPacketSignature(packet, publicKey: 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: existingPeerForVerify?.noisePublicKey,
announcedNoisePublicKey: announcement.noisePublicKey
)
if case .reject(.keyMismatch) = trustDecision {
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
}
let verifiedAnnounce = trustDecision.isVerified
var isNewPeer = false
var isReconnectedPeer = false
let directLinkState = linkState(for: peerID)
let isDirectAnnounce = packet.ttl == messageTTL
collectionsQueue.sync(flags: .barrier) {
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 = peerRegistry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: announcement.nickname,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
now: 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 = Date()
if update.isNewPeer {
SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session)
} else if update.wasDisconnected {
if reconnectLogDebouncer.shouldEmit(
peerID: peerID,
now: now,
minimumInterval: TransportConfig.bleReconnectLogDebounceSeconds
) {
SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session)
/// 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() },
existingNoisePublicKey: { [weak self] peerID in
guard let self = self else { return nil }
return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
},
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)
},
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.
self?.peerRegistry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: announcement.nickname,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected,
now: now
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
},
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))
}
} else if let previousNickname = update.previousNickname, previousNickname != announcement.nickname {
SecureLogger.debug("🔄 Peer \(peerID.id.prefix(8))… changed nickname: \(previousNickname) -> \(announcement.nickname)", category: .session)
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))
}
}
}
// Update topology with verified neighbor claims (only for authenticated announces)
if verifiedAnnounce, let neighbors = announcement.directNeighbors {
meshTopology.updateNeighbors(for: peerID.routingData, neighbors: neighbors)
}
// Persist cryptographic identity and signing key for robust offline verification
identityManager.upsertCryptographicIdentity(
fingerprint: announcement.noisePublicKey.sha256Fingerprint(),
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
claimedNickname: announcement.nickname
)
let announceBackID = "announce-back-\(peerID)"
let shouldSendBack = !messageDeduplicator.contains(announceBackID)
if shouldSendBack {
messageDeduplicator.markProcessed(announceBackID)
}
let responsePlan = BLEAnnounceResponsePolicy.plan(
isDirectAnnounce: isDirectAnnounce,
isNewPeer: isNewPeer,
isReconnectedPeer: isReconnectedPeer,
shouldSendAnnounceBack: shouldSendBack
)
// Notify UI on main thread
notifyUI { [weak self] in
guard let self = self else { return }
// Get current peer list (after addition)
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
// Only notify of connection for new or reconnected peers when it is a direct announce
if responsePlan.shouldNotifyPeerConnected {
self.deliverTransportEvent(.peerConnected(peerID))
// Schedule initial unicast sync to this peer
if responsePlan.shouldScheduleInitialSync {
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
}
}
self.requestPeerDataPublish()
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
}
// Track for sync (include our own and others' announces)
gossipSyncManager?.onPublicPacketSeen(packet)
if responsePlan.shouldSendAnnounceBack {
// Reciprocate announce for bidirectional discovery
// Force send to ensure the peer receives our announce
sendAnnounce(forceSend: true)
}
// 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)
messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
},
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)
}
}
}
)
}
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
@@ -3136,156 +3016,110 @@ extension BLEService {
// Mention parsing moved to ChatViewModel
private func handleMessage(_ packet: BitchatPacket, from peerID: PeerID) {
let now = Date()
let messageDecision = BLEPublicMessagePolicy.evaluate(
packet: packet,
from: peerID,
localPeerID: myPeerID,
now: now
)
let messagePolicy: BLEPublicMessageAcceptance
switch messageDecision {
case .accept(let acceptance):
messagePolicy = acceptance
case .reject(.selfEcho):
return
case .reject(.staleBroadcast(let ageSeconds)):
SecureLogger.debug("⏰ Ignoring stale broadcast message from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
return
}
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID }
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: myPeerID,
localNickname: myNickname,
peers: peersSnapshot,
allowConnectedUnverified: false
) ?? signedSenderDisplayName(for: packet, from: peerID) else {
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return
}
if messagePolicy.shouldTrackForSync {
gossipSyncManager?.onPublicPacketSeen(packet)
}
guard let content = String(data: packet.payload, encoding: .utf8) else {
SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session)
return
}
// Determine if we have a direct link to the sender
let directLink = linkState(for: peerID)
let hasDirectLink = directLink.hasPeripheral || directLink.hasCentral
let pathTag = hasDirectLink ? "direct" : "mesh"
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
var resolvedSelfMessageID: String? = nil
if peerID == myPeerID {
resolvedSelfMessageID = selfBroadcastTracker.takeMessageID(for: packet)
}
notifyUI { [weak self] in
self?.deliverTransportEvent(
.publicMessageReceived(
peerID: peerID,
nickname: senderNickname,
content: content,
timestamp: ts,
messageID: resolvedSelfMessageID
)
)
}
publicMessageHandler.handle(packet, from: peerID)
}
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
// Use NoiseEncryptionService for handshake processing
if PeerID(hexData: packet.recipientID) == myPeerID {
// Handshake is for us
do {
if let response = try noiseService.processHandshakeMessage(from: peerID, message: packet.payload) {
// Send response
let responsePacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: response,
signature: nil,
ttl: messageTTL
/// 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 }
},
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
)
)
// We're on messageQueue from delegate callback
broadcastPacket(responsePacket)
}
// Session establishment will trigger onPeerAuthenticated callback
// which will send any pending messages at the right time
} catch {
SecureLogger.error("Failed to process handshake: \(error)")
// Try initiating a new handshake
if !noiseService.hasSession(with: peerID) {
initiateNoiseHandshake(with: peerID)
}
}
}
)
}
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
noisePacketHandler.handleHandshake(packet, from: peerID)
}
private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
guard let recipientID = PeerID(hexData: packet.recipientID) else {
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
return
}
if recipientID != myPeerID {
SecureLogger.debug("🔐 Encrypted message not for me (for \(recipientID.id.prefix(8))…, I am \(myPeerID.id.prefix(8))…)", category: .session)
return
}
// Update lastSeen for the peer we received from (important for private messages)
updatePeerLastSeen(peerID)
do {
let decrypted = try noiseService.decrypt(packet.payload, from: peerID)
guard decrypted.count > 0 else { return }
// First byte indicates the payload type
let payloadType = decrypted[0]
let payloadData = decrypted.dropFirst()
noisePacketHandler.handleEncrypted(packet, from: peerID)
}
guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else {
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
return
/// 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
try self?.noiseService.processHandshakeMessage(from: peerID, message: message)
},
hasNoiseSession: { [weak self] peerID in
self?.noiseService.hasSession(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 }
return try self.noiseService.decrypt(payload, from: peerID)
},
clearSession: { [weak self] peerID in
self?.noiseService.clearSession(for: peerID)
},
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
// Single main-actor hop delivering `.noisePayloadReceived`.
self?.notifyUI { [weak self] in
self?.deliverTransportEvent(.noisePayloadReceived(
peerID: peerID,
type: type,
payload: payload,
timestamp: timestamp
))
}
}
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))", category: .session)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in
self?.deliverTransportEvent(.noisePayloadReceived(
peerID: peerID,
type: noisePayloadType,
payload: Data(payloadData),
timestamp: ts
))
}
} catch NoiseEncryptionError.sessionNotEstablished {
// We received an encrypted message before establishing a session with this peer.
// Trigger a handshake so future messages can be decrypted.
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
if !noiseService.hasSession(with: peerID) {
initiateNoiseHandshake(with: peerID)
}
} catch {
// Decryption failed - clear the corrupted session and re-initiate handshake
// This handles cases where session state got out of sync (nonce mismatch, etc.)
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
noiseService.clearSession(for: peerID)
initiateNoiseHandshake(with: peerID)
}
)
}
// MARK: Helper Functions