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

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