field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

TeraSessionStore.swift (8401B)


      1 import Foundation
      2 import RadrootsKit
      3 
      4 final class TeraProtectedDataMonitor: @unchecked Sendable {
      5   private let lock = NSLock()
      6   private var available: Bool
      7 
      8   init(available: Bool) {
      9     self.available = available
     10   }
     11 
     12   func update(available: Bool) {
     13     lock.withLock { self.available = available }
     14   }
     15 
     16   func isAvailable() -> Bool {
     17     lock.withLock { available }
     18   }
     19 }
     20 
     21 enum TeraSessionPhase: Sendable, Equatable {
     22   case starting
     23   case identityRequired
     24   case identityLocked(TeraAppIdentity)
     25   case protectedDataUnavailable(TeraAppIdentity)
     26   case recoveryRequired(TeraAppIdentity)
     27   case corruptIdentity(TeraAppIdentity)
     28   case configurationReconfigurationRequired(TeraConfigurationReconfigurationRequirement)
     29   case running(TeraRuntimeSnapshot)
     30   case stopped
     31   case failed(TeraRuntimeFailure)
     32 }
     33 
     34 struct TeraConfigurationReconfigurationRequirement: Sendable, Equatable {
     35   let generation: UInt64
     36   let previousBlossomConfigFingerprint: String?
     37 }
     38 
     39 actor TeraSessionStore {
     40   let configurationStore: TeraConfigurationStore
     41   let identityStore: TeraIdentityStore
     42   let runtimeClient: TeraRuntimeClient
     43   let roots: RadrootsAppleFileRoots
     44   let protectedData: TeraProtectedDataMonitor
     45   private let automatesQualificationIdentity: Bool
     46   let qualificationEvidenceStore: TeraRemoteQualificationEvidenceStore?
     47   var generation = TeraSessionGeneration.initial
     48   var phase: TeraSessionPhase = .starting
     49   var removalInProgress = false
     50 
     51   init(
     52     configurationStore: TeraConfigurationStore,
     53     identityStore: TeraIdentityStore,
     54     runtimeClient: TeraRuntimeClient,
     55     roots: RadrootsAppleFileRoots,
     56     protectedData: TeraProtectedDataMonitor,
     57     automatesQualificationIdentity: Bool = false,
     58     qualificationEvidenceStore: TeraRemoteQualificationEvidenceStore? = nil
     59   ) {
     60     self.configurationStore = configurationStore
     61     self.identityStore = identityStore
     62     self.runtimeClient = runtimeClient
     63     self.roots = roots
     64     self.protectedData = protectedData
     65     self.automatesQualificationIdentity = automatesQualificationIdentity
     66     self.qualificationEvidenceStore = qualificationEvidenceStore
     67   }
     68 
     69   func updateProtectedDataAvailability(_ available: Bool) {
     70     protectedData.update(available: available)
     71   }
     72 
     73   func currentPhase() -> TeraSessionPhase {
     74     phase
     75   }
     76 
     77   func start() async -> TeraSessionPhase {
     78     guard !removalInProgress else { return phase }
     79     return await start(acceptingReconfiguration: false)
     80   }
     81 
     82   func applyConfigurationReconfiguration() async -> TeraSessionPhase {
     83     guard !removalInProgress else { return phase }
     84     return await start(acceptingReconfiguration: true)
     85   }
     86 
     87   func suspend() async {
     88     generation = generation.invalidated()
     89     let requestedGeneration = generation
     90     await runtimeClient.suspend()
     91     if generation == requestedGeneration, case .starting = phase {
     92       phase = .stopped
     93     }
     94   }
     95 
     96   private func start(acceptingReconfiguration: Bool) async -> TeraSessionPhase {
     97     generation = generation.invalidated()
     98     let requestedGeneration = generation
     99     phase = .starting
    100     do {
    101       let identity = try await identityStore.loadAndMigrate()
    102       guard generation == requestedGeneration else {
    103         throw TeraRuntimeClientError.superseded
    104       }
    105       switch identity.state {
    106       case .absent:
    107         phase = .identityRequired
    108       case .locked:
    109         #if DEBUG
    110           if automatesQualificationIdentity {
    111             return await unlockIdentity()
    112           }
    113         #endif
    114         phase = .identityLocked(identity)
    115       case .protectedDataUnavailable:
    116         phase = .protectedDataUnavailable(identity)
    117       case .recoveryRequired:
    118         phase = .recoveryRequired(identity)
    119       case .corrupt:
    120         phase = .corruptIdentity(identity)
    121       case .unlocked:
    122         phase = try await startUnlocked(
    123           identity, acceptingReconfiguration: acceptingReconfiguration, generation: requestedGeneration
    124         )
    125       }
    126     } catch TeraRuntimeClientError.superseded {
    127       return phase
    128     } catch let TeraRuntimeClientError.startup(failure) {
    129       guard generation == requestedGeneration else { return phase }
    130       phase = .failed(failure)
    131     } catch {
    132       guard generation == requestedGeneration else { return phase }
    133       phase = .failed(
    134         .local(
    135           operation: "session.start",
    136           code: "ios.session.start_failed",
    137           safeMessage: TeraUserMessages.text(for: error, fallback: .startupFailed)
    138         )
    139       )
    140     }
    141     return phase
    142   }
    143 
    144   private func startUnlocked(
    145     _ identity: TeraAppIdentity, acceptingReconfiguration: Bool,
    146     generation requestedGeneration: TeraSessionGeneration
    147   ) async throws -> TeraSessionPhase {
    148     let configuration = try await configurationStore.load()
    149     try ensureCurrent(requestedGeneration)
    150     if configuration.activationState == .reconfigurationRequired,
    151       !acceptingReconfiguration
    152     {
    153       return .configurationReconfigurationRequired(
    154         TeraConfigurationReconfigurationRequirement(
    155           generation: configuration.generation,
    156           previousBlossomConfigFingerprint: configuration
    157             .previousBlossomConfigFingerprint
    158         )
    159       )
    160     }
    161     return try await startRuntime(
    162       configuration: configuration,
    163       identity: identity,
    164       generation: requestedGeneration.requireActive(),
    165       forceReconfiguration: configuration.activationState
    166         == .reconfigurationRequired,
    167       adoptBootstrapSettings: configuration.activationState
    168         == .reconfigurationRequired
    169     )
    170   }
    171 
    172   func createIdentity(label: String? = nil) async -> TeraSessionPhase {
    173     await runIdentityOperation { try await self.identityStore.create(label: label) }
    174   }
    175 
    176   func importIdentity(
    177     _ material: RadrootsIdentitySecretMaterial,
    178     label: String? = nil
    179   ) async -> TeraSessionPhase {
    180     await runIdentityOperation { try await self.identityStore.importIdentity(material, label: label) }
    181   }
    182 
    183   func lockIdentity() async -> TeraSessionPhase {
    184     generation = generation.invalidated()
    185     let requestedGeneration = generation
    186     if case .running = phase,
    187       let settings = try? await runtimeClient.mobileSettings()
    188     {
    189       guard isCurrent(requestedGeneration) else { return phase }
    190       _ = try? await runtimeClient.applyIdentityCommand(
    191         expectedRevision: settings.revision,
    192         command: TeraIdentityCommand(
    193           kind: .lock,
    194           operationID: nil,
    195           identityID: nil,
    196           publicKeyHex: nil
    197         )
    198       )
    199     }
    200     guard isCurrent(requestedGeneration) else { return phase }
    201     _ = try? await runtimeClient.stop()
    202     guard isCurrent(requestedGeneration) else { return phase }
    203     await identityStore.lock()
    204     guard isCurrent(requestedGeneration) else { return phase }
    205     let identity = await identityStore.snapshot()
    206     guard isCurrent(requestedGeneration) else { return phase }
    207     phase = .identityLocked(identity)
    208     return phase
    209   }
    210 
    211   func unlockIdentity() async -> TeraSessionPhase {
    212     await runIdentityOperation { try await self.identityStore.unlock() }
    213   }
    214 
    215   func recoverIdentity() async -> TeraSessionPhase {
    216     await runIdentityOperation { try await self.identityStore.recover() }
    217   }
    218 
    219   private func runIdentityOperation(
    220     _ operation: () async throws -> TeraAppIdentity
    221   ) async -> TeraSessionPhase {
    222     guard !removalInProgress else { return phase }
    223     generation = generation.invalidated()
    224     let requestedGeneration = generation
    225     do {
    226       try ensureCurrent(requestedGeneration)
    227       _ = try await operation()
    228       try ensureCurrent(requestedGeneration)
    229       return await start()
    230     } catch {
    231       guard generation == requestedGeneration, !Task.isCancelled else { return phase }
    232       return failIdentityOperation(error)
    233     }
    234   }
    235 
    236   func isCurrent(_ requested: TeraSessionGeneration) -> Bool {
    237     generation == requested && generation.isActive && !Task.isCancelled
    238   }
    239 
    240   func ensureCurrent(_ requested: TeraSessionGeneration) throws {
    241     guard isCurrent(requested) else { throw TeraRuntimeClientError.superseded }
    242   }
    243 
    244   private func failIdentityOperation(_ error: Error) -> TeraSessionPhase {
    245     generation = generation.invalidated()
    246     phase = .failed(
    247       .local(
    248         operation: "identity.operation",
    249         code: "ios.identity.operation_failed",
    250         safeMessage: TeraUserMessages.text(for: error, fallback: .identityOperationFailed)
    251       )
    252     )
    253     return phase
    254   }
    255 }