eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1import CryptoKit
2import Foundation
3import Security
4
5// πŸ€– The synchronizable DEK item β€” issue #1089's DEK-sync slice: the backup
6// blob's key ALSO stored as an iCloud-Keychain-synchronizable item, so a new
7// device on the same Apple ID can open the vault after one Sign in with Apple
8// sheet β€” no passkey ceremony, no credential manager in the loop.
9//
10// Three invariants, from the design (docs/accounts-and-recovery.md Β§ Sign in
11// with Apple, precisely):
12//
13// 1. **Two items, never a swap.** This is a SECOND item beside the existing
14// device-local session (HomeSessionStore, ThisDeviceOnly) β€” a distinct
15// service name, added by addition (add-or-update, never delete-then-add:
16// a crash between delete and add would lose the key). The device-local
17// item stays the source of truth on this device; the synced item is an
18// accelerator for the NEXT device, never a dependency β€” every failure
19// here falls back to the passkey path exactly as today.
20// 2. **Write-gated on the anchor.** The item is only ever written once the
21// account holds a SiwA anchor β€” a synced DEK without the identity gate
22// would auto-unlock any device on the same Apple ID (the family-iPad
23// case). If the account definitively sheds its last SiwA anchor, the item
24// is cleared (account-wide via iCloud β€” correct: the gate is gone).
25// 3. **Use-gated on a fresh assertion (job 1 gates job 2).** `release` is
26// the ONLY reader of the item, and its only callers run immediately after
27// a fresh SiwA verify β€” the Face ID moment on Apple's sheet. Release
28// additionally requires (a) the resolved account's anchor list to contain
29// the very anchor hash the item was written under, and (b) the DEK to
30// actually open that account's blob β€” cryptographic proof the key belongs
31// to the asserted identity's vault.
32//
33// The distinct service name is load-bearing beyond tidiness: AppReset's
34// sign-out wipe deletes whole services by exact name, and deleting a
35// synchronizable item removes it from EVERY device via iCloud β€” signing out
36// of one Mac must not strip the account's travelling key from the member's
37// other devices. Sign-out therefore leaves this item alone by construction;
38// the use-gate is what keeps a signed-out device's lingering copy inert.
39//
40// Build-flag gated, OFF by default (the device matrix runs before it ships).
41public enum DEKSync {
42 // ---- the flag ------------------------------------------------------------
43
44 static let flagKey = "dek_sync_enabled"
45
46 /// OFF by default. Ships on by defining LCHAT_DEK_SYNC in Package.swift
47 /// (the one-line flip once the device matrix passes). Until then Debug
48 /// builds opt in per device: env LCHAT_DEK_SYNC=1 (a devicectl/scheme
49 /// launch β€” latched into UserDefaults so later icon-tap launches keep it;
50 /// =0 unlatches), or `defaults write <bundle id> dek_sync_enabled -bool
51 /// YES` on the Mac. Resolved once per launch, like HomeBase.
52 public static let enabled: Bool = resolveEnabled()
53
54 private static func resolveEnabled() -> Bool {
55 #if LCHAT_DEK_SYNC
56 return true
57 #elseif DEBUG
58 let defaults = UserDefaults.standard
59 switch ProcessInfo.processInfo.environment["LCHAT_DEK_SYNC"] {
60 case "1": defaults.set(true, forKey: flagKey)
61 case "0": defaults.removeObject(forKey: flagKey)
62 default: break
63 }
64 return defaults.bool(forKey: flagKey)
65 #else
66 return false
67 #endif
68 }
69
70 // ---- the item ------------------------------------------------------------
71
72 /// What syncs: the DEK plus the SiwA anchor hash it was gated on (the
73 /// opaque HMAC-peppered id GET /api/anchors hands the account's own
74 /// client β€” reverses to nothing, and it's exactly what release must
75 /// match). NEVER the link token: tokens are per-device transport auth;
76 /// the releasing device mints its own via the fresh verify.
77 struct Item: Codable, Equatable {
78 var v: Int
79 var dekB64: String // base64url raw DEK bytes (32)
80 var siwaAnchor: String // the anchor id the write-gate saw
81 var credentialID: String // which passkey wraps this DEK (informative)
82 }
83
84 // ---- the write-gate --------------------------------------------------------
85
86 /// Add the synced item if this device's session holds the DEK and the
87 /// account holds a SiwA anchor; clear it if the account definitively
88 /// holds none. Every other outcome (signed out, anchor-only session,
89 /// offline) changes nothing. Called from the session-adoption seam and
90 /// once per launch β€” the launch call is how existing installs migrate by
91 /// addition on their first unlock.
92 public static func writeIfEligible(client: HomeClient = HomeClient()) async {
93 guard enabled else { return }
94 guard let session = HomeSessionStore.load(), session.dek != nil else { return }
95 guard let anchors = try? await client.anchors(token: session.token) else { return }
96 guard let siwa = anchors.first(where: { $0.kind == "siwa" }) else {
97 clear() // the gate is gone β€” the key must not keep travelling
98 return
99 }
100 save(Item(v: 1, dekB64: session.dekB64, siwaAnchor: siwa.id,
101 credentialID: session.credentialID))
102 }
103
104 // ---- the use-gate ----------------------------------------------------------
105
106 /// Release the synced DEK against a FRESH SiwA session token β€” callers
107 /// pass the token `siwaVerify` just returned, never a stored one. Returns
108 /// the upgraded session plus the opened vault, or nil for any reason at
109 /// all (flag off, no item, anchor mismatch, no blob, stale DEK) β€” nil
110 /// always means "carry on exactly as before this feature existed".
111 public static func release(token: String, client: HomeClient = HomeClient())
112 async -> (session: HomeSession, contents: HomeVault.Contents)? {
113 guard enabled, let item = load() else { return nil }
114 guard let anchors = try? await client.anchors(token: token),
115 releasable(item, anchors: anchors) else { return nil }
116 guard let dekRaw = Data(base64urlEncoded: item.dekB64), dekRaw.count == 32 else { return nil }
117 let dek = SymmetricKey(data: dekRaw)
118 guard let fetch = try? await client.fetchBlob(token: token),
119 let contents = try? HomeVault.open(fetch.bytes, dek: dek) else { return nil }
120 return (HomeSession(token: token, credentialID: item.credentialID, dek: dek),
121 contents)
122 }
123
124 /// The pure matching rule: the account the fresh assertion resolved must
125 /// carry the very SiwA anchor the item was written under. A fresh
126 /// account (unknown sub), someone else's account, or an account that
127 /// since shed that anchor all fail here.
128 static func releasable(_ item: Item, anchors: [HomeClient.AnchorEntry]) -> Bool {
129 anchors.contains { $0.kind == "siwa" && $0.id == item.siwaAnchor }
130 }
131
132 // ---- the synchronizable keychain store -------------------------------------
133
134 // Distinct service (see the header) + per-home-base account scoping, like
135 // HomeSessionStore: a DEK minted against the dev home must never release
136 // against production's.
137 private static let service = "com.lchat.client.dek-synced"
138 private static let accessGroup = "J5NDF5PCD8.com.elevenmessenger.messenger.shared"
139 private static var account: String { "dek" + HomeBase.storageSuffix }
140
141 /// Base attributes every call shares. kSecAttrSynchronizable must ride
142 /// EVERY query β€” without it the keychain silently hides synchronizable
143 /// items from matches and deletes.
144 private static func attrs(withGroup: Bool) -> [String: Any] {
145 var q: [String: Any] = [
146 kSecClass as String: kSecClassGenericPassword,
147 kSecAttrService as String: service,
148 kSecAttrAccount as String: account,
149 kSecAttrSynchronizable as String: true,
150 kSecUseDataProtectionKeychain as String: true,
151 ]
152 if withGroup { q[kSecAttrAccessGroup as String] = accessGroup }
153 return q
154 }
155
156 static func load() -> Item? {
157 for withGroup in [true, false] {
158 var query = attrs(withGroup: withGroup)
159 query[kSecReturnData as String] = true
160 query[kSecMatchLimit as String] = kSecMatchLimitOne
161 var out: CFTypeRef?
162 if SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess,
163 let data = out as? Data,
164 let item = try? JSONDecoder().decode(Item.self, from: data) {
165 return item
166 }
167 }
168 return nil
169 }
170
171 /// Add-or-update in place β€” deliberately NOT Keychain.save's
172 /// delete-then-add (a crash in that window loses the key everywhere).
173 /// The shared group first so the Mac app can read what the iPhone wrote;
174 /// ungrouped fallback for ad-hoc dev builds, matching Keychain.swift.
175 @discardableResult
176 static func save(_ item: Item) -> Bool {
177 guard let data = try? JSONEncoder().encode(item) else { return false }
178 for withGroup in [true, false] {
179 var add = attrs(withGroup: withGroup)
180 add[kSecValueData as String] = data
181 // AfterFirstUnlock WITHOUT ThisDeviceOnly β€” the whole point is
182 // that this item may leave the device (via iCloud Keychain only).
183 add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
184 switch SecItemAdd(add as CFDictionary, nil) {
185 case errSecSuccess:
186 return true
187 case errSecDuplicateItem:
188 let update = [kSecValueData as String: data]
189 if SecItemUpdate(attrs(withGroup: withGroup) as CFDictionary,
190 update as CFDictionary) == errSecSuccess {
191 return true
192 }
193 default:
194 break
195 }
196 }
197 return false
198 }
199
200 /// Account-wide removal (iCloud propagates the delete) β€” only for when
201 /// the gate itself is gone. Never called on sign-out.
202 public static func clear() {
203 for withGroup in [true, false] {
204 SecItemDelete(attrs(withGroup: withGroup) as CFDictionary)
205 }
206 }
207}
208