ios_client
This commit is contained in:
3666
ios_client 0.8.5/Kecalek/Core/ChatClient.swift
Normal file
3666
ios_client 0.8.5/Kecalek/Core/ChatClient.swift
Normal file
File diff suppressed because it is too large
Load Diff
485
ios_client 0.8.5/Kecalek/Core/KeyStorage.swift
Normal file
485
ios_client 0.8.5/Kecalek/Core/KeyStorage.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
132
ios_client 0.8.5/Kecalek/Core/KeychainService.swift
Normal file
132
ios_client 0.8.5/Kecalek/Core/KeychainService.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
200
ios_client 0.8.5/Kecalek/Core/MessageCache.swift
Normal file
200
ios_client 0.8.5/Kecalek/Core/MessageCache.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user