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 {