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