initial commit

This commit is contained in:
Filip
2026-03-11 16:06:00 +01:00
parent b3c69053f6
commit 5fd80e6dd6
127 changed files with 39684 additions and 0 deletions

View 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()
}

View 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")
}
}
}
}

File diff suppressed because it is too large Load Diff

View 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)
}
}

View 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)
}
}

View 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)"
}
}
}

View 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")
}
}
}

View 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
}
}

View 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)
}
}

View 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)
}
}

View 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
}
}

View 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
}
}

View 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
)
}
}

View 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)
}
}

View 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
}
}

View 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 }
}

View 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
)
}
}

View 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
}

View 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
}

View 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?
}

View 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)
}
}

View 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
}
}

View 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
}

View 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
}
}

View 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
}
}
}

View 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
}
}

View 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
}
}

View 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"
}
}
}

View 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)
}
}

View 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)
}
}

View File

@@ -0,0 +1,4 @@
import SwiftUI
// Registration is handled within LoginView via mode toggle.
// This file exists for potential future separation.

View 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()
}
}
}

View 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)
}
}
}

View 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))
}
}

View 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)
}
}

View 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)
}
}

View File

@@ -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]
}
}

View File

@@ -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"
}
}
}

View File

@@ -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)
)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,4 @@
import SwiftUI
// Group creation is handled within NewConversationSheet via the isGroup toggle.
// This file exists for potential future separation.

View 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)
}
}
}
}
}
}

View 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)
}
}

View File

@@ -0,0 +1,4 @@
import SwiftUI
// Profile editing is handled within ProfileView when isOwnProfile = true.
// This file exists for potential future separation.

View 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)
}
}
}
}

View File

@@ -0,0 +1,239 @@
# iOS Client — Inkrementální sync zpráv
## Problém
Klient při každém otevření konverzace posílá `get_messages` a server vrací 50 zpráv (šifrované bloby + metadata). I když klient 49 z nich už má. Zbytečný přenos dat a zátěž serveru.
## Řešení
Server už podporuje parametr `after_ts` v `get_messages`. Klient si pamatuje timestamp poslední zprávy a posílá jen dotaz na novější.
---
## Protokol — co posílat serveru
### `get_messages` — nový volitelný parametr `after_ts`
**Request:**
```json
{
"type": "get_messages",
"request_id": "uuid",
"conversation_id": "conv-uuid",
"limit": 50,
"offset": 0,
"after_ts": "2026-02-15T22:15:45"
}
```
- `after_ts` (string, ISO 8601, volitelný) — server vrátí jen zprávy s `created_at > after_ts`
- Pokud `after_ts` chybí nebo je null, chová se jako dřív (vrátí posledních `limit` zpráv)
**Response** — beze změny, jen méně zpráv:
```json
{
"type": "get_messages",
"status": "ok",
"data": {
"messages": [...],
"total_count": 123
}
}
```
### `get_deleted_since` — sync smazaných zpráv
Po inkrementálním fetchi je nutné zjistit co bylo smazáno od posledního syncu.
**Request:**
```json
{
"type": "get_deleted_since",
"request_id": "uuid",
"conversation_id": "conv-uuid",
"since": "2026-02-15T22:15:45"
}
```
**Response:**
```json
{
"type": "get_deleted_since",
"status": "ok",
"data": {
"message_ids": ["msg-uuid-1", "msg-uuid-2"]
}
}
```
### `mark_read` — optimalizace
**Request** — beze změny, jen posílat méně ID:
```json
{
"type": "mark_read",
"request_id": "uuid",
"conversation_id": "conv-uuid",
"message_ids": ["only-unread-msg-id-1"]
}
```
Filtrovat na klientovi: jen zprávy kde `sender_id != myId` **a** `myId` není v `read_by`.
---
## Implementace na iOS klientovi
### 1. Lokální cache zpráv
Ukládat dešifrované zprávy na disk per konverzace. Klíč = `message_id`, hodnota = dešifrovaný payload (bez `read_by` — ten se mění).
```swift
// MessageCache.swift nebo rozšíření ChatClient
/// Uložit zprávu do lokální cache
func cacheMessage(convId: String, msgId: String, payload: [String: Any])
/// Načíst cache pro konverzaci [msgId: payload]
func loadCache(convId: String) -> [String: [String: Any]]
/// Smazat zprávu z cache
func removeCachedMessage(convId: String, msgId: String)
```
Formát na disku: JSON soubor v app sandbox, šifrovaný identity key (stejně jako Python klient).
### 2. Logika v `getMessages()`
```
1. Načíst lokální cache pro conv_id
2. Pokud cache je neprázdná A offset == 0:
a. Najít nejnovější created_at v cache → after_ts
b. Poslat get_messages s after_ts (server vrátí jen nové)
c. Dešifrovat nové zprávy, přidat do cache
d. Poslat get_deleted_since s after_ts → smazat z cache
e. Sestavit výsledek z cache (seřadit, vzít posledních limit)
3. Pokud cache je prázdná NEBO offset > 0:
a. Plný fetch jako dřív (bez after_ts)
b. Dešifrovat, uložit do cache
c. Vrátit
4. mark_read: filtrovat jen sender_id != myId a myId not in read_by
```
### 3. Pseudokód
```swift
func getMessages(convId: String, limit: Int = 50, offset: Int = 0) async -> [Message] {
var cache = loadCache(convId: convId)
let myId = userId ?? ""
// Rozhodnout: inkrementální vs plný fetch
var afterTs: String? = nil
if !cache.isEmpty && offset == 0 {
afterTs = cache.values
.compactMap { $0["created_at"] as? String }
.filter { !($0.isEmpty) }
.max()
}
// Fetch ze serveru
var params: [String: Any] = [
"conversation_id": convId,
"limit": limit,
"offset": offset,
]
if let ts = afterTs {
params["after_ts"] = ts
}
let resp = await sendAndReceive(type: "get_messages", params: params)
guard resp.string(for: "status") == "ok",
let data = resp.dict(for: "data"),
let rawMessages = data["messages"] as? [[String: Any]] else {
// Offline fallback vrátit z cache
if !cache.isEmpty && offset == 0 {
return buildFromCache(cache, limit: limit)
}
return []
}
// Dešifrovat nové zprávy (existující logika)
let newMessages = decryptRawMessages(rawMessages, cache: &cache, convId: convId)
// mark_read jen pro nepřečtené
let unreadIds = rawMessages.filter { msg in
let senderId = msg["sender_id"] as? String ?? ""
if senderId == myId { return false }
let readBy = msg["read_by"] as? [[String: Any]] ?? []
return !readBy.contains { ($0["user_id"] as? String) == myId }
}.compactMap { $0["message_id"] as? String }
if !unreadIds.isEmpty {
await markRead(convId: convId, messageIds: unreadIds)
}
if afterTs != nil {
// Inkrementální: sync smazaných
let delResp = await sendAndReceive(type: "get_deleted_since", params: [
"conversation_id": convId,
"since": afterTs!,
])
if let delData = delResp.dict(for: "data"),
let delIds = delData["message_ids"] as? [String] {
for id in delIds {
cache.removeValue(forKey: id)
removeCachedMessage(convId: convId, msgId: id)
}
}
return buildFromCache(cache, limit: limit)
}
return newMessages
}
/// Sestavit seřazený seznam z cache
func buildFromCache(_ cache: [String: [String: Any]], limit: Int) -> [Message] {
var messages: [Message] = []
for (msgId, payload) in cache {
guard payload["_control"] == nil else { continue }
// Vytvořit Message z payload...
messages.append(messageFromPayload(msgId: msgId, payload: payload))
}
messages.sort { $0.createdAt < $1.createdAt }
if messages.count > limit {
messages = Array(messages.suffix(limit))
}
return messages
}
```
### 4. Co se změní v praxi
| Situace | Dřív | Teď |
|---------|------|-----|
| Otevření konverzace kde jsem byl před 5 min | Server vrátí 50 zpráv (vše) | Server vrátí 0-2 nové zprávy |
| Otevření konverzace poprvé | Server vrátí 50 zpráv | Stejné (plný fetch) |
| Load older (scroll nahoru) | Server vrátí 50 starších | Stejné (offset > 0, plný fetch) |
| Po reconnectu | Server vrátí 50 zpráv | Server vrátí jen zprávy od odpojení |
| Offline | Nic (chyba) | Zobrazí cache |
### 5. Metadata (read_by, reactions, pins)
- **read_by** — neukládá se do cache (mění se často). Přichází v reálném čase přes `messages_read` notifikaci. Po reconnectu může být chvilku stale — přijatelné.
- **reactions** — server je vrací u každé zprávy. V cache se ukládají. Aktualizace přes `message_reacted` notifikaci v reálném čase.
- **pins** — stejně jako reactions. `message_pinned`/`message_unpinned` notifikace.
- Po inkrementálním fetchi jsou metadata aktuální jen pro NOVÉ zprávy. Starší mají stav z cache + real-time notifikací. Při plném fetchi (scroll nahoru / první load) jsou vždy aktuální.
### 6. `ChatViewModel.loadMessages` — úprava
```swift
func loadMessages(convId: String, chatClient: ChatClient) async {
isLoading = true
messages = await chatClient.getMessages(convId: convId, limit: 50)
isLoading = false
// mark_read se teď řeší uvnitř getMessages tady nic
updatePinnedBanner()
}
```
`mark_read` volání se přesune z ViewModelu do `getMessages()` v ChatClientu (tam kde má přístup k `read_by` z response).

33
ios_client/project.yml Normal file
View File

@@ -0,0 +1,33 @@
name: EncryptedChat
options:
bundleIdPrefix: com.encryptedchat
deploymentTarget:
iOS: "16.0"
xcodeVersion: "15.0"
generateEmptyDirectories: true
settings:
base:
SWIFT_VERSION: "5.9"
IPHONEOS_DEPLOYMENT_TARGET: "16.0"
ENABLE_PREVIEWS: "YES"
targets:
EncryptedChat:
type: application
platform: iOS
sources:
- path: EncryptedChat
settings:
base:
GENERATE_INFOPLIST_FILE: "YES"
PRODUCT_BUNDLE_IDENTIFIER: com.encryptedchat.app
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: "YES"
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: "YES"
INFOPLIST_KEY_UILaunchScreen_Generation: "YES"
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
INFOPLIST_KEY_NSPhotoLibraryUsageDescription: "Select photos to share in chat"
INFOPLIST_KEY_NSCameraUsageDescription: "Take photos to share in chat"
INFOPLIST_KEY_CFBundleDisplayName: "Encrypted Chat"
INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.social-networking"
CODE_SIGN_STYLE: Automatic

1036
ios_client/v0.8.4_changes.md Normal file

File diff suppressed because it is too large Load Diff