| 1 | import CryptoKit |
| 2 | import Foundation |
| 3 | |
| 4 | // π€ The home-server API client: WebAuthn ceremony begin/finish and the |
| 5 | // encrypted blob's GET/PUT. It speaks the same JSON wire the web pages do |
| 6 | // (base64url everywhere), so a native passkey ceremony's results β reshaped |
| 7 | // into WebAuthn JSON by HomeAuth β verify against the same go-webauthn server. |
| 8 | // This layer holds NO key material; the ceremony's PRF output and the derived |
| 9 | // DEK live in HomeCrypto / the caller. |
| 10 | public struct HomeClient { |
| 11 | public let base: URL |
| 12 | |
| 13 | public init(base: URL = HomeBase.url) { |
| 14 | self.base = base |
| 15 | } |
| 16 | |
| 17 | // ---- ceremony options (only the fields the native ceremony needs) ------- |
| 18 | |
| 19 | public struct BeginResponse: Decodable { |
| 20 | public let session: String |
| 21 | public let options: Options |
| 22 | public struct Options: Decodable { public let publicKey: PublicKey } |
| 23 | public struct PublicKey: Decodable { |
| 24 | public let challenge: String // base64url |
| 25 | public let user: User? // register only |
| 26 | public let rp: RP? |
| 27 | public struct User: Decodable { public let id: String; public let name: String? } |
| 28 | public struct RP: Decodable { public let id: String? } |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | // ---- ceremony result ---------------------------------------------------- |
| 33 | |
| 34 | public struct FinishResponse: Decodable { |
| 35 | public let token: String |
| 36 | public let proof: String? |
| 37 | public let credential_id: String |
| 38 | } |
| 39 | |
| 40 | public func registerBegin() async throws -> BeginResponse { |
| 41 | try await getJSON("POST", "/api/register/begin") |
| 42 | } |
| 43 | |
| 44 | public func loginBegin() async throws -> BeginResponse { |
| 45 | try await getJSON("POST", "/api/login/begin") |
| 46 | } |
| 47 | |
| 48 | /// Post an assembled WebAuthn credential JSON to finish a ceremony. |
| 49 | public func finish(register: Bool, session: String, credentialJSON: Data) async throws -> FinishResponse { |
| 50 | var comps = URLComponents(url: base.appending(path: register ? "/api/register/finish" |
| 51 | : "/api/login/finish"), |
| 52 | resolvingAgainstBaseURL: false)! |
| 53 | comps.queryItems = [URLQueryItem(name: "session", value: session)] |
| 54 | var req = URLRequest(url: comps.url!) |
| 55 | req.httpMethod = "POST" |
| 56 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 57 | req.httpBody = credentialJSON |
| 58 | let (data, resp) = try await URLSession.shared.data(for: req) |
| 59 | try Self.ensureOK(resp, data) |
| 60 | return try JSONDecoder().decode(FinishResponse.self, from: data) |
| 61 | } |
| 62 | |
| 63 | // ---- the blob ----------------------------------------------------------- |
| 64 | |
| 65 | public struct BlobFetch { public let bytes: Data; public let version: String } |
| 66 | |
| 67 | /// Returns the stored envelope + its version, or nil on 404 (no backup). |
| 68 | public func fetchBlob(token: String) async throws -> BlobFetch? { |
| 69 | var req = URLRequest(url: base.appending(path: "/api/blob")) |
| 70 | req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") |
| 71 | let (data, resp) = try await URLSession.shared.data(for: req) |
| 72 | guard let http = resp as? HTTPURLResponse else { throw HomeClientError.network } |
| 73 | if http.statusCode == 404 { return nil } |
| 74 | try Self.ensureOK(resp, data) |
| 75 | let version = (http.value(forHTTPHeaderField: "ETag") ?? "\"0\"") |
| 76 | .replacingOccurrences(of: "\"", with: "") |
| 77 | return BlobFetch(bytes: data, version: version) |
| 78 | } |
| 79 | |
| 80 | /// Stores an envelope with optimistic concurrency; nil = 409 version race |
| 81 | /// (caller re-fetches + retries), else the new version. |
| 82 | public func putBlob(token: String, bytes: Data, ifMatch version: String) async throws -> String? { |
| 83 | var req = URLRequest(url: base.appending(path: "/api/blob")) |
| 84 | req.httpMethod = "PUT" |
| 85 | req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") |
| 86 | req.setValue("\"\(version)\"", forHTTPHeaderField: "If-Match") |
| 87 | req.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") |
| 88 | req.httpBody = bytes |
| 89 | let (data, resp) = try await URLSession.shared.data(for: req) |
| 90 | guard let http = resp as? HTTPURLResponse else { throw HomeClientError.network } |
| 91 | if http.statusCode == 409 { return nil } |
| 92 | try Self.ensureOK(resp, data) |
| 93 | struct R: Decodable { let version: JSONValueString } |
| 94 | return try JSONDecoder().decode(R.self, from: data).version.value |
| 95 | } |
| 96 | |
| 97 | // ---- sign-in methods (anchors) ------------------------------------------- |
| 98 | |
| 99 | /// One row of GET /api/anchors β a way into the account. The id is the |
| 100 | /// opaque removal handle home already hands its own client (for siwa the |
| 101 | /// HMAC-peppered sub hash β reverses to nothing); DEKSync stores and |
| 102 | /// matches it verbatim. |
| 103 | public struct AnchorEntry: Decodable { |
| 104 | public let kind: String |
| 105 | public let id: String |
| 106 | |
| 107 | public init(kind: String, id: String) { |
| 108 | self.kind = kind |
| 109 | self.id = id |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | /// GET /api/anchors β every sign-in method on the account (link-token |
| 114 | /// gated, like the blob). |
| 115 | public func anchors(token: String) async throws -> [AnchorEntry] { |
| 116 | var req = URLRequest(url: base.appending(path: "/api/anchors")) |
| 117 | req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") |
| 118 | let (data, resp) = try await URLSession.shared.data(for: req) |
| 119 | try Self.ensureOK(resp, data) |
| 120 | struct R: Decodable { let anchors: [AnchorEntry]? } |
| 121 | return try JSONDecoder().decode(R.self, from: data).anchors ?? [] |
| 122 | } |
| 123 | |
| 124 | // ---- space creation (the cloud provisioner, via home) ------------------- |
| 125 | |
| 126 | /// GET /api/features β whether this home server offers space creation |
| 127 | /// (a paired cloud provisioner). False on error/older servers, so callers |
| 128 | /// simply don't show the affordance (the web hides its + the same way). |
| 129 | public func features() async -> Bool { |
| 130 | struct R: Decodable { let spaces: Bool? } |
| 131 | guard let r: R = try? await getJSON("GET", "/api/features") else { return false } |
| 132 | return r.spaces ?? false |
| 133 | } |
| 134 | |
| 135 | public struct SpaceCreation: Decodable { |
| 136 | public let host: String |
| 137 | public let url: String |
| 138 | public let setup_link: String |
| 139 | } |
| 140 | |
| 141 | /// A create-space failure carrying the server's user-facing copy verbatim |
| 142 | /// (quota, capacity, eligibility β home writes these for members). |
| 143 | public struct SpaceCreateError: LocalizedError { |
| 144 | public let message: String |
| 145 | public var errorDescription: String? { message } |
| 146 | public init(message: String) { self.message = message } |
| 147 | } |
| 148 | |
| 149 | // ---- proof of work (hashcash) at creation -------------------------------- |
| 150 | |
| 151 | /// The server's creation gate, when it runs one: find a nonce where |
| 152 | /// SHA-256(challenge ":" nonce) clears `bits` leading zero bits. Mirrors |
| 153 | /// home's pow.go and the web's solver; native CryptoKit grinds this in |
| 154 | /// well under a second at production difficulty, so no progress UI β |
| 155 | /// the create flow's own "building" moment covers it. |
| 156 | struct PowChallenge: Decodable { let bits: Int; let challenge: String? } |
| 157 | |
| 158 | /// Fetch and solve the creation challenge; nil when the server runs no |
| 159 | /// gate (bits 0 / older servers / endpoint missing β creation proceeds |
| 160 | /// bare and the server decides, so old apps and new servers never wedge). |
| 161 | func solveCreateChallenge(token: String) async -> (challenge: String, nonce: String)? { |
| 162 | var req = URLRequest(url: base.appending(path: "/api/spaces/challenge")) |
| 163 | req.httpMethod = "POST" |
| 164 | req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") |
| 165 | guard let (data, resp) = try? await URLSession.shared.data(for: req), |
| 166 | let http = resp as? HTTPURLResponse, http.statusCode == 200, |
| 167 | let c = try? JSONDecoder().decode(PowChallenge.self, from: data), |
| 168 | c.bits > 0, let challenge = c.challenge else { return nil } |
| 169 | var n = 0 |
| 170 | while !Task.isCancelled { |
| 171 | let nonce = String(n) |
| 172 | if Self.powSolves(challenge: challenge, nonce: nonce, bits: c.bits) { |
| 173 | return (challenge, nonce) |
| 174 | } |
| 175 | n += 1 |
| 176 | } |
| 177 | return nil |
| 178 | } |
| 179 | |
| 180 | static func powSolves(challenge: String, nonce: String, bits: Int) -> Bool { |
| 181 | let digest = SHA256.hash(data: Data((challenge + ":" + nonce).utf8)) |
| 182 | return leadingZeroBits(Array(digest)) >= bits |
| 183 | } |
| 184 | |
| 185 | static func leadingZeroBits(_ bytes: [UInt8]) -> Int { |
| 186 | var bits = 0 |
| 187 | for b in bytes { |
| 188 | if b == 0 { bits += 8; continue } |
| 189 | var mask: UInt8 = 0x80 |
| 190 | while mask != 0 { |
| 191 | if b & mask != 0 { return bits } |
| 192 | bits += 1 |
| 193 | mask >>= 1 |
| 194 | } |
| 195 | } |
| 196 | return bits |
| 197 | } |
| 198 | |
| 199 | /// POST /api/spaces β create a fresh instance. The call blocks while the |
| 200 | /// box starts the instance, health-checks it, and confirms the PUBLIC path |
| 201 | /// answers (~45s worst case), so the returned setup link works on first |
| 202 | /// use. Auth is the home link token; create is additive, no ceremony |
| 203 | /// proof β but it may carry a solved hashcash challenge (above). |
| 204 | public func createSpace(token: String) async throws -> SpaceCreation { |
| 205 | var req = URLRequest(url: base.appending(path: "/api/spaces")) |
| 206 | req.httpMethod = "POST" |
| 207 | req.timeoutInterval = 75 // home's own downstream budget is 45s |
| 208 | req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") |
| 209 | if let proof = await solveCreateChallenge(token: token) { |
| 210 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| 211 | req.httpBody = try? JSONEncoder().encode(["challenge": proof.challenge, "nonce": proof.nonce]) |
| 212 | } |
| 213 | let data: Data, resp: URLResponse |
| 214 | do { |
| 215 | (data, resp) = try await URLSession.shared.data(for: req) |
| 216 | } catch { |
| 217 | throw SpaceCreateError(message: "Couldn't reach Eleven β check your connection and try again.") |
| 218 | } |
| 219 | guard let http = resp as? HTTPURLResponse else { |
| 220 | throw SpaceCreateError(message: "Couldn't reach Eleven β check your connection and try again.") |
| 221 | } |
| 222 | guard http.statusCode == 201 else { |
| 223 | struct E: Decodable { let error: String?; let message: String? } |
| 224 | let e = try? JSONDecoder().decode(E.self, from: data) |
| 225 | throw SpaceCreateError(message: e?.message ?? e?.error |
| 226 | ?? "Couldn't create a space (HTTP \(http.statusCode)).") |
| 227 | } |
| 228 | return try JSONDecoder().decode(SpaceCreation.self, from: data) |
| 229 | } |
| 230 | |
| 231 | // ---- plumbing ----------------------------------------------------------- |
| 232 | |
| 233 | private func getJSON<T: Decodable>(_ method: String, _ path: String) async throws -> T { |
| 234 | var req = URLRequest(url: base.appending(path: path)) |
| 235 | req.httpMethod = method |
| 236 | let (data, resp) = try await URLSession.shared.data(for: req) |
| 237 | try Self.ensureOK(resp, data) |
| 238 | return try JSONDecoder().decode(T.self, from: data) |
| 239 | } |
| 240 | |
| 241 | static func ensureOK(_ resp: URLResponse, _ body: Data) throws { |
| 242 | guard let http = resp as? HTTPURLResponse else { throw HomeClientError.network } |
| 243 | guard (200..<300).contains(http.statusCode) else { |
| 244 | throw HomeClientError.http(http.statusCode, |
| 245 | String(data: body, encoding: .utf8) ?? "") |
| 246 | } |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | public enum HomeClientError: Error { case network, http(Int, String) } |
| 251 | |
| 252 | // The server returns version as a JSON string; tolerate a number too. |
| 253 | struct JSONValueString: Decodable { |
| 254 | let value: String |
| 255 | init(from decoder: Decoder) throws { |
| 256 | let c = try decoder.singleValueContainer() |
| 257 | if let s = try? c.decode(String.self) { value = s } |
| 258 | else if let n = try? c.decode(Int.self) { value = String(n) } |
| 259 | else { value = "0" } |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | extension CharacterSet { |
| 264 | static let urlQueryValueAllowed: CharacterSet = { |
| 265 | var cs = CharacterSet.urlQueryAllowed |
| 266 | cs.remove(charactersIn: "&=?/") |
| 267 | return cs |
| 268 | }() |
| 269 | } |
| 270 | |