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
3
4// πŸ€– The decrypted backup's contents + the read/modify/write cycle against the
5// home server. The blob JSON matches the web's exactly:
6// { v:1, servers:[ { url, name?, starred?, credentials:{token,userPub,userPriv,name?} } ] }
7// The ARRAY ORDER is the master order; `starred` is a per-server boolean. The
8// list renders as a **Starred** group and the rest, each keeping its own relative
9// array order. home/web/static/serverlist.js is the shared contract β€” these pure
10// helpers mirror it exactly (HomeVaultTests ports its test), so the native rail
11// and the home page sort a member's spaces identically.
12// Writes are last-writer-wins per url with optimistic-concurrency retry.
13public struct HomeVault {
14 public struct Server: Codable, Equatable {
15 public var url: String
16 public var name: String?
17 public var starred: Bool?
18 // Archived: hidden from the rail + overflow, kept in the account.
19 // Native-first field β€” home's web UI doesn't render it yet, but its
20 // spread-merge writes preserve it (as does the native raw-JSON push).
21 public var archived: Bool?
22 // Optional: home can hold a credential-less entry (a space saved by URL
23 // alone β€” home.js pushes bare {url, name}). Restore skips those; a native
24 // push must preserve them untouched.
25 public var credentials: Credentials?
26
27 public struct Credentials: Codable, Equatable {
28 public var token: String
29 public var userPub: String
30 public var userPriv: String
31 public var name: String?
32
33 public init(token: String, userPub: String, userPriv: String, name: String? = nil) {
34 self.token = token
35 self.userPub = userPub
36 self.userPriv = userPriv
37 self.name = name
38 }
39 }
40
41 public var isStarred: Bool { starred ?? false }
42 public var isArchived: Bool { archived ?? false }
43
44 public init(url: String, name: String? = nil, starred: Bool? = nil,
45 archived: Bool? = nil, credentials: Credentials? = nil) {
46 self.url = url
47 self.name = name
48 self.starred = starred
49 self.archived = archived
50 self.credentials = credentials
51 }
52 }
53
54 // A removal made on the home page β€” { url, ts } (epoch ms, newest wins per
55 // url), the blob's top-level `removed` list. Sync never deletes, so without
56 // these every signed-in device re-upserts a deleted server on its next
57 // push; a tombstone newer than the device copy's addedAt makes the removal
58 // stick (drop locally, never re-push), while a space deliberately re-added
59 // AFTER the removal clears its tombstone and wins. serverlist.js is the
60 // shared contract β€” keep the two in lockstep.
61 public struct Tombstone: Codable, Equatable {
62 public var url: String
63 public var ts: Double
64
65 public init(url: String, ts: Double) {
66 self.url = url
67 self.ts = ts
68 }
69 }
70
71 public struct Contents: Codable {
72 public var v: Int
73 public var servers: [Server]
74 // Optional so pre-tombstone blobs decode; nil and [] read the same.
75 public var removed: [Tombstone]?
76
77 public init(v: Int = 1, servers: [Server], removed: [Tombstone]? = nil) {
78 self.v = v
79 self.servers = servers
80 self.removed = removed
81 }
82 }
83
84 /// When `url` was removed on the home page (epoch ms), or 0 if it wasn't β€”
85 /// mirrors serverlist.js tombstonedAt. Callers compare against a device
86 /// copy's addedAt (missing β†’ 0, so pre-feature entries always lose).
87 public static func tombstonedAt(_ removed: [Tombstone]?, url: String) -> Double {
88 removed?.first(where: { $0.url == url })?.ts ?? 0
89 }
90
91 // ---- pure list helpers (mirror serverlist.js β€” keep in lockstep) --------
92
93 /// Split into (starred, others), each preserving its own array order.
94 public static func partition(_ servers: [Server]) -> (starred: [Server], others: [Server]) {
95 var starred: [Server] = [], others: [Server] = []
96 for s in servers { if s.isStarred { starred.append(s) } else { others.append(s) } }
97 return (starred, others)
98 }
99
100 /// Flip a server's starred flag AND reposition it: starring sends it to the
101 /// END of the Starred group; unstarring sends it to the TOP of the rest β€”
102 /// both the boundary between the two groups. Returns a NEW array (never
103 /// mutates the input); an unknown url passes through unchanged.
104 public static func toggleStar(_ servers: [Server], url: String) -> [Server] {
105 guard let target = servers.first(where: { $0.url == url }) else { return servers }
106 var updated = target
107 updated.starred = target.isStarred ? nil : true // nil β‡’ omitted, like the web's drop
108 let starred = servers.filter { $0.url != url && $0.isStarred }
109 let others = servers.filter { $0.url != url && !$0.isStarred }
110 return starred + [updated] + others
111 }
112
113 /// Split into (active, archived), each preserving its own array order β€” the
114 /// first cut rendering makes, before active splits into starred/others.
115 public static func partitionArchived(_ servers: [Server]) -> (active: [Server], archived: [Server]) {
116 var active: [Server] = [], archived: [Server] = []
117 for s in servers { if s.isArchived { archived.append(s) } else { active.append(s) } }
118 return (active, archived)
119 }
120
121 /// Set a server's archived flag AND reposition it, keeping the master order
122 /// active-first-then-archived (mirrors serverlist.js setArchived):
123 /// archiving drops `starred` (an archived space leaves the rail) and lands
124 /// at the TOP of the archived group; unarchiving lands at the BOTTOM of the
125 /// active list β€” a returning space rejoins at the back, not ahead of the
126 /// spaces you kept. Both are the boundary between the two groups, so the
127 /// move is a single insert there. Returns a NEW array; an unknown url
128 /// passes through unchanged.
129 public static func setArchived(_ servers: [Server], url: String, _ on: Bool) -> [Server] {
130 guard let target = servers.first(where: { $0.url == url }) else { return servers }
131 var updated = target
132 updated.archived = on ? true : nil // nil β‡’ omitted, like the web's drop
133 if on { updated.starred = nil } // leaving the rail
134 let starred = servers.filter { $0.url != url && $0.isStarred && !$0.isArchived }
135 let others = servers.filter { $0.url != url && !$0.isStarred && !$0.isArchived }
136 let archived = servers.filter { $0.url != url && $0.isArchived }
137 return starred + others + [updated] + archived
138 }
139
140 /// Swap the entry at `url` with its nearest SAME-GROUP neighbour in `dir`
141 /// (-1 = up, +1 = down), skipping the other group; unchanged if it's already
142 /// at an end of its list. The keyboard-reorder fallback.
143 public static func moveInGroup(_ servers: [Server], url: String, dir: Int) -> [Server] {
144 var arr = servers
145 guard let i = arr.firstIndex(where: { $0.url == url }) else { return arr }
146 let group = arr[i].isStarred
147 var j = i + dir
148 while j >= 0, j < arr.count, arr[j].isStarred != group { j += dir }
149 guard j >= 0, j < arr.count else { return arr }
150 arr.swapAt(i, j)
151 return arr
152 }
153
154 /// Reorder to match `urlsInOrder` (the full top-to-bottom display order);
155 /// entries whose url isn't listed keep their relative order at the end.
156 /// Each entry (with its starred flag + credentials) is preserved.
157 public static func order(_ servers: [Server], urlsInOrder: [String]) -> [Server] {
158 var byUrl = Dictionary(servers.map { ($0.url, $0) }, uniquingKeysWith: { a, _ in a })
159 var out: [Server] = []
160 for url in urlsInOrder {
161 if let s = byUrl.removeValue(forKey: url) { out.append(s) }
162 }
163 for s in servers where byUrl[s.url] != nil { out.append(s) }
164 return out
165 }
166
167 // ---- open a fetched blob with a PRF output ------------------------------
168
169 /// Decrypt a fetched envelope: find our credential's wrapped DEK, unwrap it
170 /// with the PRF-derived KEK, open the blob. Returns nil if this passkey
171 /// isn't one of the backup's keys.
172 public static func open(_ bytes: Data, credentialID: String, prf: Data)
173 throws -> (contents: Contents, dek: SymmetricKey)? {
174 let (keys, _) = try HomeCrypto.unpack(bytes)
175 guard let wrapped = keys[credentialID] else { return nil }
176 let kek = HomeCrypto.kekFromPRF(prf)
177 let dek = try HomeCrypto.unwrapDEK(wrapped, kek: kek)
178 let (objJSON, _) = try HomeCrypto.openBlob(bytes, dek: dek)
179 let contents = try JSONDecoder().decode(Contents.self, from: objJSON)
180 return (contents, dek)
181 }
182
183 /// Open a fetched envelope with an already-unwrapped DEK (a persisted home
184 /// session) β€” no passkey ceremony needed for routine sync reads.
185 public static func open(_ bytes: Data, dek: SymmetricKey) throws -> Contents {
186 let (objJSON, _) = try HomeCrypto.openBlob(bytes, dek: dek)
187 return try JSONDecoder().decode(Contents.self, from: objJSON)
188 }
189
190 /// The decrypted blob as a RAW JSON object β€” for read-modify-write cycles
191 /// (HomeSync.push), which must round-trip fields this build doesn't know:
192 /// the web spread-merges unknown keys, and a native writer that re-encoded
193 /// through strict Codable would silently strip them vault-wide.
194 public static func openRawObject(_ bytes: Data, dek: SymmetricKey) throws -> [String: Any] {
195 let (objJSON, _) = try HomeCrypto.openBlob(bytes, dek: dek)
196 return (try JSONSerialization.jsonObject(with: objJSON) as? [String: Any]) ?? [:]
197 }
198
199 /// Re-seal a raw object under the same DEK, keeping the existing wrapped-key
200 /// header (openRawObject's write-back partner).
201 public static func sealRawObject(_ obj: [String: Any], existingEnvelope: Data,
202 dek: SymmetricKey) throws -> Data {
203 let (keys, _) = try HomeCrypto.unpack(existingEnvelope)
204 let objJSON = try JSONSerialization.data(withJSONObject: obj)
205 return try HomeCrypto.sealBlob(objJSON: objJSON, dek: dek, keys: keys)
206 }
207
208 /// A brand-new envelope for a first backup: fresh random DEK, wrapped only
209 /// to this passkey (via its PRF-derived KEK) β€” the web's createBlobFor.
210 public static func sealFresh(_ contents: Contents, credentialID: String, prf: Data)
211 throws -> (bytes: Data, dek: SymmetricKey) {
212 let dek = SymmetricKey(size: .bits256)
213 let wrapped = try HomeCrypto.wrapDEK(dek, kek: HomeCrypto.kekFromPRF(prf))
214 let objJSON = try JSONEncoder().encode(contents)
215 let bytes = try HomeCrypto.sealBlob(objJSON: objJSON, dek: dek,
216 keys: [credentialID: wrapped])
217 return (bytes, dek)
218 }
219
220 // ---- write back, preserving every other passkey's wrapped DEK -----------
221
222 /// Re-seal `contents` under the same DEK, keeping the existing wrapped-key
223 /// header so other enrolled passkeys still open the blob.
224 public static func seal(_ contents: Contents, existingEnvelope: Data, dek: SymmetricKey)
225 throws -> Data {
226 let (keys, _) = try HomeCrypto.unpack(existingEnvelope)
227 let objJSON = try JSONEncoder().encode(contents)
228 return try HomeCrypto.sealBlob(objJSON: objJSON, dek: dek, keys: keys)
229 }
230
231 /// Upsert a server by url (last-writer-wins), preserving its position or
232 /// appending at the end.
233 public static func upsert(_ contents: inout Contents, _ server: Server) {
234 if let i = contents.servers.firstIndex(where: { $0.url == server.url }) {
235 contents.servers[i] = server
236 } else {
237 contents.servers.append(server)
238 }
239 }
240}
241