import SwiftUI #if canImport(CoreLocation) && canImport(CoreNFC) && canImport(CryptoKit) && os(iOS) import CoreLocation import CoreNFC import CryptoKit @MainActor final class NFCIdentifyReader: NSObject, ObservableObject, @preconcurrency NFCNDEFReaderSessionDelegate { @Published private(set) var helperText: String @Published private(set) var isScanning = false @Published private(set) var isSupported: Bool var onAuthenticationRequestDetected: ((PairingAuthenticationRequest) -> Void)? var onError: ((String) -> Void)? private let signedGPSPositionProvider = SignedGPSPositionProvider() private var session: NFCNDEFReaderSession? private var isPreparingLocationProof = false override init() { let supported = NFCNDEFReaderSession.readingAvailable _helperText = Published(initialValue: supported ? NFCIdentifyReader.idleHelperText : NFCIdentifyReader.unavailableHelperText) _isSupported = Published(initialValue: supported) super.init() } func beginScanning() { refreshAvailability() guard isSupported else { onError?(Self.unavailableErrorMessage) return } guard !isScanning else { return } isScanning = true isPreparingLocationProof = false helperText = Self.scanningHelperText let session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: true) session.alertMessage = "Hold your iPhone near the idp.global tag. A signed GPS position will be attached to this NFC identify action." self.session = session session.begin() } func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) { DispatchQueue.main.async { self.helperText = Self.scanningHelperText } } func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) { guard let payload = extractPayload(from: messages) else { session.invalidate() DispatchQueue.main.async { self.finishScanning() self.onError?(Self.invalidTagMessage) } return } DispatchQueue.main.async { self.isPreparingLocationProof = true self.helperText = Self.signingLocationHelperText Task { @MainActor in await self.completeAuthentication(for: payload) } } } func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { let nsError = error as NSError let ignoredCodes = [200, 204] // User canceled, first tag read. DispatchQueue.main.async { self.session = nil } guard !(nsError.domain == NFCErrorDomain && ignoredCodes.contains(nsError.code)) else { if !isPreparingLocationProof { DispatchQueue.main.async { self.finishScanning() } } return } DispatchQueue.main.async { self.finishScanning() self.onError?(Self.failureMessage(for: nsError)) } } @MainActor private func completeAuthentication(for payload: String) async { do { let signedGPSPosition = try await signedGPSPositionProvider.currentSignedGPSPosition(for: payload) let request = PairingAuthenticationRequest( pairingPayload: payload, transport: .nfc, signedGPSPosition: signedGPSPosition ) finishScanning() onAuthenticationRequestDetected?(request) } catch let error as AppError { finishScanning() onError?(error.errorDescription ?? Self.gpsSigningFailureMessage) } catch { finishScanning() onError?(Self.gpsSigningFailureMessage) } } private func finishScanning() { session = nil isPreparingLocationProof = false isScanning = false refreshAvailability() } private func refreshAvailability() { let available = NFCNDEFReaderSession.readingAvailable isSupported = available if !isScanning { helperText = available ? Self.idleHelperText : Self.unavailableHelperText } } private func extractPayload(from messages: [NFCNDEFMessage]) -> String? { for message in messages { for record in message.records { if let url = record.wellKnownTypeURIPayload() { return url.absoluteString } let (text, _) = record.wellKnownTypeTextPayload() if let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty { return trimmed } if let fallback = String(data: record.payload, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines), !fallback.isEmpty { return fallback } } } return nil } private static func failureMessage(for error: NSError) -> String { if error.domain == NFCErrorDomain && error.code == 2 { return "NFC identify is not permitted in this build. Check the NFC entitlement and privacy description." } return "NFC identify could not be completed on this device." } private static let idleHelperText = "Tap to identify with an NFC tag on supported iPhone hardware. A signed GPS position will be attached automatically." private static let scanningHelperText = "Hold the top of your iPhone near the NFC tag until it is identified." private static let signingLocationHelperText = "Tag detected. Capturing and signing the current GPS position for NFC identify." private static let unavailableHelperText = "NFC identify is unavailable on this device." private static let unavailableErrorMessage = "Tap to identify requires supported iPhone hardware with NFC enabled." private static let invalidTagMessage = "The NFC tag did not contain a usable idp.global payload." private static let gpsSigningFailureMessage = "The NFC tag was read, but the signed GPS position could not be attached." } @MainActor private final class SignedGPSPositionProvider: NSObject, @preconcurrency CLLocationManagerDelegate { private var manager: CLLocationManager? private var authorizationContinuation: CheckedContinuation? private var locationContinuation: CheckedContinuation? func currentSignedGPSPosition(for pairingPayload: String) async throws -> SignedGPSPosition { let location = try await currentLocation() return try sign(location: location, pairingPayload: pairingPayload) } private func currentLocation() async throws -> CLLocation { let manager = CLLocationManager() manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters manager.distanceFilter = kCLDistanceFilterNone self.manager = manager switch manager.authorizationStatus { case .authorizedAlways, .authorizedWhenInUse: break case .notDetermined: let status = await requestAuthorization(using: manager) guard status == .authorizedAlways || status == .authorizedWhenInUse else { throw AppError.locationPermissionDenied } case .denied, .restricted: throw AppError.locationPermissionDenied @unknown default: throw AppError.locationUnavailable } return try await requestLocation(using: manager) } private func requestAuthorization(using manager: CLLocationManager) async -> CLAuthorizationStatus { manager.requestWhenInUseAuthorization() return await withCheckedContinuation { continuation in authorizationContinuation = continuation } } private func requestLocation(using manager: CLLocationManager) async throws -> CLLocation { try await withCheckedThrowingContinuation { continuation in locationContinuation = continuation manager.requestLocation() } } private func sign(location: CLLocation, pairingPayload: String) throws -> SignedGPSPosition { let isFresh = abs(location.timestamp.timeIntervalSinceNow) <= 120 guard location.horizontalAccuracy >= 0, isFresh else { throw AppError.locationUnavailable } let unsignedPosition = SignedGPSPosition( latitude: location.coordinate.latitude, longitude: location.coordinate.longitude, horizontalAccuracyMeters: location.horizontalAccuracy, capturedAt: location.timestamp ) let privateKey = P256.Signing.PrivateKey() let signature = try privateKey.signature(for: unsignedPosition.signingPayload(for: pairingPayload)) return unsignedPosition.signed( signatureData: signature.derRepresentation, publicKeyData: privateKey.publicKey.x963Representation ) } func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { guard let continuation = authorizationContinuation else { return } let status = manager.authorizationStatus guard status != .notDetermined else { return } authorizationContinuation = nil continuation.resume(returning: status) } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let continuation = locationContinuation, let location = locations.last else { return } authorizationContinuation = nil locationContinuation = nil self.manager = nil continuation.resume(returning: location) } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { guard let continuation = locationContinuation else { return } authorizationContinuation = nil locationContinuation = nil self.manager = nil if let locationError = error as? CLError, locationError.code == .denied { continuation.resume(throwing: AppError.locationPermissionDenied) return } continuation.resume(throwing: AppError.locationUnavailable) } } #else @MainActor final class NFCIdentifyReader: NSObject, ObservableObject { @Published private(set) var helperText = "NFC identify with a signed GPS position is available on supported iPhone hardware only." @Published private(set) var isScanning = false @Published private(set) var isSupported = false var onAuthenticationRequestDetected: ((PairingAuthenticationRequest) -> Void)? var onError: ((String) -> Void)? func beginScanning() { onError?("Tap to identify requires supported iPhone hardware with NFC and location access enabled.") } } #endif