| 1 | import CryptoKit |
| 2 | import Foundation |
| 3 | |
| 4 | // π€ Keeping the device's server list and the home backup in lockstep. |
| 5 | // |
| 6 | // After a sign-in the app holds a HomeSession β the home LINK TOKEN (bearer; |
| 7 | // auth only) plus the blob DEK β persisted in the Keychain so routine changes |
| 8 | // (star, reorder, add) sync without re-running Face ID each time. Storing the |
| 9 | // DEK adds no new exposure class: it decrypts the backup blob, whose contents |
| 10 | // (each server's credentials) already live in this same Keychain via |
| 11 | // InstancesStore. Neither the DEK nor anything it opens ever leaves the device; |
| 12 | // the server still only ever sees the sealed envelope. |
| 13 | // |
| 14 | // Sync model (v1, deliberately simple): |
| 15 | // - PULL (sign-in): the vault wins β its membership, order and stars are applied |
| 16 | // to the device; device-only servers are kept, tailed, and pushed back up. |
| 17 | // - PUSH (any local change): the device's list wins for the servers it knows β |
| 18 | // order, stars, names, credentials are upserted; vault-only entries (spaces |
| 19 | // saved on another device or by URL alone) are preserved, tailed in their |
| 20 | // relative order. Optimistic concurrency: one refetch+reapply on a 409. |
| 21 | // - Nothing is ever deleted from the vault by sync. Removing a space from the |
| 22 | // backup is an explicit act on the home page, not a side effect of removing |
| 23 | // it from one device. |
| 24 | // - But a removal made THERE must stick: the home page leaves a tombstone |
| 25 | // (HomeVault.Tombstone), which push honours (never re-upsert a copy older |
| 26 | // than its tombstone; a deliberate re-add clears it) and pull applies (drop |
| 27 | // the local copy). Without this, any signed-in device resurrected deleted |
| 28 | // entries on its next sync. |
| 29 | public struct HomeSession: Codable { |
| 30 | public var token: String // home link token β transport auth, NOT a key |
| 31 | public var credentialID: String // which enrolled passkey wraps our DEK |
| 32 | public var dekB64: String // the backup blob's DEK (base64url raw bytes) |
| 33 | |
| 34 | public var dek: SymmetricKey? { |
| 35 | guard !dekB64.isEmpty else { return nil } // anchor sessions carry no DEK |
| 36 | return Data(base64urlEncoded: dekB64).map(SymmetricKey.init(data:)) |
| 37 | } |
| 38 | |
| 39 | public init(token: String, credentialID: String, dek: SymmetricKey) { |
| 40 | self.token = token |
| 41 | self.credentialID = credentialID |
| 42 | self.dekB64 = dek.withUnsafeBytes { Data($0) }.base64urlEncodedString() |
| 43 | } |
| 44 | |
| 45 | /// An anchor session (Sign in with Apple / an email code): the link |
| 46 | /// token alone. It authenticates every home call, but no passkey PRF was |
| 47 | /// in play, so there is no DEK β vault sync parks (pull/push quietly |
| 48 | /// no-op on the nil dek) until a passkey sign-in upgrades this session |
| 49 | /// in place. |
| 50 | public init(token: String) { |
| 51 | self.token = token |
| 52 | self.credentialID = "" |
| 53 | self.dekB64 = "" |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | public enum HomeSessionStore { |
| 58 | // Scoped by the resolved home base: production keeps the account name it |
| 59 | // has always had, an overridden base (Debug-only, HomeBase) gets its own β |
| 60 | // the dev home's accounts are entirely separate, so a token/DEK minted |
| 61 | // against one base must never be loaded against another. |
| 62 | static var account: String { "lchat.home.session" + HomeBase.storageSuffix } |
| 63 | |
| 64 | public static func load() -> HomeSession? { |
| 65 | Keychain.load(HomeSession.self, account: account) |
| 66 | } |
| 67 | |
| 68 | @discardableResult |
| 69 | public static func save(_ session: HomeSession) -> Bool { |
| 70 | Keychain.save(session, account: account) |
| 71 | } |
| 72 | |
| 73 | public static func clear() { |
| 74 | Keychain.clear(account: account) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | public enum HomeSync { |
| 79 | /// Fire-and-forget push for UI mutation paths: no-op unless signed in. |
| 80 | /// Serialised on a single task chain so two quick star-taps can't interleave |
| 81 | /// their read-modify-write cycles and drop one. |
| 82 | public static func pushSoon(instances: [SavedInstance]) { |
| 83 | guard let session = HomeSessionStore.load() else { return } |
| 84 | queue = (queue ?? Task {}).then { |
| 85 | await push(instances: instances, session: session) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | private static var queue: Task<Void, Never>? |
| 90 | |
| 91 | /// Silent vault fetch with the stored session (no ceremony β the DEK is |
| 92 | /// all it needs): the foreground pull that carries changes made on the |
| 93 | /// home page (or another device) INTO this one. Returns nil when signed |
| 94 | /// out, offline, or the blob is gone. Rate-limited so scene-phase churn |
| 95 | /// doesn't hammer home. |
| 96 | public static func pull(minInterval: TimeInterval = 20) async -> HomeVault.Contents? { |
| 97 | guard let session = HomeSessionStore.load(), let dek = session.dek else { return nil } |
| 98 | if let last = lastPull, Date().timeIntervalSince(last) < minInterval { return nil } |
| 99 | lastPull = Date() |
| 100 | guard let fetch = try? await HomeClient().fetchBlob(token: session.token), |
| 101 | let contents = try? HomeVault.open(fetch.bytes, dek: dek) else { return nil } |
| 102 | return contents |
| 103 | } |
| 104 | |
| 105 | private static var lastPull: Date? |
| 106 | |
| 107 | /// Upsert the device's list (order + stars + credentials) into the vault. |
| 108 | /// Works on the RAW JSON object and patches only the fields this build |
| 109 | /// owns, so anything a newer web/app build stored β unknown top-level |
| 110 | /// keys, per-server or per-credentials fields β rides through untouched |
| 111 | /// (the web spread-merges the same way; a strict-Codable re-encode here |
| 112 | /// would strip them vault-wide on the first star-tap). |
| 113 | /// Returns true when the write landed (retries once on a version race). |
| 114 | @discardableResult |
| 115 | public static func push(instances: [SavedInstance], session: HomeSession, |
| 116 | client: HomeClient = HomeClient()) async -> Bool { |
| 117 | guard let dek = session.dek else { return false } |
| 118 | for _ in 0..<2 { |
| 119 | guard let fetch = try? await client.fetchBlob(token: session.token), |
| 120 | let obj = try? HomeVault.openRawObject(fetch.bytes, dek: dek) else { return false } |
| 121 | |
| 122 | let patched = patchedVault(obj, instances: instances) |
| 123 | guard let sealed = try? HomeVault.sealRawObject(patched, existingEnvelope: fetch.bytes, |
| 124 | dek: dek) |
| 125 | else { return false } |
| 126 | if let _ = try? await client.putBlob(token: session.token, bytes: sealed, |
| 127 | ifMatch: fetch.version) { |
| 128 | return true |
| 129 | } |
| 130 | // nil = 409 (someone else wrote meanwhile) β loop refetches + reapplies. |
| 131 | } |
| 132 | return false |
| 133 | } |
| 134 | |
| 135 | /// The pure patch step of push β the device's list upserted into the raw |
| 136 | /// vault object. Split from the network loop so the merge rules (unknown- |
| 137 | /// field round-trip, tombstone skip/clear) are unit-testable. |
| 138 | static func patchedVault(_ obj: [String: Any], instances: [SavedInstance]) -> [String: Any] { |
| 139 | var obj = obj |
| 140 | let existing = (obj["servers"] as? [[String: Any]]) ?? [] |
| 141 | var byUrl: [String: [String: Any]] = [:] |
| 142 | for e in existing { if let u = e["url"] as? String { byUrl[u] = e } } |
| 143 | |
| 144 | // Removal tombstones (written by the home page β see HomeVault |
| 145 | // .Tombstone): a server the user deleted there must NOT be |
| 146 | // re-upserted just because this device still holds a copy. Only a |
| 147 | // copy gained AFTER the removal (a deliberate re-add) wins β and |
| 148 | // clears the tombstone so every other client follows. |
| 149 | var removed = (obj["removed"] as? [[String: Any]]) ?? [] |
| 150 | func tombstoneTS(_ url: String) -> Double { |
| 151 | for t in removed where (t["url"] as? String) == url { |
| 152 | return (t["ts"] as? NSNumber)?.doubleValue ?? 0 |
| 153 | } |
| 154 | return 0 |
| 155 | } |
| 156 | |
| 157 | // Device servers first, in device order β each PATCHED onto its |
| 158 | // existing vault entry so unknown fields survive. |
| 159 | var servers: [[String: Any]] = [] |
| 160 | for inst in instances { |
| 161 | let ts = tombstoneTS(inst.origin) |
| 162 | if ts > 0 { |
| 163 | if (inst.addedAt ?? 0) > ts { |
| 164 | removed.removeAll { ($0["url"] as? String) == inst.origin } |
| 165 | } else { |
| 166 | // Deleted on the home page after we got it β skip (the |
| 167 | // next pull drops our local copy too). Consume any |
| 168 | // vault entry so the tail below can't resurrect it. |
| 169 | byUrl.removeValue(forKey: inst.origin) |
| 170 | continue |
| 171 | } |
| 172 | } |
| 173 | var e = byUrl.removeValue(forKey: inst.origin) ?? [:] |
| 174 | e["url"] = inst.origin |
| 175 | // Only write a name we actually know β the device's ignorance |
| 176 | // must not delete one another client stored. |
| 177 | if !inst.groupName.isEmpty { e["name"] = inst.groupName } |
| 178 | if inst.isStarred { e["starred"] = true } else { e.removeValue(forKey: "starred") } |
| 179 | if inst.isArchived { e["archived"] = true } else { e.removeValue(forKey: "archived") } |
| 180 | var creds = (e["credentials"] as? [String: Any]) ?? [:] |
| 181 | creds["token"] = inst.deviceToken |
| 182 | creds["userPub"] = inst.userPub |
| 183 | creds["userPriv"] = inst.userPriv |
| 184 | creds["name"] = inst.screenName |
| 185 | e["credentials"] = creds |
| 186 | servers.append(e) |
| 187 | } |
| 188 | // Vault-only entries survive verbatim, tailed in their relative |
| 189 | // order β except tombstoned ones, which an old client re-pushed |
| 190 | // after the removal; dropping them here heals the vault. |
| 191 | for e in existing { |
| 192 | guard let u = e["url"] as? String, byUrl[u] != nil else { continue } |
| 193 | if tombstoneTS(u) > 0 { continue } |
| 194 | servers.append(e) |
| 195 | } |
| 196 | obj["servers"] = servers |
| 197 | if removed.isEmpty { obj.removeValue(forKey: "removed") } else { obj["removed"] = removed } |
| 198 | return obj |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | private extension Task where Success == Void, Failure == Never { |
| 203 | /// Chain a follow-on operation after this task, preserving order. |
| 204 | func then(_ op: @escaping () async -> Void) -> Task<Void, Never> { |
| 205 | Task { await self.value; await op() } |
| 206 | } |
| 207 | } |
| 208 | |