| 1 | import CryptoKit |
| 2 | import Foundation |
| 3 | import 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). |
| 41 | public 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 | |