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// πŸ€– 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.
10public 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
250public enum HomeClientError: Error { case network, http(Int, String) }
251
252// The server returns version as a JSON string; tolerate a number too.
253struct 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
263extension CharacterSet {
264 static let urlQueryValueAllowed: CharacterSet = {
265 var cs = CharacterSet.urlQueryAllowed
266 cs.remove(charactersIn: "&=?/")
267 return cs
268 }()
269}
270