297 lines
11 KiB
Swift
297 lines
11 KiB
Swift
|
|
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<CLAuthorizationStatus, Never>?
|
||
|
|
private var locationContinuation: CheckedContinuation<CLLocation, Error>?
|
||
|
|
|
||
|
|
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
|