Files
swiftapp/swift/Sources/Core/Services/MockIDPService.swift
T
jkunz a298b5e421
CI / test (push) Has been cancelled
replace mock passport flows with live server integration
Switch the app to the real passport enrollment, dashboard, device, alert, and challenge APIs so it can pair with idp.global and act on server-backed state instead of demo data.
2026-04-20 13:21:39 +00:00

1232 lines
43 KiB
Swift

import Foundation
import CryptoKit
#if canImport(UIKit)
import UIKit
#endif
#if canImport(CoreLocation)
import CoreLocation
#endif
#if canImport(Security)
import Security
#endif
protocol IDPServicing {
func bootstrap() async throws -> BootstrapContext
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot
func refreshDashboard() async throws -> DashboardSnapshot
func approveRequest(id: UUID) async throws -> DashboardSnapshot
func rejectRequest(id: UUID) async throws -> DashboardSnapshot
func simulateIncomingRequest() async throws -> DashboardSnapshot
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot
}
enum DefaultIDPService {
static let shared: IDPServicing = LiveIDPService.shared
}
actor LiveIDPService: IDPServicing {
static let shared = LiveIDPService()
private let appStateStore: AppStateStoring
private let keyStore: PassportKeyStoring
init(
appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
keyStore: PassportKeyStoring = DefaultPassportKeyStore()
) {
self.appStateStore = appStateStore
self.keyStore = keyStore
}
func bootstrap() async throws -> BootstrapContext {
let suggestedPayload = appStateStore.load()?.session.pairingCode ?? ""
return BootstrapContext(suggestedPairingPayload: suggestedPayload)
}
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult {
try validateSignedGPSPosition(in: request)
let context = try PairingPayloadParser.parse(request.pairingPayload)
let baseURL = try serverURL(from: context)
let client = LiveTypedRequestClient(baseURL: baseURL)
let keyMaterial = try loadOrCreateKeyMaterial(for: context.originHost)
let signingPayload = try buildEnrollmentSigningPayload(from: context)
let signatureBase64 = try signPayload(signingPayload, with: keyMaterial.privateKeyData)
let enrollmentResponse = try await client.fire(
method: "completePassportEnrollment",
request: CompletePassportEnrollmentRequest(
pairingToken: context.pairingToken,
deviceLabel: DeviceEnvironment.currentDeviceLabel,
platform: DeviceEnvironment.currentPlatform,
publicKeyX963Base64: keyMaterial.publicKeyBase64,
signatureBase64: signatureBase64,
signatureFormat: "der",
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String,
capabilities: DeviceEnvironment.capabilities
),
responseType: CompletePassportEnrollmentResponse.self
)
let session = AuthSession(
deviceName: enrollmentResponse.device.data.label,
originHost: context.originHost,
serverURL: baseURL.absoluteString,
passportDeviceID: enrollmentResponse.device.id,
pairedAt: .now,
tokenPreview: context.tokenPreview,
pairingCode: request.pairingPayload,
pairingTransport: request.transport,
signedGPSPosition: request.signedGPSPosition
)
let snapshot = try await dashboardSnapshot(for: session)
return SignInResult(session: session, snapshot: snapshot)
}
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot {
guard let state = appStateStore.load() else {
throw AppError.invalidPairingPayload
}
_ = request
return try await dashboardSnapshot(for: state.session)
}
func refreshDashboard() async throws -> DashboardSnapshot {
guard let state = appStateStore.load() else {
throw AppError.invalidPairingPayload
}
return try await dashboardSnapshot(for: state.session)
}
func approveRequest(id: UUID) async throws -> DashboardSnapshot {
guard let state = appStateStore.load(),
let request = state.requests.first(where: { $0.id == id }) else {
throw AppError.requestNotFound
}
guard let signingPayload = request.signingPayload else {
throw AppError.requestNotFound
}
let client = LiveTypedRequestClient(baseURL: URL(string: state.session.serverURL)!)
let signatureBase64 = try signPayload(signingPayload, with: try privateKeyData(for: state.session))
let locationEvidence = request.requiresLocation
? try await CurrentLocationEvidenceProvider.currentLocationEvidenceIfAvailable()
: nil
_ = try await client.fire(
method: "approvePassportChallenge",
request: ApprovePassportChallengeRequest(
challengeId: request.serverID,
deviceId: state.session.passportDeviceID,
signatureBase64: signatureBase64,
signatureFormat: "der",
location: locationEvidence,
nfc: nil
),
responseType: ApprovePassportChallengeResponse.self
)
return try await dashboardSnapshot(for: state.session)
}
func rejectRequest(id: UUID) async throws -> DashboardSnapshot {
guard let state = appStateStore.load(),
let request = state.requests.first(where: { $0.id == id }) else {
throw AppError.requestNotFound
}
let client = LiveTypedRequestClient(baseURL: URL(string: state.session.serverURL)!)
let signedRequest = try signedDeviceRequest(
session: state.session,
action: "rejectPassportChallenge",
signedFields: ["challenge_id=\(request.serverID)"]
)
_ = try await client.fire(
method: "rejectPassportChallenge",
request: RejectPassportChallengeRequest(
deviceId: signedRequest.deviceId,
timestamp: signedRequest.timestamp,
nonce: signedRequest.nonce,
signatureBase64: signedRequest.signatureBase64,
signatureFormat: signedRequest.signatureFormat,
challengeId: request.serverID
),
responseType: RejectPassportChallengeResponse.self
)
return try await dashboardSnapshot(for: state.session)
}
func simulateIncomingRequest() async throws -> DashboardSnapshot {
try await refreshDashboard()
}
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot {
guard let state = appStateStore.load(),
let notification = state.notifications.first(where: { $0.id == id }) else {
return try await refreshDashboard()
}
let client = LiveTypedRequestClient(baseURL: URL(string: state.session.serverURL)!)
let signedRequest = try signedDeviceRequest(
session: state.session,
action: "markPassportAlertSeen",
signedFields: ["hint_id=\(notification.hintID)"]
)
_ = try await client.fire(
method: "markPassportAlertSeen",
request: MarkPassportAlertSeenRequest(
deviceId: signedRequest.deviceId,
timestamp: signedRequest.timestamp,
nonce: signedRequest.nonce,
signatureBase64: signedRequest.signatureBase64,
signatureFormat: signedRequest.signatureFormat,
hintId: notification.hintID
),
responseType: SimpleSuccessResponse.self
)
return try await dashboardSnapshot(for: state.session)
}
private func dashboardSnapshot(for session: AuthSession) async throws -> DashboardSnapshot {
let client = LiveTypedRequestClient(baseURL: URL(string: session.serverURL)!)
let signedRequest = try signedDeviceRequest(session: session, action: "getPassportDashboard", signedFields: [])
let response = try await client.fire(
method: "getPassportDashboard",
request: signedRequest,
responseType: PassportDashboardResponse.self
)
return DashboardSnapshot(
profile: MemberProfile(
name: response.profile.name,
handle: response.profile.handle,
organization: response.profile.organizations.first?.name ?? "No organization",
deviceCount: response.profile.deviceCount,
recoverySummary: response.profile.recoverySummary
),
requests: response.challenges.map { challengeItem in
ApprovalRequest(
serverID: challengeItem.challenge.id,
hintID: challengeItem.challenge.data.notification?.hintId ?? challengeItem.challenge.id,
title: approvalTitle(for: challengeItem.challenge),
subtitle: approvalSubtitle(for: challengeItem.challenge),
source: challengeItem.challenge.data.metadata.audience ?? challengeItem.challenge.data.metadata.originHost ?? "idp.global",
createdAt: Date(millisecondsSince1970: challengeItem.challenge.data.createdAt),
kind: approvalKind(for: challengeItem.challenge),
risk: approvalRisk(for: challengeItem.challenge),
scopes: approvalScopes(for: challengeItem.challenge),
requiresLocation: challengeItem.challenge.data.metadata.requireLocation,
challenge: challengeItem.challenge.data.challenge,
signingPayload: challengeItem.signingPayload,
deviceSummaryText: challengeItem.challenge.data.metadata.deviceLabel,
locationSummaryText: locationSummary(for: challengeItem.challenge),
networkSummaryText: challengeItem.challenge.data.metadata.originHost,
ipSummaryText: nil,
expiresAtDate: Date(millisecondsSince1970: challengeItem.challenge.data.expiresAt),
status: .pending
)
},
notifications: response.alerts.map { alert in
AppNotification(
serverID: alert.id,
hintID: alert.data.notification.hintId,
title: alert.data.title,
message: alert.data.body,
sentAt: Date(millisecondsSince1970: alert.data.createdAt),
kind: notificationKind(for: alert.data.category),
isUnread: alert.data.seenAt == nil
)
},
devices: response.devices.map { device in
PassportDeviceRecord(
id: device.id,
label: device.data.label,
platform: device.data.platform,
lastSeenAt: device.data.lastSeenAt.map(Date.init(millisecondsSince1970:)),
isCurrent: device.id == session.passportDeviceID
)
}
)
}
private func validateSignedGPSPosition(in request: PairingAuthenticationRequest) throws {
if request.transport == .nfc,
request.signedGPSPosition == nil {
throw AppError.missingSignedGPSPosition
}
if let signedGPSPosition = request.signedGPSPosition,
!signedGPSPosition.verified(for: request.pairingPayload) {
throw AppError.invalidSignedGPSPosition
}
}
private func serverURL(from context: PairingPayloadContext) throws -> URL {
guard let url = URL(string: "https://\(context.originHost)") else {
throw AppError.invalidPairingPayload
}
return url
}
private func buildEnrollmentSigningPayload(from context: PairingPayloadContext) throws -> String {
guard let challenge = context.challenge,
let challengeID = context.challengeID else {
throw AppError.invalidPairingPayload
}
return [
"purpose=passport-enrollment",
"origin=\(context.originHost)",
"token=\(context.pairingToken)",
"challenge=\(challenge)",
"challenge_id=\(challengeID)"
].joined(separator: "\n")
}
private func loadOrCreateKeyMaterial(for originHost: String) throws -> StoredPassportKeyMaterial {
if let existing = try keyStore.load(key: originHost) {
return existing
}
let privateKey = P256.Signing.PrivateKey()
let material = StoredPassportKeyMaterial(
privateKeyBase64: privateKey.rawRepresentation.base64EncodedString(),
publicKeyBase64: privateKey.publicKey.x963Representation.base64EncodedString()
)
try keyStore.save(material, key: originHost)
return material
}
private func privateKeyData(for session: AuthSession) throws -> Data {
guard let stored = try keyStore.load(key: session.originHost),
let data = Data(base64Encoded: stored.privateKeyBase64) else {
throw AppError.invalidPairingPayload
}
return data
}
private func signPayload(_ payload: String, with privateKeyData: Data) throws -> String {
let privateKey = try P256.Signing.PrivateKey(rawRepresentation: privateKeyData)
let signature = try privateKey.signature(for: Data(payload.utf8))
return signature.derRepresentation.base64EncodedString()
}
private func signedDeviceRequest(
session: AuthSession,
action: String,
signedFields: [String]
) throws -> SignedDeviceRequest {
let nonce = UUID().uuidString.lowercased()
let timestamp = Int(Date().timeIntervalSince1970 * 1000)
let payload = ([
"purpose=passport-device-request",
"origin=\(session.originHost)",
"action=\(action)",
"device_id=\(session.passportDeviceID)",
"timestamp=\(timestamp)",
"nonce=\(nonce)"
] + signedFields).joined(separator: "\n")
return SignedDeviceRequest(
deviceId: session.passportDeviceID,
timestamp: timestamp,
nonce: nonce,
signatureBase64: try signPayload(payload, with: try privateKeyData(for: session)),
signatureFormat: "der"
)
}
private func approvalKind(for challenge: ServerPassportChallenge) -> ApprovalRequestKind {
switch challenge.data.type {
case "authentication":
return .signIn
case "physical_access":
return .accessGrant
default:
return .elevatedAction
}
}
private func approvalRisk(for challenge: ServerPassportChallenge) -> ApprovalRisk {
if challenge.data.metadata.requireLocation ||
challenge.data.metadata.requireNfc ||
challenge.data.metadata.locationPolicy != nil ||
challenge.data.type != "authentication" {
return .elevated
}
return .routine
}
private func approvalTitle(for challenge: ServerPassportChallenge) -> String {
challenge.data.metadata.notificationTitle ?? challenge.data.type.replacingOccurrences(of: "_", with: " ").capitalized
}
private func approvalSubtitle(for challenge: ServerPassportChallenge) -> String {
if let audience = challenge.data.metadata.audience {
return "Approve this proof for \(audience)."
}
return "Approve this passport challenge on your trusted device."
}
private func approvalScopes(for challenge: ServerPassportChallenge) -> [String] {
var scopes = ["passport:device-proof"]
if challenge.data.metadata.requireLocation {
scopes.append("context:location")
}
if challenge.data.metadata.requireNfc {
scopes.append("context:nfc")
}
if let label = challenge.data.metadata.locationPolicy?.label {
scopes.append("policy:\(label)")
}
return scopes
}
private func locationSummary(for challenge: ServerPassportChallenge) -> String {
if let locationPolicy = challenge.data.metadata.locationPolicy {
return locationPolicy.label ?? "Location within required area"
}
if challenge.data.metadata.requireLocation {
return "Current location required"
}
return "Location not required"
}
private func notificationKind(for category: String) -> AppNotificationKind {
switch category {
case "security":
return .security
case "admin":
return .approval
default:
return .system
}
}
}
actor MockIDPService: IDPServicing {
static let shared = MockIDPService()
private let profile = MemberProfile(
name: "Phil Kunz",
handle: "phil@idp.global",
organization: "idp.global",
deviceCount: 4,
recoverySummary: "Recovery kit healthy with 2 of 3 backup paths verified."
)
private let appStateStore: AppStateStoring
private var requests: [ApprovalRequest] = []
private var notifications: [AppNotification] = []
private var devices: [PassportDeviceRecord] = []
init(appStateStore: AppStateStoring = UserDefaultsAppStateStore()) {
self.appStateStore = appStateStore
if let state = appStateStore.load() {
requests = state.requests.sorted { $0.createdAt > $1.createdAt }
notifications = state.notifications.sorted { $0.sentAt > $1.sentAt }
devices = state.devices
} else {
requests = Self.seedRequests()
notifications = Self.seedNotifications()
devices = Self.seedDevices()
}
}
func bootstrap() async throws -> BootstrapContext {
restoreSharedState()
try await Task.sleep(for: .milliseconds(120))
return BootstrapContext(
suggestedPairingPayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
)
}
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult {
restoreSharedState()
try await Task.sleep(for: .milliseconds(260))
try validateSignedGPSPosition(in: request)
let session = try parseSession(from: request)
notifications.insert(
AppNotification(
title: "Passport activated",
message: pairingMessage(for: session),
sentAt: .now,
kind: .security,
isUnread: true
),
at: 0
)
persistSharedStateIfAvailable()
return SignInResult(
session: session,
snapshot: snapshot()
)
}
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot {
restoreSharedState()
try await Task.sleep(for: .milliseconds(180))
try validateSignedGPSPosition(in: request)
let context = try PairingPayloadParser.parse(request.pairingPayload)
notifications.insert(
AppNotification(
title: "Identity proof completed",
message: identificationMessage(for: context, signedGPSPosition: request.signedGPSPosition),
sentAt: .now,
kind: .security,
isUnread: true
),
at: 0
)
persistSharedStateIfAvailable()
return snapshot()
}
func refreshDashboard() async throws -> DashboardSnapshot {
restoreSharedState()
try await Task.sleep(for: .milliseconds(180))
return snapshot()
}
func approveRequest(id: UUID) async throws -> DashboardSnapshot {
restoreSharedState()
try await Task.sleep(for: .milliseconds(150))
guard let index = requests.firstIndex(where: { $0.id == id }) else {
throw AppError.requestNotFound
}
requests[index].status = .approved
notifications.insert(
AppNotification(
title: "Identity verified",
message: "\(requests[index].title) was completed for \(requests[index].source).",
sentAt: .now,
kind: .approval,
isUnread: true
),
at: 0
)
persistSharedStateIfAvailable()
return snapshot()
}
func rejectRequest(id: UUID) async throws -> DashboardSnapshot {
restoreSharedState()
try await Task.sleep(for: .milliseconds(150))
guard let index = requests.firstIndex(where: { $0.id == id }) else {
throw AppError.requestNotFound
}
requests[index].status = .rejected
notifications.insert(
AppNotification(
title: "Identity proof declined",
message: "\(requests[index].title) was declined before the session could continue.",
sentAt: .now,
kind: .security,
isUnread: true
),
at: 0
)
persistSharedStateIfAvailable()
return snapshot()
}
func simulateIncomingRequest() async throws -> DashboardSnapshot {
restoreSharedState()
try await Task.sleep(for: .milliseconds(120))
let syntheticRequest = ApprovalRequest(
title: "Prove identity for web sign-in",
subtitle: "A browser session is asking this passport to prove that it is really you.",
source: "auth.idp.global",
createdAt: .now,
kind: .signIn,
risk: .routine,
scopes: ["proof:basic", "client:web", "method:qr"],
status: .pending
)
requests.insert(syntheticRequest, at: 0)
notifications.insert(
AppNotification(
title: "Fresh identity proof request",
message: "A new relying party is waiting for your identity proof.",
sentAt: .now,
kind: .approval,
isUnread: true
),
at: 0
)
persistSharedStateIfAvailable()
return snapshot()
}
func markNotificationRead(id: UUID) async throws -> DashboardSnapshot {
restoreSharedState()
try await Task.sleep(for: .milliseconds(80))
guard let index = notifications.firstIndex(where: { $0.id == id }) else {
return snapshot()
}
notifications[index].isUnread = false
persistSharedStateIfAvailable()
return snapshot()
}
private func snapshot() -> DashboardSnapshot {
DashboardSnapshot(
profile: profile,
requests: requests,
notifications: notifications,
devices: devices
)
}
private func validateSignedGPSPosition(in request: PairingAuthenticationRequest) throws {
if request.transport == .nfc,
request.signedGPSPosition == nil {
throw AppError.missingSignedGPSPosition
}
if let signedGPSPosition = request.signedGPSPosition,
!signedGPSPosition.verified(for: request.pairingPayload) {
throw AppError.invalidSignedGPSPosition
}
}
private func parseSession(from request: PairingAuthenticationRequest) throws -> AuthSession {
let context = try PairingPayloadParser.parse(request.pairingPayload)
return AuthSession(
deviceName: context.deviceName,
originHost: context.originHost,
serverURL: "https://\(context.originHost)",
passportDeviceID: UUID().uuidString,
pairedAt: .now,
tokenPreview: context.tokenPreview,
pairingCode: request.pairingPayload,
pairingTransport: request.transport,
signedGPSPosition: request.signedGPSPosition
)
}
private func pairingMessage(for session: AuthSession) -> String {
let transportSummary: String
switch session.pairingTransport {
case .qr:
transportSummary = "activated via QR"
case .nfc:
transportSummary = "activated via NFC with a signed GPS position"
case .manual:
transportSummary = "activated via manual payload"
case .preview:
transportSummary = "activated via preview payload"
}
if let signedGPSPosition = session.signedGPSPosition {
return "\(session.deviceName) is now acting as a passport, \(transportSummary) against \(session.originHost) from \(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)."
}
return "\(session.deviceName) is now acting as a passport, \(transportSummary) against \(session.originHost)."
}
private func identificationMessage(for context: PairingPayloadContext, signedGPSPosition: SignedGPSPosition?) -> String {
if let signedGPSPosition {
return "A signed GPS proof was sent for \(context.deviceName) on \(context.originHost) from \(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)."
}
return "An identity proof was completed for \(context.deviceName) on \(context.originHost)."
}
private func restoreSharedState() {
guard let state = appStateStore.load() else {
requests = Self.seedRequests()
notifications = Self.seedNotifications()
devices = Self.seedDevices()
return
}
requests = state.requests.sorted { $0.createdAt > $1.createdAt }
notifications = state.notifications.sorted { $0.sentAt > $1.sentAt }
devices = state.devices
}
private func persistSharedStateIfAvailable() {
guard let state = appStateStore.load() else { return }
appStateStore.save(
PersistedAppState(
session: state.session,
profile: state.profile,
requests: requests,
notifications: notifications,
devices: devices
)
)
}
private static func seedRequests() -> [ApprovalRequest] {
[
ApprovalRequest(
title: "Prove identity for Safari sign-in",
subtitle: "The portal wants this passport to prove that the browser session is really you.",
source: "code.foss.global",
createdAt: .now.addingTimeInterval(-60 * 12),
kind: .signIn,
risk: .routine,
scopes: ["proof:basic", "client:web", "origin:trusted"],
status: .pending
),
ApprovalRequest(
title: "Prove identity for workstation unlock",
subtitle: "Your secure workspace is asking for a stronger proof before it unlocks.",
source: "berlin-mbp.idp.global",
createdAt: .now.addingTimeInterval(-60 * 42),
kind: .elevatedAction,
risk: .elevated,
scopes: ["proof:high", "client:desktop", "presence:required"],
status: .pending
),
ApprovalRequest(
title: "Prove identity for CLI session",
subtitle: "The CLI session asked for proof earlier and was completed from this passport.",
source: "cli.idp.global",
createdAt: .now.addingTimeInterval(-60 * 180),
kind: .signIn,
risk: .routine,
scopes: ["proof:basic", "client:cli"],
status: .approved
)
]
}
private static func seedNotifications() -> [AppNotification] {
[
AppNotification(
title: "Two identity checks are waiting",
message: "One routine web proof and one stronger workstation proof are waiting for this passport.",
sentAt: .now.addingTimeInterval(-60 * 8),
kind: .approval,
isUnread: true
),
AppNotification(
title: "Recovery health check passed",
message: "Backup recovery channels were verified in the last 24 hours.",
sentAt: .now.addingTimeInterval(-60 * 95),
kind: .system,
isUnread: false
),
AppNotification(
title: "Passport quiet hours active",
message: "Routine identity checks will be delivered silently until the morning.",
sentAt: .now.addingTimeInterval(-60 * 220),
kind: .security,
isUnread: false
)
]
}
private static func seedDevices() -> [PassportDeviceRecord] {
[
PassportDeviceRecord(id: UUID().uuidString, label: "Phil's iPhone", platform: "ios", lastSeenAt: .now, isCurrent: true),
PassportDeviceRecord(id: UUID().uuidString, label: "Phil's iPad Pro", platform: "ipados", lastSeenAt: .now.addingTimeInterval(-60 * 18), isCurrent: false),
PassportDeviceRecord(id: UUID().uuidString, label: "Berlin MacBook Pro", platform: "macos", lastSeenAt: .now.addingTimeInterval(-60 * 74), isCurrent: false)
]
}
}
private struct StoredPassportKeyMaterial: Codable, Equatable {
let privateKeyBase64: String
let publicKeyBase64: String
}
private protocol PassportKeyStoring {
func load(key: String) throws -> StoredPassportKeyMaterial?
func save(_ value: StoredPassportKeyMaterial, key: String) throws
}
private final class DefaultPassportKeyStore: PassportKeyStoring {
private let service = "global.idp.swiftapp.passport-keys"
private let fallbackDefaults = UserDefaults.standard
func load(key: String) throws -> StoredPassportKeyMaterial? {
#if canImport(Security)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess,
let data = result as? Data {
return try JSONDecoder().decode(StoredPassportKeyMaterial.self, from: data)
}
if status == errSecItemNotFound {
return nil
}
#endif
guard let data = fallbackDefaults.data(forKey: "passport-key-\(key)") else {
return nil
}
return try JSONDecoder().decode(StoredPassportKeyMaterial.self, from: data)
}
func save(_ value: StoredPassportKeyMaterial, key: String) throws {
let data = try JSONEncoder().encode(value)
#if canImport(Security)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
let attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
SecItemAdd(attributes as CFDictionary, nil)
#endif
fallbackDefaults.set(data, forKey: "passport-key-\(key)")
}
}
private enum DeviceEnvironment {
static var currentPlatform: String {
#if os(iOS)
return "ios"
#elseif os(watchOS)
return "watchos"
#elseif os(macOS)
return "macos"
#else
return "unknown"
#endif
}
static var currentDeviceLabel: String {
#if canImport(UIKit)
return UIDevice.current.name
#elseif os(macOS)
return Host.current().localizedName ?? "Mac Passport"
#elseif os(watchOS)
return "Apple Watch Passport"
#else
return "Swift Passport Device"
#endif
}
static var capabilities: CapabilityRequest {
CapabilityRequest(
gps: true,
nfc: {
#if os(iOS)
return true
#else
return false
#endif
}(),
push: false
)
}
}
private struct LiveTypedRequestClient {
let endpoint: URL
private let session: URLSession = .shared
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
init(baseURL: URL) {
self.endpoint = baseURL.appending(path: "typedrequest")
}
func fire<Request: Encodable, Response: Decodable>(
method: String,
request: Request,
responseType: Response.Type
) async throws -> Response {
let envelope = TypedRequestEnvelope(method: method, request: request, response: nil, correlation: TypedCorrelation(phase: "request"))
return try await send(envelope: envelope, method: method, responseType: responseType)
}
private func send<Request: Encodable, Response: Decodable>(
envelope: TypedRequestEnvelope<Request>,
method: String,
responseType: Response.Type
) async throws -> Response {
var urlRequest = URLRequest(url: endpoint)
urlRequest.httpMethod = "POST"
urlRequest.httpBody = try encoder.encode(envelope)
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse,
200 ..< 300 ~= httpResponse.statusCode else {
throw TypedRequestTransportError(message: "Typed request transport failed")
}
let typedResponse = try decoder.decode(TypedResponseEnvelope<Response>.self, from: data)
if let error = typedResponse.error {
throw TypedRequestTransportError(message: error.text)
}
if let retry = typedResponse.retry {
try await Task.sleep(for: .milliseconds(retry.waitForMs))
return try await send(envelope: envelope, method: method, responseType: responseType)
}
guard let responsePayload = typedResponse.response else {
throw TypedRequestTransportError(message: "Typed request returned no response payload")
}
return responsePayload
}
}
private struct TypedCorrelation: Codable {
let id: String
let phase: String
init(id: String = UUID().uuidString, phase: String) {
self.id = id
self.phase = phase
}
}
private struct TypedRequestEnvelope<Request: Encodable>: Encodable {
let method: String
let request: Request
let response: EmptyJSONValue?
let correlation: TypedCorrelation
}
private struct TypedResponseEnvelope<Response: Decodable>: Decodable {
let response: Response?
let error: TypedResponseErrorPayload?
let retry: TypedRetryInstruction?
}
private struct TypedResponseErrorPayload: Decodable {
let text: String
}
private struct TypedRetryInstruction: Decodable {
let waitForMs: Int
}
private struct EmptyJSONValue: Codable {}
private struct TypedRequestTransportError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
private struct SignedDeviceRequest: Encodable {
let deviceId: String
let timestamp: Int
let nonce: String
let signatureBase64: String
let signatureFormat: String
}
private struct CapabilityRequest: Encodable {
let gps: Bool
let nfc: Bool
let push: Bool
}
private struct CompletePassportEnrollmentRequest: Encodable {
let pairingToken: String
let deviceLabel: String
let platform: String
let publicKeyX963Base64: String
let signatureBase64: String
let signatureFormat: String
let appVersion: String?
let capabilities: CapabilityRequest
}
private struct ApprovePassportChallengeRequest: Encodable {
let challengeId: String
let deviceId: String
let signatureBase64: String
let signatureFormat: String
let location: LocationEvidenceRequest?
let nfc: NFCEvidenceRequest?
}
private struct RejectPassportChallengeRequest: Encodable {
let deviceId: String
let timestamp: Int
let nonce: String
let signatureBase64: String
let signatureFormat: String
let challengeId: String
}
private struct MarkPassportAlertSeenRequest: Encodable {
let deviceId: String
let timestamp: Int
let nonce: String
let signatureBase64: String
let signatureFormat: String
let hintId: String
}
private struct LocationEvidenceRequest: Encodable {
let latitude: Double
let longitude: Double
let accuracyMeters: Double
let capturedAt: Int
}
private struct NFCEvidenceRequest: Encodable {
let tagId: String?
let readerId: String?
}
private struct SimpleSuccessResponse: Decodable {
let success: Bool
}
private struct CompletePassportEnrollmentResponse: Decodable {
let device: ServerPassportDevice
}
private struct ApprovePassportChallengeResponse: Decodable {
let success: Bool
}
private struct RejectPassportChallengeResponse: Decodable {
let success: Bool
}
private struct PassportDashboardResponse: Decodable {
struct Profile: Decodable {
let userId: String
let name: String
let handle: String
let organizations: [Organization]
let deviceCount: Int
let recoverySummary: String
}
struct Organization: Decodable {
let id: String
let name: String
}
let profile: Profile
let devices: [ServerPassportDevice]
let challenges: [ServerChallengeItem]
let alerts: [ServerAlert]
}
private struct ServerChallengeItem: Decodable {
let challenge: ServerPassportChallenge
let signingPayload: String
}
private struct ServerPassportDevice: Decodable {
struct DataPayload: Decodable {
let userId: String
let label: String
let platform: String
let status: String
let lastSeenAt: Int?
}
let id: String
let data: DataPayload
}
private struct ServerPassportChallenge: Decodable {
struct DataPayload: Decodable {
struct MetadataPayload: Decodable {
struct LocationPolicyPayload: Decodable {
let mode: String
let label: String?
let latitude: Double
let longitude: Double
let radiusMeters: Double
let maxAccuracyMeters: Double?
}
let originHost: String?
let audience: String?
let notificationTitle: String?
let deviceLabel: String?
let requireLocation: Bool
let requireNfc: Bool
let locationPolicy: LocationPolicyPayload?
}
struct NotificationPayload: Decodable {
let hintId: String
}
let challenge: String
let type: String
let status: String
let metadata: MetadataPayload
let notification: NotificationPayload?
let createdAt: Int
let expiresAt: Int
}
let id: String
let data: DataPayload
}
private struct ServerAlert: Decodable {
struct DataPayload: Decodable {
struct NotificationPayload: Decodable {
let hintId: String
}
let category: String
let title: String
let body: String
let createdAt: Int
let seenAt: Int?
let notification: NotificationPayload
}
let id: String
let data: DataPayload
}
#if canImport(CoreLocation)
@MainActor
private final class CurrentLocationEvidenceProvider: NSObject, @preconcurrency CLLocationManagerDelegate {
private var manager: CLLocationManager?
private var authorizationContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
private var locationContinuation: CheckedContinuation<CLLocation, Error>?
static func currentLocationEvidenceIfAvailable() async throws -> LocationEvidenceRequest {
let provider = CurrentLocationEvidenceProvider()
return try await provider.currentLocationEvidence()
}
private func currentLocationEvidence() async throws -> LocationEvidenceRequest {
let manager = CLLocationManager()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
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
}
let location = try await requestLocation(using: manager)
return LocationEvidenceRequest(
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude,
accuracyMeters: location.horizontalAccuracy,
capturedAt: Int(location.timestamp.timeIntervalSince1970 * 1000)
)
}
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()
}
}
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
continuation.resume(throwing: AppError.locationUnavailable)
}
}
#else
private enum CurrentLocationEvidenceProvider {
static func currentLocationEvidenceIfAvailable() async throws -> LocationEvidenceRequest {
throw AppError.locationUnavailable
}
}
#endif
private extension Date {
init(millisecondsSince1970: Int) {
self = Date(timeIntervalSince1970: TimeInterval(millisecondsSince1970) / 1000)
}
}