1// MessageStore.swift2// SophaxChatCore3//4// Encrypted at-rest message storage.5//6// Architecture:7// • One JSON file per conversation (named by peerID hash)8// • Each file is encrypted with AES-256-GCM using a per-app master key9// • Master key is stored in Keychain (kSecAttrAccessibleWhenUnlockedThisDeviceOnly)10// • Files are stored in the app's private Application Support directory11// (not accessible to other apps, not backed up if configured so)12//13// Security note: This provides "encryption at rest" — even if the filesystem14// is read by an attacker (e.g., physical access with device unlocked), the15// messages cannot be read without the Keychain-protected master key.16// For maximum security, enable "Full-disk encryption" (default on modern iOS).1718import Foundation19import CryptoKit2021// Threading contract: all public methods must be called from a single thread or22// under external mutual exclusion. ChatManager owns the sole instance and accesses23// it on the main thread. The internal `queue` is used only for async disk I/O.24public final class MessageStore:@unchecked Sendable {2526 private let storageKey: SymmetricKey27 private let baseURL: URL28 private let queue = DispatchQueue(label:"com.sophax.messagestore", qos:.userInitiated)2930// In-memory cache to avoid redundant decrypt operations31 private var cache:[String:[StoredMessage]]=[:]3233// MARK: - Init3435 public init(keychain: KeychainManager) throws {36// Load or generate the master storage key37 if let existing = try? keychain.loadStorageKey(){38 self.storageKey = existing39} else {40 let newKey = SymmetricKey(size:.bits256)41 try keychain.saveStorageKey(newKey)42 self.storageKey = newKey43}4445// Use Application Support directory (excluded from iCloud backup by default)46 guard let appSupport = FileManager.default.urls(47 for:.applicationSupportDirectory,48 in:.userDomainMask49).first else {50 throw SophaxError.invalidMessageFormat("Application Support directory unavailable")51}52 self.baseURL = appSupport.appendingPathComponent("sophax_messages", isDirectory: true)5354 try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)5556// Exclude from iCloud backup57 var resourceValues = URLResourceValues()58 resourceValues.isExcludedFromBackup = true59 var mutableURL = baseURL60 try? mutableURL.setResourceValues(resourceValues)6162// Set strongest file protection on the directory — .complete means the file is63// inaccessible while the device is locked (not just after first unlock).64// The iOS default (.completeUntilFirstUserAuthentication) would allow reading65// encrypted files in the locked state, which is unnecessary given we have our66// own AES-256-GCM layer. Defense-in-depth: double-encrypt with OS protection too.67 try? FileManager.default.setAttributes(68[.protectionKey: FileProtectionType.complete],69 ofItemAtPath: baseURL.path70)71}7273// MARK: - Public API7475/// Load all messages for a conversation.76 public func messages(forPeer peerID: String) throws ->[StoredMessage]{77 if let cached = cache[peerID]{ return cached }78 let messages = try loadFromDisk(peerID: peerID)79 cache[peerID]= messages80 return messages81}8283/// Append a single message to a conversation.84/// Idempotent: silently ignores duplicates (same message ID) to handle relay replays.85 public func append(message: StoredMessage) throws {86 var messages =(try? messages(forPeer: message.peerID))??[]87 guard !messages.contains(where:{ $0.id == message.id }) else { return }88 messages.append(message)89 cache[message.peerID]= messages90 try saveToDisk(messages: messages, peerID: message.peerID)91}9293/// Update the emoji reactions map for a specific message.94 public func updateReactions(_ reactions:[String: String], forMessageID messageID: String, peerID: String) throws {95 try mutate(peerID: peerID, messageID: messageID){ msgs, idx in96 msgs[idx].reactions = reactions.isEmpty ? nil : reactions97}98}99100/// Append `peerID` to the `deliveredBy` set of a group message. Idempotent.101 public func addDeliveredBy(_ delivererID: String, forMessageID messageID: String, convID: String) throws {102 try mutate(peerID: convID, messageID: messageID){ msgs, idx in103 var set = msgs[idx].deliveredBy ??[]104 guard !set.contains(delivererID) else { return }105 set.append(delivererID)106 msgs[idx].deliveredBy = set107}108}109110/// Append `readerID` to the `readBy` set of a group message. Idempotent.111 public func addReadBy(_ readerID: String, forMessageID messageID: String, convID: String) throws {112 try mutate(peerID: convID, messageID: messageID){ msgs, idx in113 var set = msgs[idx].readBy ??[]114 guard !set.contains(readerID) else { return }115 set.append(readerID)116 msgs[idx].readBy = set117}118}119120/// Update the body text of a message, marking it as edited.121 public func updateMessage(id: String, peerID: String, newBody: String, editedAt: Date) throws {122 try mutate(peerID: peerID, messageID: id){ msgs, idx in123 let old = msgs[idx]124 msgs[idx]= StoredMessage(125 id: old.id, peerID: old.peerID, direction: old.direction,126 body: newBody, timestamp: old.timestamp, status: old.status,127 replyToID: old.replyToID, expiresAt: old.expiresAt, hopCount: old.hopCount,128 attachmentID: old.attachmentID, attachmentMimeType: old.attachmentMimeType,129 attachmentFilename: old.attachmentFilename, audioDuration: old.audioDuration,130 reactions: old.reactions, senderID: old.senderID, receivedAt: old.receivedAt,131 deliveredBy: old.deliveredBy, isEdited: true, editedAt: editedAt132)133}134}135136/// Update message status (sent → delivered, sending → failed, etc.).137 public func updateStatus(_ status: StoredMessage.MessageStatus, forMessageID messageID: String, peerID: String) throws {138 try mutate(peerID: peerID, messageID: messageID){ msgs, idx in139 msgs[idx].status = status140}141}142143/// All conversation peer IDs that have stored messages.144 public func allConversationPeerIDs()->[String]{145 guard let files = try? FileManager.default.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil) else {146 return []147}148 return files.map { $0.deletingPathExtension().lastPathComponent }149}150151/// Delete all messages whose `expiresAt` timestamp is in the past.152/// Called periodically by ChatManager (every 60 s) and on app launch.153 public func deleteExpiredMessages(){154 let now = Date()155 let peerIDs = allConversationPeerIDs()156 for peerID in peerIDs {157 guard var messages = try? self.messages(forPeer: peerID) else { continue }158 let before = messages.count159 messages.removeAll { msg in160 guard let exp = msg.expiresAt else { return false }161 return exp < now162}163 guard messages.count != before else { continue }164 cache[peerID]= messages165 try? saveToDisk(messages: messages, peerID: peerID)166}167}168169/// Delete a single message by ID.170 public func deleteMessage(id: String, peerID: String) throws {171 var messages =(try? self.messages(forPeer: peerID))??[]172 messages.removeAll { $0.id == id }173 cache[peerID]= messages174 if messages.isEmpty {175 try? FileManager.default.removeItem(at: fileURL(for: peerID))176} else {177 try saveToDisk(messages: messages, peerID: peerID)178}179}180181/// Delete all messages for a conversation.182 public func deleteConversation(peerID: String) throws {183 let url = fileURL(for: peerID)184 cache.removeValue(forKey: peerID)185 try? FileManager.default.removeItem(at: url)186}187188/// Encrypt and write arbitrary data to a named file in the same directory.189/// Used by ChatManager to persist the offline pending queue.190 public func saveEncryptedBlob(_ data: Data, fileName: String) throws {191 let url = baseURL.appendingPathComponent("\(fileName).enc")192 let encrypted = try encrypt(data)193 try encrypted.write(to: url, options:.atomic)194}195196/// Load and decrypt an arbitrary blob previously saved with saveEncryptedBlob.197/// Returns nil if the file does not exist.198 public func loadEncryptedBlob(fileName: String)-> Data?{199 let url = baseURL.appendingPathComponent("\(fileName).enc")200 guard FileManager.default.fileExists(atPath: url.path),201 let encrypted = try? Data(contentsOf: url),202 let plain = try? decrypt(encrypted) else { return nil }203 return plain204}205206/// Wipe ALL stored messages.207 public func wipeAll() throws {208 cache.removeAll()209 let files = try FileManager.default.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil)210 for file in files {211 try FileManager.default.removeItem(at: file)212}213}214215// MARK: - Private: Mutation helper216217/// Load messages for `peerID`, find the message by ID, apply `body`, then cache and save.218 private func mutate(peerID: String, messageID: String, _ body:(inout [StoredMessage], Int) throws -> Void) throws {219 var messages =(try? self.messages(forPeer: peerID))??[]220 guard let idx = messages.firstIndex(where:{ $0.id == messageID }) else { return }221 try body(&messages, idx)222 cache[peerID]= messages223 try saveToDisk(messages: messages, peerID: peerID)224}225226// MARK: - Private: Disk I/O with encryption227228 private func loadFromDisk(peerID: String) throws ->[StoredMessage]{229 let url = fileURL(for: peerID)230 guard FileManager.default.fileExists(atPath: url.path) else {231 return []232}233 let encryptedData = try Data(contentsOf: url)234 let plaintext = try decrypt(encryptedData)235 return try JSONDecoder().decode([StoredMessage].self, from: plaintext)236}237238 private func saveToDisk(messages:[StoredMessage], peerID: String) throws {239 let plaintext = try JSONEncoder().encode(messages)240 let encryptedData = try encrypt(plaintext)241 let url = fileURL(for: peerID)242 try encryptedData.write(to: url, options:.atomic)243// Ensure each written file also carries .complete protection (the directory244// attribute is inherited by new files, but setting explicitly is more reliable).245 try? FileManager.default.setAttributes(246[.protectionKey: FileProtectionType.complete],247 ofItemAtPath: url.path248)249}250251// MARK: - Private: Encryption helpers252253/// AES-256-GCM encrypt with the master storage key.254/// Output format: 12-byte nonce || ciphertext || 16-byte tag (combined)255 private func encrypt(_ plaintext: Data) throws -> Data {256 let sealed = try AES.GCM.seal(plaintext, using: storageKey)257 guard let combined = sealed.combined else {258 throw SophaxError.encryptionFailed("AES-GCM combined output unavailable")259}260 return combined261}262263/// AES-256-GCM decrypt with the master storage key.264 private func decrypt(_ data: Data) throws -> Data {265 do {266 let sealedBox = try AES.GCM.SealedBox(combined: data)267 return try AES.GCM.open(sealedBox, using: storageKey)268} catch {269 throw SophaxError.decryptionFailed270}271}272273// MARK: - Private: File URL274275 private func fileURL(for peerID: String)-> URL {276// Allow alphanumeric, hyphens, and dots (for "group." prefix)277 let safeID = peerID.filter { $0.isLetter || $0.isNumber || $0 =="-"|| $0 =="."}278 return baseURL.appendingPathComponent("\(safeID).enc")279}280}