Files
Kecalek_python/ios_client 0.8.5/Kecalek/Core/ChatClient.swift
2026-03-14 12:43:56 +01:00

3667 lines
151 KiB
Swift

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<String> = []
// 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<Void, Never>?
// MARK: - Notification Broadcast (supports multiple consumers)
private var notificationSubscribers: [UUID: AsyncStream<ChatNotification>.Continuation] = [:]
/// Create a new notification stream. Each subscriber gets ALL notifications.
func makeNotificationStream() -> AsyncStream<ChatNotification> {
let id = UUID()
var captured: AsyncStream<ChatNotification>.Continuation!
let stream = AsyncStream<ChatNotification> { 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..<batchEnd])
_ = await sendAndReceive(type: "reencrypt_messages", params: ["updates": batch])
}
}
// MARK: - Conversations
func listConversations() async -> [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..<end])
let resp = await sendAndReceive(type: "reencrypt_messages", params: ["updates": batch])
if resp.string(for: "status") == "ok" {
#if DEBUG
print("DEBUG reencryptHistory: re-encrypted \(end)/\(allUpdates.count) messages")
#endif
} else {
#if DEBUG
print("DEBUG reencryptHistory: batch failed: \(resp.dict(for: "data")?.string(for: "message") ?? "unknown")")
#endif
}
}
#if DEBUG
print("DEBUG reencryptHistory: complete, \(allUpdates.count) messages re-encrypted")
#endif
}
/// Authorize a new device from the EXISTING device: encrypts and sends keys
func authorizeDevice(code: String) async -> (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..<end]
let chunkNum = offset / Constants.imageChunkSize + 1
let chunkResp = await sendAndReceive(type: "upload_image_chunk", timeout: 60, params: [
"file_id": fileId,
"data": ProtocolHandler.encodeBinary(Data(chunk)),
"offset": offset,
])
guard chunkResp.string(for: "status") == "ok" else {
return (false, "Chunk upload failed (chunk \(chunkNum))", nil)
}
offset = end
}
// End upload
let endResp = await sendAndReceive(type: "upload_image_end", timeout: 60, params: [
"file_id": fileId,
])
guard endResp.string(for: "status") == "ok" else {
return (false, "Upload completion failed", nil)
}
// Send message with file info
// Keys must be base64 encoded to match Python server
let fileInfo: [String: Any] = [
"file_id": fileId,
"aes_key": ProtocolHandler.encodeBinary(aesKey),
"iv": ProtocolHandler.encodeBinary(nonce),
"filename": filename,
"size": fileData.count,
"mime_type": mimeType,
]
let extra: [String: Any] = ["file": fileInfo]
let (success, msg, sentMessage) = await sendMessage(
convId: convId, text: "", members: members, replyTo: replyTo,
extraPayload: extra, imageFileId: fileId
)
return (success, msg, sentMessage)
}
func sendImage(convId: String, imageData: Data, members: [ConversationMember],
replyTo: String? = nil) async -> (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..<end]
let chunkResp = await sendAndReceive(type: "upload_image_chunk", timeout: 60, params: [
"file_id": fileId,
"data": ProtocolHandler.encodeBinary(Data(chunk)),
"offset": offset,
])
guard chunkResp.string(for: "status") == "ok" else {
return (false, "Chunk upload failed", nil)
}
offset = end
}
let endResp = await sendAndReceive(type: "upload_image_end", timeout: 60, params: ["file_id": fileId])
guard endResp.string(for: "status") == "ok" else {
return (false, "Upload completion failed", nil)
}
// Step 4: Send message with image info
let imageInfo: [String: Any] = [
"file_id": fileId,
"aes_key": ProtocolHandler.encodeBinary(aesKey),
"iv": ProtocolHandler.encodeBinary(nonce),
"thumbnail": processed.thumbnailB64,
"filename": "image.jpg",
"size": processed.originalSize,
]
let extra: [String: Any] = ["image": imageInfo]
let (success, msg, sentMessage) = await sendMessage(
convId: convId, text: "", members: members, replyTo: replyTo,
extraPayload: extra, imageFileId: fileId
)
return (success, msg, sentMessage)
}
func downloadFile(fileId: String, aesKey: Data, iv: Data, conversationId: String? = nil) async -> 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..<targetBytes {
if digestBytes[i] != 0 {
ok = false
break
}
}
if ok && targetBits > 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)
}
}