From f6396bc70e48d19bacc34b987afa33707dbb0b37 Mon Sep 17 00:00:00 2001 From: jay Date: Fri, 15 May 2026 18:21:40 +0200 Subject: [PATCH] feat(T-2.2): Pairing flow, Keychain, QR scanner, TLS pinning stub --- Sources/Core/Auth/Keychain.swift | 97 ++++++++ Sources/Core/Auth/Pairing.swift | 144 ++++++++++++ Sources/Core/Auth/SidecarCredential.swift | 18 ++ Sources/UI/Pairing/PairingFlowView.swift | 261 ++++++++++++++++++++++ Sources/UI/Pairing/QRScannerView.swift | 126 +++++++++++ 5 files changed, 646 insertions(+) create mode 100644 Sources/Core/Auth/Keychain.swift create mode 100644 Sources/Core/Auth/Pairing.swift create mode 100644 Sources/Core/Auth/SidecarCredential.swift create mode 100644 Sources/UI/Pairing/PairingFlowView.swift create mode 100644 Sources/UI/Pairing/QRScannerView.swift diff --git a/Sources/Core/Auth/Keychain.swift b/Sources/Core/Auth/Keychain.swift new file mode 100644 index 0000000..3c834c0 --- /dev/null +++ b/Sources/Core/Auth/Keychain.swift @@ -0,0 +1,97 @@ +// Sources/Core/Auth/Keychain.swift +// T-2.2: Generic Keychain wrapper (kSecClassGenericPassword) + +import Foundation +import Security + +// MARK: - Errors + +enum KeychainError: Error, Sendable { + case notFound + case encodingFailed + case saveFailed(OSStatus) +} + +// MARK: - Keychain + +/// Thread-safe Keychain wrapper for `Codable` values stored as JSON data. +/// All items use `kSecClassGenericPassword` with a caller-supplied service key. +final class Keychain: Sendable { + + static let shared = Keychain() + + /// Canonical key used to store the active `SidecarCredential`. + static let credentialKey = "piremote.credential" + + private init() {} + + // MARK: Save + + /// Encodes `value` as JSON and upserts it into the Keychain under `key`. + func save(_ value: T, key: String) throws { + let data: Data + do { + data = try JSONEncoder().encode(value) + } catch { + throw KeychainError.encodingFailed + } + + // Attempt update first; if item doesn't exist, add it. + let query = baseQuery(for: key) + let updateAttributes: [CFString: Any] = [kSecValueData: data] + + let updateStatus = SecItemUpdate(query as CFDictionary, updateAttributes as CFDictionary) + if updateStatus == errSecItemNotFound { + var addQuery = query + addQuery[kSecValueData] = data + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw KeychainError.saveFailed(addStatus) + } + } else if updateStatus != errSecSuccess { + throw KeychainError.saveFailed(updateStatus) + } + } + + // MARK: Load + + /// Loads and JSON-decodes a value of type `T` stored under `key`. + func load(key: String) throws -> T { + var query = baseQuery(for: key) + query[kSecReturnData] = true + query[kSecMatchLimit] = kSecMatchLimitOne + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { + throw KeychainError.notFound + } + guard let data = result as? Data else { + throw KeychainError.notFound + } + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw KeychainError.encodingFailed + } + } + + // MARK: Delete + + /// Removes the item stored under `key` (no-op if absent). + func delete(key: String) { + SecItemDelete(baseQuery(for: key) as CFDictionary) + } + + // MARK: - Private helpers + + private func baseQuery(for key: String) -> [CFString: Any] { + [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: "de.vpsj.pi-remote", + kSecAttrAccount: key, + kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + } +} diff --git a/Sources/Core/Auth/Pairing.swift b/Sources/Core/Auth/Pairing.swift new file mode 100644 index 0000000..3684199 --- /dev/null +++ b/Sources/Core/Auth/Pairing.swift @@ -0,0 +1,144 @@ +// 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() + ) + } +} diff --git a/Sources/Core/Auth/SidecarCredential.swift b/Sources/Core/Auth/SidecarCredential.swift new file mode 100644 index 0000000..8217cc9 --- /dev/null +++ b/Sources/Core/Auth/SidecarCredential.swift @@ -0,0 +1,18 @@ +// Sources/Core/Auth/SidecarCredential.swift +// T-2.2: Pairing flow — credential model + +import Foundation + +/// Persisted after a successful pairing exchange. +/// Stored in Keychain under `Keychain.credentialKey`. +struct SidecarCredential: Codable, Sendable { + let sidecarId: String + let host: String + let port: Int + let bearerToken: String + /// SHA-256 hex fingerprint of the sidecar's self-signed TLS cert (from QR). + /// Used by `PinnedTrust` (wired in T-2.5) to validate the TLS handshake. + let tlsFingerprint: String + let sidecarName: String + let pairedAt: Date +} diff --git a/Sources/UI/Pairing/PairingFlowView.swift b/Sources/UI/Pairing/PairingFlowView.swift new file mode 100644 index 0000000..e8b8caf --- /dev/null +++ b/Sources/UI/Pairing/PairingFlowView.swift @@ -0,0 +1,261 @@ +// Sources/UI/Pairing/PairingFlowView.swift +// T-2.2: SwiftUI pairing flow (idle → scanning → pairing → success/error) + +import SwiftUI + +// MARK: - State machine + +private enum PairingState: Sendable { + case idle + case scanning + case pairing(qrPayload: QRPayload) + case success(credential: SidecarCredential) + case error(message: String, qrPayload: QRPayload?) +} + +/// Intermediate value capturing a parsed QR result. +private struct QRPayload: Sendable { + let host: String + let port: Int + let pairingToken: String + let fingerprint: String + let name: String +} + +// MARK: - View + +struct PairingFlowView: View { + + /// Called after the credential is saved to Keychain. + var onDismiss: (() -> Void)? + + @State private var state: PairingState = .idle + + private let service = PairingService() + + var body: some View { + Group { + switch state { + case .idle: + idleView + case .scanning: + scanningView + case .pairing: + pairingView + case .success(let credential): + successView(credential: credential) + case .error(let message, let payload): + errorView(message: message, retryPayload: payload) + } + } + .animation(.default, value: stateTag) + } + + // MARK: - Sub-views + + private var idleView: some View { + VStack(spacing: 24) { + Image(systemName: "qrcode.viewfinder") + .resizable() + .scaledToFit() + .frame(width: 100, height: 100) + .foregroundStyle(.secondary) + + Text("Pair with Pi Remote") + .font(.title2) + .bold() + + Text("Scan the QR code shown by `pi-remote pair` on your server.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + + Button(action: { state = .scanning }) { + Label("Tap to Scan", systemImage: "camera") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + .padding(32) + } + + private var scanningView: some View { + ZStack(alignment: .top) { + QRScannerView { rawString in + handleQRScan(rawString) + } + .ignoresSafeArea() + + VStack { + HStack { + Button(action: { state = .idle }) { + Label("Cancel", systemImage: "xmark.circle.fill") + .labelStyle(.iconOnly) + .font(.title) + .foregroundStyle(.white) + } + Spacer() + } + .padding() + + Text("Point at the pi-remote QR code") + .font(.subheadline) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: Capsule()) + .padding(.top, 4) + } + } + } + + private var pairingView: some View { + VStack(spacing: 20) { + ProgressView() + .controlSize(.large) + Text("Pairing…") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func successView(credential: SidecarCredential) -> some View { + VStack(spacing: 24) { + Image(systemName: "checkmark.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 80, height: 80) + .foregroundStyle(.green) + + Text("Connected to \(credential.sidecarName)") + .font(.title2) + .bold() + .multilineTextAlignment(.center) + + Text("Paired on \(credential.pairedAt.formatted(date: .abbreviated, time: .shortened))") + .foregroundStyle(.secondary) + .font(.subheadline) + + Button("Done") { + onDismiss?() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + .padding(32) + } + + private func errorView(message: String, retryPayload: QRPayload?) -> some View { + VStack(spacing: 24) { + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .scaledToFit() + .frame(width: 80, height: 80) + .foregroundStyle(.orange) + + Text("Pairing Failed") + .font(.title2) + .bold() + + Text(message) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + + Button(action: retryAction(for: retryPayload)) { + Label("Retry", systemImage: "arrow.counterclockwise") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) + } + .padding(32) + } + + // MARK: - Logic + + private func retryAction(for payload: QRPayload?) -> () -> Void { + if let payload { + // Retry the exchange with the already-parsed QR data. + return { startExchange(payload: payload) } + } else { + // QR parse failed — let the user scan again. + return { state = .scanning } + } + } + + private func handleQRScan(_ rawString: String) { + let payload: QRPayload + do { + let parsed = try PairingService.parseQR(rawString) + payload = QRPayload( + host: parsed.host, + port: parsed.port, + pairingToken: parsed.pairingToken, + fingerprint: parsed.fingerprint, + name: parsed.name + ) + } catch { + state = .error(message: "Invalid QR code — please try again.", qrPayload: nil) + return + } + + startExchange(payload: payload) + } + + private func startExchange(payload: QRPayload) { + state = .pairing(qrPayload: payload) + + let deviceName = UIDevice.current.name + + Task { @MainActor in + do { + let credential = try await service.exchange( + host: payload.host, + port: payload.port, + pairingToken: payload.pairingToken, + fingerprint: payload.fingerprint, + name: payload.name, + deviceName: deviceName + ) + try Keychain.shared.save(credential, key: Keychain.credentialKey) + state = .success(credential: credential) + } catch let err as PairingError { + state = .error(message: friendlyMessage(for: err), qrPayload: payload) + } catch { + state = .error(message: error.localizedDescription, qrPayload: payload) + } + } + } + + private func friendlyMessage(for error: PairingError) -> String { + switch error { + case .invalidQR: + return "The QR code doesn't look like a pi-remote URL." + case .networkError(let underlying): + return "Network error: \(underlying.localizedDescription)" + case .serverError(let code, let body): + return "Server returned \(code): \(body)" + case .decodingFailed: + return "Couldn't understand the server's response." + } + } + + // MARK: - Helpers + + /// Computed tag for `animation(value:)` — just needs to change when state changes. + private var stateTag: Int { + switch state { + case .idle: return 0 + case .scanning: return 1 + case .pairing: return 2 + case .success: return 3 + case .error: return 4 + } + } +} + +// MARK: - Preview + +#Preview { + PairingFlowView() +} diff --git a/Sources/UI/Pairing/QRScannerView.swift b/Sources/UI/Pairing/QRScannerView.swift new file mode 100644 index 0000000..a649568 --- /dev/null +++ b/Sources/UI/Pairing/QRScannerView.swift @@ -0,0 +1,126 @@ +// Sources/UI/Pairing/QRScannerView.swift +// T-2.2: AVFoundation QR scanner wrapped as UIViewRepresentable + +import AVFoundation +import SwiftUI + +/// A fullscreen AVFoundation-backed QR code scanner. +/// +/// Calls `onScan` exactly once with the raw QR string, then stops the +/// capture session so the caller can drive the next state transition. +struct QRScannerView: UIViewRepresentable { + + /// Called on the **main actor** with the raw decoded QR string. + let onScan: @MainActor (String) -> Void + + // MARK: UIViewRepresentable + + func makeCoordinator() -> Coordinator { + Coordinator(onScan: onScan) + } + + func makeUIView(context: Context) -> PreviewView { + let view = PreviewView() + let coordinator = context.coordinator + + let session = coordinator.session + + // Input + guard + let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device) + else { + return view + } + + guard session.canAddInput(input) else { return view } + session.addInput(input) + + // Output + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { return view } + session.addOutput(output) + + output.setMetadataObjectsDelegate(coordinator, + queue: DispatchQueue.main) + output.metadataObjectTypes = [.qr] + + // Preview layer + let previewLayer = AVCaptureVideoPreviewLayer(session: session) + previewLayer.videoGravity = .resizeAspectFill + view.previewLayer = previewLayer + view.layer.addSublayer(previewLayer) + + coordinator.previewLayer = previewLayer + + // Start capture on a background thread to avoid blocking the main queue. + Task.detached(priority: .userInitiated) { + session.startRunning() + } + + return view + } + + func updateUIView(_ uiView: PreviewView, context: Context) { + // Layout is handled inside PreviewView.layoutSubviews. + } + + static func dismantleUIView(_ uiView: PreviewView, coordinator: Coordinator) { + coordinator.stop() + } + + // MARK: - Coordinator + + @MainActor + final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { + + let session = AVCaptureSession() + var previewLayer: AVCaptureVideoPreviewLayer? + + private let onScan: @MainActor (String) -> Void + private var hasScanned = false + + init(onScan: @MainActor @escaping (String) -> Void) { + self.onScan = onScan + } + + // Called on main queue (set via setMetadataObjectsDelegate). + nonisolated func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + MainActor.assumeIsolated { + guard !hasScanned else { return } + guard + let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + object.type == .qr, + let string = object.stringValue + else { return } + + hasScanned = true + stop() + onScan(string) + } + } + + func stop() { + guard session.isRunning else { return } + Task.detached(priority: .userInitiated) { [session] in + session.stopRunning() + } + } + } + + // MARK: - PreviewView + + /// UIView subclass that keeps the `AVCaptureVideoPreviewLayer` sized to its bounds. + final class PreviewView: UIView { + var previewLayer: AVCaptureVideoPreviewLayer? + + override func layoutSubviews() { + super.layoutSubviews() + previewLayer?.frame = bounds + } + } +}