1137a117b1
CI / test (push) Has been cancelled
Narrow the live service initializer visibility, correct key material decoding, and restrict the location evidence helper to iOS so the app builds cleanly on macOS.
1235 lines
43 KiB
Swift
1235 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
|
|
|
|
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)
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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) && 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)
|
|
}
|
|
}
|