Refocus app around identity proof flows

This commit is contained in:
2026-04-18 01:05:22 +02:00
parent d195037eb6
commit ea6b45388f
45 changed files with 2784 additions and 3159 deletions
+153 -32
View File
@@ -1,3 +1,4 @@
import CryptoKit
import Foundation
enum AppSection: String, CaseIterable, Identifiable, Hashable {
@@ -58,17 +59,119 @@ enum NotificationPermissionState: String, CaseIterable, Identifiable {
case .unknown:
"The app has not asked for notification delivery yet."
case .allowed:
"Alerts can break through immediately when a request arrives."
"Identity proof alerts can break through immediately when a check arrives."
case .provisional:
"Notifications can be delivered quietly until the user promotes them."
"Identity proof alerts can be delivered quietly until the user promotes them."
case .denied:
"Approval events stay in-app until the user re-enables notifications."
"Identity proof events stay in-app until the user re-enables notifications."
}
}
}
struct BootstrapContext {
let suggestedQRCodePayload: String
let suggestedPairingPayload: String
}
enum PairingTransport: String, Hashable {
case qr
case nfc
case manual
case preview
var title: String {
switch self {
case .qr:
"QR"
case .nfc:
"NFC"
case .manual:
"Manual"
case .preview:
"Preview"
}
}
}
struct PairingAuthenticationRequest: Hashable {
let pairingPayload: String
let transport: PairingTransport
let signedGPSPosition: SignedGPSPosition?
}
struct SignedGPSPosition: Hashable {
let latitude: Double
let longitude: Double
let horizontalAccuracyMeters: Double
let capturedAt: Date
let signatureBase64: String
let publicKeyBase64: String
init(
latitude: Double,
longitude: Double,
horizontalAccuracyMeters: Double,
capturedAt: Date,
signatureBase64: String = "",
publicKeyBase64: String = ""
) {
self.latitude = latitude
self.longitude = longitude
self.horizontalAccuracyMeters = horizontalAccuracyMeters
self.capturedAt = capturedAt
self.signatureBase64 = signatureBase64
self.publicKeyBase64 = publicKeyBase64
}
var coordinateSummary: String {
"\(Self.normalized(latitude, precision: 5)), \(Self.normalized(longitude, precision: 5))"
}
var accuracySummary: String {
"±\(Int(horizontalAccuracyMeters.rounded())) m"
}
func signingPayload(for pairingPayload: String) -> Data {
let lines = [
"payload=\(pairingPayload)",
"latitude=\(Self.normalized(latitude, precision: 6))",
"longitude=\(Self.normalized(longitude, precision: 6))",
"accuracy=\(Self.normalized(horizontalAccuracyMeters, precision: 2))",
"captured_at=\(Self.timestampFormatter.string(from: capturedAt))"
]
return Data(lines.joined(separator: "\n").utf8)
}
func verified(for pairingPayload: String) -> Bool {
guard let signatureData = Data(base64Encoded: signatureBase64),
let publicKeyData = Data(base64Encoded: publicKeyBase64),
let publicKey = try? P256.Signing.PublicKey(x963Representation: publicKeyData),
let signature = try? P256.Signing.ECDSASignature(derRepresentation: signatureData) else {
return false
}
return publicKey.isValidSignature(signature, for: signingPayload(for: pairingPayload))
}
func signed(signatureData: Data, publicKeyData: Data) -> SignedGPSPosition {
SignedGPSPosition(
latitude: latitude,
longitude: longitude,
horizontalAccuracyMeters: horizontalAccuracyMeters,
capturedAt: capturedAt,
signatureBase64: signatureData.base64EncodedString(),
publicKeyBase64: publicKeyData.base64EncodedString()
)
}
private static let timestampFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private static func normalized(_ value: Double, precision: Int) -> String {
String(format: "%.\(precision)f", locale: Locale(identifier: "en_US_POSIX"), value)
}
}
struct DashboardSnapshot {
@@ -114,6 +217,8 @@ struct AuthSession: Identifiable, Hashable {
let pairedAt: Date
let tokenPreview: String
let pairingCode: String
let pairingTransport: PairingTransport
let signedGPSPosition: SignedGPSPosition?
init(
id: UUID = UUID(),
@@ -121,7 +226,9 @@ struct AuthSession: Identifiable, Hashable {
originHost: String,
pairedAt: Date,
tokenPreview: String,
pairingCode: String
pairingCode: String,
pairingTransport: PairingTransport = .manual,
signedGPSPosition: SignedGPSPosition? = nil
) {
self.id = id
self.deviceName = deviceName
@@ -129,6 +236,8 @@ struct AuthSession: Identifiable, Hashable {
self.pairedAt = pairedAt
self.tokenPreview = tokenPreview
self.pairingCode = pairingCode
self.pairingTransport = pairingTransport
self.signedGPSPosition = signedGPSPosition
}
}
@@ -139,17 +248,17 @@ enum ApprovalRequestKind: String, CaseIterable, Hashable {
var title: String {
switch self {
case .signIn: "Sign-In"
case .accessGrant: "Access Grant"
case .elevatedAction: "Elevated Action"
case .signIn: "Identity Check"
case .accessGrant: "Strong Proof"
case .elevatedAction: "Sensitive Proof"
}
}
var systemImage: String {
switch self {
case .signIn: "qrcode.viewfinder"
case .accessGrant: "key.fill"
case .elevatedAction: "shield.lefthalf.filled"
case .accessGrant: "person.badge.shield.checkmark.fill"
case .elevatedAction: "shield.checkered"
}
}
}
@@ -168,18 +277,18 @@ enum ApprovalRisk: String, Hashable {
var summary: String {
switch self {
case .routine:
"Routine access to profile or sign-in scopes."
"A familiar identity proof for a normal sign-in or check."
case .elevated:
"Sensitive access that can sign, publish, or unlock privileged actions."
"A higher-assurance identity proof for a sensitive check."
}
}
var guidance: String {
switch self {
case .routine:
"Review the origin and scope list, then approve if the session matches the device you expect."
"Review the origin and continue only if it matches the proof you started."
case .elevated:
"Treat this like a privileged operation. Verify the origin, the requested scopes, and whether the action is time-bound before approving."
"Only continue if you initiated this proof and trust the origin asking for it."
}
}
}
@@ -192,8 +301,8 @@ enum ApprovalStatus: String, Hashable {
var title: String {
switch self {
case .pending: "Pending"
case .approved: "Approved"
case .rejected: "Rejected"
case .approved: "Verified"
case .rejected: "Declined"
}
}
@@ -241,34 +350,34 @@ struct ApprovalRequest: Identifiable, Hashable {
var scopeSummary: String {
if scopes.isEmpty {
return "No scopes listed"
return "No proof details listed"
}
let suffix = scopes.count == 1 ? "" : "s"
return "\(scopes.count) requested scope\(suffix)"
return "\(scopes.count) proof detail\(suffix)"
}
var trustHeadline: String {
switch (kind, risk) {
case (.signIn, .routine):
"Low-friction sign-in request"
"Standard identity proof"
case (.signIn, .elevated):
"Privileged sign-in request"
"High-assurance sign-in proof"
case (.accessGrant, _):
"Token grant request"
"Cross-device identity proof"
case (.elevatedAction, _):
"Sensitive action request"
"Sensitive identity proof"
}
}
var trustDetail: String {
switch kind {
case .signIn:
"This request usually creates or refreshes a session token for a browser, CLI, or device."
"This request proves that the person at the browser, CLI, or device is really you."
case .accessGrant:
"This request issues scoped access for a service or automation that wants to act on your behalf."
"This request asks for a stronger proof so the relying party can trust the session with higher confidence."
case .elevatedAction:
"This request performs a privileged action such as signing, publishing, or creating short-lived credentials."
"This request asks for the highest confidence proof before continuing with a sensitive flow."
}
}
}
@@ -280,7 +389,7 @@ enum AppNotificationKind: String, Hashable {
var title: String {
switch self {
case .approval: "Approval"
case .approval: "Proof"
case .security: "Security"
case .system: "System"
}
@@ -297,9 +406,9 @@ enum AppNotificationKind: String, Hashable {
var summary: String {
switch self {
case .approval:
"Decision and approval activity"
"Identity proof activity"
case .security:
"Pairing and security posture updates"
"Passport and security posture updates"
case .system:
"Product and environment status messages"
}
@@ -332,15 +441,27 @@ struct AppNotification: Identifiable, Hashable {
}
enum AppError: LocalizedError {
case invalidQRCode
case invalidPairingPayload
case missingSignedGPSPosition
case invalidSignedGPSPosition
case locationPermissionDenied
case locationUnavailable
case requestNotFound
var errorDescription: String? {
switch self {
case .invalidQRCode:
"That QR payload is not valid for idp.global sign-in."
case .invalidPairingPayload:
"That idp.global payload is not valid for this action."
case .missingSignedGPSPosition:
"Tap NFC requires a signed GPS position."
case .invalidSignedGPSPosition:
"The signed GPS position attached to this NFC proof could not be verified."
case .locationPermissionDenied:
"Location access is required so Tap NFC can attach a signed GPS position."
case .locationUnavailable:
"Unable to determine the current GPS position for Tap NFC."
case .requestNotFound:
"The selected request could not be found."
"The selected identity check could not be found."
}
}
}
+121 -44
View File
@@ -2,7 +2,8 @@ import Foundation
protocol IDPServicing {
func bootstrap() async throws -> BootstrapContext
func signIn(withQRCode payload: String) async throws -> SignInResult
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
@@ -30,18 +31,19 @@ actor MockIDPService: IDPServicing {
func bootstrap() async throws -> BootstrapContext {
try await Task.sleep(for: .milliseconds(120))
return BootstrapContext(
suggestedQRCodePayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
suggestedPairingPayload: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP"
)
}
func signIn(withQRCode payload: String) async throws -> SignInResult {
func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult {
try await Task.sleep(for: .milliseconds(260))
let session = try parseSession(from: payload)
try validateSignedGPSPosition(in: request)
let session = try parseSession(from: request)
notifications.insert(
AppNotification(
title: "New device paired",
message: "\(session.deviceName) completed a QR pairing against \(session.originHost).",
title: "Passport activated",
message: pairingMessage(for: session),
sentAt: .now,
kind: .security,
isUnread: true
@@ -55,6 +57,25 @@ actor MockIDPService: IDPServicing {
)
}
func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot {
try await Task.sleep(for: .milliseconds(180))
try validateSignedGPSPosition(in: request)
let context = try parsePayloadContext(from: request.pairingPayload)
notifications.insert(
AppNotification(
title: "Identity proof completed",
message: identificationMessage(for: context, signedGPSPosition: request.signedGPSPosition),
sentAt: .now,
kind: .security,
isUnread: true
),
at: 0
)
return snapshot()
}
func refreshDashboard() async throws -> DashboardSnapshot {
try await Task.sleep(for: .milliseconds(180))
return snapshot()
@@ -70,8 +91,8 @@ actor MockIDPService: IDPServicing {
requests[index].status = .approved
notifications.insert(
AppNotification(
title: "Request approved",
message: "\(requests[index].title) was approved for \(requests[index].source).",
title: "Identity verified",
message: "\(requests[index].title) was completed for \(requests[index].source).",
sentAt: .now,
kind: .approval,
isUnread: true
@@ -92,8 +113,8 @@ actor MockIDPService: IDPServicing {
requests[index].status = .rejected
notifications.insert(
AppNotification(
title: "Request rejected",
message: "\(requests[index].title) was rejected before token issuance.",
title: "Identity proof declined",
message: "\(requests[index].title) was declined before the session could continue.",
sentAt: .now,
kind: .security,
isUnread: true
@@ -108,21 +129,21 @@ actor MockIDPService: IDPServicing {
try await Task.sleep(for: .milliseconds(120))
let syntheticRequest = ApprovalRequest(
title: "Approve SSH certificate issue",
subtitle: "CI runner wants a short-lived signing certificate for a deployment pipeline.",
source: "deploy.idp.global",
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: .elevatedAction,
risk: .elevated,
scopes: ["sign:ssh", "ttl:10m", "environment:staging"],
kind: .signIn,
risk: .routine,
scopes: ["proof:basic", "client:web", "method:qr"],
status: .pending
)
requests.insert(syntheticRequest, at: 0)
notifications.insert(
AppNotification(
title: "Fresh approval request",
message: "A staging deployment is waiting for your approval.",
title: "Fresh identity proof request",
message: "A new relying party is waiting for your identity proof.",
sentAt: .now,
kind: .approval,
isUnread: true
@@ -152,7 +173,33 @@ actor MockIDPService: IDPServicing {
)
}
private func parseSession(from payload: String) throws -> AuthSession {
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 parsePayloadContext(from: request.pairingPayload)
return AuthSession(
deviceName: context.deviceName,
originHost: context.originHost,
pairedAt: .now,
tokenPreview: context.tokenPreview,
pairingCode: request.pairingPayload,
pairingTransport: request.transport,
signedGPSPosition: request.signedGPSPosition
)
}
private func parsePayloadContext(from payload: String) throws -> PayloadContext {
if let components = URLComponents(string: payload),
components.scheme == "idp.global",
components.host == "pair" {
@@ -161,58 +208,88 @@ actor MockIDPService: IDPServicing {
let origin = queryItems.first(where: { $0.name == "origin" })?.value ?? "code.foss.global"
let device = queryItems.first(where: { $0.name == "device" })?.value ?? "Web Session"
return AuthSession(
return PayloadContext(
deviceName: device,
originHost: origin,
pairedAt: .now,
tokenPreview: String(token.suffix(6)),
pairingCode: payload
tokenPreview: String(token.suffix(6))
)
}
if payload.contains("token") || payload.contains("pair") {
return AuthSession(
deviceName: "Manual Pairing",
return PayloadContext(
deviceName: "Manual Session",
originHost: "code.foss.global",
pairedAt: .now,
tokenPreview: String(payload.suffix(6)),
pairingCode: payload
tokenPreview: String(payload.suffix(6))
)
}
throw AppError.invalidQRCode
throw AppError.invalidPairingPayload
}
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: PayloadContext, 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 struct PayloadContext {
let deviceName: String
let originHost: String
let tokenPreview: String
}
private static func seedRequests() -> [ApprovalRequest] {
[
ApprovalRequest(
title: "Approve Safari sign-in",
subtitle: "A browser session from Berlin wants an SSO token for the portal.",
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: ["openid", "profile", "groups:read"],
scopes: ["proof:basic", "client:web", "origin:trusted"],
status: .pending
),
ApprovalRequest(
title: "Grant package publish access",
subtitle: "The release bot is asking for a scoped publish token.",
source: "registry.foss.global",
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: .accessGrant,
kind: .elevatedAction,
risk: .elevated,
scopes: ["packages:write", "ttl:30m"],
scopes: ["proof:high", "client:desktop", "presence:required"],
status: .pending
),
ApprovalRequest(
title: "Approve CLI login",
subtitle: "A terminal session completed QR pairing earlier today.",
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: ["openid", "profile"],
scopes: ["proof:basic", "client:cli"],
status: .approved
)
]
@@ -221,8 +298,8 @@ actor MockIDPService: IDPServicing {
private static func seedNotifications() -> [AppNotification] {
[
AppNotification(
title: "Two requests are waiting",
message: "The queue includes one routine sign-in and one elevated access grant.",
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
@@ -235,8 +312,8 @@ actor MockIDPService: IDPServicing {
isUnread: false
),
AppNotification(
title: "Quiet hours active on mobile",
message: "Routine notifications will be delivered silently until the morning.",
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