feat(T-2.2): Pairing flow, Keychain, QR scanner, TLS pinning stub
This commit is contained in:
parent
aa010cf874
commit
f6396bc70e
|
|
@ -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<T: Encodable>(_ 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<T: Decodable>(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,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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://<host>:<port>?pair=<pairingToken>&fp=<sha256-hex>&name=<sidecarName>
|
||||
/// ```
|
||||
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) ?? "<empty>"
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue