1// ChatManager.swift2// SophaxChatCore3//4// High-level coordinator — the single entry point for the app layer.5//6// Session lifecycle:7// 1. Peer connects (MPC) → Hello exchanged immediately → PreKeyBundle stored8// 2. First sendMessage → X3DH sender-side → .initiateSession wire message9// 3. Subsequent messages → .message wire message (Double Ratchet)10// 4. Relay: peer not directly connected → wrap in RelayEnvelope, broadcast11// 5. Offline: no connected peers → queue message, drain on next connect/hello12//13// Threading:14// All MeshManagerDelegate callbacks are dispatched through an internal15// DispatchQueue by MeshManager, then delegate calls are re-dispatched to16// main. ChatManager is @unchecked Sendable — the caller must not call17// public methods concurrently from different threads.1819import Foundation20import CryptoKit2122// MARK: - Sealed sender helpers (file-private)2324private func sealWireMessage(_ wire: WireMessage, recipientDHPublicKey: Data) throws -> SealedMessage {25// Generate ephemeral key pair26 let ephPair = DHKeyPair()27 let recipKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: recipientDHPublicKey)28 let shared = try ephPair.privateKey.sharedSecretFromKeyAgreement(with: recipKey)2930// Derive sealing key31 var ikmData = Data()32 shared.withUnsafeBytes { ikmData.append(contentsOf: $0)}33 let sealingKey = HKDF<SHA256>.deriveKey(34 inputKeyMaterial: SymmetricKey(data: ikmData),35 info: CryptoConstants.sealedSenderInfo,36 outputByteCount: 3237)3839// Bind the ciphertext to the intended recipient's static DH public key.40// The sealing key is already derived from ECDH(eph, recipientDHKey), so the41// recipient is implicitly authenticated via key derivation. Explicitly including42// the recipient's DH public key as AAD additionally prevents key-confusion: even43// if two recipients somehow shared the same DH key the AAD tag would differ.44 let wireJSON = try JSONEncoder().encode(wire)45 let nonce = ChaChaPoly.Nonce()46 let sealed = try ChaChaPoly.seal(wireJSON, using: sealingKey, nonce: nonce,47 authenticating: recipientDHPublicKey)48 return SealedMessage(ephemeralPublicKey: ephPair.publicKeyData, encryptedPayload: sealed.combined)49}5051private func unsealMessage(_ sealed: SealedMessage, recipientDHPrivateKey: Curve25519.KeyAgreement.PrivateKey) throws -> WireMessage {52 let ephKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: sealed.ephemeralPublicKey)53 let shared = try recipientDHPrivateKey.sharedSecretFromKeyAgreement(with: ephKey)5455 var ikmData = Data()56 shared.withUnsafeBytes { ikmData.append(contentsOf: $0)}57 let sealingKey = HKDF<SHA256>.deriveKey(58 inputKeyMaterial: SymmetricKey(data: ikmData),59 info: CryptoConstants.sealedSenderInfo,60 outputByteCount: 3261)6263// Reconstruct the same AAD used during sealing: our own DH public key.64 let recipientDHPublicKey = recipientDHPrivateKey.publicKey.rawRepresentation65 do {66 let box = try ChaChaPoly.SealedBox(combined: sealed.encryptedPayload)67 let wireJSON = try ChaChaPoly.open(box, using: sealingKey,68 authenticating: recipientDHPublicKey)69 return try JSONDecoder().decode(WireMessage.self, from: wireJSON)70} catch {71 throw SophaxError.decryptionFailed72}73}7475// MARK: - Delegate7677public protocol ChatManagerDelegate: AnyObject {78/// A peer came online and their identity has been verified.79 func chatManager(_ manager: ChatManager, didDiscoverPeer peer: KnownPeer)80/// A peer is no longer reachable (direct or relay).81 func chatManager(_ manager: ChatManager, peerDidDisconnect peerID: String)82/// A new inbound message was decrypted and stored.83 func chatManager(_ manager: ChatManager, didReceiveMessage message: StoredMessage, fromPeer peerID: String)84/// A message was sent (or queued) successfully by the local user.85 func chatManager(_ manager: ChatManager, didSendMessage message: StoredMessage, toPeer peerID: String)86/// A sent message was acknowledged by the recipient.87 func chatManager(_ manager: ChatManager, messageDelivered messageID: String, toPeer peerID: String)88/// A non-fatal error occurred (logged; caller may display or ignore).89 func chatManager(_ manager: ChatManager, didEncounterError error: Error)90/// The remote peer's typing state changed.91 func chatManager(_ manager: ChatManager, peerDidUpdateTyping peerID: String, isTyping: Bool)92/// The remote peer has read one or more messages we sent.93 func chatManager(_ manager: ChatManager, messagesRead messageIDs:[String], byPeer peerID: String)94/// A peer updated their emoji reaction on a specific message.95 func chatManager(_ manager: ChatManager, didUpdateReactions reactions:[String: String], onMessageID messageID: String, peerID: String)96/// The local user was added to a new group (created locally or invited by another peer).97 func chatManager(_ manager: ChatManager, didJoinGroup group: GroupInfo)98/// A group chat message was decrypted and stored.99 func chatManager(_ manager: ChatManager, didReceiveGroupMessage message: StoredMessage, inGroup groupID: String)100/// An X3DH session was established WITHOUT a one-time prekey (reduced entropy window).101 func chatManager(_ manager: ChatManager, sessionEstablishedWithPeer peerID: String, usedOPK: Bool)102/// A previously-known peer has come back online after being offline.103 func chatManager(_ manager: ChatManager, peerDidReconnect peer: KnownPeer)104/// Emoji reactions on a group message were updated.105 func chatManager(_ manager: ChatManager, didUpdateGroupReactions reactions:[String: String],106 onMessageID messageID: String, groupID: String)107/// A member left a group. The delegate should update stored membership to `remainingMemberIDs`.108 func chatManager(_ manager: ChatManager, peer leavingPeerID: String,109 leftGroupID groupID: String, remainingMemberIDs:[String])110/// A nearby peer is advertising a group that the local user is not a member of.111/// The delegate can surface this in a "Nearby channels" list.112 func chatManager(_ manager: ChatManager, didDiscoverChannel announcement: ChannelAnnouncement)113/// A group message we sent was acknowledged by `peerID`. `isRead` = true means the user114/// has viewed the message; false means delivery-only confirmation.115 func chatManager(_ manager: ChatManager, groupMessageDelivered messageID: String,116 inGroup groupID: String, byPeer peerID: String, isRead: Bool)117/// A received message was edited by its sender. The delegate should update local state.118 func chatManager(_ manager: ChatManager, didReceiveEditedMessage messageID: String,119 newBody: String, editedAt: Date, peerID: String)120/// A group message was edited. The delegate should update local state.121 func chatManager(_ manager: ChatManager, didReceiveEditedGroupMessage messageID: String,122 newBody: String, editedAt: Date, groupID: String)123/// A peer's identity keys changed from a previously stored value.124/// This may indicate a legitimate re-install or a potential MITM.125 func chatManager(_ manager: ChatManager, didDetectKeyChange forPeerID: String)126/// The local user's sender key for a group was rotated (break-in recovery).127 func chatManager(_ manager: ChatManager, didRotateSenderKey forGroupID: String)128/// The group creator dissolved the group; local user and all members must drop it.129 func chatManager(_ manager: ChatManager, groupDeletedWithID groupID: String)130/// A group message carried avatar data for a peer not yet in peerAvatars.131 func chatManager(_ manager: ChatManager, didReceiveAvatarData data: Data, fromPeerID peerID: String)132/// The MLS commit coordinator for a group was updated via a handoff message.133 func chatManager(_ manager: ChatManager, didUpdateCoordinator newCoordinatorID: String, inGroupID groupID: String)134/// A new peer sent a Hello but has not yet been accepted — show accept/reject UI.135 func chatManager(_ manager: ChatManager, didReceiveContactRequest peer: KnownPeer)136/// A device was successfully linked (via QR scan or incoming link request). UI should refresh the linked devices list.137 func chatManager(_ manager: ChatManager, didLinkDevice peer: KnownPeer)138/// A linked device forwarded a message copy. Delegate should store and display it.139 func chatManager(_ manager: ChatManager, didReceiveSyncedMessage message: StoredMessage, conversationID: String)140/// A contact sent us one of their SSS backup shares to hold. UI should confirm acceptance.141 func chatManager(_ manager: ChatManager, didReceiveSSSShare shareID: String,142 fromPeerID: String, threshold: Int, total: Int)143/// SSS recovery succeeded — `secret` is 64 bytes (Ed25519 || X25519 private keys).144/// Caller is responsible for zeroing `secret` after use.145 func chatManager(_ manager: ChatManager, didRecoverSSSSecret secret: Data, shareID: String)146147/// Called when a trusted peer requests remote account wipe.148 func chatManagerDidReceiveRemoteWipeRequest(_ manager: ChatManager)149150/// A peer sent a join request for a group the local user created.151/// The delegate should present an approval UI (approve via `addMemberToGroup`).152 func chatManager(_ manager: ChatManager, didReceiveGroupJoinRequest request: ChannelJoinRequestMessage)153}154155// MARK: - ChatManager156157public final class ChatManager:@unchecked Sendable {158159// MARK: - Sub-components160161 public let identity: IdentityManager162 public let preKeys: PreKeyManager163 public let mesh: MeshManager164 public let messageStore: MessageStore165 public let attachmentStore: AttachmentStore166167 private let keychain: KeychainManager168 let wireBuilder: WireMessageBuilder169 private let relayRouter: RelayRouter170171/// MLS group manager (Group v3). Nil until first use — initialised lazily on first172/// MLS group create/join so non-MLS builds don't pay the Rust init cost.173 private var mlsManager: MLSGroupManager?174175/// Persistent log of observed peer identity keys for auditability.176 public let keyLog: KeyTransparencyLog177178// MARK: - State179180/// Active Double Ratchet sessions keyed by application-level peerID.181/// Always access through withSession(_:) to guarantee mutual exclusion.182 private var sessions:[String: DoubleRatchet]=[:]183184/// Serialises every load → mutate → persist cycle on a single session.185/// DoubleRatchet is a class whose encrypt/decrypt methods mutate internal186/// state; concurrent access to the same session would corrupt the chain.187 private let sessionLock = NSLock()188189/// Peers whose identity we've cryptographically verified, keyed by peerID.190 private var knownPeers:[String: KnownPeer]=[:]191192/// PreKeyBundles keyed by peerID — populated on Hello, used for X3DH initiation.193 var peerBundles:[String: PreKeyBundle]=[:]194195/// Outbound messages queued for peers not currently reachable.196/// Drained as soon as a path (direct or relay) becomes available.197 private var pendingQueue:[String:[(wire: WireMessage, messageID: String)]]=[:]198199/// Codable wrapper so pending queue items can be persisted to disk.200 private struct PendingQueueItem: Codable {201 let wire: WireMessage202 let messageID: String203}204205 private let pendingQueueFileName ="pending_queue"206207/// In-memory cache of message keys for group messages that arrived out of order.208/// Key: "groupID/senderPeerID" → (senderKeyIteration → messageKey)209/// Bounded to `maxSkippedKeysCacheSize` entries per sender; oldest are evicted first.210 private var skippedGroupMessageKeys:[String:[UInt32: SymmetricKey]]=[:]211/// Parallel timestamp map for Keychain persistence (entry age → eviction after 7 days).212 private var skippedGroupMessageKeyDates:[String:[UInt32: Date]]=[:]213 private static let maxSkippedKeysCacheSize = 200214 private static let senderKeyRotationMessageLimit: UInt32 = 500215 private static let senderKeyRotationAgeLimit: TimeInterval = 7 * 24 * 3600216 private static let senderKeyRequestCooldown: TimeInterval = 60217 private static let senderKeyStaleThreshold: TimeInterval = 14 * 24 * 3600218219/// Last time we sent a senderKeyRequest for a given "groupID/peerID".220/// Prevents request spam from peers who repeatedly send undecryptable messages.221 private var lastSenderKeyRequestSent:[String: Date]=[:]222/// Last time we responded to a senderKeyRequest from a given "groupID/peerID".223 private var lastSenderKeyRequestResponded:[String: Date]=[:]224225/// Known group membership keyed by groupID. Populated on create/join, cleared on leave.226/// Used to reject messages from peers not in the group.227 var joinedGroups:[String: Set<String>]=[:]228229/// Creator peerID for each joined group — used to verify group-delete authority.230 var groupCreators:[String: String]=[:]231232/// Current MLS commit coordinator per group. Starts as creatorID; updated on handoff.233 var groupCoordinators:[String: String]=[:]234235/// Crypto version per group — used to skip sealed-sender double-wrap for MLS groups.236 var groupCryptoVersions:[String: GroupCryptoVersion]=[:]237238/// PeerIDs of devices linked to this account (same person, different device).239/// Inbound messages are forwarded to linked devices; they forward back via deviceSyncMessage.240 private var linkedDevicePeerIDs: Set<String>=[]241242/// Human-readable label for this device shown to the pairing partner (e.g. "iPhone 16 Pro").243 public var deviceLabel: String ="Device"244245/// Messages stored on behalf of offline peers (relay-store role).246 private struct StoredForwardItem: Codable {247 let targetPeerID: String248 let messageID: String249 let sealed: SealedMessage250 let expiresAt: Date251}252 private var storedForwardItems:[StoredForwardItem]=[]253 private static let maxStoredForwardItems = 300254 private static let maxStoredForwardPerPeer = 30 // prevents single-peer DoS255 private static let storeAndForwardTTL: TimeInterval = 48 * 60 * 60 // 48 hours256 private let storedForwardFileName ="stored_forward_items"257258/// Dead drops stored at this node — re-broadcast when target peer appears.259 private var deadDrops:[DeadDropEnvelope]=[]260 private static let maxDeadDrops: Int = 100261 private static let deadDropDedupeWindow: TimeInterval = 60 // seconds262 private var seenDeadDropIDs:[String: Date]=[:]// id → receivedAt263264// MARK: - SSS recovery accumulator265266/// Shares collected during an active recovery session: shareID → [SSSShare].267/// When count ≥ threshold the secret is reconstructed.268 private var pendingRecoveryShares:[String:[SSSShare]]=[:]269270// MARK: - Per-peer rate limiter (typing + reaction)271272/// Token bucket: allow up to `capacity` events per `windowSeconds`, refilled continuously.273 private struct TokenBucket {274 let capacity: Double275 let windowSeconds: Double276 var tokens: Double277 var lastRefill: Date278279 init(capacity: Double, windowSeconds: Double){280 self.capacity = capacity281 self.windowSeconds = windowSeconds282 self.tokens = capacity283 self.lastRefill = Date()284}285286/// Returns true if the event is allowed; false if rate-limited.287 mutating func consume()-> Bool {288 let now = Date()289 let elapsed = now.timeIntervalSince(lastRefill)290 tokens = min(capacity, tokens + elapsed *(capacity / windowSeconds))291 lastRefill = now292 if tokens >= 1 {293 tokens -= 1294 return true295}296 return false297}298}299300/// Per-peer buckets for `.typing` (10 per 10 s) and `.reaction` (20 per 10 s).301 private var typingRateLimiters:[String: TokenBucket]=[:]302 private var reactionRateLimiters:[String: TokenBucket]=[:]303/// Rate limiter for channel announcements: max 3 per 5 minutes per peer.304/// Prevents a malicious node from flooding the mesh with repeated announcements.305 private var channelAnnouncementRateLimiters:[String: TokenBucket]=[:]306307/// Fires every 60 seconds to purge messages whose expiresAt has passed.308 private var expiryTimer: Timer?309310// MARK: - DHT peer discovery311312 private var dhtEngine: DHTEngine?313 private var dhtStorage: DHTStorage?314/// DHT messages queued for peers not yet TCP-connected.315/// Keyed by "host.onion:port" — flushed in tcpTransport(_:didConnectToPeer:address:).316 private var pendingDHTMessages:[String:[WireMessage]]=[:]317/// Rate limiter: max 30 DHT requests/min per sender.318 private var dhtRateLimiters:[String: TokenBucket]=[:]319320 public weak var delegate: ChatManagerDelegate?321322// MARK: - TCP transport (optional internet layer)323324/// Set before calling `start()` — or swap at runtime via `startTCP` / `stopTCP`.325/// Nil means local BLE/WiFi mesh only.326 public var tcpTransport: TCPTransport?327328/// Our TCP address advertised in Hello bundles ("host:port"), set by AppState.329 public var myTCPAddress: String?330331/// Generates an ephemeral P-256 TLS certificate for the TCP transport each session.332/// The certificate fingerprint is propagated in every PreKeyBundle we emit.333 private let tlsCertManager = TLSCertManager()334335// MARK: - LAN discovery (mDNS/Bonjour — enables iOS ↔ Android on same WiFi)336337 private var lanDiscovery: LanDiscovery?338339// MARK: - Init340341 public init(342 identity: IdentityManager,343 preKeys: PreKeyManager,344 mesh: MeshManager,345 messageStore: MessageStore,346 attachmentStore: AttachmentStore,347 keychain: KeychainManager348){349 self.identity = identity350 self.preKeys = preKeys351 self.mesh = mesh352 self.messageStore = messageStore353 self.attachmentStore = attachmentStore354 self.keychain = keychain355 self.keyLog = KeyTransparencyLog(keychain: keychain)356 self.wireBuilder = WireMessageBuilder(identity: identity)357 self.relayRouter = RelayRouter()358 mesh.delegate = self359}360361// MARK: - MLS lazy init362363/// Returns the shared MLSGroupManager, creating it on first call.364/// Throws if the signing key is unavailable (should never happen post-onboarding).365 func requireMLSManager() throws -> MLSGroupManager {366 if let m = mlsManager { return m }367 let signingKeyBytes = try identity.signingPrivateKeyData()368 let m = try MLSGroupManager(369 peerID: identity.publicIdentity.peerID,370 signingKeyBytes: signingKeyBytes,371 keychain: keychain372)373 mlsManager = m374 return m375}376377// MARK: - Public API378379/// Start advertising and browsing on the P2P mesh (and TCP if configured).380 public func start(){381// Generate ephemeral TLS cert for this session before starting the TCP transport.382 tlsCertManager.start()383 preKeys.tlsCertFingerprint = tlsCertManager.certFingerprint384385 mesh.start()386 if let tcp = tcpTransport {387 tcp.tlsIdentity = tlsCertManager.secIdentity388 tcp.helloProvider ={[weak self] in self?.makeTCPHello()}389 tcp.delegate = self390 tcp.start()391}392// mDNS discovery — auto-connects to iOS and Android peers on the same WiFi393 let lan = LanDiscovery()394 lan.delegate = self395 lan.start(peerID: identity.publicIdentity.peerID)396 lanDiscovery = lan397 try? preKeys.rotateIfNeeded()398 scheduleExpiryTimer()399 loadPersistedQueue()400 loadPersistedForwardItems()401 loadSkippedGroupKeyCache()402 loadLinkedDevices()403 seenWipeRequestIDs = keychain.loadSeenWipeRequestIDs()404}405406/// Stop the mesh and TCP transport (call on app background / termination).407 public func stop(){408 mesh.stop()409 tcpTransport?.stop()410 lanDiscovery?.stop()411 lanDiscovery = nil412 expiryTimer?.invalidate()413 expiryTimer = nil414 persistQueue()415 persistForwardItems()416}417418/// Permanently wipe all data — keys, messages, attachments.419/// Call `stop()` before this. After returning, the caller should release this instance.420 public func wipeAllData() throws {421 stop()422 try keychain.wipeAll()423 keychain.deleteAllSSSData()424 try messageStore.wipeAll()425 try attachmentStore.wipeAll()426// KeyTransparencyLog is stored in UserDefaults — cleared by the caller (AppState)427// along with all other com.sophax.* UserDefaults keys.428}429430/// Attach a TCP transport at runtime and start it immediately.431 public func startTCP(_ transport: TCPTransport){432 tcpTransport?.stop()433 tcpTransport = transport434 transport.tlsIdentity = tlsCertManager.secIdentity435 transport.helloProvider ={[weak self] in self?.makeTCPHello()}436 transport.delegate = self437 transport.start()438}439440/// Stop and detach the TCP transport.441 public func stopTCP(){442 tcpTransport?.stop()443 tcpTransport = nil444 if let engine = dhtEngine {445 Task { await engine.stop()}446}447 dhtEngine = nil448}449450/// Start the DHT engine. Call after TCP transport is up and myTCPAddress is set.451 public func startDHT(){452 guard dhtEngine == nil else { return }453 let storage = DHTStorage(messageStore: messageStore)454 dhtStorage = storage455456 let sendBlock: DHTSendBlock ={[weak self] message, contact in457 guard let self else { return }458 self.sendDHTMessage(message, to: contact)459}460461 guard let engine = try? DHTEngine(462 identity: identity,463 wireBuilder: wireBuilder,464 send: sendBlock465) else { return }466 dhtEngine = engine467468// Bootstrap priority: (1) persisted k-buckets, (2) knownPeers with TCP address,469// (3) hardcoded bootstrap nodes.470 var bootstrapContacts = storage.load()471 let fromKnown:[DHTContact]= knownPeers.values.compactMap { peer in472 guard let addr = peer.tcpAddress else { return nil }473 let nodeIDData = peer.signingKeyPublic + peer.dhKeyPublic474 let nodeID = Data(SHA256.hash(data: nodeIDData)).hexString475 let parts = addr.split(separator:":").map(String.init)476 guard parts.count == 2, parts[0].hasSuffix(".onion"),477 let port = UInt16(parts[1]) else { return nil }478 return DHTContact(nodeID: nodeID, onionAddress: parts[0], port: port)479}480 bootstrapContacts.append(contentsOf: fromKnown)481 if bootstrapContacts.isEmpty { bootstrapContacts = DHTBootstrap.nodes }482483 Task {484 await engine.start(bootstrapContacts: bootstrapContacts)485// Publish our bundle immediately after bootstrap so peers can find us486// without waiting for the 60s expiry-timer tick.487 self.publishDHTBundleIfNeeded()488}489490// Schedule k-bucket snapshot every 30 minutes491 Timer.scheduledTimer(withTimeInterval: 30 * 60, repeats: true){[weak self, weak engine] _ in492 guard let self, let engine else { return }493 Task {494 let snap = await engine.table.snapshot()495 self.dhtStorage?.save(snapshot: snap)496}497}498}499500/// Initiate an outbound TCP connection to `address` ("host:port").501 public func connectViaTCP(address: String) throws {502 guard let tcp = tcpTransport else { return }503// Pin to the peer's TLS cert fingerprint when we have their bundle on file.504 let fingerprint = knownPeers.values505.first(where:{ $0.tcpAddress == address })?506.tlsCertFingerprint507 try tcp.connect(to: address, expectedCertFingerprint: fingerprint)508}509510// MARK: - DHT send helper511512/// Sends a DHT WireMessage to a contact. Connects if not already connected;513/// queues the message and flushes it in tcpTransport(_:didConnectToPeer:address:).514 private func sendDHTMessage(_ message: WireMessage, to contact: DHTContact){515 guard let tcp = tcpTransport else { return }516 let address = contact.tcpAddress517 if tcp.connectedPeerIDs.contains(where:{ knownPeers[$0]?.tcpAddress == address }){518// Already connected — find the peerID and send519 if let peerID = tcp.connectedPeerIDs.first(where:{ knownPeers[$0]?.tcpAddress == address }){520 try? tcp.send(message, toPeerID: peerID)521}522} else {523// Queue and connect (pass cert fingerprint if peer is already known)524 pendingDHTMessages[address, default:[]].append(message)525 let fp = knownPeers.values526.first(where:{ $0.tcpAddress == address })?527.tlsCertFingerprint528 try? tcp.connect(to: address, expectedCertFingerprint: fp)529}530}531532// MARK: - DHT peer lookup533534/// Lookup a peer by their 16-char peerID via the DHT.535/// Resolves their PreKeyBundle, connects TCP, and returns a KnownPeer.536 public func lookupPeer(peerID: String) async throws -> KnownPeer {537 guard let engine = dhtEngine else { throw SophaxError.invalidMessageFormat("DHT not running")}538539 let (_, bundle)= try await engine.lookup(peerID: peerID)540541 let safetyNumber = generateSafetyNumber(for: bundle)542 let peer = KnownPeer(from: bundle, safetyNumber: safetyNumber, trustLevel:.pending)543544 knownPeers[peerID]= peer545 if let addr = bundle.tcpAddress { try? connectViaTCP(address: addr)}546547 return peer548}549550// MARK: - Public: Identity broadcast551552/// Send our Hello (PreKeyBundle) to a single peer via the normal send/queue path.553/// Used to proactively initiate bundle exchange with group members we haven't met directly.554/// Internal so ChatManager extensions (ChatManager+MLS.swift) can call it too.555 func sendHelloToPeer(_ peerID: String){556 guard let bundle = try? preKeys.generateBundle(tcpAddress: myTCPAddress) else { return }557 let hello = HelloMessage(bundle: bundle)558 guard let wire = try? wireBuilder.build(.hello, payload: hello) else { return }559 try? sendOrQueue(wire, toPeerID: peerID, messageID: UUID().uuidString)560}561562/// Re-broadcast our Hello (PreKeyBundle) to all currently-connected peers (mesh + TCP).563/// Call after a username change so peers pick up the new display name.564 public func broadcastHello(){565 guard let bundle = try? preKeys.generateBundle(tcpAddress: myTCPAddress) else { return }566 let hello = HelloMessage(bundle: bundle)567 guard let wire = try? wireBuilder.build(.hello, payload: hello) else { return }568 try? mesh.broadcast(wire)569// Also push Hello to TCP peers — they need the refreshed bundle570 if let tcp = tcpTransport {571 for peerID in tcp.connectedPeerIDs {572 try? tcp.send(wire, toPeerID: peerID)573}574}575}576577// MARK: - Public: Contact requests578579/// Accept a pending contact request — promotes peer to `.accepted` and fires `didDiscoverPeer`.580 public func acceptContactRequest(peerID: String){581 guard var peer = knownPeers[peerID], peer.trustLevel ==.pending else { return }582 peer.trustLevel =.accepted583 knownPeers[peerID]= peer584 DispatchQueue.main.async {[weak self] in585 guard let self else { return }586 self.delegate?.chatManager(self, didDiscoverPeer: peer)587}588// Now that the peer is accepted, drain any queued messages and deliver stored items589 drainQueue(forPeerID: peerID)590 deliverStoredForwardItems(toPeerID: peerID)591}592593/// Reject a pending contact request — removes all local state for this peer.594 public func rejectContactRequest(peerID: String){595 guard knownPeers[peerID]?.trustLevel ==.pending else { return }596 knownPeers.removeValue(forKey: peerID)597 peerBundles.removeValue(forKey: peerID)598 pendingQueue.removeValue(forKey: peerID)599}600601/// Register a peer that was persisted in the pending state across app restarts.602/// Does NOT fire the `didReceiveContactRequest` delegate (already shown at request time).603 public func registerPendingPeer(_ peer: KnownPeer){604 guard peer.trustLevel ==.pending else { return }605 knownPeers[peer.id]= peer606}607608// MARK: - Public: Multi-device linking609610/// All currently linked device peerIDs.611 public var linkedDevices:[String]{ Array(linkedDevicePeerIDs)}612613/// Generate a JSON blob to encode in a QR code for device linking.614/// The other device calls `acceptDeviceLink(_:)` after scanning.615 public func generateDeviceLinkPayload(label: String) throws -> Data {616 let bundle = try preKeys.generateBundle(tcpAddress: myTCPAddress)617 let msg = DeviceLinkRequestMessage(618 deviceLabel: label,619 bundle: bundle,620 expiresAt: Date().addingTimeInterval(10 * 60)// 10-minute window621)622 return try JSONEncoder().encode(msg)623}624625/// Called on the scanning device after scanning a device-link QR.626/// Stores the scanned device's bundle, marks it as linked, and queues a reply so627/// the other device can complete the pairing without another QR scan.628 public func acceptDeviceLink(_ data: Data) throws {629 let msg = try JSONDecoder().decode(DeviceLinkRequestMessage.self, from: data)630 if let exp = msg.expiresAt, exp < Date(){631 throw SophaxError.invalidMessageFormat("Device link QR code has expired")632}633 let peerID = msg.bundle.peerID634 guard peerID != identity.publicIdentity.peerID else { return }635 guard try msg.bundle.verifySignedPreKey() else { throw SophaxError.invalidSignature }636637 peerBundles[peerID]= msg.bundle638 linkedDevicePeerIDs.insert(peerID)639 saveLinkedDevices()640641 let safetyNumber = generateSafetyNumber(for: msg.bundle)642 let peer = KnownPeer(from: msg.bundle, safetyNumber: safetyNumber, trustLevel:.accepted)643 knownPeers[peerID]= peer644645// Queue a reciprocal deviceLinkRequest so the other device learns our bundle automatically.646 let myBundle = try preKeys.generateBundle(tcpAddress: myTCPAddress)647 let reply = DeviceLinkRequestMessage(deviceLabel: deviceLabel, bundle: myBundle)648 let wire = try wireBuilder.build(.deviceLinkRequest, payload: reply)649 try? sendOrQueue(wire, toPeerID: peerID, messageID: UUID().uuidString)650651// Push recent message history to the newly linked device652 backfillHistory(toPeerID: peerID)653654 let peerCopy = peer655 DispatchQueue.main.async {[weak self] in656 guard let self else { return }657 self.delegate?.chatManager(self, didLinkDevice: peerCopy)658 self.delegate?.chatManager(self, didDiscoverPeer: peerCopy)659}660}661662/// Remove a linked device. Future messages will not be forwarded to it.663 public func unlinkDevice(peerID: String){664 linkedDevicePeerIDs.remove(peerID)665 saveLinkedDevices()666}667668 private func saveLinkedDevices(){669 keychain.saveLinkedDevices(Array(linkedDevicePeerIDs))670}671672 private func loadLinkedDevices(){673// Migrate from UserDefaults if present674 let defaultsKey ="com.sophax.linkedDevices"675 if let legacy = UserDefaults.standard.stringArray(forKey: defaultsKey),!legacy.isEmpty {676 linkedDevicePeerIDs = Set(legacy)677 keychain.saveLinkedDevices(legacy)678 UserDefaults.standard.removeObject(forKey: defaultsKey)679 return680}681 linkedDevicePeerIDs = Set(keychain.loadLinkedDevices())682}683684// MARK: - Public: Group reactions685686/// Send an emoji reaction (or remove one) on a specific group message.687 public func sendGroupReaction(emoji: String?, toMessageID messageID: String, groupID: String, members:[String]){688 let myID = identity.publicIdentity.peerID689 let payload = GroupReactionMessage(groupID: groupID, targetMessageID: messageID, emoji: emoji)690 guard let wire = try? wireBuilder.build(.groupReaction, payload: payload) else { return }691 for peerID in members where peerID != myID {692 try? sendOrQueue(wire, toPeerID: peerID, messageID: UUID().uuidString)693}694// Apply locally695 let convID ="group.\(groupID)"696 if let reactions = applyReaction(emoji: emoji, senderID: myID, messageID: messageID, convID: convID){697 DispatchQueue.main.async {[weak self] in698 guard let self else { return }699 self.delegate?.chatManager(self, didUpdateGroupReactions: reactions,700 onMessageID: messageID, groupID: groupID)701}702}703}704705 private func scheduleExpiryTimer(){706 expiryTimer?.invalidate()707// Run on main runloop — safe since all ChatManager state is on main708 expiryTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true){[weak self] _ in709 guard let self else { return }710 self.purgeExpiredMessages()711// Republish our DHT bundle every 24h (approx — timer fires every 60s,712// DHTEngine.publishInterval guards the actual frequency inside the engine).713 self.publishDHTBundleIfNeeded()714}715// Fire once immediately to clean up any stale messages from previous sessions716 purgeExpiredMessages()717}718719 private var lastDHTPublish: Date =.distantPast720 private func publishDHTBundleIfNeeded(){721 guard let engine = dhtEngine,722 Date().timeIntervalSince(lastDHTPublish)> DHTEngine.publishInterval else { return }723 lastDHTPublish = Date()724 guard let bundle = try? preKeys.generateBundle(tcpAddress: myTCPAddress) else { return }725 Task { await engine.publishSelf(bundle: bundle)}726}727728/// Executes a Keychain save, logging failures in debug builds.729/// Silent discard is intentional in release — Keychain errors are transient730/// (locked device, quota) and should not crash the app or abort message flow.731@inline(__always)732 private func keychainSave(_ label: String, _ operation:() throws -> Void){733 do {734 try operation()735} catch {736#if DEBUG737 print("[SophaxChat] ⚠️ Keychain save failed (\(label)): \(error)")738#endif739}740}741742 private func purgeExpiredMessages(){743 messageStore.deleteExpiredMessages()744 let now = Date()745 let beforeCount = storedForwardItems.count746 storedForwardItems.removeAll { $0.expiresAt <= now }747 if storedForwardItems.count != beforeCount { persistForwardItems()}748 deadDrops.removeAll { $0.expiresAt <= now }749 seenDeadDropIDs = seenDeadDropIDs.filter { now.timeIntervalSince($0.value)< Self.deadDropDedupeWindow }750}751752/// Maximum text message body length in UTF-8 bytes.753 public static let maxMessageBytes = 65_536 // 64 KB754755/// Maximum binary attachment size (image, audio) in bytes.756 public static let maxAttachmentBytes = 524_288 // 512 KB757758/// Maximum file attachment size in bytes (arbitrary files via file picker).759/// 2 MB keeps the JSON-encoded WireMessage comfortably under the 4 MiB TCP frame limit.760 public static let maxFileAttachmentBytes = 2_097_152 // 2 MB761762/// Maximum number of outbound messages queued per offline peer.763/// Prevents memory exhaustion if a peer never reconnects.764 private static let maxQueuedMessagesPerPeer = 100765766/// Maximum expiry interval accepted from inbound messages (1 year).767/// Clamps peer-supplied expiresAt so a malicious sender cannot set768/// an absurd far-future date to prevent local cleanup.769 private static let maxExpiryInterval: TimeInterval = 365 * 24 * 60 * 60770771/// Send a plaintext message to `peerID`.772///773/// Handles all cases automatically:774/// - New session: performs X3DH, sends `.initiateSession`775/// - Existing session: sends `.message` (Double Ratchet)776/// - Peer reachable via relay: wraps in `RelayEnvelope`777/// - No connectivity: queues for later delivery778 public func sendMessage(_ text: String, toPeerID peerID: String, expiresAt: Date?= nil, replyToID: String?= nil){779 guard !text.isEmpty, text.utf8.count <= Self.maxMessageBytes else {780 delegate?.chatManager(self, didEncounterError:781 SophaxError.invalidMessageFormat("Message must be 1–65536 bytes"))782 return783}784 let messageID = UUID().uuidString785 let stored = StoredMessage(786 id: messageID, peerID: peerID,787 direction:.sent, body: text, status:.sending,788 replyToID: replyToID789)790 do {791 try messageStore.append(message: stored)792} catch {793 CrashLogManager.shared.log(error, context:"MessageStore")794 delegate?.chatManager(self, didEncounterError: error)795 return796}797798 forwardToLinkedDevices(stored)799 DispatchQueue.main.async {[weak self] in800 guard let self else { return }801 self.delegate?.chatManager(self, didSendMessage: stored, toPeer: peerID)802}803804 do {805 let content = MessageContent(body: text, replyToID: replyToID, expiresAt: expiresAt)806 let wire = try buildOutboundWire(content: content, messageID: messageID, toPeerID: peerID)807 try sendOrQueue(wire, toPeerID: peerID, messageID: messageID)808} catch SophaxError.sessionStateCorrupted {809// Corrupt session was cleared in withSession(). Broadcast Hello so the810// peer re-initiates X3DH; the user should retry sending after reconnection.811 CrashLogManager.shared.log("Session state corrupted on send — cleared, broadcasting Hello", context:"DR")812 try? messageStore.updateStatus(.failed, forMessageID: messageID, peerID: peerID)813 broadcastHello()814 delegate?.chatManager(self, didEncounterError: SophaxError.sessionStateCorrupted)815} catch {816 CrashLogManager.shared.log(error, context:"MessageSend")817 try? messageStore.updateStatus(.failed, forMessageID: messageID, peerID: peerID)818 delegate?.chatManager(self, didEncounterError: error)819}820}821822/// Send read receipts for messages the local user has viewed.823/// Best-effort: uses direct / relay / offline-queue routing.824 public func sendReadReceipts(messageIDs:[String], toPeerID peerID: String){825 guard !messageIDs.isEmpty else { return }826 let payload = ReadReceiptMessage(messageIDs: messageIDs)827 guard let wire = try? wireBuilder.build(.readReceipt, payload: payload) else { return }828 try? sendOrQueue(wire, toPeerID: peerID, messageID: UUID().uuidString)829}830831/// Send true read receipts for group messages the local user has viewed.832/// Called by AppState when the user opens a group conversation.833/// Sends unicast to each message's original sender (not broadcast to the group).834 public func sendGroupReadReceipts(messageIDs:[String], senderPeerID: String, groupID: String){835 guard !messageIDs.isEmpty else { return }836 for messageID in messageIDs {837 let payload = GroupReadReceiptMessage(groupID: groupID, targetMessageID: messageID, isRead: true)838 guard let wire = try? wireBuilder.build(.groupReadReceipt, payload: payload) else { continue }839 try? sendOrQueue(wire, toPeerID: senderPeerID, messageID: UUID().uuidString)840}841}842843/// Edit a previously sent text message. Sends a Double Ratchet–encrypted edit to the peer.844/// Also updates the local message store immediately.845 public func sendEditMessage(messageID: String, newBody: String, toPeerID peerID: String){846 let editedAt = Date()847 let editPayload = EditMessagePayload(messageID: messageID, newBody: newBody, editedAt: editedAt)848 let content = MessageContent(body: newBody, editPayload: editPayload)849 let wireID = UUID().uuidString850 guard let wire = try? buildOutboundWire(content: content, messageID: wireID, toPeerID: peerID) else { return }851 try? sendOrQueue(wire, toPeerID: peerID, messageID: wireID)852 try? messageStore.updateMessage(id: messageID, peerID: peerID, newBody: newBody, editedAt: editedAt)853}854855/// Edit a previously sent group (Sender Keys v2) message.856/// Fans out a `groupEditMessage` to every member. Only the original sender can edit;857/// edits are text-only and must occur within 5 minutes of the original send.858 public func sendEditGroupMessage(messageID: String, newBody: String, groupID: String, members:[String]){859 guard !newBody.isEmpty, newBody.utf8.count <= Self.maxMessageBytes else { return }860 let convID ="group.\(groupID)"861 let editedAt = Date()862// Enforce: must be our own sent message, no attachment, within 5-minute window863 guard let msgs = try? messageStore.messages(forPeer: convID),864 let existing = msgs.first(where:{ $0.id == messageID }),865 existing.direction ==.sent,866 existing.attachmentID == nil,867 existing.timestamp.timeIntervalSinceNow >-300 else { return }868869 let payload = GroupEditMessagePayload(870 groupID: groupID,871 messageID: messageID,872 newBody: newBody,873 editedAt: editedAt874)875 guard let wire = try? wireBuilder.build(.groupEditMessage, payload: payload) else { return }876 let myID = identity.publicIdentity.peerID877 for peerID in members where peerID != myID {878 try? sendOrQueue(wire, toPeerID: peerID, messageID: UUID().uuidString)879}880 try? messageStore.updateMessage(id: messageID, peerID: convID, newBody: newBody, editedAt: editedAt)881882// Forward updated message to linked devices883 if let updated =(try? messageStore.messages(forPeer: convID))?.first(where:{ $0.id == messageID }){884 forwardToLinkedDevices(updated)885}886887 let body = newBody888 let editTime = editedAt889 DispatchQueue.main.async {[weak self] in890 guard let self else { return }891 self.delegate?.chatManager(self, didReceiveEditedGroupMessage: messageID,892 newBody: body, editedAt: editTime, groupID: groupID)893}894}895896/// Send an emoji reaction (or remove one) on a specific message.897/// `emoji` = nil removes any existing reaction from the local user.898 public func sendReaction(emoji: String?, toMessageID messageID: String, toPeerID peerID: String){899 let payload = ReactionMessage(targetMessageID: messageID, emoji: emoji)900 guard let wire = try? wireBuilder.build(.reaction, payload: payload) else { return }901 try? sendOrQueue(wire, toPeerID: peerID, messageID: UUID().uuidString)902}903904// MARK: - Group messaging905906/// Create a new group, distribute Sender Keys to all members, and notify the delegate.907@discardableResult908 public func createGroup(name: String, memberPeerIDs:[String])-> GroupInfo?{909 guard !name.isEmpty,!memberPeerIDs.isEmpty else { return nil }910 let myID = identity.publicIdentity.peerID911 let groupID = UUID().uuidString912 var seen = Set<String>()913 let allMembers =([myID]+ memberPeerIDs).filter { seen.insert($0).inserted }914 let group = GroupInfo(id: groupID, name: name, memberIDs: allMembers, creatorID: myID)915916// Generate my sender chain key (v2 — random 32-byte seed via CryptoKit)917 let tmpKey = SymmetricKey(size:.bits256)918 let chainKeyData = tmpKey.withUnsafeBytes { Data($0)}919 let myState = SenderKeyState(chainKey: chainKeyData, iteration: 0,920 messageCount: 0, createdAt: Date())921 do {922 try keychain.saveMySenderKeyState(myState, groupID: groupID)923} catch {924 delegate?.chatManager(self, didEncounterError: error)925 return nil926}927928 let invite = GroupInvitePayload(929 groupID: groupID,930 groupName: name,931 memberIDs: allMembers,932 creatorID: myID,933 senderChainKey: chainKeyData,934 senderIteration: 0935)936 guard let inviteData = try? JSONEncoder().encode(invite) else { return nil }937938// Send the invite to each member via the existing DR-encrypted channel939 for peerID in memberPeerIDs {940 let content = MessageContent(body: name, type:.groupInvite,941 groupInviteData: inviteData)942 if let wire = try? buildOutboundWire(content: content,943 messageID: UUID().uuidString,944 toPeerID: peerID){945 try? sendOrQueue(wire, toPeerID: peerID, messageID: UUID().uuidString)946}947}948949 joinedGroups[groupID]= Set(allMembers)950 groupCreators[groupID]= myID951 groupCoordinators[groupID]= myID952 groupCryptoVersions[groupID]=.senderKeysV2953954 DispatchQueue.main.async {[weak self] in955 guard let self else { return }956 self.delegate?.chatManager(self, didJoinGroup: group)957}958 return group959}960961/// Register groups that were persisted across restarts (called by the delegate on startup).962 public func registerKnownGroups(_ groups:[GroupInfo]){963 for group in groups {964 joinedGroups[group.id]= Set(group.memberIDs)965 groupCreators[group.id]= group.creatorID966 groupCoordinators[group.id]= group.currentCoordinatorID967 groupCryptoVersions[group.id]= group.cryptoVersion968}969}970971/// Flood a channel announcement so nearby peers (non-members) can discover this group.972///973/// Call this after `createGroup()` and whenever membership changes.974/// The announcement contains only the group name, creator ID, and member count —975/// no message content, no encryption keys.976 public func broadcastChannelAnnouncement(for group: GroupInfo){977 let announcement = ChannelAnnouncement(978 groupID: group.id,979 groupName: group.name,980 creatorID: identity.publicIdentity.peerID,981 memberCount: group.memberIDs.count982)983 guard let wire = try? wireBuilder.build(.channelAnnouncement, payload: announcement) else { return }984 try? mesh.broadcast(wire)985}986987// MARK: - Public: Channel join requests988989/// Send a DR-encrypted join request to the group creator.990/// The request is queued if the creator is currently offline and delivered when991/// they reconnect — no server required, fully P2P.992///993/// - Parameters:994/// - announcement: The ChannelAnnouncement the user wants to join.995 public func sendChannelJoinRequest(for announcement: ChannelAnnouncement){996 let myID = identity.publicIdentity.peerID997// Don't request to join a group we're already in998 guard joinedGroups[announcement.groupID]== nil,999 announcement.creatorID != myID else { return }1000 let payload = ChannelJoinRequestMessage(1001 groupID: announcement.groupID,1002 groupName: announcement.groupName,1003 requesterPeerID: myID,1004 requesterUsername: identity.publicIdentity.username1005)1006 guard let wire = try? wireBuilder.build(.channelJoinRequest, payload: payload) else { return }1007 try? sendOrQueue(wire, toPeerID: announcement.creatorID, messageID: UUID().uuidString)1008}10091010/// Add a new member to an existing group (creator-only).1011///1012/// - SKv2 groups: sends a fresh GroupInvitePayload to `newMemberPeerID` with the1013/// updated member list and the creator's current sender chain key. The new member1014/// self-announces their own sender key to existing members via the normal1015/// SenderKeyDistribution flow.1016/// - MLS groups: sends an `.mlsCommitRequest` action `.add` to self (as coordinator),1017/// which triggers addMember → Commit + unicast Welcome.1018///1019/// No-op if called by a non-creator or if the peer is already a member.1020 public func addMemberToGroup(_ group: GroupInfo, newMemberPeerID: String){1021 let myID = identity.publicIdentity.peerID1022 guard group.creatorID == myID,1023!group.memberIDs.contains(newMemberPeerID) else { return }10241025 if group.cryptoVersion ==.mls {1026// MLS path: request coordinator (ourselves) to issue an Add Commit.1027 guard let keyPackage = peerBundles[newMemberPeerID]?.mlsKeyPackage else {1028 delegate?.chatManager(self, didEncounterError:1029 SophaxError.invalidMessageFormat("Peer \(newMemberPeerID) has no MLS KeyPackage"))1030 return1031}1032 let req = MLSCommitRequestMessage(1033 groupID: group.id,1034 action:.add,1035 keyPackage: keyPackage,1036 targetPeerID: newMemberPeerID1037)1038// Call the commit-request handler directly — we are the coordinator1039 handleMLSCommitRequest(req, fromPeer: myID)1040} else {1041// SKv2 path: send a GroupInvitePayload to the new member with the current chain key.1042 guard let myState = keychain.loadMySenderKeyState(groupID: group.id) else { return }1043 let updatedMembers = group.memberIDs +[newMemberPeerID]1044 let invite = GroupInvitePayload(1045 groupID: group.id,1046 groupName: group.name,1047 memberIDs: updatedMembers,1048 creatorID: myID,1049 senderChainKey: myState.chainKey,1050 senderIteration: myState.messageCount ?? 01051)1052 guard let inviteData = try? JSONEncoder().encode(invite) else { return }1053 let content = MessageContent(body: group.name, type:.groupInvite, groupInviteData: inviteData)1054 if let wire = try? buildOutboundWire(content: content, messageID: UUID().uuidString, toPeerID: newMemberPeerID){1055 try? sendOrQueue(wire, toPeerID: newMemberPeerID, messageID: UUID().uuidString)1056}1057}1058}10591060// MARK: - Private: Channel announcement + join-request handlers10611062 private func handleChannelJoinRequest(_ payload: ChannelJoinRequestMessage, fromPeer senderID: String){1063// Sender's claimed peerID must match the DR sender we already verified1064 guard payload.requesterPeerID == senderID else { return }1065// Field-length guards1066 guard payload.groupID.count <= 64,1067 payload.groupName.count <= 64,1068 payload.requesterPeerID.count <= 64,1069 payload.requesterUsername.count <= 64 else { return }1070// We must be the creator of this group to be the right recipient1071 guard groupCreators[payload.groupID]== identity.publicIdentity.peerID else { return }1072// Requester must not already be a member1073 guard joinedGroups[payload.groupID]?.contains(payload.requesterPeerID)!= true else { return }1074// Stale requests (>48 h) are silently dropped1075 guard abs(payload.timestamp.timeIntervalSinceNow)< 172_800 else { return }10761077 let req = payload1078 DispatchQueue.main.async {[weak self] in1079 guard let self else { return }1080 self.delegate?.chatManager(self, didReceiveGroupJoinRequest: req)1081}1082}10831084 private func handleChannelAnnouncement(_ announcement: ChannelAnnouncement, fromPeer peerID: String){1085// Discard stale announcements (older than 5 minutes)1086 guard abs(announcement.timestamp.timeIntervalSinceNow)< 300 else { return }10871088// Rate-limit per sender: max 3 announcements per 5-minute window.1089// A legitimate client announces at group-creation time and rarely thereafter;1090// spamming announcements is a mesh-flooding vector.1091 if channelAnnouncementRateLimiters[peerID]== nil {1092 channelAnnouncementRateLimiters[peerID]= TokenBucket(capacity: 3, windowSeconds: 300)1093}1094 guard channelAnnouncementRateLimiters[peerID]!.consume() else { return }10951096 DispatchQueue.main.async {[weak self] in1097 guard let self else { return }1098 self.delegate?.chatManager(self, didDiscoverChannel: announcement)1099}1100}11011102/// Send a text message to all members of a group.1103 public func sendGroupMessage(_ body: String, groupID: String, members:[String], expiresAt: Date?= nil, replyToID: String?= nil){1104 guard !body.isEmpty else { return }11051106 let myID = identity.publicIdentity.peerID1107 let myUsername = identity.publicIdentity.username1108 let messageID = UUID().uuidString1109 let timestamp = Date()1110 let convID ="group.\(groupID)"11111112// Helper: surface an encryption/storage failure to the UI.1113 func fail(_ error: Error){1114 try? messageStore.updateStatus(.failed, forMessageID: messageID, peerID: convID)1115 DispatchQueue.main.async {[weak self] in1116 guard let self else { return }1117 self.delegate?.chatManager(self, didEncounterError: error)1118}1119}11201121// Store locally as .sending before encryption so the bubble appears immediately.1122 let stored = StoredMessage(1123 id: messageID, peerID: convID,1124 direction:.sent, body: body, status:.sending,1125 replyToID: replyToID, expiresAt: expiresAt, senderID: myID1126)1127 do {1128 try messageStore.append(message: stored)1129} catch {1130 delegate?.chatManager(self, didEncounterError: error)1131 return1132}1133 DispatchQueue.main.async {[weak self] in1134 guard let self else { return }1135 self.delegate?.chatManager(self, didReceiveGroupMessage: stored, inGroup: groupID)1136}11371138// Encrypt with v2 Sender Key ratchet1139 guard var myState = keychain.loadMySenderKeyState(groupID: groupID) else {1140 fail(SophaxError.encryptionFailed("No sender key found — group may have been left or key material deleted"))1141 return1142}1143 let (messageKey, nextCK)= senderKeyRatchetStep(myState.chainKey)1144 let iteration = myState.iteration1145 let newCount =(myState.messageCount ?? 0)+ 11146 myState = SenderKeyState(chainKey: nextCK, iteration: iteration + 1,1147 messageCount: newCount, createdAt: myState.createdAt ?? Date())1148 keychainSave("mySenderKey:\(groupID)"){ try keychain.saveMySenderKeyState(myState, groupID: groupID)}1149// Bind ciphertext to wire-message context so the AEAD tag authenticates1150// not just the plaintext but also which group, sender, and chain position1151// this message belongs to. Prevents a compromised relay from mixing1152// ciphertexts across groups or reordering them across chain positions.1153 let aadBody = Data("\(groupID)/\(myID)/\(iteration)/body".utf8)1154 guard let bodyData = body.data(using:.utf8),1155 let sealed = try? ChaChaPoly.seal(bodyData, using: messageKey,1156 authenticating: aadBody) else {1157 fail(SophaxError.encryptionFailed("Group message body encryption failed"))1158 return1159}1160 let ciphertext = sealed.combined1161 let senderKeyIteration = iteration11621163 let wireMsg = GroupWireMessage(1164 groupID: groupID,1165 messageID: messageID,1166 senderPeerID: myID,1167 senderUsername: myUsername,1168 timestamp: timestamp,1169 ciphertext: ciphertext,1170 senderKeyIteration: senderKeyIteration,1171 expiresAt: expiresAt,1172 replyToID: replyToID,1173 senderAvatarData: identity.loadAvatar()1174)11751176 guard let wire = try? wireBuilder.build(.groupMessage, payload: wireMsg) else {1177 fail(SophaxError.encryptionFailed("Failed to build group wire message"))1178 return1179}11801181// Mark delivered (no per-member ACK in group), then broadcast.1182 try? messageStore.updateStatus(.delivered, forMessageID: messageID, peerID: convID)11831184// Broadcast to each member (excluding self).1185// For SKv2 groups, wrap each copy in sealed sender so relay nodes cannot correlate1186// the group wire with a specific recipient. MLS groups skip this — epoch keys already1187// provide per-member confidentiality.1188// No unsealed fallback: if the DH key is unavailable the member is either unknown1189// (no Hello received yet) or transiently offline. The relay path delivers the message1190// sealed, or the member can recover via SenderKeyRequest. Never send group frames1191// in the clear — a plaintext .groupMessage frame leaks membership metadata to relays.1192 let useSealedSender = groupCryptoVersions[groupID]!=.mls1193 var sendError: Error?= nil1194 for peerID in members where peerID != myID {1195 do {1196 if useSealedSender {1197 guard let dhKey = knownPeers[peerID]?.dhKeyPublic,1198 let sealed = try? sealWireMessage(wire, recipientDHPublicKey: dhKey),1199 let sealedWire = try? wireBuilder.build(.sealed, payload: sealed) else {1200 continue // DH key unavailable — relay or SenderKeyRequest will recover1201}1202 try sendOrQueue(sealedWire, toPeerID: peerID, messageID: messageID)1203} else {1204 try sendOrQueue(wire, toPeerID: peerID, messageID: messageID)1205}1206} catch {1207 sendError = error1208}1209}1210 if let err = sendError {1211 delegate?.chatManager(self, didEncounterError: err)1212}1213// Check rotation threshold after broadcast — rotates chain for next message1214 performSenderKeyRotationIfNeeded(groupID: groupID, members: members)1215}12161217/// Send a binary attachment (image, audio, or arbitrary file) to all members of a group.1218 public func sendGroupAttachment(1219 _ data: Data,1220 mimeType: String,1221 caption: String ="",1222 filename: String?= nil,1223 audioDuration: Double?= nil,1224 groupID: String,1225 members:[String],1226 expiresAt: Date?= nil,1227 replyToID: String?= nil1228){1229 let isFile =!mimeType.hasPrefix("image/")&&!mimeType.hasPrefix("audio/")&&!mimeType.hasPrefix("video/")1230 let sizeLimit = isFile ? Self.maxFileAttachmentBytes : Self.maxAttachmentBytes1231 guard data.count <= sizeLimit else {1232 let limit = isFile ?"2 MB":"512 KB"1233 delegate?.chatManager(self, didEncounterError:1234 SophaxError.invalidMessageFormat("Attachment exceeds \(limit) limit"))1235 return1236}12371238 let myID = identity.publicIdentity.peerID1239 let myUsername = identity.publicIdentity.username1240 let messageID = UUID().uuidString1241 let attachmentID = UUID().uuidString1242 let timestamp = Date()1243 let msgType: MessageContent.MessageType1244 if mimeType.hasPrefix("image/"){ msgType =.image }1245 else if mimeType.hasPrefix("audio/"){ msgType =.audio }1246 else if mimeType.hasPrefix("video/"){ msgType =.image }1247 else { msgType =.file }12481249 let displayBody: String1250 if !caption.isEmpty { displayBody = caption }1251 else if msgType ==.image { displayBody ="📷 Photo"}1252 else if msgType ==.audio { displayBody ="🎤 Voice message"}1253 else { displayBody ="📎 \(filename ?? mimeType)"}1254 let convID ="group.\(groupID)"12551256// Helper: surface an encryption/storage failure to the UI.1257 func fail(_ error: Error){1258 try? messageStore.updateStatus(.failed, forMessageID: messageID, peerID: convID)1259 DispatchQueue.main.async {[weak self] in1260 guard let self else { return }1261 self.delegate?.chatManager(self, didEncounterError: error)1262}1263}12641265// Save attachment locally for sender's own bubble1266 try? attachmentStore.save(data, id: attachmentID)12671268// Store locally as .sending so the bubble appears immediately.1269 let stored = StoredMessage(1270 id: messageID, peerID: convID,1271 direction:.sent, body: displayBody, status:.sending,1272 replyToID: replyToID, expiresAt: expiresAt,1273 attachmentID: attachmentID, attachmentMimeType: mimeType,1274 attachmentFilename: filename,1275 audioDuration: audioDuration, senderID: myID1276)1277 do {1278 try messageStore.append(message: stored)1279} catch {1280 delegate?.chatManager(self, didEncounterError: error)1281 return1282}1283 DispatchQueue.main.async {[weak self] in1284 guard let self else { return }1285 self.delegate?.chatManager(self, didReceiveGroupMessage: stored, inGroup: groupID)1286}12871288// Encrypt body + attachment with v2 Sender Key ratchet.1289// Both use the SAME message key (one chain step = one message).1290 guard var myState = keychain.loadMySenderKeyState(groupID: groupID) else {1291 fail(SophaxError.encryptionFailed("No sender key found — group may have been left or key material deleted"))1292 return1293}1294 let (messageKey, nextCK)= senderKeyRatchetStep(myState.chainKey)1295 let iteration = myState.iteration1296 let newCount =(myState.messageCount ?? 0)+ 11297 myState = SenderKeyState(chainKey: nextCK, iteration: iteration + 1,1298 messageCount: newCount, createdAt: myState.createdAt ?? Date())1299 keychainSave("mySenderKey:\(groupID)"){ try keychain.saveMySenderKeyState(myState, groupID: groupID)}1300 let aadBody = Data("\(groupID)/\(myID)/\(iteration)/body".utf8)1301 let aadAtt = Data("\(groupID)/\(myID)/\(iteration)/att".utf8)1302 guard let bodyData = displayBody.data(using:.utf8),1303 let sealedBody = try? ChaChaPoly.seal(bodyData, using: messageKey,1304 authenticating: aadBody),1305 let sealedAtt = try? ChaChaPoly.seal(data, using: messageKey,1306 authenticating: aadAtt) else {1307 fail(SophaxError.encryptionFailed("Group attachment encryption failed"))1308 return1309}1310 let bodyCiphertext = sealedBody.combined1311 let attCiphertext = sealedAtt.combined1312 let senderKeyIteration = iteration13131314 let wireMsg = GroupWireMessage(1315 groupID: groupID,1316 messageID: messageID,1317 senderPeerID: myID,1318 senderUsername: myUsername,1319 timestamp: timestamp,1320 ciphertext: bodyCiphertext,1321 attachmentCiphertext: attCiphertext,1322 attachmentMimeType: mimeType,1323 attachmentFilename: filename,1324 audioDuration: audioDuration,1325 senderKeyIteration: senderKeyIteration,1326 expiresAt: expiresAt,1327 replyToID: replyToID,1328 senderAvatarData: identity.loadAvatar()1329)13301331 guard let wire = try? wireBuilder.build(.groupMessage, payload: wireMsg) else {1332 fail(SophaxError.encryptionFailed("Failed to build group wire message"))1333 return1334}13351336// Mark delivered (no per-member ACK in group), then broadcast.1337 try? messageStore.updateStatus(.delivered, forMessageID: messageID, peerID: convID)13381339 let useSealedSender = groupCryptoVersions[groupID]!=.mls1340 var sendError: Error?= nil1341 for peerID in members where peerID != myID {1342 do {1343 if useSealedSender {1344 guard let dhKey = knownPeers[peerID]?.dhKeyPublic,1345 let sealed = try? sealWireMessage(wire, recipientDHPublicKey: dhKey),1346 let sealedWire = try? wireBuilder.build(.sealed, payload: sealed) else {1347 continue // DH key unavailable — relay or SenderKeyRequest will recover1348}1349 try sendOrQueue(sealedWire, toPeerID: peerID, messageID: messageID)1350} else {1351 try sendOrQueue(wire, toPeerID: peerID, messageID: messageID)1352}1353} catch {1354 sendError = error1355}1356}1357 if let err = sendError {1358 delegate?.chatManager(self, didEncounterError: err)1359}1360 performSenderKeyRotationIfNeeded(groupID: groupID, members: members)1361}13621363/// Remove the local user from a group.1364///1365/// Broadcasts a `.groupMemberLeft` notification to all remaining members so they1366/// can update their local membership list and rotate their sender keys (ensuring1367/// the leaver cannot decrypt any future group messages).1368 public func leaveGroup(_ group: GroupInfo){1369 let myID = identity.publicIdentity.peerID1370 let remaining = group.memberIDs.filter { $0 != myID }13711372// MLS: if we're the current coordinator, hand off before leaving so the group1373// isn't left leaderless (no one could issue Remove/Add commits otherwise).1374// Pick the alphabetically first remaining member for determinism — the same1375// election rule is applied on the receiver side, ensuring convergence even if1376// the `.mlsCoordinatorHandoff` message and this leave message race each other.1377 var pendingCoordinatorID: String?= nil1378 if group.cryptoVersion ==.mls,1379 groupCoordinators[group.id]== myID,1380 let newCoordinator = remaining.sorted().first {1381 pendingCoordinatorID = newCoordinator1382 handoffCoordinator(group: group, newCoordinatorID: newCoordinator)1383}13841385// Notify all remaining members before deleting local crypto state.1386// pendingCoordinatorID (set above) lets the new coordinator issue a Remove1387// Commit atomically, without waiting for the separate handoff message.1388 let leaveMsg = GroupMemberLeftMessage(1389 groupID: group.id,1390 leavingPeerID: myID,1391 remainingMemberIDs: remaining,1392 pendingCoordinatorID: pendingCoordinatorID1393)1394 if let wire = try? wireBuilder.build(.groupMemberLeft, payload: leaveMsg){1395 for peerID in remaining {1396 try? sendOrQueue(wire, toPeerID: peerID, messageID: UUID().uuidString)1397}1398}13991400// Clean up local state1401 joinedGroups.removeValue(forKey: group.id)1402 groupCreators.removeValue(forKey: group.id)1403 groupCoordinators.removeValue(forKey: group.id)1404 keychain.deleteGroupKey(groupID: group.id)// v1 cleanup1405 keychain.deleteAllSenderKeyStates(groupID: group.id)// v2 cleanup1406 if group.cryptoVersion ==.mls {1407 Task { try? await self.mlsManager?.deleteGroupState(groupID: group.id)}1408}1409// Remove this group's entries from the skipped-key cache1410 let prefix = group.id +"/"1411 skippedGroupMessageKeys.keys.filter { $0.hasPrefix(prefix)}.forEach {1412 skippedGroupMessageKeys.removeValue(forKey: $0)1413 skippedGroupMessageKeyDates.removeValue(forKey: $0)1414}1415 persistSkippedGroupKeyCache()1416 try? messageStore.deleteConversation(peerID: group.conversationID)1417}14181419/// Dissolve a group — creator-only. Broadcasts `.groupDeleted` to all members,1420/// then cleans up local state identically to `leaveGroup()`.1421 public func deleteGroup(_ group: GroupInfo){1422 let myID = identity.publicIdentity.peerID1423 guard group.creatorID == myID else { return }14241425 let msg = GroupDeletedMessage(groupID: group.id, deletedByPeerID: myID)1426 if let wire = try? wireBuilder.build(.groupDeleted, payload: msg){1427 for peerID in group.memberIDs where peerID != myID {1428 try? sendOrQueue(wire, toPeerID: peerID, messageID: UUID().uuidString)1429}1430}14311432// Clean up identically to leaveGroup()1433 joinedGroups.removeValue(forKey: group.id)1434 groupCreators.removeValue(forKey: group.id)1435 groupCoordinators.removeValue(forKey: group.id)1436 keychain.deleteGroupKey(groupID: group.id)1437 keychain.deleteAllSenderKeyStates(groupID: group.id)1438 if group.cryptoVersion ==.mls {1439 Task { try? await self.mlsManager?.deleteGroupState(groupID: group.id)}1440}1441 let prefix = group.id +"/"1442 skippedGroupMessageKeys.keys.filter { $0.hasPrefix(prefix)}.forEach {1443 skippedGroupMessageKeys.removeValue(forKey: $0)1444 skippedGroupMessageKeyDates.removeValue(forKey: $0)1445}1446 persistSkippedGroupKeyCache()1447 try? messageStore.deleteConversation(peerID: group.conversationID)1448}14491450/// Remove a group from the local device only — no wire message sent.1451/// Identical crypto cleanup to `leaveGroup()` but silent: other members are1452/// not notified and continue to list this peer as a group member.1453 public func deleteGroupLocally(_ group: GroupInfo){1454 joinedGroups.removeValue(forKey: group.id)1455 groupCreators.removeValue(forKey: group.id)1456 groupCoordinators.removeValue(forKey: group.id)1457 keychain.deleteGroupKey(groupID: group.id)1458 keychain.deleteAllSenderKeyStates(groupID: group.id)1459 if group.cryptoVersion ==.mls {1460 Task { try? await self.mlsManager?.deleteGroupState(groupID: group.id)}1461}1462 let prefix = group.id +"/"1463 skippedGroupMessageKeys.keys.filter { $0.hasPrefix(prefix)}.forEach {1464 skippedGroupMessageKeys.removeValue(forKey: $0)1465 skippedGroupMessageKeyDates.removeValue(forKey: $0)1466}1467 persistSkippedGroupKeyCache()1468 try? messageStore.deleteConversation(peerID: group.conversationID)1469}14701471// MARK: - SSS Backup — public API14721473/// Split the local identity secret into N Shamir shares and send one ECDH-encrypted1474/// share to each peer in `holderPeerIDs`. Stores a manifest in Keychain so the1475/// creator can later initiate recovery by contacting the right peers.1476///1477/// - Parameters:1478/// - holderPeerIDs: PeerIDs of trusted contacts who will each hold one share.1479/// - threshold: Minimum shares needed to recover. Must be ≥ 2 and ≤ holderPeerIDs.count.1480 public func distributeSSSBackup(holderPeerIDs:[String], threshold: Int) throws {1481 let myID = identity.publicIdentity.peerID1482// Build 64-byte secret: Ed25519 || X25519 private keys1483 var secret = try identity.signingPrivateKeyData()+ identity.dhPrivateKeyData()1484 defer { secret.resetBytes(in: secret.startIndex..<secret.endIndex)}14851486 let shares = try ShamirBackup.split(secret: secret, m: threshold, n: holderPeerIDs.count)1487 let shareID = shares[0].id14881489 for (idx, holderID) in holderPeerIDs.enumerated(){1490 guard let bundle = peerBundles[holderID] else { continue }1491 let share = shares[idx]1492 let (ephKey, ciphertext)= try ShamirBackup.encryptShare(1493 share,1494 recipientDHPublicKey: bundle.dhIdentityKeyPublic1495)1496 let msg = SSSShareDeliveryMessage(1497 shareID: shareID,1498 ephemeralPublicKey: ephKey,1499 encryptedShare: ciphertext,1500 senderPeerID: myID,1501 threshold: UInt8(threshold),1502 total: UInt8(holderPeerIDs.count)1503)1504 if let wire = try? wireBuilder.build(.sssShareDelivery, payload: msg){1505 try? sendOrQueue(wire, toPeerID: holderID, messageID: UUID().uuidString)1506}1507}15081509 let manifest = SSSBackupManifest(1510 shareID: shareID,1511 threshold: threshold,1512 holderPeerIDs: holderPeerIDs,1513 createdAt: Date()1514)1515 keychain.saveSSSBackupManifest(manifest)1516}15171518/// Initiate recovery by requesting shares from all known holders in the manifest.1519/// Collected shares are accumulated in `pendingRecoveryShares`; once M are received1520/// the delegate is called with the reconstructed secret.1521 public func initiateSSSRecovery(){1522 guard let manifest = keychain.loadSSSBackupManifest() else { return }1523 pendingRecoveryShares[manifest.shareID]=[]1524 let myDHPublicKey = identity.publicIdentity.dhKeyPublic1525 let req = SSSShareRequestMessage(1526 shareID: manifest.shareID,1527 requesterDHPublicKey: myDHPublicKey1528)1529 guard let wire = try? wireBuilder.build(.sssShareRequest, payload: req) else { return }1530 for holderID in manifest.holderPeerIDs {1531 try? sendOrQueue(wire, toPeerID: holderID, messageID: UUID().uuidString)1532}1533}15341535// MARK: - SSS Backup — private handlers15361537 private func handleSSSShareDelivery(_ payload: SSSShareDeliveryMessage){1538 guard let dhPrivKey = try? keychain.loadDHIdentityKey() else { return }1539 guard let share = try? ShamirBackup.decryptShare(1540 ephPublicKey: payload.ephemeralPublicKey,1541 ciphertext: payload.encryptedShare,1542 myDHPrivateKey: dhPrivKey1543) else { return }1544 keychain.saveSSSShare(share)1545 delegate?.chatManager(self, didReceiveSSSShare: payload.shareID,1546 fromPeerID: payload.senderPeerID,1547 threshold: Int(payload.threshold),1548 total: Int(payload.total))1549}15501551 private func handleSSSShareRequest(_ payload: SSSShareRequestMessage, requesterPeerID: String){1552 let allShares = keychain.loadSSSShares()1553 guard let share = allShares.first(where:{ $0.id == payload.shareID }) else { return }1554 guard let (ephKey, ciphertext)= try? ShamirBackup.encryptShare(1555 share,1556 recipientDHPublicKey: payload.requesterDHPublicKey1557) else { return }1558 let resp = SSSShareResponseMessage(1559 shareID: payload.shareID,1560 ephemeralPublicKey: ephKey,1561 encryptedShare: ciphertext1562)1563 if let wire = try? wireBuilder.build(.sssShareResponse, payload: resp){1564 try? sendOrQueue(wire, toPeerID: requesterPeerID, messageID: UUID().uuidString)1565}1566}15671568 private func handleSSSShareResponse(_ payload: SSSShareResponseMessage){1569 guard let dhPrivKey = try? keychain.loadDHIdentityKey() else { return }1570 guard let share = try? ShamirBackup.decryptShare(1571 ephPublicKey: payload.ephemeralPublicKey,1572 ciphertext: payload.encryptedShare,1573 myDHPrivateKey: dhPrivKey1574) else { return }15751576 var collected = pendingRecoveryShares[payload.shareID, default:[]]1577 guard !collected.contains(where:{ $0.index == share.index }) else { return }1578 collected.append(share)1579 pendingRecoveryShares[payload.shareID]= collected15801581 let threshold = Int(share.threshold)1582 guard collected.count >= threshold else { return }15831584// Attempt reconstruction1585 guard var secret = try? ShamirBackup.reconstruct(shares: collected) else { return }1586 defer { secret.resetBytes(in: secret.startIndex..<secret.endIndex)}15871588 pendingRecoveryShares.removeValue(forKey: payload.shareID)1589 delegate?.chatManager(self, didRecoverSSSSecret: secret, shareID: payload.shareID)1590}15911592/// Transfer MLS commit coordinator authority to another group member.1593/// Only the current coordinator may call this. Broadcasts to all members so they1594/// update their local `groupCoordinators` record.1595 public func handoffCoordinator(group: GroupInfo, newCoordinatorID: String){1596 let myID = identity.publicIdentity.peerID1597 guard groupCoordinators[group.id]== myID,1598 group.memberIDs.contains(newCoordinatorID) else { return }15991600 let msg = MLSCoordinatorHandoffMessage(1601 groupID: group.id,1602 fromCoordinatorID: myID,1603 newCoordinatorID: newCoordinatorID1604)1605 if let wire = try? wireBuilder.build(.mlsCoordinatorHandoff, payload: msg){1606 for peerID in group.memberIDs where peerID != myID {1607 try? sendOrQueue(wire, toPeerID: peerID, messageID: UUID().uuidString)1608}1609}1610 groupCoordinators[group.id]= newCoordinatorID1611 delegate?.chatManager(self, didUpdateCoordinator: newCoordinatorID, inGroupID: group.id)1612}16131614/// Rotate our sender key for a group — generates a fresh random chain key, saves it,1615/// and distributes a new SenderKeyDistributionMessage to all members.1616/// Use this for break-in recovery when a device may have been compromised.1617 public func rotateSenderKey(forGroup group: GroupInfo){1618 let myID = identity.publicIdentity.peerID1619 let tmpKey = SymmetricKey(size:.bits256)1620 let newChainKey = tmpKey.withUnsafeBytes { Data($0)}1621 let newState = SenderKeyState(chainKey: newChainKey, iteration: 0,1622 messageCount: 0, createdAt: Date())1623 keychainSave("mySenderKey:\(group.id)"){ try self.keychain.saveMySenderKeyState(newState, groupID: group.id)}16241625 let skd = SenderKeyDistributionMessage(groupID: group.id, chainKey: newChainKey, iteration: 0)1626 guard let skdData = try? JSONEncoder().encode(skd) else { return }1627 for memberID in group.memberIDs where memberID != myID {1628 let content = MessageContent(body:"", type:.senderKeyDistribution, senderKeyData: skdData)1629 if let wire = try? buildOutboundWire(content: content,1630 messageID: UUID().uuidString,1631 toPeerID: memberID){1632 try? sendOrQueue(wire, toPeerID: memberID, messageID: UUID().uuidString)1633}1634}16351636 let manager = self1637 DispatchQueue.main.async {[weak self] in1638 guard let self else { return }1639 self.delegate?.chatManager(manager, didRotateSenderKey: group.id)1640}1641}16421643/// Send a binary attachment (image or audio) to `peerID`.1644 public func sendAttachment(1645 _ data: Data,1646 mimeType: String,1647 caption: String ="",1648 filename: String?= nil,1649 audioDuration: Double?= nil,1650 toPeerID peerID: String,1651 expiresAt: Date?= nil1652){1653 let isFile =!mimeType.hasPrefix("image/")&&!mimeType.hasPrefix("audio/")&&!mimeType.hasPrefix("video/")1654 let sizeLimit = isFile ? Self.maxFileAttachmentBytes : Self.maxAttachmentBytes1655 guard data.count <= sizeLimit else {1656 let limit = isFile ?"2 MB":"512 KB"1657 delegate?.chatManager(self, didEncounterError:1658 SophaxError.invalidMessageFormat("Attachment exceeds \(limit) limit"))1659 return1660}16611662 let messageID = UUID().uuidString1663 let attachmentID = UUID().uuidString1664 let msgType: MessageContent.MessageType1665 if mimeType.hasPrefix("image/"){ msgType =.image }1666 else if mimeType.hasPrefix("audio/"){ msgType =.audio }1667 else if mimeType.hasPrefix("video/"){ msgType =.image }// video treated as image for display1668 else { msgType =.file }16691670 let displayBody: String1671 if !caption.isEmpty { displayBody = caption }1672 else if msgType ==.image { displayBody ="📷 Photo"}1673 else if msgType ==.audio { displayBody ="🎤 Voice message"}1674 else { displayBody ="📎 \(filename ?? mimeType)"}16751676// Save attachment locally for the sender's own bubble1677 try? attachmentStore.save(data, id: attachmentID)16781679 let stored = StoredMessage(1680 id: messageID, peerID: peerID,1681 direction:.sent, body: displayBody, status:.sending,1682 attachmentID: attachmentID, attachmentMimeType: mimeType,1683 attachmentFilename: filename,1684 audioDuration: audioDuration1685)1686 do {1687 try messageStore.append(message: stored)1688} catch {1689 delegate?.chatManager(self, didEncounterError: error)1690 return1691}16921693 DispatchQueue.main.async {[weak self] in1694 guard let self else { return }1695 self.delegate?.chatManager(self, didSendMessage: stored, toPeer: peerID)1696}16971698 do {1699 let content = MessageContent(1700 body: caption, type: msgType, expiresAt: expiresAt,1701 attachmentData: data, attachmentMimeType: mimeType,1702 attachmentFilename: filename,1703 audioDuration: audioDuration1704)1705 let wire = try buildOutboundWire(content: content, messageID: messageID, toPeerID: peerID)1706 try sendOrQueue(wire, toPeerID: peerID, messageID: messageID)1707} catch SophaxError.sessionStateCorrupted {1708 CrashLogManager.shared.log("Session state corrupted on attachment send — cleared, broadcasting Hello", context:"DR")1709 try? messageStore.updateStatus(.failed, forMessageID: messageID, peerID: peerID)1710 broadcastHello()1711 delegate?.chatManager(self, didEncounterError: SophaxError.sessionStateCorrupted)1712} catch {1713 CrashLogManager.shared.log(error, context:"AttachmentSend")1714 try? messageStore.updateStatus(.failed, forMessageID: messageID, peerID: peerID)1715 delegate?.chatManager(self, didEncounterError: error)1716}1717}17181719/// All stored messages for a conversation, oldest-first.1720 public func messages(forPeer peerID: String)->[StoredMessage]{1721(try? messageStore.messages(forPeer: peerID))??[]1722}17231724/// Send a typing indicator to `peerID` (direct path only, best-effort — no relay, no queue).1725/// Wrapped in sealed sender when the recipient DH key is known so directly-connected peers1726/// cannot distinguish a typing frame from any other sealed message by type alone.1727/// Fire-and-forget: silently skipped if the DH key is not yet available (pre-Hello).1728 public func sendTypingIndicator(toPeerID peerID: String, isTyping: Bool){1729 guard mesh.isConnected(peerID: peerID) else { return }1730 guard let wire = try? wireBuilder.build(.typing, payload: TypingMessage(isTyping: isTyping)) else { return }1731 if let dhKey = knownPeers[peerID]?.dhKeyPublic,1732 let sealed = try? sealWireMessage(wire, recipientDHPublicKey: dhKey),1733 let sealedWire = try? wireBuilder.build(.sealed, payload: sealed){1734 try? mesh.send(sealedWire, toPeerID: peerID)1735} else {1736// DH key not yet established (pre-Hello exchange) — send unsealed best-effort.1737 try? mesh.send(wire, toPeerID: peerID)1738}1739}17401741/// All peers with a verified identity (online or offline).1742 public func allPeers()->[KnownPeer]{1743 Array(knownPeers.values)1744}17451746/// Manually replenish the one-time prekey pool to the default target (20 keys).1747 public func replenishPreKeys(){1748 try? preKeys.replenishIfNeeded(target: 20)1749}17501751// MARK: - Private: Build outbound wire message17521753/// Encrypt `content` and return the wire message for `peerID`.1754///1755/// - Existing session → `.message`1756/// - No session + bundle known → X3DH + `.initiateSession`1757/// - No bundle yet → throws `sessionNotInitialized` (caller queues)1758 private func buildOutboundWire(1759 content: MessageContent,1760 messageID: String,1761 toPeerID peerID: String1762) throws -> WireMessage {1763 let plaintext = try JSONEncoder().encode(content)1764 let ad = associatedData(peerID: peerID)17651766// ── Case 1: existing session ──────────────────────────────────────────1767 if let ratchetMsg = try withSession(peerID: peerID,{ ratchet in1768 try ratchet.encrypt(plaintext: plaintext, associatedData: ad)1769}){1770 let payload = ChatMessagePayload(ratchetMessage: ratchetMsg, messageID: messageID)1771 return try wireBuilder.build(.message, payload: payload)1772}17731774// ── Case 2: new session — need peer's PreKeyBundle for X3DH ──────────1775 guard let bundle = peerBundles[peerID] else {1776 throw SophaxError.sessionNotInitialized1777}17781779 let x3dhResult = try X3DH.initiateSender(1780 senderIdentity: identity.dhKeyPair,1781 recipientBundle: bundle1782)1783 let ratchet = try DoubleRatchet.initAsInitiator(1784 sharedSecret: x3dhResult.sharedSecret,1785 remoteRatchetPublicKey: bundle.signedPreKeyPublic1786)1787 let ratchetMsg = try ratchet.encrypt(plaintext: plaintext, associatedData: ad)1788 try storeNewSession(ratchet, peerID: peerID)17891790 let senderBundle = try preKeys.generateBundle()1791 let initPayload = InitiateSessionMessage(1792 senderBundle: senderBundle,1793 ephemeralPublicKey: x3dhResult.ephemeralPublicKey,1794 usedSignedPreKeyId: bundle.signedPreKeyId,1795 usedOneTimePreKeyId: x3dhResult.usedOneTimePreKeyId,1796 initialMessage: ratchetMsg,1797 pqEncapsulatedKey: x3dhResult.pqEncapsulatedKey1798)1799 return try wireBuilder.build(.initiateSession, payload: initPayload)1800}18011802// MARK: - Private: Routing18031804/// Send a wire message: direct → relay → offline queue (in priority order).1805 func sendOrQueue(1806 _ wire: WireMessage,1807 toPeerID peerID: String,1808 messageID: String1809) throws {1810// ── TCP direct path (internet / Tor) ──────────────────────────────────1811 if let tcp = tcpTransport, tcp.isConnected(peerID: peerID){1812 try tcp.send(wire, toPeerID: peerID)1813 return1814}18151816 if mesh.isConnected(peerID: peerID){1817// ── BLE/WiFi direct path ──────────────────────────────────────────1818 try mesh.send(wire, toPeerID: peerID)18191820} else if mesh.directPeerCount > 0 {1821// ── Multihop relay: seal the inner message then flood ─────────────1822// Sealed sender hides message type and payload from relay nodes.1823// Only the intended recipient can decrypt; relay nodes see only1824// the ephemeral public key and opaque ciphertext.1825 let innerWire: WireMessage1826 var sealedForTarget: SealedMessage?= nil1827 if let peer = knownPeers[peerID]{1828 let sealed = try sealWireMessage(wire, recipientDHPublicKey: peer.dhKeyPublic)1829 sealedForTarget = sealed1830 innerWire = try wireBuilder.build(.sealed, payload: sealed)1831} else {1832 innerWire = wire // Unknown peer — fallback to plaintext relay1833}18341835 let envelope = RelayEnvelope(1836 id: UUID().uuidString,1837 targetPeerID: peerID,1838 originPeerID: identity.publicIdentity.peerID,1839 ttl: RelayEnvelope.randomInitialTTL(),1840 hopCount: 0,1841 message: innerWire1842)1843 let relayWire = try wireBuilder.build(.relay, payload: envelope)1844 try mesh.broadcast(relayWire)18451846// Also forward relay envelope over any connected TCP/Tor peers so the1847// message can reach nodes outside the local BLE/WiFi mesh.1848// The inner payload is already sealed for the target; TCP relay nodes1849// see only the targetPeerID in the envelope — same exposure as mesh relay.1850 if let tcp = tcpTransport {1851 for tcpPeerID in tcp.connectedPeerIDs {1852 try? tcp.send(relayWire, toPeerID: tcpPeerID)1853}1854}18551856// ── Store-and-forward: also ask relay peers to hold the message ───1857// If the target is not currently in the mesh, at least one relay peer1858// may later encounter them. Re-uses the sealed message already produced1859// above so no extra crypto is needed.1860 if let sf = sealedForTarget {1861 let sfReq = StoreAndForwardRequest(1862 targetPeerID: peerID,1863 messageID: messageID,1864 sealed: sf,1865 expiresAt: Date().addingTimeInterval(Self.storeAndForwardTTL)1866)1867 if let sfWire = try? wireBuilder.build(.storeAndForward, payload: sfReq){1868 try? mesh.broadcast(sfWire)1869}1870}18711872} else {1873// ── No connectivity: queue for later ──────────────────────────────1874 var queue = pendingQueue[peerID, default:[]]1875 guard queue.count < Self.maxQueuedMessagesPerPeer else {1876 throw SophaxError.invalidMessageFormat("Offline message queue is full — reconnect before sending more")1877}1878 queue.append((wire: wire, messageID: messageID))1879 pendingQueue[peerID]= queue1880 persistQueue()1881}1882}18831884/// Drain queued messages for a peer that just became reachable.1885 private func drainQueue(forPeerID peerID: String){1886 guard let queued = pendingQueue.removeValue(forKey: peerID),1887!queued.isEmpty else { return }1888 for item in queued {1889 do {1890 try sendOrQueue(item.wire, toPeerID: peerID, messageID: item.messageID)1891} catch {1892 try? messageStore.updateStatus(.failed, forMessageID: item.messageID, peerID: peerID)1893}1894}1895}18961897// MARK: - Private: Session management18981899/// Execute `body` with the session for `peerID` under `sessionLock`.1900///1901/// Returns `nil` if no session exists (Keychain miss) — callers treat that as1902/// "needs X3DH initiation". Throws on Keychain or crypto errors.1903///1904/// The entire sequence — load → body → persist — is held under the lock so1905/// no two calls can operate on the same DoubleRatchet concurrently.1906 private func withSession<T>(1907 peerID: String,1908 _ body:(DoubleRatchet) throws -> T1909) throws -> T?{1910 sessionLock.lock()1911 defer { sessionLock.unlock()}19121913// Load from memory; fall back to Keychain1914 let ratchet: DoubleRatchet1915 if let existing = sessions[peerID]{1916 ratchet = existing1917} else {1918 guard let data = try? keychain.loadSessionState(peerID: peerID) else {1919 return nil // No persisted session — not an error1920}1921 do {1922 ratchet = try DoubleRatchet.importState(data)1923} catch {1924// Persisted state is malformed or truncated (e.g. crash mid-write).1925// Delete the corrupt blob so the next outbound message triggers1926// a clean X3DH re-initiation rather than failing indefinitely.1927 CrashLogManager.shared.log(error, context:"SessionLoad")1928 try? keychain.deleteSessionState(peerID: peerID)1929 sessions.removeValue(forKey: peerID)1930 throw SophaxError.sessionStateCorrupted1931}1932 sessions[peerID]= ratchet1933}19341935// Run the caller's crypto operation1936 let result = try body(ratchet)19371938// Persist mutated ratchet state (still under lock)1939 sessions[peerID]= ratchet1940 try keychain.saveSessionState(data: ratchet.exportState(), peerID: peerID)19411942 return result1943}19441945/// Store a brand-new ratchet session (after X3DH initiation or reception).1946 private func storeNewSession(_ ratchet: DoubleRatchet, peerID: String) throws {1947 sessionLock.lock()1948 defer { sessionLock.unlock()}1949 sessions[peerID]= ratchet1950 try keychain.saveSessionState(data: ratchet.exportState(), peerID: peerID)1951}19521953// MARK: - Private: Message handlers19541955 private func handleHello(_ payload: HelloMessage) throws {1956 let bundle = payload.bundle19571958// Reject oversized fields to prevent memory exhaustion from malicious peers1959 guard bundle.username.count <= 64 else { return }1960 if let avatar = bundle.avatarData {1961 guard avatar.count <= 512_000 else { return }// 512 KB max1962}19631964 guard try bundle.verifySignedPreKey() else {1965 throw SophaxError.invalidSignature1966}1967 guard abs(bundle.timestamp.timeIntervalSinceNow)< CryptoConstants.maxPreKeyBundleAge else {1968 throw SophaxError.stalePreKeyBundle1969}19701971 let peerID = bundle.peerID1972 let safetyNumber = generateSafetyNumber(for: bundle)19731974// Preserve existing trust level; new peers default to .pending (contact request gate).1975// Linked devices are always auto-accepted — they belong to the same person.1976 let existing = knownPeers[peerID]1977 let trustLevel: PeerTrustLevel = linkedDevicePeerIDs.contains(peerID)1978?.accepted1979:(existing?.trustLevel ??.pending)1980 let peer = KnownPeer(from: bundle, safetyNumber: safetyNumber, trustLevel: trustLevel)19811982// Detect reconnect: peer was known but is now coming back online1983 let wasOffline = existing.map {!$0.isOnline }?? false19841985// Record in the key transparency log; only alert if an existing peer's key changed1986 let isKnown = existing != nil1987 let logEntryIsNew = keyLog.record(1988 peerID: peerID,1989 signingKey: bundle.signingKeyPublic,1990 dhKey: bundle.dhIdentityKeyPublic1991)1992 let keyChanged = isKnown && logEntryIsNew19931994 knownPeers[peerID]= peer1995 peerBundles[peerID]= bundle19961997// Eagerly push avatar data so group-only contacts (who never send a DM) get1998// their avatar immediately after Hello rather than waiting for a group message.1999 if let avatarData = bundle.avatarData {2000 let pid = peerID2001 DispatchQueue.main.async {[weak self] in2002 guard let self else { return }2003 self.delegate?.chatManager(self, didReceiveAvatarData: avatarData, fromPeerID: pid)2004}2005}20062007 let reconnected = wasOffline2008 let isPending = trustLevel ==.pending &&!isKnown // fire request only once2009 DispatchQueue.main.async {[weak self] in2010 guard let self else { return }2011 if isPending {2012 self.delegate?.chatManager(self, didReceiveContactRequest: peer)2013} else if trustLevel ==.accepted {2014 self.delegate?.chatManager(self, didDiscoverPeer: peer)2015 if reconnected {2016 self.delegate?.chatManager(self, peerDidReconnect: peer)2017}2018}2019 if keyChanged {2020 self.delegate?.chatManager(self, didDetectKeyChange: peerID)2021}2022}20232024// Only drain/forward for accepted peers2025 if trustLevel ==.accepted {2026 drainQueue(forPeerID: peerID)2027 deliverStoredForwardItems(toPeerID: peerID)2028 deliverDeadDrops(toPeerID: peerID)2029}2030}20312032 private func handleInitiateSession(_ payload: InitiateSessionMessage) throws {2033 let senderBundle = payload.senderBundle2034 guard try senderBundle.verifySignedPreKey() else {2035 throw SophaxError.invalidSignature2036}20372038 let peerID = senderBundle.peerID20392040// Drop session initiation from peers we haven't accepted yet2041 if knownPeers[peerID]?.trustLevel ==.pending { return }20422043// Deduplication: if an active session already exists with this peerID,2044// drop the duplicate initiateSession. This prevents replayed X3DH messages2045// from overwriting an established session.2046// A legitimate re-initiation always comes from a new peerID (new identity keys).2047 if sessions[peerID]!= nil ||(try? keychain.loadSessionState(peerID: peerID))!= nil {2048 return2049}20502051 peerBundles[peerID]= senderBundle20522053// Retrieve and consume the one-time prekey if Alice used one,2054// then replenish the supply so future sessions have keys available.2055 let usedOPKId = payload.usedOneTimePreKeyId2056 var otpk: DHKeyPair?= nil2057 if let id = usedOPKId {2058 otpk = try preKeys.consumeOneTimePreKey(id: id)2059}2060 try? preKeys.replenishIfNeeded()20612062// Notify delegate so the UI can warn when no OPK was available (reduced entropy)2063 let usedOPK = usedOPKId != nil2064 let notifyPeerID = peerID2065 DispatchQueue.main.async {[weak self] in2066 guard let self else { return }2067 self.delegate?.chatManager(self, sessionEstablishedWithPeer: notifyPeerID, usedOPK: usedOPK)2068}20692070// X3DH: Bob (responder) side — produces the same shared secret as Alice.2071// Pass the PQ encapsulated key so the hybrid KEM path can run on iOS 18+.2072 let sharedSecret = try X3DH.initiateReceiver(2073 recipientIdentityDH: identity.dhKeyPair,2074 recipientSignedPreKey: preKeys.signedPreKeyPair,2075 recipientOneTimePreKey: otpk,2076 senderIdentityDHKeyData: senderBundle.dhIdentityKeyPublic,2077 senderEphemeralKeyData: payload.ephemeralPublicKey,2078 senderPQEncapsulatedKey: payload.pqEncapsulatedKey,2079 identityManager: identity2080)20812082// Double Ratchet: Bob starts as responder2083 let ratchet = try DoubleRatchet.initAsResponder(2084 sharedSecret: sharedSecret,2085 ownRatchetKeyPair: preKeys.signedPreKeyPair2086)20872088// Decrypt the initial message (Alice's first plaintext)2089 let ad = associatedData(peerID: peerID)2090 let plaintext = try ratchet.decrypt(message: payload.initialMessage, associatedData: ad)2091 let content = try JSONDecoder().decode(MessageContent.self, from: plaintext)20922093 var attachmentID: String?= nil2094 if let attachData = content.attachmentData, content.attachmentMimeType != nil {2095 let id = UUID().uuidString2096 try? attachmentStore.save(attachData, id: id)2097 attachmentID = id2098}20992100// Group invite in the initial message — unlikely but handle gracefully2101 if content.type ==.groupInvite {2102 if let inviteData = content.groupInviteData {2103 handleGroupInviteReceived(inviteData, fromPeer: peerID)2104}2105 try storeNewSession(ratchet, peerID: peerID)2106 return2107}21082109// Sender key distribution in the initial message — handle gracefully2110 if content.type ==.senderKeyDistribution {2111 handleSenderKeyDistribution(content, fromPeer: peerID)2112 try storeNewSession(ratchet, peerID: peerID)2113 return2114}21152116 let displayBody: String2117 switch content.type {2118 case .text: displayBody = content.body2119 case .image: displayBody = content.body.isEmpty ?"📷 Photo": content.body2120 case .audio: displayBody = content.body.isEmpty ?"🎤 Voice message": content.body2121 case .file: displayBody = content.attachmentFilename ??(content.body.isEmpty ?"📎 File": content.body)2122 case .groupInvite: return // dead code; handled above2123 case .senderKeyDistribution: return // dead code; handled above2124 case .mlsCommitRequest: return // wire-level only; never arrives as DR payload content2125}21262127 try storeNewSession(ratchet, peerID: peerID)21282129// Register the peer2130 let safetyNumber = generateSafetyNumber(for: senderBundle)2131 var peer = KnownPeer(from: senderBundle, safetyNumber: safetyNumber)2132 peer.isDirectlyConnected = mesh.isConnected(peerID: peerID)2133 knownPeers[peerID]= peer21342135 let stored = StoredMessage(2136 peerID: peerID,2137 direction:.received,2138 body: displayBody,2139 status:.delivered,2140 expiresAt: clampedExpiry(content.expiresAt),2141 attachmentID: attachmentID,2142 attachmentMimeType: content.attachmentMimeType,2143 attachmentFilename: content.attachmentFilename,2144 audioDuration: content.audioDuration2145)2146 try messageStore.append(message: stored)21472148 let discoveredPeer = peer2149 DispatchQueue.main.async {[weak self] in2150 guard let self else { return }2151 self.delegate?.chatManager(self, didDiscoverPeer: discoveredPeer)2152 self.delegate?.chatManager(self, didReceiveMessage: stored, fromPeer: peerID)2153}2154}21552156 private func handleChatMessage(2157 _ payload: ChatMessagePayload,2158 fromPeer peerID: String,2159 hopCount: UInt8?= nil2160) throws {2161// Drop messages from peers not yet accepted (contact request gate)2162 guard knownPeers[peerID]?.trustLevel !=.pending else { return }21632164 let ad = associatedData(peerID: peerID)21652166 guard let plaintext = try withSession(peerID: peerID,{ ratchet in2167 try ratchet.decrypt(message: payload.ratchetMessage, associatedData: ad)2168}) else {2169 throw SophaxError.sessionNotInitialized2170}21712172 let content = try JSONDecoder().decode(MessageContent.self, from: plaintext)21732174// Save attachment to local store (decrypted data is ephemeral after this)2175 var attachmentID: String?= nil2176 if let attachData = content.attachmentData, content.attachmentMimeType != nil {2177 let id = UUID().uuidString2178 try? attachmentStore.save(attachData, id: id)2179 attachmentID = id2180}21812182// Group invite — don't store as a chat message; process separately2183 if content.type ==.groupInvite {2184 if let inviteData = content.groupInviteData {2185 handleGroupInviteReceived(inviteData, fromPeer: peerID)2186}2187// Still ack delivery2188 let ack = AckMessage(messageID: payload.messageID, status:.delivered)2189 if let wire = try? wireBuilder.build(.ack, payload: ack){2190 try? sendOrQueue(wire, toPeerID: peerID, messageID: payload.messageID)2191}2192 return2193}21942195// Sender key distribution — don't store as a chat message; update crypto state2196 if content.type ==.senderKeyDistribution {2197 handleSenderKeyDistribution(content, fromPeer: peerID)2198 let ack = AckMessage(messageID: payload.messageID, status:.delivered)2199 if let wire = try? wireBuilder.build(.ack, payload: ack){2200 try? sendOrQueue(wire, toPeerID: peerID, messageID: payload.messageID)2201}2202 return2203}22042205// Edit payload — update an existing message body; don't create a new message2206 if let edit = content.editPayload {2207 handleEditMessage(edit, fromPeer: peerID)2208 return2209}22102211 let displayBody: String2212 switch content.type {2213 case .text: displayBody = content.body2214 case .image: displayBody = content.body.isEmpty ?"📷 Photo": content.body2215 case .audio: displayBody = content.body.isEmpty ?"🎤 Voice message": content.body2216 case .file: displayBody = content.attachmentFilename ??(content.body.isEmpty ?"📎 File": content.body)2217 case .groupInvite: return // already handled above; belt-and-suspenders guard2218 case .senderKeyDistribution: return // already handled above; belt-and-suspenders guard2219 case .mlsCommitRequest: return // wire-level only; never arrives as DR payload content2220}22212222 let stored = StoredMessage(2223 id: payload.messageID,2224 peerID: peerID,2225 direction:.received,2226 body: displayBody,2227 status:.delivered,2228 replyToID: content.replyToID,2229 expiresAt: clampedExpiry(content.expiresAt),2230 hopCount: hopCount,2231 attachmentID: attachmentID,2232 attachmentMimeType: content.attachmentMimeType,2233 attachmentFilename: content.attachmentFilename,2234 audioDuration: content.audioDuration2235)2236 try messageStore.append(message: stored)2237 forwardToLinkedDevices(stored)22382239// Acknowledge delivery — errors here are non-fatal (peer may be gone),2240// but surfaced to the delegate so they are visible in logs.2241 do {2242 let ack = AckMessage(messageID: payload.messageID, status:.delivered)2243 let wire = try wireBuilder.build(.ack, payload: ack)2244 try sendOrQueue(wire, toPeerID: peerID, messageID: payload.messageID)2245} catch {2246 delegate?.chatManager(self, didEncounterError: error)2247}22482249 DispatchQueue.main.async {[weak self] in2250 guard let self else { return }2251 self.delegate?.chatManager(self, didReceiveMessage: stored, fromPeer: peerID)2252}2253}22542255 private func handleAck(_ payload: AckMessage, fromPeer peerID: String){2256 try? messageStore.updateStatus(.delivered, forMessageID: payload.messageID, peerID: peerID)2257 DispatchQueue.main.async {[weak self] in2258 guard let self else { return }2259 self.delegate?.chatManager(self, messageDelivered: payload.messageID, toPeer: peerID)2260}2261}22622263 private func handleReadReceipt(_ payload: ReadReceiptMessage, fromPeer peerID: String){2264 for id in payload.messageIDs {2265 try? messageStore.updateStatus(.read, forMessageID: id, peerID: peerID)2266}2267 DispatchQueue.main.async {[weak self] in2268 guard let self else { return }2269 self.delegate?.chatManager(self, messagesRead: payload.messageIDs, byPeer: peerID)2270}2271}22722273/// Handle an incoming message edit. Only accepts edits for messages we received from `peerID`.2274 private func handleEditMessage(_ payload: EditMessagePayload, fromPeer peerID: String){2275 guard !payload.newBody.isEmpty, payload.newBody.utf8.count <= Self.maxMessageBytes else { return }2276// Only allow the original sender to edit their own messages.2277 guard let msgs = try? messageStore.messages(forPeer: peerID),2278 let existing = msgs.first(where:{ $0.id == payload.messageID }),2279 existing.direction ==.received else { return }2280 try? messageStore.updateMessage(2281 id: payload.messageID, peerID: peerID,2282 newBody: payload.newBody, editedAt: payload.editedAt2283)2284 let messageID = payload.messageID2285 let newBody = payload.newBody2286 let editedAt = payload.editedAt2287 DispatchQueue.main.async {[weak self] in2288 guard let self else { return }2289 self.delegate?.chatManager(self, didReceiveEditedMessage: messageID,2290 newBody: newBody, editedAt: editedAt, peerID: peerID)2291}2292}22932294/// Handle an incoming group message edit (Sender Keys v2).2295/// Validates authorship (senderID must match stored message senderID), 5-minute window,2296/// and text-only constraint before updating the store.2297 private func handleGroupEditMessage(_ payload: GroupEditMessagePayload, fromPeer peerID: String){2298 guard !payload.newBody.isEmpty, payload.newBody.utf8.count <= Self.maxMessageBytes else { return }2299 guard joinedGroups[payload.groupID]!= nil else { return }2300 let convID ="group.\(payload.groupID)"2301 guard let msgs = try? messageStore.messages(forPeer: convID),2302 let existing = msgs.first(where:{ $0.id == payload.messageID }),2303 existing.senderID == peerID,// only the original author can edit2304 existing.attachmentID == nil,// text-only edits2305 existing.timestamp.timeIntervalSinceNow >-310 else { return }// 5 min + 10 s clock slack2306 try? messageStore.updateMessage(2307 id: payload.messageID, peerID: convID,2308 newBody: payload.newBody, editedAt: payload.editedAt2309)2310 let messageID = payload.messageID2311 let newBody = payload.newBody2312 let editedAt = payload.editedAt2313 let groupID = payload.groupID2314 DispatchQueue.main.async {[weak self] in2315 guard let self else { return }2316 self.delegate?.chatManager(self, didReceiveEditedGroupMessage: messageID,2317 newBody: newBody, editedAt: editedAt, groupID: groupID)2318}2319}23202321 private func handleReaction(_ payload: ReactionMessage, fromPeer peerID: String){2322 guard let reactions = applyReaction(emoji: payload.emoji, senderID: peerID,2323 messageID: payload.targetMessageID, convID: peerID) else { return }2324 let messageID = payload.targetMessageID2325 DispatchQueue.main.async {[weak self] in2326 guard let self else { return }2327 self.delegate?.chatManager(self, didUpdateReactions: reactions, onMessageID: messageID, peerID: peerID)2328}2329}23302331 private func handleGroupReaction(_ payload: GroupReactionMessage, fromPeer peerID: String){2332 let convID ="group.\(payload.groupID)"2333 guard let reactions = applyReaction(emoji: payload.emoji, senderID: peerID,2334 messageID: payload.targetMessageID, convID: convID) else { return }2335 let messageID = payload.targetMessageID2336 let groupID = payload.groupID2337 DispatchQueue.main.async {[weak self] in2338 guard let self else { return }2339 self.delegate?.chatManager(self, didUpdateGroupReactions: reactions,2340 onMessageID: messageID, groupID: groupID)2341}2342}23432344/// Apply an emoji reaction (or removal) to the message store and return the updated map.2345/// Returns nil if the target message was not found.2346@discardableResult2347 func applyReaction(emoji: String?, senderID: String, messageID: String, convID: String)->[String: String]?{2348 guard let msgs = try? messageStore.messages(forPeer: convID),2349 let idx = msgs.firstIndex(where:{ $0.id == messageID }) else { return nil }2350 var reactions = msgs[idx].reactions ??[:]2351 if let e = emoji { reactions[senderID]= e } else { reactions.removeValue(forKey: senderID)}2352 try? messageStore.updateReactions(reactions, forMessageID: messageID, peerID: convID)2353 return reactions2354}23552356// MARK: - Store-and-forward handlers23572358/// A connected peer asks us to hold a sealed message for an offline third party.2359 private func handleStoreAndForward(_ payload: StoreAndForwardRequest){2360 guard payload.expiresAt > Date() else { return }2361// Dedup2362 guard !storedForwardItems.contains(where:{ $0.messageID == payload.messageID }) else { return }23632364// If target is currently connected to us, deliver immediately2365 if mesh.isConnected(peerID: payload.targetPeerID){2366 let delivery = StoreAndForwardDelivery(2367 items:[StoreAndForwardItem(messageID: payload.messageID, sealed: payload.sealed)]2368)2369 if let wire = try? wireBuilder.build(.storeAndForwardDelivery, payload: delivery){2370 try? mesh.send(wire, toPeerID: payload.targetPeerID)2371}2372 return2373}23742375// Capacity management: purge expired, enforce global cap + per-peer cap2376 let now = Date()2377 storedForwardItems.removeAll { $0.expiresAt <= now }2378// Per-peer cap: prevents a single attacker peer from consuming all slots2379 let peerCount = storedForwardItems.count(where:{ $0.targetPeerID == payload.targetPeerID })2380 guard peerCount < Self.maxStoredForwardPerPeer else { return }2381 if storedForwardItems.count >= Self.maxStoredForwardItems {2382 storedForwardItems.removeFirst()2383}23842385 let clampedExpiry = min(payload.expiresAt, Date().addingTimeInterval(Self.storeAndForwardTTL))2386 storedForwardItems.append(StoredForwardItem(2387 targetPeerID: payload.targetPeerID,2388 messageID: payload.messageID,2389 sealed: payload.sealed,2390 expiresAt: clampedExpiry2391))2392 persistForwardItems()2393}23942395/// We received stored messages from a relay peer (we are the target).2396 private func handleStoreAndForwardDelivery(_ payload: StoreAndForwardDelivery){2397 for item in payload.items {2398 guard let inner = try? unsealMessage(2399 item.sealed, recipientDHPrivateKey: identity.dhKeyPair.privateKey2400) else { continue }2401// Reject unknown senders for non-self-authenticating message types.2402 if inner.type !=.hello && inner.type !=.initiateSession {2403 guard let peer = knownPeers[inner.senderID],2404(try? WireMessageBuilder.verify(inner, signingKeyPublic: peer.signingKeyPublic))== true2405 else { continue }2406}2407 try? processRelayedInnerMessage(inner, hopCount: 0)2408}2409}24102411/// Deliver all stored-forward items to `peerID` (called when they come online).2412 private func deliverStoredForwardItems(toPeerID peerID: String){2413 let pending = storedForwardItems.filter { $0.targetPeerID == peerID && $0.expiresAt > Date()}2414 guard !pending.isEmpty else { return }2415 let items = pending.map { StoreAndForwardItem(messageID: $0.messageID, sealed: $0.sealed)}2416 let delivery = StoreAndForwardDelivery(items: items)2417 if let wire = try? wireBuilder.build(.storeAndForwardDelivery, payload: delivery){2418 try? mesh.send(wire, toPeerID: peerID)2419}2420 storedForwardItems.removeAll { $0.targetPeerID == peerID }2421 persistForwardItems()2422}24232424// MARK: - Dead Drop24252426/// Send a sealed message flooded over the entire mesh.2427/// The content is invisible to relay nodes — only the target can open it.2428/// Note: Dead drops do NOT use Double Ratchet (no session needed — that's the point).2429/// They use one-shot ECDH sealing, similar to sealed sender.2430 public func sendDeadDrop(toPeerID peerID: String, text: String) throws {2431 guard let peer = knownPeers[peerID] else { throw SophaxError.sessionNotInitialized }2432// Build a plain wire message carrying the text as a MessageContent2433 let content = MessageContent(body: text, replyToID: nil, expiresAt: nil)2434 let inner = try wireBuilder.build(.message, payload: content)2435// Seal it for the recipient's DH public key (one-shot ECDH, no session needed)2436 let sealed = try sealWireMessage(inner, recipientDHPublicKey: peer.dhKeyPublic)2437 let drop = DeadDropEnvelope(targetPeerID: peerID, sealed: sealed)2438 let wire = try wireBuilder.build(.deadDrop, payload: drop)2439// Flood over mesh — TCP intentionally excluded (dead drops are mesh-only by design)2440 try mesh.broadcast(wire, excluding: nil)2441// Store locally so we can deliver if target connects to us later2442 storeDeadDrop(drop)2443}24442445 private func handleDeadDrop(_ drop: DeadDropEnvelope){2446// Deduplicate2447 let now = Date()2448 seenDeadDropIDs = seenDeadDropIDs.filter { now.timeIntervalSince($0.value)< Self.deadDropDedupeWindow }2449// Clamp expiry to prevent attackers setting expiresAt far in the future2450// to consume unbounded storage on relay nodes.2451 let clampedExpiry = min(drop.expiresAt, now.addingTimeInterval(Self.storeAndForwardTTL))2452 guard seenDeadDropIDs[drop.id]== nil, clampedExpiry > now else { return }2453 seenDeadDropIDs[drop.id]= now24542455 let myPeerID = identity.publicIdentity.peerID24562457// Is this drop for us?2458 if drop.targetPeerID == myPeerID {2459 guard let inner = try? unsealMessage(2460 drop.sealed, recipientDHPrivateKey: identity.dhKeyPair.privateKey2461) else { return }2462 try? processRelayedInnerMessage(inner, hopCount: 0)2463 return2464}24652466// Not for us — store and re-broadcast2467 storeDeadDrop(drop)2468 if let wire = try? wireBuilder.build(.deadDrop, payload: drop){2469 try? mesh.broadcast(wire, excluding: nil)2470}24712472// If target is directly connected, deliver immediately2473 if mesh.connectedPeerIDs.contains(drop.targetPeerID){2474 if let wire = try? wireBuilder.build(.deadDrop, payload: drop){2475 try? mesh.send(wire, toPeerID: drop.targetPeerID)2476}2477}2478}24792480 private func storeDeadDrop(_ drop: DeadDropEnvelope){2481 guard drop.expiresAt > Date() else { return }2482 guard !deadDrops.contains(where:{ $0.id == drop.id }) else { return }2483 if deadDrops.count >= Self.maxDeadDrops { deadDrops.removeFirst()}2484 deadDrops.append(drop)2485}24862487/// Deliver any stored dead drops when a peer comes online.2488 private func deliverDeadDrops(toPeerID peerID: String){2489 let pending = deadDrops.filter { $0.targetPeerID == peerID && $0.expiresAt > Date()}2490 guard !pending.isEmpty else { return }2491 for drop in pending {2492 if let wire = try? wireBuilder.build(.deadDrop, payload: drop){2493 try? mesh.send(wire, toPeerID: peerID)2494}2495}2496 deadDrops.removeAll { $0.targetPeerID == peerID }2497}24982499 private func handleGroupMemberLeft(_ payload: GroupMemberLeftMessage, senderID: String){2500// Only the peer who is leaving may announce their own departure.2501// Accepting announcements from arbitrary senders would allow any peer to2502// silently eject another member from the group.2503 guard senderID == payload.leavingPeerID else { return }25042505 let myID = identity.publicIdentity.peerID2506 let groupID = payload.groupID25072508// Notify the delegate so the UI can remove the leaver from the member list2509 let leavingPeerID = payload.leavingPeerID2510 let remaining = payload.remainingMemberIDs2511 DispatchQueue.main.async {[weak self] in2512 guard let self else { return }2513 self.delegate?.chatManager(self, peer: leavingPeerID,2514 leftGroupID: groupID, remainingMemberIDs: remaining)2515}25162517// Only remaining members rotate; if we're the leaver this message is irrelevant2518 guard remaining.contains(myID) else { return }25192520// Remove the leaver's sender key state so we stop trying to decrypt with old material2521 var states = keychain.loadPeerSenderKeyStates(groupID: groupID)2522 states.removeValue(forKey: leavingPeerID)2523 keychainSave("peerSenderKeys:\(groupID)"){ try keychain.savePeerSenderKeyStates(states, groupID: groupID)}25242525// Rotate our own sender key (fresh random seed) so the leaver's copy of our2526// old chain key is no longer valid for any future messages.2527 guard keychain.loadMySenderKeyState(groupID: groupID)!= nil else { return }2528 let tmpKey = SymmetricKey(size:.bits256)2529 let newChainKey = tmpKey.withUnsafeBytes { Data($0)}2530 keychainSave("mySenderKey:\(groupID)"){2531 try keychain.saveMySenderKeyState(2532 SenderKeyState(chainKey: newChainKey, iteration: 0, messageCount: 0, createdAt: Date()),2533 groupID: groupID)2534}25352536// Re-distribute our new sender key to every remaining member (excluding self)2537 let skd = SenderKeyDistributionMessage(groupID: groupID, chainKey: newChainKey, iteration: 0)2538 guard let skdData = try? JSONEncoder().encode(skd) else { return }2539 for memberID in remaining where memberID != myID {2540 let content = MessageContent(body:"", type:.senderKeyDistribution, senderKeyData: skdData)2541 if let wire = try? buildOutboundWire(content: content,2542 messageID: UUID().uuidString,2543 toPeerID: memberID){2544 try? sendOrQueue(wire, toPeerID: memberID, messageID: UUID().uuidString)2545}2546}25472548// MLS: as coordinator, issue a Remove Commit so the epoch advances and the2549// leaver's epoch key is revoked (post-compromise security on member leave).2550//2551// pendingCoordinatorID handles the race where the leaving member was the old2552// coordinator and sent `.mlsCoordinatorHandoff` alongside this message: if that2553// field names us, we apply the coordinator update immediately (idempotent once2554// the separate handoff message also arrives) and then issue the Commit.2555 if groupCryptoVersions[groupID]==.mls {2556// Apply pending coordinator promotion if needed (order-independent of handoff msg)2557 if let pending = payload.pendingCoordinatorID, pending == myID {2558 groupCoordinators[groupID]= myID2559}2560 if groupCoordinators[groupID]== myID {2561 let leaving = leavingPeerID2562 let remain = remaining2563 Task {[weak self] in2564 guard let self else { return }2565 do {2566 let mls = try self.requireMLSManager()2567 let commitOut = try await mls.removeMember(groupID: groupID, peerID: leaving)2568 let commitMsg = MLSCommitMessage(2569 groupID: groupID,2570 epoch: commitOut.newEpoch,2571 commitBytes: commitOut.commitBytes,2572 coordinatorID: myID2573)2574 if let wire = try? self.wireBuilder.build(.mlsCommit, payload: commitMsg){2575 for peerID in remain where peerID != myID {2576 try? self.sendOrQueue(wire, toPeerID: peerID, messageID: UUID().uuidString)2577}2578}2579} catch {2580// Non-fatal — peer may not have an MLS leaf (e.g. joined via Welcome2581// from a different epoch). Log in DEBUG only.2582#if DEBUG2583 print("[ChatManager][MLS] Remove Commit failed for \(leaving): \(error)")2584#endif2585}2586}2587}2588}2589}25902591 private func handleGroupDeleted(_ payload: GroupDeletedMessage, senderID: String){2592 let groupID = payload.groupID2593// Only accept from the known group creator.2594 guard joinedGroups[groupID]!= nil,2595 let creatorID = groupCreators[groupID],2596 senderID == creatorID else { return }25972598// Notify delegate so the UI removes the group2599 DispatchQueue.main.async {[weak self] in2600 guard let self else { return }2601 self.delegate?.chatManager(self, groupDeletedWithID: groupID)2602}26032604 joinedGroups.removeValue(forKey: groupID)2605 groupCreators.removeValue(forKey: groupID)2606 groupCoordinators.removeValue(forKey: groupID)2607 keychain.deleteGroupKey(groupID: groupID)2608 keychain.deleteAllSenderKeyStates(groupID: groupID)2609 Task { try? await self.mlsManager?.deleteGroupState(groupID: groupID)}2610 let prefix = groupID +"/"2611 skippedGroupMessageKeys.keys.filter { $0.hasPrefix(prefix)}.forEach {2612 skippedGroupMessageKeys.removeValue(forKey: $0)2613 skippedGroupMessageKeyDates.removeValue(forKey: $0)2614}2615 persistSkippedGroupKeyCache()2616 try? messageStore.deleteConversation(peerID:"group.\(groupID)")2617}26182619 func handleCoordinatorHandoff(_ msg: MLSCoordinatorHandoffMessage, senderID: String){2620 let groupID = msg.groupID2621 guard joinedGroups[groupID]!= nil,2622 groupCoordinators[groupID]== senderID,// sender must be current coordinator2623 senderID == msg.fromCoordinatorID else { return }// consistency check2624 groupCoordinators[groupID]= msg.newCoordinatorID2625 DispatchQueue.main.async {[weak self] in2626 guard let self else { return }2627 self.delegate?.chatManager(self, didUpdateCoordinator: msg.newCoordinatorID, inGroupID: groupID)2628}2629}26302631 private func handleGroupInviteReceived(_ inviteData: Data, fromPeer peerID: String){2632 guard let invite = try? JSONDecoder().decode(GroupInvitePayload.self, from: inviteData) else { return }2633// The creatorID inside the payload must match the verified DR sender — prevents a peer2634// with an established session from forwarding a fabricated invite on someone else's behalf.2635 guard invite.creatorID == peerID else { return }2636// Cap member list to prevent memory DoS from a malformed or malicious invite;2637// also validate individual ID lengths to prevent oversized string allocations.2638 guard invite.memberIDs.count <= 100,2639 invite.groupID.count <= 64,2640 invite.creatorID.count <= 64,2641 invite.memberIDs.allSatisfy({ $0.count <= 64 }) else { return }2642 let myID = identity.publicIdentity.peerID26432644// Store creator's sender key state2645 var states = keychain.loadPeerSenderKeyStates(groupID: invite.groupID)2646 states[invite.creatorID]= SenderKeyState(2647 chainKey: invite.senderChainKey,2648 iteration: invite.senderIteration,2649 receivedAt: Date()2650)2651 keychainSave("peerSenderKeys:\(invite.groupID)"){ try keychain.savePeerSenderKeyStates(states, groupID: invite.groupID)}26522653// Generate my own sender key and store it2654 let tmpKey = SymmetricKey(size:.bits256)2655 let chainKeyData = tmpKey.withUnsafeBytes { Data($0)}2656 let myState = SenderKeyState(chainKey: chainKeyData, iteration: 0,2657 messageCount: 0, createdAt: Date())2658 keychainSave("mySenderKey:\(invite.groupID)"){ try keychain.saveMySenderKeyState(myState, groupID: invite.groupID)}26592660// Distribute my sender key to all other members via DR2661 let skd = SenderKeyDistributionMessage(groupID: invite.groupID, chainKey: chainKeyData)2662 if let skdData = try? JSONEncoder().encode(skd){2663 for memberID in invite.memberIDs where memberID != myID {2664 let content = MessageContent(body:"", type:.senderKeyDistribution,2665 senderKeyData: skdData)2666 if let wire = try? buildOutboundWire(content: content,2667 messageID: UUID().uuidString,2668 toPeerID: memberID){2669 try? sendOrQueue(wire, toPeerID: memberID, messageID: UUID().uuidString)2670}2671}2672}26732674 let dedupedMembers = Array(Set(invite.memberIDs))2675 let group = GroupInfo(2676 id: invite.groupID,2677 name: invite.groupName,2678 memberIDs: dedupedMembers,2679 creatorID: invite.creatorID2680)2681 joinedGroups[invite.groupID]= Set(dedupedMembers)2682 groupCreators[invite.groupID]= invite.creatorID2683 groupCoordinators[invite.groupID]= invite.creatorID26842685// Proactively exchange Hellos with members we haven't met directly.2686// Group-only contacts (no prior DM) never send a Hello, so we'd never2687// receive their bundle — and never see their avatar or MLS KeyPackage.2688// Sending our Hello triggers a reciprocal Hello from them, closing the gap.2689 for memberID in invite.memberIDs where memberID != myID && peerBundles[memberID]== nil {2690 sendHelloToPeer(memberID)2691}26922693 DispatchQueue.main.async {[weak self] in2694 guard let self else { return }2695 self.delegate?.chatManager(self, didJoinGroup: group)2696}2697}26982699 private func handleGroupMessage(_ payload: GroupWireMessage, fromPeer peerID: String){2700// The inner payload's claimed senderPeerID MUST match the outer wire senderID whose2701// Ed25519 signature was already verified by processRelayedInnerMessage. Without this2702// check a group member (B) could sign a wire message as themselves but set2703// senderPeerID = "A" inside the payload, causing the message to be attributed to A.2704 guard payload.senderPeerID == peerID else { return }27052706// Validate field lengths before any further processing to prevent large2707// in-memory allocations from malicious peers (e.g. 10 MB senderUsername).2708 guard payload.senderPeerID.count <= 64,2709 payload.senderUsername.count <= 64,2710 payload.groupID.count <= 64 else { return }27112712// Reject messages from peers who are not in this group.2713// Guard (not if-let) so that an unknown groupID is also rejected — prevents2714// injection into a group whose member list hasn't been cached yet.2715 guard let members = joinedGroups[payload.groupID],2716 members.contains(payload.senderPeerID) else { return }27172718// Reject messages with timestamps too far in the past (>5 min) or future (>30 s)2719// — same window applied to direct messages — to prevent replay and backdating.2720 let msgAge = Date().timeIntervalSince(payload.timestamp)2721 guard msgAge < 300, msgAge >-30 else { return }27222723 let body: String2724 var attachDecryptKey: SymmetricKey?= nil27252726 guard let iteration = payload.senderKeyIteration else { return }27272728// ── Sender Key ratchet ────────────────────────────────────────────2729 let MAX_SKIP: UInt32 = 1002730 let cacheKey ="\(payload.groupID)/\(payload.senderPeerID)"27312732// ── Fast path: out-of-order delivery via skipped-key cache ─────────2733 if let cachedKey = skippedGroupMessageKeys[cacheKey]?[iteration]{2734 let aadBody = Data("\(payload.groupID)/\(payload.senderPeerID)/\(iteration)/body".utf8)2735 guard let sealedBox = try? ChaChaPoly.SealedBox(combined: payload.ciphertext),2736 let bodyData = try? ChaChaPoly.open(sealedBox, using: cachedKey,2737 authenticating: aadBody),2738 let decoded = String(data: bodyData, encoding:.utf8) else { return }2739 body = decoded2740 attachDecryptKey = cachedKey2741// Consume the cached key so it cannot be replayed2742 skippedGroupMessageKeys[cacheKey]?.removeValue(forKey: iteration)2743 skippedGroupMessageKeyDates[cacheKey]?.removeValue(forKey: iteration)2744 if skippedGroupMessageKeys[cacheKey]?.isEmpty == true {2745 skippedGroupMessageKeys.removeValue(forKey: cacheKey)2746 skippedGroupMessageKeyDates.removeValue(forKey: cacheKey)2747}2748 persistSkippedGroupKeyCache()2749} else {2750// ── Normal path: advance the chain ───────────────────────────2751 var states = keychain.loadPeerSenderKeyStates(groupID: payload.groupID)2752 guard var senderState = states[payload.senderPeerID] else {2753// No key yet — request one and drop; it may arrive shortly after2754 sendSenderKeyRequest(groupID: payload.groupID, fromPeer: payload.senderPeerID)2755 return2756}27572758// If the stored key is stale (>30 days old), request a fresh one.2759// Still attempt decryption in case it works — dropping is worse than trying.2760 if let received = senderState.receivedAt,2761 Date().timeIntervalSince(received)> Self.senderKeyStaleThreshold {2762 sendSenderKeyRequest(groupID: payload.groupID, fromPeer: payload.senderPeerID)2763}2764 guard iteration >= senderState.iteration,2765 iteration - senderState.iteration <= MAX_SKIP else { return }27662767// Fast-forward to the target iteration, caching skipped message keys2768// so out-of-order messages that arrive later can still be decrypted.2769 var cached = skippedGroupMessageKeys[cacheKey]??[:]2770 var cachedDates = skippedGroupMessageKeyDates[cacheKey]??[:]2771 let now = Date()2772 while senderState.iteration < iteration {2773 let (msgKey, nextCK)= senderKeyRatchetStep(senderState.chainKey)2774 cached[senderState.iteration]= msgKey2775 cachedDates[senderState.iteration]= now2776 senderState = SenderKeyState(chainKey: nextCK, iteration: senderState.iteration + 1)2777}2778// Evict oldest entries if the cache grows too large2779 if cached.count > Self.maxSkippedKeysCacheSize {2780 let toEvict = cached.keys.sorted().prefix(cached.count - Self.maxSkippedKeysCacheSize)2781 toEvict.forEach { cached.removeValue(forKey: $0); cachedDates.removeValue(forKey: $0)}2782}2783 skippedGroupMessageKeys[cacheKey]= cached.isEmpty ? nil : cached2784 skippedGroupMessageKeyDates[cacheKey]= cachedDates.isEmpty ? nil : cachedDates2785 if !cached.isEmpty { persistSkippedGroupKeyCache()}27862787 let (messageKey, nextCK)= senderKeyRatchetStep(senderState.chainKey)2788 let aadBody = Data("\(payload.groupID)/\(payload.senderPeerID)/\(iteration)/body".utf8)2789 guard let sealedBox = try? ChaChaPoly.SealedBox(combined: payload.ciphertext),2790 let bodyData = try? ChaChaPoly.open(sealedBox, using: messageKey,2791 authenticating: aadBody),2792 let decoded = String(data: bodyData, encoding:.utf8) else { return }2793 body = decoded2794 attachDecryptKey = messageKey27952796 states[payload.senderPeerID]= SenderKeyState(chainKey: nextCK, iteration: iteration + 1,2797 receivedAt: states[payload.senderPeerID]?.receivedAt)2798 keychainSave("peerSenderKeys:\(payload.groupID)"){ try keychain.savePeerSenderKeyStates(states, groupID: payload.groupID)}2799}28002801// Decrypt attachment if present2802 var attachmentID: String?= nil2803 if let attCiphertext = payload.attachmentCiphertext,2804 payload.attachmentMimeType != nil,2805 let key = attachDecryptKey,2806 let sealedAtt = try? ChaChaPoly.SealedBox(combined: attCiphertext),2807 let attData = try? ChaChaPoly.open(sealedAtt, using: key,2808 authenticating: Data("\(payload.groupID)/\(payload.senderPeerID)/\(iteration)/att".utf8)){2809 let id = UUID().uuidString2810 try? attachmentStore.save(attData, id: id)2811 attachmentID = id2812}28132814 let convID ="group.\(payload.groupID)"2815 let stored = StoredMessage(2816 id: payload.messageID,2817 peerID: convID,2818 direction:.received,2819 body: body,2820 timestamp: payload.timestamp,2821 status:.delivered,2822 replyToID: payload.replyToID,2823 expiresAt: clampedExpiry(payload.expiresAt),2824 attachmentID: attachmentID,2825 attachmentMimeType: payload.attachmentMimeType,2826 attachmentFilename: payload.attachmentFilename,2827 audioDuration: payload.audioDuration,2828 senderID: payload.senderPeerID,2829 receivedAt: Date()2830)2831 try? messageStore.append(message: stored)28322833// Cache sender avatar if this is a group-only contact (no Hello received yet)2834 if let avatarData = payload.senderAvatarData,2835!avatarData.isEmpty, avatarData.count <= 8_192,2836 peerBundles[payload.senderPeerID]?.avatarData == nil {2837 let senderID = payload.senderPeerID2838 DispatchQueue.main.async {[weak self] in2839 guard let self else { return }2840 self.delegate?.chatManager(self, didReceiveAvatarData: avatarData, fromPeerID: senderID)2841}2842}28432844// Send a delivery receipt back to the original sender so they can track delivery.2845// Best-effort: if we have no path to the sender yet, the receipt is silently dropped.2846 let receiptPayload = GroupReadReceiptMessage(groupID: payload.groupID, targetMessageID: payload.messageID, isRead: false)2847 if let receipt = try? wireBuilder.build(.groupReadReceipt, payload: receiptPayload){2848 try? sendOrQueue(receipt, toPeerID: payload.senderPeerID, messageID: UUID().uuidString)2849}28502851 let groupID = payload.groupID2852 DispatchQueue.main.async {[weak self] in2853 guard let self else { return }2854 self.delegate?.chatManager(self, didReceiveGroupMessage: stored, inGroup: groupID)2855}2856}28572858 private func handleGroupReadReceipt(_ payload: GroupReadReceiptMessage, fromPeer peerID: String){2859 let convID ="group.\(payload.groupID)"2860 if payload.isRead == true {2861 try? messageStore.addReadBy(peerID, forMessageID: payload.targetMessageID, convID: convID)2862} else {2863 try? messageStore.addDeliveredBy(peerID, forMessageID: payload.targetMessageID, convID: convID)2864}2865 let messageID = payload.targetMessageID2866 let groupID = payload.groupID2867 let isRead = payload.isRead == true2868 DispatchQueue.main.async {[weak self] in2869 guard let self else { return }2870 self.delegate?.chatManager(self, groupMessageDelivered: messageID,2871 inGroup: groupID, byPeer: peerID, isRead: isRead)2872}2873}28742875/// Handle a RelayEnvelope arriving from a directly-connected peer.2876 private func handleRelay(_ envelope: RelayEnvelope, fromRelayPeer relayPeerID: String) throws {2877 let myPeerID = identity.publicIdentity.peerID28782879 if envelope.targetPeerID == myPeerID {2880// ── Destination reached — process the inner message ───────────────2881 let inner = envelope.message28822883// Reject unknown senders for non-self-authenticating types. processRelayedInnerMessage2884// already enforces this, but checking here avoids unnecessary decryption work.2885 if inner.type !=.hello && inner.type !=.initiateSession {2886 guard let peer = knownPeers[inner.senderID] else { return }2887 guard (try? WireMessageBuilder.verify(inner, signingKeyPublic: peer.signingKeyPublic))== true else {2888 return2889}2890}28912892 try processRelayedInnerMessage(inner, hopCount: envelope.hopCount)28932894} else {2895// ── Not for me — check dedup cache and forward if still alive ─────2896 guard !relayRouter.isRateLimited(senderID: relayPeerID) else { return }2897 guard relayRouter.shouldProcess(envelope) else { return }28982899 let forwarded = envelope.forwarded()2900 let relayWire = try wireBuilder.build(.relay, payload: forwarded)2901// Exclude the peer who sent us this envelope to avoid looping2902 try mesh.broadcast(relayWire, excluding: relayPeerID)2903}2904}29052906/// Dispatch a WireMessage that arrived via the relay system.2907/// `depth` prevents recursive sealed message processing (stack overflow / DoS).2908 private func processRelayedInnerMessage(_ message: WireMessage, hopCount: UInt8, depth: Int = 0) throws {2909// .hello and .initiateSession are self-authenticating — the signing key is embedded2910// in the payload itself (PreKeyBundle / senderBundle). All other message types must2911// come from a known peer whose key we have already verified.2912//2913// Without this check, a known relay peer could forward messages on behalf of an2914// unknown third party: the outer relay message passes the knownPeers guard in2915// handleIncomingMessage(), but the inner senderID could be anyone — allowing2916// unauthenticated reactions, read receipts, and group messages to reach the UI.2917 if message.type !=.hello && message.type !=.initiateSession {2918 guard let peer = knownPeers[message.senderID] else { return }2919 do {2920 guard try WireMessageBuilder.verify(message, signingKeyPublic: peer.signingKeyPublic) else {2921 return2922}2923} catch {2924#if DEBUG2925 print("[ChatManager] ⚠️ Sig verify error from \(message.senderID.prefix(8)): \(error)")2926#endif2927 return2928}2929}29302931 switch message.type {29322933 case .hello:2934 let payload = try wireBuilder.decodePayload(HelloMessage.self, from: message)2935// Verify using the signing key that's inside the bundle itself2936 guard try WireMessageBuilder.verify(2937 message, signingKeyPublic: payload.bundle.signingKeyPublic2938) else { throw SophaxError.invalidSignature }2939// Reject if the claimed senderID doesn't match the bundle-derived peerID2940 guard message.senderID == payload.bundle.peerID else { throw SophaxError.invalidSignature }2941 try handleHello(payload)29422943 case .initiateSession:2944 let payload = try wireBuilder.decodePayload(InitiateSessionMessage.self, from: message)2945// Self-authenticating: verify outer WireMessage signature using the key2946// embedded in the sender's bundle. This prevents a relay node from2947// substituting its own X25519 keys into the X3DH handshake (MITM).2948 guard try WireMessageBuilder.verify(2949 message, signingKeyPublic: payload.senderBundle.signingKeyPublic2950) else { throw SophaxError.invalidSignature }2951 try handleInitiateSession(payload)29522953 case .message:2954 let payload = try wireBuilder.decodePayload(ChatMessagePayload.self, from: message)2955 try handleChatMessage(payload, fromPeer: message.senderID, hopCount: hopCount)29562957 case .ack:2958 let payload = try wireBuilder.decodePayload(AckMessage.self, from: message)2959 handleAck(payload, fromPeer: message.senderID)29602961 case .readReceipt:2962 let payload = try wireBuilder.decodePayload(ReadReceiptMessage.self, from: message)2963 handleReadReceipt(payload, fromPeer: message.senderID)29642965 case .reaction:2966 let payload = try wireBuilder.decodePayload(ReactionMessage.self, from: message)2967 handleReaction(payload, fromPeer: message.senderID)29682969 case .editMessage:2970 let payload = try wireBuilder.decodePayload(EditMessagePayload.self, from: message)2971 handleEditMessage(payload, fromPeer: message.senderID)29722973 case .groupEditMessage:2974 if let payload = try? wireBuilder.decodePayload(GroupEditMessagePayload.self, from: message){2975 handleGroupEditMessage(payload, fromPeer: message.senderID)2976}29772978 case .groupMessage:2979 let payload = try wireBuilder.decodePayload(GroupWireMessage.self, from: message)2980 handleGroupMessage(payload, fromPeer: message.senderID)29812982 case .sealed:2983// Reject nested sealed messages to prevent recursive DoS (stack overflow / CPU exhaustion).2984 guard depth == 0 else { return }2985 let sealed = try wireBuilder.decodePayload(SealedMessage.self, from: message)2986 let inner = try unsealMessage(sealed, recipientDHPrivateKey: identity.dhKeyPair.privateKey)2987// Reject sealed messages from unknown senders — we cannot verify their signature.2988// Self-authenticating types (.hello, .initiateSession) are handled in the recursive2989// call's own guard block above, so they still work for new peer discovery.2990 if inner.type !=.hello && inner.type !=.initiateSession {2991 guard let peer = knownPeers[inner.senderID] else { return }2992 do {2993 guard try WireMessageBuilder.verify(inner, signingKeyPublic: peer.signingKeyPublic) else { return }2994} catch { return }2995}2996 try processRelayedInnerMessage(inner, hopCount: hopCount, depth: depth + 1)29972998 case .groupReaction:2999 let payload = try wireBuilder.decodePayload(GroupReactionMessage.self, from: message)3000 handleGroupReaction(payload, fromPeer: message.senderID)30013002 case .groupMemberLeft:3003 let payload = try wireBuilder.decodePayload(GroupMemberLeftMessage.self, from: message)3004 handleGroupMemberLeft(payload, senderID: message.senderID)30053006 case .groupDeleted:3007 if let payload = try? wireBuilder.decodePayload(GroupDeletedMessage.self, from: message){3008 handleGroupDeleted(payload, senderID: message.senderID)3009}30103011 case .groupReadReceipt:3012 let payload = try wireBuilder.decodePayload(GroupReadReceiptMessage.self, from: message)3013 handleGroupReadReceipt(payload, fromPeer: message.senderID)30143015 case .senderKeyRequest:3016 if let payload = try? wireBuilder.decodePayload(SenderKeyRequestMessage.self, from: message){3017 handleSenderKeyRequest(payload, fromPeer: message.senderID)3018}30193020 case .storeAndForward,.storeAndForwardDelivery:3021 break // S&F is direct-only; relay nodes must not forward these30223023 case .relay,.typing:3024 break // No relay-of-relay; typing over relay has no value30253026 case .channelAnnouncement:3027 break // Channel announcements are broadcast-only; not forwarded via relay30283029 case .channelJoinRequest:3030 break // DR-encrypted unicast; the outer relay layer already sealed it30313032 case .deadDrop:3033 if let drop = try? wireBuilder.decodePayload(DeadDropEnvelope.self, from: message){3034 handleDeadDrop(drop)3035}30363037 case .mlsWelcome,.mlsCommit,.mlsMessage,.mlsCommitRequest,.mlsReaction,.mlsCoordinatorHandoff,.mlsGroupEditMessage:3038 dispatchMLSMessage(message)30393040 case .deviceLinkRequest:3041 if let payload = try? wireBuilder.decodePayload(DeviceLinkRequestMessage.self, from: message){3042 handleDeviceLinkRequest(payload, senderID: message.senderID)3043}30443045 case .deviceSyncMessage:3046 if let payload = try? wireBuilder.decodePayload(DeviceSyncMessage.self, from: message){3047 handleDeviceSyncMessage(payload, fromPeer: message.senderID)3048}30493050 case .sssShareDelivery:3051 if let payload = try? wireBuilder.decodePayload(SSSShareDeliveryMessage.self, from: message){3052 handleSSSShareDelivery(payload)3053}30543055 case .sssShareRequest:3056 if let payload = try? wireBuilder.decodePayload(SSSShareRequestMessage.self, from: message){3057 handleSSSShareRequest(payload, requesterPeerID: message.senderID)3058}30593060 case .sssShareResponse:3061 if let payload = try? wireBuilder.decodePayload(SSSShareResponseMessage.self, from: message){3062 handleSSSShareResponse(payload)3063}30643065 case .remoteWipe:3066 if let payload = try? wireBuilder.decodePayload(RemoteWipeRequest.self, from: message){3067 handleRemoteWipe(payload, fromPeer: message.senderID)3068}30693070 case .dhtPing,.dhtPong,.dhtFindNode,.dhtFindNodeResp,3071.dhtStore,.dhtFindValue,.dhtFindValueResp:3072 if dhtRateLimiters[message.senderID]== nil {3073 dhtRateLimiters[message.senderID]= TokenBucket(capacity: 30, windowSeconds: 60)3074}3075 guard dhtRateLimiters[message.senderID]!.consume() else { break }3076 Task {[weak self] in3077 guard let self else { return }3078 await self.dhtEngine?.handleMessage(message, fromPeer: message.senderID)3079}3080}3081}30823083// MARK: - Private: Remote Wipe3084// Persisted to Keychain so replayed wipe requests are rejected across app restarts.3085// Capped at 200 entries; oldest are pruned when the cap is exceeded.30863087 private static let maxSeenWipeIDs = 20030883089/// Maps wipe request ID → first-seen timestamp.3090/// Using [String: Date] instead of Set<String> so eviction always removes the3091/// chronologically oldest entry rather than an arbitrary one (Set has no ordering).3092 private var seenWipeRequestIDs:[String: Date]=[:]30933094 public func sendRemoteWipe(toPeerID: String){3095 let payload = RemoteWipeRequest()3096 guard let wire = try? wireBuilder.build(.remoteWipe, payload: payload) else { return }3097 try? sendOrQueue(wire, toPeerID: toPeerID, messageID: payload.requestID)3098}30993100 public func addTrustedWipePeer(_ peerID: String){3101 var peers = trustedWipePeers3102 peers.insert(peerID)3103 keychain.saveTrustedWipePeers(Array(peers))3104}31053106 public func removeTrustedWipePeer(_ peerID: String){3107 var peers = trustedWipePeers3108 peers.remove(peerID)3109 keychain.saveTrustedWipePeers(Array(peers))3110}31113112 public var trustedWipePeers: Set<String>{3113 Set(keychain.loadTrustedWipePeers())3114}31153116 private func handleRemoteWipe(_ req: RemoteWipeRequest, fromPeer senderID: String){3117// 1. Sender must be a trusted wipe peer3118 guard trustedWipePeers.contains(senderID) else { return }3119// 2. Dedup — reject replays including across app restarts3120 guard seenWipeRequestIDs[req.requestID]== nil else { return }3121 seenWipeRequestIDs[req.requestID]= Date()3122// Cap to prevent unbounded Keychain growth; evict the chronologically oldest3123// entry so replay protection is preserved for the most recent IDs.3124 while seenWipeRequestIDs.count > Self.maxSeenWipeIDs,3125 let oldest = seenWipeRequestIDs.min(by:{ $0.value < $1.value }){3126 seenWipeRequestIDs.removeValue(forKey: oldest.key)3127}3128 keychain.saveSeenWipeRequestIDs(seenWipeRequestIDs)3129// 3. Notify delegate on main thread3130 DispatchQueue.main.async {[weak self] in3131 guard let self else { return }3132 self.delegate?.chatManagerDidReceiveRemoteWipeRequest(self)3133}3134}31353136// MARK: - Private: Multi-device handlers31373138 private func handleDeviceLinkRequest(_ payload: DeviceLinkRequestMessage, senderID: String){3139// Bundle peerID must match the outer wire senderID to prevent spoofing3140 guard payload.bundle.peerID == senderID else { return }3141 guard senderID != identity.publicIdentity.peerID else { return }3142// Reject expired QR payloads (nil expiresAt = reciprocal reply, no expiry needed)3143 if let exp = payload.expiresAt, exp < Date(){ return }3144 guard (try? payload.bundle.verifySignedPreKey())== true else { return }31453146 peerBundles[senderID]= payload.bundle3147 linkedDevicePeerIDs.insert(senderID)3148 saveLinkedDevices()31493150 let safetyNumber = generateSafetyNumber(for: payload.bundle)3151 let peer = KnownPeer(from: payload.bundle, safetyNumber: safetyNumber, trustLevel:.accepted)3152 knownPeers[senderID]= peer31533154// Push recent history to the peer that initiated the link (so both sides backfill each other)3155 backfillHistory(toPeerID: senderID)31563157 let peerCopy = peer3158 DispatchQueue.main.async {[weak self] in3159 guard let self else { return }3160 self.delegate?.chatManager(self, didLinkDevice: peerCopy)3161 self.delegate?.chatManager(self, didDiscoverPeer: peerCopy)3162}3163}31643165 private func handleDeviceSyncMessage(_ payload: DeviceSyncMessage, fromPeer peerID: String){3166// Only accept synced messages from devices we explicitly linked3167 guard linkedDevicePeerIDs.contains(peerID) else { return }3168 guard let msg = try? JSONDecoder().decode(StoredMessage.self, from: payload.messageJSON) else { return }3169// Dedup: skip if we already stored this message (could happen if both devices are online)3170 if let existing = try? messageStore.messages(forPeer: msg.peerID),3171 existing.contains(where:{ $0.id == msg.id }){ return }3172 try? messageStore.append(message: msg)3173 let convID = payload.conversationID3174 DispatchQueue.main.async {[weak self] in3175 guard let self else { return }3176 self.delegate?.chatManager(self, didReceiveSyncedMessage: msg, conversationID: convID)3177}3178}31793180/// Send the last 100 messages from every conversation to a newly linked device.3181/// Attachment bytes are not included — only message metadata and body text.3182 private func backfillHistory(toPeerID peerID: String){3183 let convIDs = messageStore.allConversationPeerIDs()3184 for convID in convIDs {3185// Skip conversations that ARE this device (would create a sync loop)3186 guard convID != peerID else { continue }3187 guard let msgs = try? messageStore.messages(forPeer: convID) else { continue }3188// Cap at the last 100 messages per conversation3189 for stored in msgs.suffix(100){3190 guard let messageJSON = try? JSONEncoder().encode(stored) else { continue }3191 let direction = stored.direction ==.sent ?"sent":"received"3192 let sync = DeviceSyncMessage(3193 conversationID: convID,3194 messageJSON: messageJSON,3195 direction: direction3196)3197 guard let wire = try? wireBuilder.build(.deviceSyncMessage, payload: sync) else { continue }3198 try? sendOrQueue(wire, toPeerID: peerID, messageID: UUID().uuidString)3199}3200}3201}32023203/// Forward a stored message to all linked devices as a `deviceSyncMessage`.3204/// Skips forwarding if this message itself came from a linked device (breaks sync loops).3205 private func forwardToLinkedDevices(_ stored: StoredMessage){3206 guard !linkedDevicePeerIDs.isEmpty else { return }3207// Don't forward messages whose peerID is a linked device — they're already synced3208 guard !linkedDevicePeerIDs.contains(stored.peerID) else { return }3209// Note to Self is local-only — never sync to linked devices3210 guard stored.peerID !="__note_to_self__" else { return }3211 guard let messageJSON = try? JSONEncoder().encode(stored) else { return }3212 let direction = stored.direction ==.sent ?"sent":"received"3213 let sync = DeviceSyncMessage(3214 conversationID: stored.peerID,3215 messageJSON: messageJSON,3216 direction: direction3217)3218 guard let wire = try? wireBuilder.build(.deviceSyncMessage, payload: sync) else { return }3219 for devicePeerID in linkedDevicePeerIDs {3220 try? sendOrQueue(wire, toPeerID: devicePeerID, messageID: UUID().uuidString)3221}3222}32233224// MARK: - Private: Persistent queue32253226 private func persistQueue(){3227 let serializable:[String:[PendingQueueItem]]= pendingQueue.mapValues { items in3228 items.map { PendingQueueItem(wire: $0.wire, messageID: $0.messageID)}3229}3230 guard let data = try? JSONEncoder().encode(serializable) else { return }3231 try? messageStore.saveEncryptedBlob(data, fileName: pendingQueueFileName)3232}32333234 private func loadPersistedQueue(){3235 guard let data = messageStore.loadEncryptedBlob(fileName: pendingQueueFileName),3236 let decoded = try? JSONDecoder().decode([String:[PendingQueueItem]].self, from: data) else { return }3237 pendingQueue = decoded.mapValues { items in3238 items.map {(wire: $0.wire, messageID: $0.messageID)}3239}3240}32413242 private func persistForwardItems(){3243 guard let data = try? JSONEncoder().encode(storedForwardItems) else { return }3244 try? messageStore.saveEncryptedBlob(data, fileName: storedForwardFileName)3245}32463247 private func loadPersistedForwardItems(){3248 guard let data = messageStore.loadEncryptedBlob(fileName: storedForwardFileName),3249 let decoded = try? JSONDecoder().decode([StoredForwardItem].self, from: data) else { return }3250 storedForwardItems = decoded.filter { $0.expiresAt > Date()}3251}32523253/// Load persisted skipped-message-key cache from Keychain into memory.3254/// Called once during `start()`.3255 private func loadSkippedGroupKeyCache(){3256 let saved = keychain.loadSkippedGroupKeys()3257 for (cacheKey, entries) in saved {3258 var keys:[UInt32: SymmetricKey]=[:]3259 var dates:[UInt32: Date]=[:]3260 for (iterStr, entry) in entries {3261 guard let iter = UInt32(iterStr) else { continue }3262 keys[iter]= SymmetricKey(data: entry.keyData)3263 dates[iter]= entry.storedAt3264}3265 if !keys.isEmpty {3266 skippedGroupMessageKeys[cacheKey]= keys3267 skippedGroupMessageKeyDates[cacheKey]= dates3268}3269}3270}32713272/// Persist the current in-memory skipped-key cache to Keychain.3273 private func persistSkippedGroupKeyCache(){3274 var entries:[String:[String: KeychainManager.SkippedKeyEntry]]=[:]3275 for (cacheKey, keys) in skippedGroupMessageKeys {3276 entries[cacheKey]= Dictionary(uniqueKeysWithValues: keys.map { iter, key in3277 let keyData = key.withUnsafeBytes { Data($0)}3278 let storedAt = skippedGroupMessageKeyDates[cacheKey]?[iter]?? Date()3279 return (String(iter), KeychainManager.SkippedKeyEntry(keyData: keyData, storedAt: storedAt))3280})3281}3282 keychainSave("skippedGroupKeyCache"){ try self.keychain.saveSkippedGroupKeys(entries)}3283}32843285// MARK: - Private: Helpers32863287/// Associated data for Double Ratchet AEAD operations.3288/// Binds the session cryptographically to the specific pair of identities.3289/// The IDs are sorted so the value is the same on both sides regardless of3290/// who initiated the session.3291 private func associatedData(peerID: String)-> Data {3292 let localID = identity.publicIdentity.peerID3293 let sortedIDs =[localID, peerID].sorted()3294 return Data((sortedIDs.joined()+ CryptoConstants.appVersion).utf8)3295}32963297/// 60-digit safety number (12 groups of 5 digits) for out-of-band3298/// identity verification, derived from SHA-512 of both identity keys.3299 private func generateSafetyNumber(for bundle: PreKeyBundle)-> String {3300 let combined = bundle.signingKeyPublic + bundle.dhIdentityKeyPublic3301 let hash = SHA512.hash(data: combined)3302 let hashData = Data(hash)3303 var groups:[String]=[]3304 for i in stride(from: 0, to: 30, by: 5){3305 let chunk = hashData[i..<(i + 5)]3306 let value = chunk.reduce(UInt64(0)){($0 << 8)| UInt64($1)}% 100_0003307 groups.append(String(format:"%05d", value))3308}3309 return groups.joined(separator:" ")3310}3311}33123313// MARK: - MeshManagerDelegate33143315extension ChatManager: MeshManagerDelegate {33163317 public func meshManager(3318 _ manager: MeshManager, didDiscoverPeer peerID: String, withName displayName: String3319){3320// Nothing yet — wait for the Hello message to learn their crypto identity3321}33223323 public func meshManager(_ manager: MeshManager, didLosePeer peerID: String){3324 knownPeers[peerID]?.isOnline = false3325 knownPeers[peerID]?.isDirectlyConnected = false3326 DispatchQueue.main.async {[weak self] in3327 guard let self else { return }3328 self.delegate?.chatManager(self, peerDidDisconnect: peerID)3329}3330}33313332 public func meshManager(_ manager: MeshManager, didConnectToPeer mcPeerID: String){3333// Send our PreKeyBundle immediately so the peer can initiate X3DH.3334// Include tcpAddress so the peer can also reach us over Tor/TCP.3335// Called on the main thread by MeshManager — all operations here are synchronous3336// and non-blocking (pure in-memory crypto + MCSession.send which is thread-safe).3337 do {3338 let bundle = try preKeys.generateBundle(tcpAddress: myTCPAddress)3339 let hello = HelloMessage(bundle: bundle)3340 let wire = try wireBuilder.build(.hello, payload: hello)3341 try mesh.send(wire, toPeerID: mcPeerID)3342} catch {3343 delegate?.chatManager(self, didEncounterError: error)3344}3345// Note: drainQueue is called from handleHello once the peer's real peerID is known3346}33473348 public func meshManager(_ manager: MeshManager, didDisconnectFromPeer peerID: String){3349 knownPeers[peerID]?.isOnline = false3350 knownPeers[peerID]?.isDirectlyConnected = false3351 DispatchQueue.main.async {[weak self] in3352 guard let self else { return }3353 self.delegate?.chatManager(self, peerDidDisconnect: peerID)3354}3355}33563357 public func meshManager(3358 _ manager: MeshManager, didReceiveMessage message: WireMessage, fromPeer mcPeerID: String3359){3360 handleIncomingMessage(message)3361}33623363 public func meshManager(3364 _ manager: MeshManager, sendDidFailForPeer peerID: String, error: Error3365){3366 delegate?.chatManager(self, didEncounterError: error)3367}33683369// MARK: - Private helpers33703371/// Shared message dispatch — called from both MeshManagerDelegate and TCPTransportDelegate.3372/// Verifies signatures and routes to the appropriate handler.3373 private func handleIncomingMessage(_ message: WireMessage){3374// Reject messages with timestamps too far from now.3375// Relay envelopes get a wider window (10 min) to tolerate multi-hop latency;3376// all other types use a strict 5-minute window.3377// Directional validation: reject messages too far in the past OR more than 30s in the future.3378// Using abs() previously allowed future-dated messages up to maxAge seconds ahead.3379 let offset: TimeInterval = message.timestamp.timeIntervalSinceNow // positive = future3380 let maxAge: TimeInterval = message.type ==.relay ? 600 : 3003381 guard offset >-maxAge, offset < 30 else { return }33823383// Signature verification:3384// • .hello — self-verifying (signing key inside bundle); handled below.3385// • .initiateSession — self-verifying (signing key inside sender bundle); handled below.3386// • everything else — MUST come from a known peer with a verified signature.3387// Unknown senders cannot send arbitrary message types; we drop silently.3388 if message.type !=.hello && message.type !=.initiateSession {3389 guard let peer = knownPeers[message.senderID] else {3390#if DEBUG3391 print("[ChatManager] ⚠️ Unknown sender: type=\(message.type.rawValue) sender=\(message.senderID.prefix(8))")3392#endif3393 return3394}3395 do {3396 guard try WireMessageBuilder.verify(message, signingKeyPublic: peer.signingKeyPublic) else {3397#if DEBUG3398 print("[ChatManager] ⚠️ Invalid signature: type=\(message.type.rawValue) sender=\(message.senderID.prefix(8))")3399#endif3400 return3401}3402} catch {3403#if DEBUG3404 print("[ChatManager] ⚠️ Sig verify error: type=\(message.type.rawValue) sender=\(message.senderID.prefix(8)) error=\(error)")3405#endif3406 return3407}3408}34093410 do {3411 switch message.type {34123413 case .hello:3414 let payload = try wireBuilder.decodePayload(HelloMessage.self, from: message)3415// Self-verifying: use the key inside the bundle3416 guard try WireMessageBuilder.verify(3417 message, signingKeyPublic: payload.bundle.signingKeyPublic3418) else { throw SophaxError.invalidSignature }3419// Reject if the claimed senderID doesn't match the bundle-derived peerID3420 guard message.senderID == payload.bundle.peerID else { throw SophaxError.invalidSignature }3421 try handleHello(payload)34223423 case .initiateSession:3424 let payload = try wireBuilder.decodePayload(InitiateSessionMessage.self, from: message)3425// Self-authenticating: verify using the key embedded in the sender's bundle.3426// The outer signature check above skips unknown senders, so3427// initiateSession must ALWAYS verify itself — same as hello.3428 guard try WireMessageBuilder.verify(3429 message, signingKeyPublic: payload.senderBundle.signingKeyPublic3430) else { throw SophaxError.invalidSignature }3431 try handleInitiateSession(payload)34323433 case .message:3434 let payload = try wireBuilder.decodePayload(ChatMessagePayload.self, from: message)3435 try handleChatMessage(payload, fromPeer: message.senderID)34363437 case .ack:3438 let payload = try wireBuilder.decodePayload(AckMessage.self, from: message)3439 handleAck(payload, fromPeer: message.senderID)34403441 case .readReceipt:3442 let payload = try wireBuilder.decodePayload(ReadReceiptMessage.self, from: message)3443 handleReadReceipt(payload, fromPeer: message.senderID)34443445 case .reaction:3446 let reactionSenderID = message.senderID3447 if reactionRateLimiters[reactionSenderID]== nil {3448 reactionRateLimiters[reactionSenderID]= TokenBucket(capacity: 20, windowSeconds: 10)3449}3450 guard reactionRateLimiters[reactionSenderID]!.consume() else { break }3451 let payload = try wireBuilder.decodePayload(ReactionMessage.self, from: message)3452 handleReaction(payload, fromPeer: reactionSenderID)34533454 case .editMessage:3455 let payload = try wireBuilder.decodePayload(EditMessagePayload.self, from: message)3456 handleEditMessage(payload, fromPeer: message.senderID)34573458 case .groupEditMessage:3459 if let payload = try? wireBuilder.decodePayload(GroupEditMessagePayload.self, from: message){3460 handleGroupEditMessage(payload, fromPeer: message.senderID)3461}34623463 case .groupMessage:3464 let payload = try wireBuilder.decodePayload(GroupWireMessage.self, from: message)3465 handleGroupMessage(payload, fromPeer: message.senderID)34663467 case .groupReaction:3468 let payload = try wireBuilder.decodePayload(GroupReactionMessage.self, from: message)3469 handleGroupReaction(payload, fromPeer: message.senderID)34703471 case .groupMemberLeft:3472 let payload = try wireBuilder.decodePayload(GroupMemberLeftMessage.self, from: message)3473 handleGroupMemberLeft(payload, senderID: message.senderID)34743475 case .groupDeleted:3476 if let payload = try? wireBuilder.decodePayload(GroupDeletedMessage.self, from: message){3477 handleGroupDeleted(payload, senderID: message.senderID)3478}34793480 case .groupReadReceipt:3481 let payload = try wireBuilder.decodePayload(GroupReadReceiptMessage.self, from: message)3482 handleGroupReadReceipt(payload, fromPeer: message.senderID)34833484 case .senderKeyRequest:3485 if let payload = try? wireBuilder.decodePayload(SenderKeyRequestMessage.self, from: message){3486 handleSenderKeyRequest(payload, fromPeer: message.senderID)3487}34883489 case .storeAndForward:3490 let payload = try wireBuilder.decodePayload(StoreAndForwardRequest.self, from: message)3491 handleStoreAndForward(payload)34923493 case .storeAndForwardDelivery:3494 let payload = try wireBuilder.decodePayload(StoreAndForwardDelivery.self, from: message)3495 handleStoreAndForwardDelivery(payload)34963497 case .channelAnnouncement:3498// Channel announcements are direct-broadcast only (1 hop).3499// They are signed so we can verify the creator's identity.3500 let payload = try wireBuilder.decodePayload(ChannelAnnouncement.self, from: message)3501 handleChannelAnnouncement(payload, fromPeer: message.senderID)35023503 case .channelJoinRequest:3504 let payload = try wireBuilder.decodePayload(ChannelJoinRequestMessage.self, from: message)3505 handleChannelJoinRequest(payload, fromPeer: message.senderID)35063507 case .relay:3508 let envelope = try wireBuilder.decodePayload(RelayEnvelope.self, from: message)3509 try handleRelay(envelope, fromRelayPeer: message.senderID)35103511 case .sealed:3512// Sealed sender arriving on a direct connection (unusual but valid)3513 let sealed = try wireBuilder.decodePayload(SealedMessage.self, from: message)3514 let inner = try unsealMessage(sealed, recipientDHPrivateKey: identity.dhKeyPair.privateKey)3515// Reject unknown senders (non-self-authenticating types).3516 if inner.type !=.hello && inner.type !=.initiateSession {3517 guard let peer = knownPeers[inner.senderID],3518(try? WireMessageBuilder.verify(inner, signingKeyPublic: peer.signingKeyPublic))== true3519 else { break }3520}3521 try processRelayedInnerMessage(inner, hopCount: 0)35223523 case .typing:3524 let typingSenderID = message.senderID3525 if typingRateLimiters[typingSenderID]== nil {3526 typingRateLimiters[typingSenderID]= TokenBucket(capacity: 10, windowSeconds: 10)3527}3528 guard typingRateLimiters[typingSenderID]!.consume() else { break }3529 let payload = try wireBuilder.decodePayload(TypingMessage.self, from: message)3530 let isTyping = payload.isTyping3531 DispatchQueue.main.async {[weak self] in3532 guard let self else { return }3533 self.delegate?.chatManager(self, peerDidUpdateTyping: typingSenderID, isTyping: isTyping)3534}35353536 case .deadDrop:3537 let payload = try wireBuilder.decodePayload(DeadDropEnvelope.self, from: message)3538 handleDeadDrop(payload)35393540 case .mlsWelcome,.mlsCommit,.mlsMessage,.mlsCommitRequest,.mlsReaction,.mlsCoordinatorHandoff,.mlsGroupEditMessage:3541 dispatchMLSMessage(message)35423543 case .deviceLinkRequest:3544 if let payload = try? wireBuilder.decodePayload(DeviceLinkRequestMessage.self, from: message){3545 handleDeviceLinkRequest(payload, senderID: message.senderID)3546}35473548 case .deviceSyncMessage:3549 if let payload = try? wireBuilder.decodePayload(DeviceSyncMessage.self, from: message){3550 handleDeviceSyncMessage(payload, fromPeer: message.senderID)3551}35523553 case .sssShareDelivery:3554 if let payload = try? wireBuilder.decodePayload(SSSShareDeliveryMessage.self, from: message){3555 handleSSSShareDelivery(payload)3556}35573558 case .sssShareRequest:3559 if let payload = try? wireBuilder.decodePayload(SSSShareRequestMessage.self, from: message){3560 handleSSSShareRequest(payload, requesterPeerID: message.senderID)3561}35623563 case .sssShareResponse:3564 if let payload = try? wireBuilder.decodePayload(SSSShareResponseMessage.self, from: message){3565 handleSSSShareResponse(payload)3566}35673568 case .remoteWipe:3569 if let payload = try? wireBuilder.decodePayload(RemoteWipeRequest.self, from: message){3570 handleRemoteWipe(payload, fromPeer: message.senderID)3571}35723573 case .dhtPing,.dhtPong,.dhtFindNode,.dhtFindNodeResp,3574.dhtStore,.dhtFindValue,.dhtFindValueResp:3575// Routed to DHTEngine — handled in Fáze 5.3576 break3577}3578} catch SophaxError.sessionStateCorrupted {3579// The persisted DR session blob was malformed (e.g. crashed mid-write).3580// The corrupt state has already been purged from Keychain in withSession().3581// Re-broadcast our Hello so the sender's next message triggers a fresh3582// X3DH handshake from their side, restoring the session automatically.3583 CrashLogManager.shared.log("Session state corrupted on receive — broadcasting Hello", context:"DR")3584 broadcastHello()3585 delegate?.chatManager(self, didEncounterError: SophaxError.sessionStateCorrupted)3586} catch {3587 CrashLogManager.shared.log(error, context:"MessageReceive")3588#if DEBUG3589 print("[ChatManager] ❌ Error: \(error) | type=\(message.type.rawValue)")3590#endif3591 delegate?.chatManager(self, didEncounterError: error)3592}3593}35943595/// Clamps a peer-supplied expiry date to at most `maxExpiryInterval` from now.3596/// Prevents a malicious sender from setting expiresAt = year 9999 to block cleanup.3597 private func clampedExpiry(_ date: Date?)-> Date?{3598 guard let date else { return nil }3599 let maxDate = Date().addingTimeInterval(Self.maxExpiryInterval)3600 return min(date, maxDate)3601}36023603// MARK: - Sender Key KDF chain (v2 group messaging)36043605/// One step of the Signal-style sender key KDF chain.3606///3607/// messageKey_n = HMAC-SHA256(chainKey_n, 0x01)3608/// chainKey_{n+1} = HMAC-SHA256(chainKey_n, 0x02)3609///3610/// The `messageKey` is used to encrypt/decrypt exactly one message.3611/// The `nextChainKey` replaces `chainKey` for subsequent messages.3612 private func senderKeyRatchetStep(3613 _ chainKey: Data3614)->(messageKey: SymmetricKey, nextChainKey: Data){3615 let ck = SymmetricKey(data: chainKey)3616 let messageKey = Data(HMAC<SHA256>.authenticationCode(for: Data([0x01]), using: ck))3617 let nextCK = Data(HMAC<SHA256>.authenticationCode(for: Data([0x02]), using: ck))3618 return (SymmetricKey(data: messageKey), nextCK)3619}36203621/// Store a peer's sender key distribution and update Keychain.3622 private func handleSenderKeyDistribution(_ content: MessageContent, fromPeer peerID: String){3623 guard let skdData = content.senderKeyData,3624 let skd = try? JSONDecoder().decode(SenderKeyDistributionMessage.self, from: skdData)3625 else { return }36263627// Reject sender key from a peer who is not in the group3628 if let members = joinedGroups[skd.groupID]{3629 guard members.contains(peerID) else { return }3630}36313632 var states = keychain.loadPeerSenderKeyStates(groupID: skd.groupID)3633// Reject non-monotonic distributions — a peer must never lower their iteration.3634// An attacker who replays an old SKD or sends iteration=0 would reset the chain3635// and break decryption for all subsequent group messages (DoS).3636// Note: equal iteration (==) is intentionally allowed — after a sender key3637// rotation the new chain always starts at iteration=0, so a peer that already3638// has state at iteration=0 must accept the new rotation SKD. Replay of an old3639// SKD at the same iteration is prevented by the DR channel's own replay protection.3640 if let existing = states[peerID], skd.iteration < existing.iteration { return }36413642// Bidirectional exchange: if this is the first time we see this sender in this3643// group, send back our own SKD so they can decrypt our messages immediately.3644// This ensures a newly joined member converges to having all members' keys3645// without requiring a separate Welcome message flow.3646 let isNewSender = states[peerID]== nil3647 states[peerID]= SenderKeyState(chainKey: skd.chainKey, iteration: skd.iteration,3648 receivedAt: Date())3649 keychainSave("peerSenderKeys:\(skd.groupID)"){ try keychain.savePeerSenderKeyStates(states, groupID: skd.groupID)}36503651 if isNewSender, sessions[peerID]!= nil,3652 let myState = keychain.loadMySenderKeyState(groupID: skd.groupID){3653 let reply = SenderKeyDistributionMessage(3654 groupID: skd.groupID,3655 chainKey: myState.chainKey,3656 iteration: myState.iteration3657)3658 if let replyData = try? JSONEncoder().encode(reply){3659 let replyContent = MessageContent(body:"", type:.senderKeyDistribution, senderKeyData: replyData)3660 if let wire = try? buildOutboundWire(content: replyContent,3661 messageID: UUID().uuidString,3662 toPeerID: peerID){3663 try? sendOrQueue(wire, toPeerID: peerID, messageID: UUID().uuidString)3664}3665}3666}3667}36683669// MARK: - Private: Sender key request36703671/// Request a peer to re-send their SenderKeyDistributionMessage.3672/// Rate-limited to once per 60 s per (group, peer) pair.3673 private func sendSenderKeyRequest(groupID: String, fromPeer peerID: String){3674 let key ="\(groupID)/\(peerID)"3675 if let last = lastSenderKeyRequestSent[key],3676 Date().timeIntervalSince(last)< Self.senderKeyRequestCooldown { return }3677 lastSenderKeyRequestSent[key]= Date()36783679 let req = SenderKeyRequestMessage(groupID: groupID, targetPeerID: peerID)3680 guard let wire = try? wireBuilder.build(.senderKeyRequest, payload: req) else { return }3681 try? sendOrQueue(wire, toPeerID: peerID, messageID: UUID().uuidString)3682}36833684/// Handle an incoming senderKeyRequest — respond by re-sending our current SKD.3685/// Rate-limited to once per 60 s per (group, requester) pair.3686 private func handleSenderKeyRequest(_ payload: SenderKeyRequestMessage, fromPeer peerID: String){3687 guard payload.groupID.count <= 64,3688 payload.targetPeerID.count <= 64 else { return }3689// Only respond if: (a) we are in this group, (b) sender is a member,3690// (c) they are asking for OUR key (not spoofing another member's peerID).3691 guard let members = joinedGroups[payload.groupID],3692 members.contains(peerID),3693 payload.targetPeerID == identity.publicIdentity.peerID else { return }36943695 let key ="\(payload.groupID)/\(peerID)"3696 if let last = lastSenderKeyRequestResponded[key],3697 Date().timeIntervalSince(last)< Self.senderKeyRequestCooldown { return }3698 lastSenderKeyRequestResponded[key]= Date()36993700 guard let myState = keychain.loadMySenderKeyState(groupID: payload.groupID) else { return }3701 let skd = SenderKeyDistributionMessage(groupID: payload.groupID,3702 chainKey: myState.chainKey,3703 iteration: myState.iteration)3704 guard let skdData = try? JSONEncoder().encode(skd) else { return }3705 let content = MessageContent(body:"", type:.senderKeyDistribution, senderKeyData: skdData)3706 if let wire = try? buildOutboundWire(content: content,3707 messageID: UUID().uuidString,3708 toPeerID: peerID){3709 try? sendOrQueue(wire, toPeerID: peerID, messageID: UUID().uuidString)3710}3711}37123713// MARK: - Private: Sender key rotation37143715/// Rotate own sender key if message count OR age threshold is exceeded.3716/// Generates a fresh random chain, saves it, and broadcasts a new3717/// SenderKeyDistributionMessage to all current group members.3718 private func performSenderKeyRotationIfNeeded(groupID: String, members:[String]){3719 guard var myState = keychain.loadMySenderKeyState(groupID: groupID) else { return }3720 let count = myState.messageCount ?? 03721 let createdAt = myState.createdAt ?? Date()3722 let age = Date().timeIntervalSince(createdAt)37233724 guard count >= Self.senderKeyRotationMessageLimit ||3725 age >= Self.senderKeyRotationAgeLimit else { return }37263727 let newChainKey = SymmetricKey(size:.bits256).withUnsafeBytes { Data($0)}3728 myState = SenderKeyState(chainKey: newChainKey, iteration: 0,3729 messageCount: 0, createdAt: Date())3730 keychainSave("mySenderKey:\(groupID)"){ try keychain.saveMySenderKeyState(myState, groupID: groupID)}37313732 let skd = SenderKeyDistributionMessage(groupID: groupID, chainKey: newChainKey, iteration: 0)3733 guard let skdData = try? JSONEncoder().encode(skd) else { return }3734 let myID = identity.publicIdentity.peerID3735 for memberID in members where memberID != myID {3736 let content = MessageContent(body:"", type:.senderKeyDistribution, senderKeyData: skdData)3737 if let wire = try? buildOutboundWire(content: content,3738 messageID: UUID().uuidString,3739 toPeerID: memberID){3740 try? sendOrQueue(wire, toPeerID: memberID, messageID: UUID().uuidString)3741}3742}3743}37443745// MARK: - Private: TCP helpers37463747/// Build a signed Hello WireMessage to send on new TCP connections.3748 private func makeTCPHello()-> WireMessage?{3749 guard let bundle = try? preKeys.generateBundle(tcpAddress: myTCPAddress),3750 let hello = try? wireBuilder.build(.hello, payload: HelloMessage(bundle: bundle))3751 else { return nil }3752 return hello3753}3754}37553756// MARK: - TCPTransportDelegate37573758extension ChatManager: TCPTransportDelegate {37593760 public func tcpTransport(3761 _ transport: TCPTransport, didConnectToPeer peerID: String, address: String3762){3763// Flush any DHT messages queued for this address3764 if let queued = pendingDHTMessages.removeValue(forKey: address){3765 for msg in queued { try? transport.send(msg, toPeerID: peerID)}3766}37673768// Mark TCP address for this peer if we know them3769 knownPeers[peerID]?.tcpAddress = address3770 knownPeers[peerID]?.isOnline = true3771 knownPeers[peerID]?.isDirectlyConnected = true3772// Send our Hello so the remote peer gets our current bundle (incl. avatar).3773// TCP connections do not go through the mesh Hello exchange, so we do it here.3774 if let bundle = try? preKeys.generateBundle(tcpAddress: myTCPAddress),3775 let wire = try? wireBuilder.build(.hello, payload: HelloMessage(bundle: bundle)){3776 try? transport.send(wire, toPeerID: peerID)3777}3778 drainQueue(forPeerID: peerID)3779 if let peer = knownPeers[peerID]{3780 let p = peer3781 DispatchQueue.main.async {[weak self] in3782 guard let self else { return }3783 self.delegate?.chatManager(self, peerDidReconnect: p)3784}3785}3786}37873788 public func tcpTransport(3789 _ transport: TCPTransport, didDisconnectFromPeer peerID: String3790){3791 knownPeers[peerID]?.isOnline = false3792 knownPeers[peerID]?.isDirectlyConnected = false3793 DispatchQueue.main.async {[weak self] in3794 guard let self else { return }3795 self.delegate?.chatManager(self, peerDidDisconnect: peerID)3796}3797}37983799 public func tcpTransport(3800 _ transport: TCPTransport, didReceiveMessage message: WireMessage, fromPeer peerID: String3801){3802// Route through the same dispatch logic as the mesh path.3803 handleIncomingMessage(message)3804}38053806 public func tcpTransport(3807 _ transport: TCPTransport, sendDidFailForPeer peerID: String, error: Error3808){3809 delegate?.chatManager(self, didEncounterError: error)3810}38113812 public func tcpTransportDidStartListening(3813 _ transport: TCPTransport, onPort port: UInt163814){3815#if DEBUG3816 print("[ChatManager] TCP listening on port \(port)")3817#endif3818}3819}38203821// MARK: - LanDiscoveryDelegate38223823extension ChatManager: LanDiscoveryDelegate {38243825/// mDNS resolved a peer's address — initiate a TCP connection.3826/// sendOrRoute() will automatically prefer TCP once the Hello handshake completes.3827 public func lanDiscovery(didFind address: String){3828 try? connectViaTCP(address: address)3829}3830}