// Sources/Core/Auth/Pairing.swift // T-2.2: QR parsing + pairing exchange import Foundation // MARK: - Errors enum PairingError: Error, Sendable { case invalidQR case networkError(Error) case serverError(Int, String) case decodingFailed } // MARK: - Wire types private struct PairRequestBody: Encodable { let pairingToken: String let deviceName: String // deviceToken / environment injected in T-2.4 (Push integration) } private struct PairResponseBody: Decodable { let bearerToken: String let sidecarId: String } // MARK: - PairingService struct PairingService: Sendable { // MARK: QR parsing /// Parses a `pi-remote://` URL produced by the sidecar `pi-remote pair` command. /// /// Expected format: /// ``` /// pi-remote://:?pair=&fp=&name= /// ``` static func parseQR(_ string: String) throws -> ( host: String, port: Int, pairingToken: String, fingerprint: String, name: String ) { var components = URLComponents(string: string) // The custom scheme confuses URLComponents on some inputs — patch it. if components == nil || components?.host == nil { // Try replacing custom scheme with https so standard parsing works. let patched = string.replacingOccurrences(of: "pi-remote://", with: "https://") components = URLComponents(string: patched) } guard let comps = components, let host = comps.host, !host.isEmpty, let port = comps.port else { throw PairingError.invalidQR } let items = comps.queryItems ?? [] func queryValue(_ name: String) throws -> String { guard let value = items.first(where: { $0.name == name })?.value, !value.isEmpty else { throw PairingError.invalidQR } return value } let pairingToken = try queryValue("pair") let fingerprint = try queryValue("fp") let name = try queryValue("name") return (host: host, port: port, pairingToken: pairingToken, fingerprint: fingerprint, name: name) } // MARK: Exchange /// Sends `POST /pair` to the sidecar and returns a persisted `SidecarCredential`. /// /// - Note: TLS pinning via `PinnedTrust` is wired in T-2.5. /// Until then the request uses plain HTTP or default TLS validation. func exchange( host: String, port: Int, pairingToken: String, fingerprint: String, name: String, deviceName: String ) async throws -> SidecarCredential { // Prefer HTTPS; the sidecar uses a self-signed cert that will be // pinned in T-2.5. For now plain HTTP is also acceptable during dev. guard let url = URL(string: "http://\(host):\(port)/pair") else { throw PairingError.invalidQR } var request = URLRequest(url: url, timeoutInterval: 15) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") let body = PairRequestBody(pairingToken: pairingToken, deviceName: deviceName) do { request.httpBody = try JSONEncoder().encode(body) } catch { throw PairingError.decodingFailed } let data: Data let response: URLResponse do { (data, response) = try await URLSession.shared.data(for: request) } catch { throw PairingError.networkError(error) } if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { let body = String(data: data, encoding: .utf8) ?? "" throw PairingError.serverError(http.statusCode, body) } let decoded: PairResponseBody do { decoded = try JSONDecoder().decode(PairResponseBody.self, from: data) } catch { throw PairingError.decodingFailed } return SidecarCredential( sidecarId: decoded.sidecarId, host: host, port: port, bearerToken: decoded.bearerToken, tlsFingerprint: fingerprint, sidecarName: name, pairedAt: Date() ) } }