import Foundation import CryptoKit import UIKit /// Notification types from the server enum ChatNotification { case newMessage(data: [String: Any]) case messagesRead(data: [String: Any]) case messageDeleted(data: [String: Any]) case conversationCreated(data: [String: Any]) case memberAdded(data: [String: Any]) case memberRemoved(data: [String: Any]) case userOnline(userId: String) case userOffline(userId: String) case onlineUsers(userIds: [String]) case groupInvitation(data: [String: Any]) case conversationRenamed(data: [String: Any]) case sessionReset(data: [String: Any]) case messageReacted(data: [String: Any]) case messagePinned(data: [String: Any]) case messageUnpinned(data: [String: Any]) case messageDelivered(data: [String: Any]) case conversationDeleted(data: [String: Any]) case connectionStateChanged(connected: Bool) case reconnected } /// Result of a reconnect attempt enum ReconnectResult { case success case authFailed // Keys invalid (e.g. rotated) — should logout immediately case networkError // TCP/connection issue — can retry } /// Main chat client — handles all server communication and crypto operations. /// Thread-safe via Swift actor isolation. /// Port of Python ChatClient class from chat_core.py actor ChatClient { // MARK: - Connection let connectionManager = ConnectionManager() private(set) var isConnected = false private var lastHost = Constants.defaultHost private var lastPort = Constants.defaultPort private(set) var sessionToken: String? private(set) var userId: String? private(set) var username: String = "" private(set) var email: String = "" private(set) var loginRejected = false private(set) var onlineUserIds: Set = [] // MARK: - Keys private var rsaPrivate: SecKey? private var rsaPublic: SecKey? private(set) var identityPrivate: Curve25519.Signing.PrivateKey? private(set) var identityPublic: Curve25519.Signing.PublicKey? private var spkPrivate: Curve25519.KeyAgreement.PrivateKey? private var spkId: String = "" private var prevSpkPrivate: Curve25519.KeyAgreement.PrivateKey? private var prevSpkId: String = "" private var opkPrivates: [String: Curve25519.KeyAgreement.PrivateKey] = [:] // MARK: - Sessions & Sender Keys private var sessions: [String: DoubleRatchet] = [:] // "userId:deviceId" -> ratchet private var senderKeyStates: [String: SenderKeyState] = [:] // convId -> own sender key private var recvSenderKeys: [String: SenderKeyState] = [:] // "convId:senderId:deviceId" -> their key // MARK: - Derived Keys private(set) var cacheKey: Data? // for encrypting message cache private(set) var localKey: Data? // for encrypting session/sender key files // MARK: - Multi-Device private(set) var deviceId: String? // MARK: - Caches private var userCache: [String: User] = [:] private var deviceBundleCache: [String: (timestamp: Date, bundles: [DeviceBundle])] = [:] // MARK: - Self-Encrypt Queue private var pendingSelfEncrypt: [(messageId: String, plaintext: Data)] = [] // MARK: - TOFU / Contact Verification private var knownIdentityKeys: [String: [String: String]] = [:] // userId -> {identity_key, first_seen, last_seen} private var verifiedContacts: [String: [String: String]] = [:] // userId -> {identity_key, verified_at, method} // MARK: - Brute-Force Lockout private static let lockoutBaseSeconds: TimeInterval = 2 private static let lockoutMaxSeconds: TimeInterval = 300 // MARK: - Request/Response Tracking private var pendingRequests: [String: CheckedContinuation<[String: Any], Error>] = [:] private var listenerTask: Task? // MARK: - Notification Broadcast (supports multiple consumers) private var notificationSubscribers: [UUID: AsyncStream.Continuation] = [:] /// Create a new notification stream. Each subscriber gets ALL notifications. func makeNotificationStream() -> AsyncStream { let id = UUID() var captured: AsyncStream.Continuation! let stream = AsyncStream { cont in captured = cont } captured.onTermination = { [weak self] _ in Task { await self?.removeSubscriber(id: id) } } notificationSubscribers[id] = captured return stream } private func removeSubscriber(id: UUID) { notificationSubscribers.removeValue(forKey: id) } private func broadcastNotification(_ notification: ChatNotification) { for (_, continuation) in notificationSubscribers { continuation.yield(notification) } } // MARK: - Init init() {} // MARK: - Connection func connect(host: String = Constants.defaultHost, port: UInt16 = Constants.defaultPort) async throws { try await connectionManager.connect(host: host, port: port) lastHost = host lastPort = port isConnected = true startBackgroundListener() broadcastNotification(.connectionStateChanged(connected: true)) } /// Check if the underlying TCP connection is actually alive func isConnectionAlive() async -> Bool { guard isConnected else { return false } return await connectionManager.isConnected } func disconnect() async { listenerTask?.cancel() listenerTask = nil await connectionManager.disconnect() isConnected = false // Fail all pending requests let pending = pendingRequests pendingRequests.removeAll() for (_, cont) in pending { cont.resume(throwing: NetworkError.notConnected) } broadcastNotification(.connectionStateChanged(connected: false)) } // MARK: - Send and Receive /// Send a request and wait for the matching response. func sendAndReceive(type: String, timeout: TimeInterval = 30, params: [String: Any] = [:]) async -> [String: Any] { let requestId = ProtocolHandler.newRequestId() #if DEBUG print("DEBUG sendAndReceive: type=\(type), requestId=\(requestId)") #endif // Timeout task — resumes continuation with error if server doesn't respond let timeoutTask = Task { try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) if let cont = pendingRequests.removeValue(forKey: requestId) { cont.resume(throwing: NetworkError.timeout) } } do { let response: [String: Any] = try await withCheckedThrowingContinuation { continuation in pendingRequests[requestId] = continuation Task { do { try await connectionManager.sendMessage(type: type, requestId: requestId, params: params) } catch { if let cont = pendingRequests.removeValue(forKey: requestId) { cont.resume(throwing: error) } } } } timeoutTask.cancel() return response } catch { timeoutTask.cancel() pendingRequests.removeValue(forKey: requestId) return [ "type": type, "status": "error", "data": ["message": error.localizedDescription] ] } } // MARK: - Background Listener func startBackgroundListener() { listenerTask?.cancel() listenerTask = Task { [weak self] in guard let self = self else { return } await self.backgroundListenerLoop() } } private func backgroundListenerLoop() async { while !Task.isCancelled { do { guard let msg = try await connectionManager.readMessage() else { // EOF — connection closed handleDisconnect() break } routeMessage(msg) } catch { handleDisconnect() break } } } private func handleDisconnect() { isConnected = false // Fail all pending futures let pending = pendingRequests pendingRequests.removeAll() for (_, cont) in pending { cont.resume(throwing: NetworkError.notConnected) } broadcastNotification(.connectionStateChanged(connected: false)) } private func routeMessage(_ msg: [String: Any]) { #if DEBUG print("DEBUG routeMessage received: \(msg)") #endif let msgType = msg["type"] as? String ?? "" // Notification types (no request_id expected from client) let notificationTypes = Set([ "new_message", "messages_read", "message_deleted", "conversation_created", "member_added", "member_removed", "user_online", "user_offline", "online_users", "group_invitation", "conversation_renamed", "session_reset", "keys_updated", "message_reacted", "message_pinned", "message_unpinned", "message_delivered", "conversation_deleted" ]) if notificationTypes.contains(msgType) { let data = msg["data"] as? [String: Any] ?? msg switch msgType { case "new_message": broadcastNotification(.newMessage(data: data)) case "messages_read": broadcastNotification(.messagesRead(data: data)) case "message_deleted": broadcastNotification(.messageDeleted(data: data)) case "conversation_created": broadcastNotification(.conversationCreated(data: data)) case "member_added": broadcastNotification(.memberAdded(data: data)) case "member_removed": broadcastNotification(.memberRemoved(data: data)) case "user_online": if let uid = data["user_id"] as? String { onlineUserIds.insert(uid) broadcastNotification(.userOnline(userId: uid)) } case "user_offline": if let uid = data["user_id"] as? String { onlineUserIds.remove(uid) broadcastNotification(.userOffline(userId: uid)) } case "online_users": if let uids = data["user_ids"] as? [String] { onlineUserIds = Set(uids) broadcastNotification(.onlineUsers(userIds: uids)) } case "group_invitation": broadcastNotification(.groupInvitation(data: data)) case "conversation_renamed": broadcastNotification(.conversationRenamed(data: data)) case "session_reset": broadcastNotification(.sessionReset(data: data)) case "message_reacted": broadcastNotification(.messageReacted(data: data)) case "message_pinned": broadcastNotification(.messagePinned(data: data)) case "message_unpinned": broadcastNotification(.messageUnpinned(data: data)) case "message_delivered": broadcastNotification(.messageDelivered(data: data)) case "conversation_deleted": broadcastNotification(.conversationDeleted(data: data)) case "keys_updated": // Peer uploaded new prekeys (new device or rotation) — invalidate bundle cache if let uid = data["user_id"] as? String { deviceBundleCache.removeValue(forKey: uid) #if DEBUG print("DEBUG keys_updated: invalidated bundle cache for \(uid)") #endif } default: break } } else { // Response to a pending request if let requestId = msg["request_id"] as? String, let cont = pendingRequests.removeValue(forKey: requestId) { cont.resume(returning: msg) } } } // MARK: - User Info Cache func getUserInfo(userId: String = "", userEmail: String = "") async -> User? { if !userId.isEmpty, let cached = userCache[userId] { return cached } var params: [String: Any] = [:] if !userId.isEmpty { params["user_id"] = userId } else if !userEmail.isEmpty { params["email"] = userEmail } else { return nil } let resp = await sendAndReceive(type: "get_user_info", params: params) guard resp.string(for: "status") == "ok", let data = resp.dict(for: "data") else { return nil } var ikData: Data? if let ikB64 = data["identity_key"] as? String { ikData = try? ProtocolHandler.decodeBinary(ikB64) } let user = User( id: data.string(for: "user_id") ?? "", username: data.string(for: "username") ?? "", email: data.string(for: "email") ?? "", identityKey: ikData ) userCache[user.id] = user return user } // MARK: - Registration func register(username: String, password: String, email: String) async -> (success: Bool, message: String) { self.username = username self.email = email var pwdBytes = Array(password.utf8) defer { pwdBytes.withUnsafeMutableBytes { ptr in _ = memset(ptr.baseAddress!, 0, ptr.count) } } let pwdData = Data(pwdBytes) do { // RSA keys let (rsaPriv, rsaPub, _) = KeyStorage.loadRSAKeys(email: email, password: pwdData) if let rsaPriv = rsaPriv, let rsaPub = rsaPub { self.rsaPrivate = rsaPriv self.rsaPublic = rsaPub } else { let (newPriv, newPub) = try RSACrypto.generateKeypair() try KeyStorage.saveRSAKeys(email: email, privateKey: newPriv, publicKey: newPub, password: pwdData) self.rsaPrivate = newPriv self.rsaPublic = newPub } // Ed25519 identity keys let (edPriv, edPub) = KeyStorage.loadIdentityKeys(email: email, password: pwdData) if let edPriv = edPriv, let edPub = edPub { self.identityPrivate = edPriv self.identityPublic = edPub } else { let (newPriv, newPub) = Ed25519Crypto.generateKeypair() try KeyStorage.saveIdentityKeys(email: email, privateKey: newPriv, publicKey: newPub, password: pwdData) self.identityPrivate = newPriv self.identityPublic = newPub } self.cacheKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: identityPrivate!.rawData) self.localKey = CryptoUtils.deriveLocalStorageKey(identityPrivateRaw: identityPrivate!.rawData) } catch { return (false, "Key generation failed: \(error.localizedDescription)") } // Send registration request let pubPem = String(data: try! RSACrypto.serializePublicKey(rsaPublic!), encoding: .utf8)! let ikB64 = ProtocolHandler.encodeBinary(Ed25519Crypto.serializePublic(identityPublic!)) let resp = await sendAndReceive(type: "register", params: [ "username": username, "public_key": pubPem, "email": email, "identity_key": ikB64, ]) #if DEBUG print("DEBUG register response: \(resp)") #endif // Handle PoW challenge if required by server if resp.string(for: "status") == "pow_required", let powData = resp.dict(for: "data"), let powChallenge = powData.string(for: "challenge"), let powMac = powData.string(for: "mac"), let powDifficulty = powData.int(for: "difficulty") { #if DEBUG print("DEBUG register: PoW required, difficulty=\(powDifficulty)") #endif let powNonce = ChatClient.solvePow(challenge: powChallenge, difficulty: powDifficulty) #if DEBUG print("DEBUG register: PoW solved, nonce=\(powNonce)") #endif // Retry with PoW solution let retryResp = await sendAndReceive(type: "register", params: [ "username": username, "public_key": pubPem, "email": email, "identity_key": ikB64, "pow_challenge": powChallenge, "pow_mac": powMac, "pow_nonce": powNonce, ]) guard retryResp.string(for: "status") == "ok" else { let msg = retryResp.dict(for: "data")?.string(for: "message") ?? "Registration failed after PoW" return (false, msg) } let retryData = retryResp.dict(for: "data") ?? [:] if let code = retryData.string(for: "code") { return (true, code) } return (true, retryData.string(for: "message") ?? "Check your email for the code.") } guard resp.string(for: "status") == "ok" else { let msg = resp.dict(for: "data")?.string(for: "message") ?? "Registration failed" return (false, msg) } let data = resp.dict(for: "data") ?? [:] if let code = data.string(for: "code") { return (true, code) } return (true, data.string(for: "message") ?? "Check your email for the code.") } func confirmRegistration(email: String, username: String, code: String) async -> (success: Bool, message: String) { #if DEBUG print("DEBUG confirmRegistration: email=\(email), code=\(code)") #endif let resp = await sendAndReceive(type: "register_confirm", params: [ "email": email, "code": code, ]) guard resp.string(for: "status") == "ok" else { let msg = resp.dict(for: "data")?.string(for: "message") ?? "Confirmation failed" return (false, msg) } // NOTE: Don't upload prekeys here - user isn't logged in yet. // ensurePrekeys() will be called after login. let uid = resp.dict(for: "data")?.string(for: "user_id") ?? "" return (true, "Registered as '\(username)' (ID: \(uid))") } // MARK: - Prekeys private func generateAndUploadPrekeys(keepSPK: Bool = false) async { guard let identityPrivate = identityPrivate else { return } do { let spkData: [String: Any] if keepSPK, let spkPriv = spkPrivate, !spkId.isEmpty { let spkPubBytes = X25519Crypto.serializePublic(spkPriv.publicKey) let sig = try Ed25519Crypto.sign(identityPrivate, data: spkPubBytes) spkData = [ "id": spkId, "public_key": ProtocolHandler.encodeBinary(spkPubBytes), "signature": ProtocolHandler.encodeBinary(sig), ] } else { // Save current as previous (grace period) if let spkPriv = spkPrivate, !spkId.isEmpty { prevSpkPrivate = spkPriv prevSpkId = spkId try? KeyStorage.savePrevSPK(email: email, privateKey: spkPriv, spkId: spkId) } let spk = try X3DH.generateSignedPrekey(identityPrivate: identityPrivate) self.spkPrivate = spk.privateKey self.spkId = spk.id try? KeyStorage.saveSPK(email: email, privateKey: spk.privateKey, spkId: spk.id) spkData = [ "id": spk.id, "public_key": ProtocolHandler.encodeBinary(X25519Crypto.serializePublic(spk.publicKey)), "signature": ProtocolHandler.encodeBinary(spk.signature), ] } // Generate OPKs let opks = X3DH.generateOneTimePrekeys(count: Constants.opkBatchSize) #if DEBUG print("DEBUG generatePrekeys: generating \(opks.count) OPKs") #endif for opk in opks { opkPrivates[opk.id] = opk.privateKey do { try KeyStorage.saveOPKPrivate(email: email, opkId: opk.id, privateKey: opk.privateKey) } catch { #if DEBUG print("DEBUG generatePrekeys: FAILED to save OPK \(opk.id): \(error)") #endif } } #if DEBUG print("DEBUG generatePrekeys: OPK IDs = \(opks.map { $0.id })") #endif let otpData = opks.map { opk -> [String: Any] in [ "id": opk.id, "public_key": ProtocolHandler.encodeBinary(X25519Crypto.serializePublic(opk.publicKey)), ] } let uploadResp = await sendAndReceive(type: "ensure_prekeys", params: [ "signed_prekey": spkData, "one_time_prekeys": otpData, ]) let uploadStatus = uploadResp.string(for: "status") ?? "nil" #if DEBUG print("DEBUG generatePrekeys: ensure_prekeys response status=\(uploadStatus) deviceId=\(deviceId ?? "nil")") #endif if uploadStatus != "ok" { let errMsg = uploadResp.dict(for: "data")?.string(for: "message") ?? "unknown" #if DEBUG print("DEBUG generatePrekeys: upload FAILED: \(errMsg)") #endif } } catch { // Log error but don't fail #if DEBUG print("Prekey generation error: \(error)") #endif } } private func ensurePrekeys() async { let resp = await sendAndReceive(type: "get_prekey_count") guard resp.string(for: "status") == "ok", let data = resp.dict(for: "data") else { return } let count = data.int(for: "count") ?? 0 let spkCreatedAt = data.string(for: "spk_created_at") ?? "" var needNewSPK = false if !spkCreatedAt.isEmpty { if let created = DateParsing.parse(spkCreatedAt) { let ageDays = Calendar.current.dateComponents([.day], from: created, to: Date()).day ?? 0 if ageDays >= Constants.spkRotationDays { needNewSPK = true } } } if count < Constants.opkReplenishThreshold || needNewSPK { await generateAndUploadPrekeys() } } // MARK: - Login func login(email: String, password: String) async -> (success: Bool, message: String) { self.email = email // Check brute-force lockout let lockoutRemaining = ChatClient.checkLockout(email: email) if lockoutRemaining > 0 { return (false, "Too many failed attempts. Wait \(Int(lockoutRemaining))s.") } var pwdBytes = Array(password.utf8) defer { pwdBytes.withUnsafeMutableBytes { ptr in _ = memset(ptr.baseAddress!, 0, ptr.count) } } let pwdData = Data(pwdBytes) // Load RSA keys let (rsaPriv, rsaPub, err) = KeyStorage.loadRSAKeys(email: email, password: pwdData) guard let rsaPriv = rsaPriv, let rsaPub = rsaPub else { return (false, err ?? "No local keys found. Register first.") } self.rsaPrivate = rsaPriv self.rsaPublic = rsaPub // Load identity keys let (edPriv, edPub) = KeyStorage.loadIdentityKeys(email: email, password: pwdData) if let edPriv = edPriv, let edPub = edPub { self.identityPrivate = edPriv self.identityPublic = edPub self.cacheKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: edPriv.rawData) self.localKey = CryptoUtils.deriveLocalStorageKey(identityPrivateRaw: edPriv.rawData) } // Load SPK let (spkP, spkI) = KeyStorage.loadSPK(email: email) if let spkP = spkP { self.spkPrivate = spkP self.spkId = spkI ?? "" } // Load previous SPK (grace period) let (prevP, prevI) = KeyStorage.loadPrevSPK(email: email) if let prevP = prevP { self.prevSpkPrivate = prevP self.prevSpkId = prevI ?? "" } // Load device ID self.deviceId = KeyStorage.loadDeviceId(email: email) // RSA challenge-response login let startResp = await sendAndReceive(type: "login_start", params: ["email": email]) guard startResp.string(for: "status") == "ok", let startData = startResp.dict(for: "data"), let challengeB64 = startData.string(for: "challenge") else { let msg = startResp.dict(for: "data")?.string(for: "message") ?? "Login failed" return (false, msg) } let challengeData: Data do { challengeData = try ProtocolHandler.decodeBinary(challengeB64) } catch { return (false, "Invalid challenge data") } let signature: Data do { signature = try RSACrypto.sign(rsaPriv, data: challengeData) } catch { return (false, "RSA signing failed: \(error.localizedDescription)") } var finishParams: [String: Any] = [ "email": email, "signature": ProtocolHandler.encodeBinary(signature), "client_version": Constants.version, ] if let deviceId = deviceId { finishParams["device_id"] = deviceId } let finishResp = await sendAndReceive(type: "login_finish", params: finishParams) guard finishResp.string(for: "status") == "ok", let finishData = finishResp.dict(for: "data") else { let msg = finishResp.dict(for: "data")?.string(for: "message") ?? "Login failed" loginRejected = true ChatClient.recordFailedAttempt(email: email) return (false, msg) } self.userId = finishData.string(for: "user_id") self.username = finishData.string(for: "username") ?? "" self.sessionToken = finishData.string(for: "session_token") // Save device ID from server if let newDeviceId = finishData.string(for: "device_id") { self.deviceId = newDeviceId try? KeyStorage.saveDeviceId(email: email, deviceId: newDeviceId) } // Handle online_users if included if let onlineUserIds = finishData["online_user_ids"] as? [String] { self.onlineUserIds = Set(onlineUserIds) broadcastNotification(.onlineUsers(userIds: onlineUserIds)) } // Check if we have local OPK private keys. // After device pairing, the new device has no local OPKs — must generate fresh ones. let hasLocalOPKs: Bool if let dir = try? KeyStorage.getKeyDir(email: email) { let opkDir = dir.appendingPathComponent("opk_private") let files = (try? FileManager.default.contentsOfDirectory(atPath: opkDir.path)) ?? [] hasLocalOPKs = !files.isEmpty } else { hasLocalOPKs = false } if hasLocalOPKs { // Existing device — check/replenish prekeys in background Task { await ensurePrekeys() } } else { // New device — MUST upload prekeys before returning so other clients // can encrypt for this device immediately. Fire-and-forget would create // a race where senders fetch bundles before prekeys exist on server. #if DEBUG print("DEBUG login: no local OPKs (likely new device). Generating fresh prekeys (synchronous).") #endif await generateAndUploadPrekeys(keepSPK: true) } // Load previous SPK for grace period (M4) let (prevSPK, prevSPKId) = KeyStorage.loadPrevSPK(email: email) if let prevSPK = prevSPK { prevSpkPrivate = prevSPK prevSpkId = prevSPKId ?? "" } // Load TOFU and verification stores loadVerificationStores() // Clear lockout on successful login ChatClient.clearLockout(email: email) return (true, "Logged in as \(username)") } // MARK: - Reconnect func reconnect() async -> ReconnectResult { guard rsaPrivate != nil else { return .authFailed } let host = lastHost let port = lastPort await disconnect() do { try await connect(host: host, port: port) } catch { return .networkError } // TCP connected — try RSA challenge-response with in-memory keys let startResp = await sendAndReceive(type: "login_start", params: ["email": email]) guard startResp.string(for: "status") == "ok", let startData = startResp.dict(for: "data"), let challengeB64 = startData.string(for: "challenge"), let challengeData = try? ProtocolHandler.decodeBinary(challengeB64), let signature = try? RSACrypto.sign(rsaPrivate!, data: challengeData) else { // TCP was fine but login_start failed — auth issue return .authFailed } var finishParams: [String: Any] = [ "email": email, "signature": ProtocolHandler.encodeBinary(signature), "client_version": Constants.version, ] if let deviceId = deviceId { finishParams["device_id"] = deviceId } let finishResp = await sendAndReceive(type: "login_finish", params: finishParams) guard finishResp.string(for: "status") == "ok", let finishData = finishResp.dict(for: "data") else { // TCP connected, got challenge, but signature rejected — keys rotated return .authFailed } // Update session token if server returned a new one if let token = finishData.string(for: "session_token") { self.sessionToken = token } // Refresh online users (matches Python: self.session = finish["data"]) if let onlineUserIds = finishData["online_user_ids"] as? [String] { self.onlineUserIds = Set(onlineUserIds) broadcastNotification(.onlineUsers(userIds: onlineUserIds)) } // Replenish prekeys if needed (matches Python: asyncio.create_task(self._ensure_prekeys())) Task { await ensurePrekeys() } broadcastNotification(.reconnected) return .success } // MARK: - Device Bundles private func getDeviceBundles(userId: String) async throws -> [DeviceBundle] { // Check cache (5-min TTL) if let cached = deviceBundleCache[userId], Date().timeIntervalSince(cached.timestamp) < Constants.deviceBundleCacheTTL { return cached.bundles } let resp = await sendAndReceive(type: "get_key_bundle", params: ["user_id": userId]) guard resp.string(for: "status") == "ok", let data = resp.dict(for: "data") else { throw ChatError.operationFailed("Failed to get key bundle") } var bundles: [DeviceBundle] = [] // Get identity key from top level (shared across all device bundles) var identityKey: Data? if let ikB64 = data["identity_key"] as? String { identityKey = Data(base64Encoded: ikB64) } // Per-device bundles (new format) if let deviceBundlesRaw = data["device_bundles"] as? [[String: Any]] { for bundleDict in deviceBundlesRaw { if let bundle = try? DeviceBundle.fromDict(bundleDict, identityKey: identityKey) { bundles.append(bundle) } } } // Legacy single bundle else if identityKey != nil { let bundle = try DeviceBundle.fromDict(data, identityKey: identityKey) bundles.append(bundle) } deviceBundleCache[userId] = (Date(), bundles) return bundles } // MARK: - Session Management private func getOrCreateSession( peerUserId: String, peerDeviceId: String, bundle: DeviceBundle ) async throws -> DoubleRatchet { let sessionKey = "\(peerUserId):\(peerDeviceId)" #if DEBUG print("DEBUG getOrCreateSession: looking for session \(sessionKey)") #endif // Check memory if let session = sessions[sessionKey] { #if DEBUG print("DEBUG getOrCreateSession: found in memory") #endif return session } // Check disk if let session = KeyStorage.loadSession( email: email, peerUserId: peerUserId, localKey: localKey, peerDeviceId: peerDeviceId ) { #if DEBUG print("DEBUG getOrCreateSession: found on disk") #endif sessions[sessionKey] = session return session } #if DEBUG print("DEBUG getOrCreateSession: creating new session via X3DH") #endif // Create new via X3DH let remoteIkEd = try Ed25519Crypto.loadPublic(bundle.identityKey) let spkRemote = try X25519Crypto.loadPublic(bundle.spk) var opkRemote: Curve25519.KeyAgreement.PublicKey? if let opkData = bundle.opk { opkRemote = try X25519Crypto.loadPublic(opkData) } let (sharedSecret, _, ekPub) = try X3DH.initiate( ikPrivateEd: identityPrivate!, ikPublicRemoteEd: remoteIkEd, spkRemote: spkRemote, spkSignature: bundle.spkSignature, opkRemote: opkRemote ) let ratchet = try DoubleRatchet.initAlice(sharedSecret: sharedSecret, bobSpkPub: spkRemote) // Build X3DH header for first message // Keys must be base64 encoded (not hex) to match Python server let myIkBytes = Ed25519Crypto.serializePublic(identityPublic!) let ekPubBytes = X25519Crypto.serializePublic(ekPub) #if DEBUG print("DEBUG getOrCreateSession: sending ik len=\(myIkBytes.count)") print("DEBUG getOrCreateSession: sending ek len=\(ekPubBytes.count)") #endif var x3dhHeader: [String: Any] = [ "ik": ProtocolHandler.encodeBinary(myIkBytes), "ek": ProtocolHandler.encodeBinary(ekPubBytes), "spk_id": bundle.spkId, ] if let opkId = bundle.opkId { x3dhHeader["opk_id"] = opkId #if DEBUG print("DEBUG getOrCreateSession: using opk_id = \(opkId)") #endif } ratchet.x3dhHeader = x3dhHeader sessions[sessionKey] = ratchet try? KeyStorage.saveSession(email: email, peerUserId: peerUserId, ratchet: ratchet, localKey: localKey, peerDeviceId: peerDeviceId) return ratchet } // MARK: - X3DH Response (Bob Side) private func processX3DHHeader( senderId: String, x3dhHeader: [String: Any], senderDeviceId: String, spkOverride: Curve25519.KeyAgreement.PrivateKey? = nil ) throws -> DoubleRatchet { guard let ikB64 = x3dhHeader["ik"] as? String, let ikData = try? ProtocolHandler.decodeBinary(ikB64), let ekB64 = x3dhHeader["ek"] as? String, let ekData = try? ProtocolHandler.decodeBinary(ekB64) else { throw CryptoError.x3dhFailed("Invalid X3DH header - missing ik or ek") } // spk_id is optional - if missing, use current SPK let spkIdStr = x3dhHeader["spk_id"] as? String #if DEBUG print("DEBUG processX3DHHeader: ik=\(ikData.count)B, ek=\(ekData.count)B, spk_id=\(spkIdStr ?? "nil")") #endif let remoteIkEd = try Ed25519Crypto.loadPublic(ikData) let ekRemote = try X25519Crypto.loadPublic(ekData) // Determine which SPK to use let spkToUse: Curve25519.KeyAgreement.PrivateKey if let override = spkOverride { #if DEBUG print("DEBUG processX3DHHeader: using spkOverride") #endif spkToUse = override } else if let spkIdStr = spkIdStr { // spk_id provided - match against known SPKs #if DEBUG print("DEBUG processX3DHHeader: spk_id provided, mySpkId=\(spkId), prevSpkId=\(prevSpkId)") #endif if spkIdStr == spkId, let spk = spkPrivate { spkToUse = spk } else if spkIdStr == prevSpkId, let prevSpk = prevSpkPrivate { spkToUse = prevSpk } else { throw CryptoError.x3dhFailed("SPK \(spkIdStr) not found") } } else { // No spk_id provided - use current SPK (for backward compatibility) #if DEBUG print("DEBUG processX3DHHeader: no spk_id, using current SPK") #endif guard let spk = spkPrivate else { throw CryptoError.x3dhFailed("No SPK available") } spkToUse = spk } // OPK var opkPriv: Curve25519.KeyAgreement.PrivateKey? if let opkIdStr = x3dhHeader["opk_id"] as? String { #if DEBUG print("DEBUG processX3DHHeader: looking for OPK \(opkIdStr)") print("DEBUG processX3DHHeader: opkPrivates has \(opkPrivates.count) keys: \(Array(opkPrivates.keys).prefix(5))...") #endif // Check file system if let dir = try? KeyStorage.getKeyDir(email: email) { let opkDir = dir.appendingPathComponent("opk_private") let opkFile = opkDir.appendingPathComponent("\(opkIdStr).bin") #if DEBUG print("DEBUG processX3DHHeader: checking file \(opkFile.path)") print("DEBUG processX3DHHeader: file exists = \(FileManager.default.fileExists(atPath: opkFile.path))") #endif if let files = try? FileManager.default.contentsOfDirectory(atPath: opkDir.path) { #if DEBUG print("DEBUG processX3DHHeader: opk_private dir has \(files.count) files: \(files.prefix(5))...") #endif } } opkPriv = opkPrivates[opkIdStr] ?? KeyStorage.loadOPKPrivate(email: email, opkId: opkIdStr) if opkPriv != nil { #if DEBUG print("DEBUG processX3DHHeader: OPK found") #endif opkPrivates.removeValue(forKey: opkIdStr) KeyStorage.deleteOPKPrivate(email: email, opkId: opkIdStr) } else { #if DEBUG print("DEBUG processX3DHHeader: OPK NOT found - continuing without OPK") #endif } } #if DEBUG print("DEBUG processX3DHHeader: performing X3DH respond") #endif let sharedSecret = try X3DH.respond( ikPrivateEd: identityPrivate!, spkPrivate: spkToUse, ikRemoteEd: remoteIkEd, ekRemote: ekRemote, opkPrivate: opkPriv ) #if DEBUG print("DEBUG processX3DHHeader: X3DH respond success, sharedSecret=\(sharedSecret.count) bytes") #endif let ratchet = DoubleRatchet.initBob( sharedSecret: sharedSecret, spkPair: (spkToUse, spkToUse.publicKey) ) let sessionKey = "\(senderId):\(senderDeviceId)" sessions[sessionKey] = ratchet try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: ratchet, localKey: localKey, peerDeviceId: senderDeviceId) #if DEBUG print("DEBUG processX3DHHeader: session created for \(sessionKey)") #endif return ratchet } // MARK: - Send Message func sendMessage(convId: String, text: String, members: [ConversationMember], replyTo: String? = nil, extraPayload: [String: Any]? = nil, imageFileId: String? = nil) async -> (success: Bool, message: String, sentMessage: Message?) { let isGroup = members.count > 2 if isGroup { return await sendGroupMessage(convId: convId, text: text, members: members, replyTo: replyTo, extraPayload: extraPayload, imageFileId: imageFileId) } else { return await sendDM(convId: convId, text: text, members: members, replyTo: replyTo, extraPayload: extraPayload, imageFileId: imageFileId) } } // MARK: - Send DM private func sendDM(convId: String, text: String, members: [ConversationMember], replyTo: String? = nil, extraPayload: [String: Any]? = nil, imageFileId: String? = nil) async -> (success: Bool, message: String, sentMessage: Message?) { #if DEBUG print("DEBUG sendDM: convId=\(convId), members=\(members.map { $0.userId })") #endif guard let identityPrivate = identityPrivate else { #if DEBUG print("DEBUG sendDM: Identity key not loaded") #endif return (false, "Identity key not loaded", nil) } // Build JSON payload matching Python format for cross-client compatibility var payload: [String: Any] = [ "sender": username, "text": text, "reply_to": (replyTo as Any?) ?? NSNull(), "timestamp": ISO8601DateFormatter().string(from: Date()), ] // Merge extra fields (image, file) at top level if let extra = extraPayload { for (key, value) in extra { payload[key] = value } } let rawPlaintext: Data if let jsonData = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) { rawPlaintext = jsonData } else { rawPlaintext = Data(text.utf8) } let plaintext = MessagePadding.pad(rawPlaintext) var recipients: [[String: Any]] = [] // Ensure we have the other member(s) — refetch if members only contains self var actualMembers = members let otherMembers = members.filter { $0.userId != userId } if otherMembers.isEmpty { #if DEBUG print("DEBUG sendDM: WARNING — no other members in conversation, refetching from server") #endif let allConvs = await listConversations() if let conv = allConvs.first(where: { $0.id == convId }) { actualMembers = conv.members #if DEBUG print("DEBUG sendDM: refetched \(actualMembers.count) members for conv \(convId)") #endif } } // Encrypt for each member's devices #if DEBUG print("DEBUG sendDM: encrypting for members, my userId=\(userId ?? "nil")") #endif for member in actualMembers where member.userId != userId { #if DEBUG print("DEBUG sendDM: processing member \(member.userId)") #endif do { let bundles = try await getDeviceBundles(userId: member.userId) #if DEBUG print("DEBUG sendDM: got \(bundles.count) device bundles for \(member.userId)") #endif for (index, bundle) in bundles.enumerated() { #if DEBUG print("DEBUG sendDM: encrypting for bundle \(index + 1)/\(bundles.count), deviceId=\(bundle.deviceId)") #endif let ratchet = try await getOrCreateSession( peerUserId: member.userId, peerDeviceId: bundle.deviceId, bundle: bundle ) // Consume X3DH header if present (first message only) let x3dhHeader = ratchet.x3dhHeader ratchet.x3dhHeader = nil let encrypted = try ratchet.encrypt(plaintext) try? KeyStorage.saveSession(email: email, peerUserId: member.userId, ratchet: ratchet, localKey: localKey, peerDeviceId: bundle.deviceId) var recipientEntry: [String: Any] = [ "user_id": member.userId, "device_id": bundle.deviceId, "encrypted_content": ProtocolHandler.encodeBinary(encrypted.ciphertext), "nonce": ProtocolHandler.encodeBinary(encrypted.nonce), "ratchet_header": encrypted.header, ] if let x3dh = x3dhHeader { recipientEntry["x3dh_header"] = x3dh #if DEBUG print("DEBUG sendDM: x3dh_header for \(bundle.deviceId) = \(x3dh)") #endif } #if DEBUG print("DEBUG sendDM: ratchet_header for \(bundle.deviceId) = \(encrypted.header)") #endif recipients.append(recipientEntry) } } catch { #if DEBUG print("DEBUG sendDM: ERROR encrypting for member \(member.userId): \(error)") #endif return (false, "Encryption failed for \(member.username): \(error.localizedDescription)", nil) } } // Self-encrypted copy (readable by all own devices via shared identity key) let selfKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: identityPrivate.rawData) if let (_, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(plaintext, key: selfKey) { let selfCiphertext = ct + tag recipients.append([ "user_id": userId!, "device_id": Constants.selfDeviceId, "encrypted_content": ProtocolHandler.encodeBinary(selfCiphertext), "nonce": ProtocolHandler.encodeBinary(nonce), "ratchet_header": ["self": true], ]) } // Build ratchet header for message table (use first recipient's or dummy) let ratchetHeader: [String: Any] if let first = recipients.first { ratchetHeader = first["ratchet_header"] as? [String: Any] ?? [:] } else { ratchetHeader = ["dh_pub": String(repeating: "00", count: 32), "n": 0, "pn": 0] } var params: [String: Any] = [ "conversation_id": convId, "ratchet_header": ratchetHeader, "recipients": recipients, ] if let replyTo = replyTo { params["reply_to"] = replyTo } if let imageFileId = imageFileId { params["image_file_id"] = imageFileId } #if DEBUG print("DEBUG sendDM: sending with \(recipients.count) recipients") #endif let resp = await sendAndReceive(type: "send_message", params: params) #if DEBUG print("DEBUG sendDM: response status=\(resp.string(for: "status") ?? "nil")") #endif guard resp.string(for: "status") == "ok" else { let msg = resp.dict(for: "data")?.string(for: "message") ?? "Send failed" return (false, msg, nil) } // Build sent Message and cache the plaintext var sentMessage: Message? if let msgData = resp.dict(for: "data"), let messageId = msgData.string(for: "message_id") { // Cache plaintext for future getMessages calls MessageCache.cacheDecryptedMessage( email: email, convId: convId, messageId: messageId, plaintext: plaintext, cacheKey: cacheKey ) var forwardedFrom: ForwardedFrom? if let fwd = extraPayload?["forwarded_from"] as? [String: Any], let fwdSender = fwd["sender"] as? String { forwardedFrom = ForwardedFrom(sender: fwdSender, conversationId: fwd["conversation_id"] as? String ?? "", messageId: fwd["message_id"] as? String ?? "") } // Parse image/file info from extraPayload so the sent message displays correctly var sentImage: ImageInfo? if let imgDict = extraPayload?["image"] as? [String: Any], let imgFileId = imgDict["file_id"] as? String, !imgFileId.isEmpty { sentImage = ImageInfo( fileId: imgFileId, aesKey: imgDict["aes_key"] as? String ?? "", iv: imgDict["iv"] as? String ?? "", thumbnail: imgDict["thumbnail"] as? String, filename: imgDict["filename"] as? String ?? "image.jpg", size: imgDict["size"] as? Int ?? 0 ) } var sentFile: FileInfo? if let fileDict = extraPayload?["file"] as? [String: Any], let fFileId = fileDict["file_id"] as? String, !fFileId.isEmpty { sentFile = FileInfo( fileId: fFileId, aesKey: fileDict["aes_key"] as? String ?? "", iv: fileDict["iv"] as? String ?? "", filename: fileDict["filename"] as? String ?? "", size: fileDict["size"] as? Int ?? 0, mimeType: fileDict["mime_type"] as? String ?? "" ) } let createdAt = msgData.string(for: "created_at").flatMap { DateParsing.parse($0) } ?? Date() sentMessage = Message( id: messageId, conversationId: convId, senderId: userId!, senderUsername: username, createdAt: createdAt, text: text.isEmpty ? nil : text, replyTo: replyTo, imageFileId: imageFileId, file: sentFile, image: sentImage, isDeleted: false, readBy: [], reactions: [], forwardedFrom: forwardedFrom, pinnedAt: nil, pinnedBy: nil ) } return (true, "Message sent", sentMessage) } // MARK: - Send Group Message private func sendGroupMessage(convId: String, text: String, members: [ConversationMember], replyTo: String? = nil, extraPayload: [String: Any]? = nil, imageFileId: String? = nil) async -> (success: Bool, message: String, sentMessage: Message?) { guard let identityPrivate = identityPrivate, let userId = userId, deviceId != nil else { return (false, "Not properly logged in", nil) } // Ensure we have all members — refetch if only self is present var actualMembers = members let otherMembers = members.filter { $0.userId != userId } if otherMembers.isEmpty { #if DEBUG print("DEBUG sendGroupMessage: WARNING — no other members, refetching from server") #endif let allConvs = await listConversations() if let conv = allConvs.first(where: { $0.id == convId }) { actualMembers = conv.members } } // Get or create sender key for this group var senderKeyState = senderKeyStates[convId] if senderKeyState == nil { senderKeyState = KeyStorage.loadSenderKeyState(email: email, convId: convId, localKey: localKey) } var needDistribute = false if senderKeyState == nil { senderKeyState = SenderKeyState() needDistribute = true } senderKeyStates[convId] = senderKeyState // Distribute sender key if new if needDistribute { await distributeSenderKey(convId: convId, members: actualMembers) } // Encrypt with sender key — JSON payload for cross-client compatibility var groupPayload: [String: Any] = [ "sender": username, "text": text, "reply_to": (replyTo as Any?) ?? NSNull(), "timestamp": ISO8601DateFormatter().string(from: Date()), ] if let extra = extraPayload { for (key, value) in extra { groupPayload[key] = value } } let rawPlaintext: Data if let jsonData = try? JSONSerialization.data(withJSONObject: groupPayload, options: [.sortedKeys]) { rawPlaintext = jsonData } else { rawPlaintext = Data(text.utf8) } let plaintext = MessagePadding.pad(rawPlaintext) do { let encrypted = try senderKeyState!.encrypt(plaintext) try? KeyStorage.saveSenderKeyState(email: email, convId: convId, state: senderKeyState!, localKey: localKey) // Build recipients (same ciphertext for all) var recipients: [[String: Any]] = [] for member in actualMembers where member.userId != userId { recipients.append([ "user_id": member.userId, "device_id": Constants.selfDeviceId, // group messages use sentinel "encrypted_content": ProtocolHandler.encodeBinary(encrypted.ciphertext), "nonce": ProtocolHandler.encodeBinary(encrypted.nonce), ]) } // Self copy (readable by all own devices via shared identity key) let selfKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: identityPrivate.rawData) if let (_, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(plaintext, key: selfKey) { recipients.append([ "user_id": userId, "device_id": Constants.selfDeviceId, "encrypted_content": ProtocolHandler.encodeBinary(ct + tag), "nonce": ProtocolHandler.encodeBinary(nonce), "ratchet_header": ["self": true], ]) } let dummyHeader: [String: Any] = [ "dh_pub": String(repeating: "00", count: 32), "n": 0, "pn": 0, ] var params: [String: Any] = [ "conversation_id": convId, "ratchet_header": dummyHeader, "recipients": recipients, ] // Include sender key metadata for group routing (base64-encoded chain ID bytes) if let chainIdBytes = Data(hexString: encrypted.chainIdHex) { params["sender_chain_id"] = ProtocolHandler.encodeBinary(chainIdBytes) } params["sender_chain_n"] = encrypted.n if let replyTo = replyTo { params["reply_to"] = replyTo } if let imageFileId = imageFileId { params["image_file_id"] = imageFileId } let resp = await sendAndReceive(type: "send_message", params: params) guard resp.string(for: "status") == "ok" else { let msg = resp.dict(for: "data")?.string(for: "message") ?? "Send failed" return (false, msg, nil) } // Build sent Message and cache the plaintext var sentMessage: Message? if let msgData = resp.dict(for: "data"), let messageId = msgData.string(for: "message_id") { MessageCache.cacheDecryptedMessage( email: email, convId: convId, messageId: messageId, plaintext: plaintext, cacheKey: cacheKey ) var forwardedFrom: ForwardedFrom? if let fwd = extraPayload?["forwarded_from"] as? [String: Any], let fwdSender = fwd["sender"] as? String { forwardedFrom = ForwardedFrom(sender: fwdSender, conversationId: fwd["conversation_id"] as? String ?? "", messageId: fwd["message_id"] as? String ?? "") } // Parse image/file info from extraPayload so the sent message displays correctly var sentImage: ImageInfo? if let imgDict = extraPayload?["image"] as? [String: Any], let imgFileId = imgDict["file_id"] as? String, !imgFileId.isEmpty { sentImage = ImageInfo( fileId: imgFileId, aesKey: imgDict["aes_key"] as? String ?? "", iv: imgDict["iv"] as? String ?? "", thumbnail: imgDict["thumbnail"] as? String, filename: imgDict["filename"] as? String ?? "image.jpg", size: imgDict["size"] as? Int ?? 0 ) } var sentFile: FileInfo? if let fileDict = extraPayload?["file"] as? [String: Any], let fFileId = fileDict["file_id"] as? String, !fFileId.isEmpty { sentFile = FileInfo( fileId: fFileId, aesKey: fileDict["aes_key"] as? String ?? "", iv: fileDict["iv"] as? String ?? "", filename: fileDict["filename"] as? String ?? "", size: fileDict["size"] as? Int ?? 0, mimeType: fileDict["mime_type"] as? String ?? "" ) } let createdAt = msgData.string(for: "created_at").flatMap { DateParsing.parse($0) } ?? Date() sentMessage = Message( id: messageId, conversationId: convId, senderId: userId, senderUsername: username, createdAt: createdAt, text: text.isEmpty ? nil : text, replyTo: replyTo, imageFileId: imageFileId, file: sentFile, image: sentImage, isDeleted: false, readBy: [], reactions: [], forwardedFrom: forwardedFrom, pinnedAt: nil, pinnedBy: nil ) } return (true, "Message sent", sentMessage) } catch { return (false, "Encryption failed: \(error.localizedDescription)", nil) } } // MARK: - Distribute Sender Key private func distributeSenderKey(convId: String, members: [ConversationMember]) async { guard let senderKeyState = senderKeyStates[convId], let userId = userId, let deviceId = deviceId else { return } let exportedKey = senderKeyState.exportKey() for member in members where member.userId != userId { do { let bundles = try await getDeviceBundles(userId: member.userId) for bundle in bundles { let ratchet = try await getOrCreateSession( peerUserId: member.userId, peerDeviceId: bundle.deviceId, bundle: bundle ) let x3dhHeader = ratchet.x3dhHeader ratchet.x3dhHeader = nil // Payload includes sender key + metadata let controlPayload: [String: Any] = [ "_sender_key": [ "conv_id": convId, "key": ProtocolHandler.encodeBinary(exportedKey), "sender_device_id": deviceId, ] ] let controlData = try JSONSerialization.data(withJSONObject: controlPayload) let encrypted = try ratchet.encrypt(controlData) try? KeyStorage.saveSession(email: email, peerUserId: member.userId, ratchet: ratchet, localKey: localKey, peerDeviceId: bundle.deviceId) var recipientEntry: [String: Any] = [ "user_id": member.userId, "device_id": bundle.deviceId, "encrypted_content": ProtocolHandler.encodeBinary(encrypted.ciphertext), "nonce": ProtocolHandler.encodeBinary(encrypted.nonce), "ratchet_header": encrypted.header, ] if let x3dh = x3dhHeader { recipientEntry["x3dh_header"] = x3dh } let dummyHeader: [String: Any] = [ "dh_pub": String(repeating: "00", count: 32), "n": 0, "pn": 0, ] _ = await sendAndReceive(type: "send_message", params: [ "conversation_id": convId, "ratchet_header": dummyHeader, "recipients": [recipientEntry], ]) } } catch { #if DEBUG print("Failed to distribute sender key to \(member.userId): \(error)") #endif } } } // MARK: - Decrypt /// Decrypt DM using Double Ratchet, or static key for self-copies. /// Matches Python: _decrypt_dm() lines 1547-1652 func decryptDMRecipientData( senderData: [String: Any], senderId: String, senderDeviceId: String ) -> Data? { // Server uses "encrypted_content", fallback to "ciphertext" for compatibility let ctB64 = senderData["encrypted_content"] as? String ?? senderData["ciphertext"] as? String guard let ctB64 = ctB64, let nonceB64 = senderData["nonce"] as? String, let ciphertext = try? ProtocolHandler.decodeBinary(ctB64), let nonce = try? ProtocolHandler.decodeBinary(nonceB64) else { #if DEBUG print("DEBUG decryptDM: missing encrypted_content or nonce") #endif return nil } #if DEBUG print("DEBUG decryptDM: ct=\(ciphertext.count)B nonce=\(nonce.count)B sender=\(senderId) senderDev=\(senderDeviceId)") #endif let ratchetHeader = senderData["ratchet_header"] as? [String: Any] ?? [:] // Self-encrypted message (ratchet_header.self == true) // Matches Python lines 1562-1566 let isSelf = (ratchetHeader["self"] as? Bool) == true || (ratchetHeader["self"] as? Int) == 1 if isSelf { #if DEBUG print("DEBUG decryptDM: self-encrypted copy") #endif guard let cacheKey = cacheKey, ciphertext.count >= 16 else { #if DEBUG print("DEBUG decryptDM: no cacheKey or ct too short") #endif return nil } let ct = ciphertext.prefix(ciphertext.count - 16) let tag = ciphertext.suffix(16) let result = try? CryptoUtils.aesDecrypt(key: cacheKey, nonce: nonce, ciphertext: Data(ct), tag: Data(tag)) #if DEBUG print("DEBUG decryptDM: self-decrypt \(result != nil ? "OK" : "FAILED")") #endif return result } // Regular DM — Double Ratchet decryption // Matches Python lines 1568-1631 let x3dhHeader = senderData["x3dh_header"] as? [String: Any] let sessionKey = "\(senderId):\(senderDeviceId)" // Try to load existing session (memory → disk) let ratchet = sessions[sessionKey] ?? KeyStorage.loadSession(email: email, peerUserId: senderId, localKey: localKey, peerDeviceId: senderDeviceId) if ratchet != nil { sessions[sessionKey] = ratchet } #if DEBUG print("DEBUG decryptDM: sessionKey=\(sessionKey) existingSession=\(ratchet != nil) hasX3DH=\(x3dhHeader != nil)") #endif var plaintext: Data? if ratchet != nil && x3dhHeader == nil { // Normal case: existing session, no X3DH header (Python line 1581-1585) do { plaintext = try ratchet!.decrypt(headerDict: ratchetHeader, ciphertext: ciphertext, nonce: nonce) try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: ratchet!, localKey: localKey, peerDeviceId: senderDeviceId) #if DEBUG print("DEBUG decryptDM: normal decrypt OK") #endif } catch { #if DEBUG print("DEBUG decryptDM: normal decrypt FAILED: \(error)") #endif return nil } } else if let x3dh = x3dhHeader, let existingRatchet = ratchet { // Existing session + X3DH: sender may have reset. Try existing first, fallback to new X3DH // Matches Python lines 1587-1613 #if DEBUG print("DEBUG decryptDM: existing session + X3DH header, trying existing first") #endif let backup = try? existingRatchet.exportState() do { plaintext = try existingRatchet.decrypt(headerDict: ratchetHeader, ciphertext: ciphertext, nonce: nonce) try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: existingRatchet, localKey: localKey, peerDeviceId: senderDeviceId) #if DEBUG print("DEBUG decryptDM: existing session decrypt OK (X3DH ignored)") #endif } catch { // Restore from backup and try X3DH #if DEBUG print("DEBUG decryptDM: existing session failed, trying X3DH") #endif if let backup = backup, let restored = try? DoubleRatchet.importState(backup) { sessions[sessionKey] = restored try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: restored, localKey: localKey, peerDeviceId: senderDeviceId) } plaintext = tryX3DHDecrypt(x3dh: x3dh, ratchetHeader: ratchetHeader, ciphertext: ciphertext, nonce: nonce, senderId: senderId, senderDeviceId: senderDeviceId) } } else if let x3dh = x3dhHeader { // No existing session, process X3DH // Matches Python lines 1614-1629 #if DEBUG print("DEBUG decryptDM: no existing session, processing X3DH") #endif plaintext = tryX3DHDecrypt(x3dh: x3dh, ratchetHeader: ratchetHeader, ciphertext: ciphertext, nonce: nonce, senderId: senderId, senderDeviceId: senderDeviceId) } else { // No session and no X3DH #if DEBUG print("DEBUG decryptDM: no session and no X3DH for \(senderId)") #endif return nil } guard let rawDecrypted = plaintext else { return nil } let unpadded = MessagePadding.unpad(rawDecrypted) sessions[sessionKey] = ratchet ?? sessions[sessionKey] #if DEBUG print("DEBUG decryptDM: decrypt OK, \(unpadded.count) bytes") #endif // Check for sender key distribution (control message) if let jsonObj = try? JSONSerialization.jsonObject(with: unpadded) as? [String: Any], let senderKeyInfo = jsonObj["_sender_key"] as? [String: Any] { handleSenderKeyDistribution(senderKeyInfo, senderId: senderId) return nil // Control message } return unpadded } /// Helper: try X3DH header processing + decrypt, with prev SPK fallback. /// Matches Python's try/except pattern in _decrypt_dm lines 1615-1629. private func tryX3DHDecrypt( x3dh: [String: Any], ratchetHeader: [String: Any], ciphertext: Data, nonce: Data, senderId: String, senderDeviceId: String ) -> Data? { let sessionKey = "\(senderId):\(senderDeviceId)" do { let ratchet = try processX3DHHeader( senderId: senderId, x3dhHeader: x3dh, senderDeviceId: senderDeviceId) let plaintext = try ratchet.decrypt(headerDict: ratchetHeader, ciphertext: ciphertext, nonce: nonce) sessions[sessionKey] = ratchet try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: ratchet, localKey: localKey, peerDeviceId: senderDeviceId) #if DEBUG print("DEBUG tryX3DHDecrypt: OK with current SPK") #endif return plaintext } catch { #if DEBUG print("DEBUG tryX3DHDecrypt: failed with current SPK: \(error)") #endif // Try with previous SPK (grace period, M4) if let prevSpk = prevSpkPrivate { do { let ratchet = try processX3DHHeader( senderId: senderId, x3dhHeader: x3dh, senderDeviceId: senderDeviceId, spkOverride: prevSpk) let plaintext = try ratchet.decrypt(headerDict: ratchetHeader, ciphertext: ciphertext, nonce: nonce) sessions[sessionKey] = ratchet try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: ratchet, localKey: localKey, peerDeviceId: senderDeviceId) #if DEBUG print("DEBUG tryX3DHDecrypt: OK with prev SPK") #endif return plaintext } catch { #if DEBUG print("DEBUG tryX3DHDecrypt: failed with prev SPK too: \(error)") #endif } } return nil } } private func handleSenderKeyDistribution(_ info: [String: Any], senderId: String) { guard let convId = info["conv_id"] as? String, let keyB64 = info["key"] as? String, let keyData = try? ProtocolHandler.decodeBinary(keyB64) else { return } let senderDeviceId = info["sender_device_id"] as? String ?? Constants.selfDeviceId do { let senderKey = try SenderKeyState.fromKey(keyData) let stateKey = "\(convId):\(senderId):\(senderDeviceId)" recvSenderKeys[stateKey] = senderKey try? KeyStorage.saveRecvSenderKey( email: email, convId: convId, senderId: senderId, senderDeviceId: senderDeviceId, state: senderKey, localKey: localKey ) } catch { #if DEBUG print("Failed to import sender key: \(error)") #endif } } // MARK: - Group Decrypt /// Decrypt a group message using the sender's distributed Sender Key. /// Matches Python: _decrypt_group() in chat_core.py func decryptGroupMessage( msgDict: [String: Any], senderId: String, senderDeviceId: String, convId: String ) -> Data? { guard let chainIdB64 = msgDict["sender_chain_id"] as? String, let chainN = msgDict["sender_chain_n"] as? Int, let chainIdData = try? ProtocolHandler.decodeBinary(chainIdB64) else { #if DEBUG print("DEBUG decryptGroupMessage: missing sender_chain_id or sender_chain_n") #endif return nil } let chainIdHex = chainIdData.hexString let ctB64 = msgDict["encrypted_content"] as? String ?? msgDict["ciphertext"] as? String guard let ctB64 = ctB64, let nonceB64 = msgDict["nonce"] as? String, let ciphertext = try? ProtocolHandler.decodeBinary(ctB64), let nonce = try? ProtocolHandler.decodeBinary(nonceB64) else { #if DEBUG print("DEBUG decryptGroupMessage: missing encrypted_content or nonce") #endif return nil } // Look up received sender key — try with sender_device_id first var sk: SenderKeyState? let keyWithDevice = "\(convId):\(senderId):\(senderDeviceId)" sk = recvSenderKeys[keyWithDevice] if sk == nil { sk = KeyStorage.loadRecvSenderKey( email: email, convId: convId, senderId: senderId, senderDeviceId: senderDeviceId, localKey: localKey ) if let loaded = sk { recvSenderKeys[keyWithDevice] = loaded } } // Fallback: try with self device ID (legacy/default) if sk == nil && senderDeviceId != Constants.selfDeviceId { let keyLegacy = "\(convId):\(senderId):\(Constants.selfDeviceId)" sk = recvSenderKeys[keyLegacy] if sk == nil { sk = KeyStorage.loadRecvSenderKey( email: email, convId: convId, senderId: senderId, senderDeviceId: Constants.selfDeviceId, localKey: localKey ) if let loaded = sk { recvSenderKeys[keyLegacy] = loaded } } } guard let senderKey = sk else { #if DEBUG print("DEBUG decryptGroupMessage: no sender key for \(senderId) in conv \(convId)") #endif return nil } do { let rawPlaintext = try senderKey.decrypt(chainIdHex: chainIdHex, n: chainN, ciphertext: ciphertext, nonce: nonce) let plaintext = MessagePadding.unpad(rawPlaintext) // Save updated state (chain has advanced) try? KeyStorage.saveRecvSenderKey( email: email, convId: convId, senderId: senderId, senderDeviceId: senderDeviceId, state: senderKey, localKey: localKey ) #if DEBUG print("DEBUG decryptGroupMessage: success, \(plaintext.count) bytes") #endif return plaintext } catch { #if DEBUG print("DEBUG decryptGroupMessage: decrypt failed: \(error)") #endif return nil } } /// Decrypt a new_message notification. Matches Python: decrypt_notification(). /// Supports multi-device format (device_entries array) and legacy flat format. func decryptNotification(_ data: [String: Any]) -> Message? { guard let senderId = data.string(for: "sender_id"), let conversationId = data.string(for: "conversation_id"), let messageId = data.string(for: "message_id") else { #if DEBUG print("DEBUG decryptNotification: missing sender_id/conversation_id/message_id") #endif return nil } let senderDeviceId = data.string(for: "sender_device_id") ?? Constants.selfDeviceId let myUserId = userId ?? "" #if DEBUG print("DEBUG decryptNotification: msgId=\(messageId) senderId=\(senderId) senderDeviceId=\(senderDeviceId) convId=\(conversationId)") #endif // --- Step 1: Extract per-device encrypted content --- // Matches Python decrypt_notification lines 1862-1897 var encryptedContent: String = "" var nonce: String = "" var ratchetHeader: [String: Any] = [:] var x3dhHeader: [String: Any]? if let deviceEntries = data["device_entries"] as? [[String: Any]] { // Multi-device format: pick entry matching our device_id or SELF_DEVICE_ID let entryDeviceIds = deviceEntries.compactMap { $0["device_id"] as? String } #if DEBUG print("DEBUG decryptNotification: myDeviceId=\(deviceId ?? "nil") device_entries=\(entryDeviceIds)") #endif var chosen: [String: Any]? var selfEntry: [String: Any]? for entry in deviceEntries { let eid = entry["device_id"] as? String ?? "" if eid == deviceId { chosen = entry break } if eid == Constants.selfDeviceId { selfEntry = entry } } // If sender is us, prefer self-encrypted entry (matches Python lines 1878-1882) if senderId == myUserId { chosen = selfEntry ?? chosen } else if chosen == nil { chosen = selfEntry } guard let chosen = chosen else { #if DEBUG print("DEBUG decryptNotification: NO matching device_entry for device \(deviceId ?? "nil")") #endif return nil } encryptedContent = chosen["encrypted_content"] as? String ?? "" nonce = chosen["nonce"] as? String ?? "" ratchetHeader = (chosen["ratchet_header"] as? [String: Any]) ?? (data["ratchet_header"] as? [String: Any]) ?? [:] x3dhHeader = (chosen["x3dh_header"] as? [String: Any]) ?? (data["x3dh_header"] as? [String: Any]) } else { // Legacy flat format (no device_entries) encryptedContent = data["encrypted_content"] as? String ?? data["ciphertext"] as? String ?? "" nonce = data["nonce"] as? String ?? "" ratchetHeader = data["ratchet_header"] as? [String: Any] ?? [:] x3dhHeader = data["x3dh_header"] as? [String: Any] } guard !encryptedContent.isEmpty, !nonce.isEmpty else { #if DEBUG print("DEBUG decryptNotification: empty encrypted_content or nonce") #endif return nil } // --- Step 2: Build msg_data and decrypt --- // Matches Python decrypt_notification lines 1899-1910 let msgData: [String: Any] = [ "sender_id": senderId, "sender_device_id": senderDeviceId, "conversation_id": conversationId, "ratchet_header": ratchetHeader, "encrypted_content": encryptedContent, "nonce": nonce, "x3dh_header": x3dhHeader as Any, "sender_chain_id": data["sender_chain_id"] as Any, "sender_chain_n": data["sender_chain_n"] as Any, ] // Dispatch: matches Python _decrypt_message() lines 1533-1545 // Check self-encrypted FIRST (before group check) — after re-encryption, // group messages have {"self": true} ratchet_header but still sender_chain_id. var plaintext: Data? let isSelfEncrypted = (ratchetHeader["self"] as? Bool) == true || (ratchetHeader["self"] as? Int) == 1 if isSelfEncrypted { // Self-encrypted copy → static self-key (same path as _decrypt_dm self check) #if DEBUG print("DEBUG decryptNotification: self-encrypted message") #endif if let cacheKey = cacheKey { if let ct = try? ProtocolHandler.decodeBinary(encryptedContent), let n = try? ProtocolHandler.decodeBinary(nonce), ct.count >= 16 { let ciphertext = ct.prefix(ct.count - 16) let tag = ct.suffix(16) plaintext = try? CryptoUtils.aesDecrypt(key: cacheKey, nonce: n, ciphertext: Data(ciphertext), tag: Data(tag)) #if DEBUG print("DEBUG decryptNotification: self-decrypt result=\(plaintext != nil ? "OK" : "FAILED")") #endif } } } else if let chainId = msgData["sender_chain_id"] as? String, !chainId.isEmpty, senderId != myUserId { // Group message from someone else → sender key decryption #if DEBUG print("DEBUG decryptNotification: group message") #endif plaintext = decryptGroupMessage( msgDict: msgData, senderId: senderId, senderDeviceId: senderDeviceId, convId: conversationId ) } else { // DM → ratchet decryption #if DEBUG print("DEBUG decryptNotification: DM message, calling decryptDMRecipientData") #endif plaintext = decryptDMRecipientData( senderData: msgData, senderId: senderId, senderDeviceId: senderDeviceId ) } guard let plaintext = plaintext else { #if DEBUG print("DEBUG decryptNotification: decrypt returned nil for msgId=\(messageId)") #endif return nil } // --- Step 3: Cache and parse --- MessageCache.cacheDecryptedMessage( email: email, convId: conversationId, messageId: messageId, plaintext: plaintext, cacheKey: cacheKey ) // Queue for self-encryption if from another user (so other devices can read it) if senderId != myUserId && !messageId.isEmpty { pendingSelfEncrypt.append((messageId: messageId, plaintext: plaintext)) } var messageText: String? = String(data: plaintext, encoding: .utf8) var replyTo: String? var file: FileInfo? var image: ImageInfo? var forwardedFrom: ForwardedFrom? var senderName = data.string(for: "sender_username") ?? userCache[senderId]?.username ?? "Unknown" if let jsonObj = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any] { messageText = jsonObj["text"] as? String replyTo = jsonObj["reply_to"] as? String if senderName == "Unknown", let s = jsonObj["sender"] as? String { senderName = s } if let fileDict = jsonObj["file"] as? [String: Any] { file = FileInfo( fileId: fileDict["file_id"] as? String ?? "", aesKey: fileDict["aes_key"] as? String ?? "", iv: fileDict["iv"] as? String ?? "", filename: fileDict["filename"] as? String ?? "", size: fileDict["size"] as? Int ?? 0, mimeType: fileDict["mime_type"] as? String ?? "" ) } image = parseImageInfo(from: jsonObj) if let fwd = jsonObj["forwarded_from"] as? [String: Any], let fwdSender = fwd["sender"] as? String { forwardedFrom = ForwardedFrom(sender: fwdSender, conversationId: fwd["conversation_id"] as? String ?? "", messageId: fwd["message_id"] as? String ?? "") } // Check for sender key distribution (control message) if jsonObj["_sender_key"] != nil { #if DEBUG print("DEBUG decryptNotification: control message (sender key distribution)") #endif return nil } } if messageText == nil && file == nil && image == nil { #if DEBUG print("DEBUG decryptNotification: control message (no text/file/image)") #endif return nil } let createdAt = data.string(for: "created_at").flatMap { DateParsing.parse($0) } ?? Date() return Message( id: messageId, conversationId: conversationId, senderId: senderId, senderUsername: senderName, createdAt: createdAt, text: messageText, replyTo: replyTo, imageFileId: data.string(for: "image_file_id"), file: file, image: image, isDeleted: false, readBy: [], reactions: [], forwardedFrom: forwardedFrom, pinnedAt: nil, pinnedBy: nil ) } // MARK: - Self-Encrypt Flush /// Encrypt received messages with self-encryption key and upload to server. /// Allows other devices of the same user to read received messages. /// Matches Python: _flush_self_encrypt() func flushSelfEncrypt() async { guard !pendingSelfEncrypt.isEmpty, let edPriv = identityPrivate else { return } let selfKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: edPriv.rawData) var updates: [[String: Any]] = [] for item in pendingSelfEncrypt { guard let (_, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(item.plaintext, key: selfKey) else { continue } let selfCiphertext = ct + tag updates.append([ "message_id": item.messageId, "encrypted_content": ProtocolHandler.encodeBinary(selfCiphertext), "nonce": ProtocolHandler.encodeBinary(nonce), ]) } pendingSelfEncrypt.removeAll() // Send in batches of 500 (matches Python batch size) let batchSize = 500 for batchStart in stride(from: 0, to: updates.count, by: batchSize) { let batchEnd = min(batchStart + batchSize, updates.count) let batch = Array(updates[batchStart.. [Conversation] { let resp = await sendAndReceive(type: "list_conversations") guard resp.string(for: "status") == "ok", let data = resp.dict(for: "data"), let convList = data["conversations"] as? [[String: Any]] else { return [] } return convList.compactMap { dict -> Conversation? in guard let id = dict.string(for: "conversation_id") else { return nil } let membersRaw = dict["members"] as? [[String: Any]] ?? [] #if DEBUG print("DEBUG listConversations: conv \(id) raw members count=\(membersRaw.count), raw=\(membersRaw)") #endif let members = membersRaw.compactMap { m -> ConversationMember? in guard let uid = m.string(for: "user_id"), let uname = m.string(for: "username"), let uemail = m.string(for: "email") else { #if DEBUG print("DEBUG listConversations: FAILED to parse member: \(m)") #endif return nil } return ConversationMember(userId: uid, username: uname, email: uemail) } #if DEBUG print("DEBUG listConversations: conv \(id) parsed members count=\(members.count)") #endif let unreadCount = dict.int(for: "unread_count") ?? 0 return Conversation( id: id, name: dict.string(for: "name"), members: members, createdBy: dict.string(for: "created_by"), avatarFile: dict.string(for: "avatar_file"), unreadCount: unreadCount, isFavorite: false, lastMessageTime: nil ) } } func createConversation(emails: [String], name: String? = nil) async -> (convId: String?, message: String) { var params: [String: Any] = ["members": emails] if let name = name { params["name"] = name } let resp = await sendAndReceive(type: "create_conversation", params: params) guard resp.string(for: "status") == "ok", let data = resp.dict(for: "data"), let convId = data.string(for: "conversation_id") else { let msg = resp.dict(for: "data")?.string(for: "message") ?? "Failed to create conversation" return (nil, msg) } return (convId, "Conversation created") } func findConversation(email: String) async -> String? { let resp = await sendAndReceive(type: "find_conversation", params: ["email": email]) guard resp.string(for: "status") == "ok", let data = resp.dict(for: "data") else { return nil } return data.string(for: "conversation_id") } // MARK: - Payload Parsing Helpers private func parseImageInfo(from jsonObj: [String: Any]) -> ImageInfo? { guard let imgDict = jsonObj["image"] as? [String: Any], let fileId = imgDict["file_id"] as? String, !fileId.isEmpty else { return nil } return ImageInfo( fileId: fileId, aesKey: imgDict["aes_key"] as? String ?? "", iv: imgDict["iv"] as? String ?? "", thumbnail: imgDict["thumbnail"] as? String, filename: imgDict["filename"] as? String ?? "image.jpg", size: imgDict["size"] as? Int ?? 0 ) } // MARK: - Messages func getMessages(convId: String, limit: Int = 50, offset: Int = 0, afterTs: String? = nil) async -> [Message] { var params: [String: Any] = [ "conversation_id": convId, "limit": limit, "offset": offset, ] if let afterTs = afterTs { params["after_ts"] = afterTs } let resp = await sendAndReceive(type: "get_messages", params: params) guard resp.string(for: "status") == "ok", let data = resp.dict(for: "data"), let messagesRaw = data["messages"] as? [[String: Any]] else { return [] } // Server returns DESC (newest first), reverse to ASC (oldest first) — matches Python client let sortedMessages = Array(messagesRaw.reversed()) // Deduplicate: server can return both device-specific and SELF_DEVICE_ID rows // for the same message. Prefer self-encrypted rows (new devices after pairing // can't decrypt old device-specific ratchet data, but CAN decrypt self-encrypted). var dedupedMessages: [[String: Any]] = [] var msgIndex: [String: Int] = [:] for msgDict in sortedMessages { guard let msgId = msgDict.string(for: "message_id") else { continue } let rh = msgDict["ratchet_header"] as? [String: Any] let isSelf = (rh?["self"] as? Bool) == true || (rh?["self"] as? Int) == 1 if let idx = msgIndex[msgId] { if isSelf { dedupedMessages[idx] = msgDict } } else { msgIndex[msgId] = dedupedMessages.count dedupedMessages.append(msgDict) } } var messages: [Message] = [] #if DEBUG print("DEBUG getMessages: processing \(dedupedMessages.count) messages (from \(sortedMessages.count) rows)") #endif for msgDict in dedupedMessages { guard let msgId = msgDict.string(for: "message_id"), let senderId = msgDict.string(for: "sender_id") else { #if DEBUG print("DEBUG getMessages: skipping message without message_id or sender_id") #endif continue } let senderDeviceId = msgDict.string(for: "sender_device_id") ?? Constants.selfDeviceId let isDeleted = msgDict["deleted_at"] != nil && !(msgDict["deleted_at"] is NSNull) #if DEBUG print("DEBUG getMessages: msgId=\(msgId), senderId=\(senderId), senderDeviceId=\(senderDeviceId), myUserId=\(userId ?? "nil")") print("DEBUG getMessages: msgDict keys=\(Array(msgDict.keys))") #endif // Parse read_by from server response (matches Python: m.get("read_by", [])) let readBy = Set(msgDict["read_by"] as? [String] ?? []) // Parse reactions from server response var reactions: [MessageReaction] = [] if let reactionsArr = msgDict["reactions"] as? [[String: Any]] { reactions = reactionsArr.compactMap { r in guard let rUserId = r["user_id"] as? String, let reaction = r["reaction"] as? String else { return nil } let rCreatedAt = (r["created_at"] as? String).flatMap { DateParsing.parse($0) } ?? Date() return MessageReaction(userId: rUserId, reaction: reaction, createdAt: rCreatedAt) } } // Parse pin info from server response let pinnedAt = (msgDict["pinned_at"] as? String).flatMap { DateParsing.parse($0) } let pinnedBy = msgDict["pinned_by"] as? String if isDeleted { let createdAt = msgDict.string(for: "created_at").flatMap { DateParsing.parse($0) } ?? Date() messages.append(Message( id: msgId, conversationId: convId, senderId: senderId, senderUsername: "", createdAt: createdAt, text: nil, isDeleted: true, readBy: readBy, reactions: [], forwardedFrom: nil, pinnedAt: pinnedAt, pinnedBy: pinnedBy )) continue } // Check per-message cache first (ratchet keys are one-time — cannot re-decrypt) if let cachedPlaintext = MessageCache.getCachedMessage( email: email, convId: convId, messageId: msgId, cacheKey: cacheKey ) { var messageText: String? = String(data: cachedPlaintext, encoding: .utf8) var replyTo: String? var file: FileInfo? var image: ImageInfo? var forwardedFrom: ForwardedFrom? var senderName = userCache[senderId]?.username ?? "" if let jsonObj = try? JSONSerialization.jsonObject(with: cachedPlaintext) as? [String: Any] { messageText = jsonObj["text"] as? String replyTo = jsonObj["reply_to"] as? String if senderName.isEmpty, let s = jsonObj["sender"] as? String { senderName = s } if let fileDict = jsonObj["file"] as? [String: Any] { file = FileInfo( fileId: fileDict["file_id"] as? String ?? "", aesKey: fileDict["aes_key"] as? String ?? "", iv: fileDict["iv"] as? String ?? "", filename: fileDict["filename"] as? String ?? "", size: fileDict["size"] as? Int ?? 0, mimeType: fileDict["mime_type"] as? String ?? "" ) } image = parseImageInfo(from: jsonObj) if let fwd = jsonObj["forwarded_from"] as? [String: Any], let fwdSender = fwd["sender"] as? String { forwardedFrom = ForwardedFrom(sender: fwdSender, conversationId: fwd["conversation_id"] as? String ?? "", messageId: fwd["message_id"] as? String ?? "") } } if messageText == nil && file == nil && image == nil { continue } // Control message let createdAt = msgDict.string(for: "created_at").flatMap { DateParsing.parse($0) } ?? Date() messages.append(Message( id: msgId, conversationId: convId, senderId: senderId, senderUsername: senderName, createdAt: createdAt, text: messageText, replyTo: replyTo, imageFileId: msgDict.string(for: "image_file_id"), file: file, image: image, isDeleted: false, readBy: readBy, reactions: reactions, forwardedFrom: forwardedFrom, pinnedAt: pinnedAt, pinnedBy: pinnedBy )) #if DEBUG print("DEBUG getMessages: loaded from cache msgId=\(msgId)") #endif continue } // Try to decrypt (only for messages not in cache) // Dispatch: self-encrypted (ratchet_header.self) → static key // group messages (sender_chain_id present, from others) → sender key decrypt // DM or self-copy → ratchet / self-key decrypt var plaintext: Data? let ratchetHeader = msgDict["ratchet_header"] as? [String: Any] let isSelfEncrypted = (ratchetHeader?["self"] as? Bool) == true || (ratchetHeader?["self"] as? Int) == 1 #if DEBUG print("DEBUG getMessages: decrypt msgId=\(msgId) senderId=\(senderId) isSelfEncrypted=\(isSelfEncrypted) ratchetHeader=\(ratchetHeader as Any) hasCacheKey=\(cacheKey != nil)") #endif if isSelfEncrypted, let cacheKey = cacheKey { // Re-encrypted message (from reencryptHistory) — decrypt with self-key if let ctB64 = (msgDict["encrypted_content"] as? String ?? msgDict["ciphertext"] as? String), let nonceB64 = msgDict["nonce"] as? String, let ct = try? ProtocolHandler.decodeBinary(ctB64), let nonce = try? ProtocolHandler.decodeBinary(nonceB64), ct.count >= 16 { let ciphertext = ct.prefix(ct.count - 16) let tag = ct.suffix(16) plaintext = try? CryptoUtils.aesDecrypt(key: cacheKey, nonce: nonce, ciphertext: Data(ciphertext), tag: Data(tag)) #if DEBUG print("DEBUG getMessages: self-decrypt msgId=\(msgId) ctLen=\(ct.count) nonceLen=\(nonce.count) result=\(plaintext != nil ? "OK(\(plaintext!.count)B)" : "FAILED")") #endif } else { #if DEBUG print("DEBUG getMessages: self-decrypt msgId=\(msgId) SKIPPED (missing ct/nonce)") #endif } } else if let chainId = msgDict["sender_chain_id"] as? String, !chainId.isEmpty, senderId != userId { plaintext = decryptGroupMessage( msgDict: msgDict, senderId: senderId, senderDeviceId: senderDeviceId, convId: convId ) } else { plaintext = decryptDMRecipientData( senderData: msgDict, senderId: senderId, senderDeviceId: senderDeviceId ) } if let plaintext = plaintext { // Cache the decrypted plaintext for future use MessageCache.cacheDecryptedMessage( email: email, convId: convId, messageId: msgId, plaintext: plaintext, cacheKey: cacheKey ) // Queue for self-encryption if from another user if senderId != userId && !msgId.isEmpty { pendingSelfEncrypt.append((messageId: msgId, plaintext: plaintext)) } let text = String(data: plaintext, encoding: .utf8) var messageText = text var replyTo: String? var file: FileInfo? var image: ImageInfo? var forwardedFrom: ForwardedFrom? var senderName = userCache[senderId]?.username ?? "" if let jsonObj = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any] { messageText = jsonObj["text"] as? String replyTo = jsonObj["reply_to"] as? String if senderName.isEmpty, let s = jsonObj["sender"] as? String { senderName = s } if let fileDict = jsonObj["file"] as? [String: Any] { file = FileInfo( fileId: fileDict["file_id"] as? String ?? "", aesKey: fileDict["aes_key"] as? String ?? "", iv: fileDict["iv"] as? String ?? "", filename: fileDict["filename"] as? String ?? "", size: fileDict["size"] as? Int ?? 0, mimeType: fileDict["mime_type"] as? String ?? "" ) } image = parseImageInfo(from: jsonObj) if let fwd = jsonObj["forwarded_from"] as? [String: Any], let fwdSender = fwd["sender"] as? String { forwardedFrom = ForwardedFrom(sender: fwdSender, conversationId: fwd["conversation_id"] as? String ?? "", messageId: fwd["message_id"] as? String ?? "") } } if messageText == nil && file == nil && image == nil { continue } // Control message let createdAt = msgDict.string(for: "created_at").flatMap { DateParsing.parse($0) } ?? Date() messages.append(Message( id: msgId, conversationId: convId, senderId: senderId, senderUsername: senderName, createdAt: createdAt, text: messageText, replyTo: replyTo, imageFileId: msgDict.string(for: "image_file_id"), file: file, image: image, isDeleted: false, readBy: readBy, reactions: reactions, forwardedFrom: forwardedFrom, pinnedAt: pinnedAt, pinnedBy: pinnedBy )) #if DEBUG print("DEBUG getMessages: decrypted and cached msgId=\(msgId)") #endif } else { #if DEBUG print("DEBUG getMessages: decryption failed for msgId=\(msgId)") #endif } } // Confirm delivery for messages from others (fire-and-forget) let deliverIds = messages.filter { $0.senderId != userId && !$0.isDeleted }.map(\.id) if !deliverIds.isEmpty { Task { await confirmDelivery(convId: convId, messageIds: deliverIds) } } // Flush any queued self-encryptions in background if !pendingSelfEncrypt.isEmpty { Task { await flushSelfEncrypt() } } #if DEBUG print("DEBUG getMessages: returning \(messages.count) messages") #endif return messages } func markRead(convId: String, messageIds: [String]) async { _ = await sendAndReceive(type: "mark_read", params: [ "conversation_id": convId, "message_ids": messageIds, ]) } func markConversationRead(convId: String) async { _ = await sendAndReceive(type: "mark_conversation_read", params: [ "conversation_id": convId, ]) } func deleteMessage(messageId: String, convId: String) async -> Bool { let resp = await sendAndReceive(type: "delete_message", params: [ "message_id": messageId, "conversation_id": convId, ]) return resp.string(for: "status") == "ok" } func getDeletedSince(convId: String, sinceTs: String) async -> [String] { let resp = await sendAndReceive(type: "get_deleted_since", params: [ "conversation_id": convId, "since_ts": sinceTs, ]) guard resp.string(for: "status") == "ok", let data = resp.dict(for: "data"), let ids = data["deleted_ids"] as? [String] else { return [] } return ids } func reactMessage(messageId: String, conversationId: String, reaction: String, action: String) async -> Bool { let resp = await sendAndReceive(type: "react_message", params: [ "message_id": messageId, "conversation_id": conversationId, "reaction": reaction, "action": action, ]) return resp.string(for: "status") == "ok" } func pinMessage(messageId: String, conversationId: String, action: String) async -> Bool { let resp = await sendAndReceive(type: "pin_message", params: [ "message_id": messageId, "conversation_id": conversationId, "action": action, ]) return resp.string(for: "status") == "ok" } func getPinnedMessages(conversationId: String) async -> [[String: Any]] { let resp = await sendAndReceive(type: "get_pinned_messages", params: [ "conversation_id": conversationId, ]) guard resp.string(for: "status") == "ok", let data = resp.dict(for: "data"), let messages = data["messages"] as? [[String: Any]] else { return [] } return messages } // MARK: - Group Operations func addMember(convId: String, email: String) async -> (success: Bool, message: String) { let resp = await sendAndReceive(type: "add_member", params: [ "conversation_id": convId, "email": email, ]) let msg = resp.dict(for: "data")?.string(for: "message") ?? "" return (resp.string(for: "status") == "ok", msg) } func removeMember(convId: String, userId: String) async -> (success: Bool, message: String) { let resp = await sendAndReceive(type: "remove_member", params: [ "conversation_id": convId, "user_id": userId, ]) let msg = resp.dict(for: "data")?.string(for: "message") ?? "" return (resp.string(for: "status") == "ok", msg) } func leaveGroup(convId: String) async -> (success: Bool, message: String) { let resp = await sendAndReceive(type: "leave_group", params: [ "conversation_id": convId, ]) if resp.string(for: "status") == "ok" { // Clean up local sender keys senderKeyStates.removeValue(forKey: convId) KeyStorage.deleteSenderKeyState(email: email, convId: convId) KeyStorage.deleteRecvSenderKeys(email: email, convId: convId) return (true, "Left group") } let msg = resp.dict(for: "data")?.string(for: "message") ?? "Failed" return (false, msg) } func renameConversation(convId: String, name: String) async -> (success: Bool, message: String) { let resp = await sendAndReceive(type: "rename_conversation", params: [ "conversation_id": convId, "name": name, ]) let msg = resp.dict(for: "data")?.string(for: "message") ?? "" return (resp.string(for: "status") == "ok", msg) } func deleteConversation(convId: String) async -> (success: Bool, message: String) { let resp = await sendAndReceive(type: "delete_conversation", params: [ "conversation_id": convId, ]) if resp.string(for: "status") == "ok" { senderKeyStates.removeValue(forKey: convId) KeyStorage.deleteSenderKeyState(email: email, convId: convId) KeyStorage.deleteRecvSenderKeys(email: email, convId: convId) return (true, "Deleted") } let msg = resp.dict(for: "data")?.string(for: "message") ?? "Failed" return (false, msg) } // MARK: - Invitations func acceptInvitation(convId: String) async -> (success: Bool, message: String) { let resp = await sendAndReceive(type: "accept_invitation", params: [ "conversation_id": convId, ]) let msg = resp.dict(for: "data")?.string(for: "message") ?? "" return (resp.string(for: "status") == "ok", msg) } func declineInvitation(convId: String) async -> (success: Bool, message: String) { let resp = await sendAndReceive(type: "decline_invitation", params: [ "conversation_id": convId, ]) let msg = resp.dict(for: "data")?.string(for: "message") ?? "" return (resp.string(for: "status") == "ok", msg) } func listInvitations() async -> [Invitation] { let resp = await sendAndReceive(type: "list_invitations") guard resp.string(for: "status") == "ok", let data = resp.dict(for: "data"), let invList = data["invitations"] as? [[String: Any]] else { return [] } return invList.compactMap { dict -> Invitation? in guard let convId = dict.string(for: "conversation_id") else { return nil } return Invitation( id: dict.string(for: "id") ?? convId, conversationId: convId, conversationName: dict.string(for: "conversation_name") ?? "Group", invitedBy: dict.string(for: "invited_by") ?? "", invitedByUsername: dict.string(for: "invited_by_username") ?? "" ) } } // MARK: - Profile func getProfile(userId: String? = nil) async -> UserProfile? { var params: [String: Any] = [:] if let userId = userId { params["user_id"] = userId } let resp = await sendAndReceive(type: "get_profile", params: params) guard resp.string(for: "status") == "ok", let data = resp.dict(for: "data") else { return nil } return UserProfile( userId: data.string(for: "user_id") ?? userId ?? self.userId ?? "", username: data.string(for: "username"), email: data.string(for: "email"), phone: data.string(for: "phone"), phoneVisible: data.bool(for: "phone_visible") ?? false, location: data.string(for: "location"), locationVisible: data.bool(for: "location_visible") ?? false, avatarFile: data.string(for: "avatar_file") ) } func updateProfile(phone: String? = nil, phoneVisible: Bool? = nil, location: String? = nil, locationVisible: Bool? = nil) async -> Bool { var params: [String: Any] = [:] if let phone = phone { params["phone"] = phone } if let phoneVisible = phoneVisible { params["phone_visible"] = phoneVisible } if let location = location { params["location"] = location } if let locationVisible = locationVisible { params["location_visible"] = locationVisible } let resp = await sendAndReceive(type: "update_profile", params: params) return resp.string(for: "status") == "ok" } func updateAvatar(imageData: Data) async -> (Bool, String) { // Resize avatar to fit within protocol message limit (64KB total) guard let uiImage = UIImage(data: imageData) else { return (false, "Invalid image") } let maxDim: CGFloat = 256 let scale = min(1.0, maxDim / max(uiImage.size.width, uiImage.size.height)) let newSize = CGSize(width: uiImage.size.width * scale, height: uiImage.size.height * scale) let renderer = UIGraphicsImageRenderer(size: newSize) let processed = renderer.image { _ in uiImage.draw(in: CGRect(origin: .zero, size: newSize)) } guard let jpegData = processed.jpegData(compressionQuality: 0.6) else { return (false, "JPEG encode failed") } #if DEBUG print("DEBUG updateAvatar: jpegData=\(jpegData.count) bytes, base64=\(jpegData.count * 4 / 3) bytes approx") #endif let resp = await sendAndReceive(type: "update_avatar", params: [ "data": ProtocolHandler.encodeBinary(jpegData), ]) #if DEBUG print("DEBUG updateAvatar: resp=\(resp)") #endif if resp.string(for: "status") == "ok" { return (true, "") } let errMsg = resp.dict(for: "data")?.string(for: "message") ?? "Upload failed" return (false, errMsg) } func getAvatar(userId: String) async -> Data? { let resp = await sendAndReceive(type: "get_avatar", params: ["user_id": userId]) guard resp.string(for: "status") == "ok", let data = resp.dict(for: "data"), let avatarB64 = data.string(for: "data"), let avatarData = try? ProtocolHandler.decodeBinary(avatarB64) else { return nil } return avatarData } // MARK: - Group Avatar func updateGroupAvatar(convId: String, imageData: Data) async -> Bool { guard let uiImage = UIImage(data: imageData) else { return false } let maxDim: CGFloat = 256 let scale = min(1.0, maxDim / max(uiImage.size.width, uiImage.size.height)) let newSize = CGSize(width: uiImage.size.width * scale, height: uiImage.size.height * scale) let renderer = UIGraphicsImageRenderer(size: newSize) let processed = renderer.image { _ in uiImage.draw(in: CGRect(origin: .zero, size: newSize)) } guard let jpegData = processed.jpegData(compressionQuality: 0.6) else { return false } let resp = await sendAndReceive(type: "update_group_avatar", params: [ "conversation_id": convId, "data": ProtocolHandler.encodeBinary(jpegData), ]) return resp.string(for: "status") == "ok" } func getGroupAvatar(convId: String) async -> Data? { let resp = await sendAndReceive(type: "get_group_avatar", params: ["conversation_id": convId]) guard resp.string(for: "status") == "ok", let data = resp.dict(for: "data"), let avatarB64 = data.string(for: "data"), let avatarData = try? ProtocolHandler.decodeBinary(avatarB64) else { return nil } return avatarData } // MARK: - Key Rotation func rotateKeys(password: String) async -> (success: Bool, message: String) { guard rsaPrivate != nil, userId != nil else { return (false, "Not logged in.") } // Generate new RSA-4096 keypair let newPriv: SecKey let newPub: SecKey do { (newPriv, newPub) = try RSACrypto.generateKeypair() } catch { return (false, "Key generation failed: \(error.localizedDescription)") } // Save locally (password-encrypted) var pwdBytes = Array(password.utf8) defer { pwdBytes.withUnsafeMutableBytes { ptr in _ = memset(ptr.baseAddress!, 0, ptr.count) } } do { try KeyStorage.saveRSAKeys(email: email, privateKey: newPriv, publicKey: newPub, password: Data(pwdBytes)) } catch { return (false, "Failed to save keys: \(error.localizedDescription)") } // Update in-memory self.rsaPrivate = newPriv self.rsaPublic = newPub // Serialize public key to PEM and send to server guard let pubPEM = try? RSACrypto.serializePublicKey(newPub), let pubPEMStr = String(data: pubPEM, encoding: .utf8) else { return (false, "Failed to serialize public key") } let resp = await sendAndReceive(type: "rotate_keys", params: ["public_key": pubPEMStr]) if resp.string(for: "status") == "ok" { return (true, "Keys rotated. Other devices will be disconnected.") } let errMsg = resp.dict(for: "data")?.string(for: "message") ?? "Rotation failed" return (false, errMsg) } // MARK: - Device Pairing private var pairingTempPrivateKey: SecKey? private var pairingPollToken: String = "" /// Start pairing on NEW device: generates temp RSA-2048 key, sends to server, returns 8-digit code func pairingStart(email: String) async -> (success: Bool, codeOrMessage: String) { // Generate ephemeral RSA-2048 keypair let tempPriv: SecKey let tempPub: SecKey do { (tempPriv, tempPub) = try RSACrypto.generateKeypair2048() } catch { return (false, "Temp key generation failed") } self.pairingTempPrivateKey = tempPriv // Serialize temp public key to PEM guard let tempPubPEM = try? RSACrypto.serializePublicKey(tempPub), let tempPubStr = String(data: tempPubPEM, encoding: .utf8) else { return (false, "Failed to serialize temp key") } let resp = await sendAndReceive(type: "pairing_start", params: [ "email": email, "temp_public_key": tempPubStr, ]) guard resp.string(for: "status") == "ok", let data = resp.dict(for: "data"), let code = data.string(for: "code") else { let msg = resp.dict(for: "data")?.string(for: "message") ?? "Pairing start failed" return (false, msg) } self.pairingPollToken = data.string(for: "poll_token") ?? "" return (true, code) } /// Wait for pairing on NEW device: polls server every 2s, decrypts keys when ready func pairingWait(code: String, email: String, password: String, timeout: TimeInterval = 300) async -> (success: Bool, message: String) { guard pairingTempPrivateKey != nil else { return (false, "Pairing not started.") } let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { let resp = await sendAndReceive(type: "pairing_poll", params: [ "code": code, "poll_token": pairingPollToken, ]) guard resp.string(for: "status") == "ok", let data = resp.dict(for: "data") else { let msg = resp.dict(for: "data")?.string(for: "message") ?? "Poll failed" return (false, msg) } guard data.bool(for: "ready") == true else { try? await Task.sleep(for: .seconds(2)) continue } // Payload received — decrypt keys guard let payload = data["payload"] as? [String: Any], let encKeyB64 = payload.string(for: "encrypted_key"), let ivB64 = payload.string(for: "iv"), let ctB64 = payload.string(for: "ciphertext"), let tagB64 = payload.string(for: "tag") else { return (false, "Invalid payload format") } do { let encAESKey = try ProtocolHandler.decodeBinary(encKeyB64) let iv = try ProtocolHandler.decodeBinary(ivB64) let ct = try ProtocolHandler.decodeBinary(ctB64) let tag = try ProtocolHandler.decodeBinary(tagB64) // RSA-OAEP decrypt the AES key let aesKey = try RSACrypto.decrypt(pairingTempPrivateKey!, ciphertext: encAESKey) // AES-GCM decrypt the keys JSON let keysJSON = try CryptoUtils.aesDecrypt(key: aesKey, nonce: iv, ciphertext: ct, tag: tag) guard let keysDict = try JSONSerialization.jsonObject(with: keysJSON) as? [String: String], let rsaPrivPEM = keysDict["rsa_private"], let identityHex = keysDict["identity_private"] else { return (false, "Invalid keys JSON") } // Import RSA key var pwdBytes = Array(password.utf8) defer { pwdBytes.withUnsafeMutableBytes { ptr in _ = memset(ptr.baseAddress!, 0, ptr.count) } } let pwdData = Data(pwdBytes) let rsaPriv = try RSACrypto.loadPrivateKey(Data(rsaPrivPEM.utf8), password: nil) let rsaPub = SecKeyCopyPublicKey(rsaPriv)! // Import Ed25519 identity key guard let identityRaw = Data(hexString: identityHex), identityRaw.count == 32 else { return (false, "Invalid identity key format") } let edPriv = try Curve25519.Signing.PrivateKey(rawRepresentation: identityRaw) let edPub = edPriv.publicKey // Save to disk (encrypted with password) try KeyStorage.saveRSAKeys(email: email, privateKey: rsaPriv, publicKey: rsaPub, password: pwdData) try KeyStorage.saveIdentityKeys(email: email, privateKey: edPriv, publicKey: edPub, password: pwdData) // Update in-memory state self.email = email self.rsaPrivate = rsaPriv self.rsaPublic = rsaPub self.identityPrivate = edPriv self.identityPublic = edPub self.cacheKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: edPriv.rawData) self.localKey = CryptoUtils.deriveLocalStorageKey(identityPrivateRaw: edPriv.rawData) // Clear pairing state self.pairingTempPrivateKey = nil self.pairingPollToken = "" return (true, "Pairing complete.") } catch { return (false, "Failed to import keys: \(error.localizedDescription)") } } return (false, "Pairing timed out.") } /// Re-encrypt all cached messages with self-encryption key so a paired device can read them. /// Called by authorizeDevice() before sending keys. func reencryptHistory() async { guard let edPriv = identityPrivate else { return } let selfKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: edPriv.rawData) // Phase 1: Fetch all messages to populate cache (decrypts and caches them) let convs = await listConversations() for conv in convs { var offset = 0 while true { let msgs = await getMessages(convId: conv.id, limit: 200, offset: offset) if msgs.count < 200 { break } offset += msgs.count } } // Phase 2: Read per-message cache and re-encrypt with self-key var allUpdates: [[String: String]] = [] for conv in convs { let cachedMessages = MessageCache.loadAllCachedMessages(email: email, convId: conv.id, cacheKey: cacheKey) for (msgId, plaintext) in cachedMessages { // Skip control messages if let jsonObj = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any] { if jsonObj["_control"] != nil { continue } let text = jsonObj["text"] as? String let image = jsonObj["image"] let file = jsonObj["file"] if text == nil && image == nil && file == nil { continue } } // Re-encrypt with self-encryption key guard let (_, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(plaintext, key: selfKey) else { continue } allUpdates.append([ "message_id": msgId, "encrypted_content": ProtocolHandler.encodeBinary(ct + tag), "nonce": ProtocolHandler.encodeBinary(nonce), ]) } } if allUpdates.isEmpty { return } // Send in batches of 500 let batchSize = 500 for start in stride(from: 0, to: allUpdates.count, by: batchSize) { let end = min(start + batchSize, allUpdates.count) let batch = Array(allUpdates[start.. (success: Bool, message: String) { guard let rsaPriv = rsaPrivate, let edPriv = identityPrivate else { return (false, "Not logged in.") } // Claim the pairing code — get temp public key let claimResp = await sendAndReceive(type: "pairing_claim", params: ["code": code]) guard claimResp.string(for: "status") == "ok", let claimData = claimResp.dict(for: "data"), let tempPubPEM = claimData.string(for: "temp_public_key") else { let msg = claimResp.dict(for: "data")?.string(for: "message") ?? "Claim failed" return (false, msg) } // Load temp public key let tempPub: SecKey do { tempPub = try RSACrypto.loadPublicKey(Data(tempPubPEM.utf8)) } catch { return (false, "Invalid temp public key: \(error.localizedDescription)") } // Re-encrypt message history so new device can read old messages await reencryptHistory() // Build keys payload (RSA private unencrypted PEM + Ed25519 raw hex) let rsaPrivPEM: String do { let pemData = try RSACrypto.serializePrivateKey(rsaPriv, password: nil) rsaPrivPEM = String(data: pemData, encoding: .utf8) ?? "" } catch { return (false, "Failed to serialize RSA key") } let identityHex = edPriv.rawRepresentation.map { String(format: "%02x", $0) }.joined() let keysDict: [String: String] = [ "rsa_private": rsaPrivPEM, "identity_private": identityHex, ] guard let keysJSON = try? JSONSerialization.data(withJSONObject: keysDict) else { return (false, "JSON serialization failed") } // Encrypt: AES-GCM for payload, RSA-OAEP for AES key do { let (aesKey, nonce, ct, tag) = try CryptoUtils.aesEncrypt(keysJSON) let encAESKey = try RSACrypto.encrypt(tempPub, plaintext: aesKey) let payload: [String: String] = [ "encrypted_key": ProtocolHandler.encodeBinary(encAESKey), "iv": ProtocolHandler.encodeBinary(nonce), "ciphertext": ProtocolHandler.encodeBinary(ct), "tag": ProtocolHandler.encodeBinary(tag), ] let resp = await sendAndReceive(type: "pairing_send", params: [ "code": code, "payload": payload, ]) if resp.string(for: "status") == "ok" { return (true, "Device authorized.") } let msg = resp.dict(for: "data")?.string(for: "message") ?? "Send failed" return (false, msg) } catch { return (false, "Encryption failed: \(error.localizedDescription)") } } // MARK: - File Sharing func sendFile(convId: String, fileData: Data, filename: String, mimeType: String, members: [ConversationMember], replyTo: String? = nil) async -> (success: Bool, message: String, sentMessage: Message?) { // Encrypt file with AES-GCM guard let (aesKey, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(fileData) else { return (false, "File encryption failed", nil) } let encryptedData = ct + tag let fileType = mimeType.hasPrefix("image/") ? "image" : "file" // Start upload let fileId = UUID().uuidString.lowercased() let startResp = await sendAndReceive(type: "upload_image_start", params: [ "conversation_id": convId, "file_id": fileId, "file_size": encryptedData.count, "file_type": fileType, ]) guard startResp.string(for: "status") == "ok" else { let msg = startResp.dict(for: "data")?.string(for: "message") ?? "Upload start failed" return (false, msg, nil) } // Upload chunks var offset = 0 while offset < encryptedData.count { let end = min(offset + Constants.imageChunkSize, encryptedData.count) let chunk = encryptedData[offset.. (success: Bool, message: String, sentMessage: Message?) { // Step 1: Process image on MainActor (UIKit requires main thread) let processed = await MainActor.run { () -> (imageBytes: Data, thumbnailB64: String, originalSize: Int)? in guard let uiImage = UIImage(data: imageData) else { return nil } var finalBytes = imageData var img = uiImage // AES-GCM overhead: 12 nonce + 16 tag = 28 bytes let maxPlaintextSize = Constants.maxImageBytes - 28 // If too large, progressively compress if finalBytes.count > maxPlaintextSize { let qualities: [CGFloat] = [0.92, 0.85, 0.75, 0.60] var fits = false for quality in qualities { if let compressed = img.jpegData(compressionQuality: quality) { finalBytes = compressed if compressed.count <= maxPlaintextSize { fits = true; break } } } if !fits { // Downscale dimensions for maxDim: CGFloat in [3840, 2560, 1920, 1280] { if max(img.size.width, img.size.height) > maxDim { let scale = maxDim / max(img.size.width, img.size.height) let newSize = CGSize(width: img.size.width * scale, height: img.size.height * scale) let renderer = UIGraphicsImageRenderer(size: newSize) img = renderer.image { _ in img.draw(in: CGRect(origin: .zero, size: newSize)) } } if let compressed = img.jpegData(compressionQuality: 0.75) { finalBytes = compressed if compressed.count <= maxPlaintextSize { break } } } } } // Generate thumbnail (200x200, quality 60) let thumbScale = min(200.0 / max(img.size.width, img.size.height), 1.0) let thumbDim = CGSize(width: img.size.width * thumbScale, height: img.size.height * thumbScale) let thumbRenderer = UIGraphicsImageRenderer(size: thumbDim) let thumbImg = thumbRenderer.image { _ in img.draw(in: CGRect(origin: .zero, size: thumbDim)) } let thumbB64 = thumbImg.jpegData(compressionQuality: 0.6)?.base64EncodedString() ?? "" return (finalBytes, thumbB64, imageData.count) } guard let processed = processed else { return (false, "Invalid image data", nil) } // Step 2: Encrypt on background (actor isolates this automatically) guard let (aesKey, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(processed.imageBytes) else { return (false, "Image encryption failed", nil) } let encryptedData = ct + tag // Step 3: Chunked upload let fileId = UUID().uuidString.lowercased() let startResp = await sendAndReceive(type: "upload_image_start", params: [ "conversation_id": convId, "file_id": fileId, "file_size": encryptedData.count, "file_type": "image", ]) guard startResp.string(for: "status") == "ok" else { let msg = startResp.dict(for: "data")?.string(for: "message") ?? "Upload start failed" return (false, msg, nil) } var offset = 0 while offset < encryptedData.count { let end = min(offset + Constants.imageChunkSize, encryptedData.count) let chunk = encryptedData[offset.. Data? { #if DEBUG print("DEBUG downloadFile: starting download for fileId=\(fileId) convId=\(conversationId ?? "nil") aesKeyLen=\(aesKey.count) ivLen=\(iv.count)") #endif var allData = Data() var offset = 0 while true { var params: [String: Any] = [ "file_id": fileId, "offset": offset, ] if let conversationId = conversationId { params["conversation_id"] = conversationId } let resp = await sendAndReceive(type: "download_image", params: params) let status = resp.string(for: "status") guard status == "ok" else { let errMsg = resp.dict(for: "data")?.string(for: "message") ?? "unknown" #if DEBUG print("DEBUG downloadFile: server error status=\(status ?? "nil") msg=\(errMsg)") #endif break } guard let data = resp.dict(for: "data") else { #if DEBUG print("DEBUG downloadFile: missing 'data' dict in response, keys=\(Array(resp.keys))") #endif break } guard let chunkB64 = data.string(for: "data") else { #if DEBUG print("DEBUG downloadFile: missing 'data' string in data dict, keys=\(Array(data.keys))") #endif break } guard let chunk = try? ProtocolHandler.decodeBinary(chunkB64) else { #if DEBUG print("DEBUG downloadFile: base64 decode failed, chunkB64 length=\(chunkB64.count)") #endif break } if chunk.isEmpty { #if DEBUG print("DEBUG downloadFile: empty chunk received, done") #endif break } allData.append(chunk) offset += chunk.count #if DEBUG print("DEBUG downloadFile: received chunk \(chunk.count) bytes, total=\(allData.count)") #endif if data.bool(for: "done") == true { #if DEBUG print("DEBUG downloadFile: server signaled done") #endif break } } guard !allData.isEmpty else { #if DEBUG print("DEBUG downloadFile: no data downloaded for fileId=\(fileId)") #endif return nil } // Decrypt: allData = ciphertext + tag(16) guard allData.count >= 16 else { #if DEBUG print("DEBUG downloadFile: data too short (\(allData.count) bytes) for fileId=\(fileId)") #endif return nil } let ct = allData.prefix(allData.count - 16) let tag = allData.suffix(16) #if DEBUG print("DEBUG downloadFile: decrypting \(ct.count) bytes ciphertext + 16 bytes tag") #endif do { let result = try CryptoUtils.aesDecrypt(key: aesKey, nonce: iv, ciphertext: Data(ct), tag: Data(tag)) #if DEBUG print("DEBUG downloadFile: decryption success, \(result.count) bytes") #endif return result } catch { #if DEBUG print("DEBUG downloadFile: decryption FAILED: \(error)") #endif return nil } } // MARK: - Devices func listDevices() async -> [[String: Any]] { let resp = await sendAndReceive(type: "list_devices") guard resp.string(for: "status") == "ok", let data = resp.dict(for: "data") else { return [] } return data["devices"] as? [[String: Any]] ?? [] } func removeDevice(deviceIdToRemove: String) async -> Bool { let resp = await sendAndReceive(type: "remove_device", params: [ "device_id": deviceIdToRemove, ]) return resp.string(for: "status") == "ok" } // MARK: - Session Reset func resetSession(peerUserId: String, peerDeviceId: String? = nil) async { if let peerDeviceId = peerDeviceId { let sessionKey = "\(peerUserId):\(peerDeviceId)" sessions.removeValue(forKey: sessionKey) KeyStorage.deleteSession(email: email, peerUserId: peerUserId, peerDeviceId: peerDeviceId) } else { // Delete all sessions for this user for key in sessions.keys where key.hasPrefix(peerUserId) { sessions.removeValue(forKey: key) } KeyStorage.deleteSession(email: email, peerUserId: peerUserId) } _ = await sendAndReceive(type: "session_reset", params: [ "peer_user_id": peerUserId, "peer_device_id": peerDeviceId ?? "", ]) } func handleSessionResetNotification(fromUserId: String, fromDeviceId: String?) { if let deviceId = fromDeviceId { let sessionKey = "\(fromUserId):\(deviceId)" sessions.removeValue(forKey: sessionKey) KeyStorage.deleteSession(email: email, peerUserId: fromUserId, peerDeviceId: deviceId) } else { for key in sessions.keys where key.hasPrefix(fromUserId) { sessions.removeValue(forKey: key) } KeyStorage.deleteSession(email: email, peerUserId: fromUserId) } } // MARK: - Search func searchMessages(convId: String, query: String) -> [[String: Any]] { MessageCache.search(email: email, convId: convId, query: query, cacheKey: cacheKey) } // MARK: - Change Username func changeUsername(newUsername: String) async -> (success: Bool, message: String) { let trimmed = newUsername.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, trimmed.count <= 100 else { return (false, "Username must be 1-100 characters.") } let resp = await sendAndReceive(type: "change_username", params: ["username": trimmed]) if resp.string(for: "status") == "ok" { if let data = resp.dict(for: "data"), let newName = data.string(for: "username") { username = newName } else { username = trimmed } return (true, "Username changed.") } let msg = resp.dict(for: "data")?.string(for: "message") ?? "Unknown error" return (false, msg) } // MARK: - Change Password func changePassword(oldPassword: String, newPassword: String) -> (success: Bool, message: String) { guard !email.isEmpty else { return (false, "Not logged in.") } let oldPwd = Data(oldPassword.utf8) let newPwd = Data(newPassword.utf8) // 1. Verify old password by loading keys let (rsaPriv, rsaPub, err) = KeyStorage.loadRSAKeys(email: email, password: oldPwd) guard let rsaPriv = rsaPriv, let rsaPub = rsaPub else { return (false, err ?? "Wrong current password.") } let (edPriv, edPub) = KeyStorage.loadIdentityKeys(email: email, password: oldPwd) guard let edPriv = edPriv, let edPub = edPub else { return (false, "Failed to load identity key.") } // 2. Re-save with new password do { try KeyStorage.saveRSAKeys(email: email, privateKey: rsaPriv, publicKey: rsaPub, password: newPwd) try KeyStorage.saveIdentityKeys(email: email, privateKey: edPriv, publicKey: edPub, password: newPwd) return (true, "Password changed successfully.") } catch { return (false, "Failed to save keys: \(error.localizedDescription)") } } // MARK: - Confirm Delivery func confirmDelivery(convId: String, messageIds: [String]) async { guard !messageIds.isEmpty else { return } // Fire-and-forget, non-critical _ = await sendAndReceive(type: "confirm_delivery", params: [ "conversation_id": convId, "message_ids": messageIds, ]) } // MARK: - Forward Message func forwardMessage(targetConvId: String, originalMsg: [String: Any], targetMembers: [ConversationMember]) async -> (success: Bool, message: String, sentMessage: Message?) { let text = originalMsg["text"] as? String ?? "" var payload: [String: Any] = [ "sender": username, "text": text, "forwarded_from": [ "sender": originalMsg["sender"] as? String ?? "", "conversation_id": originalMsg["conversation_id"] as? String ?? "", "message_id": originalMsg["message_id"] as? String ?? "", ] as [String: Any], "timestamp": ISO8601DateFormatter().string(from: Date()), ] // Forward image/file metadata (the encrypted blob is already on the server) if let image = originalMsg["image"] { payload["image"] = image if text.isEmpty { payload["text"] = "" } } if let file = originalMsg["file"] { payload["file"] = file if text.isEmpty { payload["text"] = "" } } var extraPayload: [String: Any] = [:] if let fwd = payload["forwarded_from"] { extraPayload["forwarded_from"] = fwd } if let image = payload["image"] { extraPayload["image"] = image } if let file = payload["file"] { extraPayload["file"] = file } return await sendMessage( convId: targetConvId, text: text, members: targetMembers, extraPayload: extraPayload ) } // MARK: - Proof of Work static func solvePow(challenge: String, difficulty: Int) -> String { let targetBytes = difficulty / 8 let targetBits = difficulty % 8 let mask: UInt8 = targetBits > 0 ? UInt8((0xFF << (8 - targetBits)) & 0xFF) : 0 var nonce: UInt64 = 0 while true { let input = "\(challenge)\(nonce)" let inputData = Data(input.utf8) let digest = SHA256.hash(data: inputData) let digestBytes = Array(digest) var ok = true for i in 0.. 0 { if digestBytes[targetBytes] & mask != 0 { ok = false } } if ok { return String(nonce) } nonce += 1 } } // MARK: - TOFU / Contact Verification /// Load TOFU and verification stores from disk private func loadVerificationStores() { guard !email.isEmpty else { return } knownIdentityKeys = KeyStorage.loadKnownIdentityKeys(email: email, localKey: localKey) verifiedContacts = KeyStorage.loadVerifiedContacts(email: email, localKey: localKey) } /// Check a user's identity key against TOFU registry func checkIdentityKey(userId: String, identityKeyBytes: Data) -> String { let ikHex = identityKeyBytes.hexString let now = ISO8601DateFormatter().string(from: Date()) let known = knownIdentityKeys[userId] if known == nil { // TOFU: trust on first use knownIdentityKeys[userId] = [ "identity_key": ikHex, "first_seen": now, "last_seen": now, ] try? KeyStorage.saveKnownIdentityKeys(email: email, keys: knownIdentityKeys, localKey: localKey) return "new" } if known?["identity_key"] == ikHex { knownIdentityKeys[userId]?["last_seen"] = now try? KeyStorage.saveKnownIdentityKeys(email: email, keys: knownIdentityKeys, localKey: localKey) let verified = verifiedContacts[userId] if verified != nil && verified?["identity_key"] == ikHex { return "verified" } return "trusted" } // Key has CHANGED let wasVerified = verifiedContacts[userId] != nil return wasVerified ? "changed_verified" : "changed" } /// Mark a contact's identity key as explicitly verified func verifyContact(userId: String, identityKey: Data, method: String = "manual") { let ikHex = identityKey.hexString let now = ISO8601DateFormatter().string(from: Date()) verifiedContacts[userId] = [ "identity_key": ikHex, "verified_at": now, "method": method, ] if knownIdentityKeys[userId] == nil { knownIdentityKeys[userId] = [ "identity_key": ikHex, "first_seen": now, "last_seen": now, ] } else { knownIdentityKeys[userId]?["last_seen"] = now } try? KeyStorage.saveVerifiedContacts(email: email, contacts: verifiedContacts, localKey: localKey) try? KeyStorage.saveKnownIdentityKeys(email: email, keys: knownIdentityKeys, localKey: localKey) } /// Remove explicit verification for a contact func unverifyContact(userId: String) { verifiedContacts.removeValue(forKey: userId) try? KeyStorage.saveVerifiedContacts(email: email, contacts: verifiedContacts, localKey: localKey) } /// Accept a changed identity key func acceptKeyChange(userId: String, newIdentityKey: Data) { let ikHex = newIdentityKey.hexString let now = ISO8601DateFormatter().string(from: Date()) knownIdentityKeys[userId] = [ "identity_key": ikHex, "first_seen": now, "last_seen": now, ] verifiedContacts.removeValue(forKey: userId) try? KeyStorage.saveKnownIdentityKeys(email: email, keys: knownIdentityKeys, localKey: localKey) try? KeyStorage.saveVerifiedContacts(email: email, contacts: verifiedContacts, localKey: localKey) } /// Get verification status for a user func getVerificationStatus(userId: String) -> String { if let verified = verifiedContacts[userId] { let known = knownIdentityKeys[userId] if known?["identity_key"] == verified["identity_key"] { return "verified" } } if knownIdentityKeys[userId] != nil { return "trusted" } return "unverified" } /// Get formatted safety number for a peer func getSafetyNumber(peerUserId: String) -> String? { guard let idPub = identityPublic, let myId = userId else { return nil } let myIK = idPub.rawData guard let peerIK = getPeerIdentityKeySync(userId: peerUserId) else { return nil } return ContactVerification.computeSafetyNumber( myUserId: myId, myIdentityKey: myIK, theirUserId: peerUserId, theirIdentityKey: peerIK ) } /// Get formatted fingerprint for own identity key func getMyFingerprint() -> String? { guard let idPub = identityPublic, let myId = userId else { return nil } let fp = ContactVerification.computeFingerprint(userId: myId, identityKey: idPub.rawData) return ContactVerification.formatFingerprint(fp) } /// Get formatted fingerprint for a peer's identity key func getPeerFingerprint(peerUserId: String) -> String? { guard let peerIK = getPeerIdentityKeySync(userId: peerUserId) else { return nil } let fp = ContactVerification.computeFingerprint(userId: peerUserId, identityKey: peerIK) return ContactVerification.formatFingerprint(fp) } /// Get QR code payload bytes for own identity func getVerificationQRData() -> Data? { guard let idPub = identityPublic, let myId = userId else { return nil } return ContactVerification.encodeVerificationQR(userId: myId, identityKey: idPub.rawData) } /// Verify a scanned QR code against known identity keys func verifyQRCode(qrData: Data) -> (success: Bool, userId: String, message: String) { let decoded: (userId: String, identityKey: Data) do { decoded = try ContactVerification.decodeVerificationQR(qrData) } catch { return (false, "", "Invalid QR code: \(error.localizedDescription)") } guard let peerIK = getPeerIdentityKeySync(userId: decoded.userId) else { return (false, decoded.userId, "Unknown user — not in your contacts.") } guard peerIK == decoded.identityKey else { return (false, decoded.userId, "Identity key MISMATCH — verification failed!") } verifyContact(userId: decoded.userId, identityKey: decoded.identityKey, method: "qr_code") let name = userCache[decoded.userId]?.username ?? String(decoded.userId.prefix(8)) return (true, decoded.userId, "Verified \(name) via QR code.") } /// Get a peer's identity key bytes (from cache, TOFU registry, or server) func getPeerIdentityKey(userId: String) async -> Data? { // 1. Check local caches first if let ik = getPeerIdentityKeySync(userId: userId) { return ik } // 2. Fetch from server if let user = await getUserInfo(userId: userId) { return user.identityKey } return nil } /// Sync lookup: user cache → TOFU registry private func getPeerIdentityKeySync(userId: String) -> Data? { if let ik = userCache[userId]?.identityKey { return ik } if let ikHex = knownIdentityKeys[userId]?["identity_key"], let ikData = Data(hexString: ikHex) { return ikData } return nil } // MARK: - Brute-Force Lockout static func checkLockout(email: String) -> TimeInterval { guard let dir = try? KeyStorage.getKeyDir(email: email) else { return 0 } let path = dir.appendingPathComponent("login_lockout.json") guard let data = try? Data(contentsOf: path), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let lockedUntil = json["locked_until"] as? TimeInterval else { return 0 } return max(0, lockedUntil - Date().timeIntervalSince1970) } static func recordFailedAttempt(email: String) { guard let dir = try? KeyStorage.getKeyDir(email: email) else { return } let path = dir.appendingPathComponent("login_lockout.json") var failed = 0 if let data = try? Data(contentsOf: path), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { failed = json["failed_attempts"] as? Int ?? 0 } failed += 1 let delay = min(pow(lockoutBaseSeconds, Double(failed)), lockoutMaxSeconds) let lockedUntil = Date().timeIntervalSince1970 + delay let json: [String: Any] = ["failed_attempts": failed, "locked_until": lockedUntil] if let data = try? JSONSerialization.data(withJSONObject: json) { try? data.write(to: path, options: .completeFileProtection) } } static func clearLockout(email: String) { guard let dir = try? KeyStorage.getKeyDir(email: email) else { return } let path = dir.appendingPathComponent("login_lockout.json") try? FileManager.default.removeItem(at: path) } }