eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import Security
3
4// Thread keys + display titles shared with the notification extension: the APNs
5// payload is ciphertext (the server never sees plaintext), so the NSE decrypts
6// on-device with the key stored here. The app refreshes entries whenever a
7// ThreadStore loads; the NSE only reads.
8//
9// Storage is the keychain (they're message keys), accessible after first unlock
10// so a notification arriving while the phone is locked can still be decrypted.
11// On properly-signed builds both targets opt into the same keychain access
12// group; ad-hoc/dev builds can't mint that entitlement, so writes retry without
13// the group (simulator keychains aren't segmented, which is what makes the
14// simulator end-to-end push test work).
15public enum SharedThreadKeys {
16 public struct Entry: Codable {
17 public let key: Data // raw AES-GCM thread key
18 public let title: String // decrypted chat title (never stored server-side)
19 public let origin: String // owning instance, for notification → chat routing
20 public let group: Bool // groups prefix the sender name in the banner
21 // This user's uid on the owning instance: the NSE compares it to a
22 // reaction event's targetUid to show "X reacted" only to the reacted-to
23 // message's author. Optional so entries parked by older builds still
24 // decode (they fall back to the quiet non-author path).
25 public var myUid: String?
26 // This thread's members, uid → the name THIS member shows for them
27 // (per-viewer names resolved, #577) — the NSE names senders with it
28 // instead of the payload's snapshot. Optional: older entries decode
29 // and fall back to the snapshot.
30 public var names: [String: String]?
31 // This user muted the chat (#578): the NSE delivers its pushes as
32 // passive, silent entries. Belt-and-braces — the server already skips
33 // pushes for muted threads; this covers entries parked before an
34 // unmute raced a stale park, and older servers. Optional so entries
35 // parked by older builds decode as unmuted.
36 public var muted: Bool?
37 }
38
39 private static let service = "com.lchat.client.threadkeys"
40 // TeamID prefix + shared suffix; only valid with the keychain-sharing
41 // entitlement (paid signing). See the go-live notes in ios/README.md.
42 private static let accessGroup = "J5NDF5PCD8.com.elevenmessenger.messenger.shared"
43
44 // Every ThreadStore.refresh re-parks every thread — but a Keychain write
45 // costs 5-50ms ON MAIN, so a busy instance stalled scrolling for hundreds
46 // of ms per refresh. Skip unchanged payloads (the overwhelmingly common
47 // case) and do the real writes off the main thread (Keychain is
48 // thread-safe).
49 private static var savedFingerprints: [String: Int] = [:]
50 private static let fingerprintLock = NSLock()
51 private static let writeQueue = DispatchQueue(label: "shared-thread-keys", qos: .utility)
52
53 public static func save(threadID: String, entry: Entry) {
54 guard let data = try? JSONEncoder().encode(entry) else { return }
55 let fingerprint = data.hashValue
56 fingerprintLock.lock()
57 let unchanged = savedFingerprints[threadID] == fingerprint
58 if !unchanged { savedFingerprints[threadID] = fingerprint }
59 fingerprintLock.unlock()
60 if unchanged { return }
61 writeQueue.async { write(threadID: threadID, data: data) }
62 }
63
64 private static func write(threadID: String, data: Data) {
65 var base: [String: Any] = [
66 kSecClass as String: kSecClassGenericPassword,
67 kSecAttrService as String: service,
68 kSecAttrAccount as String: threadID,
69 kSecUseDataProtectionKeychain as String: true,
70 kSecAttrAccessGroup as String: accessGroup,
71 ]
72 base[kSecAttrAccount as String] = threadID
73 var add = base
74 add[kSecValueData as String] = data
75 add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
76 SecItemDelete(base as CFDictionary)
77 if SecItemAdd(add as CFDictionary, nil) == errSecSuccess { return }
78 // No sharing entitlement (dev build): keep it app-local so the rest of
79 // the flow still works; the NSE then falls back to the generic banner.
80 base.removeValue(forKey: kSecAttrAccessGroup as String)
81 add.removeValue(forKey: kSecAttrAccessGroup as String)
82 SecItemDelete(base as CFDictionary)
83 SecItemAdd(add as CFDictionary, nil)
84 }
85
86 public static func lookup(threadID: String) -> Entry? {
87 for withGroup in [true, false] {
88 var query: [String: Any] = [
89 kSecClass as String: kSecClassGenericPassword,
90 kSecAttrService as String: service,
91 kSecAttrAccount as String: threadID,
92 kSecUseDataProtectionKeychain as String: true,
93 kSecReturnData as String: true,
94 kSecMatchLimit as String: kSecMatchLimitOne,
95 ]
96 if withGroup { query[kSecAttrAccessGroup as String] = accessGroup }
97 var item: CFTypeRef?
98 if SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
99 let data = item as? Data,
100 let entry = try? JSONDecoder().decode(Entry.self, from: data) {
101 return entry
102 }
103 }
104 return nil
105 }
106}
107
108// The muted-people set (#578), parked for the notification extension the way
109// SharedThreadKeys parks thread keys: the NSE decides AFTER decrypting whether
110// the sender is muted (the uid rides inside the ciphertext, so the server —
111// which never learns who's muted — can't skip these pushes for us), and goes
112// passive when they are. One entry per instance origin; plaintext uids are
113// fine here — the shared keychain is the same trust domain as the parked
114// thread keys beside them. Refreshed whenever the prefs blob loads or saves.
115public enum SharedMutedPeople {
116 private static let service = "com.lchat.client.mutedpeople"
117 private static let accessGroup = "J5NDF5PCD8.com.elevenmessenger.messenger.shared"
118
119 // Same skip-unchanged + off-main write posture as SharedThreadKeys —
120 // saves fire on every prefs load and a Keychain write costs main-thread ms.
121 private static var savedFingerprints: [String: Int] = [:]
122 private static let fingerprintLock = NSLock()
123 private static let writeQueue = DispatchQueue(label: "shared-muted-people", qos: .utility)
124
125 public static func save(origin: String, uids: Set<String>) {
126 guard let data = try? JSONEncoder().encode(uids.sorted()) else { return }
127 let fingerprint = data.hashValue
128 fingerprintLock.lock()
129 let unchanged = savedFingerprints[origin] == fingerprint
130 if !unchanged { savedFingerprints[origin] = fingerprint }
131 fingerprintLock.unlock()
132 if unchanged { return }
133 writeQueue.async { write(origin: origin, data: data) }
134 }
135
136 private static func write(origin: String, data: Data) {
137 var base: [String: Any] = [
138 kSecClass as String: kSecClassGenericPassword,
139 kSecAttrService as String: service,
140 kSecAttrAccount as String: origin,
141 kSecUseDataProtectionKeychain as String: true,
142 kSecAttrAccessGroup as String: accessGroup,
143 ]
144 var add = base
145 add[kSecValueData as String] = data
146 add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
147 SecItemDelete(base as CFDictionary)
148 if SecItemAdd(add as CFDictionary, nil) == errSecSuccess { return }
149 // No sharing entitlement (dev build): keep it app-local; the NSE then
150 // simply sees nobody muted — the banner degrades, never errors.
151 base.removeValue(forKey: kSecAttrAccessGroup as String)
152 add.removeValue(forKey: kSecAttrAccessGroup as String)
153 SecItemDelete(base as CFDictionary)
154 SecItemAdd(add as CFDictionary, nil)
155 }
156
157 public static func lookup(origin: String) -> Set<String> {
158 for withGroup in [true, false] {
159 var query: [String: Any] = [
160 kSecClass as String: kSecClassGenericPassword,
161 kSecAttrService as String: service,
162 kSecAttrAccount as String: origin,
163 kSecUseDataProtectionKeychain as String: true,
164 kSecReturnData as String: true,
165 kSecMatchLimit as String: kSecMatchLimitOne,
166 ]
167 if withGroup { query[kSecAttrAccessGroup as String] = accessGroup }
168 var item: CFTypeRef?
169 if SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
170 let data = item as? Data,
171 let uids = try? JSONDecoder().decode([String].self, from: data) {
172 return Set(uids)
173 }
174 }
175 return []
176 }
177}
178