initial commit
This commit is contained in:
18
ios_client/EncryptedChat/App/AppState.swift
Normal file
18
ios_client/EncryptedChat/App/AppState.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum ConnectionStatus: Equatable {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class AppState {
|
||||
var isLoggedIn = false
|
||||
var currentUser: User?
|
||||
var connectionStatus: ConnectionStatus = .disconnected
|
||||
var email: String = ""
|
||||
|
||||
let chatClient = ChatClient()
|
||||
}
|
||||
36
ios_client/EncryptedChat/App/EncryptedChatApp.swift
Normal file
36
ios_client/EncryptedChat/App/EncryptedChatApp.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct EncryptedChatApp: App {
|
||||
@State private var appState = AppState()
|
||||
@State private var authViewModel = AuthViewModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
if appState.isLoggedIn {
|
||||
MainTabView(appState: appState)
|
||||
} else {
|
||||
LoginView(viewModel: authViewModel, appState: appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MainTabView: View {
|
||||
var appState: AppState
|
||||
@State private var convListVM = ConversationListVM()
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
ConversationListView(appState: appState, viewModel: convListVM)
|
||||
.tabItem {
|
||||
Label("Chats", systemImage: "bubble.left.and.bubble.right.fill")
|
||||
}
|
||||
|
||||
ProfileView(appState: appState, isOwnProfile: true)
|
||||
.tabItem {
|
||||
Label("Profile", systemImage: "person.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1644
ios_client/EncryptedChat/Core/ChatClient.swift
Normal file
1644
ios_client/EncryptedChat/Core/ChatClient.swift
Normal file
File diff suppressed because it is too large
Load Diff
397
ios_client/EncryptedChat/Core/KeyStorage.swift
Normal file
397
ios_client/EncryptedChat/Core/KeyStorage.swift
Normal file
@@ -0,0 +1,397 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Local file storage for keys, sessions, and sender keys.
|
||||
/// Matches Python: chat_core.py key storage functions.
|
||||
///
|
||||
/// Base directory: Application Support / EncryptedChat / {email}
|
||||
/// Same file names as Python client for cross-platform compatibility.
|
||||
enum KeyStorage {
|
||||
|
||||
// MARK: - Base Directory
|
||||
|
||||
/// Get or create the key storage directory for a user
|
||||
static func getKeyDir(email: String) throws -> URL {
|
||||
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
let dir = appSupport.appendingPathComponent("EncryptedChat").appendingPathComponent(email)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
// iOS file protection
|
||||
try (dir as NSURL).setResourceValue(URLFileProtection.complete, forKey: .fileProtectionKey)
|
||||
return dir
|
||||
}
|
||||
|
||||
// MARK: - RSA Keys
|
||||
|
||||
/// Save RSA keypair
|
||||
static func saveRSAKeys(email: String, privateKey: SecKey, publicKey: SecKey, password: Data? = nil) throws {
|
||||
let dir = try getKeyDir(email: email)
|
||||
let privData = try RSACrypto.serializePrivateKey(privateKey, password: password)
|
||||
let pubData = try RSACrypto.serializePublicKey(publicKey)
|
||||
try writeProtected(privData, to: dir.appendingPathComponent("private.pem"))
|
||||
try pubData.write(to: dir.appendingPathComponent("public.pem"))
|
||||
}
|
||||
|
||||
/// Load RSA keypair. Returns (private, public, error).
|
||||
static func loadRSAKeys(email: String, password: Data? = nil) -> (SecKey?, SecKey?, String?) {
|
||||
guard let dir = try? getKeyDir(email: email) else {
|
||||
return (nil, nil, "Cannot access key directory")
|
||||
}
|
||||
let privPath = dir.appendingPathComponent("private.pem")
|
||||
let pubPath = dir.appendingPathComponent("public.pem")
|
||||
|
||||
guard FileManager.default.fileExists(atPath: privPath.path) else {
|
||||
return (nil, nil, "No local keys found.")
|
||||
}
|
||||
|
||||
guard let privData = try? Data(contentsOf: privPath),
|
||||
let pubData = try? Data(contentsOf: pubPath) else {
|
||||
return (nil, nil, "Cannot read key files.")
|
||||
}
|
||||
|
||||
do {
|
||||
let privateKey = try RSACrypto.loadPrivateKey(privData, password: password)
|
||||
let publicKey = try RSACrypto.loadPublicKey(pubData)
|
||||
return (privateKey, publicKey, nil)
|
||||
} catch {
|
||||
// Try without password (unencrypted)
|
||||
do {
|
||||
let privateKey = try RSACrypto.loadPrivateKey(privData, password: nil)
|
||||
let publicKey = try RSACrypto.loadPublicKey(pubData)
|
||||
// Re-save with password if provided
|
||||
if let password = password {
|
||||
try? saveRSAKeys(email: email, privateKey: privateKey, publicKey: publicKey, password: password)
|
||||
}
|
||||
return (privateKey, publicKey, nil)
|
||||
} catch {
|
||||
return (nil, nil, "Invalid or missing password.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Identity Keys (Ed25519)
|
||||
|
||||
static func saveIdentityKeys(
|
||||
email: String,
|
||||
privateKey: Curve25519.Signing.PrivateKey,
|
||||
publicKey: Curve25519.Signing.PublicKey,
|
||||
password: Data? = nil
|
||||
) throws {
|
||||
let dir = try getKeyDir(email: email)
|
||||
let privData = try Ed25519Crypto.serializePrivate(privateKey, password: password)
|
||||
let pubData = Ed25519Crypto.serializePublic(publicKey)
|
||||
try writeProtected(privData, to: dir.appendingPathComponent("identity_private.bin"))
|
||||
try pubData.write(to: dir.appendingPathComponent("identity_public.bin"))
|
||||
}
|
||||
|
||||
static func loadIdentityKeys(
|
||||
email: String,
|
||||
password: Data? = nil
|
||||
) -> (Curve25519.Signing.PrivateKey?, Curve25519.Signing.PublicKey?) {
|
||||
guard let dir = try? getKeyDir(email: email) else { return (nil, nil) }
|
||||
let privPath = dir.appendingPathComponent("identity_private.bin")
|
||||
let pubPath = dir.appendingPathComponent("identity_public.bin")
|
||||
|
||||
guard FileManager.default.fileExists(atPath: privPath.path),
|
||||
let privData = try? Data(contentsOf: privPath),
|
||||
let pubData = try? Data(contentsOf: pubPath) else {
|
||||
return (nil, nil)
|
||||
}
|
||||
|
||||
do {
|
||||
let priv = try Ed25519Crypto.loadPrivate(privData, password: password)
|
||||
let pub = try Ed25519Crypto.loadPublic(pubData)
|
||||
return (priv, pub)
|
||||
} catch {
|
||||
return (nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Signed Pre-Key
|
||||
|
||||
static func saveSPK(email: String, privateKey: Curve25519.KeyAgreement.PrivateKey, spkId: String) throws {
|
||||
let dir = try getKeyDir(email: email)
|
||||
try writeProtected(X25519Crypto.serializePrivate(privateKey), to: dir.appendingPathComponent("spk_private.bin"))
|
||||
try spkId.write(to: dir.appendingPathComponent("spk_id.txt"), atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
static func loadSPK(email: String) -> (Curve25519.KeyAgreement.PrivateKey?, String?) {
|
||||
guard let dir = try? getKeyDir(email: email) else { return (nil, nil) }
|
||||
let privPath = dir.appendingPathComponent("spk_private.bin")
|
||||
let idPath = dir.appendingPathComponent("spk_id.txt")
|
||||
|
||||
guard FileManager.default.fileExists(atPath: privPath.path),
|
||||
let privData = try? Data(contentsOf: privPath),
|
||||
let priv = try? X25519Crypto.loadPrivate(privData) else {
|
||||
return (nil, nil)
|
||||
}
|
||||
let spkId = (try? String(contentsOf: idPath, encoding: .utf8))?.trimmed ?? ""
|
||||
return (priv, spkId)
|
||||
}
|
||||
|
||||
// MARK: - Previous SPK (Grace Period)
|
||||
|
||||
static func savePrevSPK(email: String, privateKey: Curve25519.KeyAgreement.PrivateKey, spkId: String) throws {
|
||||
let dir = try getKeyDir(email: email)
|
||||
try writeProtected(X25519Crypto.serializePrivate(privateKey), to: dir.appendingPathComponent("prev_spk_private.bin"))
|
||||
try spkId.write(to: dir.appendingPathComponent("prev_spk_id.txt"), atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
static func loadPrevSPK(email: String) -> (Curve25519.KeyAgreement.PrivateKey?, String?) {
|
||||
guard let dir = try? getKeyDir(email: email) else { return (nil, nil) }
|
||||
let privPath = dir.appendingPathComponent("prev_spk_private.bin")
|
||||
let idPath = dir.appendingPathComponent("prev_spk_id.txt")
|
||||
|
||||
guard FileManager.default.fileExists(atPath: privPath.path),
|
||||
let privData = try? Data(contentsOf: privPath),
|
||||
let priv = try? X25519Crypto.loadPrivate(privData) else {
|
||||
return (nil, nil)
|
||||
}
|
||||
let spkId = (try? String(contentsOf: idPath, encoding: .utf8))?.trimmed ?? ""
|
||||
return (priv, spkId)
|
||||
}
|
||||
|
||||
// MARK: - One-Time Pre-Keys
|
||||
|
||||
static func saveOPKPrivate(email: String, opkId: String, privateKey: Curve25519.KeyAgreement.PrivateKey) throws {
|
||||
let dir = try getKeyDir(email: email).appendingPathComponent("opk_private")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
try writeProtected(X25519Crypto.serializePrivate(privateKey), to: dir.appendingPathComponent("\(opkId).bin"))
|
||||
}
|
||||
|
||||
static func loadOPKPrivate(email: String, opkId: String) -> Curve25519.KeyAgreement.PrivateKey? {
|
||||
guard let dir = try? getKeyDir(email: email) else { return nil }
|
||||
let path = dir.appendingPathComponent("opk_private").appendingPathComponent("\(opkId).bin")
|
||||
guard let data = try? Data(contentsOf: path) else { return nil }
|
||||
return try? X25519Crypto.loadPrivate(data)
|
||||
}
|
||||
|
||||
static func deleteOPKPrivate(email: String, opkId: String) {
|
||||
guard let dir = try? getKeyDir(email: email) else { return }
|
||||
let path = dir.appendingPathComponent("opk_private").appendingPathComponent("\(opkId).bin")
|
||||
try? FileManager.default.removeItem(at: path)
|
||||
}
|
||||
|
||||
// MARK: - Device ID
|
||||
|
||||
static func saveDeviceId(email: String, deviceId: String) throws {
|
||||
let dir = try getKeyDir(email: email)
|
||||
try writeProtected(Data(deviceId.utf8), to: dir.appendingPathComponent("device_id.txt"))
|
||||
}
|
||||
|
||||
static func loadDeviceId(email: String) -> String? {
|
||||
guard let dir = try? getKeyDir(email: email) else { return nil }
|
||||
let path = dir.appendingPathComponent("device_id.txt")
|
||||
guard let data = try? Data(contentsOf: path) else { return nil }
|
||||
let str = String(data: data, encoding: .utf8)?.trimmed
|
||||
return (str?.isEmpty ?? true) ? nil : str
|
||||
}
|
||||
|
||||
// MARK: - Sessions (Double Ratchet)
|
||||
|
||||
static func saveSession(
|
||||
email: String,
|
||||
peerUserId: String,
|
||||
ratchet: DoubleRatchet,
|
||||
localKey: Data? = nil,
|
||||
peerDeviceId: String? = nil
|
||||
) throws {
|
||||
let dir = try getKeyDir(email: email).appendingPathComponent("sessions")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
let filename: String
|
||||
if let deviceId = peerDeviceId {
|
||||
filename = "\(peerUserId)_\(deviceId).bin"
|
||||
} else {
|
||||
filename = "\(peerUserId).bin"
|
||||
}
|
||||
|
||||
var data = try ratchet.exportState()
|
||||
if let localKey = localKey {
|
||||
data = try CryptoUtils.encryptLocal(data, key: localKey)
|
||||
}
|
||||
try writeProtected(data, to: dir.appendingPathComponent(filename))
|
||||
}
|
||||
|
||||
static func loadSession(
|
||||
email: String,
|
||||
peerUserId: String,
|
||||
localKey: Data? = nil,
|
||||
peerDeviceId: String? = nil
|
||||
) -> DoubleRatchet? {
|
||||
guard let dir = try? getKeyDir(email: email) else { return nil }
|
||||
let sessionsDir = dir.appendingPathComponent("sessions")
|
||||
|
||||
let filename: String
|
||||
if let deviceId = peerDeviceId {
|
||||
filename = "\(peerUserId)_\(deviceId).bin"
|
||||
} else {
|
||||
filename = "\(peerUserId).bin"
|
||||
}
|
||||
|
||||
let path = sessionsDir.appendingPathComponent(filename)
|
||||
return loadSessionFile(path, localKey: localKey)
|
||||
}
|
||||
|
||||
static func deleteSession(email: String, peerUserId: String, peerDeviceId: String? = nil) {
|
||||
guard let dir = try? getKeyDir(email: email) else { return }
|
||||
let sessionsDir = dir.appendingPathComponent("sessions")
|
||||
|
||||
if let deviceId = peerDeviceId {
|
||||
let path = sessionsDir.appendingPathComponent("\(peerUserId)_\(deviceId).bin")
|
||||
try? FileManager.default.removeItem(at: path)
|
||||
} else {
|
||||
// Delete all sessions for this user
|
||||
if let files = try? FileManager.default.contentsOfDirectory(atPath: sessionsDir.path) {
|
||||
for file in files where file.hasPrefix(peerUserId) {
|
||||
try? FileManager.default.removeItem(at: sessionsDir.appendingPathComponent(file))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadSessionFile(_ path: URL, localKey: Data?) -> DoubleRatchet? {
|
||||
guard let raw = try? Data(contentsOf: path) else { return nil }
|
||||
|
||||
if let localKey = localKey {
|
||||
// Try encrypted first
|
||||
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: localKey) {
|
||||
return try? DoubleRatchet.importState(decrypted)
|
||||
}
|
||||
// Fallback: plaintext (transparent migration)
|
||||
if let ratchet = try? DoubleRatchet.importState(raw) {
|
||||
// Re-save encrypted
|
||||
try? writeProtected(CryptoUtils.encryptLocal(try ratchet.exportState(), key: localKey), to: path)
|
||||
return ratchet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return try? DoubleRatchet.importState(raw)
|
||||
}
|
||||
|
||||
// MARK: - Sender Keys
|
||||
|
||||
static func saveSenderKeyState(
|
||||
email: String,
|
||||
convId: String,
|
||||
state: SenderKeyState,
|
||||
localKey: Data? = nil
|
||||
) throws {
|
||||
let dir = try getKeyDir(email: email).appendingPathComponent("sender_keys")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
var data = state.exportState()
|
||||
if let localKey = localKey {
|
||||
data = try CryptoUtils.encryptLocal(data, key: localKey)
|
||||
}
|
||||
try writeProtected(data, to: dir.appendingPathComponent("\(convId).bin"))
|
||||
}
|
||||
|
||||
static func loadSenderKeyState(
|
||||
email: String,
|
||||
convId: String,
|
||||
localKey: Data? = nil
|
||||
) -> SenderKeyState? {
|
||||
guard let dir = try? getKeyDir(email: email) else { return nil }
|
||||
let path = dir.appendingPathComponent("sender_keys").appendingPathComponent("\(convId).bin")
|
||||
guard let raw = try? Data(contentsOf: path) else { return nil }
|
||||
|
||||
if let localKey = localKey {
|
||||
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: localKey) {
|
||||
return try? SenderKeyState.importState(decrypted)
|
||||
}
|
||||
// Plaintext fallback
|
||||
if let state = try? SenderKeyState.importState(raw) {
|
||||
try? writeProtected(CryptoUtils.encryptLocal(state.exportState(), key: localKey), to: path)
|
||||
return state
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return try? SenderKeyState.importState(raw)
|
||||
}
|
||||
|
||||
static func deleteSenderKeyState(email: String, convId: String) {
|
||||
guard let dir = try? getKeyDir(email: email) else { return }
|
||||
let path = dir.appendingPathComponent("sender_keys").appendingPathComponent("\(convId).bin")
|
||||
try? FileManager.default.removeItem(at: path)
|
||||
}
|
||||
|
||||
// MARK: - Received Sender Keys
|
||||
|
||||
static func saveRecvSenderKey(
|
||||
email: String,
|
||||
convId: String,
|
||||
senderId: String,
|
||||
senderDeviceId: String,
|
||||
state: SenderKeyState,
|
||||
localKey: Data? = nil
|
||||
) throws {
|
||||
let dir = try getKeyDir(email: email).appendingPathComponent("sender_keys_recv")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
var data = state.exportState()
|
||||
if let localKey = localKey {
|
||||
data = try CryptoUtils.encryptLocal(data, key: localKey)
|
||||
}
|
||||
try writeProtected(data, to: dir.appendingPathComponent("\(convId)_\(senderId)_\(senderDeviceId).bin"))
|
||||
}
|
||||
|
||||
static func loadRecvSenderKey(
|
||||
email: String,
|
||||
convId: String,
|
||||
senderId: String,
|
||||
senderDeviceId: String,
|
||||
localKey: Data? = nil
|
||||
) -> SenderKeyState? {
|
||||
guard let dir = try? getKeyDir(email: email) else { return nil }
|
||||
let path = dir.appendingPathComponent("sender_keys_recv").appendingPathComponent("\(convId)_\(senderId)_\(senderDeviceId).bin")
|
||||
guard let raw = try? Data(contentsOf: path) else { return nil }
|
||||
|
||||
if let localKey = localKey {
|
||||
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: localKey) {
|
||||
return try? SenderKeyState.importState(decrypted)
|
||||
}
|
||||
if let state = try? SenderKeyState.importState(raw) {
|
||||
try? writeProtected(CryptoUtils.encryptLocal(state.exportState(), key: localKey), to: path)
|
||||
return state
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return try? SenderKeyState.importState(raw)
|
||||
}
|
||||
|
||||
static func deleteRecvSenderKeys(email: String, convId: String) {
|
||||
guard let dir = try? getKeyDir(email: email) else { return }
|
||||
let recvDir = dir.appendingPathComponent("sender_keys_recv")
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(atPath: recvDir.path) else { return }
|
||||
for file in files where file.hasPrefix(convId) {
|
||||
try? FileManager.default.removeItem(at: recvDir.appendingPathComponent(file))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Favorites
|
||||
|
||||
static func saveFavorites(email: String, favorites: Set<String>) throws {
|
||||
let dir = try getKeyDir(email: email)
|
||||
let data = try JSONSerialization.data(withJSONObject: Array(favorites))
|
||||
try data.write(to: dir.appendingPathComponent("favorites.json"))
|
||||
}
|
||||
|
||||
static func loadFavorites(email: String) -> Set<String> {
|
||||
guard let dir = try? getKeyDir(email: email) else { return [] }
|
||||
let path = dir.appendingPathComponent("favorites.json")
|
||||
guard let data = try? Data(contentsOf: path),
|
||||
let array = try? JSONSerialization.jsonObject(with: data) as? [String] else {
|
||||
return []
|
||||
}
|
||||
return Set(array)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func writeProtected(_ data: Data, to url: URL) throws {
|
||||
try data.write(to: url, options: .completeFileProtection)
|
||||
}
|
||||
}
|
||||
65
ios_client/EncryptedChat/Core/MessageCache.swift
Normal file
65
ios_client/EncryptedChat/Core/MessageCache.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
/// Encrypted local message cache.
|
||||
/// Matches Python: chat_core.py message cache (message_cache/{conv_id}.json)
|
||||
enum MessageCache {
|
||||
|
||||
/// Save messages for a conversation (encrypted with local storage key)
|
||||
static func save(email: String, convId: String, messages: [[String: Any]], cacheKey: Data?) throws {
|
||||
let dir = try KeyStorage.getKeyDir(email: email).appendingPathComponent("message_cache")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: messages)
|
||||
|
||||
let dataToWrite: Data
|
||||
if let cacheKey = cacheKey {
|
||||
dataToWrite = try CryptoUtils.encryptLocal(jsonData, key: cacheKey)
|
||||
} else {
|
||||
dataToWrite = jsonData
|
||||
}
|
||||
|
||||
try dataToWrite.write(to: dir.appendingPathComponent("\(convId).json"), options: .completeFileProtection)
|
||||
}
|
||||
|
||||
/// Load messages for a conversation
|
||||
static func load(email: String, convId: String, cacheKey: Data?) -> [[String: Any]]? {
|
||||
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil }
|
||||
let path = dir.appendingPathComponent("message_cache").appendingPathComponent("\(convId).json")
|
||||
guard let raw = try? Data(contentsOf: path) else { return nil }
|
||||
|
||||
let jsonData: Data
|
||||
if let cacheKey = cacheKey {
|
||||
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) {
|
||||
jsonData = decrypted
|
||||
} else {
|
||||
// Plaintext fallback (migration)
|
||||
jsonData = raw
|
||||
}
|
||||
} else {
|
||||
jsonData = raw
|
||||
}
|
||||
|
||||
return try? JSONSerialization.jsonObject(with: jsonData) as? [[String: Any]]
|
||||
}
|
||||
|
||||
/// Search messages in a conversation
|
||||
static func search(email: String, convId: String, query: String, cacheKey: Data?) -> [[String: Any]] {
|
||||
guard let messages = load(email: email, convId: convId, cacheKey: cacheKey) else {
|
||||
return []
|
||||
}
|
||||
let lowerQuery = query.lowercased()
|
||||
return messages.filter { msg in
|
||||
if let text = msg["text"] as? String, text.lowercased().contains(lowerQuery) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete cache for a conversation
|
||||
static func delete(email: String, convId: String) {
|
||||
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return }
|
||||
let path = dir.appendingPathComponent("message_cache").appendingPathComponent("\(convId).json")
|
||||
try? FileManager.default.removeItem(at: path)
|
||||
}
|
||||
}
|
||||
95
ios_client/EncryptedChat/Crypto/CryptoErrors.swift
Normal file
95
ios_client/EncryptedChat/Crypto/CryptoErrors.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
enum CryptoError: Error, LocalizedError {
|
||||
case invalidBase64
|
||||
case invalidHex
|
||||
case invalidKeyData(String)
|
||||
case invalidSignature
|
||||
case signatureVerificationFailed
|
||||
case encryptionFailed(String)
|
||||
case decryptionFailed(String)
|
||||
case invalidECP1Format
|
||||
case pbkdf2Failed
|
||||
case rsaKeyGenerationFailed
|
||||
case rsaOperationFailed(String)
|
||||
case x3dhFailed(String)
|
||||
case ratchetError(String)
|
||||
case senderKeyError(String)
|
||||
case maxSkipExceeded
|
||||
case duplicateMessage
|
||||
case invalidHeader(String)
|
||||
case stateImportFailed(String)
|
||||
case keyConversionFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidBase64: return "Invalid base64 encoding"
|
||||
case .invalidHex: return "Invalid hex encoding"
|
||||
case .invalidKeyData(let msg): return "Invalid key data: \(msg)"
|
||||
case .invalidSignature: return "Invalid signature format"
|
||||
case .signatureVerificationFailed: return "Signature verification failed"
|
||||
case .encryptionFailed(let msg): return "Encryption failed: \(msg)"
|
||||
case .decryptionFailed(let msg): return "Decryption failed: \(msg)"
|
||||
case .invalidECP1Format: return "Invalid ECP1 key format"
|
||||
case .pbkdf2Failed: return "PBKDF2 key derivation failed"
|
||||
case .rsaKeyGenerationFailed: return "RSA key generation failed"
|
||||
case .rsaOperationFailed(let msg): return "RSA operation failed: \(msg)"
|
||||
case .x3dhFailed(let msg): return "X3DH failed: \(msg)"
|
||||
case .ratchetError(let msg): return "Ratchet error: \(msg)"
|
||||
case .senderKeyError(let msg): return "Sender key error: \(msg)"
|
||||
case .maxSkipExceeded: return "Maximum message skip exceeded"
|
||||
case .duplicateMessage: return "Duplicate message detected"
|
||||
case .invalidHeader(let msg): return "Invalid header: \(msg)"
|
||||
case .stateImportFailed(let msg): return "State import failed: \(msg)"
|
||||
case .keyConversionFailed(let msg): return "Key conversion failed: \(msg)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NetworkError: Error, LocalizedError {
|
||||
case notConnected
|
||||
case connectionFailed(String)
|
||||
case timeout
|
||||
case serverError(String)
|
||||
case protocolError(String)
|
||||
case messageTooLarge
|
||||
case invalidResponse(String)
|
||||
case authenticationFailed(String)
|
||||
case alreadyConnected
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notConnected: return "Not connected to server"
|
||||
case .connectionFailed(let msg): return "Connection failed: \(msg)"
|
||||
case .timeout: return "Request timed out"
|
||||
case .serverError(let msg): return "Server error: \(msg)"
|
||||
case .protocolError(let msg): return "Protocol error: \(msg)"
|
||||
case .messageTooLarge: return "Message exceeds maximum size"
|
||||
case .invalidResponse(let msg): return "Invalid response: \(msg)"
|
||||
case .authenticationFailed(let msg): return "Authentication failed: \(msg)"
|
||||
case .alreadyConnected: return "Already connected"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ChatError: Error, LocalizedError {
|
||||
case notLoggedIn
|
||||
case conversationNotFound
|
||||
case membershipRequired
|
||||
case permissionDenied(String)
|
||||
case operationFailed(String)
|
||||
case fileError(String)
|
||||
case invalidData(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notLoggedIn: return "Not logged in"
|
||||
case .conversationNotFound: return "Conversation not found"
|
||||
case .membershipRequired: return "Must be a member of this conversation"
|
||||
case .permissionDenied(let msg): return "Permission denied: \(msg)"
|
||||
case .operationFailed(let msg): return "Operation failed: \(msg)"
|
||||
case .fileError(let msg): return "File error: \(msg)"
|
||||
case .invalidData(let msg): return "Invalid data: \(msg)"
|
||||
}
|
||||
}
|
||||
}
|
||||
196
ios_client/EncryptedChat/Crypto/CryptoUtils.swift
Normal file
196
ios_client/EncryptedChat/Crypto/CryptoUtils.swift
Normal file
@@ -0,0 +1,196 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Core cryptographic utilities: AES-GCM, HKDF, KDF helpers
|
||||
enum CryptoUtils {
|
||||
|
||||
// MARK: - AES-256-GCM
|
||||
|
||||
/// Encrypt with AES-256-GCM. Returns (key, nonce, ciphertext, tag) — all as Data.
|
||||
/// If key is nil, generates a random 256-bit key.
|
||||
/// Matches Python: aes_encrypt(plaintext, key=None)
|
||||
static func aesEncrypt(_ plaintext: Data, key: Data? = nil) throws -> (key: Data, nonce: Data, ciphertext: Data, tag: Data) {
|
||||
let keyData = key ?? Data.randomBytes(32)
|
||||
let symmetricKey = SymmetricKey(data: keyData)
|
||||
let nonceData = Data.randomBytes(12)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonceData)
|
||||
|
||||
let sealedBox = try AES.GCM.seal(plaintext, using: symmetricKey, nonce: gcmNonce)
|
||||
|
||||
return (
|
||||
key: keyData,
|
||||
nonce: nonceData,
|
||||
ciphertext: Data(sealedBox.ciphertext),
|
||||
tag: Data(sealedBox.tag)
|
||||
)
|
||||
}
|
||||
|
||||
/// Decrypt with AES-256-GCM.
|
||||
/// Matches Python: aes_decrypt(key, nonce, ciphertext, tag)
|
||||
static func aesDecrypt(key: Data, nonce: Data, ciphertext: Data, tag: Data) throws -> Data {
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||
|
||||
let sealedBox = try AES.GCM.SealedBox(
|
||||
nonce: gcmNonce,
|
||||
ciphertext: ciphertext,
|
||||
tag: tag
|
||||
)
|
||||
|
||||
do {
|
||||
return try AES.GCM.open(sealedBox, using: symmetricKey)
|
||||
} catch {
|
||||
throw CryptoError.decryptionFailed("AES-GCM decryption failed")
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt with AES-256-GCM using AAD. Returns ciphertext with tag appended.
|
||||
/// Used by Double Ratchet and Sender Keys.
|
||||
static func aesGcmEncrypt(_ plaintext: Data, key: Data, nonce: Data, aad: Data) throws -> Data {
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||
|
||||
let sealedBox = try AES.GCM.seal(
|
||||
plaintext,
|
||||
using: symmetricKey,
|
||||
nonce: gcmNonce,
|
||||
authenticating: aad
|
||||
)
|
||||
|
||||
// Return ciphertext + tag concatenated (matches Python AESGCM.encrypt)
|
||||
return Data(sealedBox.ciphertext) + Data(sealedBox.tag)
|
||||
}
|
||||
|
||||
/// Decrypt AES-256-GCM with AAD. Input ciphertext has tag appended (last 16 bytes).
|
||||
static func aesGcmDecrypt(_ ctWithTag: Data, key: Data, nonce: Data, aad: Data) throws -> Data {
|
||||
guard ctWithTag.count >= 16 else {
|
||||
throw CryptoError.decryptionFailed("Ciphertext too short")
|
||||
}
|
||||
|
||||
let ct = ctWithTag.prefix(ctWithTag.count - 16)
|
||||
let tag = ctWithTag.suffix(16)
|
||||
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||
|
||||
let sealedBox = try AES.GCM.SealedBox(
|
||||
nonce: gcmNonce,
|
||||
ciphertext: ct,
|
||||
tag: tag
|
||||
)
|
||||
|
||||
do {
|
||||
return try AES.GCM.open(sealedBox, using: symmetricKey, authenticating: aad)
|
||||
} catch {
|
||||
throw CryptoError.decryptionFailed("AES-GCM decryption with AAD failed")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HKDF
|
||||
|
||||
/// HKDF-SHA256 key derivation.
|
||||
/// Matches Python: hkdf_derive(input_key, salt, info, length=32)
|
||||
static func hkdfDerive(inputKey: Data, salt: Data, info: Data, length: Int = 32) -> Data {
|
||||
let symmetricKey = SymmetricKey(data: inputKey)
|
||||
let derived = HKDF<SHA256>.deriveKey(
|
||||
inputKeyMaterial: symmetricKey,
|
||||
salt: salt,
|
||||
info: info,
|
||||
outputByteCount: length
|
||||
)
|
||||
return derived.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
|
||||
// MARK: - KDF for Double Ratchet
|
||||
|
||||
/// Root key KDF. Returns (newRootKey, chainKey).
|
||||
/// HKDF with rootKey as salt and DH output as input. Derives 64 bytes, split in half.
|
||||
/// Matches Python: kdf_rk(root_key, dh_output)
|
||||
static func kdfRK(rootKey: Data, dhOutput: Data) -> (newRootKey: Data, chainKey: Data) {
|
||||
let derived = hkdfDerive(
|
||||
inputKey: dhOutput,
|
||||
salt: rootKey,
|
||||
info: Data(Constants.rootKeyInfo.utf8),
|
||||
length: 64
|
||||
)
|
||||
return (derived.prefix(32), Data(derived.suffix(32)))
|
||||
}
|
||||
|
||||
/// Chain key KDF. Returns (newChainKey, messageKey).
|
||||
/// HMAC-SHA256: messageKey = HMAC(chainKey, 0x01), newChainKey = HMAC(chainKey, 0x02)
|
||||
/// Matches Python: kdf_ck(chain_key)
|
||||
static func kdfCK(chainKey: Data) -> (newChainKey: Data, messageKey: Data) {
|
||||
let symmetricKey = SymmetricKey(data: chainKey)
|
||||
let messageKey = Data(HMAC<SHA256>.authenticationCode(for: Data([0x01]), using: symmetricKey))
|
||||
let newChainKey = Data(HMAC<SHA256>.authenticationCode(for: Data([0x02]), using: symmetricKey))
|
||||
return (newChainKey, messageKey)
|
||||
}
|
||||
|
||||
// MARK: - Self-Encryption Key
|
||||
|
||||
/// Derive static AES-256 key from identity key for self-encrypted message copies.
|
||||
/// Matches Python: derive_self_encryption_key(identity_private)
|
||||
static func deriveSelfEncryptionKey(identityPrivateRaw: Data) -> Data {
|
||||
hkdfDerive(
|
||||
inputKey: identityPrivateRaw,
|
||||
salt: Data(Constants.selfEncryptionSalt.utf8),
|
||||
info: Data(Constants.selfEncryptionInfo.utf8),
|
||||
length: 32
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Local Storage Key
|
||||
|
||||
/// Derive AES-256 key for encrypting local session/sender key files.
|
||||
/// Matches Python: derive_local_storage_key(identity_private)
|
||||
static func deriveLocalStorageKey(identityPrivateRaw: Data) -> Data {
|
||||
hkdfDerive(
|
||||
inputKey: identityPrivateRaw,
|
||||
salt: Data(Constants.localStorageSalt.utf8),
|
||||
info: Data(Constants.localStorageInfo.utf8),
|
||||
length: 32
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Local File Encryption
|
||||
|
||||
/// Encrypt data for local storage. Format: nonce(12) + tag(16) + ciphertext
|
||||
/// Matches Python: _encrypt_local(data, key)
|
||||
static func encryptLocal(_ data: Data, key: Data) throws -> Data {
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
let sealedBox = try AES.GCM.seal(data, using: symmetricKey)
|
||||
|
||||
var result = Data()
|
||||
result.append(Data(sealedBox.nonce)) // 12 bytes
|
||||
result.append(Data(sealedBox.tag)) // 16 bytes
|
||||
result.append(Data(sealedBox.ciphertext)) // N bytes
|
||||
return result
|
||||
}
|
||||
|
||||
/// Decrypt locally stored data. Format: nonce(12) + tag(16) + ciphertext
|
||||
/// Matches Python: _decrypt_local(raw, key)
|
||||
static func decryptLocal(_ raw: Data, key: Data) throws -> Data {
|
||||
guard raw.count >= 28 else { // 12 + 16 minimum
|
||||
throw CryptoError.decryptionFailed("Local encrypted data too short")
|
||||
}
|
||||
|
||||
let nonce = raw[0..<12]
|
||||
let tag = raw[12..<28]
|
||||
let ct = raw[28...]
|
||||
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||
|
||||
let sealedBox = try AES.GCM.SealedBox(
|
||||
nonce: gcmNonce,
|
||||
ciphertext: ct,
|
||||
tag: tag
|
||||
)
|
||||
|
||||
do {
|
||||
return try AES.GCM.open(sealedBox, using: symmetricKey)
|
||||
} catch {
|
||||
throw CryptoError.decryptionFailed("Local storage decryption failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
371
ios_client/EncryptedChat/Crypto/DoubleRatchet.swift
Normal file
371
ios_client/EncryptedChat/Crypto/DoubleRatchet.swift
Normal file
@@ -0,0 +1,371 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Ratchet header sent with each message
|
||||
struct RatchetHeader {
|
||||
let dhPub: Data // sender's current ratchet public key (32 bytes)
|
||||
let n: Int // message number in current sending chain
|
||||
let pn: Int // number of messages in previous sending chain
|
||||
|
||||
/// Serialize header to JSON bytes for use as AAD.
|
||||
/// Matches Python: RatchetHeader.serialize()
|
||||
func serialize() -> Data {
|
||||
let dict: [String: Any] = [
|
||||
"dh_pub": dhPub.hexString,
|
||||
"n": n,
|
||||
"pn": pn,
|
||||
]
|
||||
// Must produce consistent JSON — sorted keys for determinism
|
||||
return try! JSONSerialization.data(withJSONObject: dict, options: .sortedKeys)
|
||||
}
|
||||
|
||||
/// Convert to dictionary for protocol.
|
||||
/// Matches Python: RatchetHeader.to_dict()
|
||||
func toDict() -> [String: Any] {
|
||||
[
|
||||
"dh_pub": dhPub.hexString,
|
||||
"n": n,
|
||||
"pn": pn,
|
||||
]
|
||||
}
|
||||
|
||||
/// Parse from dictionary.
|
||||
/// Matches Python: RatchetHeader.from_dict(d)
|
||||
static func fromDict(_ d: [String: Any]) throws -> RatchetHeader {
|
||||
guard let dhPubHex = d["dh_pub"] as? String,
|
||||
let dhPub = Data(hexString: dhPubHex),
|
||||
let n = d["n"] as? Int,
|
||||
let pn = d["pn"] as? Int else {
|
||||
throw CryptoError.invalidHeader("Missing or invalid header fields")
|
||||
}
|
||||
return RatchetHeader(dhPub: dhPub, n: n, pn: pn)
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal Double Ratchet implementation.
|
||||
/// Matches Python: DoubleRatchet class in crypto_utils.py
|
||||
class DoubleRatchet {
|
||||
|
||||
private(set) var dhPair: (privateKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
publicKey: Curve25519.KeyAgreement.PublicKey)?
|
||||
private(set) var dhRemote: Curve25519.KeyAgreement.PublicKey?
|
||||
private(set) var rootKey: Data = Data()
|
||||
private(set) var sendChainKey: Data?
|
||||
private(set) var recvChainKey: Data?
|
||||
private(set) var sendN: Int = 0
|
||||
private(set) var recvN: Int = 0
|
||||
private(set) var prevSendN: Int = 0
|
||||
// Skipped message keys: "dh_pub_hex:n" → message_key
|
||||
private(set) var skipped: [String: Data] = [:]
|
||||
|
||||
/// Attached X3DH header — set when creating a new session, consumed on first send.
|
||||
/// Matches Python: ratchet._x3dh_header
|
||||
var x3dhHeader: [String: Any]?
|
||||
|
||||
init() {}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Initialize as initiator (Alice) after X3DH.
|
||||
/// Matches Python: DoubleRatchet.init_alice(shared_secret, bob_spk_pub)
|
||||
static func initAlice(sharedSecret: Data, bobSpkPub: Curve25519.KeyAgreement.PublicKey) throws -> DoubleRatchet {
|
||||
let ratchet = DoubleRatchet()
|
||||
let (priv, pub) = X25519Crypto.generateKeypair()
|
||||
ratchet.dhPair = (priv, pub)
|
||||
ratchet.dhRemote = bobSpkPub
|
||||
|
||||
// Perform DH ratchet to derive send chain
|
||||
let dhOutput = try X25519Crypto.dh(priv, bobSpkPub)
|
||||
let (newRK, sendCK) = CryptoUtils.kdfRK(rootKey: sharedSecret, dhOutput: dhOutput)
|
||||
ratchet.rootKey = newRK
|
||||
ratchet.sendChainKey = sendCK
|
||||
ratchet.recvChainKey = nil
|
||||
ratchet.sendN = 0
|
||||
ratchet.recvN = 0
|
||||
ratchet.prevSendN = 0
|
||||
return ratchet
|
||||
}
|
||||
|
||||
/// Initialize as responder (Bob) after X3DH.
|
||||
/// Matches Python: DoubleRatchet.init_bob(shared_secret, spk_pair)
|
||||
static func initBob(
|
||||
sharedSecret: Data,
|
||||
spkPair: (privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey)
|
||||
) -> DoubleRatchet {
|
||||
let ratchet = DoubleRatchet()
|
||||
ratchet.dhPair = spkPair
|
||||
ratchet.rootKey = sharedSecret
|
||||
ratchet.sendChainKey = nil
|
||||
ratchet.recvChainKey = nil
|
||||
ratchet.sendN = 0
|
||||
ratchet.recvN = 0
|
||||
ratchet.prevSendN = 0
|
||||
return ratchet
|
||||
}
|
||||
|
||||
// MARK: - Encrypt
|
||||
|
||||
/// Encrypt a message.
|
||||
/// Returns (header dict, ciphertext with tag, nonce).
|
||||
/// Matches Python: DoubleRatchet.encrypt(plaintext)
|
||||
func encrypt(_ plaintext: Data) throws -> (header: [String: Any], ciphertext: Data, nonce: Data) {
|
||||
guard sendChainKey != nil else {
|
||||
throw CryptoError.ratchetError("Send chain not initialized")
|
||||
}
|
||||
guard let dhPair = dhPair else {
|
||||
throw CryptoError.ratchetError("DH pair not set")
|
||||
}
|
||||
|
||||
let (newCK, messageKey) = CryptoUtils.kdfCK(chainKey: sendChainKey!)
|
||||
sendChainKey = newCK
|
||||
|
||||
let header = RatchetHeader(
|
||||
dhPub: X25519Crypto.serializePublic(dhPair.publicKey),
|
||||
n: sendN,
|
||||
pn: prevSendN
|
||||
)
|
||||
|
||||
let nonce = Data.randomBytes(12)
|
||||
let aad = header.serialize()
|
||||
let ctWithTag = try CryptoUtils.aesGcmEncrypt(plaintext, key: messageKey, nonce: nonce, aad: aad)
|
||||
|
||||
sendN += 1
|
||||
|
||||
return (header.toDict(), ctWithTag, nonce)
|
||||
}
|
||||
|
||||
// MARK: - Decrypt
|
||||
|
||||
/// Decrypt a message. Handles DH ratchet step if new dh_pub.
|
||||
/// State is snapshotted before modification and restored on failure (M9 fix).
|
||||
/// Matches Python: DoubleRatchet.decrypt(header_dict, ciphertext, nonce)
|
||||
func decrypt(headerDict: [String: Any], ciphertext: Data, nonce: Data) throws -> Data {
|
||||
let header = try RatchetHeader.fromDict(headerDict)
|
||||
let remoteDhPubBytes = header.dhPub
|
||||
|
||||
// Check if this is from a skipped message
|
||||
let skipKey = "\(remoteDhPubBytes.hexString):\(header.n)"
|
||||
if let mk = skipped[skipKey] {
|
||||
skipped.removeValue(forKey: skipKey)
|
||||
let aad = header.serialize()
|
||||
do {
|
||||
return try CryptoUtils.aesGcmDecrypt(ciphertext, key: mk, nonce: nonce, aad: aad)
|
||||
} catch {
|
||||
// Restore skipped key on failure
|
||||
skipped[skipKey] = mk
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot state before modifications
|
||||
let snap = snapshot()
|
||||
|
||||
do {
|
||||
let remoteDhPub = try X25519Crypto.loadPublic(remoteDhPubBytes)
|
||||
let currentRemoteBytes: Data? = dhRemote.map { X25519Crypto.serializePublic($0) }
|
||||
|
||||
if currentRemoteBytes == nil || remoteDhPubBytes != currentRemoteBytes {
|
||||
// New DH ratchet step
|
||||
try skipMessages(until: header.pn)
|
||||
try dhRatchet(remoteDhPub: remoteDhPub)
|
||||
}
|
||||
|
||||
try skipMessages(until: header.n)
|
||||
|
||||
// Derive message key from receive chain
|
||||
guard recvChainKey != nil else {
|
||||
throw CryptoError.ratchetError("Receive chain key is nil")
|
||||
}
|
||||
let (newCK, mk) = CryptoUtils.kdfCK(chainKey: recvChainKey!)
|
||||
recvChainKey = newCK
|
||||
recvN += 1
|
||||
|
||||
let aad = header.serialize()
|
||||
return try CryptoUtils.aesGcmDecrypt(ciphertext, key: mk, nonce: nonce, aad: aad)
|
||||
} catch {
|
||||
restore(snap)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State Snapshot/Restore (M9)
|
||||
|
||||
private struct Snapshot {
|
||||
let dhPairPriv: Data?
|
||||
let dhPairPub: Data?
|
||||
let dhRemote: Data?
|
||||
let rootKey: Data
|
||||
let sendChainKey: Data?
|
||||
let recvChainKey: Data?
|
||||
let sendN: Int
|
||||
let recvN: Int
|
||||
let prevSendN: Int
|
||||
let skipped: [String: Data]
|
||||
}
|
||||
|
||||
private func snapshot() -> Snapshot {
|
||||
Snapshot(
|
||||
dhPairPriv: dhPair.map { X25519Crypto.serializePrivate($0.privateKey) },
|
||||
dhPairPub: dhPair.map { X25519Crypto.serializePublic($0.publicKey) },
|
||||
dhRemote: dhRemote.map { X25519Crypto.serializePublic($0) },
|
||||
rootKey: rootKey,
|
||||
sendChainKey: sendChainKey,
|
||||
recvChainKey: recvChainKey,
|
||||
sendN: sendN,
|
||||
recvN: recvN,
|
||||
prevSendN: prevSendN,
|
||||
skipped: skipped
|
||||
)
|
||||
}
|
||||
|
||||
private func restore(_ snap: Snapshot) {
|
||||
if let privData = snap.dhPairPriv, let pubData = snap.dhPairPub,
|
||||
let priv = try? X25519Crypto.loadPrivate(privData),
|
||||
let pub = try? X25519Crypto.loadPublic(pubData) {
|
||||
dhPair = (priv, pub)
|
||||
} else {
|
||||
dhPair = nil
|
||||
}
|
||||
if let remoteData = snap.dhRemote, let remote = try? X25519Crypto.loadPublic(remoteData) {
|
||||
dhRemote = remote
|
||||
} else {
|
||||
dhRemote = nil
|
||||
}
|
||||
rootKey = snap.rootKey
|
||||
sendChainKey = snap.sendChainKey
|
||||
recvChainKey = snap.recvChainKey
|
||||
sendN = snap.sendN
|
||||
recvN = snap.recvN
|
||||
prevSendN = snap.prevSendN
|
||||
skipped = snap.skipped
|
||||
}
|
||||
|
||||
// MARK: - Internal Ratchet Operations
|
||||
|
||||
private func skipMessages(until: Int) throws {
|
||||
guard recvChainKey != nil else { return }
|
||||
if until - recvN > Constants.maxSkip {
|
||||
throw CryptoError.maxSkipExceeded
|
||||
}
|
||||
while recvN < until {
|
||||
let (newCK, mk) = CryptoUtils.kdfCK(chainKey: recvChainKey!)
|
||||
recvChainKey = newCK
|
||||
let remoteHex = dhRemote.map { X25519Crypto.serializePublic($0).hexString } ?? ""
|
||||
skipped["\(remoteHex):\(recvN)"] = mk
|
||||
recvN += 1
|
||||
}
|
||||
}
|
||||
|
||||
private func dhRatchet(remoteDhPub: Curve25519.KeyAgreement.PublicKey) throws {
|
||||
prevSendN = sendN
|
||||
sendN = 0
|
||||
recvN = 0
|
||||
dhRemote = remoteDhPub
|
||||
|
||||
// Derive new receive chain key
|
||||
guard let dhPair = dhPair else {
|
||||
throw CryptoError.ratchetError("DH pair not set")
|
||||
}
|
||||
let dhOutput1 = try X25519Crypto.dh(dhPair.privateKey, remoteDhPub)
|
||||
let (newRK1, recvCK) = CryptoUtils.kdfRK(rootKey: rootKey, dhOutput: dhOutput1)
|
||||
rootKey = newRK1
|
||||
recvChainKey = recvCK
|
||||
|
||||
// Generate new DH pair and derive new send chain key
|
||||
let (newPriv, newPub) = X25519Crypto.generateKeypair()
|
||||
self.dhPair = (newPriv, newPub)
|
||||
let dhOutput2 = try X25519Crypto.dh(newPriv, remoteDhPub)
|
||||
let (newRK2, sendCK) = CryptoUtils.kdfRK(rootKey: rootKey, dhOutput: dhOutput2)
|
||||
rootKey = newRK2
|
||||
sendChainKey = sendCK
|
||||
}
|
||||
|
||||
// MARK: - State Export/Import
|
||||
|
||||
/// Serialize full ratchet state for persistent storage.
|
||||
/// Produces JSON matching Python's DoubleRatchet.export_state() exactly.
|
||||
func exportState() throws -> Data {
|
||||
var state: [String: Any] = [:]
|
||||
|
||||
if let pair = dhPair {
|
||||
state["dh_priv"] = X25519Crypto.serializePrivate(pair.privateKey).hexString
|
||||
state["dh_pub"] = X25519Crypto.serializePublic(pair.publicKey).hexString
|
||||
} else {
|
||||
state["dh_priv"] = NSNull()
|
||||
state["dh_pub"] = NSNull()
|
||||
}
|
||||
|
||||
if let remote = dhRemote {
|
||||
state["dh_remote"] = X25519Crypto.serializePublic(remote).hexString
|
||||
} else {
|
||||
state["dh_remote"] = NSNull()
|
||||
}
|
||||
|
||||
state["root_key"] = rootKey.hexString
|
||||
state["send_ck"] = sendChainKey?.hexString ?? NSNull()
|
||||
state["recv_ck"] = recvChainKey?.hexString ?? NSNull()
|
||||
state["send_n"] = sendN
|
||||
state["recv_n"] = recvN
|
||||
state["prev_send_n"] = prevSendN
|
||||
|
||||
// Skipped keys: Python format is "dh_pub_hex:n" -> message_key_hex
|
||||
var skippedDict: [String: String] = [:]
|
||||
for (key, value) in skipped {
|
||||
skippedDict[key] = value.hexString
|
||||
}
|
||||
state["skipped"] = skippedDict
|
||||
|
||||
return try JSONSerialization.data(withJSONObject: state)
|
||||
}
|
||||
|
||||
/// Deserialize ratchet state.
|
||||
/// Matches Python: DoubleRatchet.import_state(data)
|
||||
static func importState(_ data: Data) throws -> DoubleRatchet {
|
||||
guard let state = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw CryptoError.stateImportFailed("Invalid JSON")
|
||||
}
|
||||
|
||||
let r = DoubleRatchet()
|
||||
|
||||
if let dhPrivHex = state["dh_priv"] as? String,
|
||||
let dhPubHex = state["dh_pub"] as? String,
|
||||
let privData = Data(hexString: dhPrivHex),
|
||||
let pubData = Data(hexString: dhPubHex) {
|
||||
let priv = try X25519Crypto.loadPrivate(privData)
|
||||
let pub = try X25519Crypto.loadPublic(pubData)
|
||||
r.dhPair = (priv, pub)
|
||||
}
|
||||
|
||||
if let dhRemoteHex = state["dh_remote"] as? String,
|
||||
let remoteData = Data(hexString: dhRemoteHex) {
|
||||
r.dhRemote = try X25519Crypto.loadPublic(remoteData)
|
||||
}
|
||||
|
||||
guard let rootKeyHex = state["root_key"] as? String,
|
||||
let rootKey = Data(hexString: rootKeyHex) else {
|
||||
throw CryptoError.stateImportFailed("Missing root_key")
|
||||
}
|
||||
r.rootKey = rootKey
|
||||
|
||||
if let sendCKHex = state["send_ck"] as? String, let ck = Data(hexString: sendCKHex) {
|
||||
r.sendChainKey = ck
|
||||
}
|
||||
if let recvCKHex = state["recv_ck"] as? String, let ck = Data(hexString: recvCKHex) {
|
||||
r.recvChainKey = ck
|
||||
}
|
||||
|
||||
r.sendN = state["send_n"] as? Int ?? 0
|
||||
r.recvN = state["recv_n"] as? Int ?? 0
|
||||
r.prevSendN = state["prev_send_n"] as? Int ?? 0
|
||||
|
||||
if let skippedDict = state["skipped"] as? [String: String] {
|
||||
for (key, valueHex) in skippedDict {
|
||||
if let value = Data(hexString: valueHex) {
|
||||
r.skipped[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
}
|
||||
73
ios_client/EncryptedChat/Crypto/Ed25519Crypto.swift
Normal file
73
ios_client/EncryptedChat/Crypto/Ed25519Crypto.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Ed25519 signing operations — Identity Key management
|
||||
enum Ed25519Crypto {
|
||||
|
||||
// MARK: - Key Generation
|
||||
|
||||
/// Generate Ed25519 keypair
|
||||
static func generateKeypair() -> (privateKey: Curve25519.Signing.PrivateKey, publicKey: Curve25519.Signing.PublicKey) {
|
||||
let privateKey = Curve25519.Signing.PrivateKey()
|
||||
return (privateKey, privateKey.publicKey)
|
||||
}
|
||||
|
||||
// MARK: - Serialization
|
||||
|
||||
/// Serialize Ed25519 private key. With password: raw 32B → ECP1. Without: raw 32B.
|
||||
/// Matches Python: serialize_ed25519_private(key, password=None)
|
||||
static func serializePrivate(_ key: Curve25519.Signing.PrivateKey, password: Data? = nil) throws -> Data {
|
||||
let raw = key.rawData // 32 bytes
|
||||
if let password = password {
|
||||
return try KeyEncryption.encrypt(raw, password: password)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/// Serialize Ed25519 public key to 32 raw bytes.
|
||||
/// Matches Python: serialize_ed25519_public(key)
|
||||
static func serializePublic(_ key: Curve25519.Signing.PublicKey) -> Data {
|
||||
key.rawData // 32 bytes
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
/// Load Ed25519 private key. Auto-detects ECP1 / raw 32B.
|
||||
/// Matches Python: load_ed25519_private(data, password=None)
|
||||
static func loadPrivate(_ data: Data, password: Data? = nil) throws -> Curve25519.Signing.PrivateKey {
|
||||
if KeyEncryption.isECP1Format(data) {
|
||||
guard let pwd = password else {
|
||||
throw CryptoError.invalidKeyData("ECP1 key requires password")
|
||||
}
|
||||
let raw = try KeyEncryption.decrypt(data, password: pwd)
|
||||
return try Curve25519.Signing.PrivateKey(rawRepresentation: raw)
|
||||
}
|
||||
if data.count == 32 {
|
||||
return try Curve25519.Signing.PrivateKey(rawRepresentation: data)
|
||||
}
|
||||
throw CryptoError.invalidKeyData("Cannot parse Ed25519 private key (\(data.count) bytes)")
|
||||
}
|
||||
|
||||
/// Load Ed25519 public key from 32 raw bytes.
|
||||
/// Matches Python: load_ed25519_public(data)
|
||||
static func loadPublic(_ data: Data) throws -> Curve25519.Signing.PublicKey {
|
||||
guard data.count == 32 else {
|
||||
throw CryptoError.invalidKeyData("Ed25519 public key must be 32 bytes, got \(data.count)")
|
||||
}
|
||||
return try Curve25519.Signing.PublicKey(rawRepresentation: data)
|
||||
}
|
||||
|
||||
// MARK: - Sign / Verify
|
||||
|
||||
/// Sign data with Ed25519. Returns 64-byte signature.
|
||||
/// Matches Python: ed25519_sign(private_key, data)
|
||||
static func sign(_ privateKey: Curve25519.Signing.PrivateKey, data: Data) throws -> Data {
|
||||
Data(try privateKey.signature(for: data))
|
||||
}
|
||||
|
||||
/// Verify Ed25519 signature.
|
||||
/// Matches Python: ed25519_verify(public_key, signature, data)
|
||||
static func verify(_ publicKey: Curve25519.Signing.PublicKey, signature: Data, data: Data) -> Bool {
|
||||
publicKey.isValidSignature(signature, for: data)
|
||||
}
|
||||
}
|
||||
231
ios_client/EncryptedChat/Crypto/FieldArithmetic.swift
Normal file
231
ios_client/EncryptedChat/Crypto/FieldArithmetic.swift
Normal file
@@ -0,0 +1,231 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure Swift GF(2^255-19) arithmetic for Ed25519 → X25519 public key conversion.
|
||||
///
|
||||
/// The conversion formula is: u = (1 + y) / (1 - y) mod p
|
||||
/// where p = 2^255 - 19, and y is the Ed25519 public key's y-coordinate.
|
||||
///
|
||||
/// Uses 4-limb UInt64 representation (little-endian).
|
||||
enum FieldArithmetic {
|
||||
|
||||
// p = 2^255 - 19
|
||||
static let p: [UInt64] = [
|
||||
0xFFFF_FFFF_FFFF_FFED, // limb 0 (least significant)
|
||||
0xFFFF_FFFF_FFFF_FFFF, // limb 1
|
||||
0xFFFF_FFFF_FFFF_FFFF, // limb 2
|
||||
0x7FFF_FFFF_FFFF_FFFF, // limb 3 (most significant, 2^63 - 1 accounting for -19)
|
||||
]
|
||||
|
||||
/// Load a 256-bit little-endian byte array into 4 UInt64 limbs
|
||||
static func load(_ bytes: Data) -> [UInt64] {
|
||||
precondition(bytes.count == 32)
|
||||
var limbs = [UInt64](repeating: 0, count: 4)
|
||||
for i in 0..<4 {
|
||||
var val: UInt64 = 0
|
||||
for j in 0..<8 {
|
||||
val |= UInt64(bytes[i * 8 + j]) << (j * 8)
|
||||
}
|
||||
limbs[i] = val
|
||||
}
|
||||
return limbs
|
||||
}
|
||||
|
||||
/// Store 4 UInt64 limbs as 32 little-endian bytes
|
||||
static func store(_ limbs: [UInt64]) -> Data {
|
||||
var bytes = Data(count: 32)
|
||||
for i in 0..<4 {
|
||||
for j in 0..<8 {
|
||||
bytes[i * 8 + j] = UInt8((limbs[i] >> (j * 8)) & 0xFF)
|
||||
}
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
/// a + b mod p
|
||||
static func add(_ a: [UInt64], _ b: [UInt64]) -> [UInt64] {
|
||||
var result = [UInt64](repeating: 0, count: 4)
|
||||
var carry: UInt64 = 0
|
||||
for i in 0..<4 {
|
||||
let (sum1, c1) = a[i].addingReportingOverflow(b[i])
|
||||
let (sum2, c2) = sum1.addingReportingOverflow(carry)
|
||||
result[i] = sum2
|
||||
carry = (c1 ? 1 : 0) + (c2 ? 1 : 0)
|
||||
}
|
||||
// Reduce mod p
|
||||
return reduceOnce(result, carry: carry)
|
||||
}
|
||||
|
||||
/// a - b mod p
|
||||
static func sub(_ a: [UInt64], _ b: [UInt64]) -> [UInt64] {
|
||||
var result = [UInt64](repeating: 0, count: 4)
|
||||
var borrow: UInt64 = 0
|
||||
for i in 0..<4 {
|
||||
let (diff1, b1) = a[i].subtractingReportingOverflow(b[i])
|
||||
let (diff2, b2) = diff1.subtractingReportingOverflow(borrow)
|
||||
result[i] = diff2
|
||||
borrow = (b1 ? 1 : 0) + (b2 ? 1 : 0)
|
||||
}
|
||||
if borrow > 0 {
|
||||
// Add p back
|
||||
var c: UInt64 = 0
|
||||
for i in 0..<4 {
|
||||
let (s1, c1) = result[i].addingReportingOverflow(p[i])
|
||||
let (s2, c2) = s1.addingReportingOverflow(c)
|
||||
result[i] = s2
|
||||
c = (c1 ? 1 : 0) + (c2 ? 1 : 0)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Multiply two 256-bit numbers mod p using schoolbook multiplication
|
||||
static func mul(_ a: [UInt64], _ b: [UInt64]) -> [UInt64] {
|
||||
// Full 512-bit product in 8 limbs
|
||||
var product = [UInt64](repeating: 0, count: 8)
|
||||
|
||||
for i in 0..<4 {
|
||||
var carry: UInt64 = 0
|
||||
for j in 0..<4 {
|
||||
let (hi, lo) = a[i].multipliedFullWidth(by: b[j])
|
||||
let (sum1, c1) = product[i + j].addingReportingOverflow(lo)
|
||||
let (sum2, c2) = sum1.addingReportingOverflow(carry)
|
||||
product[i + j] = sum2
|
||||
carry = hi + (c1 ? 1 : 0) + (c2 ? 1 : 0)
|
||||
}
|
||||
product[i + 4] = carry
|
||||
}
|
||||
|
||||
// Reduce mod p using Barrett-like reduction
|
||||
// Since p = 2^255 - 19, for a 512-bit number we can use:
|
||||
// x mod p = (x_low + x_high * 2^256) mod p
|
||||
// Since 2^255 ≡ 19 (mod p), 2^256 ≡ 38 (mod p)
|
||||
return reduceFull(product)
|
||||
}
|
||||
|
||||
/// Reduce 512-bit product mod p using 2^256 ≡ 38 (mod p)
|
||||
private static func reduceFull(_ product: [UInt64]) -> [UInt64] {
|
||||
// Split: low = product[0..3], high = product[4..7]
|
||||
// result = low + high * 38
|
||||
var result = [UInt64](repeating: 0, count: 5)
|
||||
|
||||
// Start with low part
|
||||
for i in 0..<4 {
|
||||
result[i] = product[i]
|
||||
}
|
||||
|
||||
// Add high * 38
|
||||
var carry: UInt64 = 0
|
||||
for i in 0..<4 {
|
||||
let (hi, lo) = product[i + 4].multipliedFullWidth(by: 38)
|
||||
let (sum1, c1) = result[i].addingReportingOverflow(lo)
|
||||
let (sum2, c2) = sum1.addingReportingOverflow(carry)
|
||||
result[i] = sum2
|
||||
carry = hi + (c1 ? 1 : 0) + (c2 ? 1 : 0)
|
||||
}
|
||||
result[4] = carry
|
||||
|
||||
// The result might still be >= p, so reduce once more
|
||||
// result[4] * 2^256 ≡ result[4] * 38 (mod p)
|
||||
var extra: UInt64 = result[4]
|
||||
result[4] = 0
|
||||
if extra > 0 {
|
||||
let (hi, lo) = extra.multipliedFullWidth(by: 38)
|
||||
let (sum1, c1) = result[0].addingReportingOverflow(lo)
|
||||
result[0] = sum1
|
||||
var c = hi + (c1 ? 1 : 0)
|
||||
for i in 1..<4 {
|
||||
let (s, cf) = result[i].addingReportingOverflow(c)
|
||||
result[i] = s
|
||||
c = cf ? 1 : 0
|
||||
}
|
||||
// One more round if carry
|
||||
if c > 0 {
|
||||
let (s, _) = result[0].addingReportingOverflow(c * 38)
|
||||
result[0] = s
|
||||
}
|
||||
}
|
||||
|
||||
var out = Array(result[0..<4])
|
||||
// Final reduction: if >= p, subtract p
|
||||
out = reduceOnce(out, carry: 0)
|
||||
return out
|
||||
}
|
||||
|
||||
/// If the number >= p, subtract p
|
||||
private static func reduceOnce(_ val: [UInt64], carry: UInt64) -> [UInt64] {
|
||||
if carry > 0 || isGreaterOrEqual(val, p) {
|
||||
var result = [UInt64](repeating: 0, count: 4)
|
||||
var borrow: UInt64 = 0
|
||||
for i in 0..<4 {
|
||||
let (diff1, b1) = val[i].subtractingReportingOverflow(p[i])
|
||||
let (diff2, b2) = diff1.subtractingReportingOverflow(borrow)
|
||||
result[i] = diff2
|
||||
borrow = (b1 ? 1 : 0) + (b2 ? 1 : 0)
|
||||
}
|
||||
// If borrow after subtracting p, the original was fine (shouldn't happen with carry)
|
||||
if borrow > 0 && carry == 0 {
|
||||
return val
|
||||
}
|
||||
return result
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
/// Compare a >= b
|
||||
private static func isGreaterOrEqual(_ a: [UInt64], _ b: [UInt64]) -> Bool {
|
||||
for i in stride(from: 3, through: 0, by: -1) {
|
||||
if a[i] > b[i] { return true }
|
||||
if a[i] < b[i] { return false }
|
||||
}
|
||||
return true // equal
|
||||
}
|
||||
|
||||
/// Modular inverse using Fermat's little theorem: a^(-1) = a^(p-2) mod p
|
||||
static func inverse(_ a: [UInt64]) -> [UInt64] {
|
||||
// p - 2 = 2^255 - 21
|
||||
let pMinus2 = sub(p, [2, 0, 0, 0])
|
||||
return power(a, pMinus2)
|
||||
}
|
||||
|
||||
/// Modular exponentiation using square-and-multiply
|
||||
static func power(_ base: [UInt64], _ exp: [UInt64]) -> [UInt64] {
|
||||
var result: [UInt64] = [1, 0, 0, 0] // 1
|
||||
var b = base
|
||||
|
||||
for i in 0..<4 {
|
||||
var limb = exp[i]
|
||||
let bits = (i == 3) ? 63 : 64 // top limb has 63 bits for p-2
|
||||
for _ in 0..<bits {
|
||||
if limb & 1 == 1 {
|
||||
result = mul(result, b)
|
||||
}
|
||||
b = mul(b, b)
|
||||
limb >>= 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Ed25519 → X25519 Public Key Conversion
|
||||
|
||||
/// Convert Ed25519 public key (32 bytes) to X25519 public key (32 bytes).
|
||||
/// Formula: u = (1 + y) * inverse(1 - y) mod p
|
||||
static func ed25519PublicToX25519(_ ed25519Pub: Data) -> Data {
|
||||
precondition(ed25519Pub.count == 32)
|
||||
|
||||
// Ed25519 public key is the y-coordinate with sign bit in the top bit of byte 31
|
||||
var keyBytes = ed25519Pub
|
||||
// Clear the sign bit
|
||||
keyBytes[31] &= 0x7F
|
||||
|
||||
let y = load(keyBytes)
|
||||
let one: [UInt64] = [1, 0, 0, 0]
|
||||
|
||||
let onePlusY = add(one, y)
|
||||
let oneMinusY = sub(one, y)
|
||||
let inv = inverse(oneMinusY)
|
||||
let u = mul(onePlusY, inv)
|
||||
|
||||
return store(u)
|
||||
}
|
||||
}
|
||||
106
ios_client/EncryptedChat/Crypto/KeyEncryption.swift
Normal file
106
ios_client/EncryptedChat/Crypto/KeyEncryption.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import CommonCrypto
|
||||
|
||||
/// ECP1 key encryption format: PBKDF2-HMAC-SHA256 (600k iterations) + AES-256-GCM
|
||||
/// Wire format: magic(4) + salt(16) + nonce(12) + ciphertext_with_tag(N+16)
|
||||
enum KeyEncryption {
|
||||
|
||||
/// Encrypt raw key bytes with password using ECP1 format
|
||||
static func encrypt(_ rawBytes: Data, password: Data) throws -> Data {
|
||||
let salt = Data.randomBytes(16)
|
||||
let derivedKey = try pbkdf2(password: password, salt: salt)
|
||||
|
||||
let nonce = Data.randomBytes(12)
|
||||
let symmetricKey = SymmetricKey(data: derivedKey)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||
|
||||
// AAD = ECP1 magic bytes (matching Python)
|
||||
let sealedBox = try AES.GCM.seal(
|
||||
rawBytes,
|
||||
using: symmetricKey,
|
||||
nonce: gcmNonce,
|
||||
authenticating: Constants.ecp1Magic
|
||||
)
|
||||
|
||||
// ciphertext + tag concatenated (matches Python's AESGCM.encrypt output)
|
||||
var result = Data()
|
||||
result.append(Constants.ecp1Magic) // 4 bytes
|
||||
result.append(salt) // 16 bytes
|
||||
result.append(nonce) // 12 bytes
|
||||
result.append(sealedBox.ciphertext) // N bytes
|
||||
result.append(sealedBox.tag) // 16 bytes
|
||||
return result
|
||||
}
|
||||
|
||||
/// Decrypt ECP1-encrypted key bytes with password
|
||||
static func decrypt(_ data: Data, password: Data) throws -> Data {
|
||||
guard data.count >= 48 else { // 4 + 16 + 12 + 16 minimum
|
||||
throw CryptoError.invalidECP1Format
|
||||
}
|
||||
guard data.prefix(4) == Constants.ecp1Magic else {
|
||||
throw CryptoError.invalidECP1Format
|
||||
}
|
||||
|
||||
let salt = data[4..<20]
|
||||
let nonce = data[20..<32]
|
||||
let ctWithTag = data[32...]
|
||||
|
||||
guard ctWithTag.count >= 16 else {
|
||||
throw CryptoError.invalidECP1Format
|
||||
}
|
||||
|
||||
let derivedKey = try pbkdf2(password: password, salt: Data(salt))
|
||||
let symmetricKey = SymmetricKey(data: derivedKey)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||
|
||||
// Split ciphertext and tag
|
||||
let ct = ctWithTag.prefix(ctWithTag.count - 16)
|
||||
let tag = ctWithTag.suffix(16)
|
||||
|
||||
let sealedBox = try AES.GCM.SealedBox(
|
||||
nonce: gcmNonce,
|
||||
ciphertext: ct,
|
||||
tag: tag
|
||||
)
|
||||
|
||||
do {
|
||||
return try AES.GCM.open(sealedBox, using: symmetricKey, authenticating: Constants.ecp1Magic)
|
||||
} catch {
|
||||
throw CryptoError.decryptionFailed("ECP1 decryption failed - wrong password?")
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if data starts with ECP1 magic
|
||||
static func isECP1Format(_ data: Data) -> Bool {
|
||||
data.count >= 4 && data.prefix(4) == Constants.ecp1Magic
|
||||
}
|
||||
|
||||
// MARK: - PBKDF2
|
||||
|
||||
/// Derive 32-byte key using PBKDF2-HMAC-SHA256 with 600k iterations
|
||||
static func pbkdf2(password: Data, salt: Data) throws -> Data {
|
||||
var derivedKey = Data(count: 32)
|
||||
let status = derivedKey.withUnsafeMutableBytes { derivedKeyPtr in
|
||||
password.withUnsafeBytes { passwordPtr in
|
||||
salt.withUnsafeBytes { saltPtr in
|
||||
CCKeyDerivationPBKDF(
|
||||
CCPBKDFAlgorithm(kCCPBKDF2),
|
||||
passwordPtr.baseAddress?.assumingMemoryBound(to: Int8.self),
|
||||
password.count,
|
||||
saltPtr.baseAddress?.assumingMemoryBound(to: UInt8.self),
|
||||
salt.count,
|
||||
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
|
||||
Constants.pbkdf2Iterations,
|
||||
derivedKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self),
|
||||
32
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
guard status == kCCSuccess else {
|
||||
throw CryptoError.pbkdf2Failed
|
||||
}
|
||||
return derivedKey
|
||||
}
|
||||
}
|
||||
309
ios_client/EncryptedChat/Crypto/RSACrypto.swift
Normal file
309
ios_client/EncryptedChat/Crypto/RSACrypto.swift
Normal file
@@ -0,0 +1,309 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// RSA-4096 operations — used for login challenge-response ONLY
|
||||
enum RSACrypto {
|
||||
|
||||
// MARK: - Key Generation
|
||||
|
||||
/// Generate RSA-4096 keypair
|
||||
static func generateKeypair() throws -> (privateKey: SecKey, publicKey: SecKey) {
|
||||
let attributes: [String: Any] = [
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
||||
kSecAttrKeySizeInBits as String: 4096,
|
||||
]
|
||||
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
|
||||
throw CryptoError.rsaKeyGenerationFailed
|
||||
}
|
||||
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
|
||||
throw CryptoError.rsaKeyGenerationFailed
|
||||
}
|
||||
return (privateKey, publicKey)
|
||||
}
|
||||
|
||||
// MARK: - Serialization
|
||||
|
||||
/// Serialize RSA private key. With password: DER → ECP1. Without: PEM PKCS#8.
|
||||
static func serializePrivateKey(_ key: SecKey, password: Data? = nil) throws -> Data {
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let derData = SecKeyCopyExternalRepresentation(key, &error) as Data? else {
|
||||
throw CryptoError.rsaOperationFailed("Failed to export private key")
|
||||
}
|
||||
|
||||
// SecKey exports in PKCS#1 format on iOS — wrap in PKCS#8 for Python compat
|
||||
let pkcs8 = wrapRSAPrivateKeyPKCS8(derData)
|
||||
|
||||
if let password = password {
|
||||
return try KeyEncryption.encrypt(pkcs8, password: password)
|
||||
}
|
||||
|
||||
// PEM encode for Python compatibility
|
||||
return pemEncode(pkcs8, label: "PRIVATE KEY")
|
||||
}
|
||||
|
||||
/// Serialize RSA public key as PEM SubjectPublicKeyInfo (Python-compatible)
|
||||
static func serializePublicKey(_ key: SecKey) throws -> Data {
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let derData = SecKeyCopyExternalRepresentation(key, &error) as Data? else {
|
||||
throw CryptoError.rsaOperationFailed("Failed to export public key")
|
||||
}
|
||||
|
||||
// SecKey exports PKCS#1 on iOS — wrap in SubjectPublicKeyInfo
|
||||
let spki = wrapRSAPublicKeySPKI(derData)
|
||||
return pemEncode(spki, label: "PUBLIC KEY")
|
||||
}
|
||||
|
||||
/// Load RSA private key. Auto-detects ECP1 vs PEM format.
|
||||
static func loadPrivateKey(_ data: Data, password: Data? = nil) throws -> SecKey {
|
||||
let derData: Data
|
||||
|
||||
if KeyEncryption.isECP1Format(data) {
|
||||
guard let pwd = password else {
|
||||
throw CryptoError.invalidKeyData("ECP1 key requires password")
|
||||
}
|
||||
let raw = try KeyEncryption.decrypt(data, password: pwd)
|
||||
derData = unwrapPKCS8ToRSAPrivateKey(raw)
|
||||
} else {
|
||||
// PEM format
|
||||
let pem = String(data: data, encoding: .utf8) ?? ""
|
||||
derData = try pemDecode(pem, label: "PRIVATE KEY")
|
||||
.flatMap { unwrapPKCS8ToRSAPrivateKey($0) }
|
||||
?? pemDecode(pem, label: "RSA PRIVATE KEY")
|
||||
?? { throw CryptoError.invalidKeyData("Cannot parse RSA private key PEM") }()
|
||||
}
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
||||
kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
|
||||
]
|
||||
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let key = SecKeyCreateWithData(derData as CFData, attributes as CFDictionary, &error) else {
|
||||
throw CryptoError.invalidKeyData("Failed to create RSA private key from DER")
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
/// Load RSA public key from PEM
|
||||
static func loadPublicKey(_ pemData: Data) throws -> SecKey {
|
||||
let pem = String(data: pemData, encoding: .utf8) ?? ""
|
||||
|
||||
// Try SubjectPublicKeyInfo (PUBLIC KEY), unwrap to PKCS#1
|
||||
let derData: Data
|
||||
if let spki = pemDecode(pem, label: "PUBLIC KEY") {
|
||||
derData = unwrapSPKIToRSAPublicKey(spki)
|
||||
} else if let pkcs1 = pemDecode(pem, label: "RSA PUBLIC KEY") {
|
||||
derData = pkcs1
|
||||
} else {
|
||||
throw CryptoError.invalidKeyData("Cannot parse RSA public key PEM")
|
||||
}
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
||||
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
|
||||
]
|
||||
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let key = SecKeyCreateWithData(derData as CFData, attributes as CFDictionary, &error) else {
|
||||
throw CryptoError.invalidKeyData("Failed to create RSA public key from DER")
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// MARK: - Sign / Verify
|
||||
|
||||
/// Sign data with RSA-PSS SHA-256.
|
||||
/// Note: iOS uses salt_length = hash_length (32). Server must use PSS.AUTO to verify.
|
||||
static func sign(_ privateKey: SecKey, data: Data) throws -> Data {
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let signature = SecKeyCreateSignature(
|
||||
privateKey,
|
||||
.rsaSignatureMessagePSSSHA256,
|
||||
data as CFData,
|
||||
&error
|
||||
) as Data? else {
|
||||
throw CryptoError.rsaOperationFailed("RSA signing failed")
|
||||
}
|
||||
return signature
|
||||
}
|
||||
|
||||
/// Verify RSA-PSS SHA-256 signature
|
||||
static func verify(_ publicKey: SecKey, signature: Data, data: Data) -> Bool {
|
||||
SecKeyVerifySignature(
|
||||
publicKey,
|
||||
.rsaSignatureMessagePSSSHA256,
|
||||
data as CFData,
|
||||
signature as CFData,
|
||||
nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - PEM Helpers
|
||||
|
||||
private static func pemEncode(_ der: Data, label: String) -> Data {
|
||||
let base64 = der.base64EncodedString(options: .lineLength64Characters)
|
||||
let pem = "-----BEGIN \(label)-----\n\(base64)\n-----END \(label)-----\n"
|
||||
return Data(pem.utf8)
|
||||
}
|
||||
|
||||
private static func pemDecode(_ pem: String, label: String) -> Data? {
|
||||
let beginMarker = "-----BEGIN \(label)-----"
|
||||
let endMarker = "-----END \(label)-----"
|
||||
|
||||
guard let beginRange = pem.range(of: beginMarker),
|
||||
let endRange = pem.range(of: endMarker) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let base64String = pem[beginRange.upperBound..<endRange.lowerBound]
|
||||
.replacingOccurrences(of: "\n", with: "")
|
||||
.replacingOccurrences(of: "\r", with: "")
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
|
||||
return Data(base64Encoded: base64String)
|
||||
}
|
||||
|
||||
// MARK: - ASN.1 PKCS#8 / SPKI Wrappers
|
||||
|
||||
// SecKey on iOS exports RSA keys in PKCS#1 format, but Python expects PKCS#8 / SPKI.
|
||||
// These functions add/remove the ASN.1 wrapping.
|
||||
|
||||
// RSA OID: 1.2.840.113549.1.1.1
|
||||
private static let rsaOID: [UInt8] = [0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01]
|
||||
private static let nullParam: [UInt8] = [0x05, 0x00]
|
||||
|
||||
/// Wrap PKCS#1 RSA private key in PKCS#8 PrivateKeyInfo envelope
|
||||
private static func wrapRSAPrivateKeyPKCS8(_ pkcs1: Data) -> Data {
|
||||
// PrivateKeyInfo ::= SEQUENCE {
|
||||
// version INTEGER (0),
|
||||
// algorithm AlgorithmIdentifier,
|
||||
// privateKey OCTET STRING (containing PKCS#1 key)
|
||||
// }
|
||||
let version = Data([0x02, 0x01, 0x00]) // INTEGER 0
|
||||
let algorithmSeq = asn1Sequence(Data(rsaOID) + Data(nullParam))
|
||||
let privateKeyOctet = asn1OctetString(pkcs1)
|
||||
return asn1Sequence(version + algorithmSeq + privateKeyOctet)
|
||||
}
|
||||
|
||||
/// Unwrap PKCS#8 to get PKCS#1 RSA private key
|
||||
private static func unwrapPKCS8ToRSAPrivateKey(_ pkcs8: Data) -> Data {
|
||||
// Parse SEQUENCE, skip version + algorithm, extract OCTET STRING
|
||||
guard pkcs8.count > 2 else { return pkcs8 }
|
||||
|
||||
var offset = 0
|
||||
// Outer SEQUENCE
|
||||
guard pkcs8[offset] == 0x30 else { return pkcs8 }
|
||||
offset += 1
|
||||
offset = skipASN1Length(pkcs8, offset: offset)
|
||||
|
||||
// Version INTEGER
|
||||
guard offset < pkcs8.count, pkcs8[offset] == 0x02 else { return pkcs8 }
|
||||
offset += 1
|
||||
let versionLen = readASN1Length(pkcs8, offset: &offset)
|
||||
offset += versionLen
|
||||
|
||||
// Algorithm SEQUENCE
|
||||
guard offset < pkcs8.count, pkcs8[offset] == 0x30 else { return pkcs8 }
|
||||
offset += 1
|
||||
let algoLen = readASN1Length(pkcs8, offset: &offset)
|
||||
offset += algoLen
|
||||
|
||||
// Private key OCTET STRING
|
||||
guard offset < pkcs8.count, pkcs8[offset] == 0x04 else { return pkcs8 }
|
||||
offset += 1
|
||||
let keyLen = readASN1Length(pkcs8, offset: &offset)
|
||||
guard offset + keyLen <= pkcs8.count else { return pkcs8 }
|
||||
return Data(pkcs8[offset..<(offset + keyLen)])
|
||||
}
|
||||
|
||||
/// Wrap PKCS#1 RSA public key in SubjectPublicKeyInfo
|
||||
private static func wrapRSAPublicKeySPKI(_ pkcs1: Data) -> Data {
|
||||
// SubjectPublicKeyInfo ::= SEQUENCE {
|
||||
// algorithm AlgorithmIdentifier,
|
||||
// subjectPublicKey BIT STRING (containing PKCS#1 key)
|
||||
// }
|
||||
let algorithmSeq = asn1Sequence(Data(rsaOID) + Data(nullParam))
|
||||
let bitString = asn1BitString(pkcs1)
|
||||
return asn1Sequence(algorithmSeq + bitString)
|
||||
}
|
||||
|
||||
/// Unwrap SubjectPublicKeyInfo to get PKCS#1 RSA public key
|
||||
private static func unwrapSPKIToRSAPublicKey(_ spki: Data) -> Data {
|
||||
guard spki.count > 2 else { return spki }
|
||||
|
||||
var offset = 0
|
||||
// Outer SEQUENCE
|
||||
guard spki[offset] == 0x30 else { return spki }
|
||||
offset += 1
|
||||
offset = skipASN1Length(spki, offset: offset)
|
||||
|
||||
// Algorithm SEQUENCE
|
||||
guard offset < spki.count, spki[offset] == 0x30 else { return spki }
|
||||
offset += 1
|
||||
let algoLen = readASN1Length(spki, offset: &offset)
|
||||
offset += algoLen
|
||||
|
||||
// BIT STRING
|
||||
guard offset < spki.count, spki[offset] == 0x03 else { return spki }
|
||||
offset += 1
|
||||
let bitLen = readASN1Length(spki, offset: &offset)
|
||||
// Skip the unused bits byte
|
||||
guard offset < spki.count, spki[offset] == 0x00 else { return spki }
|
||||
offset += 1
|
||||
let keyLen = bitLen - 1
|
||||
guard offset + keyLen <= spki.count else { return spki }
|
||||
return Data(spki[offset..<(offset + keyLen)])
|
||||
}
|
||||
|
||||
// MARK: - ASN.1 Primitives
|
||||
|
||||
private static func asn1Length(_ length: Int) -> Data {
|
||||
if length < 0x80 {
|
||||
return Data([UInt8(length)])
|
||||
} else if length <= 0xFF {
|
||||
return Data([0x81, UInt8(length)])
|
||||
} else if length <= 0xFFFF {
|
||||
return Data([0x82, UInt8(length >> 8), UInt8(length & 0xFF)])
|
||||
} else {
|
||||
return Data([0x83, UInt8(length >> 16), UInt8((length >> 8) & 0xFF), UInt8(length & 0xFF)])
|
||||
}
|
||||
}
|
||||
|
||||
private static func asn1Sequence(_ content: Data) -> Data {
|
||||
Data([0x30]) + asn1Length(content.count) + content
|
||||
}
|
||||
|
||||
private static func asn1OctetString(_ content: Data) -> Data {
|
||||
Data([0x04]) + asn1Length(content.count) + content
|
||||
}
|
||||
|
||||
private static func asn1BitString(_ content: Data) -> Data {
|
||||
// BIT STRING: tag + length + unused_bits(0) + content
|
||||
Data([0x03]) + asn1Length(content.count + 1) + Data([0x00]) + content
|
||||
}
|
||||
|
||||
private static func readASN1Length(_ data: Data, offset: inout Int) -> Int {
|
||||
guard offset < data.count else { return 0 }
|
||||
let first = data[offset]
|
||||
offset += 1
|
||||
if first < 0x80 {
|
||||
return Int(first)
|
||||
}
|
||||
let numBytes = Int(first & 0x7F)
|
||||
var length = 0
|
||||
for _ in 0..<numBytes {
|
||||
guard offset < data.count else { return length }
|
||||
length = (length << 8) | Int(data[offset])
|
||||
offset += 1
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
private static func skipASN1Length(_ data: Data, offset: Int) -> Int {
|
||||
var off = offset
|
||||
_ = readASN1Length(data, offset: &off)
|
||||
return off
|
||||
}
|
||||
}
|
||||
175
ios_client/EncryptedChat/Crypto/SenderKeyState.swift
Normal file
175
ios_client/EncryptedChat/Crypto/SenderKeyState.swift
Normal file
@@ -0,0 +1,175 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Sender key chain for group messaging.
|
||||
/// Each sender in a group has their own chain. Others receive the initial key via pairwise ratchet.
|
||||
/// Matches Python: SenderKeyState class in crypto_utils.py
|
||||
class SenderKeyState {
|
||||
|
||||
let senderKey: Data
|
||||
let chainId: Data
|
||||
private(set) var chainKey: Data
|
||||
private(set) var n: Int
|
||||
private var knownKeys: [Int: Data]
|
||||
|
||||
/// Initialize with optional sender key (generates random 32B if nil).
|
||||
/// Matches Python: SenderKeyState.__init__(sender_key=None)
|
||||
init(senderKey: Data? = nil) {
|
||||
let key = senderKey ?? Data.randomBytes(32)
|
||||
self.senderKey = key
|
||||
self.chainId = Data(SHA256.hash(data: key))
|
||||
self.chainKey = CryptoUtils.hkdfDerive(
|
||||
inputKey: key,
|
||||
salt: Data(repeating: 0x00, count: 32),
|
||||
info: Data(Constants.senderKeyChainInfo.utf8),
|
||||
length: 32
|
||||
)
|
||||
self.n = 0
|
||||
self.knownKeys = [:]
|
||||
}
|
||||
|
||||
/// Private init for import
|
||||
private init(senderKey: Data, chainId: Data, chainKey: Data, n: Int, knownKeys: [Int: Data]) {
|
||||
self.senderKey = senderKey
|
||||
self.chainId = chainId
|
||||
self.chainKey = chainKey
|
||||
self.n = n
|
||||
self.knownKeys = knownKeys
|
||||
}
|
||||
|
||||
// MARK: - Encrypt
|
||||
|
||||
/// Encrypt with current chain key.
|
||||
/// Returns (chainId hex, n, ciphertext with tag, nonce).
|
||||
/// Matches Python: SenderKeyState.encrypt(plaintext)
|
||||
func encrypt(_ plaintext: Data) throws -> (chainIdHex: String, n: Int, ciphertext: Data, nonce: Data) {
|
||||
let (newCK, messageKey) = CryptoUtils.kdfCK(chainKey: chainKey)
|
||||
chainKey = newCK
|
||||
|
||||
let nonce = Data.randomBytes(12)
|
||||
// AAD = chainId + bigEndian(UInt32(n))
|
||||
let aad = chainId + UInt32(n).bigEndianData
|
||||
let ctWithTag = try CryptoUtils.aesGcmEncrypt(plaintext, key: messageKey, nonce: nonce, aad: aad)
|
||||
|
||||
let result = (chainIdHex: chainId.hexString, n: n, ciphertext: ctWithTag, nonce: nonce)
|
||||
n += 1
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Decrypt
|
||||
|
||||
/// Decrypt a group message. Fast-forwards the chain if needed.
|
||||
/// State is snapshotted before modification and restored on failure.
|
||||
/// Matches Python: SenderKeyState.decrypt(chain_id_hex, n, ciphertext, nonce)
|
||||
func decrypt(chainIdHex: String, n: Int, ciphertext: Data, nonce: Data) throws -> Data {
|
||||
guard let expectedChainId = Data(hexString: chainIdHex) else {
|
||||
throw CryptoError.senderKeyError("Invalid chain ID hex")
|
||||
}
|
||||
guard expectedChainId == chainId else {
|
||||
throw CryptoError.senderKeyError("Chain ID mismatch")
|
||||
}
|
||||
|
||||
if n - self.n > Constants.maxSenderKeySkip {
|
||||
throw CryptoError.senderKeyError("Sender key skip too large (\(n - self.n) > \(Constants.maxSenderKeySkip))")
|
||||
}
|
||||
|
||||
// Snapshot before fast-forward
|
||||
let snapChainKey = chainKey
|
||||
let snapN = self.n
|
||||
let snapKnown = knownKeys
|
||||
|
||||
do {
|
||||
// Fast-forward the chain to reach message n
|
||||
while self.n <= n {
|
||||
let (newCK, mk) = CryptoUtils.kdfCK(chainKey: chainKey)
|
||||
chainKey = newCK
|
||||
knownKeys[self.n] = mk
|
||||
self.n += 1
|
||||
}
|
||||
|
||||
guard let mk = knownKeys.removeValue(forKey: n) else {
|
||||
throw CryptoError.senderKeyError("Message key for n=\(n) not available")
|
||||
}
|
||||
|
||||
let aad = chainId + UInt32(n).bigEndianData
|
||||
return try CryptoUtils.aesGcmDecrypt(ciphertext, key: mk, nonce: nonce, aad: aad)
|
||||
} catch {
|
||||
// Restore state on failure
|
||||
chainKey = snapChainKey
|
||||
self.n = snapN
|
||||
knownKeys = snapKnown
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Key Export/Import
|
||||
|
||||
/// Export sender key for distribution to group members.
|
||||
/// Matches Python: SenderKeyState.export_key()
|
||||
func exportKey() -> Data {
|
||||
let dict: [String: Any] = ["sender_key": senderKey.hexString]
|
||||
return try! JSONSerialization.data(withJSONObject: dict)
|
||||
}
|
||||
|
||||
/// Initialize a receiving SenderKeyState from an exported key.
|
||||
/// Matches Python: SenderKeyState.from_key(exported_key)
|
||||
static func fromKey(_ exportedKey: Data) throws -> SenderKeyState {
|
||||
guard let dict = try JSONSerialization.jsonObject(with: exportedKey) as? [String: Any],
|
||||
let senderKeyHex = dict["sender_key"] as? String,
|
||||
let senderKey = Data(hexString: senderKeyHex) else {
|
||||
throw CryptoError.stateImportFailed("Invalid sender key export")
|
||||
}
|
||||
return SenderKeyState(senderKey: senderKey)
|
||||
}
|
||||
|
||||
// MARK: - Full State Export/Import
|
||||
|
||||
/// Serialize full state for persistent storage.
|
||||
/// Matches Python: SenderKeyState.export_state()
|
||||
func exportState() -> Data {
|
||||
var knownKeysDict: [String: String] = [:]
|
||||
for (k, v) in knownKeys {
|
||||
knownKeysDict[String(k)] = v.hexString
|
||||
}
|
||||
let state: [String: Any] = [
|
||||
"sender_key": senderKey.hexString,
|
||||
"chain_id": chainId.hexString,
|
||||
"chain_key": chainKey.hexString,
|
||||
"n": n,
|
||||
"known_keys": knownKeysDict,
|
||||
]
|
||||
return try! JSONSerialization.data(withJSONObject: state)
|
||||
}
|
||||
|
||||
/// Deserialize full state.
|
||||
/// Matches Python: SenderKeyState.import_state(data)
|
||||
static func importState(_ data: Data) throws -> SenderKeyState {
|
||||
guard let state = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let senderKeyHex = state["sender_key"] as? String,
|
||||
let senderKey = Data(hexString: senderKeyHex),
|
||||
let chainIdHex = state["chain_id"] as? String,
|
||||
let chainId = Data(hexString: chainIdHex),
|
||||
let chainKeyHex = state["chain_key"] as? String,
|
||||
let chainKey = Data(hexString: chainKeyHex),
|
||||
let n = state["n"] as? Int else {
|
||||
throw CryptoError.stateImportFailed("Invalid sender key state")
|
||||
}
|
||||
|
||||
var knownKeys: [Int: Data] = [:]
|
||||
if let knownKeysDict = state["known_keys"] as? [String: String] {
|
||||
for (k, v) in knownKeysDict {
|
||||
if let idx = Int(k), let data = Data(hexString: v) {
|
||||
knownKeys[idx] = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SenderKeyState(
|
||||
senderKey: senderKey,
|
||||
chainId: chainId,
|
||||
chainKey: chainKey,
|
||||
n: n,
|
||||
knownKeys: knownKeys
|
||||
)
|
||||
}
|
||||
}
|
||||
77
ios_client/EncryptedChat/Crypto/X25519Crypto.swift
Normal file
77
ios_client/EncryptedChat/Crypto/X25519Crypto.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// X25519 Diffie-Hellman key agreement
|
||||
enum X25519Crypto {
|
||||
|
||||
// MARK: - Key Generation
|
||||
|
||||
/// Generate X25519 keypair
|
||||
static func generateKeypair() -> (privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) {
|
||||
let privateKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
return (privateKey, privateKey.publicKey)
|
||||
}
|
||||
|
||||
// MARK: - Serialization
|
||||
|
||||
/// Serialize X25519 private key to 32 raw bytes
|
||||
static func serializePrivate(_ key: Curve25519.KeyAgreement.PrivateKey) -> Data {
|
||||
key.rawData // 32 bytes
|
||||
}
|
||||
|
||||
/// Serialize X25519 public key to 32 raw bytes
|
||||
static func serializePublic(_ key: Curve25519.KeyAgreement.PublicKey) -> Data {
|
||||
key.rawData // 32 bytes
|
||||
}
|
||||
|
||||
/// Load X25519 private key from 32 raw bytes
|
||||
static func loadPrivate(_ data: Data) throws -> Curve25519.KeyAgreement.PrivateKey {
|
||||
guard data.count == 32 else {
|
||||
throw CryptoError.invalidKeyData("X25519 private key must be 32 bytes")
|
||||
}
|
||||
return try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: data)
|
||||
}
|
||||
|
||||
/// Load X25519 public key from 32 raw bytes
|
||||
static func loadPublic(_ data: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
||||
guard data.count == 32 else {
|
||||
throw CryptoError.invalidKeyData("X25519 public key must be 32 bytes")
|
||||
}
|
||||
return try Curve25519.KeyAgreement.PublicKey(rawRepresentation: data)
|
||||
}
|
||||
|
||||
// MARK: - Diffie-Hellman
|
||||
|
||||
/// Perform X25519 DH key agreement. Returns 32-byte shared secret.
|
||||
/// Matches Python: x25519_dh(private_key, public_key)
|
||||
static func dh(_ privateKey: Curve25519.KeyAgreement.PrivateKey, _ publicKey: Curve25519.KeyAgreement.PublicKey) throws -> Data {
|
||||
let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: publicKey)
|
||||
// Extract raw bytes from SharedSecret
|
||||
return sharedSecret.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
|
||||
// MARK: - Ed25519 → X25519 Key Conversion
|
||||
|
||||
/// Convert Ed25519 private key to X25519 private key.
|
||||
/// SHA-512(seed) → take first 32 bytes → clamp per RFC 7748
|
||||
/// Matches Python: ed25519_private_to_x25519(ed_private)
|
||||
static func fromEd25519Private(_ edPrivate: Curve25519.Signing.PrivateKey) throws -> Curve25519.KeyAgreement.PrivateKey {
|
||||
let raw = edPrivate.rawData // 32 bytes seed
|
||||
// SHA-512 of the seed
|
||||
let hash = SHA512.hash(data: raw)
|
||||
var clamped = Data(hash.prefix(32))
|
||||
// Clamp per RFC 7748
|
||||
clamped[0] &= 248
|
||||
clamped[31] &= 127
|
||||
clamped[31] |= 64
|
||||
return try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: clamped)
|
||||
}
|
||||
|
||||
/// Convert Ed25519 public key to X25519 public key.
|
||||
/// Uses Montgomery birational map: u = (1+y)/(1-y) mod p
|
||||
/// Matches Python: ed25519_public_to_x25519(ed_public)
|
||||
static func fromEd25519Public(_ edPublic: Curve25519.Signing.PublicKey) throws -> Curve25519.KeyAgreement.PublicKey {
|
||||
let x25519Bytes = FieldArithmetic.ed25519PublicToX25519(edPublic.rawData)
|
||||
return try Curve25519.KeyAgreement.PublicKey(rawRepresentation: x25519Bytes)
|
||||
}
|
||||
}
|
||||
118
ios_client/EncryptedChat/Crypto/X3DH.swift
Normal file
118
ios_client/EncryptedChat/Crypto/X3DH.swift
Normal file
@@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// X3DH key agreement protocol (Signal Protocol)
|
||||
enum X3DH {
|
||||
|
||||
// MARK: - Pre-Key Generation
|
||||
|
||||
/// Generate a signed pre-key (SPK).
|
||||
/// Returns (private, public, signature, id).
|
||||
/// Matches Python: generate_signed_prekey(identity_private)
|
||||
static func generateSignedPrekey(
|
||||
identityPrivate: Curve25519.Signing.PrivateKey
|
||||
) throws -> (privateKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
publicKey: Curve25519.KeyAgreement.PublicKey,
|
||||
signature: Data,
|
||||
id: String) {
|
||||
let (spkPriv, spkPub) = X25519Crypto.generateKeypair()
|
||||
let spkPubBytes = X25519Crypto.serializePublic(spkPub)
|
||||
let signature = try Ed25519Crypto.sign(identityPrivate, data: spkPubBytes)
|
||||
return (spkPriv, spkPub, signature, UUID().uuidString)
|
||||
}
|
||||
|
||||
/// Generate a batch of one-time pre-keys.
|
||||
/// Matches Python: generate_one_time_prekeys(count=50)
|
||||
static func generateOneTimePrekeys(count: Int = 50) -> [(privateKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
publicKey: Curve25519.KeyAgreement.PublicKey,
|
||||
id: String)] {
|
||||
(0..<count).map { _ in
|
||||
let (priv, pub) = X25519Crypto.generateKeypair()
|
||||
return (priv, pub, UUID().uuidString)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - X3DH Initiate (Alice)
|
||||
|
||||
/// Initiator side of X3DH.
|
||||
/// Returns (sharedSecret, ephemeralPrivate, ephemeralPublic).
|
||||
/// Matches Python: x3dh_initiate(ik_private_ed, ik_public_remote_ed, spk_remote, spk_signature, opk_remote?)
|
||||
static func initiate(
|
||||
ikPrivateEd: Curve25519.Signing.PrivateKey,
|
||||
ikPublicRemoteEd: Curve25519.Signing.PublicKey,
|
||||
spkRemote: Curve25519.KeyAgreement.PublicKey,
|
||||
spkSignature: Data,
|
||||
opkRemote: Curve25519.KeyAgreement.PublicKey? = nil
|
||||
) throws -> (sharedSecret: Data,
|
||||
ephemeralPrivate: Curve25519.KeyAgreement.PrivateKey,
|
||||
ephemeralPublic: Curve25519.KeyAgreement.PublicKey) {
|
||||
// Verify SPK signature
|
||||
let spkRemoteBytes = X25519Crypto.serializePublic(spkRemote)
|
||||
guard Ed25519Crypto.verify(ikPublicRemoteEd, signature: spkSignature, data: spkRemoteBytes) else {
|
||||
throw CryptoError.x3dhFailed("Invalid SPK signature")
|
||||
}
|
||||
|
||||
// Convert identity keys to X25519
|
||||
let ikX25519Private = try X25519Crypto.fromEd25519Private(ikPrivateEd)
|
||||
let ikX25519Remote = try X25519Crypto.fromEd25519Public(ikPublicRemoteEd)
|
||||
|
||||
// Generate ephemeral keypair
|
||||
let (ekPriv, ekPub) = X25519Crypto.generateKeypair()
|
||||
|
||||
// DH computations
|
||||
let dh1 = try X25519Crypto.dh(ikX25519Private, spkRemote) // IK_A, SPK_B
|
||||
let dh2 = try X25519Crypto.dh(ekPriv, ikX25519Remote) // EK_A, IK_B
|
||||
let dh3 = try X25519Crypto.dh(ekPriv, spkRemote) // EK_A, SPK_B
|
||||
|
||||
var dhConcat = dh1 + dh2 + dh3
|
||||
if let opk = opkRemote {
|
||||
let dh4 = try X25519Crypto.dh(ekPriv, opk) // EK_A, OPK_B
|
||||
dhConcat += dh4
|
||||
}
|
||||
|
||||
// Derive shared secret
|
||||
let sharedSecret = CryptoUtils.hkdfDerive(
|
||||
inputKey: dhConcat,
|
||||
salt: Data(repeating: 0x00, count: 32),
|
||||
info: Data(Constants.x3dhInfo.utf8),
|
||||
length: 32
|
||||
)
|
||||
|
||||
return (sharedSecret, ekPriv, ekPub)
|
||||
}
|
||||
|
||||
// MARK: - X3DH Respond (Bob)
|
||||
|
||||
/// Responder side of X3DH.
|
||||
/// Returns sharedSecret.
|
||||
/// Matches Python: x3dh_respond(ik_private_ed, spk_private, ik_remote_ed, ek_remote, opk_private?)
|
||||
static func respond(
|
||||
ikPrivateEd: Curve25519.Signing.PrivateKey,
|
||||
spkPrivate: Curve25519.KeyAgreement.PrivateKey,
|
||||
ikRemoteEd: Curve25519.Signing.PublicKey,
|
||||
ekRemote: Curve25519.KeyAgreement.PublicKey,
|
||||
opkPrivate: Curve25519.KeyAgreement.PrivateKey? = nil
|
||||
) throws -> Data {
|
||||
let ikX25519Private = try X25519Crypto.fromEd25519Private(ikPrivateEd)
|
||||
let ikX25519Remote = try X25519Crypto.fromEd25519Public(ikRemoteEd)
|
||||
|
||||
let dh1 = try X25519Crypto.dh(spkPrivate, ikX25519Remote) // SPK_B, IK_A
|
||||
let dh2 = try X25519Crypto.dh(ikX25519Private, ekRemote) // IK_B, EK_A
|
||||
let dh3 = try X25519Crypto.dh(spkPrivate, ekRemote) // SPK_B, EK_A
|
||||
|
||||
var dhConcat = dh1 + dh2 + dh3
|
||||
if let opk = opkPrivate {
|
||||
let dh4 = try X25519Crypto.dh(opk, ekRemote) // OPK_B, EK_A
|
||||
dhConcat += dh4
|
||||
}
|
||||
|
||||
let sharedSecret = CryptoUtils.hkdfDerive(
|
||||
inputKey: dhConcat,
|
||||
salt: Data(repeating: 0x00, count: 32),
|
||||
info: Data(Constants.x3dhInfo.utf8),
|
||||
length: 32
|
||||
)
|
||||
|
||||
return sharedSecret
|
||||
}
|
||||
}
|
||||
46
ios_client/EncryptedChat/Models/Conversation.swift
Normal file
46
ios_client/EncryptedChat/Models/Conversation.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
struct Conversation: Identifiable, Equatable {
|
||||
let id: String
|
||||
var name: String?
|
||||
var members: [ConversationMember]
|
||||
var createdBy: String?
|
||||
var avatarFile: String?
|
||||
var unreadCount: Int
|
||||
var isFavorite: Bool
|
||||
var lastMessageTime: Date?
|
||||
|
||||
var isGroup: Bool {
|
||||
name != nil || members.count > 2
|
||||
}
|
||||
|
||||
/// Display name: group name, or DM partner username
|
||||
func displayName(currentUserId: String) -> String {
|
||||
if let name = name, !name.isEmpty {
|
||||
return name
|
||||
}
|
||||
// DM: show the other person's name
|
||||
if let other = members.first(where: { $0.userId != currentUserId }) {
|
||||
return other.username
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
/// DM partner user ID (nil for groups)
|
||||
func dmPartnerId(currentUserId: String) -> String? {
|
||||
guard !isGroup else { return nil }
|
||||
return members.first(where: { $0.userId != currentUserId })?.userId
|
||||
}
|
||||
|
||||
static func == (lhs: Conversation, rhs: Conversation) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
struct ConversationMember: Identifiable, Equatable, Codable {
|
||||
let userId: String
|
||||
var username: String
|
||||
var email: String
|
||||
|
||||
var id: String { userId }
|
||||
}
|
||||
43
ios_client/EncryptedChat/Models/DeviceBundle.swift
Normal file
43
ios_client/EncryptedChat/Models/DeviceBundle.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
|
||||
/// Key bundle for one device, used in X3DH
|
||||
struct DeviceBundle {
|
||||
let deviceId: String
|
||||
let identityKey: Data // Ed25519 public key (32 bytes)
|
||||
let spk: Data // X25519 public key (32 bytes)
|
||||
let spkSignature: Data // Ed25519 signature (64 bytes)
|
||||
let spkId: String
|
||||
let opk: Data? // X25519 public key (32 bytes), optional
|
||||
let opkId: String?
|
||||
|
||||
/// Parse from server response dictionary
|
||||
static func fromDict(_ dict: [String: Any]) throws -> DeviceBundle {
|
||||
guard let deviceId = dict["device_id"] as? String,
|
||||
let ikHex = dict["identity_key"] as? String,
|
||||
let ik = Data(hexString: ikHex),
|
||||
let spkHex = dict["spk"] as? String,
|
||||
let spk = Data(hexString: spkHex),
|
||||
let spkSigHex = dict["spk_signature"] as? String,
|
||||
let spkSig = Data(hexString: spkSigHex),
|
||||
let spkId = dict["spk_id"] as? String else {
|
||||
throw ChatError.invalidData("Invalid device bundle")
|
||||
}
|
||||
|
||||
var opk: Data?
|
||||
var opkId: String?
|
||||
if let opkHex = dict["opk"] as? String, let opkData = Data(hexString: opkHex) {
|
||||
opk = opkData
|
||||
opkId = dict["opk_id"] as? String
|
||||
}
|
||||
|
||||
return DeviceBundle(
|
||||
deviceId: deviceId,
|
||||
identityKey: ik,
|
||||
spk: spk,
|
||||
spkSignature: spkSig,
|
||||
spkId: spkId,
|
||||
opk: opk,
|
||||
opkId: opkId
|
||||
)
|
||||
}
|
||||
}
|
||||
9
ios_client/EncryptedChat/Models/Invitation.swift
Normal file
9
ios_client/EncryptedChat/Models/Invitation.swift
Normal file
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
struct Invitation: Identifiable {
|
||||
let id: String // invitation id (from server) or conversationId
|
||||
let conversationId: String
|
||||
let conversationName: String
|
||||
let invitedBy: String
|
||||
let invitedByUsername: String
|
||||
}
|
||||
33
ios_client/EncryptedChat/Models/Message.swift
Normal file
33
ios_client/EncryptedChat/Models/Message.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
struct Message: Identifiable, Equatable {
|
||||
let id: String
|
||||
let conversationId: String
|
||||
let senderId: String
|
||||
var senderUsername: String
|
||||
let createdAt: Date
|
||||
var text: String?
|
||||
var replyTo: String?
|
||||
var imageFileId: String?
|
||||
var file: FileInfo?
|
||||
var isDeleted: Bool
|
||||
var readBy: Set<String>
|
||||
|
||||
/// Whether this is a self-sent message
|
||||
func isMine(currentUserId: String) -> Bool {
|
||||
senderId == currentUserId
|
||||
}
|
||||
|
||||
static func == (lhs: Message, rhs: Message) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
struct FileInfo: Equatable, Codable {
|
||||
let fileId: String
|
||||
let aesKey: String // hex
|
||||
let iv: String // hex
|
||||
let filename: String
|
||||
let size: Int
|
||||
let mimeType: String
|
||||
}
|
||||
19
ios_client/EncryptedChat/Models/User.swift
Normal file
19
ios_client/EncryptedChat/Models/User.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
struct User: Identifiable, Equatable {
|
||||
let id: String
|
||||
var username: String
|
||||
var email: String
|
||||
var identityKey: Data? // Ed25519 public key (32 bytes)
|
||||
}
|
||||
|
||||
struct UserProfile: Equatable {
|
||||
var userId: String
|
||||
var username: String?
|
||||
var email: String?
|
||||
var phone: String?
|
||||
var phoneVisible: Bool
|
||||
var location: String?
|
||||
var locationVisible: Bool
|
||||
var avatarFile: String?
|
||||
}
|
||||
188
ios_client/EncryptedChat/Network/ConnectionManager.swift
Normal file
188
ios_client/EncryptedChat/Network/ConnectionManager.swift
Normal file
@@ -0,0 +1,188 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// TCP connection manager using Network.framework.
|
||||
/// Handles connection lifecycle, TLS, buffered reading (newline-delimited), and writing.
|
||||
actor ConnectionManager {
|
||||
|
||||
enum ConnectionState: Equatable {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
private var connection: NWConnection?
|
||||
private var receiveBuffer = Data()
|
||||
private(set) var state: ConnectionState = .disconnected
|
||||
private var stateCallback: ((ConnectionState) -> Void)?
|
||||
private var messageStream: AsyncStream<[String: Any]>.Continuation?
|
||||
|
||||
/// Set a callback for connection state changes
|
||||
func onStateChange(_ callback: @escaping (ConnectionState) -> Void) {
|
||||
stateCallback = callback
|
||||
}
|
||||
|
||||
// MARK: - Connect / Disconnect
|
||||
|
||||
/// Connect to server
|
||||
func connect(host: String, port: UInt16, useTLS: Bool = false, tlsInsecure: Bool = false) async throws {
|
||||
guard state == .disconnected || state != .connected else {
|
||||
throw NetworkError.alreadyConnected
|
||||
}
|
||||
|
||||
updateState(.connecting)
|
||||
|
||||
let nwHost = NWEndpoint.Host(host)
|
||||
let nwPort = NWEndpoint.Port(rawValue: port)!
|
||||
|
||||
let params: NWParameters
|
||||
if useTLS {
|
||||
let tlsOptions = NWProtocolTLS.Options()
|
||||
if tlsInsecure {
|
||||
// Skip certificate verification (dev only)
|
||||
sec_protocol_options_set_verify_block(
|
||||
tlsOptions.securityProtocolOptions,
|
||||
{ _, _, completionHandler in completionHandler(true) },
|
||||
.main
|
||||
)
|
||||
}
|
||||
params = NWParameters(tls: tlsOptions, tcp: .init())
|
||||
} else {
|
||||
params = .tcp
|
||||
}
|
||||
|
||||
let conn = NWConnection(host: nwHost, port: nwPort, using: params)
|
||||
self.connection = conn
|
||||
self.receiveBuffer = Data()
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
conn.stateUpdateHandler = { [weak self] newState in
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
switch newState {
|
||||
case .ready:
|
||||
await self.updateState(.connected)
|
||||
continuation.resume()
|
||||
case .failed(let error):
|
||||
await self.updateState(.failed(error.localizedDescription))
|
||||
continuation.resume(throwing: NetworkError.connectionFailed(error.localizedDescription))
|
||||
case .cancelled:
|
||||
await self.updateState(.disconnected)
|
||||
case .waiting(let error):
|
||||
await self.updateState(.failed(error.localizedDescription))
|
||||
continuation.resume(throwing: NetworkError.connectionFailed("Waiting: \(error.localizedDescription)"))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
conn.start(queue: .global(qos: .userInitiated))
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect from server
|
||||
func disconnect() {
|
||||
connection?.cancel()
|
||||
connection = nil
|
||||
receiveBuffer = Data()
|
||||
updateState(.disconnected)
|
||||
messageStream?.finish()
|
||||
messageStream = nil
|
||||
}
|
||||
|
||||
// MARK: - Send
|
||||
|
||||
/// Send raw data over the connection
|
||||
func send(_ data: Data) async throws {
|
||||
guard let connection = connection, state == .connected else {
|
||||
throw NetworkError.notConnected
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
connection.send(content: data, completion: .contentProcessed { error in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: NetworkError.connectionFailed(error.localizedDescription))
|
||||
} else {
|
||||
continuation.resume()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a protocol message (builds JSON + newline, sends)
|
||||
func sendMessage(type: String, requestId: String? = nil, params: [String: Any] = [:]) async throws {
|
||||
let data = try ProtocolHandler.buildRequest(type: type, requestId: requestId, params: params)
|
||||
try await send(data)
|
||||
}
|
||||
|
||||
// MARK: - Receive
|
||||
|
||||
/// Read one newline-delimited JSON message.
|
||||
/// Returns nil on EOF / connection close.
|
||||
func readMessage() async throws -> [String: Any]? {
|
||||
while true {
|
||||
// Check buffer for a complete line
|
||||
if let newlineIndex = receiveBuffer.firstIndex(of: 0x0A) {
|
||||
let lineData = receiveBuffer.prefix(through: newlineIndex)
|
||||
receiveBuffer.removeSubrange(...newlineIndex)
|
||||
|
||||
// Check size
|
||||
if lineData.count > Constants.maxMessageBytes {
|
||||
throw NetworkError.messageTooLarge
|
||||
}
|
||||
|
||||
return try ProtocolHandler.parseMessage(Data(lineData))
|
||||
}
|
||||
|
||||
// Buffer doesn't have a complete line — read more from the connection
|
||||
guard let connection = connection else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let chunk = try await receiveChunk(connection: connection)
|
||||
guard let chunk = chunk else {
|
||||
return nil // EOF
|
||||
}
|
||||
|
||||
receiveBuffer.append(chunk)
|
||||
|
||||
// Safety: if buffer exceeds max without a newline, drop it
|
||||
if receiveBuffer.count > Constants.maxMessageBytes * 2 {
|
||||
receiveBuffer = Data()
|
||||
throw NetworkError.messageTooLarge
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a chunk of data from the connection
|
||||
private func receiveChunk(connection: NWConnection) async throws -> Data? {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { content, _, isComplete, error in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: NetworkError.connectionFailed(error.localizedDescription))
|
||||
return
|
||||
}
|
||||
if let content = content, !content.isEmpty {
|
||||
continuation.resume(returning: content)
|
||||
} else if isComplete {
|
||||
continuation.resume(returning: nil)
|
||||
} else {
|
||||
// No data and not complete — shouldn't happen but return nil
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
var isConnected: Bool {
|
||||
state == .connected
|
||||
}
|
||||
|
||||
private func updateState(_ newState: ConnectionState) {
|
||||
state = newState
|
||||
stateCallback?(newState)
|
||||
}
|
||||
}
|
||||
90
ios_client/EncryptedChat/Network/ProtocolHandler.swift
Normal file
90
ios_client/EncryptedChat/Network/ProtocolHandler.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
|
||||
/// Newline-delimited JSON protocol handler.
|
||||
/// Matches Python: protocol.py build_request, build_response, parse_message, encode_binary, decode_binary
|
||||
enum ProtocolHandler {
|
||||
|
||||
/// Build a request message (newline-terminated JSON).
|
||||
/// Matches Python: build_request(msg_type, request_id=None, **kwargs)
|
||||
static func buildRequest(type: String, requestId: String? = nil, params: [String: Any] = [:]) throws -> Data {
|
||||
var msg: [String: Any] = ["type": type]
|
||||
if let requestId = requestId {
|
||||
msg["request_id"] = requestId
|
||||
}
|
||||
// Merge params into msg
|
||||
for (key, value) in params {
|
||||
msg[key] = value
|
||||
}
|
||||
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: msg)
|
||||
guard jsonData.count < Constants.maxMessageBytes else {
|
||||
throw NetworkError.messageTooLarge
|
||||
}
|
||||
return jsonData + Data([0x0A]) // newline
|
||||
}
|
||||
|
||||
/// Build a response message (newline-terminated JSON).
|
||||
static func buildResponse(type: String, status: String, data: [String: Any]? = nil, requestId: String? = nil) throws -> Data {
|
||||
var msg: [String: Any] = ["type": type, "status": status]
|
||||
if let data = data {
|
||||
msg["data"] = data
|
||||
}
|
||||
if let requestId = requestId {
|
||||
msg["request_id"] = requestId
|
||||
}
|
||||
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: msg)
|
||||
guard jsonData.count < Constants.maxMessageBytes else {
|
||||
throw NetworkError.messageTooLarge
|
||||
}
|
||||
return jsonData + Data([0x0A])
|
||||
}
|
||||
|
||||
/// Parse a single protocol message from bytes.
|
||||
/// Matches Python: parse_message(line)
|
||||
static func parseMessage(_ data: Data) throws -> [String: Any] {
|
||||
let trimmed = data.trimmingNewlines()
|
||||
guard !trimmed.isEmpty else {
|
||||
throw NetworkError.protocolError("Empty message")
|
||||
}
|
||||
guard let obj = try JSONSerialization.jsonObject(with: trimmed) as? [String: Any] else {
|
||||
throw NetworkError.protocolError("Message is not a JSON object")
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
/// Encode bytes to base64 string.
|
||||
/// Matches Python: encode_binary(data)
|
||||
static func encodeBinary(_ data: Data) -> String {
|
||||
data.base64EncodedString(options: [])
|
||||
}
|
||||
|
||||
/// Decode base64 string to bytes.
|
||||
/// Matches Python: decode_binary(data)
|
||||
static func decodeBinary(_ string: String) throws -> Data {
|
||||
guard let data = Data(base64Encoded: string, options: .ignoreUnknownCharacters) else {
|
||||
throw CryptoError.invalidBase64
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/// Generate a new request ID (UUID string).
|
||||
static func newRequestId() -> String {
|
||||
UUID().uuidString
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Helpers
|
||||
|
||||
private extension Data {
|
||||
func trimmingNewlines() -> Data {
|
||||
var data = self
|
||||
while let last = data.last, last == 0x0A || last == 0x0D {
|
||||
data.removeLast()
|
||||
}
|
||||
while let first = data.first, first == 0x0A || first == 0x0D {
|
||||
data.removeFirst()
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
38
ios_client/EncryptedChat/Utilities/Constants.swift
Normal file
38
ios_client/EncryptedChat/Utilities/Constants.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
enum Constants {
|
||||
static let version = "0.8.2"
|
||||
static let maxMessageBytes = 65536
|
||||
static let maxImageBytes = 5 * 1024 * 1024 // 5 MB
|
||||
static let maxFileBytes = 50 * 1024 * 1024 // 50 MB
|
||||
static let imageChunkSize = 32768 // 32 KB
|
||||
static let selfDeviceId = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
static let opkReplenishThreshold = 20
|
||||
static let opkBatchSize = 50
|
||||
static let spkRotationDays = 7
|
||||
|
||||
static let maxSkip = 256
|
||||
static let maxSenderKeySkip = 256
|
||||
|
||||
static let deviceBundleCacheTTL: TimeInterval = 300 // 5 minutes
|
||||
static let sendReceiveTimeout: TimeInterval = 30
|
||||
static let reconnectBaseDelay: TimeInterval = 1
|
||||
static let reconnectMaxDelay: TimeInterval = 30
|
||||
|
||||
static let pbkdf2Iterations: UInt32 = 600_000
|
||||
static let ecp1Magic = Data([0x45, 0x43, 0x50, 0x31]) // "ECP1"
|
||||
|
||||
// HKDF info/salt strings matching Python
|
||||
static let x3dhInfo = "EncryptedChat_X3DH"
|
||||
static let rootKeyInfo = "EncryptedChat_RootKey"
|
||||
static let selfEncryptionSalt = "self_encryption"
|
||||
static let selfEncryptionInfo = "EncryptedChat_SelfKey"
|
||||
static let localStorageSalt = "local_storage"
|
||||
static let localStorageInfo = "EncryptedChat_LocalStorage"
|
||||
static let senderKeyChainInfo = "SenderKeyChain"
|
||||
|
||||
// Server connection defaults
|
||||
static let defaultHost = "127.0.0.1"
|
||||
static let defaultPort: UInt16 = 9999
|
||||
}
|
||||
132
ios_client/EncryptedChat/Utilities/Extensions.swift
Normal file
132
ios_client/EncryptedChat/Utilities/Extensions.swift
Normal file
@@ -0,0 +1,132 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Data ↔ Hex
|
||||
|
||||
extension Data {
|
||||
/// Convert data to lowercase hex string
|
||||
var hexString: String {
|
||||
map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// Initialize Data from a hex string
|
||||
init?(hexString: String) {
|
||||
let hex = hexString.lowercased()
|
||||
guard hex.count % 2 == 0 else { return nil }
|
||||
var data = Data(capacity: hex.count / 2)
|
||||
var index = hex.startIndex
|
||||
while index < hex.endIndex {
|
||||
let nextIndex = hex.index(index, offsetBy: 2)
|
||||
guard let byte = UInt8(hex[index..<nextIndex], radix: 16) else { return nil }
|
||||
data.append(byte)
|
||||
index = nextIndex
|
||||
}
|
||||
self = data
|
||||
}
|
||||
|
||||
/// Generate random bytes
|
||||
static func randomBytes(_ count: Int) -> Data {
|
||||
var data = Data(count: count)
|
||||
data.withUnsafeMutableBytes { ptr in
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, count, ptr.baseAddress!)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data ↔ Base64 (Protocol Wire Format)
|
||||
|
||||
extension Data {
|
||||
/// Encode to standard base64 string (matching Python's base64.b64encode)
|
||||
func base64EncodedString() -> String {
|
||||
self.base64EncodedString(options: [])
|
||||
}
|
||||
|
||||
/// Decode from base64 string
|
||||
static func fromBase64(_ string: String) throws -> Data {
|
||||
// Try standard base64 first, then URL-safe
|
||||
if let data = Data(base64Encoded: string, options: .ignoreUnknownCharacters) {
|
||||
return data
|
||||
}
|
||||
throw CryptoError.invalidBase64
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UInt32 Big-Endian
|
||||
|
||||
extension UInt32 {
|
||||
var bigEndianData: Data {
|
||||
var value = self.bigEndian
|
||||
return Data(bytes: &value, count: 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CryptoKit Key → Data
|
||||
|
||||
extension Curve25519.KeyAgreement.PublicKey {
|
||||
var rawData: Data {
|
||||
Data(rawRepresentation)
|
||||
}
|
||||
}
|
||||
|
||||
extension Curve25519.KeyAgreement.PrivateKey {
|
||||
var rawData: Data {
|
||||
Data(rawRepresentation)
|
||||
}
|
||||
}
|
||||
|
||||
extension Curve25519.Signing.PublicKey {
|
||||
var rawData: Data {
|
||||
Data(rawRepresentation)
|
||||
}
|
||||
}
|
||||
|
||||
extension Curve25519.Signing.PrivateKey {
|
||||
var rawData: Data {
|
||||
Data(rawRepresentation)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String helpers
|
||||
|
||||
extension String {
|
||||
/// Trim whitespace and newlines
|
||||
var trimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dictionary merge helper
|
||||
|
||||
extension Dictionary where Key == String, Value == Any {
|
||||
func string(for key: String) -> String? {
|
||||
self[key] as? String
|
||||
}
|
||||
|
||||
func int(for key: String) -> Int? {
|
||||
if let i = self[key] as? Int { return i }
|
||||
if let s = self[key] as? String, let i = Int(s) { return i }
|
||||
return nil
|
||||
}
|
||||
|
||||
func dict(for key: String) -> [String: Any]? {
|
||||
self[key] as? [String: Any]
|
||||
}
|
||||
|
||||
func array(for key: String) -> [[String: Any]]? {
|
||||
self[key] as? [[String: Any]]
|
||||
}
|
||||
|
||||
func data(for key: String) -> Data? {
|
||||
if let hex = self[key] as? String {
|
||||
return Data(hexString: hex)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bool(for key: String) -> Bool? {
|
||||
if let b = self[key] as? Bool { return b }
|
||||
if let i = self[key] as? Int { return i != 0 }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
114
ios_client/EncryptedChat/ViewModels/AuthViewModel.swift
Normal file
114
ios_client/EncryptedChat/ViewModels/AuthViewModel.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
final class AuthViewModel {
|
||||
var email = ""
|
||||
var password = ""
|
||||
var confirmPassword = ""
|
||||
var username = ""
|
||||
var confirmationCode = ""
|
||||
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
var showConfirmation = false
|
||||
var registrationMessage: String?
|
||||
|
||||
var serverHost = Constants.defaultHost
|
||||
var serverPort = String(Constants.defaultPort)
|
||||
var useTLS = false
|
||||
|
||||
enum AuthMode {
|
||||
case login, register, pairing
|
||||
}
|
||||
var mode: AuthMode = .login
|
||||
|
||||
func login(appState: AppState) async {
|
||||
guard !email.isEmpty, !password.isEmpty else {
|
||||
errorMessage = "Email and password are required"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let port = UInt16(serverPort) ?? Constants.defaultPort
|
||||
try await appState.chatClient.connect(host: serverHost, port: port, useTLS: useTLS)
|
||||
} catch {
|
||||
isLoading = false
|
||||
errorMessage = "Connection failed: \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
|
||||
let (success, message) = await appState.chatClient.login(email: email, password: password)
|
||||
isLoading = false
|
||||
|
||||
if success {
|
||||
appState.email = email
|
||||
appState.isLoggedIn = true
|
||||
appState.connectionStatus = .connected
|
||||
if let userId = await appState.chatClient.userId {
|
||||
appState.currentUser = User(id: userId, username: await appState.chatClient.username, email: email)
|
||||
}
|
||||
} else {
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
func register(appState: AppState) async {
|
||||
guard !email.isEmpty, !password.isEmpty, !username.isEmpty else {
|
||||
errorMessage = "All fields are required"
|
||||
return
|
||||
}
|
||||
guard password == confirmPassword else {
|
||||
errorMessage = "Passwords don't match"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let port = UInt16(serverPort) ?? Constants.defaultPort
|
||||
try await appState.chatClient.connect(host: serverHost, port: port, useTLS: useTLS)
|
||||
} catch {
|
||||
isLoading = false
|
||||
errorMessage = "Connection failed: \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
|
||||
let (success, message) = await appState.chatClient.register(username: username, password: password, email: email)
|
||||
isLoading = false
|
||||
|
||||
if success {
|
||||
registrationMessage = message
|
||||
showConfirmation = true
|
||||
} else {
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
func confirmRegistration(appState: AppState) async {
|
||||
guard !confirmationCode.isEmpty else {
|
||||
errorMessage = "Enter the confirmation code"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
let (success, message) = await appState.chatClient.confirmRegistration(
|
||||
email: email, username: username, code: confirmationCode
|
||||
)
|
||||
isLoading = false
|
||||
|
||||
if success {
|
||||
registrationMessage = message
|
||||
// Auto-login after registration
|
||||
await login(appState: appState)
|
||||
} else {
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
}
|
||||
131
ios_client/EncryptedChat/ViewModels/ChatViewModel.swift
Normal file
131
ios_client/EncryptedChat/ViewModels/ChatViewModel.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
final class ChatViewModel {
|
||||
var messages: [Message] = []
|
||||
var isLoading = false
|
||||
var isSending = false
|
||||
var errorMessage: String?
|
||||
var searchQuery = ""
|
||||
var searchResults: [String] = [] // message IDs matching search
|
||||
var currentSearchIndex = 0
|
||||
|
||||
private var notificationTask: Task<Void, Never>?
|
||||
|
||||
func loadMessages(convId: String, chatClient: ChatClient) async {
|
||||
isLoading = true
|
||||
messages = await chatClient.getMessages(convId: convId, limit: 50)
|
||||
isLoading = false
|
||||
|
||||
// Mark as read
|
||||
let unreadIds = messages.filter { !$0.isMine(currentUserId: await chatClient.userId ?? "") }.map(\.id)
|
||||
if !unreadIds.isEmpty {
|
||||
await chatClient.markRead(convId: convId, messageIds: unreadIds)
|
||||
}
|
||||
}
|
||||
|
||||
func loadOlderMessages(convId: String, chatClient: ChatClient) async {
|
||||
let older = await chatClient.getMessages(convId: convId, limit: 50, offset: messages.count)
|
||||
messages.insert(contentsOf: older, at: 0)
|
||||
}
|
||||
|
||||
func sendMessage(convId: String, text: String, members: [ConversationMember],
|
||||
chatClient: ChatClient, replyTo: String? = nil) async {
|
||||
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
|
||||
isSending = true
|
||||
errorMessage = nil
|
||||
|
||||
let (success, msg) = await chatClient.sendMessage(
|
||||
convId: convId, text: text, members: members, replyTo: replyTo
|
||||
)
|
||||
|
||||
isSending = false
|
||||
|
||||
if !success {
|
||||
errorMessage = msg
|
||||
} else {
|
||||
// Reload messages to get the sent message
|
||||
await loadMessages(convId: convId, chatClient: chatClient)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteMessage(messageId: String, convId: String, chatClient: ChatClient) async {
|
||||
let success = await chatClient.deleteMessage(messageId: messageId, convId: convId)
|
||||
if success {
|
||||
messages.removeAll { $0.id == messageId }
|
||||
}
|
||||
}
|
||||
|
||||
func search(query: String) {
|
||||
searchQuery = query
|
||||
if query.isEmpty {
|
||||
searchResults = []
|
||||
currentSearchIndex = 0
|
||||
return
|
||||
}
|
||||
let lower = query.lowercased()
|
||||
searchResults = messages.filter { $0.text?.lowercased().contains(lower) == true }.map(\.id)
|
||||
currentSearchIndex = searchResults.isEmpty ? 0 : searchResults.count - 1
|
||||
}
|
||||
|
||||
func nextSearchResult() {
|
||||
guard !searchResults.isEmpty else { return }
|
||||
currentSearchIndex = (currentSearchIndex + 1) % searchResults.count
|
||||
}
|
||||
|
||||
func prevSearchResult() {
|
||||
guard !searchResults.isEmpty else { return }
|
||||
currentSearchIndex = (currentSearchIndex - 1 + searchResults.count) % searchResults.count
|
||||
}
|
||||
|
||||
func startNotificationListener(convId: String, chatClient: ChatClient) {
|
||||
notificationTask?.cancel()
|
||||
notificationTask = Task {
|
||||
for await notification in await chatClient.notifications {
|
||||
await handleNotification(notification, convId: convId, chatClient: chatClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleNotification(_ notification: ChatNotification, convId: String, chatClient: ChatClient) {
|
||||
switch notification {
|
||||
case .newMessage(let data):
|
||||
if data["conversation_id"] as? String == convId {
|
||||
if let msg = Task.detached(priority: .userInitiated, operation: {
|
||||
await chatClient.decryptNotification(data)
|
||||
}) as? Task<Message?, Never> {
|
||||
Task {
|
||||
if let message = await msg.value {
|
||||
messages.append(message)
|
||||
// Mark as read immediately since we're viewing this conv
|
||||
await chatClient.markRead(convId: convId, messageIds: [message.id])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case .messageDeleted(let data):
|
||||
if let msgId = data["message_id"] as? String {
|
||||
messages.removeAll { $0.id == msgId }
|
||||
}
|
||||
case .messagesRead(let data):
|
||||
if let readUserId = data["user_id"] as? String,
|
||||
let msgIds = data["message_ids"] as? [String] {
|
||||
for i in messages.indices {
|
||||
if msgIds.contains(messages[i].id) {
|
||||
messages[i].readBy.insert(readUserId)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
notificationTask?.cancel()
|
||||
notificationTask = nil
|
||||
}
|
||||
}
|
||||
127
ios_client/EncryptedChat/ViewModels/ConversationListVM.swift
Normal file
127
ios_client/EncryptedChat/ViewModels/ConversationListVM.swift
Normal file
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
final class ConversationListVM {
|
||||
var conversations: [Conversation] = []
|
||||
var invitations: [Invitation] = []
|
||||
var onlineUsers: Set<String> = []
|
||||
var unreadCounts: [String: Int] = [:]
|
||||
var favorites: Set<String> = []
|
||||
var isLoading = false
|
||||
|
||||
private var notificationTask: Task<Void, Never>?
|
||||
|
||||
func load(chatClient: ChatClient, email: String) async {
|
||||
isLoading = true
|
||||
|
||||
// Load favorites from disk
|
||||
favorites = KeyStorage.loadFavorites(email: email)
|
||||
|
||||
// Fetch conversations
|
||||
let convs = await chatClient.listConversations()
|
||||
conversations = sortConversations(convs, currentUserId: await chatClient.userId ?? "")
|
||||
|
||||
// Populate unread counts from server
|
||||
for conv in conversations where conv.unreadCount > 0 {
|
||||
unreadCounts[conv.id] = conv.unreadCount
|
||||
}
|
||||
|
||||
// Fetch invitations
|
||||
invitations = await chatClient.listInvitations()
|
||||
|
||||
isLoading = false
|
||||
|
||||
// Start notification listener
|
||||
startNotificationListener(chatClient: chatClient, email: email)
|
||||
}
|
||||
|
||||
func refresh(chatClient: ChatClient) async {
|
||||
let convs = await chatClient.listConversations()
|
||||
conversations = sortConversations(convs, currentUserId: await chatClient.userId ?? "")
|
||||
invitations = await chatClient.listInvitations()
|
||||
}
|
||||
|
||||
func toggleFavorite(convId: String, email: String) {
|
||||
if favorites.contains(convId) {
|
||||
favorites.remove(convId)
|
||||
} else {
|
||||
favorites.insert(convId)
|
||||
}
|
||||
try? KeyStorage.saveFavorites(email: email, favorites: favorites)
|
||||
|
||||
// Re-sort
|
||||
let userId = conversations.first?.createdBy ?? ""
|
||||
conversations = sortConversations(conversations, currentUserId: userId)
|
||||
}
|
||||
|
||||
func markConversationRead(convId: String) {
|
||||
unreadCounts[convId] = 0
|
||||
}
|
||||
|
||||
func incrementUnread(convId: String) {
|
||||
unreadCounts[convId, default: 0] += 1
|
||||
}
|
||||
|
||||
private func sortConversations(_ convs: [Conversation], currentUserId: String) -> [Conversation] {
|
||||
var result = convs.map { conv -> Conversation in
|
||||
var c = conv
|
||||
c.isFavorite = favorites.contains(conv.id)
|
||||
c.unreadCount = unreadCounts[conv.id] ?? conv.unreadCount
|
||||
return c
|
||||
}
|
||||
|
||||
result.sort { a, b in
|
||||
// Favorites first
|
||||
if a.isFavorite != b.isFavorite { return a.isFavorite }
|
||||
// Online DMs next
|
||||
let aOnline = a.dmPartnerId(currentUserId: currentUserId).map { onlineUsers.contains($0) } ?? false
|
||||
let bOnline = b.dmPartnerId(currentUserId: currentUserId).map { onlineUsers.contains($0) } ?? false
|
||||
if aOnline != bOnline { return aOnline }
|
||||
// Alphabetical
|
||||
return a.displayName(currentUserId: currentUserId).lowercased() < b.displayName(currentUserId: currentUserId).lowercased()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func startNotificationListener(chatClient: ChatClient, email: String) {
|
||||
notificationTask?.cancel()
|
||||
notificationTask = Task {
|
||||
for await notification in await chatClient.notifications {
|
||||
await handleNotification(notification, chatClient: chatClient, email: email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleNotification(_ notification: ChatNotification, chatClient: ChatClient, email: String) {
|
||||
switch notification {
|
||||
case .newMessage(let data):
|
||||
if let convId = data["conversation_id"] as? String {
|
||||
incrementUnread(convId: convId)
|
||||
}
|
||||
case .onlineUsers(let userIds):
|
||||
onlineUsers = Set(userIds)
|
||||
case .userOnline(let userId):
|
||||
onlineUsers.insert(userId)
|
||||
case .userOffline(let userId):
|
||||
onlineUsers.remove(userId)
|
||||
case .conversationCreated, .memberAdded, .memberRemoved, .conversationRenamed:
|
||||
Task { await refresh(chatClient: chatClient) }
|
||||
case .groupInvitation:
|
||||
Task { invitations = await chatClient.listInvitations() }
|
||||
case .connectionStateChanged(let connected):
|
||||
if !connected {
|
||||
// Could trigger auto-reconnect here
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
notificationTask?.cancel()
|
||||
notificationTask = nil
|
||||
}
|
||||
}
|
||||
66
ios_client/EncryptedChat/ViewModels/ProfileViewModel.swift
Normal file
66
ios_client/EncryptedChat/ViewModels/ProfileViewModel.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
final class ProfileViewModel {
|
||||
var profile: UserProfile?
|
||||
var avatarData: Data?
|
||||
var isLoading = false
|
||||
var isSaving = false
|
||||
var errorMessage: String?
|
||||
|
||||
// Editable fields
|
||||
var phone = ""
|
||||
var phoneVisible = false
|
||||
var location = ""
|
||||
var locationVisible = false
|
||||
|
||||
func loadProfile(userId: String? = nil, chatClient: ChatClient) async {
|
||||
isLoading = true
|
||||
profile = await chatClient.getProfile(userId: userId)
|
||||
isLoading = false
|
||||
|
||||
if let p = profile {
|
||||
phone = p.phone ?? ""
|
||||
phoneVisible = p.phoneVisible
|
||||
location = p.location ?? ""
|
||||
locationVisible = p.locationVisible
|
||||
}
|
||||
|
||||
// Load avatar
|
||||
let uid = userId ?? await chatClient.userId ?? ""
|
||||
if !uid.isEmpty {
|
||||
avatarData = await chatClient.getAvatar(userId: uid)
|
||||
}
|
||||
}
|
||||
|
||||
func saveProfile(chatClient: ChatClient) async {
|
||||
isSaving = true
|
||||
errorMessage = nil
|
||||
|
||||
let success = await chatClient.updateProfile(
|
||||
phone: phone.isEmpty ? nil : phone,
|
||||
phoneVisible: phoneVisible,
|
||||
location: location.isEmpty ? nil : location,
|
||||
locationVisible: locationVisible
|
||||
)
|
||||
|
||||
isSaving = false
|
||||
|
||||
if !success {
|
||||
errorMessage = "Failed to update profile"
|
||||
}
|
||||
}
|
||||
|
||||
func uploadAvatar(imageData: Data, chatClient: ChatClient) async {
|
||||
isSaving = true
|
||||
let success = await chatClient.updateAvatar(imageData: imageData)
|
||||
isSaving = false
|
||||
|
||||
if success {
|
||||
avatarData = imageData
|
||||
} else {
|
||||
errorMessage = "Failed to upload avatar"
|
||||
}
|
||||
}
|
||||
}
|
||||
134
ios_client/EncryptedChat/Views/Auth/LoginView.swift
Normal file
134
ios_client/EncryptedChat/Views/Auth/LoginView.swift
Normal file
@@ -0,0 +1,134 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@Bindable var viewModel: AuthViewModel
|
||||
var appState: AppState
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "lock.shield.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(.blue)
|
||||
.padding(.top, 40)
|
||||
|
||||
Text("Encrypted Chat")
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
Text("End-to-end encrypted messaging")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
// Server config
|
||||
DisclosureGroup("Server") {
|
||||
TextField("Host", text: $viewModel.serverHost)
|
||||
.textContentType(.URL)
|
||||
.autocapitalization(.none)
|
||||
TextField("Port", text: $viewModel.serverPort)
|
||||
.keyboardType(.numberPad)
|
||||
Toggle("Use TLS", isOn: $viewModel.useTLS)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
TextField("Email", text: $viewModel.email)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
SecureField("Password", text: $viewModel.password)
|
||||
.textContentType(.password)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
if viewModel.mode == .register {
|
||||
TextField("Username", text: $viewModel.username)
|
||||
.textContentType(.username)
|
||||
.autocapitalization(.none)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
SecureField("Confirm Password", text: $viewModel.confirmPassword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
Task {
|
||||
if viewModel.mode == .login {
|
||||
await viewModel.login(appState: appState)
|
||||
} else {
|
||||
await viewModel.register(appState: appState)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text(viewModel.mode == .login ? "Login" : "Register")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(viewModel.isLoading)
|
||||
|
||||
Button(viewModel.mode == .login ? "Don't have an account? Register" : "Already have an account? Login") {
|
||||
viewModel.mode = viewModel.mode == .login ? .register : .login
|
||||
viewModel.errorMessage = nil
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showConfirmation) {
|
||||
ConfirmationSheet(viewModel: viewModel, appState: appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfirmationSheet: View {
|
||||
@Bindable var viewModel: AuthViewModel
|
||||
var appState: AppState
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("Confirm Registration")
|
||||
.font(.title2.bold())
|
||||
|
||||
if let msg = viewModel.registrationMessage {
|
||||
Text(msg)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
TextField("Confirmation Code", text: $viewModel.confirmationCode)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.keyboardType(.numberPad)
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Button("Confirm") {
|
||||
Task {
|
||||
await viewModel.confirmRegistration(appState: appState)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
}
|
||||
49
ios_client/EncryptedChat/Views/Auth/PairingView.swift
Normal file
49
ios_client/EncryptedChat/Views/Auth/PairingView.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PairingView: View {
|
||||
var appState: AppState
|
||||
@State private var pairingCode = ""
|
||||
@State private var isWaiting = false
|
||||
@State private var statusMessage: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "iphone.and.arrow.forward")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
Text("Device Pairing")
|
||||
.font(.title2.bold())
|
||||
|
||||
Text("Enter the 8-digit pairing code shown on your other device.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
TextField("Pairing Code", text: $pairingCode)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.keyboardType(.numberPad)
|
||||
.frame(maxWidth: 200)
|
||||
|
||||
if let status = statusMessage {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(status.contains("Error") ? .red : .secondary)
|
||||
}
|
||||
|
||||
if isWaiting {
|
||||
ProgressView("Waiting for authorization...")
|
||||
}
|
||||
|
||||
Button("Start Pairing") {
|
||||
Task {
|
||||
// Pairing implementation would go here
|
||||
statusMessage = "Pairing not yet implemented"
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(pairingCode.count != 8 || isWaiting)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
}
|
||||
4
ios_client/EncryptedChat/Views/Auth/RegisterView.swift
Normal file
4
ios_client/EncryptedChat/Views/Auth/RegisterView.swift
Normal file
@@ -0,0 +1,4 @@
|
||||
import SwiftUI
|
||||
|
||||
// Registration is handled within LoginView via mode toggle.
|
||||
// This file exists for potential future separation.
|
||||
164
ios_client/EncryptedChat/Views/Chat/ChatView.swift
Normal file
164
ios_client/EncryptedChat/Views/Chat/ChatView.swift
Normal file
@@ -0,0 +1,164 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChatView: View {
|
||||
let conversation: Conversation
|
||||
var appState: AppState
|
||||
@State private var viewModel = ChatViewModel()
|
||||
@State private var inputText = ""
|
||||
@State private var replyTo: Message?
|
||||
@State private var showGroupInfo = false
|
||||
@State private var showSearch = false
|
||||
@State private var showDeleteConfirm = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Search bar
|
||||
if showSearch {
|
||||
SearchOverlayView(
|
||||
query: $viewModel.searchQuery,
|
||||
matchCount: viewModel.searchResults.count,
|
||||
currentIndex: viewModel.currentSearchIndex,
|
||||
onSearch: { viewModel.search(query: $0) },
|
||||
onNext: { viewModel.nextSearchResult() },
|
||||
onPrev: { viewModel.prevSearchResult() },
|
||||
onClose: { showSearch = false; viewModel.search(query: "") }
|
||||
)
|
||||
}
|
||||
|
||||
// Messages
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 8) {
|
||||
if viewModel.messages.count >= 50 {
|
||||
Button("Load older messages") {
|
||||
Task {
|
||||
await viewModel.loadOlderMessages(convId: conversation.id, chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.padding()
|
||||
}
|
||||
|
||||
ForEach(viewModel.messages) { message in
|
||||
MessageBubbleView(
|
||||
message: message,
|
||||
isMine: message.isMine(currentUserId: appState.currentUser?.id ?? ""),
|
||||
isHighlighted: viewModel.searchResults.contains(message.id),
|
||||
isCurrentSearchResult: viewModel.searchResults.indices.contains(viewModel.currentSearchIndex) &&
|
||||
viewModel.searchResults[viewModel.currentSearchIndex] == message.id,
|
||||
onReply: { replyTo = message },
|
||||
onDelete: {
|
||||
Task {
|
||||
await viewModel.deleteMessage(messageId: message.id, convId: conversation.id, chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
)
|
||||
.id(message.id)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.onChange(of: viewModel.messages.count) {
|
||||
if let lastId = viewModel.messages.last?.id {
|
||||
withAnimation {
|
||||
proxy.scrollTo(lastId, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reply preview
|
||||
if let reply = replyTo {
|
||||
HStack {
|
||||
Rectangle()
|
||||
.fill(.blue)
|
||||
.frame(width: 3)
|
||||
VStack(alignment: .leading) {
|
||||
Text(reply.senderUsername)
|
||||
.font(.caption.bold())
|
||||
Text(reply.text ?? "")
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Button(action: { replyTo = nil }) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
|
||||
// Input
|
||||
MessageInputView(
|
||||
text: $inputText,
|
||||
isSending: viewModel.isSending,
|
||||
onSend: {
|
||||
Task {
|
||||
let text = inputText
|
||||
inputText = ""
|
||||
let reply = replyTo?.id
|
||||
replyTo = nil
|
||||
await viewModel.sendMessage(
|
||||
convId: conversation.id,
|
||||
text: text,
|
||||
members: conversation.members,
|
||||
chatClient: appState.chatClient,
|
||||
replyTo: reply
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.navigationTitle(conversation.displayName(currentUserId: appState.currentUser?.id ?? ""))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
Button(action: { showSearch.toggle() }) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
}
|
||||
|
||||
if conversation.isGroup {
|
||||
Button(action: { showGroupInfo = true }) {
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete button
|
||||
if !conversation.isGroup || conversation.createdBy == appState.currentUser?.id {
|
||||
Button(action: { showDeleteConfirm = true }) {
|
||||
Image(systemName: "trash")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Delete Conversation?", isPresented: $showDeleteConfirm) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Delete", role: .destructive) {
|
||||
Task {
|
||||
await appState.chatClient.deleteConversation(convId: conversation.id)
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text(conversation.isGroup
|
||||
? "This will remove all members and delete the conversation."
|
||||
: "This will remove you from the conversation.")
|
||||
}
|
||||
.sheet(isPresented: $showGroupInfo) {
|
||||
GroupInfoView(conversation: conversation, appState: appState)
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadMessages(convId: conversation.id, chatClient: appState.chatClient)
|
||||
viewModel.startNotificationListener(convId: conversation.id, chatClient: appState.chatClient)
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
43
ios_client/EncryptedChat/Views/Chat/ImageViewerView.swift
Normal file
43
ios_client/EncryptedChat/Views/Chat/ImageViewerView.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ImageViewerView: View {
|
||||
let imageData: Data
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
GeometryReader { geo in
|
||||
if let uiImage = UIImage(data: imageData) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.scaleEffect(scale)
|
||||
.gesture(
|
||||
MagnifyGesture()
|
||||
.onChanged { value in
|
||||
scale = value.magnification
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation {
|
||||
scale = max(1.0, min(scale, 5.0))
|
||||
}
|
||||
}
|
||||
)
|
||||
.onTapGesture(count: 2) {
|
||||
withAnimation {
|
||||
scale = scale > 1 ? 1 : 2
|
||||
}
|
||||
}
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
.background(.black)
|
||||
}
|
||||
}
|
||||
}
|
||||
123
ios_client/EncryptedChat/Views/Chat/MessageBubbleView.swift
Normal file
123
ios_client/EncryptedChat/Views/Chat/MessageBubbleView.swift
Normal file
@@ -0,0 +1,123 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MessageBubbleView: View {
|
||||
let message: Message
|
||||
let isMine: Bool
|
||||
var isHighlighted: Bool = false
|
||||
var isCurrentSearchResult: Bool = false
|
||||
var onReply: (() -> Void)?
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if isMine { Spacer(minLength: 60) }
|
||||
|
||||
VStack(alignment: isMine ? .trailing : .leading, spacing: 4) {
|
||||
if !isMine {
|
||||
Text(message.senderUsername)
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if message.isDeleted {
|
||||
Text("Message deleted")
|
||||
.font(.body.italic())
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(12)
|
||||
.background(Color(.systemGray6))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
} else {
|
||||
// Reply reference
|
||||
if let replyTo = message.replyTo {
|
||||
HStack(spacing: 4) {
|
||||
Rectangle()
|
||||
.fill(.blue.opacity(0.5))
|
||||
.frame(width: 2)
|
||||
Text("Reply to message")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
|
||||
// File card
|
||||
if let file = message.file {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: "paperclip")
|
||||
Text(file.filename)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.font(.subheadline)
|
||||
|
||||
Text(formatFileSize(file.size))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(.systemGray5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// Text content
|
||||
if let text = message.text {
|
||||
Text(text)
|
||||
.padding(12)
|
||||
.background(
|
||||
isMine ? Color.blue : Color(.systemGray5)
|
||||
)
|
||||
.foregroundStyle(isMine ? .white : .primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
Text(formatTime(message.createdAt))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.background(
|
||||
isCurrentSearchResult ? Color.orange.opacity(0.3) :
|
||||
isHighlighted ? Color.yellow.opacity(0.2) : Color.clear
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.contextMenu {
|
||||
if !message.isDeleted {
|
||||
Button(action: { onReply?() }) {
|
||||
Label("Reply", systemImage: "arrowshape.turn.up.left")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = message.text ?? ""
|
||||
}) {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
if isMine {
|
||||
Button(role: .destructive, action: { onDelete?() }) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isMine { Spacer(minLength: 60) }
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
if Calendar.current.isDateInToday(date) {
|
||||
formatter.dateFormat = "HH:mm"
|
||||
} else {
|
||||
formatter.dateFormat = "MMM d, HH:mm"
|
||||
}
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func formatFileSize(_ bytes: Int) -> String {
|
||||
if bytes < 1024 { return "\(bytes) B" }
|
||||
if bytes < 1024 * 1024 { return "\(bytes / 1024) KB" }
|
||||
return String(format: "%.1f MB", Double(bytes) / (1024 * 1024))
|
||||
}
|
||||
}
|
||||
55
ios_client/EncryptedChat/Views/Chat/MessageInputView.swift
Normal file
55
ios_client/EncryptedChat/Views/Chat/MessageInputView.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
struct MessageInputView: View {
|
||||
@Binding var text: String
|
||||
let isSending: Bool
|
||||
let onSend: () -> Void
|
||||
|
||||
@State private var showAttachMenu = false
|
||||
@State private var selectedPhoto: PhotosPickerItem?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
// Attach button
|
||||
Menu {
|
||||
Button(action: {}) {
|
||||
Label("Photo", systemImage: "photo")
|
||||
}
|
||||
Button(action: {}) {
|
||||
Label("File", systemImage: "doc")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
|
||||
// Text field
|
||||
TextField("Message", text: $text, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...5)
|
||||
.onSubmit {
|
||||
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
onSend()
|
||||
}
|
||||
}
|
||||
|
||||
// Send button
|
||||
Button(action: onSend) {
|
||||
if isSending {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
} else {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
46
ios_client/EncryptedChat/Views/Chat/SearchOverlayView.swift
Normal file
46
ios_client/EncryptedChat/Views/Chat/SearchOverlayView.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SearchOverlayView: View {
|
||||
@Binding var query: String
|
||||
let matchCount: Int
|
||||
let currentIndex: Int
|
||||
let onSearch: (String) -> Void
|
||||
let onNext: () -> Void
|
||||
let onPrev: () -> Void
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("Search messages", text: $query)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onChange(of: query) { _, newValue in
|
||||
onSearch(newValue)
|
||||
}
|
||||
|
||||
if matchCount > 0 {
|
||||
Text("\(currentIndex + 1)/\(matchCount)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
|
||||
Button(action: onPrev) {
|
||||
Image(systemName: "chevron.up")
|
||||
}
|
||||
Button(action: onNext) {
|
||||
Image(systemName: "chevron.down")
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CircularAvatarView: View {
|
||||
let name: String
|
||||
var imageData: Data?
|
||||
var size: CGFloat = 32
|
||||
var isGroup: Bool = false
|
||||
|
||||
var body: some View {
|
||||
if let imageData = imageData, let uiImage = UIImage(data: imageData) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
// Default: colored circle with initial letter
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(avatarColor)
|
||||
.frame(width: size, height: size)
|
||||
|
||||
Text(initial)
|
||||
.font(.system(size: size * 0.4, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var initial: String {
|
||||
String(name.prefix(1)).uppercased()
|
||||
}
|
||||
|
||||
/// Deterministic color from name hash (matching Python gui_client behavior)
|
||||
private var avatarColor: Color {
|
||||
let colors: [Color] = [
|
||||
.red, .orange, .yellow, .green, .mint,
|
||||
.teal, .cyan, .blue, .indigo, .purple, .pink
|
||||
]
|
||||
var hash = 0
|
||||
for char in name.unicodeScalars {
|
||||
hash = hash &* 31 &+ Int(char.value)
|
||||
}
|
||||
return colors[abs(hash) % colors.count]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConnectionIndicator: View {
|
||||
let status: ConnectionStatus
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(statusColor)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
if status != .connected {
|
||||
Text(statusText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch status {
|
||||
case .connected: return .green
|
||||
case .connecting: return .orange
|
||||
case .disconnected: return .red
|
||||
}
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
switch status {
|
||||
case .connected: return ""
|
||||
case .connecting: return "Connecting..."
|
||||
case .disconnected: return "Disconnected"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OnlineDotOverlay: View {
|
||||
var size: CGFloat = 12
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(.green)
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(.white, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConversationListView: View {
|
||||
var appState: AppState
|
||||
@Bindable var viewModel: ConversationListVM
|
||||
@State private var showNewConversation = false
|
||||
@State private var showProfile = false
|
||||
@State private var selectedConversation: Conversation?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// Invitations section
|
||||
if !viewModel.invitations.isEmpty {
|
||||
Section {
|
||||
ForEach(viewModel.invitations) { invitation in
|
||||
InvitationBanner(
|
||||
invitation: invitation,
|
||||
onAccept: {
|
||||
Task {
|
||||
let (success, _) = await appState.chatClient.acceptInvitation(convId: invitation.conversationId)
|
||||
if success {
|
||||
await viewModel.refresh(chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDecline: {
|
||||
Task {
|
||||
await appState.chatClient.declineInvitation(convId: invitation.conversationId)
|
||||
await viewModel.refresh(chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("Invitations")
|
||||
}
|
||||
}
|
||||
|
||||
// Conversations section
|
||||
Section {
|
||||
ForEach(viewModel.conversations) { conversation in
|
||||
NavigationLink(value: conversation) {
|
||||
ConversationRowView(
|
||||
conversation: conversation,
|
||||
currentUserId: appState.currentUser?.id ?? "",
|
||||
isOnline: conversation.dmPartnerId(currentUserId: appState.currentUser?.id ?? "")
|
||||
.map { viewModel.onlineUsers.contains($0) } ?? false,
|
||||
unreadCount: viewModel.unreadCounts[conversation.id] ?? 0
|
||||
)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(conversation.isFavorite ? "Remove from Favorites" : "Add to Favorites") {
|
||||
viewModel.toggleFavorite(convId: conversation.id, email: appState.email)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Chats")
|
||||
.navigationDestination(for: Conversation.self) { conversation in
|
||||
ChatView(
|
||||
conversation: conversation,
|
||||
appState: appState
|
||||
)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
ConnectionIndicator(status: appState.connectionStatus)
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack {
|
||||
Button(action: { showProfile = true }) {
|
||||
Image(systemName: "person.circle")
|
||||
}
|
||||
Button(action: { showNewConversation = true }) {
|
||||
Image(systemName: "square.and.pencil")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.refresh(chatClient: appState.chatClient)
|
||||
}
|
||||
.sheet(isPresented: $showNewConversation) {
|
||||
NewConversationSheet(appState: appState) { convId in
|
||||
showNewConversation = false
|
||||
await viewModel.refresh(chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showProfile) {
|
||||
ProfileView(appState: appState, isOwnProfile: true)
|
||||
}
|
||||
.task {
|
||||
await viewModel.load(chatClient: appState.chatClient, email: appState.email)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConversationRowView: View {
|
||||
let conversation: Conversation
|
||||
let currentUserId: String
|
||||
let isOnline: Bool
|
||||
let unreadCount: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Avatar
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
CircularAvatarView(
|
||||
name: conversation.displayName(currentUserId: currentUserId),
|
||||
size: 44,
|
||||
isGroup: conversation.isGroup
|
||||
)
|
||||
|
||||
if isOnline && !conversation.isGroup {
|
||||
OnlineDotOverlay(size: 12)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
if conversation.isFavorite {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
|
||||
Text(conversation.displayName(currentUserId: currentUserId))
|
||||
.font(unreadCount > 0 ? .body.bold() : .body)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if conversation.isGroup {
|
||||
Text("\(conversation.members.count) members")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if unreadCount > 0 {
|
||||
Text("\(unreadCount)")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.blue)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NewConversationSheet: View {
|
||||
var appState: AppState
|
||||
var onCreated: (String) async -> Void
|
||||
|
||||
@State private var email = ""
|
||||
@State private var groupName = ""
|
||||
@State private var isGroup = false
|
||||
@State private var memberEmails: [String] = [""]
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Create Group", isOn: $isGroup)
|
||||
|
||||
if isGroup {
|
||||
TextField("Group Name", text: $groupName)
|
||||
}
|
||||
}
|
||||
|
||||
Section(isGroup ? "Members" : "Recipient") {
|
||||
if isGroup {
|
||||
ForEach(memberEmails.indices, id: \.self) { index in
|
||||
TextField("Email", text: $memberEmails[index])
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
}
|
||||
Button("Add Member") {
|
||||
memberEmails.append("")
|
||||
}
|
||||
} else {
|
||||
TextField("Email", text: $email)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
}
|
||||
}
|
||||
|
||||
if let error = errorMessage {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Conversation")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Create") {
|
||||
Task { await create() }
|
||||
}
|
||||
.disabled(isLoading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func create() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
let emails: [String]
|
||||
if isGroup {
|
||||
emails = memberEmails.map { $0.trimmed }.filter { !$0.isEmpty }
|
||||
guard !emails.isEmpty else {
|
||||
errorMessage = "Add at least one member"
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
} else {
|
||||
guard !email.trimmed.isEmpty else {
|
||||
errorMessage = "Enter an email address"
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
emails = [email.trimmed]
|
||||
}
|
||||
|
||||
let name = isGroup && !groupName.trimmed.isEmpty ? groupName.trimmed : nil
|
||||
let (convId, message) = await appState.chatClient.createConversation(emails: emails, name: name)
|
||||
|
||||
isLoading = false
|
||||
|
||||
if let convId = convId {
|
||||
await onCreated(convId)
|
||||
} else {
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SwiftUI
|
||||
|
||||
// Group creation is handled within NewConversationSheet via the isGroup toggle.
|
||||
// This file exists for potential future separation.
|
||||
123
ios_client/EncryptedChat/Views/Groups/GroupInfoView.swift
Normal file
123
ios_client/EncryptedChat/Views/Groups/GroupInfoView.swift
Normal file
@@ -0,0 +1,123 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GroupInfoView: View {
|
||||
let conversation: Conversation
|
||||
var appState: AppState
|
||||
@State private var showRenameSheet = false
|
||||
@State private var showLeaveConfirm = false
|
||||
@State private var newName = ""
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var isCreator: Bool {
|
||||
conversation.createdBy == appState.currentUser?.id
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// Avatar section
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
CircularAvatarView(
|
||||
name: conversation.name ?? "Group",
|
||||
size: 64,
|
||||
isGroup: true
|
||||
)
|
||||
|
||||
Text(conversation.name ?? "Group")
|
||||
.font(.title2.bold())
|
||||
|
||||
Text("\(conversation.members.count) members")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
// Actions
|
||||
if isCreator {
|
||||
Section {
|
||||
Button("Rename Group") {
|
||||
newName = conversation.name ?? ""
|
||||
showRenameSheet = true
|
||||
}
|
||||
|
||||
Button("Change Avatar") {
|
||||
// Photo picker would go here
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Members
|
||||
Section("Members") {
|
||||
ForEach(conversation.members) { member in
|
||||
HStack {
|
||||
CircularAvatarView(name: member.username, size: 32, isGroup: false)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(member.username)
|
||||
.font(.body)
|
||||
Text(member.email)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if member.userId == conversation.createdBy {
|
||||
Text("Admin")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Leave / Delete
|
||||
Section {
|
||||
Button("Leave Group", role: .destructive) {
|
||||
showLeaveConfirm = true
|
||||
}
|
||||
|
||||
if isCreator {
|
||||
Button("Delete Group", role: .destructive) {
|
||||
Task {
|
||||
await appState.chatClient.deleteConversation(convId: conversation.id)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Group Info")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
.alert("Leave Group?", isPresented: $showLeaveConfirm) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Leave", role: .destructive) {
|
||||
Task {
|
||||
await appState.chatClient.leaveGroup(convId: conversation.id)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Rename Group", isPresented: $showRenameSheet) {
|
||||
TextField("Group Name", text: $newName)
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Rename") {
|
||||
Task {
|
||||
await appState.chatClient.renameConversation(convId: conversation.id, name: newName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
ios_client/EncryptedChat/Views/Groups/InvitationBanner.swift
Normal file
41
ios_client/EncryptedChat/Views/Groups/InvitationBanner.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InvitationBanner: View {
|
||||
let invitation: Invitation
|
||||
let onAccept: () -> Void
|
||||
let onDecline: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: "envelope.badge")
|
||||
.foregroundStyle(.orange)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(invitation.conversationName)
|
||||
.font(.body.bold())
|
||||
Text("Invited by \(invitation.invitedByUsername)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("Accept") {
|
||||
onAccept()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
|
||||
Button("Decline") {
|
||||
onDecline()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SwiftUI
|
||||
|
||||
// Profile editing is handled within ProfileView when isOwnProfile = true.
|
||||
// This file exists for potential future separation.
|
||||
111
ios_client/EncryptedChat/Views/Profile/ProfileView.swift
Normal file
111
ios_client/EncryptedChat/Views/Profile/ProfileView.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ProfileView: View {
|
||||
var appState: AppState
|
||||
var isOwnProfile: Bool
|
||||
var userId: String?
|
||||
@State private var viewModel = ProfileViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Avatar
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
if let avatarData = viewModel.avatarData,
|
||||
let uiImage = UIImage(data: avatarData) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
CircularAvatarView(
|
||||
name: viewModel.profile?.username ?? "?",
|
||||
size: 80,
|
||||
isGroup: false
|
||||
)
|
||||
}
|
||||
|
||||
if isOwnProfile {
|
||||
Button("Change Photo") {
|
||||
// Photo picker would go here
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
// Info
|
||||
Section("Info") {
|
||||
if let username = viewModel.profile?.username {
|
||||
LabeledContent("Username", value: username)
|
||||
}
|
||||
if let email = viewModel.profile?.email {
|
||||
LabeledContent("Email", value: email)
|
||||
}
|
||||
}
|
||||
|
||||
if isOwnProfile {
|
||||
// Editable fields
|
||||
Section("Contact") {
|
||||
TextField("Phone", text: $viewModel.phone)
|
||||
.keyboardType(.phonePad)
|
||||
Toggle("Phone visible to contacts", isOn: $viewModel.phoneVisible)
|
||||
|
||||
TextField("Location", text: $viewModel.location)
|
||||
Toggle("Location visible to contacts", isOn: $viewModel.locationVisible)
|
||||
}
|
||||
} else {
|
||||
// Read-only view
|
||||
if let phone = viewModel.profile?.phone, viewModel.profile?.phoneVisible == true {
|
||||
Section("Contact") {
|
||||
LabeledContent("Phone", value: phone)
|
||||
}
|
||||
}
|
||||
if let location = viewModel.profile?.location, viewModel.profile?.locationVisible == true {
|
||||
Section("Location") {
|
||||
LabeledContent("Location", value: location)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(isOwnProfile ? "My Profile" : "Profile")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if isOwnProfile {
|
||||
Button("Save") {
|
||||
Task {
|
||||
await viewModel.saveProfile(chatClient: appState.chatClient)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isSaving)
|
||||
} else {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadProfile(userId: userId, chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user