| 1 | import Foundation |
| 2 | import Security |
| 3 | |
| 4 | // Keychain-backed JSON storage. The saved instances live here (not UserDefaults) |
| 5 | // because each carries its user *private* key — the "key is load-bearing" rule |
| 6 | // from CLAUDE.md. |
| 7 | // |
| 8 | // Every query sets kSecUseDataProtectionKeychain so Mac Catalyst uses the same |
| 9 | // iOS-style data-protection keychain instead of the legacy file-based macOS |
| 10 | // keychain. Without it the sandboxed Mac app's writes fail silently and nothing |
| 11 | // persists — the saved-chats list comes back empty on every launch. (On iOS the |
| 12 | // flag is a no-op; the data-protection keychain is already the only one.) |
| 13 | public enum Keychain { |
| 14 | private static let service = "com.lchat.client" |
| 15 | // The app-family keychain group (same as SharedThreadKeys): lets the |
| 16 | // share extension read identities to send directly. Writes try the group |
| 17 | // first and fall back without it (ad-hoc dev builds lack the entitlement). |
| 18 | private static let accessGroup = "J5NDF5PCD8.com.elevenmessenger.messenger.shared" |
| 19 | |
| 20 | public static func load<T: Decodable>(_ type: T.Type, account: String) -> T? { |
| 21 | for withGroup in [true, false] { |
| 22 | var query: [String: Any] = [ |
| 23 | kSecClass as String: kSecClassGenericPassword, |
| 24 | kSecAttrService as String: service, |
| 25 | kSecAttrAccount as String: account, |
| 26 | kSecUseDataProtectionKeychain as String: true, |
| 27 | kSecReturnData as String: true, |
| 28 | kSecMatchLimit as String: kSecMatchLimitOne, |
| 29 | ] |
| 30 | if withGroup { query[kSecAttrAccessGroup as String] = accessGroup } |
| 31 | var item: CFTypeRef? |
| 32 | if SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, |
| 33 | let data = item as? Data, |
| 34 | let value = try? JSONDecoder().decode(T.self, from: data) { |
| 35 | return value |
| 36 | } |
| 37 | } |
| 38 | return nil |
| 39 | } |
| 40 | |
| 41 | /// Returns whether the item actually landed in the keychain — ad-hoc dev |
| 42 | /// builds can lack the entitlement the data-protection keychain requires, |
| 43 | /// in which case SecItemAdd fails and callers need a fallback. |
| 44 | @discardableResult |
| 45 | public static func save<T: Encodable>(_ value: T, account: String) -> Bool { |
| 46 | guard let data = try? JSONEncoder().encode(value) else { return false } |
| 47 | let base: [String: Any] = [ |
| 48 | kSecClass as String: kSecClassGenericPassword, |
| 49 | kSecAttrService as String: service, |
| 50 | kSecAttrAccount as String: account, |
| 51 | kSecUseDataProtectionKeychain as String: true, |
| 52 | ] |
| 53 | var grouped = base |
| 54 | grouped[kSecAttrAccessGroup as String] = accessGroup |
| 55 | SecItemDelete(grouped as CFDictionary) |
| 56 | SecItemDelete(base as CFDictionary) |
| 57 | var add = grouped |
| 58 | add[kSecValueData as String] = data |
| 59 | add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly |
| 60 | if SecItemAdd(add as CFDictionary, nil) == errSecSuccess { return true } |
| 61 | add = base |
| 62 | add[kSecValueData as String] = data |
| 63 | add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly |
| 64 | return SecItemAdd(add as CFDictionary, nil) == errSecSuccess |
| 65 | } |
| 66 | |
| 67 | public static func clear(account: String) { |
| 68 | SecItemDelete([ |
| 69 | kSecClass as String: kSecClassGenericPassword, |
| 70 | kSecAttrService as String: service, |
| 71 | kSecAttrAccount as String: account, |
| 72 | kSecUseDataProtectionKeychain as String: true, |
| 73 | ] as CFDictionary) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | // The persisted list of instances (each with its keypair). Keychain-backed because |
| 78 | // it holds the user private keys — and keychain-ONLY for writes: the old |
| 79 | // fallback wrote the list (private keys included) as plaintext JSON to |
| 80 | // Application Support when a dev build lacked the data-protection entitlement, |
| 81 | // which violates "no key material on disk in the clear". A failed keychain |
| 82 | // save now simply doesn't persist — the Mac re-extracts identity from the |
| 83 | // webview session on next launch, the honest degradation. The legacy file |
| 84 | // stays READABLE so existing installs migrate: the first successful keychain |
| 85 | // save deletes it. |
| 86 | public enum InstancesStore { |
| 87 | private static let account = "instances" |
| 88 | |
| 89 | public static func load() -> [SavedInstance] { |
| 90 | Keychain.load([SavedInstance].self, account: account) ?? fileLoad() ?? [] |
| 91 | } |
| 92 | |
| 93 | public static func save(_ instances: [SavedInstance]) { |
| 94 | if Keychain.save(instances, account: account) { |
| 95 | try? FileManager.default.removeItem(at: fileURL) // migrate: no stale plaintext copy |
| 96 | } |
| 97 | // No file fallback — private keys never touch disk unencrypted. |
| 98 | } |
| 99 | |
| 100 | private static var fileURL: URL { |
| 101 | (FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first |
| 102 | ?? URL(fileURLWithPath: NSTemporaryDirectory())) |
| 103 | .appendingPathComponent("lchat/instances.json") |
| 104 | } |
| 105 | |
| 106 | private static func fileLoad() -> [SavedInstance]? { |
| 107 | guard let data = try? Data(contentsOf: fileURL) else { return nil } |
| 108 | return try? JSONDecoder().decode([SavedInstance].self, from: data) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | // 🤖 The last folded thread list per space, so re-entering one renders its |
| 113 | // chats instantly (ThreadStore.hydrate) instead of waiting on the network — |
| 114 | // loading is a first-visit-only state. Keychain, not a file: the rows carry |
| 115 | // unwrapped thread keys and decrypted titles/previews — the SharedThreadKeys |
| 116 | // posture. Presence dots are stripped on save (they're live-only state). |
| 117 | public enum ThreadListSnapshot { |
| 118 | public struct Snap: Codable { |
| 119 | public var name: String // the space's group name (title) |
| 120 | public var rows: [ChatThread] |
| 121 | } |
| 122 | |
| 123 | // Refresh refires on every socket frame; skip the (5-50ms) Keychain write |
| 124 | // when nothing changed — the SharedThreadKeys fingerprint trick. |
| 125 | private static var savedFingerprints: [String: Int] = [:] |
| 126 | private static let lock = NSLock() |
| 127 | |
| 128 | private static func account(_ origin: String) -> String { "threads-snapshot|\(origin)" } |
| 129 | |
| 130 | public static func load(origin: String) -> Snap? { |
| 131 | Keychain.load(Snap.self, account: account(origin)) |
| 132 | } |
| 133 | |
| 134 | /// Call off the main thread (hydrate/refresh already do). |
| 135 | public static func save(origin: String, name: String, rows: [ChatThread]) { |
| 136 | var snap = Snap(name: name, rows: rows) |
| 137 | for i in snap.rows.indices { snap.rows[i].presence = nil } |
| 138 | guard let data = try? JSONEncoder().encode(snap) else { return } |
| 139 | let fp = data.hashValue |
| 140 | lock.lock() |
| 141 | let unchanged = savedFingerprints[origin] == fp |
| 142 | if !unchanged { savedFingerprints[origin] = fp } |
| 143 | lock.unlock() |
| 144 | guard !unchanged else { return } |
| 145 | Keychain.save(snap, account: account(origin)) |
| 146 | } |
| 147 | |
| 148 | public static func clear(origin: String) { |
| 149 | Keychain.clear(account: account(origin)) |
| 150 | lock.lock(); savedFingerprints[origin] = nil; lock.unlock() |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | // 🤖 The most recent chat the member had open — the share extension's |
| 155 | // "Suggested" row. Kept in the shared-keychain group (NOT app-group defaults) |
| 156 | // because the decrypted title is user content: same posture as |
| 157 | // SharedThreadKeys, which already parks decrypted titles for the NSE. |
| 158 | public enum LastOpenedChat { |
| 159 | public struct Entry: Codable, Equatable { |
| 160 | public let origin: String |
| 161 | public let thread: String |
| 162 | public let title: String |
| 163 | } |
| 164 | |
| 165 | // A Keychain write costs 5-50ms and note() runs at the exact moment a chat |
| 166 | // opens — the one frame that must not stall. Skip re-noting the same chat |
| 167 | // and do the real write off the main thread (Keychain is thread-safe; the |
| 168 | // same treatment SharedThreadKeys got). #465. |
| 169 | private static let writeQueue = DispatchQueue(label: "last-opened-chat", qos: .utility) |
| 170 | private static var lastNoted: Entry? // touched only on writeQueue |
| 171 | |
| 172 | public static func note(origin: String, thread: String, title: String) { |
| 173 | let entry = Entry(origin: origin, thread: thread, title: title) |
| 174 | writeQueue.async { |
| 175 | guard entry != lastNoted else { return } |
| 176 | lastNoted = entry |
| 177 | Keychain.save(entry, account: "last-opened-chat") |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | public static func load() -> Entry? { |
| 182 | Keychain.load(Entry.self, account: "last-opened-chat") |
| 183 | } |
| 184 | } |
| 185 | |