Files
swiftapp/swift/Sources/Core/Services/MockIDPService.swift
T

1235 lines
43 KiB
Swift
Raw Normal View History

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