| 1 | import CryptoKit |
| 2 | import 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. |
| 13 | public 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 | |