1// AttachmentStore.swift2// SophaxChatCore3//4// Encrypted at-rest storage for binary attachments (images, audio).5//6// Architecture: same as MessageStore — AES-256-GCM with the shared Keychain master key.7// Files stored in Application Support/sophax_attachments/, excluded from iCloud backup.8// Each attachment is a separate file named by its UUID.910import Foundation11import CryptoKit1213// Threading contract: all public methods must be called from a single thread or14// under external mutual exclusion. ChatManager owns the sole instance and accesses15// it on the main thread (or within a locked section).16public final class AttachmentStore:@unchecked Sendable {1718 private let storageKey: SymmetricKey19 private let baseURL: URL2021// MARK: - Init2223 public init(keychain: KeychainManager) throws {24// Share the master storage key with MessageStore25 if let existing = try? keychain.loadStorageKey(){26 self.storageKey = existing27} else {28 let newKey = SymmetricKey(size:.bits256)29 try keychain.saveStorageKey(newKey)30 self.storageKey = newKey31}3233 guard let appSupport = FileManager.default.urls(34 for:.applicationSupportDirectory,35 in:.userDomainMask36).first else {37 throw SophaxError.invalidMessageFormat("Application Support directory unavailable")38}39 self.baseURL = appSupport.appendingPathComponent("sophax_attachments", isDirectory: true)4041 try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)4243 var resourceValues = URLResourceValues()44 resourceValues.isExcludedFromBackup = true45 var mutableURL = baseURL46 try? mutableURL.setResourceValues(resourceValues)4748// .complete = file inaccessible while device is locked (strongest protection level).49// Defence-in-depth on top of AES-256-GCM application-level encryption.50 try? FileManager.default.setAttributes(51[.protectionKey: FileProtectionType.complete],52 ofItemAtPath: baseURL.path53)54}5556// MARK: - Public API5758/// Encrypt and save attachment data under a stable UUID.59 public func save(_ data: Data, id: String) throws {60 let sealed = try AES.GCM.seal(data, using: storageKey)61 guard let combined = sealed.combined else {62 throw SophaxError.encryptionFailed("AES-GCM combined output unavailable")63}64 let url = fileURL(for: id)65 try combined.write(to: url, options:.atomic)66 try? FileManager.default.setAttributes(67[.protectionKey: FileProtectionType.complete],68 ofItemAtPath: url.path69)70}7172/// Decrypt and return attachment data for the given ID.73 public func load(id: String) throws -> Data {74 let encryptedData = try Data(contentsOf: fileURL(for: id))75 do {76 let sealedBox = try AES.GCM.SealedBox(combined: encryptedData)77 return try AES.GCM.open(sealedBox, using: storageKey)78} catch {79 throw SophaxError.decryptionFailed80}81}8283/// Delete a single attachment file.84 public func delete(id: String){85 try? FileManager.default.removeItem(at: fileURL(for: id))86}8788/// Delete every attachment file in the store. Used for full account wipe.89 public func wipeAll() throws {90 let files = try FileManager.default.contentsOfDirectory(91 at: baseURL, includingPropertiesForKeys: nil92)93 for file in files {94 try FileManager.default.removeItem(at: file)95}96}9798 public func exists(id: String)-> Bool {99 FileManager.default.fileExists(atPath: fileURL(for: id).path)100}101102// MARK: - Private103104 private func fileURL(for id: String)-> URL {105 let safeID = id.filter { $0.isHexDigit || $0 =="-"}106 return baseURL.appendingPathComponent(safeID)107}108}