eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1import CryptoKit
2import 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.
29public 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
57public 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
78public 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
202private 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