import CryptoKit import Foundation /// The phone's half of the pairing flow (design §7). /// /// It works over the LAN, because at pairing time the phone is necessarily already inside the /// network — that is the fact the whole flow leans on, and it is what lets the shared secret be the /// node's own password instead of a QR code or a second credential to manage. /// /// The node's agent serves this on a temporary management port that closes itself after one /// successful pairing, so a phone that cannot reach the node has to be paired again from the node /// rather than from anywhere else. public struct RelayPairing: Sendable { /// What the node returns on success. public struct Result: Equatable, Sendable { public let endpoint: RelayEndpoint /// The device id the node assigned. It is the node's choice, not the phone's: the node's /// config is the authority for which device ids exist. public let deviceID: String } public enum Failure: Error, Equatable { /// The shared secret did not match. Distinct from a transport failure because the remedy is /// different: retype the password rather than check the network. case rejected /// The pairing window is closed — already used, or too many failed attempts. case windowClosed case unreachable case malformedResponse } private let session: URLSession private let port: Int private let maximumBytes: Int public init(session: URLSession = .shared, port: Int = 3001, maximumBytes: Int = 16 << 10) { self.session = session self.port = port self.maximumBytes = maximumBytes } /// Pairs this phone with the node at `host`. /// /// `host` is the node's LAN address as the user already typed it for direct mode, which is why /// the port is a separate parameter: the pairing window is not on the SimAdmin port. public func pair( host: String, password: String, deviceName: String, identity: DeviceIdentity ) async throws -> Result { guard let url = URL(string: "http://\(host):\(port)/provision") else { throw Failure.unreachable } // The device name is informational on the node's side, and the two public keys are the whole // point: the node stores the signing key and returns its own, which becomes the phone's // trust anchor for every later response. let body: [String: String] = [ "password": password, "appSignPub": identity.signingPublicKey.base64EncodedString(), "appKxPub": identity.agreementPublicKey.base64EncodedString(), "deviceName": deviceName, ] var request = URLRequest(url: url, timeoutInterval: 15) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "content-type") request.setValue("application/json", forHTTPHeaderField: "accept") request.httpBody = try? JSONSerialization.data(withJSONObject: body) let data: Data let response: URLResponse do { (data, response) = try await session.data(for: request) } catch { throw Failure.unreachable } guard let http = response as? HTTPURLResponse else { throw Failure.malformedResponse } switch http.statusCode { case 200: break case 401: throw Failure.rejected case 410: throw Failure.windowClosed default: throw Failure.malformedResponse } guard data.count <= maximumBytes, let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let nodeID = root["nodeID"] as? String, let appID = root["appID"] as? String, let relayURLText = root["relayURL"] as? String, let relayURL = URL(string: relayURLText), relayURL.scheme == "https", let signKey = Self.key(root["agentSignPub"] as? String), let kxKey = Self.key(root["agentKxPub"] as? String), let token = root["deviceToken"] as? String, !token.isEmpty else { throw Failure.malformedResponse } return Result( endpoint: RelayEndpoint( relayBaseURL: relayURL, nodeID: nodeID, agentSigningKey: signKey, agentKxKey: kxKey, deviceToken: token, deviceID: appID, nodeName: deviceName ), deviceID: appID ) } /// A key the node published must be exactly 32 bytes. A short one would be accepted by the /// decoder and then fail as an unreadable response, which reads to the user as "the node is /// broken" rather than "this pairing is wrong". private static func key(_ text: String?) -> Data? { guard let text, let data = Data(base64Encoded: text), data.count == RelayWire.keyBytes else { return nil } return data } }