diff --git a/LocalDevVPN/ContentView.swift b/LocalDevVPN/ContentView.swift index de2aadb..a4b1d14 100644 --- a/LocalDevVPN/ContentView.swift +++ b/LocalDevVPN/ContentView.swift @@ -778,6 +778,7 @@ class TunnelManager: ObservableObject { struct ContentView: View { @StateObject private var tunnelManager = TunnelManager.shared + @ObservedObject private var pairingBridge = PairingBridge.shared @State private var showSettings = false @State var tunnel = false @AppStorage("autoConnect") private var autoConnect = false @@ -785,6 +786,18 @@ struct ContentView: View { @Environment(\.colorScheme) private var colorScheme var body: some View { + ZStack { + dashboard + PairingAuthorizationOverlay() + } + .onChange(of: pairingBridge.pendingRequest) { request in + // The authorization prompt is an overlay on the root view, so anything + // presented on top of it has to get out of the way first. + if request != nil { showSettings = false } + } + } + + private var dashboard: some View { NBNavigationStack { ScrollView { VStack(spacing: 16) { @@ -1239,6 +1252,8 @@ struct SettingsView: View { } } + PairingBridgeSection() + Section( header: Text("network_configuration"), footer: Text("allow_intermediate_addresses_desc") diff --git a/LocalDevVPN/LocalDevVPNApp.swift b/LocalDevVPN/LocalDevVPNApp.swift index 166eda7..e432b5b 100644 --- a/LocalDevVPN/LocalDevVPNApp.swift +++ b/LocalDevVPN/LocalDevVPNApp.swift @@ -42,8 +42,30 @@ struct LocalDevVPNApp: App { UIApplication.shared.open(callbackURL) } } + case "pair": + handlePairingRequest(url) default: break } } + + /// `localdevvpn://pair?client=…&callback=https://…&state=…` + /// + /// The web-facing half of the pairing bridge. A page in Safari cannot reach a + /// suspended app, so the site sends the user here instead: LocalDevVPN comes to + /// the front, asks for authorization, and — once the user approves — hands the + /// access token back through the callback's fragment. + private func handlePairingRequest(_ url: URL) { + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + let callback = queryItems? + .first(where: { $0.name == "callback" })? + .value + .flatMap { URL(string: $0) } + + PairingBridge.shared.handleDeepLinkRequest( + client: queryItems?.first(where: { $0.name == "client" })?.value, + callback: callback, + state: queryItems?.first(where: { $0.name == "state" })?.value + ) + } } diff --git a/LocalDevVPN/Localization/en.lproj/Localizable.strings b/LocalDevVPN/Localization/en.lproj/Localizable.strings index c84e8b0..c741a6a 100644 --- a/LocalDevVPN/Localization/en.lproj/Localizable.strings +++ b/LocalDevVPN/Localization/en.lproj/Localizable.strings @@ -151,4 +151,23 @@ "restart_title" = "Restart"; "restart_message" = "To apply the changes, you need to restart the application."; "confirmYes" = "Yes"; -"confirmNo" = "No"; \ No newline at end of file +"confirmNo" = "No"; + +/* MARK: Pairing Bridge */ +"pairing_bridge" = "Pairing Bridge"; +"pairing_bridge_allow" = "Allow local web clients"; +"pairing_bridge_address" = "Address"; +"pairing_bridge_footer" = "Lets a web page on this device ask LocalDevVPN for a pairing record over 127.0.0.1. Nothing is shared until you approve the request, and the record never leaves the device."; +"pairing_record" = "Pairing record"; +"pairing_record_none" = "None"; +"pairing_import_file" = "Import Pairing File…"; +"pairing_remove_record" = "Remove Pairing Record"; +"pairing_revoke_all" = "Revoke All Access"; +"pairing_record_shared_at" = "Pairing record shared %@"; +"pairing_authorized_at" = "Authorized %@"; +"pairing_request_title" = "Pairing request"; +"pairing_origin_unverified" = "Site name reported by the client — unverified"; +"pairing_request_body" = "%@ is asking LocalDevVPN for this device's pairing record. Only approve this if you started it."; +"pairing_code_hint" = "Approve only if the site shows this code."; +"pairing_deny" = "Deny"; +"pairing_approve" = "Approve"; diff --git a/LocalDevVPN/Pairing/DevicePairingService.swift b/LocalDevVPN/Pairing/DevicePairingService.swift new file mode 100644 index 0000000..97c6d03 --- /dev/null +++ b/LocalDevVPN/Pairing/DevicePairingService.swift @@ -0,0 +1,292 @@ +// +// DevicePairingService.swift +// LocalDevVPN +// +// Drives the device pairing flow that produces a pairing record. +// + +import Combine +import Foundation +import UIKit + +// MARK: - Flow State + +/// State of a pairing attempt. The web client sees `apiState` and `message`. +enum PairingFlowState: Equatable { + /// Nothing in flight. + case idle + /// The pairing flow cannot run on this OS build; `reason` explains why. + case unavailable(reason: String) + /// Waiting for the person holding the device. `code` carries a PIN when the + /// system flow shows one, `instruction` is what the user has to do. + case awaitingUserAction(instruction: String, code: String?) + /// The system flow is running and needs no user input right now. + case inProgress + case completed(fingerprint: String) + case failed(reason: String) + + var apiState: String { + switch self { + case .idle: return "idle" + case .unavailable: return "unavailable" + case .awaitingUserAction: return "awaiting_user_action" + case .inProgress: return "in_progress" + case .completed: return "completed" + case .failed: return "failed" + } + } + + var message: String? { + switch self { + case .idle, .inProgress: + return nil + case let .unavailable(reason): + return reason + case let .awaitingUserAction(instruction, _): + return instruction + case .completed: + return nil + case let .failed(reason): + return reason + } + } + + var code: String? { + if case let .awaitingUserAction(_, code) = self { return code } + return nil + } +} + +// MARK: - Flow Provider + +/// Whether a pairing mechanism can run, and which one it is. +struct PairingFlowAvailability { + let isAvailable: Bool + /// The OS mechanism this provider drives, for diagnostics. + let mechanism: String + /// Why it is (not) usable. Shown in the UI and returned by `GET /v1/status`. + let reason: String +} + +enum PairingFlowError: LocalizedError { + case unavailable(String) + case cancelled + + var errorDescription: String? { + switch self { + case let .unavailable(reason): return reason + case .cancelled: return "Pairing was cancelled." + } + } +} + +/// A source of pairing records. +/// +/// The bridge does not care where a record comes from, which keeps the system flow +/// and the manual import path interchangeable: both report progress through +/// `PairingFlowState` and finish with a `PairingRecord`. +protocol PairingFlowProvider: AnyObject { + var availability: PairingFlowAvailability { get } + func start( + update: @escaping (PairingFlowState) -> Void, + completion: @escaping (Result) -> Void + ) + func cancel() +} + +// MARK: - System Pairing Flow + +/// Apple's on-device / remote pairing flow. +/// +/// iOS has two pairing mechanisms that mint a lockdown pairing record, and neither +/// is reachable from a sandboxed third-party app (see `docs/pairing-bridge.md`): +/// +/// * iOS 16 and earlier: `lockdownd`'s `Pair` request, which a *host* sends over USB +/// or over the network and which raises the "Trust This Computer?" prompt. It is +/// gated on `com.apple.mobile.lockdown` access that apps do not get, so an app +/// cannot pair the device it runs on. +/// * iOS 17 and later: RemoteXPC "remote pairing" — the six-digit PIN Xcode shows +/// when you pair a device wirelessly. It is served by `remotepairingd` behind +/// `com.apple.internal.dt.remote.pairing`, an Apple-internal entitlement that is +/// not issued to third-party apps and would fail App Review. +/// +/// The provider therefore reports its availability honestly instead of shipping a +/// private-API path. A fork that carries the required entitlements can compile in a +/// real implementation behind `LOCALDEVVPN_NATIVE_PAIRING` without touching the +/// bridge, the API surface, or the UI. +final class SystemPairingFlowProvider: PairingFlowProvider { + var availability: PairingFlowAvailability { + #if LOCALDEVVPN_NATIVE_PAIRING + return NativePairingFlow.availability + #else + if #available(iOS 17.0, tvOS 17.0, *) { + return PairingFlowAvailability( + isAvailable: false, + mechanism: "remote-pairing (RemoteXPC)", + reason: "iOS 17+ mints pairing records through remotepairingd, which requires the " + + "com.apple.internal.dt.remote.pairing entitlement. Apple does not issue it to " + + "third-party apps, so LocalDevVPN cannot start the PIN flow itself. Import a " + + "pairing file instead." + ) + } else { + return PairingFlowAvailability( + isAvailable: false, + mechanism: "lockdown pairing (host trust)", + reason: "On this iOS version a pairing record is created by a trusted host that sends " + + "lockdownd a Pair request and raises the Trust This Computer prompt. Apps cannot " + + "reach lockdownd on the device they run on. Import a pairing file instead." + ) + } + #endif + } + + func start( + update: @escaping (PairingFlowState) -> Void, + completion: @escaping (Result) -> Void + ) { + #if LOCALDEVVPN_NATIVE_PAIRING + NativePairingFlow.start(update: update, completion: completion) + #else + let reason = availability.reason + update(.unavailable(reason: reason)) + completion(.failure(PairingFlowError.unavailable(reason))) + #endif + } + + func cancel() { + #if LOCALDEVVPN_NATIVE_PAIRING + NativePairingFlow.cancel() + #endif + } +} + +// MARK: - Pairing Service + +/// Owns the pairing record and the pairing flow. +/// +/// All mutation happens on the main queue; the bridge is a main-queue actor too, so +/// state stays consistent without extra locking. +final class DevicePairingService: ObservableObject { + static let shared = DevicePairingService() + + @Published private(set) var state: PairingFlowState = .idle + @Published private(set) var recordInfo: StoredRecordInfo? + /// Set when the flow needs the user to pick a pairing file; the settings UI + /// observes this and raises the document picker. + @Published var isRequestingFileImport = false + + private let store = PairingRecordStore.shared + private let systemProvider = SystemPairingFlowProvider() + + private init() { + recordInfo = store.info() + } + + var systemFlowAvailability: PairingFlowAvailability { + systemProvider.availability + } + + var hasRecord: Bool { + recordInfo != nil + } + + /// Raw record bytes. Only the bridge calls this, and only for a session the user + /// authorized. + func authorizedRecordData() -> Data? { + store.load()?.data + } + + /// Starts Apple's pairing flow when the OS exposes one, and otherwise falls back + /// to a user-driven import, which is the only App Store-safe way to get a record + /// onto the device today. + func begin(requestedBy client: String?) { + let availability = systemProvider.availability + VPNLogger.shared.log("Pairing bridge: pairing requested by \(client ?? "an unnamed client")") + + guard availability.isAvailable else { + VPNLogger.shared.log("Pairing bridge: system pairing flow unavailable (\(availability.mechanism))") + requestFileImport(reason: availability.reason) + return + } + + state = .inProgress + systemProvider.start( + update: { [weak self] newState in + DispatchQueue.main.async { self?.state = newState } + }, + completion: { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case let .success(record): + self.storeRecord(record, source: "system pairing flow") + case let .failure(error): + self.requestFileImport(reason: error.localizedDescription) + } + } + } + ) + } + + /// Puts the flow into "waiting for the user to hand us a pairing file" and asks + /// the UI to raise the document picker. + func requestFileImport(reason: String?) { + var instruction = "Import a pairing file to finish pairing." + if let reason = reason, !reason.isEmpty { + instruction = reason + } + state = .awaitingUserAction(instruction: instruction, code: nil) + #if os(iOS) + // Only raise the picker if someone is looking at the app; otherwise the + // state message tells them what to do when they come back. + if UIApplication.shared.applicationState == .active { + isRequestingFileImport = true + } + #endif + } + + func completeImport(from url: URL) { + let needsScope = url.startAccessingSecurityScopedResource() + defer { if needsScope { url.stopAccessingSecurityScopedResource() } } + + do { + let data = try Data(contentsOf: url) + let record = try PairingRecord(plistData: data) + storeRecord(record, source: "imported pairing file") + } catch let error as PairingRecordError { + fail(reason: error.localizedDescription) + } catch { + fail(reason: PairingRecordError.fileUnreadable.localizedDescription) + } + } + + func fail(reason: String) { + state = .failed(reason: reason) + VPNLogger.shared.log("Pairing bridge: pairing failed – \(reason)") + } + + func cancel() { + systemProvider.cancel() + isRequestingFileImport = false + state = hasRecord ? .idle : .failed(reason: PairingFlowError.cancelled.localizedDescription) + } + + func clearRecord() { + store.clear() + recordInfo = nil + state = .idle + VPNLogger.shared.log("Pairing bridge: pairing record removed") + } + + private func storeRecord(_ record: PairingRecord, source: String) { + do { + try store.save(record) + recordInfo = store.info() + state = .completed(fingerprint: record.fingerprint) + isRequestingFileImport = false + VPNLogger.shared.log("Pairing bridge: stored pairing record \(record.fingerprint) (\(source))") + } catch { + fail(reason: "The pairing record could not be saved: \(error.localizedDescription)") + } + } +} diff --git a/LocalDevVPN/Pairing/PairingBridge.swift b/LocalDevVPN/Pairing/PairingBridge.swift new file mode 100644 index 0000000..3c89c9c --- /dev/null +++ b/LocalDevVPN/Pairing/PairingBridge.swift @@ -0,0 +1,810 @@ +// +// PairingBridge.swift +// LocalDevVPN +// +// Local-only HTTP bridge that lets a web signer ask LocalDevVPN to pair the +// device and hand back the resulting pairing record. +// + +import Combine +import Foundation +import Security +import UIKit + +// MARK: - Errors + +enum PairingBridgeError: LocalizedError { + case noAvailablePort + + var errorDescription: String? { + switch self { + case .noAvailablePort: + return "No loopback port in the LocalDevVPN range was free." + } + } +} + +// MARK: - UI models + +/// An authorization the user has to answer before a client gets anything. +struct PairingAuthorizationRequest: Identifiable, Equatable { + let id: String + let clientName: String + /// Origin taken from the request's `Origin` header — the browser sets this and a + /// page cannot forge it. + let verifiedOrigin: String? + /// Origin the client claimed in its request body. Never trusted, only shown when + /// there is nothing better. + let declaredOrigin: String? + /// Shown by the requesting page so the user can confirm they are approving the + /// tab in front of them. `nil` for deep-link requests, where the user arrived + /// from the site by tapping a link. + let verificationCode: String? + /// Where an approved deep-link request will send the access token back to. + let callbackURL: URL? + let expiresAt: Date + + var isDeepLink: Bool { callbackURL != nil } + + var displayOrigin: String { + if let origin = verifiedOrigin, !origin.isEmpty { return origin } + if let callback = callbackURL, let host = callback.host { + return "\(callback.scheme ?? "https")://\(host)" + } + if let declared = declaredOrigin, !declared.isEmpty { return declared } + return "Unknown site" + } + + var isOriginVerified: Bool { + verifiedOrigin != nil || callbackURL != nil + } +} + +/// A client the user has authorized, for the transparency list in Settings. +struct AuthorizedClientInfo: Identifiable, Equatable { + let id: String + let clientName: String + let origin: String + let authorizedAt: Date + let recordDeliveredAt: Date? +} + +// MARK: - Bridge + +/// The bridge itself: session bookkeeping, authorization and request routing. +/// +/// Everything runs on the main queue. The HTTP server hands requests over with a +/// completion block, so no bridge state is ever touched from the network queue. +final class PairingBridge: ObservableObject { + static let shared = PairingBridge() + + /// Bumped when the wire format changes. + static let apiVersion = 1 + /// Ports a web client probes, in order, to find the bridge. + static let candidatePorts: [UInt16] = [19842, 19843, 19844] + /// Required on every request. Because it is not a CORS-safelisted header, a + /// cross-origin caller is forced through a preflight, so no page can poke the + /// bridge with a "simple" request it never gets to read the answer to. + static let clientHeader = "X-LocalDevVPN-Client" + + private static let enabledDefaultsKey = "pairingBridgeEnabled" + private static let authorizationTimeout: TimeInterval = 120 + private static let sessionLifetime: TimeInterval = 15 * 60 + private static let backgroundGracePeriod: TimeInterval = 25 + private static let maximumSessionsPerMinute = 5 + + @Published private(set) var isEnabled: Bool + @Published private(set) var port: UInt16? + @Published private(set) var lastError: String? + @Published private(set) var pendingRequest: PairingAuthorizationRequest? + @Published private(set) var authorizedClients: [AuthorizedClientInfo] = [] + + private let server = PairingHTTPServer() + private let pairingService = DevicePairingService.shared + private var sessions: [String: Session] = [:] + private var sessionCreationTimestamps: [Date] = [] + private var expiryTimer: Timer? + private var backgroundTask: UIBackgroundTaskIdentifier = .invalid + private var lifecycleObservers: [NSObjectProtocol] = [] + + var baseURL: String? { + guard let port = port else { return nil } + return "http://127.0.0.1:\(port)" + } + + private init() { + isEnabled = UserDefaults.standard.bool(forKey: PairingBridge.enabledDefaultsKey) + + lifecycleObservers = [ + NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.applicationDidEnterBackground() + }, + NotificationCenter.default.addObserver( + forName: UIApplication.willEnterForegroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.applicationWillEnterForeground() + }, + ] + + if isEnabled { + startServer() + } + } + + // MARK: - Lifecycle + + func setEnabled(_ enabled: Bool) { + guard enabled != isEnabled else { return } + isEnabled = enabled + UserDefaults.standard.set(enabled, forKey: PairingBridge.enabledDefaultsKey) + + if enabled { + startServer() + } else { + revokeAll() + stopServer() + } + } + + private func startServer() { + guard !server.isRunning else { return } + lastError = nil + server.start(candidatePorts: PairingBridge.candidatePorts) { [weak self] request, respond in + DispatchQueue.main.async { + guard let self = self else { + respond(.error("unavailable", "The pairing bridge is not running.", status: 503)) + return + } + self.handle(request, respond: respond) + } + } completion: { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case let .success(port): + self.port = port + self.startExpiryTimer() + VPNLogger.shared.log("Pairing bridge listening on 127.0.0.1:\(port)") + case let .failure(error): + self.port = nil + self.lastError = error.localizedDescription + self.isEnabled = false + UserDefaults.standard.set(false, forKey: PairingBridge.enabledDefaultsKey) + VPNLogger.shared.log("Pairing bridge failed to start: \(error.localizedDescription)") + } + } + } + } + + private func stopServer() { + server.stop() + port = nil + expiryTimer?.invalidate() + expiryTimer = nil + endBackgroundGrace() + VPNLogger.shared.log("Pairing bridge stopped") + } + + private func startExpiryTimer() { + expiryTimer?.invalidate() + let timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in + self?.expireStaleSessions() + } + expiryTimer = timer + } + + private func applicationDidEnterBackground() { + // Nobody can answer an authorization prompt while the app is in the + // background, so a pending request is dropped rather than left open. + if pendingRequest != nil { + denyPendingRequest(reason: "LocalDevVPN was backgrounded") + } + + guard isEnabled, server.isRunning else { return } + + // iOS suspends the app moments after it leaves the screen. Hold a short + // assertion so a page that was just handed a token can still fetch the + // record, then shut the listener down. + backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "LocalDevVPN.PairingBridge") { [weak self] in + self?.endBackgroundGrace() + } + DispatchQueue.main.asyncAfter(deadline: .now() + PairingBridge.backgroundGracePeriod) { [weak self] in + self?.endBackgroundGrace() + } + } + + private func applicationWillEnterForeground() { + guard isEnabled else { return } + endBackgroundTaskOnly() + startServer() + } + + private func endBackgroundGrace() { + guard backgroundTask != .invalid else { return } + server.stop() + port = nil + endBackgroundTaskOnly() + } + + private func endBackgroundTaskOnly() { + guard backgroundTask != .invalid else { return } + UIApplication.shared.endBackgroundTask(backgroundTask) + backgroundTask = .invalid + } + + // MARK: - Authorization + + func approvePendingRequest() { + guard let request = pendingRequest, var session = sessions[request.id] else { return } + + let token = PairingBridge.randomToken() + session.state = .authorized + session.accessToken = token + session.authorizedAt = Date() + session.lastSeenAt = Date() + sessions[session.id] = session + pendingRequest = nil + refreshAuthorizedClients() + + VPNLogger.shared.log("Pairing bridge: authorized \(session.displayOrigin)") + + if let callback = session.callbackURL { + openCallback(callback, session: session, token: token) + } + } + + func denyPendingRequest(reason: String = "Denied by the user") { + guard let request = pendingRequest, var session = sessions[request.id] else { + pendingRequest = nil + return + } + session.state = .denied + session.accessToken = nil + sessions[session.id] = session + pendingRequest = nil + VPNLogger.shared.log("Pairing bridge: denied \(session.displayOrigin) (\(reason))") + } + + func revokeAll() { + guard !sessions.isEmpty || pendingRequest != nil else { return } + sessions.removeAll() + pendingRequest = nil + refreshAuthorizedClients() + VPNLogger.shared.log("Pairing bridge: all client authorizations revoked") + } + + private func refreshAuthorizedClients() { + authorizedClients = sessions.values + .filter { $0.state == .authorized } + .sorted { ($0.authorizedAt ?? $0.createdAt) > ($1.authorizedAt ?? $1.createdAt) } + .map { + AuthorizedClientInfo( + id: $0.id, + clientName: $0.clientName, + origin: $0.displayOrigin, + authorizedAt: $0.authorizedAt ?? $0.createdAt, + recordDeliveredAt: $0.recordDeliveredAt + ) + } + } + + private func expireStaleSessions() { + let now = Date() + var changed = false + + for (id, session) in sessions { + switch session.state { + case .pendingAuthorization: + if now.timeIntervalSince(session.createdAt) > PairingBridge.authorizationTimeout { + sessions.removeValue(forKey: id) + if pendingRequest?.id == id { pendingRequest = nil } + changed = true + } + case .authorized: + let start = session.authorizedAt ?? session.createdAt + if now.timeIntervalSince(start) > PairingBridge.sessionLifetime { + sessions.removeValue(forKey: id) + changed = true + } + case .denied, .revoked: + if now.timeIntervalSince(session.createdAt) > PairingBridge.sessionLifetime { + sessions.removeValue(forKey: id) + changed = true + } + } + } + + if changed { refreshAuthorizedClients() } + } + + // MARK: - Deep link entry point + + /// Handles `localdevvpn://pair`, the flow that works when the user is looking at + /// the website: the site sends them here, they approve, and LocalDevVPN sends + /// them back with an access token in the callback's fragment. + func handleDeepLinkRequest(client: String?, callback: URL?, state: String?) { + setEnabled(true) + + guard let callback = callback else { + // No callback: the site polls over loopback instead, nothing to prepare. + return + } + + guard PairingBridge.isAcceptableCallback(callback) else { + lastError = "That pairing link has an unsupported callback URL." + VPNLogger.shared.log("Pairing bridge: rejected callback with scheme \(callback.scheme ?? "none")") + return + } + + let session = Session( + id: PairingBridge.randomToken(), + clientName: PairingBridge.sanitize(client) ?? "Web signer", + verifiedOrigin: nil, + declaredOrigin: nil, + verificationCode: nil, + callbackURL: callback, + callbackState: PairingBridge.sanitize(state, limit: 128), + createdAt: Date(), + state: .pendingAuthorization, + accessToken: nil, + authorizedAt: nil, + lastSeenAt: Date(), + recordDeliveredAt: nil + ) + sessions[session.id] = session + pendingRequest = session.authorizationRequest(timeout: PairingBridge.authorizationTimeout) + } + + private func openCallback(_ callback: URL, session: Session, token: String) { + guard var components = URLComponents(url: callback, resolvingAgainstBaseURL: false) else { return } + + var fragment = "ldv_token=\(token)&ldv_session=\(session.id)" + if let port = port { fragment += "&ldv_port=\(port)" } + fragment += "&ldv_api=\(PairingBridge.apiVersion)" + if let state = session.callbackState, + let encoded = state.addingPercentEncoding(withAllowedCharacters: .alphanumerics) { + fragment += "&ldv_state=\(encoded)" + } + // The token travels in the fragment so it never reaches the site's server in + // a request line, a referrer or an access log. + components.fragment = fragment + + guard let url = components.url else { return } + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + + static func isAcceptableCallback(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased() else { return false } + if scheme == "https" { return true } + if scheme == "http" { + let host = url.host?.lowercased() + return host == "localhost" || host == "127.0.0.1" || host == "[::1]" || host == "::1" + } + return false + } + + // MARK: - Request handling + + private func handle(_ request: HTTPRequest, respond: @escaping (HTTPResponse) -> Void) { + let origin = request.header("origin") + + if request.method == "OPTIONS" { + var preflight = withCORS(.empty(status: 204), origin: origin) + // Chromium's Private Network Access check. WebKit does not implement it, + // so this only matters for non-Safari clients; answering it does not + // loosen anything, because access still hangs on the user approving the + // request in the app. + if request.header("access-control-request-private-network")?.lowercased() == "true" { + preflight.headers["Access-Control-Allow-Private-Network"] = "true" + } + respond(preflight) + return + } + + // A page that reaches the bridge through a hostname resolving to 127.0.0.1 + // (DNS rebinding) sends that hostname in Host; only the literal loopback + // authority is accepted. + guard isAcceptableHost(request.header("host")) else { + respond(withCORS(.error("invalid_host", "Use http://127.0.0.1 to reach the bridge.", status: 421), origin: origin)) + return + } + + let clientHeader = request.header(PairingBridge.clientHeader) ?? "" + guard !clientHeader.isEmpty else { + respond(withCORS(.error( + "missing_client_header", + "Send \(PairingBridge.clientHeader) with every request.", + status: 400 + ), origin: origin)) + return + } + + expireStaleSessions() + respond(withCORS(route(request, origin: origin), origin: origin)) + } + + private func route(_ request: HTTPRequest, origin: String?) -> HTTPResponse { + switch (request.method, request.path) { + case ("GET", "/v1/status"): + return statusResponse() + + case ("POST", "/v1/sessions"): + return createSession(request, origin: origin) + + case ("GET", "/v1/pairing-record"): + return pairingRecordResponse(request) + + case ("POST", "/v1/tunnel"): + return tunnelResponse(request) + + default: + break + } + + let prefix = "/v1/sessions/" + guard request.path.hasPrefix(prefix) else { + return .error("not_found", "Unknown endpoint.", status: 404) + } + + var identifier = String(request.path.dropFirst(prefix.count)) + var wantsPairing = false + if identifier.hasSuffix("/pairing") { + identifier = String(identifier.dropLast("/pairing".count)) + wantsPairing = true + } + + guard !identifier.isEmpty, let session = sessions[identifier] else { + return .error("unknown_session", "That session does not exist or has expired.", status: 404) + } + + if wantsPairing { + guard request.method == "POST" else { + return .error("method_not_allowed", "Use POST.", status: 405) + } + guard authorizedSession(for: request)?.id == session.id else { + return unauthorizedResponse() + } + pairingService.begin(requestedBy: session.clientName) + return .json(sessionBody(sessions[session.id] ?? session), status: 202) + } + + switch request.method { + case "GET": + touch(session.id) + return .json(sessionBody(sessions[session.id] ?? session)) + case "DELETE": + sessions.removeValue(forKey: session.id) + if pendingRequest?.id == session.id { pendingRequest = nil } + refreshAuthorizedClients() + return .empty(status: 204) + default: + return .error("method_not_allowed", "Use GET or DELETE.", status: 405) + } + } + + // MARK: - Endpoints + + private func statusResponse() -> HTTPResponse { + let availability = pairingService.systemFlowAvailability + let addresses = TunnelManager.shared.configuredAddresses + + return .json([ + "api": PairingBridge.apiVersion, + "app": "LocalDevVPN", + "version": Bundle.main.shortVersion, + "authorization_required": true, + "client_header": PairingBridge.clientHeader, + "tunnel": [ + "status": PairingBridge.describe(TunnelManager.shared.tunnelStatus), + "interface_ip": addresses.interfaceIP, + "device_ip": addresses.peerIP, + ], + "pairing": [ + "system_flow_available": availability.isAvailable, + "mechanism": availability.mechanism, + "reason": availability.reason, + ], + // Whether a record exists, never the record or its fingerprint: those + // need an authorized session. + "record": ["available": pairingService.hasRecord], + ]) + } + + private func createSession(_ request: HTTPRequest, origin: String?) -> HTTPResponse { + let now = Date() + sessionCreationTimestamps = sessionCreationTimestamps.filter { now.timeIntervalSince($0) < 60 } + guard sessionCreationTimestamps.count < PairingBridge.maximumSessionsPerMinute else { + return .error("rate_limited", "Too many authorization requests. Try again in a minute.", status: 429) + } + + if pendingRequest != nil { + return .error( + "authorization_pending", + "LocalDevVPN is already asking the user about another request.", + status: 409 + ) + } + + let body = request.jsonBody ?? [:] + let clientName = PairingBridge.sanitize(body["client"] as? String) ?? "Web signer" + let declaredOrigin = PairingBridge.sanitize(body["origin"] as? String, limit: 128) + + sessionCreationTimestamps.append(now) + + let session = Session( + id: PairingBridge.randomToken(), + clientName: clientName, + verifiedOrigin: PairingBridge.sanitize(origin, limit: 128), + declaredOrigin: declaredOrigin, + verificationCode: PairingBridge.randomCode(), + callbackURL: nil, + callbackState: nil, + createdAt: now, + state: .pendingAuthorization, + accessToken: nil, + authorizedAt: nil, + lastSeenAt: now, + recordDeliveredAt: nil + ) + sessions[session.id] = session + pendingRequest = session.authorizationRequest(timeout: PairingBridge.authorizationTimeout) + + VPNLogger.shared.log("Pairing bridge: authorization requested by \(session.displayOrigin)") + + var response = sessionBody(session) + if let code = session.verificationCode { response["verification_code"] = code } + response["poll_after_ms"] = 1000 + return .json(response, status: 201) + } + + private func pairingRecordResponse(_ request: HTTPRequest) -> HTTPResponse { + guard var session = authorizedSession(for: request) else { return unauthorizedResponse() } + + guard let data = pairingService.authorizedRecordData(), + let info = pairingService.recordInfo + else { + return .error( + "no_pairing_record", + "No pairing record is available yet. Start pairing first.", + status: 409 + ) + } + + session.lastSeenAt = Date() + session.recordDeliveredAt = Date() + sessions[session.id] = session + refreshAuthorizedClients() + VPNLogger.shared.log("Pairing bridge: pairing record \(info.fingerprint) delivered to \(session.displayOrigin)") + + let accept = request.header("accept")?.lowercased() ?? "" + if accept.contains("application/x-plist") || accept.contains("application/octet-stream") { + return .binary(data, contentType: "application/x-plist") + } + + return .json([ + "format": "plist", + "encoding": "base64", + "data": data.base64EncodedString(), + "fingerprint": info.fingerprint, + "host_id": info.hostID, + ]) + } + + private func tunnelResponse(_ request: HTTPRequest) -> HTTPResponse { + guard authorizedSession(for: request) != nil else { return unauthorizedResponse() } + + let action = (request.jsonBody?["action"] as? String)?.lowercased() ?? "status" + let manager = TunnelManager.shared + + switch action { + case "start": + manager.startVPN() + case "stop": + manager.stopVPN() + case "status": + break + default: + return .error("bad_request", "Unknown action.", status: 400) + } + + let addresses = manager.configuredAddresses + return .json([ + "status": PairingBridge.describe(manager.tunnelStatus), + "interface_ip": addresses.interfaceIP, + "device_ip": addresses.peerIP, + ]) + } + + // MARK: - Helpers + + private func sessionBody(_ session: Session) -> [String: Any] { + var body: [String: Any] = [ + "session_id": session.id, + "state": session.state.rawValue, + "client": session.clientName, + ] + + switch session.state { + case .pendingAuthorization: + let remaining = PairingBridge.authorizationTimeout - Date().timeIntervalSince(session.createdAt) + body["expires_in"] = max(0, Int(remaining)) + case .authorized: + let start = session.authorizedAt ?? session.createdAt + let remaining = PairingBridge.sessionLifetime - Date().timeIntervalSince(start) + body["expires_in"] = max(0, Int(remaining)) + if let token = session.accessToken { body["access_token"] = token } + + let state = pairingService.state + var pairing: [String: Any] = ["state": state.apiState] + if let message = state.message { pairing["message"] = message } + if let code = state.code { pairing["code"] = code } + body["pairing"] = pairing + + var record: [String: Any] = ["available": pairingService.hasRecord] + if let info = pairingService.recordInfo { + record["fingerprint"] = info.fingerprint + } + body["record"] = record + case .denied, .revoked: + body["expires_in"] = 0 + } + + return body + } + + private func authorizedSession(for request: HTTPRequest) -> Session? { + guard let header = request.header("authorization") else { return nil } + let parts = header.split(separator: " ", maxSplits: 1) + guard parts.count == 2, parts[0].lowercased() == "bearer" else { return nil } + + let token = String(parts[1]).trimmingCharacters(in: .whitespaces) + guard !token.isEmpty else { return nil } + + for session in sessions.values where session.state == .authorized { + guard let stored = session.accessToken else { continue } + if PairingBridge.constantTimeEquals(stored, token) { return session } + } + return nil + } + + private func unauthorizedResponse() -> HTTPResponse { + var response = HTTPResponse.error( + "unauthorized", + "This request needs an access token from a session the user approved in LocalDevVPN.", + status: 401 + ) + response.headers["WWW-Authenticate"] = "Bearer realm=\"LocalDevVPN\"" + return response + } + + private func touch(_ sessionID: String) { + guard var session = sessions[sessionID] else { return } + session.lastSeenAt = Date() + sessions[sessionID] = session + } + + private func isAcceptableHost(_ host: String?) -> Bool { + guard let host = host?.lowercased() else { return false } + + let authority: String + if host.hasPrefix("["), let closing = host.firstIndex(of: "]") { + authority = String(host[host.startIndex ... closing]) + } else { + authority = host.split(separator: ":").first.map(String.init) ?? host + } + + return authority == "127.0.0.1" || authority == "localhost" || authority == "[::1]" + } + + private func withCORS(_ response: HTTPResponse, origin: String?) -> HTTPResponse { + var response = response + // Any origin may *ask*; nothing is handed over without the user approving it + // in the app, and credentials are never allowed, so the browser never + // attaches ambient cookies to these requests. + response.headers["Access-Control-Allow-Origin"] = origin ?? "*" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, DELETE, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, \(PairingBridge.clientHeader)" + response.headers["Access-Control-Max-Age"] = "600" + response.headers["Vary"] = "Origin" + return response + } + + private static func describe(_ status: TunnelManager.TunnelStatus) -> String { + switch status { + case .disconnected: return "disconnected" + case .connecting: return "connecting" + case .connected: return "connected" + case .disconnecting: return "disconnecting" + case .error: return "error" + } + } + + private static func sanitize(_ value: String?, limit: Int = 64) -> String? { + guard let value = value else { return nil } + let stripped = value.components(separatedBy: .controlCharacters).joined() + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !stripped.isEmpty else { return nil } + return String(stripped.prefix(limit)) + } + + private static func randomBytes(_ count: Int) -> Data { + var bytes = [UInt8](repeating: 0, count: count) + if SecRandomCopyBytes(kSecRandomDefault, count, &bytes) != errSecSuccess { + for index in 0 ..< count { bytes[index] = UInt8.random(in: 0 ... 255) } + } + return Data(bytes) + } + + /// 256 bits, base64url, used for both session ids and access tokens. + static func randomToken() -> String { + randomBytes(32) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private static func randomCode() -> String { + let bytes = randomBytes(4) + let value = bytes.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } % 1_000_000 + return String(format: "%06u", value) + } + + private static func constantTimeEquals(_ lhs: String, _ rhs: String) -> Bool { + let left = Array(lhs.utf8) + let right = Array(rhs.utf8) + guard left.count == right.count else { return false } + var difference: UInt8 = 0 + for index in 0 ..< left.count { + difference |= left[index] ^ right[index] + } + return difference == 0 + } + + // MARK: - Session + + private struct Session { + enum State: String { + case pendingAuthorization = "pending_authorization" + case authorized + case denied + case revoked + } + + let id: String + let clientName: String + let verifiedOrigin: String? + let declaredOrigin: String? + let verificationCode: String? + let callbackURL: URL? + let callbackState: String? + let createdAt: Date + var state: State + var accessToken: String? + var authorizedAt: Date? + var lastSeenAt: Date + var recordDeliveredAt: Date? + + var displayOrigin: String { + authorizationRequest(timeout: 0).displayOrigin + } + + func authorizationRequest(timeout: TimeInterval) -> PairingAuthorizationRequest { + PairingAuthorizationRequest( + id: id, + clientName: clientName, + verifiedOrigin: verifiedOrigin, + declaredOrigin: declaredOrigin, + verificationCode: verificationCode, + callbackURL: callbackURL, + expiresAt: createdAt.addingTimeInterval(timeout) + ) + } + } +} diff --git a/LocalDevVPN/Pairing/PairingBridgeViews.swift b/LocalDevVPN/Pairing/PairingBridgeViews.swift new file mode 100644 index 0000000..41e6d49 --- /dev/null +++ b/LocalDevVPN/Pairing/PairingBridgeViews.swift @@ -0,0 +1,276 @@ +// +// PairingBridgeViews.swift +// LocalDevVPN +// +// The two pieces of UI the pairing bridge needs: a settings section, and the +// authorization prompt that gates every record hand-off. +// + +import SwiftUI + +#if os(iOS) + import UniformTypeIdentifiers +#endif + +/// New strings ship in `en.lproj` only; every other localization falls back to the +/// English text here instead of showing a raw key until it is translated. +private func bridgeText(_ key: String, _ fallback: String) -> String { + NSLocalizedString(key, value: fallback, comment: "") +} + +private let pairingTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter +}() + +// MARK: - Settings + +/// Lives inside `SettingsView`'s list. +struct PairingBridgeSection: View { + @ObservedObject private var bridge = PairingBridge.shared + @ObservedObject private var pairing = DevicePairingService.shared + + private var enabledBinding: Binding { + Binding( + get: { bridge.isEnabled }, + set: { bridge.setEnabled($0) } + ) + } + + var body: some View { + Section( + header: Text(bridgeText("pairing_bridge", "Pairing Bridge")), + footer: Text(footer) + ) { + Toggle(bridgeText("pairing_bridge_allow", "Allow local web clients"), isOn: enabledBinding) + + if let address = bridge.baseURL { + HStack { + Text(bridgeText("pairing_bridge_address", "Address")) + Spacer() + Text(address) + .font(.system(.footnote, design: .monospaced)) + .foregroundColor(.secondary) + } + } + + if let error = bridge.lastError { + Text(error) + .font(.caption) + .foregroundColor(.red) + } + + HStack { + Text(bridgeText("pairing_record", "Pairing record")) + Spacer() + Text(recordStatus) + .font(.footnote) + .foregroundColor(.secondary) + } + + if let message = pairing.state.message, !message.isEmpty { + Text(message) + .font(.caption) + .foregroundColor(.secondary) + } + + #if os(iOS) + Button { + pairing.isRequestingFileImport = true + } label: { + Label( + bridgeText("pairing_import_file", "Import Pairing File…"), + systemImage: "square.and.arrow.down" + ) + } + .fileImporter( + isPresented: $pairing.isRequestingFileImport, + allowedContentTypes: [UTType.propertyList, UTType.data], + allowsMultipleSelection: false + ) { result in + switch result { + case let .success(urls): + if let url = urls.first { pairing.completeImport(from: url) } + case let .failure(error): + pairing.fail(reason: error.localizedDescription) + } + } + #endif + + if pairing.hasRecord { + Button { + pairing.clearRecord() + } label: { + Label( + bridgeText("pairing_remove_record", "Remove Pairing Record"), + systemImage: "trash" + ) + .foregroundColor(.red) + } + } + + if !bridge.authorizedClients.isEmpty { + ForEach(bridge.authorizedClients) { client in + VStack(alignment: .leading, spacing: 2) { + Text(client.origin) + .font(.footnote) + Text(deliveryDescription(for: client)) + .font(.caption2) + .foregroundColor(.secondary) + } + } + + Button { + bridge.revokeAll() + } label: { + Text(bridgeText("pairing_revoke_all", "Revoke All Access")) + .foregroundColor(.red) + } + } + } + } + + private var footer: String { + let availability = pairing.systemFlowAvailability + let base = bridgeText( + "pairing_bridge_footer", + "Lets a web page on this device ask LocalDevVPN for a pairing record over 127.0.0.1. " + + "Nothing is shared until you approve the request, and the record never leaves the device." + ) + return availability.isAvailable ? base : base + "\n\n" + availability.reason + } + + private var recordStatus: String { + guard let info = pairing.recordInfo else { + return bridgeText("pairing_record_none", "None") + } + return info.fingerprint + } + + private func deliveryDescription(for client: AuthorizedClientInfo) -> String { + if let delivered = client.recordDeliveredAt { + return String( + format: bridgeText("pairing_record_shared_at", "Pairing record shared %@"), + pairingTimeFormatter.string(from: delivered) + ) + } + return String( + format: bridgeText("pairing_authorized_at", "Authorized %@"), + pairingTimeFormatter.string(from: client.authorizedAt) + ) + } +} + +// MARK: - Authorization prompt + +/// Shown over the whole app when a client asks for access. +/// +/// This is an overlay rather than a sheet on purpose: it has to appear no matter +/// what else the app is presenting, and it is the one thing standing between a page +/// on this device and the pairing record. +struct PairingAuthorizationOverlay: View { + @ObservedObject private var bridge = PairingBridge.shared + + var body: some View { + if let request = bridge.pendingRequest { + ZStack { + Color.black.opacity(0.45) + .ignoresSafeArea() + + card(for: request) + .padding(.horizontal, 24) + } + .transition(.opacity) + } + } + + private func card(for request: PairingAuthorizationRequest) -> some View { + VStack(spacing: 16) { + Image(systemName: "lock.shield") + .font(.largeTitle) + .foregroundColor(.accentColor) + + Text(bridgeText("pairing_request_title", "Pairing request")) + .font(.headline) + + VStack(spacing: 4) { + Text(request.displayOrigin) + .font(.body.weight(.semibold)) + .multilineTextAlignment(.center) + if !request.isOriginVerified { + Text(bridgeText("pairing_origin_unverified", "Site name reported by the client — unverified")) + .font(.caption2) + .foregroundColor(.orange) + .multilineTextAlignment(.center) + } + } + + Text(String( + format: bridgeText( + "pairing_request_body", + "%@ is asking LocalDevVPN for this device's pairing record. Only approve this if you started it." + ), + request.clientName + )) + .font(.footnote) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + if let code = request.verificationCode { + VStack(spacing: 4) { + Text(code) + .font(.system(.title, design: .monospaced)) + .fontWeight(.bold) + Text(bridgeText("pairing_code_hint", "Approve only if the site shows this code.")) + .font(.caption2) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(.vertical, 4) + } + + HStack(spacing: 12) { + Button { + bridge.denyPendingRequest() + } label: { + Text(bridgeText("pairing_deny", "Deny")) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .buttonStyle(PlainButtonStyle()) + .background(Color.gray.opacity(0.25)) + .cornerRadius(10) + + Button { + bridge.approvePendingRequest() + } label: { + Text(bridgeText("pairing_approve", "Approve")) + .fontWeight(.semibold) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .buttonStyle(PlainButtonStyle()) + .background(Color.accentColor) + .cornerRadius(10) + } + } + .padding(20) + .frame(maxWidth: 420) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(cardBackground) + ) + .shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10) + } + + private var cardBackground: Color { + #if os(tvOS) + return Color.black.opacity(0.9) + #else + return Color(.secondarySystemBackground) + #endif + } +} diff --git a/LocalDevVPN/Pairing/PairingHTTPMessages.swift b/LocalDevVPN/Pairing/PairingHTTPMessages.swift new file mode 100644 index 0000000..ab8edd7 --- /dev/null +++ b/LocalDevVPN/Pairing/PairingHTTPMessages.swift @@ -0,0 +1,180 @@ +// +// PairingHTTPMessages.swift +// LocalDevVPN +// +// HTTP/1.1 request parsing and response building for the pairing bridge. +// Foundation only, so the wire format can be exercised without a network stack. +// + +import Foundation + +/// Hard caps on what the bridge will read from a connection. +enum PairingHTTPLimits { + static let maximumBodySize = 64 * 1024 + static let maximumRequestSize = 96 * 1024 +} + +// MARK: - Request / Response + +struct HTTPRequest { + let method: String + /// Path without the query string. + let path: String + let query: [String: String] + /// Header names are lowercased. + let headers: [String: String] + let body: Data + + func header(_ name: String) -> String? { + headers[name.lowercased()] + } + + var jsonBody: [String: Any]? { + guard !body.isEmpty else { return nil } + return (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] + } +} + +struct HTTPResponse { + var status: Int + var headers: [String: String] + var body: Data + + init(status: Int, headers: [String: String] = [:], body: Data = Data()) { + self.status = status + self.headers = headers + self.body = body + } + + static func json(_ object: [String: Any], status: Int = 200) -> HTTPResponse { + let data = (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])) ?? Data("{}".utf8) + return HTTPResponse(status: status, headers: ["Content-Type": "application/json; charset=utf-8"], body: data) + } + + static func error(_ code: String, _ message: String, status: Int) -> HTTPResponse { + json(["error": code, "message": message], status: status) + } + + static func binary(_ data: Data, contentType: String) -> HTTPResponse { + HTTPResponse(status: 200, headers: ["Content-Type": contentType], body: data) + } + + static func empty(status: Int) -> HTTPResponse { + HTTPResponse(status: status) + } + + func serialized() -> Data { + var headers = self.headers + // RFC 7230: no Content-Length on a 204. + if status != 204 { + headers["Content-Length"] = String(body.count) + } + headers["Connection"] = "close" + // Nothing this server returns may be cached or embedded by another page. + headers["Cache-Control"] = "no-store" + headers["X-Content-Type-Options"] = "nosniff" + + var head = "HTTP/1.1 \(status) \(HTTPResponse.reason(for: status))\r\n" + for key in headers.keys.sorted() { + head += "\(key): \(headers[key]!)\r\n" + } + head += "\r\n" + + var data = Data(head.utf8) + data.append(body) + return data + } + + private static func reason(for status: Int) -> String { + switch status { + case 200: return "OK" + case 201: return "Created" + case 204: return "No Content" + case 400: return "Bad Request" + case 401: return "Unauthorized" + case 403: return "Forbidden" + case 404: return "Not Found" + case 405: return "Method Not Allowed" + case 409: return "Conflict" + case 413: return "Payload Too Large" + case 421: return "Misdirected Request" + case 429: return "Too Many Requests" + case 503: return "Service Unavailable" + default: return "Status" + } + } +} + +// MARK: - Parser + +enum HTTPParseResult { + case incomplete + case request(HTTPRequest) + case failure(HTTPResponse) +} + +enum HTTPRequestParser { + static func parse(_ buffer: Data) -> HTTPParseResult { + let separator = Data("\r\n\r\n".utf8) + guard let headerRange = buffer.range(of: separator) else { return .incomplete } + + let headerData = buffer.subdata(in: buffer.startIndex ..< headerRange.lowerBound) + guard let headerText = String(data: headerData, encoding: .utf8) else { + return .failure(.error("bad_request", "Malformed request headers.", status: 400)) + } + + var lines = headerText.components(separatedBy: "\r\n") + guard let requestLine = lines.first else { + return .failure(.error("bad_request", "Missing request line.", status: 400)) + } + lines.removeFirst() + + let parts = requestLine.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: false) + guard parts.count >= 2 else { + return .failure(.error("bad_request", "Malformed request line.", status: 400)) + } + + let method = String(parts[0]).uppercased() + let target = String(parts[1]) + + var headers: [String: String] = [:] + for line in lines where !line.isEmpty { + guard let colon = line.firstIndex(of: ":") else { continue } + let name = line[line.startIndex ..< colon].trimmingCharacters(in: .whitespaces).lowercased() + let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces) + headers[name] = value + } + + if let encoding = headers["transfer-encoding"], !encoding.isEmpty { + return .failure(.error("unsupported", "Chunked requests are not supported.", status: 400)) + } + + let contentLength = Int(headers["content-length"] ?? "0") ?? 0 + guard contentLength >= 0, contentLength <= PairingHTTPLimits.maximumBodySize else { + return .failure(.error("payload_too_large", "Request body is too large.", status: 413)) + } + + let bodyStart = headerRange.upperBound + let available = buffer.distance(from: bodyStart, to: buffer.endIndex) + guard available >= contentLength else { return .incomplete } + + let body = buffer.subdata(in: bodyStart ..< buffer.index(bodyStart, offsetBy: contentLength)) + + var path = target + var query: [String: String] = [:] + if let questionMark = target.firstIndex(of: "?") { + path = String(target[target.startIndex ..< questionMark]) + let queryString = String(target[target.index(after: questionMark)...]) + for pair in queryString.split(separator: "&") { + let keyValue = pair.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + let key = String(keyValue[0]).removingPercentEncoding ?? String(keyValue[0]) + let value = keyValue.count > 1 ? (String(keyValue[1]).removingPercentEncoding ?? String(keyValue[1])) : "" + query[key] = value + } + } + path = path.removingPercentEncoding ?? path + + return .request(HTTPRequest(method: method, path: path, query: query, headers: headers, body: body)) + } +} + diff --git a/LocalDevVPN/Pairing/PairingHTTPServer.swift b/LocalDevVPN/Pairing/PairingHTTPServer.swift new file mode 100644 index 0000000..ccf3a30 --- /dev/null +++ b/LocalDevVPN/Pairing/PairingHTTPServer.swift @@ -0,0 +1,254 @@ +// +// PairingHTTPServer.swift +// LocalDevVPN +// +// A deliberately small HTTP/1.1 server bound to the loopback interface. +// + +import Foundation +import Network + +// MARK: - Server + +/// Minimal loopback-only HTTP server. +/// +/// Everything about it is intentionally small: one request per connection, no +/// keep-alive, hard caps on request size, and a bind that never leaves 127.0.0.1 so +/// nothing on the local network — or on the tunnel — can reach it. +final class PairingHTTPServer { + /// Answers a request. The response block escapes: handlers answer later, from + /// another queue, once the user has had a chance to respond. + typealias Handler = (HTTPRequest, @escaping (HTTPResponse) -> Void) -> Void + + private static let requestTimeout: TimeInterval = 15 + + private let queue = DispatchQueue(label: "com.localdevvpn.pairing-bridge.server") + private var listener: NWListener? + private var handler: Handler? + /// Connections in flight. `NWListener` does not retain them, so the server does. + private var clients: [ObjectIdentifier: Client] = [:] + + private(set) var port: UInt16? + + var isRunning: Bool { listener != nil } + + /// Binds the first available port from `candidatePorts` so a web client can find + /// the bridge by probing a short, documented list. + func start( + candidatePorts: [UInt16], + handler: @escaping Handler, + completion: @escaping (Result) -> Void + ) { + queue.async { + self.handler = handler + self.attemptStart(ports: candidatePorts, completion: completion) + } + } + + func stop() { + queue.async { + self.listener?.stateUpdateHandler = nil + self.listener?.cancel() + self.listener = nil + self.port = nil + self.handler = nil + for client in self.clients.values { client.close() } + self.clients.removeAll() + } + } + + private func attemptStart(ports: [UInt16], completion: @escaping (Result) -> Void) { + guard let candidate = ports.first else { + completion(.failure(PairingBridgeError.noAvailablePort)) + return + } + let remaining = Array(ports.dropFirst()) + + guard let nwPort = NWEndpoint.Port(rawValue: candidate) else { + attemptStart(ports: remaining, completion: completion) + return + } + + let parameters = NWParameters.tcp + parameters.allowLocalEndpointReuse = true + parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: .ipv4(.loopback), port: nwPort) + if let tcp = parameters.defaultProtocolStack.transportProtocol as? NWProtocolTCP.Options { + tcp.noDelay = true + } + + let listener: NWListener + do { + listener = try NWListener(using: parameters) + } catch { + attemptStart(ports: remaining, completion: completion) + return + } + + var settled = false + listener.stateUpdateHandler = { [weak self] state in + guard let self = self else { return } + switch state { + case .ready: + guard !settled else { return } + settled = true + let boundPort = listener.port?.rawValue ?? candidate + self.listener = listener + self.port = boundPort + completion(.success(boundPort)) + case .failed, .waiting: + guard !settled else { return } + settled = true + listener.stateUpdateHandler = nil + listener.cancel() + self.attemptStart(ports: remaining, completion: completion) + case .cancelled: + if self.listener === listener { + self.listener = nil + self.port = nil + } + default: + break + } + } + + listener.newConnectionHandler = { [weak self] connection in + self?.accept(connection) + } + + listener.start(queue: queue) + } + + private func accept(_ connection: NWConnection) { + guard PairingHTTPServer.isLoopback(connection.endpoint) else { + // requiredLocalEndpoint should make this unreachable, but it has been + // reported ignored on some platforms, and a bridge listening off + // loopback would be a real problem. Check the peer as well. + connection.cancel() + return + } + + let client = Client( + connection: connection, + queue: queue, + handler: { [weak self] request, respond in + guard let handler = self?.handler else { + respond(.error("unavailable", "The pairing bridge is not running.", status: 503)) + return + } + handler(request, respond) + }, + onClose: { [weak self] client in + self?.clients.removeValue(forKey: ObjectIdentifier(client)) + } + ) + clients[ObjectIdentifier(client)] = client + client.start() + } + + static func isLoopback(_ endpoint: NWEndpoint) -> Bool { + guard case let .hostPort(host, _) = endpoint else { return false } + switch host { + case let .ipv4(address): + return address.isLoopback || "\(address)".hasPrefix("127.") + case let .ipv6(address): + return address.isLoopback || "\(address)" == "::1" + case let .name(name, _): + return name == "localhost" || name == "127.0.0.1" || name == "::1" + @unknown default: + return false + } + } + + // MARK: - Connection + + private final class Client { + private let connection: NWConnection + private let queue: DispatchQueue + private let handler: Handler + private let onClose: (Client) -> Void + private var buffer = Data() + private var finished = false + + init( + connection: NWConnection, + queue: DispatchQueue, + handler: @escaping Handler, + onClose: @escaping (Client) -> Void + ) { + self.connection = connection + self.queue = queue + self.handler = handler + self.onClose = onClose + } + + func start() { + connection.start(queue: queue) + receive() + queue.asyncAfter(deadline: .now() + PairingHTTPServer.requestTimeout) { [weak self] in + guard let self = self, !self.finished else { return } + self.finish() + } + } + + private func receive() { + connection.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1024) { [weak self] data, _, isComplete, error in + guard let self = self, !self.finished else { return } + + if let data = data, !data.isEmpty { + self.buffer.append(data) + if self.buffer.count > PairingHTTPLimits.maximumRequestSize { + self.respond(.error("payload_too_large", "Request is too large.", status: 413)) + return + } + + switch HTTPRequestParser.parse(self.buffer) { + case .incomplete: + break + case let .request(request): + // Retain self until the handler answers. + self.handler(request) { response in + self.queue.async { self.respond(response) } + } + return + case let .failure(response): + self.respond(response) + return + } + } + + if error != nil || isComplete { + self.finish() + return + } + + self.receive() + } + } + + private func respond(_ response: HTTPResponse) { + guard !finished else { return } + finished = true + connection.send( + content: response.serialized(), + completion: .contentProcessed { [weak self] _ in + guard let self = self else { return } + self.connection.cancel() + self.onClose(self) + } + ) + } + + private func finish() { + guard !finished else { return } + finished = true + connection.cancel() + onClose(self) + } + + /// Tears the connection down without waiting for the in-flight request. + func close() { + finished = true + connection.cancel() + } + } +} diff --git a/LocalDevVPN/Pairing/PairingRecord.swift b/LocalDevVPN/Pairing/PairingRecord.swift new file mode 100644 index 0000000..e8e4e30 --- /dev/null +++ b/LocalDevVPN/Pairing/PairingRecord.swift @@ -0,0 +1,200 @@ +// +// PairingRecord.swift +// LocalDevVPN +// +// Device pairing record ("pairing file") handling for the local pairing bridge. +// + +import CryptoKit +import Foundation + +// MARK: - Errors + +enum PairingRecordError: LocalizedError { + case empty + case tooLarge + case notAPropertyList + case missingKeys([String]) + case malformed + case fileUnreadable + + var errorDescription: String? { + switch self { + case .empty: + return "The pairing file is empty." + case .tooLarge: + return "The pairing file is larger than \(PairingRecord.maximumSize / 1024) KB." + case .notAPropertyList: + return "The pairing file is not a property list." + case let .missingKeys(keys): + return "The pairing file is missing required keys: \(keys.joined(separator: ", "))." + case .malformed: + return "The pairing file is missing a usable HostID / SystemBUID." + case .fileUnreadable: + return "The pairing file could not be read." + } + } +} + +// MARK: - Pairing Record + +/// A lockdown pairing record for the device this app runs on. +/// +/// The raw bytes stay on the device: they are written only to the app container +/// (with data protection enabled) and handed out only over the loopback bridge, +/// and only to a session the user explicitly authorized. +struct PairingRecord { + /// Keys that every usable lockdown pairing record contains. + static let requiredKeys = [ + "DeviceCertificate", + "HostCertificate", + "HostPrivateKey", + "HostID", + "SystemBUID", + ] + + /// Real pairing records are a few KB; anything bigger is rejected outright. + static let maximumSize = 128 * 1024 + + /// Raw property list bytes. Never logged. + let data: Data + let hostID: String + let systemBUID: String + let udid: String? + + /// Truncated SHA-256 of the record, safe to show in the UI and in logs so the + /// user can tell two records apart without exposing key material. + let fingerprint: String + + init(plistData: Data) throws { + guard !plistData.isEmpty else { throw PairingRecordError.empty } + guard plistData.count <= PairingRecord.maximumSize else { throw PairingRecordError.tooLarge } + + let object: Any + do { + object = try PropertyListSerialization.propertyList(from: plistData, options: [], format: nil) + } catch { + throw PairingRecordError.notAPropertyList + } + + guard let plist = object as? [String: Any] else { throw PairingRecordError.notAPropertyList } + + let missing = PairingRecord.requiredKeys.filter { plist[$0] == nil } + guard missing.isEmpty else { throw PairingRecordError.missingKeys(missing) } + + guard + let hostID = plist["HostID"] as? String, !hostID.isEmpty, + let systemBUID = plist["SystemBUID"] as? String, !systemBUID.isEmpty + else { + throw PairingRecordError.malformed + } + + self.data = plistData + self.hostID = hostID + self.systemBUID = systemBUID + udid = plist["UDID"] as? String + fingerprint = PairingRecord.fingerprint(of: plistData) + } + + static func fingerprint(of data: Data) -> String { + let digest = SHA256.hash(data: data) + let hex = digest.map { String(format: "%02x", $0) }.joined() + return String(hex.prefix(16)) + } +} + +// MARK: - Stored Record Info + +/// Everything the UI and the bridge may know about a stored record without +/// touching the record itself. +struct StoredRecordInfo: Equatable { + let fingerprint: String + let storedAt: Date + let hostID: String +} + +// MARK: - Store + +/// On-device storage for the pairing record. +/// +/// The record lives in the app's Application Support directory with complete data +/// protection and is excluded from backups, so it is never copied off the device by +/// iCloud or by an encrypted local backup. +final class PairingRecordStore { + static let shared = PairingRecordStore() + + private let directoryName = "PairingBridge" + private let fileName = "pairing-record.plist" + private var cached: PairingRecord? + + private init() {} + + private var directoryURL: URL? { + guard let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + return base.appendingPathComponent(directoryName, isDirectory: true) + } + + private var fileURL: URL? { + directoryURL?.appendingPathComponent(fileName, isDirectory: false) + } + + /// Loads (and caches) the stored record, if there is one. + func load() -> PairingRecord? { + if let cached = cached { return cached } + guard let url = fileURL, FileManager.default.fileExists(atPath: url.path) else { return nil } + + do { + let data = try Data(contentsOf: url) + let record = try PairingRecord(plistData: data) + cached = record + return record + } catch { + VPNLogger.shared.log("Pairing bridge: stored pairing record is unusable, discarding it") + clear() + return nil + } + } + + func info() -> StoredRecordInfo? { + guard let record = load(), let url = fileURL else { return nil } + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) + let storedAt = (attributes?[.modificationDate] as? Date) ?? Date() + return StoredRecordInfo(fingerprint: record.fingerprint, storedAt: storedAt, hostID: record.hostID) + } + + func save(_ record: PairingRecord) throws { + guard let directory = directoryURL, let url = fileURL else { throw PairingRecordError.fileUnreadable } + + var attributes: [FileAttributeKey: Any] = [:] + #if os(iOS) + attributes[.protectionKey] = FileProtectionType.complete + #endif + + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: attributes + ) + + #if os(iOS) + try record.data.write(to: url, options: [.atomic, .completeFileProtection]) + #else + try record.data.write(to: url, options: [.atomic]) + #endif + + var excluded = url + var values = URLResourceValues() + values.isExcludedFromBackup = true + try? excluded.setResourceValues(values) + + cached = record + } + + func clear() { + cached = nil + guard let url = fileURL else { return } + try? FileManager.default.removeItem(at: url) + } +} diff --git a/README.md b/README.md index e2bbc54..5044cc9 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,8 @@ Key Features: connections. Requires iOS 14.0 or later. + +## Pairing bridge +LocalDevVPN can also act as the native half of a web-based iOS signer: it runs the device pairing flow and hands the resulting pairing record to a local web client over +`http://127.0.0.1`, but only after you approve the request in the app. The bridge is off by default, the record never leaves the device, and no page can read it without an +explicit approval. See [docs/pairing-bridge.md](docs/pairing-bridge.md) for the API, the security model, and the iOS limitations involved. diff --git a/docs/pairing-bridge-demo.html b/docs/pairing-bridge-demo.html new file mode 100644 index 0000000..0c388c3 --- /dev/null +++ b/docs/pairing-bridge-demo.html @@ -0,0 +1,151 @@ + + + + + +LocalDevVPN pairing bridge — test client + + + +

LocalDevVPN pairing bridge

+

Test client for the local pairing API. Everything happens on this device; +nothing is sent anywhere.

+ +

+ + + + + + +

+ + + +
Ready.
+ + + + diff --git a/docs/pairing-bridge.md b/docs/pairing-bridge.md new file mode 100644 index 0000000..711d134 --- /dev/null +++ b/docs/pairing-bridge.md @@ -0,0 +1,294 @@ +# Local pairing bridge + +LocalDevVPN can act as the native half of a web-based iOS signer: it runs the +device pairing flow, keeps the resulting pairing record on the device, and hands +that record to a local web client **only after the user approves the request in +the app**. + +Safari cannot pair a device, and a web page cannot reach `lockdownd`. This bridge +is the smallest native surface that closes that gap without turning LocalDevVPN +into a signing tool itself. + +``` +website ──▶ localdevvpn://pair ──▶ LocalDevVPN ──▶ user approves in-app + ▲ │ + └──── access token (URL fragment) ◀───┘ + │ + └──▶ http://127.0.0.1:/v1/pairing-record (Bearer ) +``` + +## Where it plugs in + +LocalDevVPN already had two ways for the outside world to talk to it: + +* the `localdevvpn://` URL scheme (`enable`, `disable`, with a `scheme=` + callback), handled in `LocalDevVPNApp.handleURL(_:)`; +* the `ControlLocalDevVPNIntent` App Intent. + +Both are one-shot, fire-and-forget commands: they can start the tunnel but cannot +return data. The bridge extends the URL scheme with a third verb, `pair`, and +adds the missing return path as a loopback HTTP server. Nothing else about the +app's networking changed — the packet tunnel and the bridge are independent, and +the bridge is bound to `127.0.0.1`, which is not routed through the tunnel. + +| File | Role | +| --- | --- | +| `LocalDevVPN/Pairing/PairingRecord.swift` | Record model, validation, on-device storage | +| `LocalDevVPN/Pairing/DevicePairingService.swift` | Pairing flow, provider seam, flow state | +| `LocalDevVPN/Pairing/PairingBridge.swift` | Sessions, authorization, routing | +| `LocalDevVPN/Pairing/PairingHTTPServer.swift` | Loopback-only HTTP/1.1 server | +| `LocalDevVPN/Pairing/PairingHTTPMessages.swift` | HTTP request parsing and response building | +| `LocalDevVPN/Pairing/PairingBridgeViews.swift` | Settings section, authorization prompt | + +## Turning it on + +The bridge is **off by default**. No socket is opened until the user enables +*Settings → Pairing Bridge → Allow local web clients*, or follows a +`localdevvpn://pair` link, which enables it as part of the request. + +It listens on the first free port of `19842`, `19843`, `19844`. A client finds it +by probing them in order with `GET /v1/status`. + +## Authorization model + +The requirement that drove the design: `GET /pairing-record` must never answer a +random page that probes loopback. + +1. **Every request needs `X-LocalDevVPN-Client`.** That header is not + CORS-safelisted, so a cross-origin caller is forced through a preflight and + cannot fire a "simple" request at the bridge. +2. **`Host` must be `127.0.0.1` or `localhost`.** A page that reaches the bridge + through a hostname that resolves to loopback (DNS rebinding) sends that + hostname and is rejected. +3. **The record needs a session the user approved in the app.** Creating a + session only raises a prompt; it grants nothing. The prompt shows the origin + (from the `Origin` header, which a page cannot forge) and a six-digit code that + the requesting page must display, so the user confirms the tab in front of + them rather than a hidden one. +4. **Access tokens are 256-bit, random, in memory only,** compared in constant + time, and never written to disk. Approvals last 15 minutes; unanswered prompts + expire after 2 minutes; session creation is rate-limited to 5/minute and one + prompt at a time. +5. **The record itself never leaves the device.** It is written to the app + container with complete data protection, excluded from backups, and served + only over loopback. LocalDevVPN makes no outbound connections with it, and it + is never put in a URL, a log line or an analytics event — logs carry only a + truncated SHA-256 fingerprint. +6. **Credentials are never allowed.** `Access-Control-Allow-Credentials` is not + sent, so browsers never attach ambient cookies to bridge requests. + +Revoking is immediate: *Revoke All Access* in Settings, turning the bridge off, +or backgrounding the app. + +## Flows + +### A. Deep link (works when the user is on the site) + +Best flow on iOS, because a page in Safari cannot reach a suspended app. + +1. Site opens + `localdevvpn://pair?client=My%20Signer&callback=https%3A%2F%2Fsigner.example%2Fresume&state=abc123`. +2. LocalDevVPN comes to the front, enables the bridge, and shows the prompt. +3. On approval it opens the callback with the result in the **fragment**, so the + token never reaches the site's server or its access logs: + `https://signer.example/resume#ldv_token=…&ldv_session=…&ldv_port=19842&ldv_api=1&ldv_state=abc123`. +4. Safari returns to the page, which now calls the bridge with + `Authorization: Bearer `. + +`callback` must be `https:`, or `http:` on a loopback host; anything else is +rejected so the token cannot be handed to an arbitrary app or scheme. + +### B. Poll (works when the app is on screen — iPad Split View, or the user switches back) + +1. `POST /v1/sessions` → `201` with `session_id` and `verification_code`. +2. The page shows the code; the user approves the matching code in LocalDevVPN. +3. The page polls `GET /v1/sessions/{id}` until `state` is `authorized`, which + also returns the `access_token`. + +## API + +Base URL `http://127.0.0.1:`, JSON in and out, `X-LocalDevVPN-Client: +` required on every request. + +### `GET /v1/status` — anonymous + +```json +{ + "api": 1, + "app": "LocalDevVPN", + "version": "1.2.1", + "authorization_required": true, + "client_header": "X-LocalDevVPN-Client", + "tunnel": { "status": "connected", "interface_ip": "10.7.1.1/32", "device_ip": "10.7.0.1/32" }, + "pairing": { "system_flow_available": false, "mechanism": "…", "reason": "…" }, + "record": { "available": true } +} +``` + +Deliberately says only *whether* a record exists — no fingerprint, no contents. + +### `POST /v1/sessions` — anonymous, raises the prompt + +```json +{ "client": "My Web Signer", "origin": "https://signer.example" } +``` + +`201` → `{ "session_id", "state": "pending_authorization", "verification_code", +"expires_in", "poll_after_ms" }`. `409 authorization_pending` if a prompt is +already up, `429 rate_limited` past 5 requests/minute. + +The `origin` field in the body is only a fallback for display and is shown as +unverified; the `Origin` header wins when present. + +### `GET /v1/sessions/{id}` + +`{ "state": "pending_authorization" | "authorized" | "denied" | "revoked", … }`. +Once authorized it also carries `access_token`, `pairing` (the flow state) and +`record` (`available`, `fingerprint`). + +### `DELETE /v1/sessions/{id}` — `204`, drops the session + +### `POST /v1/sessions/{id}/pairing` — Bearer + +Starts the pairing flow. `202` with the session body; poll the session for +`pairing.state`: + +| state | meaning | +| --- | --- | +| `idle` | nothing running | +| `in_progress` | the system flow is working | +| `awaiting_user_action` | the user has to do something; `message` says what, `code` carries a PIN when the OS shows one | +| `completed` | a record is stored | +| `failed` / `unavailable` | `message` says why | + +### `GET /v1/pairing-record` — Bearer + +JSON by default: + +```json +{ "format": "plist", "encoding": "base64", "data": "…", "fingerprint": "…", "host_id": "…" } +``` + +Send `Accept: application/x-plist` for the raw plist bytes. `409 no_pairing_record` +when there is nothing to hand over, `401` without a valid token. + +### `POST /v1/tunnel` — Bearer + +`{ "action": "start" | "stop" | "status" }` → the tunnel status and the configured +addresses. A signer needs the tunnel up before it can reach the device, and this +saves it from bouncing the user through `localdevvpn://enable`. + +## Limitations found while building this + +### iOS pairing APIs — the important one + +**There is no public API for an app to create a lockdown pairing record for the +device it runs on.** Both mechanisms that mint one are closed to third-party apps: + +* **iOS ≤ 16 — lockdown pairing.** A *host* sends `lockdownd` a `Pair` request + over usbmux (USB or the wireless equivalent) and the device raises "Trust This + Computer?". The request has to come from outside the sandbox; an app cannot + reach `com.apple.mobile.lockdown` for its own device. +* **iOS 17+ — remote pairing (RemoteXPC).** The six-digit PIN flow Xcode uses for + wireless pairing is served by `remotepairingd` behind + `com.apple.internal.dt.remote.pairing`, an Apple-internal entitlement. It is + not issuable to third-party developers, and an app using it would not pass App + Review. +* **`DeviceDiscoveryUI`** is the one public "system pairing UI with a PIN", but it + pairs an app with an Apple TV/Vision Pro *application service* and yields an + `NWEndpoint` — not a lockdown pairing record. It does not help here. + +So `SystemPairingFlowProvider` reports its availability honestly instead of +shipping a private-API path, and the shipped flow falls back to a user-driven +import of a pairing file (produced by `jitterbugpair`, `idevicepair`, AltServer, +SideStore…) through the system document picker. The import is real, App +Store-safe, and works on every supported iOS version. + +The seam is deliberate: a build that carries the entitlements can compile a real +implementation in behind `-D LOCALDEVVPN_NATIVE_PAIRING` (a `NativePairingFlow` +with the same two entry points) and every other layer — states, endpoints, +authorization, UI — keeps working unchanged, including `awaiting_user_action` +with a `code`, which is exactly the shape a PIN flow needs. + +### Entitlements + +None added. Loopback listeners need no entitlement, and `127.0.0.1` is exempt +from the local-network privacy prompt, so no `NSLocalNetworkUsageDescription` is +required (the existing `NSBonjourServices` entry is unrelated to the bridge). The +app keeps exactly the two networking entitlements it already had. + +### App Store + +* A loopback HTTP server in a foreground app is fine; several shipping developer + tools do it. +* The private pairing services above are not, which is why they are not used. +* Because the record is user-supplied or user-approved and never transmitted, the + app's "no data collected" posture is unchanged. + +### Background execution + +**This is the sharpest practical limit.** iOS suspends the app shortly after it +leaves the screen, and a suspended app's listener refuses connections. The bridge +therefore: + +* stops the listener when the app is backgrounded, and restarts it on return; +* holds a `beginBackgroundTask` assertion for up to ~25 s first, so a page that + was just handed a token in flow A can still fetch the record after Safari comes + forward; +* drops any pending authorization prompt on backgrounding, since nobody can + answer it. + +Practical consequences for a web signer: fetch what you need immediately after +the callback, do not assume the bridge answers minutes later, and treat a +connection failure as "ask the user to reopen LocalDevVPN" rather than an error. +There is no way around this with public API — background listeners require a +`NEAppPushProvider`-class entitlement that does not apply here. + +### Safari / CORS + +* Preflights are answered for `GET`, `POST`, `DELETE`; `Authorization`, + `Content-Type` and `X-LocalDevVPN-Client` are the allowed headers. +* `Access-Control-Allow-Credentials` is never sent — do not use + `credentials: 'include'`. +* **Mixed content:** `http://127.0.0.1` is a potentially trustworthy origin per + the Secure Contexts spec, but WebKit's handling of such subresources from an + `https://` page has varied by version. Test on your minimum iOS version. If it + is blocked, either serve the signer from a loopback origin, or have the app + serve the page. The deep-link flow does not remove this constraint: the token + arrives over a URL, but the record is still fetched over HTTP. +* No TLS on the bridge: no CA will issue a certificate for `127.0.0.1`, and + shipping a private key in the app to serve `https://` would be worse than plain + HTTP over loopback. +* Chromium's Private Network Access preflight is answered when it asks + (`Access-Control-Allow-Private-Network`). WebKit does not implement PNA. + +### Networking + +* One request per connection, no keep-alive; 64 KB body cap, 96 KB request cap, + 15 s idle timeout. +* The listener binds `127.0.0.1` only via `NWParameters.requiredLocalEndpoint`, + and non-loopback peers are dropped at accept time as well — that parameter has + been reported ignored on some platforms, and a bridge listening off loopback + would be a real problem. +* The port is not fixed. Probe `19842`–`19844`; a client that hard-codes one port + will break when something else holds it. +* The bridge is unaffected by the tunnel: loopback traffic never enters the + packet tunnel, so pairing works whether or not the VPN is connected. + +## Trying it + +`docs/pairing-bridge-demo.html` is a self-contained test client. Serve it from +the device (or open it from a local server) and use it to run either flow against +a build of the app. + +Reference `curl` from a shell on the device: + +```sh +curl -s -H 'X-LocalDevVPN-Client: curl' http://127.0.0.1:19842/v1/status +curl -s -H 'X-LocalDevVPN-Client: curl' -H 'Content-Type: application/json' \ + -d '{"client":"curl"}' http://127.0.0.1:19842/v1/sessions +# approve in the app, then +curl -s -H 'X-LocalDevVPN-Client: curl' http://127.0.0.1:19842/v1/sessions/$SESSION +curl -s -H 'X-LocalDevVPN-Client: curl' -H "Authorization: Bearer $TOKEN" \ + http://127.0.0.1:19842/v1/pairing-record +```