Files
swiftapp/swift/Sources/Core/Models/AppModels.swift
T

629 lines
19 KiB
Swift
Raw Normal View History

2026-04-18 01:05:22 +02:00
import CryptoKit
2026-04-17 22:08:27 +02:00
import Foundation
enum AppSection: String, CaseIterable, Identifiable, Hashable, Codable {
case inbox
case notifications
case devices
case identity
case settings
2026-04-17 22:08:27 +02:00
var id: String { rawValue }
var title: String {
switch self {
case .inbox: "Inbox"
case .notifications: "Notifications"
case .devices: "Devices"
case .identity: "Identity"
case .settings: "Settings"
2026-04-17 22:08:27 +02:00
}
}
var systemImage: String {
switch self {
case .inbox: "tray.full.fill"
case .notifications: "bell.badge.fill"
case .devices: "desktopcomputer"
case .identity: "person.crop.rectangle.stack.fill"
case .settings: "gearshape.fill"
2026-04-17 22:08:27 +02:00
}
}
}
enum NotificationPermissionState: String, CaseIterable, Identifiable, Codable {
2026-04-17 22:08:27 +02:00
case unknown
case allowed
case provisional
case denied
var id: String { rawValue }
var title: String {
switch self {
case .unknown: "Not Asked Yet"
case .allowed: "Enabled"
case .provisional: "Delivered Quietly"
case .denied: "Disabled"
}
}
var systemImage: String {
switch self {
case .unknown: "bell"
case .allowed: "bell.badge.fill"
case .provisional: "bell.badge"
case .denied: "bell.slash.fill"
}
}
var summary: String {
switch self {
case .unknown:
"The app has not asked for notification delivery yet."
case .allowed:
2026-04-18 01:05:22 +02:00
"Identity proof alerts can break through immediately when a check arrives."
2026-04-17 22:08:27 +02:00
case .provisional:
2026-04-18 01:05:22 +02:00
"Identity proof alerts can be delivered quietly until the user promotes them."
2026-04-17 22:08:27 +02:00
case .denied:
2026-04-18 01:05:22 +02:00
"Identity proof events stay in-app until the user re-enables notifications."
2026-04-17 22:08:27 +02:00
}
}
}
struct BootstrapContext {
2026-04-18 01:05:22 +02:00
let suggestedPairingPayload: String
}
enum PairingTransport: String, Hashable, Codable {
2026-04-18 01:05:22 +02:00
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, Codable {
2026-04-18 01:05:22 +02:00
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)
}
2026-04-17 22:08:27 +02:00
}
struct DashboardSnapshot {
let profile: MemberProfile
let requests: [ApprovalRequest]
let notifications: [AppNotification]
let devices: [PassportDeviceRecord]
init(
profile: MemberProfile,
requests: [ApprovalRequest],
notifications: [AppNotification],
devices: [PassportDeviceRecord] = []
) {
self.profile = profile
self.requests = requests
self.notifications = notifications
self.devices = devices
}
2026-04-17 22:08:27 +02:00
}
struct SignInResult {
let session: AuthSession
let snapshot: DashboardSnapshot
}
struct MemberProfile: Identifiable, Hashable, Codable {
2026-04-17 22:08:27 +02:00
let id: UUID
let name: String
let handle: String
let organization: String
let deviceCount: Int
let recoverySummary: String
init(
id: UUID = UUID(),
name: String,
handle: String,
organization: String,
deviceCount: Int,
recoverySummary: String
) {
self.id = id
self.name = name
self.handle = handle
self.organization = organization
self.deviceCount = deviceCount
self.recoverySummary = recoverySummary
}
}
struct AuthSession: Identifiable, Hashable, Codable {
2026-04-17 22:08:27 +02:00
let id: UUID
let deviceName: String
let originHost: String
let serverURL: String
let passportDeviceID: String
2026-04-17 22:08:27 +02:00
let pairedAt: Date
let tokenPreview: String
let pairingCode: String
2026-04-18 01:05:22 +02:00
let pairingTransport: PairingTransport
let signedGPSPosition: SignedGPSPosition?
2026-04-17 22:08:27 +02:00
init(
id: UUID = UUID(),
deviceName: String,
originHost: String,
serverURL: String = "",
passportDeviceID: String = "",
2026-04-17 22:08:27 +02:00
pairedAt: Date,
tokenPreview: String,
2026-04-18 01:05:22 +02:00
pairingCode: String,
pairingTransport: PairingTransport = .manual,
signedGPSPosition: SignedGPSPosition? = nil
2026-04-17 22:08:27 +02:00
) {
self.id = id
self.deviceName = deviceName
self.originHost = originHost
self.serverURL = serverURL
self.passportDeviceID = passportDeviceID
2026-04-17 22:08:27 +02:00
self.pairedAt = pairedAt
self.tokenPreview = tokenPreview
self.pairingCode = pairingCode
2026-04-18 01:05:22 +02:00
self.pairingTransport = pairingTransport
self.signedGPSPosition = signedGPSPosition
2026-04-17 22:08:27 +02:00
}
private enum CodingKeys: String, CodingKey {
case id
case deviceName
case originHost
case serverURL
case passportDeviceID
case pairedAt
case tokenPreview
case pairingCode
case pairingTransport
case signedGPSPosition
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
deviceName = try container.decode(String.self, forKey: .deviceName)
originHost = try container.decode(String.self, forKey: .originHost)
serverURL = try container.decodeIfPresent(String.self, forKey: .serverURL) ?? "https://\(originHost)"
passportDeviceID = try container.decodeIfPresent(String.self, forKey: .passportDeviceID) ?? ""
pairedAt = try container.decode(Date.self, forKey: .pairedAt)
tokenPreview = try container.decode(String.self, forKey: .tokenPreview)
pairingCode = try container.decode(String.self, forKey: .pairingCode)
pairingTransport = try container.decodeIfPresent(PairingTransport.self, forKey: .pairingTransport) ?? .manual
signedGPSPosition = try container.decodeIfPresent(SignedGPSPosition.self, forKey: .signedGPSPosition)
}
2026-04-17 22:08:27 +02:00
}
enum ApprovalRequestKind: String, CaseIterable, Hashable, Codable {
2026-04-17 22:08:27 +02:00
case signIn
case accessGrant
case elevatedAction
var title: String {
switch self {
2026-04-18 01:05:22 +02:00
case .signIn: "Identity Check"
case .accessGrant: "Strong Proof"
case .elevatedAction: "Sensitive Proof"
2026-04-17 22:08:27 +02:00
}
}
var systemImage: String {
switch self {
case .signIn: "qrcode.viewfinder"
2026-04-18 01:05:22 +02:00
case .accessGrant: "person.badge.shield.checkmark.fill"
case .elevatedAction: "shield.checkered"
2026-04-17 22:08:27 +02:00
}
}
}
enum ApprovalRisk: String, Hashable, Codable {
2026-04-17 22:08:27 +02:00
case routine
case elevated
var title: String {
switch self {
case .routine: "Routine"
case .elevated: "Elevated"
}
}
var summary: String {
switch self {
case .routine:
2026-04-18 01:05:22 +02:00
"A familiar identity proof for a normal sign-in or check."
2026-04-17 22:08:27 +02:00
case .elevated:
2026-04-18 01:05:22 +02:00
"A higher-assurance identity proof for a sensitive check."
2026-04-17 22:08:27 +02:00
}
}
var guidance: String {
switch self {
case .routine:
2026-04-18 01:05:22 +02:00
"Review the origin and continue only if it matches the proof you started."
2026-04-17 22:08:27 +02:00
case .elevated:
2026-04-18 01:05:22 +02:00
"Only continue if you initiated this proof and trust the origin asking for it."
2026-04-17 22:08:27 +02:00
}
}
}
enum ApprovalStatus: String, Hashable, Codable {
2026-04-17 22:08:27 +02:00
case pending
case approved
case rejected
var title: String {
switch self {
case .pending: "Pending"
case .approved: "Approved"
case .rejected: "Denied"
2026-04-17 22:08:27 +02:00
}
}
var systemImage: String {
switch self {
case .pending: "clock.badge"
case .approved: "checkmark.circle.fill"
case .rejected: "xmark.circle.fill"
}
}
}
struct ApprovalRequest: Identifiable, Hashable, Codable {
2026-04-17 22:08:27 +02:00
let id: UUID
let serverID: String
let hintID: String
2026-04-17 22:08:27 +02:00
let title: String
let subtitle: String
let source: String
let createdAt: Date
let kind: ApprovalRequestKind
let risk: ApprovalRisk
let scopes: [String]
let requiresLocation: Bool
let challenge: String?
let signingPayload: String?
let deviceSummaryText: String?
let locationSummaryText: String?
let networkSummaryText: String?
let ipSummaryText: String?
let expiresAtDate: Date?
2026-04-17 22:08:27 +02:00
var status: ApprovalStatus
init(
id: UUID = UUID(),
serverID: String = UUID().uuidString,
hintID: String = UUID().uuidString,
2026-04-17 22:08:27 +02:00
title: String,
subtitle: String,
source: String,
createdAt: Date,
kind: ApprovalRequestKind,
risk: ApprovalRisk,
scopes: [String],
requiresLocation: Bool = false,
challenge: String? = nil,
signingPayload: String? = nil,
deviceSummaryText: String? = nil,
locationSummaryText: String? = nil,
networkSummaryText: String? = nil,
ipSummaryText: String? = nil,
expiresAtDate: Date? = nil,
2026-04-17 22:08:27 +02:00
status: ApprovalStatus
) {
self.id = id
self.serverID = serverID
self.hintID = hintID
2026-04-17 22:08:27 +02:00
self.title = title
self.subtitle = subtitle
self.source = source
self.createdAt = createdAt
self.kind = kind
self.risk = risk
self.scopes = scopes
self.requiresLocation = requiresLocation
self.challenge = challenge
self.signingPayload = signingPayload
self.deviceSummaryText = deviceSummaryText
self.locationSummaryText = locationSummaryText
self.networkSummaryText = networkSummaryText
self.ipSummaryText = ipSummaryText
self.expiresAtDate = expiresAtDate
2026-04-17 22:08:27 +02:00
self.status = status
}
private enum CodingKeys: String, CodingKey {
case id
case serverID
case hintID
case title
case subtitle
case source
case createdAt
case kind
case risk
case scopes
case requiresLocation
case challenge
case signingPayload
case deviceSummaryText
case locationSummaryText
case networkSummaryText
case ipSummaryText
case expiresAtDate
case status
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
serverID = try container.decodeIfPresent(String.self, forKey: .serverID) ?? id.uuidString
hintID = try container.decodeIfPresent(String.self, forKey: .hintID) ?? serverID
title = try container.decode(String.self, forKey: .title)
subtitle = try container.decode(String.self, forKey: .subtitle)
source = try container.decode(String.self, forKey: .source)
createdAt = try container.decode(Date.self, forKey: .createdAt)
kind = try container.decode(ApprovalRequestKind.self, forKey: .kind)
risk = try container.decode(ApprovalRisk.self, forKey: .risk)
scopes = try container.decode([String].self, forKey: .scopes)
requiresLocation = try container.decodeIfPresent(Bool.self, forKey: .requiresLocation) ?? false
challenge = try container.decodeIfPresent(String.self, forKey: .challenge)
signingPayload = try container.decodeIfPresent(String.self, forKey: .signingPayload)
deviceSummaryText = try container.decodeIfPresent(String.self, forKey: .deviceSummaryText)
locationSummaryText = try container.decodeIfPresent(String.self, forKey: .locationSummaryText)
networkSummaryText = try container.decodeIfPresent(String.self, forKey: .networkSummaryText)
ipSummaryText = try container.decodeIfPresent(String.self, forKey: .ipSummaryText)
expiresAtDate = try container.decodeIfPresent(Date.self, forKey: .expiresAtDate)
status = try container.decode(ApprovalStatus.self, forKey: .status)
}
2026-04-17 22:08:27 +02:00
var scopeSummary: String {
if scopes.isEmpty {
2026-04-18 01:05:22 +02:00
return "No proof details listed"
2026-04-17 22:08:27 +02:00
}
let suffix = scopes.count == 1 ? "" : "s"
2026-04-18 01:05:22 +02:00
return "\(scopes.count) proof detail\(suffix)"
2026-04-17 22:08:27 +02:00
}
var trustHeadline: String {
switch (kind, risk) {
case (.signIn, .routine):
2026-04-18 01:05:22 +02:00
"Standard identity proof"
2026-04-17 22:08:27 +02:00
case (.signIn, .elevated):
2026-04-18 01:05:22 +02:00
"High-assurance sign-in proof"
2026-04-17 22:08:27 +02:00
case (.accessGrant, _):
2026-04-18 01:05:22 +02:00
"Cross-device identity proof"
2026-04-17 22:08:27 +02:00
case (.elevatedAction, _):
2026-04-18 01:05:22 +02:00
"Sensitive identity proof"
2026-04-17 22:08:27 +02:00
}
}
var trustDetail: String {
switch kind {
case .signIn:
2026-04-18 01:05:22 +02:00
"This request proves that the person at the browser, CLI, or device is really you."
2026-04-17 22:08:27 +02:00
case .accessGrant:
2026-04-18 01:05:22 +02:00
"This request asks for a stronger proof so the relying party can trust the session with higher confidence."
2026-04-17 22:08:27 +02:00
case .elevatedAction:
2026-04-18 01:05:22 +02:00
"This request asks for the highest confidence proof before continuing with a sensitive flow."
2026-04-17 22:08:27 +02:00
}
}
}
enum AppNotificationKind: String, Hashable, Codable {
2026-04-17 22:08:27 +02:00
case approval
case security
case system
var title: String {
switch self {
2026-04-18 01:05:22 +02:00
case .approval: "Proof"
2026-04-17 22:08:27 +02:00
case .security: "Security"
case .system: "System"
}
}
var systemImage: String {
switch self {
case .approval: "checkmark.seal.fill"
case .security: "shield.fill"
case .system: "sparkles"
}
}
var summary: String {
switch self {
case .approval:
2026-04-18 01:05:22 +02:00
"Identity proof activity"
2026-04-17 22:08:27 +02:00
case .security:
2026-04-18 01:05:22 +02:00
"Passport and security posture updates"
2026-04-17 22:08:27 +02:00
case .system:
"Product and environment status messages"
}
}
}
struct AppNotification: Identifiable, Hashable, Codable {
2026-04-17 22:08:27 +02:00
let id: UUID
let serverID: String
let hintID: String
2026-04-17 22:08:27 +02:00
let title: String
let message: String
let sentAt: Date
let kind: AppNotificationKind
var isUnread: Bool
init(
id: UUID = UUID(),
serverID: String = UUID().uuidString,
hintID: String = UUID().uuidString,
2026-04-17 22:08:27 +02:00
title: String,
message: String,
sentAt: Date,
kind: AppNotificationKind,
isUnread: Bool
) {
self.id = id
self.serverID = serverID
self.hintID = hintID
2026-04-17 22:08:27 +02:00
self.title = title
self.message = message
self.sentAt = sentAt
self.kind = kind
self.isUnread = isUnread
}
private enum CodingKeys: String, CodingKey {
case id
case serverID
case hintID
case title
case message
case sentAt
case kind
case isUnread
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
serverID = try container.decodeIfPresent(String.self, forKey: .serverID) ?? id.uuidString
hintID = try container.decodeIfPresent(String.self, forKey: .hintID) ?? serverID
title = try container.decode(String.self, forKey: .title)
message = try container.decode(String.self, forKey: .message)
sentAt = try container.decode(Date.self, forKey: .sentAt)
kind = try container.decode(AppNotificationKind.self, forKey: .kind)
isUnread = try container.decode(Bool.self, forKey: .isUnread)
}
}
struct PassportDeviceRecord: Identifiable, Hashable, Codable {
let id: String
let label: String
let platform: String
let lastSeenAt: Date?
let isCurrent: Bool
2026-04-17 22:08:27 +02:00
}
enum AppError: LocalizedError, Equatable {
2026-04-18 01:05:22 +02:00
case invalidPairingPayload
case missingSignedGPSPosition
case invalidSignedGPSPosition
case locationPermissionDenied
case locationUnavailable
2026-04-17 22:08:27 +02:00
case requestNotFound
var errorDescription: String? {
switch self {
2026-04-18 01:05:22 +02:00
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."
2026-04-17 22:08:27 +02:00
case .requestNotFound:
2026-04-18 01:05:22 +02:00
"The selected identity check could not be found."
2026-04-17 22:08:27 +02:00
}
}
}