eleven / messenger Public

ios+mac: solve home's hashcash challenge when creating a space (#677)

HomeClient.createSpace now fetches POST /api/spaces/challenge and grinds
SHA-256(challenge:nonce) to the served difficulty before posting the
create — mirroring home's pow.go and the web solver, in shared LChatCore
so both apps get it. bits 0 / older servers / missing endpoint → no-op,
so old apps and new servers never wedge each other. CryptoKit clears
production difficulty in well under a second; the create flow's own
building moment covers it. Tests pin bit-counting + solve/verify against
the Go suite's cases.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell c96872a9030dfcf0d351376db3bd47962662305d parent 48d3c45
2 files changed, +91 −1
  • ios/LChatCore/Sources/LChatCore/HomeClient.swift +57 −1
  • ios/LChatCore/Tests/LChatCoreTests/PowSolverTests.swift +34 −0
diff --git a/ios/LChatCore/Sources/LChatCore/HomeClient.swift b/ios/LChatCore/Sources/LChatCore/HomeClient.swift
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)
diff --git a/ios/LChatCore/Tests/LChatCoreTests/PowSolverTests.swift b/ios/LChatCore/Tests/LChatCoreTests/PowSolverTests.swift
new file mode 100644
index 0000000..dd391fb
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/PowSolverTests.swift
@@ -0,0 +1,34 @@
+import CryptoKit
+import XCTest
+@testable import LChatCore
+
+// 🤖 The hashcash solver mirrors home's pow.go: SHA-256(challenge ":" nonce)
+// must clear the difficulty's leading zero bits. These pin the two pure
+// pieces (bit counting, solve/verify) against the same cases as the Go tests,
+// so the native solver and the server can't drift.
+final class PowSolverTests: XCTestCase {
+ func testLeadingZeroBits() {
+ XCTAssertEqual(HomeClient.leadingZeroBits([0x80]), 0)
+ XCTAssertEqual(HomeClient.leadingZeroBits([0x01]), 7)
+ XCTAssertEqual(HomeClient.leadingZeroBits([0x00, 0xFF]), 8)
+ XCTAssertEqual(HomeClient.leadingZeroBits([0x00, 0x0F]), 12)
+ XCTAssertEqual(HomeClient.leadingZeroBits([0x00, 0x00]), 16)
+ }
+
+ func testSolveAndVerify() {
+ // 8 bits ≈ 256 hashes — instant. Grind exactly like the client does,
+ // then check the found nonce verifies against an independent digest.
+ let challenge = "test-challenge"
+ var nonce = 0
+ while !HomeClient.powSolves(challenge: challenge, nonce: String(nonce), bits: 8) {
+ nonce += 1
+ XCTAssertLessThan(nonce, 1_000_000, "8-bit solution should be found quickly")
+ }
+ let digest = Array(SHA256.hash(data: Data((challenge + ":" + String(nonce)).utf8)))
+ XCTAssertGreaterThanOrEqual(HomeClient.leadingZeroBits(digest), 8)
+ // Bits are a floor: a 8-bit solution rarely clears 32.
+ XCTAssertFalse(HomeClient.powSolves(challenge: challenge, nonce: "definitely-not-it", bits: 32))
+ // A solution is bound to its challenge text, not portable.
+ XCTAssertTrue(HomeClient.powSolves(challenge: "anything", nonce: "0", bits: 0))
+ }
+}