mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 12:05:20 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user