From a298b5e42176347816790453d57e4792b7e4cd0f Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 20 Apr 2026 13:21:39 +0000 Subject: [PATCH] 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. --- swift/Sources/App/AppViewModel.swift | 23 +- swift/Sources/Core/Models/AppModels.swift | 158 ++++ .../Sources/Core/Services/AppStateStore.swift | 32 + .../Core/Services/MockIDPService.swift | 892 +++++++++++++++++- .../Core/Services/PairingPayloadParser.swift | 9 + swift/Sources/Features/Home/HomeCards.swift | 24 +- swift/Sources/Features/Home/HomePanels.swift | 46 +- 7 files changed, 1136 insertions(+), 48 deletions(-) diff --git a/swift/Sources/App/AppViewModel.swift b/swift/Sources/App/AppViewModel.swift index a6611cc..e79a8ab 100644 --- a/swift/Sources/App/AppViewModel.swift +++ b/swift/Sources/App/AppViewModel.swift @@ -14,6 +14,7 @@ final class AppViewModel: ObservableObject { @Published var profile: MemberProfile? @Published var requests: [ApprovalRequest] = [] @Published var notifications: [AppNotification] = [] + @Published var devices: [PassportDeviceRecord] = [] @Published var notificationPermission: NotificationPermissionState = .unknown @Published var selectedSection: AppSection = .inbox @Published var isBootstrapping = false @@ -56,7 +57,7 @@ final class AppViewModel: ObservableObject { } init( - service: IDPServicing = MockIDPService.shared, + service: IDPServicing = DefaultIDPService.shared, notificationCoordinator: NotificationCoordinating = NotificationCoordinator(), appStateStore: AppStateStoring = UserDefaultsAppStateStore(), launchArguments: [String] = ProcessInfo.processInfo.arguments @@ -118,6 +119,8 @@ final class AppViewModel: ObservableObject { if let preferredLaunchSection { selectedSection = preferredLaunchSection } + } else if session != nil { + await refreshDashboard() } } catch { if session == nil { @@ -260,6 +263,8 @@ final class AppViewModel: ObservableObject { apply(snapshot: snapshot) persistCurrentState() errorMessage = nil + } catch let error as AppError { + errorMessage = error.errorDescription } catch { errorMessage = "Unable to refresh the dashboard." } @@ -282,6 +287,8 @@ final class AppViewModel: ObservableObject { persistCurrentState() selectedSection = .inbox errorMessage = nil + } catch let error as AppError { + errorMessage = error.errorDescription } catch { errorMessage = "Unable to create a mock identity check right now." } @@ -315,6 +322,8 @@ final class AppViewModel: ObservableObject { apply(snapshot: snapshot) persistCurrentState() errorMessage = nil + } catch let error as AppError { + errorMessage = error.errorDescription } catch { errorMessage = "Unable to update the notification." } @@ -326,6 +335,7 @@ final class AppViewModel: ObservableObject { profile = nil requests = [] notifications = [] + devices = [] selectedSection = .inbox manualPairingPayload = suggestedPairingPayload isShowingPairingSuccess = false @@ -371,6 +381,8 @@ final class AppViewModel: ObservableObject { apply(snapshot: snapshot) persistCurrentState() errorMessage = nil + } catch let error as AppError { + errorMessage = error.errorDescription } catch { errorMessage = "Unable to update the identity check." } @@ -387,7 +399,8 @@ final class AppViewModel: ObservableObject { snapshot: DashboardSnapshot( profile: state.profile, requests: state.requests, - notifications: state.notifications + notifications: state.notifications, + devices: state.devices ) ) } @@ -403,7 +416,8 @@ final class AppViewModel: ObservableObject { session: session, profile: profile, requests: requests, - notifications: notifications + notifications: notifications, + devices: devices ) ) } @@ -413,6 +427,9 @@ final class AppViewModel: ObservableObject { self.profile = snapshot.profile self.requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt } self.notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt } + self.devices = snapshot.devices.sorted { + ($0.lastSeenAt ?? .distantPast) > ($1.lastSeenAt ?? .distantPast) + } } let profileValue = snapshot.profile diff --git a/swift/Sources/Core/Models/AppModels.swift b/swift/Sources/Core/Models/AppModels.swift index 3f2d69d..5d87f0c 100644 --- a/swift/Sources/Core/Models/AppModels.swift +++ b/swift/Sources/Core/Models/AppModels.swift @@ -181,6 +181,19 @@ struct DashboardSnapshot { let profile: MemberProfile let requests: [ApprovalRequest] let notifications: [AppNotification] + let devices: [PassportDeviceRecord] + + init( + profile: MemberProfile, + requests: [ApprovalRequest], + notifications: [AppNotification], + devices: [PassportDeviceRecord] = [] + ) { + self.profile = profile + self.requests = requests + self.notifications = notifications + self.devices = devices + } } struct SignInResult { @@ -217,6 +230,8 @@ struct AuthSession: Identifiable, Hashable, Codable { let id: UUID let deviceName: String let originHost: String + let serverURL: String + let passportDeviceID: String let pairedAt: Date let tokenPreview: String let pairingCode: String @@ -227,6 +242,8 @@ struct AuthSession: Identifiable, Hashable, Codable { id: UUID = UUID(), deviceName: String, originHost: String, + serverURL: String = "", + passportDeviceID: String = "", pairedAt: Date, tokenPreview: String, pairingCode: String, @@ -236,12 +253,41 @@ struct AuthSession: Identifiable, Hashable, Codable { self.id = id self.deviceName = deviceName self.originHost = originHost + self.serverURL = serverURL + self.passportDeviceID = passportDeviceID self.pairedAt = pairedAt self.tokenPreview = tokenPreview self.pairingCode = pairingCode self.pairingTransport = pairingTransport self.signedGPSPosition = signedGPSPosition } + + private enum CodingKeys: String, CodingKey { + case id + case deviceName + case originHost + case serverURL + case passportDeviceID + case pairedAt + case tokenPreview + case pairingCode + case pairingTransport + case signedGPSPosition + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + deviceName = try container.decode(String.self, forKey: .deviceName) + originHost = try container.decode(String.self, forKey: .originHost) + serverURL = try container.decodeIfPresent(String.self, forKey: .serverURL) ?? "https://\(originHost)" + passportDeviceID = try container.decodeIfPresent(String.self, forKey: .passportDeviceID) ?? "" + pairedAt = try container.decode(Date.self, forKey: .pairedAt) + tokenPreview = try container.decode(String.self, forKey: .tokenPreview) + pairingCode = try container.decode(String.self, forKey: .pairingCode) + pairingTransport = try container.decodeIfPresent(PairingTransport.self, forKey: .pairingTransport) ?? .manual + signedGPSPosition = try container.decodeIfPresent(SignedGPSPosition.self, forKey: .signedGPSPosition) + } } enum ApprovalRequestKind: String, CaseIterable, Hashable, Codable { @@ -320,6 +366,8 @@ enum ApprovalStatus: String, Hashable, Codable { struct ApprovalRequest: Identifiable, Hashable, Codable { let id: UUID + let serverID: String + let hintID: String let title: String let subtitle: String let source: String @@ -327,10 +375,20 @@ struct ApprovalRequest: Identifiable, Hashable, Codable { let kind: ApprovalRequestKind let risk: ApprovalRisk let scopes: [String] + let requiresLocation: Bool + let challenge: String? + let signingPayload: String? + let deviceSummaryText: String? + let locationSummaryText: String? + let networkSummaryText: String? + let ipSummaryText: String? + let expiresAtDate: Date? var status: ApprovalStatus init( id: UUID = UUID(), + serverID: String = UUID().uuidString, + hintID: String = UUID().uuidString, title: String, subtitle: String, source: String, @@ -338,9 +396,19 @@ struct ApprovalRequest: Identifiable, Hashable, Codable { kind: ApprovalRequestKind, risk: ApprovalRisk, scopes: [String], + requiresLocation: Bool = false, + challenge: String? = nil, + signingPayload: String? = nil, + deviceSummaryText: String? = nil, + locationSummaryText: String? = nil, + networkSummaryText: String? = nil, + ipSummaryText: String? = nil, + expiresAtDate: Date? = nil, status: ApprovalStatus ) { self.id = id + self.serverID = serverID + self.hintID = hintID self.title = title self.subtitle = subtitle self.source = source @@ -348,9 +416,62 @@ struct ApprovalRequest: Identifiable, Hashable, Codable { self.kind = kind self.risk = risk self.scopes = scopes + self.requiresLocation = requiresLocation + self.challenge = challenge + self.signingPayload = signingPayload + self.deviceSummaryText = deviceSummaryText + self.locationSummaryText = locationSummaryText + self.networkSummaryText = networkSummaryText + self.ipSummaryText = ipSummaryText + self.expiresAtDate = expiresAtDate self.status = status } + private enum CodingKeys: String, CodingKey { + case id + case serverID + case hintID + case title + case subtitle + case source + case createdAt + case kind + case risk + case scopes + case requiresLocation + case challenge + case signingPayload + case deviceSummaryText + case locationSummaryText + case networkSummaryText + case ipSummaryText + case expiresAtDate + case status + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + serverID = try container.decodeIfPresent(String.self, forKey: .serverID) ?? id.uuidString + hintID = try container.decodeIfPresent(String.self, forKey: .hintID) ?? serverID + title = try container.decode(String.self, forKey: .title) + subtitle = try container.decode(String.self, forKey: .subtitle) + source = try container.decode(String.self, forKey: .source) + createdAt = try container.decode(Date.self, forKey: .createdAt) + kind = try container.decode(ApprovalRequestKind.self, forKey: .kind) + risk = try container.decode(ApprovalRisk.self, forKey: .risk) + scopes = try container.decode([String].self, forKey: .scopes) + requiresLocation = try container.decodeIfPresent(Bool.self, forKey: .requiresLocation) ?? false + challenge = try container.decodeIfPresent(String.self, forKey: .challenge) + signingPayload = try container.decodeIfPresent(String.self, forKey: .signingPayload) + deviceSummaryText = try container.decodeIfPresent(String.self, forKey: .deviceSummaryText) + locationSummaryText = try container.decodeIfPresent(String.self, forKey: .locationSummaryText) + networkSummaryText = try container.decodeIfPresent(String.self, forKey: .networkSummaryText) + ipSummaryText = try container.decodeIfPresent(String.self, forKey: .ipSummaryText) + expiresAtDate = try container.decodeIfPresent(Date.self, forKey: .expiresAtDate) + status = try container.decode(ApprovalStatus.self, forKey: .status) + } + var scopeSummary: String { if scopes.isEmpty { return "No proof details listed" @@ -420,6 +541,8 @@ enum AppNotificationKind: String, Hashable, Codable { struct AppNotification: Identifiable, Hashable, Codable { let id: UUID + let serverID: String + let hintID: String let title: String let message: String let sentAt: Date @@ -428,6 +551,8 @@ struct AppNotification: Identifiable, Hashable, Codable { init( id: UUID = UUID(), + serverID: String = UUID().uuidString, + hintID: String = UUID().uuidString, title: String, message: String, sentAt: Date, @@ -435,12 +560,45 @@ struct AppNotification: Identifiable, Hashable, Codable { isUnread: Bool ) { self.id = id + self.serverID = serverID + self.hintID = hintID self.title = title self.message = message self.sentAt = sentAt self.kind = kind self.isUnread = isUnread } + + private enum CodingKeys: String, CodingKey { + case id + case serverID + case hintID + case title + case message + case sentAt + case kind + case isUnread + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + serverID = try container.decodeIfPresent(String.self, forKey: .serverID) ?? id.uuidString + hintID = try container.decodeIfPresent(String.self, forKey: .hintID) ?? serverID + title = try container.decode(String.self, forKey: .title) + message = try container.decode(String.self, forKey: .message) + sentAt = try container.decode(Date.self, forKey: .sentAt) + kind = try container.decode(AppNotificationKind.self, forKey: .kind) + isUnread = try container.decode(Bool.self, forKey: .isUnread) + } +} + +struct PassportDeviceRecord: Identifiable, Hashable, Codable { + let id: String + let label: String + let platform: String + let lastSeenAt: Date? + let isCurrent: Bool } enum AppError: LocalizedError, Equatable { diff --git a/swift/Sources/Core/Services/AppStateStore.swift b/swift/Sources/Core/Services/AppStateStore.swift index fd9716b..7be6497 100644 --- a/swift/Sources/Core/Services/AppStateStore.swift +++ b/swift/Sources/Core/Services/AppStateStore.swift @@ -13,6 +13,38 @@ struct PersistedAppState: Codable, Equatable { let profile: MemberProfile let requests: [ApprovalRequest] let notifications: [AppNotification] + let devices: [PassportDeviceRecord] + + init( + session: AuthSession, + profile: MemberProfile, + requests: [ApprovalRequest], + notifications: [AppNotification], + devices: [PassportDeviceRecord] = [] + ) { + self.session = session + self.profile = profile + self.requests = requests + self.notifications = notifications + self.devices = devices + } + + private enum CodingKeys: String, CodingKey { + case session + case profile + case requests + case notifications + case devices + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + session = try container.decode(AuthSession.self, forKey: .session) + profile = try container.decode(MemberProfile.self, forKey: .profile) + requests = try container.decode([ApprovalRequest].self, forKey: .requests) + notifications = try container.decode([AppNotification].self, forKey: .notifications) + devices = try container.decodeIfPresent([PassportDeviceRecord].self, forKey: .devices) ?? [] + } } protocol AppStateStoring { diff --git a/swift/Sources/Core/Services/MockIDPService.swift b/swift/Sources/Core/Services/MockIDPService.swift index 22a7ba0..501a45e 100644 --- a/swift/Sources/Core/Services/MockIDPService.swift +++ b/swift/Sources/Core/Services/MockIDPService.swift @@ -1,4 +1,17 @@ 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 @@ -11,6 +24,399 @@ protocol IDPServicing { 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() @@ -25,6 +431,7 @@ actor MockIDPService: IDPServicing { private let appStateStore: AppStateStoring private var requests: [ApprovalRequest] = [] private var notifications: [AppNotification] = [] + private var devices: [PassportDeviceRecord] = [] init(appStateStore: AppStateStoring = UserDefaultsAppStateStore()) { self.appStateStore = appStateStore @@ -32,9 +439,11 @@ actor MockIDPService: IDPServicing { 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() } } @@ -198,7 +607,8 @@ actor MockIDPService: IDPServicing { DashboardSnapshot( profile: profile, requests: requests, - notifications: notifications + notifications: notifications, + devices: devices ) } @@ -220,6 +630,8 @@ actor MockIDPService: IDPServicing { return AuthSession( deviceName: context.deviceName, originHost: context.originHost, + serverURL: "https://\(context.originHost)", + passportDeviceID: UUID().uuidString, pairedAt: .now, tokenPreview: context.tokenPreview, pairingCode: request.pairingPayload, @@ -260,11 +672,13 @@ actor MockIDPService: IDPServicing { 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() { @@ -275,7 +689,8 @@ actor MockIDPService: IDPServicing { session: state.session, profile: state.profile, requests: requests, - notifications: notifications + notifications: notifications, + devices: devices ) ) } @@ -340,4 +755,477 @@ actor MockIDPService: IDPServicing { ) ] } + + 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( + 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( + envelope: TypedRequestEnvelope, + 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.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: Encodable { + let method: String + let request: Request + let response: EmptyJSONValue? + let correlation: TypedCorrelation +} + +private struct TypedResponseEnvelope: 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? + private var locationContinuation: CheckedContinuation? + + 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) + } } diff --git a/swift/Sources/Core/Services/PairingPayloadParser.swift b/swift/Sources/Core/Services/PairingPayloadParser.swift index 5f0739d..8c31d27 100644 --- a/swift/Sources/Core/Services/PairingPayloadParser.swift +++ b/swift/Sources/Core/Services/PairingPayloadParser.swift @@ -3,6 +3,9 @@ import Foundation struct PairingPayloadContext: Equatable { let deviceName: String let originHost: String + let pairingToken: String + let challenge: String? + let challengeID: String? let tokenPreview: String } @@ -21,6 +24,9 @@ enum PairingPayloadParser { return PairingPayloadContext( deviceName: device, originHost: origin, + pairingToken: token, + challenge: queryItems.first(where: { $0.name == "challenge" })?.value, + challengeID: queryItems.first(where: { $0.name == "challenge_id" })?.value, tokenPreview: String(token.suffix(6)) ) } @@ -29,6 +35,9 @@ enum PairingPayloadParser { return PairingPayloadContext( deviceName: "Manual Session", originHost: "code.foss.global", + pairingToken: trimmedPayload, + challenge: nil, + challengeID: nil, tokenPreview: String(trimmedPayload.suffix(6)) ) } diff --git a/swift/Sources/Features/Home/HomeCards.swift b/swift/Sources/Features/Home/HomeCards.swift index 3e7383b..552e870 100644 --- a/swift/Sources/Features/Home/HomeCards.swift +++ b/swift/Sources/Features/Home/HomeCards.swift @@ -12,33 +12,19 @@ extension ApprovalRequest { } var locationSummary: String { - "Berlin, DE" + locationSummaryText ?? "Location not required" } var deviceSummary: String { - switch kind { - case .signIn: - "Safari on Berlin iPhone" - case .accessGrant: - "Chrome on iPad Pro" - case .elevatedAction: - "Berlin MacBook Pro" - } + deviceSummaryText ?? "Trusted passport device" } var networkSummary: String { - switch kind { - case .signIn: - "Home Wi-Fi" - case .accessGrant: - "Shared office Wi-Fi" - case .elevatedAction: - "Ethernet" - } + networkSummaryText ?? source } var ipSummary: String { - risk == .elevated ? "84.187.12.44" : "84.187.12.36" + ipSummaryText ?? "n/a" } var trustColor: Color { @@ -66,7 +52,7 @@ extension ApprovalRequest { } var expiresAt: Date { - createdAt.addingTimeInterval(risk == .elevated ? 180 : 300) + expiresAtDate ?? createdAt.addingTimeInterval(risk == .elevated ? 180 : 300) } } diff --git a/swift/Sources/Features/Home/HomePanels.swift b/swift/Sources/Features/Home/HomePanels.swift index 59a6e53..44fe599 100644 --- a/swift/Sources/Features/Home/HomePanels.swift +++ b/swift/Sources/Features/Home/HomePanels.swift @@ -231,27 +231,31 @@ struct NotificationCenterView: View { struct DevicesView: View { @ObservedObject var model: AppViewModel - @State private var isPairingCodePresented = false private var devices: [DevicePresentation] { guard let session else { return [] } - let current = DevicePresentation( - name: session.deviceName, - systemImage: symbolName(for: session.deviceName), - lastSeen: .now, - isCurrent: true, - isTrusted: true - ) + if !model.devices.isEmpty { + return model.devices.map { device in + DevicePresentation( + name: device.label, + systemImage: symbolName(for: device.label), + lastSeen: device.lastSeenAt ?? session.pairedAt, + isCurrent: device.isCurrent, + isTrusted: true + ) + } + } - let others = [ - DevicePresentation(name: "Phil's iPad Pro", systemImage: "ipad", lastSeen: .now.addingTimeInterval(-60 * 18), isCurrent: false, isTrusted: true), - DevicePresentation(name: "Berlin MacBook Pro", systemImage: "laptopcomputer", lastSeen: .now.addingTimeInterval(-60 * 74), isCurrent: false, isTrusted: true), - DevicePresentation(name: "Apple Watch", systemImage: "applewatch", lastSeen: .now.addingTimeInterval(-60 * 180), isCurrent: false, isTrusted: false) + return [ + DevicePresentation( + name: session.deviceName, + systemImage: symbolName(for: session.deviceName), + lastSeen: .now, + isCurrent: true, + isTrusted: true + ) ] - - let count = max((model.profile?.deviceCount ?? 1) - 1, 0) - return [current] + Array(others.prefix(count)) } private var session: AuthSession? { @@ -274,10 +278,9 @@ struct DevicesView: View { Section { VStack(spacing: 12) { - Button("Pair another device") { - isPairingCodePresented = true - } - .buttonStyle(PrimaryActionStyle()) + Text("Start new device pairing from your idp.global web session, then scan the fresh pairing QR in this app.") + .font(.footnote) + .foregroundStyle(.secondary) Button("Sign out everywhere") { model.signOut() @@ -288,11 +291,6 @@ struct DevicesView: View { } } .navigationTitle("Devices") - .sheet(isPresented: $isPairingCodePresented) { - if let session { - OneTimePasscodeSheet(session: session) - } - } } private func symbolName(for deviceName: String) -> String {