1645 lines
64 KiB
Swift
1645 lines
64 KiB
Swift
import Foundation
|
|
import CryptoKit
|
|
|
|
/// 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 connectionStateChanged(connected: Bool)
|
|
}
|
|
|
|
/// 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(set) var sessionToken: String?
|
|
private(set) var userId: String?
|
|
private(set) var username: String = ""
|
|
private(set) var email: String = ""
|
|
private(set) var loginRejected = false
|
|
|
|
// 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 var cacheKey: Data? // for encrypting message cache
|
|
private 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: - Request/Response Tracking
|
|
|
|
private var pendingRequests: [String: CheckedContinuation<[String: Any], Error>] = [:]
|
|
private var listenerTask: Task<Void, Never>?
|
|
|
|
// MARK: - Notification Stream
|
|
|
|
private var notificationContinuation: AsyncStream<ChatNotification>.Continuation?
|
|
nonisolated let notifications: AsyncStream<ChatNotification>
|
|
|
|
// MARK: - Init
|
|
|
|
init() {
|
|
var continuation: AsyncStream<ChatNotification>.Continuation!
|
|
notifications = AsyncStream { cont in
|
|
continuation = cont
|
|
}
|
|
notificationContinuation = continuation
|
|
}
|
|
|
|
// MARK: - Connection
|
|
|
|
func connect(host: String = Constants.defaultHost, port: UInt16 = Constants.defaultPort,
|
|
useTLS: Bool = false, tlsInsecure: Bool = false) async throws {
|
|
try await connectionManager.connect(host: host, port: port, useTLS: useTLS, tlsInsecure: tlsInsecure)
|
|
isConnected = true
|
|
notificationContinuation?.yield(.connectionStateChanged(connected: true))
|
|
}
|
|
|
|
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)
|
|
}
|
|
notificationContinuation?.yield(.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()
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return response
|
|
} catch {
|
|
pendingRequests.removeValue(forKey: requestId)
|
|
return [
|
|
"type": type,
|
|
"status": "error",
|
|
"data": ["message": error.localizedDescription]
|
|
]
|
|
}
|
|
}
|
|
|
|
// MARK: - Background Listener
|
|
|
|
func startBackgroundListener() {
|
|
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
|
|
await handleDisconnect()
|
|
break
|
|
}
|
|
await routeMessage(msg)
|
|
} catch {
|
|
await 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)
|
|
}
|
|
notificationContinuation?.yield(.connectionStateChanged(connected: false))
|
|
}
|
|
|
|
private func routeMessage(_ msg: [String: Any]) {
|
|
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"
|
|
])
|
|
|
|
if notificationTypes.contains(msgType) {
|
|
let data = msg["data"] as? [String: Any] ?? msg
|
|
switch msgType {
|
|
case "new_message":
|
|
notificationContinuation?.yield(.newMessage(data: data))
|
|
case "messages_read":
|
|
notificationContinuation?.yield(.messagesRead(data: data))
|
|
case "message_deleted":
|
|
notificationContinuation?.yield(.messageDeleted(data: data))
|
|
case "conversation_created":
|
|
notificationContinuation?.yield(.conversationCreated(data: data))
|
|
case "member_added":
|
|
notificationContinuation?.yield(.memberAdded(data: data))
|
|
case "member_removed":
|
|
notificationContinuation?.yield(.memberRemoved(data: data))
|
|
case "user_online":
|
|
if let uid = data["user_id"] as? String {
|
|
notificationContinuation?.yield(.userOnline(userId: uid))
|
|
}
|
|
case "user_offline":
|
|
if let uid = data["user_id"] as? String {
|
|
notificationContinuation?.yield(.userOffline(userId: uid))
|
|
}
|
|
case "online_users":
|
|
if let uids = data["user_ids"] as? [String] {
|
|
notificationContinuation?.yield(.onlineUsers(userIds: uids))
|
|
}
|
|
case "group_invitation":
|
|
notificationContinuation?.yield(.groupInvitation(data: data))
|
|
case "conversation_renamed":
|
|
notificationContinuation?.yield(.conversationRenamed(data: data))
|
|
case "session_reset":
|
|
notificationContinuation?.yield(.sessionReset(data: data))
|
|
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, err) = 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,
|
|
])
|
|
|
|
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) {
|
|
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)
|
|
}
|
|
|
|
// Upload prekeys
|
|
await generateAndUploadPrekeys()
|
|
|
|
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)
|
|
for opk in opks {
|
|
opkPrivates[opk.id] = opk.privateKey
|
|
try? KeyStorage.saveOPKPrivate(email: email, opkId: opk.id, privateKey: opk.privateKey)
|
|
}
|
|
|
|
let otpData = opks.map { opk -> [String: Any] in
|
|
[
|
|
"id": opk.id,
|
|
"public_key": ProtocolHandler.encodeBinary(X25519Crypto.serializePublic(opk.publicKey)),
|
|
]
|
|
}
|
|
|
|
_ = await sendAndReceive(type: "upload_prekeys", params: [
|
|
"signed_prekey": spkData,
|
|
"one_time_prekeys": otpData,
|
|
])
|
|
} catch {
|
|
// Log error but don't fail
|
|
print("Prekey generation error: \(error)")
|
|
}
|
|
}
|
|
|
|
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 {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
if let created = formatter.date(from: spkCreatedAt) ?? ISO8601DateFormatter().date(from: 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
|
|
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
|
|
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)
|
|
}
|
|
|
|
// Start background listener
|
|
startBackgroundListener()
|
|
|
|
// Handle online_users if included
|
|
if let onlineUserIds = finishData["online_user_ids"] as? [String] {
|
|
notificationContinuation?.yield(.onlineUsers(userIds: onlineUserIds))
|
|
}
|
|
|
|
// Ensure prekeys in background
|
|
Task { await ensurePrekeys() }
|
|
|
|
return (true, "Logged in as \(username)")
|
|
}
|
|
|
|
// MARK: - Reconnect
|
|
|
|
func reconnect() async -> Bool {
|
|
guard rsaPrivate != nil else { return false }
|
|
|
|
await disconnect()
|
|
|
|
do {
|
|
try await connect()
|
|
} catch {
|
|
return false
|
|
}
|
|
|
|
// 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 {
|
|
return false
|
|
}
|
|
|
|
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" else { return false }
|
|
|
|
startBackgroundListener()
|
|
return true
|
|
}
|
|
|
|
// 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] = []
|
|
|
|
// 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) {
|
|
bundles.append(bundle)
|
|
}
|
|
}
|
|
}
|
|
// Legacy single bundle
|
|
else if let ikHex = data["identity_key"] as? String {
|
|
let bundle = try DeviceBundle.fromDict(data)
|
|
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)"
|
|
|
|
// Check memory
|
|
if let session = sessions[sessionKey] {
|
|
return session
|
|
}
|
|
|
|
// Check disk
|
|
if let session = KeyStorage.loadSession(
|
|
email: email,
|
|
peerUserId: peerUserId,
|
|
localKey: localKey,
|
|
peerDeviceId: peerDeviceId
|
|
) {
|
|
sessions[sessionKey] = session
|
|
return session
|
|
}
|
|
|
|
// 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, ekPriv, 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
|
|
var x3dhHeader: [String: Any] = [
|
|
"ik": Ed25519Crypto.serializePublic(identityPublic!).hexString,
|
|
"ek": X25519Crypto.serializePublic(ekPub).hexString,
|
|
"spk_id": bundle.spkId,
|
|
]
|
|
if let opkId = bundle.opkId {
|
|
x3dhHeader["opk_id"] = opkId
|
|
}
|
|
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 ikHex = x3dhHeader["ik"] as? String,
|
|
let ikData = Data(hexString: ikHex),
|
|
let ekHex = x3dhHeader["ek"] as? String,
|
|
let ekData = Data(hexString: ekHex),
|
|
let spkIdStr = x3dhHeader["spk_id"] as? String else {
|
|
throw CryptoError.x3dhFailed("Invalid X3DH header")
|
|
}
|
|
|
|
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 {
|
|
spkToUse = override
|
|
} else 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")
|
|
}
|
|
|
|
// OPK
|
|
var opkPriv: Curve25519.KeyAgreement.PrivateKey?
|
|
if let opkIdStr = x3dhHeader["opk_id"] as? String {
|
|
opkPriv = opkPrivates[opkIdStr] ?? KeyStorage.loadOPKPrivate(email: email, opkId: opkIdStr)
|
|
if opkPriv != nil {
|
|
opkPrivates.removeValue(forKey: opkIdStr)
|
|
KeyStorage.deleteOPKPrivate(email: email, opkId: opkIdStr)
|
|
}
|
|
}
|
|
|
|
let sharedSecret = try X3DH.respond(
|
|
ikPrivateEd: identityPrivate!,
|
|
spkPrivate: spkToUse,
|
|
ikRemoteEd: remoteIkEd,
|
|
ekRemote: ekRemote,
|
|
opkPrivate: opkPriv
|
|
)
|
|
|
|
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)
|
|
|
|
return ratchet
|
|
}
|
|
|
|
// MARK: - Send Message
|
|
|
|
func sendMessage(convId: String, text: String, members: [ConversationMember], replyTo: String? = nil) async -> (success: Bool, message: String) {
|
|
let isGroup = members.count > 2
|
|
|
|
if isGroup {
|
|
return await sendGroupMessage(convId: convId, text: text, members: members, replyTo: replyTo)
|
|
} else {
|
|
return await sendDM(convId: convId, text: text, members: members, replyTo: replyTo)
|
|
}
|
|
}
|
|
|
|
// MARK: - Send DM
|
|
|
|
private func sendDM(convId: String, text: String, members: [ConversationMember], replyTo: String? = nil) async -> (success: Bool, message: String) {
|
|
guard let identityPrivate = identityPrivate else {
|
|
return (false, "Identity key not loaded")
|
|
}
|
|
|
|
let plaintext = Data(text.utf8)
|
|
var payload: [String: Any] = ["text": text]
|
|
if let replyTo = replyTo {
|
|
payload["reply_to"] = replyTo
|
|
}
|
|
|
|
var recipients: [[String: Any]] = []
|
|
|
|
// Encrypt for each member's devices
|
|
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
|
|
)
|
|
|
|
// 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,
|
|
"ciphertext": ProtocolHandler.encodeBinary(encrypted.ciphertext),
|
|
"nonce": ProtocolHandler.encodeBinary(encrypted.nonce),
|
|
"ratchet_header": encrypted.header,
|
|
]
|
|
if let x3dh = x3dhHeader {
|
|
recipientEntry["x3dh_header"] = x3dh
|
|
}
|
|
recipients.append(recipientEntry)
|
|
}
|
|
} catch {
|
|
return (false, "Encryption failed for \(member.username): \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
// Self-encrypted copy
|
|
let selfKey = CryptoUtils.deriveSelfEncryptionKey(identityPrivateRaw: identityPrivate.rawData)
|
|
if let (_, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(plaintext, key: selfKey) {
|
|
let selfCiphertext = ct + tag
|
|
let dummyHeader: [String: Any] = [
|
|
"dh_pub": String(repeating: "00", count: 32),
|
|
"n": 0,
|
|
"pn": 0,
|
|
]
|
|
recipients.append([
|
|
"user_id": userId!,
|
|
"device_id": Constants.selfDeviceId,
|
|
"ciphertext": ProtocolHandler.encodeBinary(selfCiphertext),
|
|
"nonce": ProtocolHandler.encodeBinary(nonce),
|
|
"ratchet_header": dummyHeader,
|
|
])
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Cache the sent message
|
|
if let msgData = resp.dict(for: "data"), let messageId = msgData.string(for: "message_id") {
|
|
var cacheEntry = payload
|
|
cacheEntry["sender_id"] = userId
|
|
cacheEntry["sender_username"] = username
|
|
cacheEntry["created_at"] = ISO8601DateFormatter().string(from: Date())
|
|
try? MessageCache.save(email: email, convId: convId, messages: [cacheEntry], cacheKey: cacheKey)
|
|
}
|
|
|
|
return (true, "Message sent")
|
|
}
|
|
|
|
// MARK: - Send Group Message
|
|
|
|
private func sendGroupMessage(convId: String, text: String, members: [ConversationMember], replyTo: String? = nil) async -> (success: Bool, message: String) {
|
|
guard let identityPrivate = identityPrivate, let userId = userId, let deviceId = deviceId else {
|
|
return (false, "Not properly logged in")
|
|
}
|
|
|
|
// 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: members)
|
|
}
|
|
|
|
// Encrypt with sender key
|
|
let plaintext = Data(text.utf8)
|
|
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 members where member.userId != userId {
|
|
recipients.append([
|
|
"user_id": member.userId,
|
|
"device_id": Constants.selfDeviceId, // group messages use sentinel
|
|
"ciphertext": ProtocolHandler.encodeBinary(encrypted.ciphertext),
|
|
"nonce": ProtocolHandler.encodeBinary(encrypted.nonce),
|
|
])
|
|
}
|
|
|
|
// Self copy
|
|
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,
|
|
"ciphertext": ProtocolHandler.encodeBinary(ct + tag),
|
|
"nonce": ProtocolHandler.encodeBinary(nonce),
|
|
])
|
|
}
|
|
|
|
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,
|
|
"sender_chain_id": ProtocolHandler.encodeBinary(encrypted.ciphertext.prefix(0)), // placeholder
|
|
]
|
|
|
|
// Include sender key metadata for group routing
|
|
params["sender_chain_id"] = encrypted.chainIdHex
|
|
params["sender_chain_n"] = encrypted.n
|
|
|
|
if let replyTo = replyTo {
|
|
params["reply_to"] = replyTo
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
return (true, "Message sent")
|
|
} catch {
|
|
return (false, "Encryption failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
"ciphertext": 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 {
|
|
print("Failed to distribute sender key to \(member.userId): \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Decrypt
|
|
|
|
func decryptDMRecipientData(
|
|
senderData: [String: Any],
|
|
senderId: String,
|
|
senderDeviceId: String
|
|
) -> Data? {
|
|
guard let ctB64 = senderData["ciphertext"] as? String,
|
|
let nonceB64 = senderData["nonce"] as? String,
|
|
let ct = try? ProtocolHandler.decodeBinary(ctB64),
|
|
let nonce = try? ProtocolHandler.decodeBinary(nonceB64) else {
|
|
return nil
|
|
}
|
|
|
|
// Self-encrypted copy
|
|
if senderDeviceId == Constants.selfDeviceId || senderId == userId {
|
|
if let cacheKey = cacheKey {
|
|
// ct = ciphertext + tag(16)
|
|
guard ct.count >= 16 else { return nil }
|
|
let ciphertext = ct.prefix(ct.count - 16)
|
|
let tag = ct.suffix(16)
|
|
return try? CryptoUtils.aesDecrypt(key: cacheKey, nonce: nonce, ciphertext: Data(ciphertext), tag: Data(tag))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Regular DM decryption
|
|
let headerDict = senderData["ratchet_header"] as? [String: Any]
|
|
let x3dhHeader = senderData["x3dh_header"] as? [String: Any]
|
|
|
|
let sessionKey = "\(senderId):\(senderDeviceId)"
|
|
var ratchet = sessions[sessionKey]
|
|
?? KeyStorage.loadSession(email: email, peerUserId: senderId, localKey: localKey, peerDeviceId: senderDeviceId)
|
|
|
|
// Handle X3DH header (new session)
|
|
if let x3dh = x3dhHeader {
|
|
do {
|
|
ratchet = try processX3DHHeader(
|
|
senderId: senderId,
|
|
x3dhHeader: x3dh,
|
|
senderDeviceId: senderDeviceId
|
|
)
|
|
} catch {
|
|
// Try with previous SPK (grace period)
|
|
if let prevSpk = prevSpkPrivate {
|
|
ratchet = try? processX3DHHeader(
|
|
senderId: senderId,
|
|
x3dhHeader: x3dh,
|
|
senderDeviceId: senderDeviceId,
|
|
spkOverride: prevSpk
|
|
)
|
|
}
|
|
if ratchet == nil { return nil }
|
|
}
|
|
}
|
|
|
|
guard let ratchet = ratchet, let header = headerDict else { return nil }
|
|
|
|
do {
|
|
let plaintext = try ratchet.decrypt(headerDict: header, ciphertext: ct, nonce: nonce)
|
|
sessions[sessionKey] = ratchet
|
|
try? KeyStorage.saveSession(email: email, peerUserId: senderId, ratchet: ratchet, localKey: localKey, peerDeviceId: senderDeviceId)
|
|
|
|
// Check for sender key distribution (control message)
|
|
if let jsonObj = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any],
|
|
let senderKeyInfo = jsonObj["_sender_key"] as? [String: Any] {
|
|
handleSenderKeyDistribution(senderKeyInfo, senderId: senderId)
|
|
return nil // Control message
|
|
}
|
|
|
|
return plaintext
|
|
} catch {
|
|
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 {
|
|
print("Failed to import sender key: \(error)")
|
|
}
|
|
}
|
|
|
|
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 {
|
|
return nil
|
|
}
|
|
|
|
let senderDeviceId = data.string(for: "sender_device_id") ?? Constants.selfDeviceId
|
|
|
|
// Find our device's entry
|
|
var recipientData: [String: Any]?
|
|
if let deviceEntries = data["device_entries"] as? [[String: Any]] {
|
|
recipientData = deviceEntries.first(where: {
|
|
($0["device_id"] as? String) == deviceId || ($0["device_id"] as? String) == Constants.selfDeviceId
|
|
})
|
|
}
|
|
// Fallback: use data directly if it has ciphertext
|
|
if recipientData == nil, data["ciphertext"] != nil {
|
|
recipientData = data
|
|
}
|
|
|
|
guard let recipientData = recipientData else { return nil }
|
|
|
|
// Try DM decryption
|
|
if let plaintext = decryptDMRecipientData(
|
|
senderData: recipientData,
|
|
senderId: senderId,
|
|
senderDeviceId: senderDeviceId
|
|
) {
|
|
let text = String(data: plaintext, encoding: .utf8)
|
|
|
|
// Parse JSON payload
|
|
var messageText = text
|
|
var replyTo: String?
|
|
var file: FileInfo?
|
|
if let jsonObj = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any] {
|
|
messageText = jsonObj["text"] as? String
|
|
replyTo = jsonObj["reply_to"] as? String
|
|
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 ?? ""
|
|
)
|
|
}
|
|
}
|
|
|
|
let createdAt = data.string(for: "created_at").flatMap { ISO8601DateFormatter().date(from: $0) } ?? Date()
|
|
let senderUsername = data.string(for: "sender_username") ?? userCache[senderId]?.username ?? "Unknown"
|
|
|
|
return Message(
|
|
id: messageId,
|
|
conversationId: conversationId,
|
|
senderId: senderId,
|
|
senderUsername: senderUsername,
|
|
createdAt: createdAt,
|
|
text: messageText,
|
|
replyTo: replyTo,
|
|
imageFileId: data.string(for: "image_file_id"),
|
|
file: file,
|
|
isDeleted: false,
|
|
readBy: []
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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: "id") else { return nil }
|
|
|
|
let membersRaw = dict["members"] as? [[String: Any]] ?? []
|
|
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 { return nil }
|
|
return ConversationMember(userId: uid, username: uname, email: uemail)
|
|
}
|
|
|
|
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] = ["emails": 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: - Messages
|
|
|
|
func getMessages(convId: String, limit: Int = 50, offset: Int = 0) async -> [Message] {
|
|
let resp = await sendAndReceive(type: "get_messages", params: [
|
|
"conversation_id": convId,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
])
|
|
guard resp.string(for: "status") == "ok",
|
|
let data = resp.dict(for: "data"),
|
|
let messagesRaw = data["messages"] as? [[String: Any]] else {
|
|
return []
|
|
}
|
|
|
|
var messages: [Message] = []
|
|
for msgDict in messagesRaw {
|
|
guard let msgId = msgDict.string(for: "id"),
|
|
let senderId = msgDict.string(for: "sender_id") else { continue }
|
|
|
|
let senderDeviceId = msgDict.string(for: "sender_device_id") ?? Constants.selfDeviceId
|
|
let isDeleted = msgDict["deleted_at"] != nil && !(msgDict["deleted_at"] is NSNull)
|
|
|
|
if isDeleted {
|
|
let createdAt = msgDict.string(for: "created_at").flatMap { ISO8601DateFormatter().date(from: $0) } ?? Date()
|
|
messages.append(Message(
|
|
id: msgId, conversationId: convId, senderId: senderId,
|
|
senderUsername: msgDict.string(for: "sender_username") ?? "",
|
|
createdAt: createdAt, text: nil, isDeleted: true, readBy: []
|
|
))
|
|
continue
|
|
}
|
|
|
|
// Try to decrypt
|
|
if let plaintext = decryptDMRecipientData(
|
|
senderData: msgDict,
|
|
senderId: senderId,
|
|
senderDeviceId: senderDeviceId
|
|
) {
|
|
let text = String(data: plaintext, encoding: .utf8)
|
|
var messageText = text
|
|
var replyTo: String?
|
|
var file: FileInfo?
|
|
|
|
if let jsonObj = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any] {
|
|
messageText = jsonObj["text"] as? String
|
|
replyTo = jsonObj["reply_to"] as? String
|
|
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 ?? ""
|
|
)
|
|
}
|
|
}
|
|
|
|
if messageText == nil && file == nil { continue } // Control message
|
|
|
|
let createdAt = msgDict.string(for: "created_at").flatMap { ISO8601DateFormatter().date(from: $0) } ?? Date()
|
|
messages.append(Message(
|
|
id: msgId, conversationId: convId, senderId: senderId,
|
|
senderUsername: msgDict.string(for: "sender_username") ?? "",
|
|
createdAt: createdAt, text: messageText, replyTo: replyTo,
|
|
imageFileId: msgDict.string(for: "image_file_id"), file: file,
|
|
isDeleted: false, readBy: []
|
|
))
|
|
}
|
|
}
|
|
|
|
return messages
|
|
}
|
|
|
|
func markRead(convId: String, messageIds: [String]) async {
|
|
_ = await sendAndReceive(type: "mark_read", params: [
|
|
"conversation_id": convId,
|
|
"message_ids": messageIds,
|
|
])
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
// 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 {
|
|
let resp = await sendAndReceive(type: "update_avatar", params: [
|
|
"avatar_data": ProtocolHandler.encodeBinary(imageData),
|
|
])
|
|
return resp.string(for: "status") == "ok"
|
|
}
|
|
|
|
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: "avatar_data"),
|
|
let avatarData = try? ProtocolHandler.decodeBinary(avatarB64) else {
|
|
return nil
|
|
}
|
|
return avatarData
|
|
}
|
|
|
|
// MARK: - Group Avatar
|
|
|
|
func updateGroupAvatar(convId: String, imageData: Data) async -> Bool {
|
|
let resp = await sendAndReceive(type: "update_group_avatar", params: [
|
|
"conversation_id": convId,
|
|
"avatar_data": ProtocolHandler.encodeBinary(imageData),
|
|
])
|
|
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: "avatar_data"),
|
|
let avatarData = try? ProtocolHandler.decodeBinary(avatarB64) else {
|
|
return nil
|
|
}
|
|
return avatarData
|
|
}
|
|
|
|
// MARK: - File Sharing
|
|
|
|
func sendFile(convId: String, fileData: Data, filename: String, mimeType: String,
|
|
members: [ConversationMember], replyTo: String? = nil) async -> (success: Bool, message: String) {
|
|
// Encrypt file with AES-GCM
|
|
guard let (aesKey, nonce, ct, tag) = try? CryptoUtils.aesEncrypt(fileData) else {
|
|
return (false, "File encryption failed")
|
|
}
|
|
|
|
let encryptedData = ct + tag
|
|
let fileType = mimeType.hasPrefix("image/") ? "image" : "file"
|
|
|
|
// Start upload
|
|
let startResp = await sendAndReceive(type: "upload_image_start", params: [
|
|
"conversation_id": convId,
|
|
"file_size": encryptedData.count,
|
|
"file_type": fileType,
|
|
])
|
|
guard startResp.string(for: "status") == "ok",
|
|
let startData = startResp.dict(for: "data"),
|
|
let fileId = startData.string(for: "file_id") else {
|
|
let msg = startResp.dict(for: "data")?.string(for: "message") ?? "Upload start failed"
|
|
return (false, msg)
|
|
}
|
|
|
|
// Upload chunks
|
|
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", params: [
|
|
"file_id": fileId,
|
|
"chunk_data": ProtocolHandler.encodeBinary(Data(chunk)),
|
|
"offset": offset,
|
|
])
|
|
guard chunkResp.string(for: "status") == "ok" else {
|
|
return (false, "Chunk upload failed")
|
|
}
|
|
offset = end
|
|
}
|
|
|
|
// End upload
|
|
let endResp = await sendAndReceive(type: "upload_image_end", params: [
|
|
"file_id": fileId,
|
|
])
|
|
guard endResp.string(for: "status") == "ok" else {
|
|
return (false, "Upload completion failed")
|
|
}
|
|
|
|
// Send message with file info
|
|
let fileInfo: [String: Any] = [
|
|
"file_id": fileId,
|
|
"aes_key": aesKey.hexString,
|
|
"iv": nonce.hexString,
|
|
"filename": filename,
|
|
"size": fileData.count,
|
|
"mime_type": mimeType,
|
|
]
|
|
|
|
var payload: [String: Any] = ["file": fileInfo]
|
|
if let replyTo = replyTo {
|
|
payload["reply_to"] = replyTo
|
|
}
|
|
|
|
let payloadData = try! JSONSerialization.data(withJSONObject: payload)
|
|
let payloadText = String(data: payloadData, encoding: .utf8)!
|
|
|
|
return await sendMessage(convId: convId, text: payloadText, members: members, replyTo: replyTo)
|
|
}
|
|
|
|
func downloadFile(fileId: String, aesKey: Data, iv: Data) async -> Data? {
|
|
var allData = Data()
|
|
var offset = 0
|
|
|
|
while true {
|
|
let resp = await sendAndReceive(type: "download_image", params: [
|
|
"file_id": fileId,
|
|
"offset": offset,
|
|
])
|
|
guard resp.string(for: "status") == "ok",
|
|
let data = resp.dict(for: "data"),
|
|
let chunkB64 = data.string(for: "chunk_data"),
|
|
let chunk = try? ProtocolHandler.decodeBinary(chunkB64) else {
|
|
break
|
|
}
|
|
|
|
if chunk.isEmpty { break }
|
|
allData.append(chunk)
|
|
offset += chunk.count
|
|
|
|
if data.bool(for: "complete") == true { break }
|
|
}
|
|
|
|
guard !allData.isEmpty else { return nil }
|
|
|
|
// Decrypt: allData = ciphertext + tag(16)
|
|
guard allData.count >= 16 else { return nil }
|
|
let ct = allData.prefix(allData.count - 16)
|
|
let tag = allData.suffix(16)
|
|
return try? CryptoUtils.aesDecrypt(key: aesKey, nonce: iv, ciphertext: Data(ct), tag: Data(tag))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|