| index dd10f50..1272f3f 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/HomeClient.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/HomeClient.swift |
| @@ -1,3 +1,4 @@ |
| +import CryptoKit |
| import Foundation |
| |
| // 🤖 The home-server API client: WebAuthn ceremony begin/finish and the |
| @@ -118,15 +119,70 @@ public struct HomeClient { |
| public init(message: String) { self.message = message } |
| } |
| |
| + // ---- proof of work (hashcash) at creation -------------------------------- |
| + |
| + /// The server's creation gate, when it runs one: find a nonce where |
| + /// SHA-256(challenge ":" nonce) clears `bits` leading zero bits. Mirrors |
| + /// home's pow.go and the web's solver; native CryptoKit grinds this in |
| + /// well under a second at production difficulty, so no progress UI — |
| + /// the create flow's own "building" moment covers it. |
| + struct PowChallenge: Decodable { let bits: Int; let challenge: String? } |
| + |
| + /// Fetch and solve the creation challenge; nil when the server runs no |
| + /// gate (bits 0 / older servers / endpoint missing — creation proceeds |
| + /// bare and the server decides, so old apps and new servers never wedge). |
| + func solveCreateChallenge(token: String) async -> (challenge: String, nonce: String)? { |
| + var req = URLRequest(url: base.appending(path: "/api/spaces/challenge")) |
| + req.httpMethod = "POST" |
| + req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") |
| + guard let (data, resp) = try? await URLSession.shared.data(for: req), |
| + let http = resp as? HTTPURLResponse, http.statusCode == 200, |
| + let c = try? JSONDecoder().decode(PowChallenge.self, from: data), |
| + c.bits > 0, let challenge = c.challenge else { return nil } |
| + var n = 0 |
| + while !Task.isCancelled { |
| + let nonce = String(n) |
| + if Self.powSolves(challenge: challenge, nonce: nonce, bits: c.bits) { |
| + return (challenge, nonce) |
| + } |
| + n += 1 |
| + } |
| + return nil |
| + } |
| + |
| + static func powSolves(challenge: String, nonce: String, bits: Int) -> Bool { |
| + let digest = SHA256.hash(data: Data((challenge + ":" + nonce).utf8)) |
| + return leadingZeroBits(Array(digest)) >= bits |
| + } |
| + |
| + static func leadingZeroBits(_ bytes: [UInt8]) -> Int { |
| + var bits = 0 |
| + for b in bytes { |
| + if b == 0 { bits += 8; continue } |
| + var mask: UInt8 = 0x80 |
| + while mask != 0 { |
| + if b & mask != 0 { return bits } |
| + bits += 1 |
| + mask >>= 1 |
| + } |
| + } |
| + return bits |
| + } |
| + |
| /// POST /api/spaces — create a fresh instance. The call blocks while the |
| /// box starts the instance, health-checks it, and confirms the PUBLIC path |
| /// answers (~45s worst case), so the returned setup link works on first |
| - /// use. Auth is the home link token; create is additive, no ceremony proof. |
| + /// use. Auth is the home link token; create is additive, no ceremony |
| + /// proof — but it may carry a solved hashcash challenge (above). |
| public func createSpace(token: String) async throws -> SpaceCreation { |
| var req = URLRequest(url: base.appending(path: "/api/spaces")) |
| req.httpMethod = "POST" |
| req.timeoutInterval = 75 // home's own downstream budget is 45s |
| req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") |
| + if let proof = await solveCreateChallenge(token: token) { |
| + req.setValue("application/json", forHTTPHeaderField: "Content-Type") |
| + req.httpBody = try? JSONEncoder().encode(["challenge": proof.challenge, "nonce": proof.nonce]) |
| + } |
| let data: Data, resp: URLResponse |
| do { |
| (data, resp) = try await URLSession.shared.data(for: req) |