ios_client

This commit is contained in:
Filip
2026-03-14 12:43:56 +01:00
parent 5fd80e6dd6
commit 214da18779
74 changed files with 13136 additions and 284 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,485 @@
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 writeProtected(pubData, 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 writeProtected(pubData, 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 writeProtected(Data(spkId.utf8), to: dir.appendingPathComponent("spk_id.txt"))
}
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(data: Data(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 writeProtected(Data(spkId.utf8), to: dir.appendingPathComponent("prev_spk_id.txt"))
}
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(data: Data(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"
}
let exported = try ratchet.exportState()
guard let localKey = localKey else {
throw CryptoError.encryptionFailed("localKey required for session storage")
}
let data = try CryptoUtils.encryptLocal(exported, 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)
}
// Migration: try plaintext, immediately re-encrypt
if let ratchet = try? DoubleRatchet.importState(raw) {
try? writeProtected(CryptoUtils.encryptLocal(try ratchet.exportState(), key: localKey), to: path)
return ratchet
}
// Corrupted delete
try? FileManager.default.removeItem(at: path)
return nil
}
// No localKey refuse to load plaintext sessions
return nil
}
// 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)
guard let localKey = localKey else {
throw CryptoError.encryptionFailed("localKey required for sender key storage")
}
let data = try CryptoUtils.encryptLocal(state.exportState(), 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)
}
// Migration: try plaintext, immediately re-encrypt
if let state = try? SenderKeyState.importState(raw) {
try? writeProtected(CryptoUtils.encryptLocal(state.exportState(), key: localKey), to: path)
return state
}
try? FileManager.default.removeItem(at: path)
return nil
}
return nil
}
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)
guard let localKey = localKey else {
throw CryptoError.encryptionFailed("localKey required for sender key storage")
}
let data = try CryptoUtils.encryptLocal(state.exportState(), 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)
}
// Migration: try plaintext, immediately re-encrypt
if let state = try? SenderKeyState.importState(raw) {
try? writeProtected(CryptoUtils.encryptLocal(state.exportState(), key: localKey), to: path)
return state
}
try? FileManager.default.removeItem(at: path)
return nil
}
return nil
}
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>, localKey: Data? = nil) throws {
let dir = try getKeyDir(email: email)
let jsonData = try JSONSerialization.data(withJSONObject: Array(favorites))
let dataToWrite: Data
if let localKey = localKey {
dataToWrite = try CryptoUtils.encryptLocal(jsonData, key: localKey)
} else {
dataToWrite = jsonData
}
try writeProtected(dataToWrite, to: dir.appendingPathComponent("favorites.json"))
}
static func loadFavorites(email: String, localKey: Data? = nil) -> Set<String> {
guard let dir = try? getKeyDir(email: email) else { return [] }
let path = dir.appendingPathComponent("favorites.json")
guard let raw = try? Data(contentsOf: path) else { return [] }
let jsonData: Data
if let localKey = localKey {
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: localKey) {
jsonData = decrypted
} else {
jsonData = raw // migration fallback
}
} else {
jsonData = raw
}
guard let array = try? JSONSerialization.jsonObject(with: jsonData) as? [String] else {
return []
}
return Set(array)
}
// MARK: - TOFU Identity Key Registry
static func saveKnownIdentityKeys(email: String, keys: [String: [String: String]], localKey: Data?) throws {
let dir = try getKeyDir(email: email)
let jsonObj: [String: Any] = ["version": 1, "keys": keys]
let jsonData = try JSONSerialization.data(withJSONObject: jsonObj)
guard let localKey = localKey else {
try writeProtected(jsonData, to: dir.appendingPathComponent("known_identity_keys.bin"))
return
}
let encrypted = try CryptoUtils.encryptLocal(jsonData, key: localKey)
try writeProtected(encrypted, to: dir.appendingPathComponent("known_identity_keys.bin"))
}
static func loadKnownIdentityKeys(email: String, localKey: Data?) -> [String: [String: String]] {
guard let dir = try? getKeyDir(email: email) else { return [:] }
let path = dir.appendingPathComponent("known_identity_keys.bin")
guard let raw = try? Data(contentsOf: path) else { return [:] }
do {
let jsonData: Data
if let localKey = localKey {
jsonData = try CryptoUtils.decryptLocal(raw, key: localKey)
} else {
jsonData = raw
}
guard let obj = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
let keys = obj["keys"] as? [String: [String: String]] else { return [:] }
return keys
} catch {
return [:]
}
}
// MARK: - Verified Contacts
static func saveVerifiedContacts(email: String, contacts: [String: [String: String]], localKey: Data?) throws {
let dir = try getKeyDir(email: email)
let jsonObj: [String: Any] = ["version": 1, "contacts": contacts]
let jsonData = try JSONSerialization.data(withJSONObject: jsonObj)
guard let localKey = localKey else {
try writeProtected(jsonData, to: dir.appendingPathComponent("verified_contacts.bin"))
return
}
let encrypted = try CryptoUtils.encryptLocal(jsonData, key: localKey)
try writeProtected(encrypted, to: dir.appendingPathComponent("verified_contacts.bin"))
}
static func loadVerifiedContacts(email: String, localKey: Data?) -> [String: [String: String]] {
guard let dir = try? getKeyDir(email: email) else { return [:] }
let path = dir.appendingPathComponent("verified_contacts.bin")
guard let raw = try? Data(contentsOf: path) else { return [:] }
do {
let jsonData: Data
if let localKey = localKey {
jsonData = try CryptoUtils.decryptLocal(raw, key: localKey)
} else {
jsonData = raw
}
guard let obj = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
let contacts = obj["contacts"] as? [String: [String: String]] else { return [:] }
return contacts
} catch {
return [:]
}
}
// MARK: - Helpers
private static func writeProtected(_ data: Data, to url: URL) throws {
try data.write(to: url, options: .completeFileProtection)
}
}

View File

@@ -0,0 +1,132 @@
import Foundation
import Security
import LocalAuthentication
enum KeychainService {
private static let service = "com.encryptedchat.credentials"
private static let account = "userCredentials"
struct Credentials: Codable {
let email: String
let password: String
let host: String
let port: UInt16
}
/// Check if saved credentials exist without triggering biometric prompt.
static func hasSavedCredentials() -> Bool {
let context = LAContext()
context.interactionNotAllowed = true
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecUseAuthenticationContext as String: context
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
// errSecInteractionNotAllowed means item exists but needs biometric
return status == errSecSuccess || status == errSecInteractionNotAllowed
}
/// Save credentials to Keychain with biometric protection.
static func saveCredentials(email: String, password: String, host: String, port: UInt16) throws {
// Delete any existing entry first
deleteCredentials()
let credentials = Credentials(email: email, password: password, host: host, port: port)
let data = try JSONEncoder().encode(credentials)
var accessError: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryAny,
&accessError
) else {
throw KeychainError.accessControlCreationFailed
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessControl as String: accessControl
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
/// Load credentials from Keychain. Triggers biometric prompt.
static func loadCredentials() throws -> Credentials {
let context = LAContext()
context.localizedReason = "Unlock to log in"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecUseAuthenticationContext as String: context
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
if status == errSecUserCanceled || status == errSecAuthFailed {
throw KeychainError.biometricFailed
}
throw KeychainError.loadFailed(status)
}
return try JSONDecoder().decode(Credentials.self, from: data)
}
/// Delete stored credentials from Keychain.
@discardableResult
static func deleteCredentials() -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
/// Check if biometric authentication is available on this device.
static func isBiometricAvailable() -> Bool {
let context = LAContext()
var error: NSError?
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
}
enum KeychainError: LocalizedError {
case accessControlCreationFailed
case saveFailed(OSStatus)
case loadFailed(OSStatus)
case biometricFailed
var errorDescription: String? {
switch self {
case .accessControlCreationFailed:
return "Failed to create biometric access control"
case .saveFailed(let status):
return "Failed to save credentials (error \(status))"
case .loadFailed(let status):
return "Failed to load credentials (error \(status))"
case .biometricFailed:
return "Biometric authentication failed or was cancelled"
}
}
}
}

View File

@@ -0,0 +1,200 @@
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)
guard let cacheKey = cacheKey else {
return // Refuse to save plaintext message cache
}
let dataToWrite = try CryptoUtils.encryptLocal(jsonData, key: cacheKey)
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 if let parsed = try? JSONSerialization.jsonObject(with: raw) as? [[String: Any]] {
// Migration: re-encrypt plaintext cache and return
try? save(email: email, convId: convId, messages: parsed, cacheKey: cacheKey)
return parsed
} else {
// Corrupted delete stale cache
try? FileManager.default.removeItem(at: path)
return nil
}
} 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)
}
// MARK: - Per-Message Cache (for Double Ratchet - messages can only be decrypted once)
/// Cache a decrypted message by its ID
static func cacheDecryptedMessage(email: String, convId: String, messageId: String, plaintext: Data, cacheKey: Data?) {
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return }
let cacheDir = dir.appendingPathComponent("decrypted_messages").appendingPathComponent(convId)
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
let path = cacheDir.appendingPathComponent("\(messageId).bin")
do {
guard let cacheKey = cacheKey else { return } // Refuse plaintext
let dataToWrite = try CryptoUtils.encryptLocal(plaintext, key: cacheKey)
try dataToWrite.write(to: path, options: .completeFileProtection)
} catch {
#if DEBUG
print("DEBUG MessageCache: failed to cache message \(messageId): \(error)")
#endif
}
}
/// Load all cached decrypted messages for a conversation.
/// Returns array of (messageId, plaintext) tuples.
static func loadAllCachedMessages(email: String, convId: String, cacheKey: Data?) -> [(String, Data)] {
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return [] }
let cacheDir = dir.appendingPathComponent("decrypted_messages").appendingPathComponent(convId)
guard let files = try? FileManager.default.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) else { return [] }
var result: [(String, Data)] = []
for file in files where file.pathExtension == "bin" {
let messageId = file.deletingPathExtension().lastPathComponent
guard let raw = try? Data(contentsOf: file) else { continue }
if let cacheKey = cacheKey,
let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) {
result.append((messageId, decrypted))
} else if cacheKey == nil {
result.append((messageId, raw))
}
}
return result
}
/// Get a cached decrypted message by ID
static func getCachedMessage(email: String, convId: String, messageId: String, cacheKey: Data?) -> Data? {
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil }
let path = dir.appendingPathComponent("decrypted_messages").appendingPathComponent(convId).appendingPathComponent("\(messageId).bin")
guard let raw = try? Data(contentsOf: path) else { return nil }
if let cacheKey = cacheKey {
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) {
return decrypted
}
// Migration: try as plaintext, re-encrypt
if let _ = try? JSONSerialization.jsonObject(with: raw) {
cacheDecryptedMessage(email: email, convId: convId, messageId: messageId, plaintext: raw, cacheKey: cacheKey)
return raw
}
// Corrupted delete
try? FileManager.default.removeItem(at: path)
return nil
}
return raw
}
// MARK: - Conversation List Cache
/// Save conversation list to disk (encrypted with local key)
static func saveConversations(email: String, conversations: [Conversation], cacheKey: Data?) {
guard let cacheKey = cacheKey else { return }
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return }
do {
let jsonData = try JSONEncoder().encode(conversations)
let encrypted = try CryptoUtils.encryptLocal(jsonData, key: cacheKey)
try encrypted.write(to: dir.appendingPathComponent("conversation_cache.json"), options: .completeFileProtection)
} catch {
#if DEBUG
print("DEBUG MessageCache: failed to save conversations: \(error)")
#endif
}
}
/// Load conversation list from disk
static func loadConversations(email: String, cacheKey: Data?) -> [Conversation]? {
guard let cacheKey = cacheKey else { return nil }
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil }
let path = dir.appendingPathComponent("conversation_cache.json")
guard let raw = try? Data(contentsOf: path) else { return nil }
guard let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) else { return nil }
return try? JSONDecoder().decode([Conversation].self, from: decrypted)
}
// MARK: - Avatar Disk Cache
/// Save avatar data to disk (encrypted with local key)
static func saveAvatar(email: String, key: String, data: Data, cacheKey: Data?) {
guard let cacheKey = cacheKey else { return }
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return }
let cacheDir = dir.appendingPathComponent("avatar_cache")
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
do {
let encrypted = try CryptoUtils.encryptLocal(data, key: cacheKey)
try encrypted.write(to: cacheDir.appendingPathComponent("\(key).dat"), options: .completeFileProtection)
} catch {
#if DEBUG
print("DEBUG MessageCache: failed to save avatar \(key): \(error)")
#endif
}
}
/// Load avatar data from disk
static func loadAvatar(email: String, key: String, cacheKey: Data?) -> Data? {
guard let cacheKey = cacheKey else { return nil }
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil }
let path = dir.appendingPathComponent("avatar_cache").appendingPathComponent("\(key).dat")
guard let raw = try? Data(contentsOf: path) else { return nil }
return try? CryptoUtils.decryptLocal(raw, key: cacheKey)
}
/// Load all cached avatars from disk
static func loadAllAvatars(email: String, cacheKey: Data?) -> [String: Data] {
guard let cacheKey = cacheKey else { return [:] }
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return [:] }
let cacheDir = dir.appendingPathComponent("avatar_cache")
guard let files = try? FileManager.default.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) else { return [:] }
var result: [String: Data] = [:]
for file in files where file.pathExtension == "dat" {
let key = file.deletingPathExtension().lastPathComponent
guard let raw = try? Data(contentsOf: file),
let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) else { continue }
result[key] = decrypted
}
return result
}
}