field_ios

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

commit 61a74b5814e2c80169b1ad0608512cc8ee9045f9
parent 4c9510c419ed1f3b23f404589abce08d763daa38
Author: triesap <tyson@radroots.org>
Date:   Sat,  8 Aug 2026 00:34:52 +0000

refactor(ios): centralize state and identity migration

- Replace the field app state object with bounded configuration, identity, and session actors.
- Bind the generated host signer to Apple custody without leaking generated models.
- Match Rust relay and Blossom profile validation and preserve corrupt-state classification.
- Prove idempotent migration, stable identity, lifecycle, and Debug and Release builds.

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 25+++++++++++++++++++++++++
MRadroots/App/AppEntry.swift | 10+++++++---
DRadroots/App/AppState.swift | 1020-------------------------------------------------------------------------------
MRadroots/App/RadrootsAppModel.swift | 101+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
MRadroots/Config/Base.xcconfig | 1+
MRadroots/Config/Debug.xcconfig | 1+
MRadroots/Info.plist | 4+++-
DRadroots/Runtime/BuildConfig.swift | 143-------------------------------------------------------------------------------
DRadroots/Runtime/FieldIdentityPublicMetadataStore.swift | 50--------------------------------------------------
DRadroots/Runtime/FieldLocalState.swift | 56--------------------------------------------------------
DRadroots/Runtime/FieldSecureIdentityStore.swift | 273-------------------------------------------------------------------------------
MRadroots/Runtime/RadrootsGeneratedRuntimeBackend.swift | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
MRadroots/Runtime/RadrootsRuntimeModels.swift | 57++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
DRadroots/Runtime/RelaySettings.swift | 138-------------------------------------------------------------------------------
ARadroots/State/RadrootsConfigurationStore.swift | 447+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ARadroots/State/RadrootsIdentityStore.swift | 335+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ARadroots/State/RadrootsSessionStore.swift | 277+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/Views/RuntimeStatusView.swift | 35+++++++++++++++++++++++++++++++++--
MRadrootsTests/RadrootsRuntimeClientTests.swift | 14+++++++++++++-
ARadrootsTests/RadrootsStateMigrationTests.swift | 286+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 4++++
21 files changed, 1639 insertions(+), 1736 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -8,7 +8,9 @@ /* Begin PBXBuildFile section */ 27F796571E92A6589E0111E3 /* RadrootsKitBindings.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */; }; + 41B5CB28ACA51221A1BA59D5 /* RadrootsSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76F84D4706216F743E373F2A /* RadrootsSessionStore.swift */; }; 47D9564F81A5E7CEA86B759C /* RadrootsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C4A7EB045E43F109C4DCA1 /* RadrootsProvider.swift */; }; + 51FE0A95B71A5D4EDCEB0748 /* RadrootsStateMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C26D512B6CBD1A9BE4780682 /* RadrootsStateMigrationTests.swift */; }; 5DAE76BBC71E4A192E029785 /* RadrootsGeneratedRuntimeBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B14CE9958D4C71AFB41D2DD /* RadrootsGeneratedRuntimeBackend.swift */; }; 69326B7DA05C115D0055AD13 /* RadrootsKitBindings.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 74082509C257766A166662FA /* RadrootsAppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343F115F383EB9C8A796AC9C /* RadrootsAppModel.swift */; }; @@ -19,9 +21,11 @@ CA827A410369AC55B0FEE305 /* RuntimeStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9469790A906080D90F8735CB /* RuntimeStatusView.swift */; }; CAD7B38817DC65AAF19576F7 /* RadrootsFFI.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BD7B47A576C4D5CE9318D3E6 /* RadrootsFFI.xcframework */; }; CEED5B97CB1F94445162D662 /* RadrootsRuntimeClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0451E8A8521D320172FFA35 /* RadrootsRuntimeClientTests.swift */; }; + D03C81CCF4FBC6D7D5634D35 /* RadrootsConfigurationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF9FBA655BDA52E08FB62EED /* RadrootsConfigurationStore.swift */; }; E236CCF8D8F8AC12AF59932E /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 397ED29DF8E9BF73B4BBFDEC /* App.swift */; }; ED326EC0067A550727A1680D /* AppEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0D9C4E6CAE584304521D08 /* AppEntry.swift */; }; F3E40E5A76B4EA19AC7603D2 /* RadrootsKit in Frameworks */ = {isa = PBXBuildFile; productRef = 2DAD90EBF8EB00ACDD7611CD /* RadrootsKit */; }; + F6F36A06BD77BCFA64FC3541 /* RadrootsIdentityStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0BC5AE5000DE34D9303379 /* RadrootsIdentityStore.swift */; }; F7EEF54776B037D98F389AD6 /* RadrootsRuntimeClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F5216F7C18E2EE21B8528A /* RadrootsRuntimeClient.swift */; }; /* End PBXBuildFile section */ @@ -58,6 +62,7 @@ /* Begin PBXFileReference section */ 298DA81B0A000E307098C840 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; + 2D0BC5AE5000DE34D9303379 /* RadrootsIdentityStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsIdentityStore.swift; sourceTree = "<group>"; }; 30A8A8321A8A261F6D1B480D /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; }; 318804462AADCA05FB04ACD9 /* RadrootsTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = RadrootsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 343F115F383EB9C8A796AC9C /* RadrootsAppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsAppModel.swift; sourceTree = "<group>"; }; @@ -69,11 +74,14 @@ 57F5216F7C18E2EE21B8528A /* RadrootsRuntimeClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRuntimeClient.swift; sourceTree = "<group>"; }; 5B14CE9958D4C71AFB41D2DD /* RadrootsGeneratedRuntimeBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsGeneratedRuntimeBackend.swift; sourceTree = "<group>"; }; 67A31CC210B73EC072BD3542 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; }; + 76F84D4706216F743E373F2A /* RadrootsSessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsSessionStore.swift; sourceTree = "<group>"; }; 879C89400666972D90996AE5 /* RadrootsKitBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsKitBindings.swift; sourceTree = "<group>"; }; 93AA285819DD1269C3EAD80A /* Radroots.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Radroots.app; sourceTree = BUILT_PRODUCTS_DIR; }; 9469790A906080D90F8735CB /* RuntimeStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeStatusView.swift; sourceTree = "<group>"; }; BD7B47A576C4D5CE9318D3E6 /* RadrootsFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = RadrootsFFI.xcframework; path = Radroots/Frameworks/RadrootsFFI.xcframework; sourceTree = "<group>"; }; + C26D512B6CBD1A9BE4780682 /* RadrootsStateMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsStateMigrationTests.swift; sourceTree = "<group>"; }; D0451E8A8521D320172FFA35 /* RadrootsRuntimeClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRuntimeClientTests.swift; sourceTree = "<group>"; }; + DF9FBA655BDA52E08FB62EED /* RadrootsConfigurationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsConfigurationStore.swift; sourceTree = "<group>"; }; FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = RadrootsKitBindings.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -102,6 +110,7 @@ isa = PBXGroup; children = ( D0451E8A8521D320172FFA35 /* RadrootsRuntimeClientTests.swift */, + C26D512B6CBD1A9BE4780682 /* RadrootsStateMigrationTests.swift */, ); path = RadrootsTests; sourceTree = "<group>"; @@ -155,6 +164,17 @@ name = Frameworks; sourceTree = "<group>"; }; + A2DE5BE1D645F19DA46B3FC5 /* State */ = { + isa = PBXGroup; + children = ( + DF9FBA655BDA52E08FB62EED /* RadrootsConfigurationStore.swift */, + 2D0BC5AE5000DE34D9303379 /* RadrootsIdentityStore.swift */, + 76F84D4706216F743E373F2A /* RadrootsSessionStore.swift */, + ); + name = State; + path = Radroots/State; + sourceTree = "<group>"; + }; C22A3ECCB9E9AF9E57B74576 /* App */ = { isa = PBXGroup; children = ( @@ -176,6 +196,7 @@ 31C88176C5674CFF5F9CFBEA /* RadrootsTests */, 94F94915631E6DC54AAB0B89 /* Resources */, 64C889EFCEB42611797110F7 /* Runtime */, + A2DE5BE1D645F19DA46B3FC5 /* State */, E05061D0ADB560FA929B4D9A /* Views */, 97FA23F0FD7E25C1AF2585FB /* Frameworks */, 6240123423927396E47D6B3E /* Products */, @@ -353,6 +374,7 @@ buildActionMask = 2147483647; files = ( CEED5B97CB1F94445162D662 /* RadrootsRuntimeClientTests.swift in Sources */, + 51FE0A95B71A5D4EDCEB0748 /* RadrootsStateMigrationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -363,10 +385,13 @@ E236CCF8D8F8AC12AF59932E /* App.swift in Sources */, ED326EC0067A550727A1680D /* AppEntry.swift in Sources */, 74082509C257766A166662FA /* RadrootsAppModel.swift in Sources */, + D03C81CCF4FBC6D7D5634D35 /* RadrootsConfigurationStore.swift in Sources */, 5DAE76BBC71E4A192E029785 /* RadrootsGeneratedRuntimeBackend.swift in Sources */, + F6F36A06BD77BCFA64FC3541 /* RadrootsIdentityStore.swift in Sources */, 47D9564F81A5E7CEA86B759C /* RadrootsProvider.swift in Sources */, F7EEF54776B037D98F389AD6 /* RadrootsRuntimeClient.swift in Sources */, B356719A54375706DBBDC118 /* RadrootsRuntimeModels.swift in Sources */, + 41B5CB28ACA51221A1BA59D5 /* RadrootsSessionStore.swift in Sources */, CA827A410369AC55B0FEE305 /* RuntimeStatusView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Radroots/App/AppEntry.swift b/Radroots/App/AppEntry.swift @@ -4,9 +4,13 @@ struct AppEntry: View { @EnvironmentObject private var appModel: RadrootsAppModel var body: some View { - RuntimeStatusView(phase: appModel.phase) { - Task { await appModel.retry() } - } + RuntimeStatusView( + phase: appModel.phase, + retry: { Task { await appModel.retry() } }, + createIdentity: { Task { await appModel.createIdentity() } }, + unlockIdentity: { Task { await appModel.unlockIdentity() } }, + recoverIdentity: { Task { await appModel.recoverIdentity() } } + ) .accessibilityIdentifier("radroots.app_entry") } } diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -1,1020 +0,0 @@ -import Foundation -import RadrootsKit - -enum FieldAppRuntimeError: LocalizedError { - case runtimeNotReady - case forcedStartupFailure - - var errorDescription: String? { - switch self { - case .runtimeNotReady: - "Runtime not ready. Please retry." - case .forcedStartupFailure: - "Startup failure requested by field iOS runtime mode." - } - } -} - -@MainActor -public final class AppState: ObservableObject { - public enum BootstrapPhase: Equatable { - case idle - case starting - case ready - case failed(String) - } - - public enum RelayLight { - case red, yellow, green - } - - @Published public private(set) var bootstrapPhase: BootstrapPhase = .idle - @Published public private(set) var infoJSONString: String = "" - @Published public private(set) var hasKey: Bool = false - @Published public private(set) var storedIdentityAvailable: Bool = false - @Published public private(set) var runtimeIdentityReady: Bool = false - @Published public private(set) var isLocked: Bool = false - @Published public private(set) var npub: String? - @Published public private(set) var identityLabel: String? - @Published public private(set) var identities: [NostrIdentityRecord] = [] - @Published public private(set) var relayConfigured: Bool = false - @Published public private(set) var relaySourceAvailable: Bool = false - @Published public private(set) var relaySinkAvailable: Bool = false - @Published public private(set) var relayLight: RelayLight = .red - @Published public private(set) var relayLastError: String? - @Published public private(set) var configuredRelayURLs: [String] = [] - @Published public private(set) var relaySettingsSourceLabel: String = RelaySettingsSource.buildConfig.displayName - @Published public private(set) var fileAccessProbeValue: String? - @Published public private(set) var documentInterchangeProbeValue: String? - @Published public private(set) var identityPolicyProbeValue: String? - @Published public private(set) var identityImportFailureProbeValue: String? - @Published public private(set) var telemetryProbeValue: String? - @Published public private(set) var backgroundExecutionProbeValue: String? - @Published public private(set) var externalActionStatus: String? - @Published public private(set) var userPresenceStatus: String? - @Published public private(set) var canOpenNostrProfile: Bool = false - @Published public private(set) var locationCheckInState: FieldLocationCheckInState = .idle( - RadrootsLocationServicesAvailability(locationServicesEnabled: false, authorization: .unavailable) - ) - @Published public private(set) var captureIntakeState: FieldCaptureIntakeState = .idle - - public var canShowAppContent: Bool { - bootstrapPhase == .ready && runtimeIdentityReady && !isLocked - } - - public var requiresSetup: Bool { - bootstrapPhase == .ready && (!storedIdentityAvailable || isLocked || !runtimeIdentityReady) - } - - public var identityDisplayName: String { - if let label = identityLabel?.trimmingCharacters(in: .whitespacesAndNewlines), - !label.isEmpty { - return label - } - if let npub { - return shortNpub(npub) - } - return "Local Nostr identity" - } - - public let radroots: Radroots - private let telemetry: FieldTelemetry - - public var runtimeService: FieldRuntimeService? { - radroots.runtimeService - } - - private let lockKey = "field_ios.identity_locked" - private var statusTask: Task<Void, Never>? - private var telemetryProbeTask: Task<Void, Never>? - private var secureIdentityStore: FieldSecureIdentityStore? - private var identityMetadataStore: FieldIdentityPublicMetadataStore? - private var captureIntake: FieldCaptureIntake? - private var backgroundExecution: FieldBackgroundExecution? - private let locationCheckIn = FieldLocationCheckIn.configured() - private let externalActions = FieldExternalActions.configured() - private let userPresenceGate = FieldUserPresenceGate.configured() - private var lastTelemetryRelayStatus: FieldTelemetryRelayStatus? - - init(radroots: Radroots = Radroots(), telemetry: FieldTelemetry = .shared) { - self.radroots = radroots - self.telemetry = telemetry - self.isLocked = UserDefaults.standard.bool(forKey: lockKey) - } - - deinit { - statusTask?.cancel() - telemetryProbeTask?.cancel() - } - - public func start() async throws { - guard bootstrapPhase == .idle || isFailed else { return } - telemetry.appStartupBegan() - bootstrapPhase = .starting - do { - try await holdBootstrapSplashForUITestIfRequested() - if startupFailureWasRequested { - throw FieldAppRuntimeError.forcedStartupFailure - } - let service = try radroots.start(telemetry: telemetry) - let secureStore = try FieldSecureIdentityStore.configured() - let metadataStore = try FieldIdentityPublicMetadataStore.configured() - #if DEBUG - identityPolicyProbeValue = try FieldIdentityPolicyUITestProbe.value() - #endif - let appBundleIdentifier = try bundleIdentifier() - let resetLocalStateRequested = BuildConfig.bool(.resetLocalState) == true - let backgroundExecution = try FieldBackgroundExecution.configured( - bundleIdentifier: appBundleIdentifier, - telemetry: telemetry - ) - self.backgroundExecution = backgroundExecution - await FieldBackgroundURLSessionEvents.shared.attach(backgroundExecution) - try FieldFileAccessUITestProbe.seedDestructiveResetSentinelIfRequested( - bundleIdentifier: appBundleIdentifier, - resetLocalStateRequested: resetLocalStateRequested - ) - secureIdentityStore = secureStore - identityMetadataStore = metadataStore - if resetLocalStateRequested { - await backgroundExecution.cancelAll() - try FieldLocalState.resetFileRoots(bundleIdentifier: appBundleIdentifier) - try RelaySettings.clearUserImportedRelays(bundleIdentifier: appBundleIdentifier) - try secureStore.deleteSelectedSecret() - metadataStore.delete() - try await resetRuntimeIdentityState(using: service) - applyNoIdentity() - setLocked(false) - } else { - loadStoredIdentityMetadata(metadataStore) - } - try refreshRelaySettingsSnapshot(bundleIdentifier: appBundleIdentifier) - let captureIntake = try FieldCaptureIntake.configured(bundleIdentifier: appBundleIdentifier) - self.captureIntake = captureIntake - try await backgroundExecution.start() - await refreshBackgroundExecutionProbe(using: backgroundExecution) - await refreshRuntimeState(using: service) - #if DEBUG - identityImportFailureProbeValue = await FieldIdentityImportFailureUITestProbe.value( - secureStore: secureStore, - service: service - ) - #endif - if runtimeIdentityReady && !isLocked { - startConnectingAndPollingStatus(using: service) - } - await refreshNostrProfileExternalActionCapability() - try refreshFileAccessProbe( - bundleIdentifier: appBundleIdentifier, - resetLocalStateRequested: resetLocalStateRequested, - identityResetObserved: false - ) - try await refreshDocumentInterchangeProbe(bundleIdentifier: appBundleIdentifier) - await refreshLocationCheckInStatus() - await refreshCaptureIntakeState(using: captureIntake) - bootstrapPhase = .ready - telemetry.appStartupSucceeded( - storedIdentityAvailable: storedIdentityAvailable, - runtimeIdentityReady: runtimeIdentityReady, - locked: isLocked - ) - startTelemetryProbeRefreshForUITest() - } catch { - statusTask?.cancel() - statusTask = nil - telemetryProbeTask?.cancel() - telemetryProbeTask = nil - await FieldBackgroundURLSessionEvents.shared.completePendingAfterStartupFailure() - backgroundExecution = nil - let message = error.fieldRuntimeMessage - bootstrapPhase = .failed(message) - telemetry.appStartupFailed(error) - startTelemetryProbeRefreshForUITest() - throw error - } - } - - public func retryStartup() { - bootstrapPhase = .idle - Task { - try? await start() - } - } - - public func refresh() { - Task { - await refreshRuntimeState() - } - } - - public func appDidBecomeActive() { - Task { - try? await backgroundExecution?.schedulePermittedTasks(reason: "active") - } - } - - public func appDidEnterBackground() { - Task { - _ = try? await backgroundExecution?.schedulePermittedTasks(reason: "background") - await backgroundExecution?.performMaintenance(reason: "background") - } - } - - public func shutdown() async { - statusTask?.cancel() - statusTask = nil - telemetryProbeTask?.cancel() - telemetryProbeTask = nil - await backgroundExecution?.cancelAll() - if let service = runtimeService { - try? await service.nostrIdentityLockHostCustodyRuntime() - } - backgroundExecution = nil - await radroots.shutdown() - } - - public func continueWithLocalIdentity() async throws { - let service = try requireRuntimeService() - do { - try await requireUserPresence(for: .unlockIdentity) - try await restoreStoredIdentity(using: service) - setLocked(false) - await refreshRuntimeState(using: service) - await refreshNostrProfileExternalActionCapability() - startConnectingAndPollingStatus(using: service) - telemetry.identityCustody(action: "unlock", outcome: "success") - } catch { - telemetry.identityCustody(action: "unlock", outcome: FieldTelemetry.userPresenceOutcome(for: error)) - throw error - } - } - - public func createLocalIdentity() async throws { - let service = try requireRuntimeService() - do { - try await requireUserPresence(for: .saveIdentity) - try await createHostCustodyIdentity(using: service) - setLocked(false) - await refreshRuntimeState(using: service) - await refreshNostrProfileExternalActionCapability() - startConnectingAndPollingStatus(using: service) - telemetry.identityCustody(action: "create", outcome: "success") - } catch { - telemetry.identityCustody(action: "create", outcome: FieldTelemetry.userPresenceOutcome(for: error)) - throw error - } - } - - public func importNostrSecret(_ secretKey: String) async throws { - let trimmed = secretKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - let service = try requireRuntimeService() - do { - try await requireUserPresence(for: .saveIdentity) - let record = try await secureIdentityStoreOrConfigured().importSecret( - trimmed, - label: "Imported Field Identity", - using: service - ) - try persistIdentity(record) - setLocked(false) - await refreshRuntimeState(using: service) - await refreshNostrProfileExternalActionCapability() - startConnectingAndPollingStatus(using: service) - telemetry.identityCustody(action: "import", outcome: "success") - } catch { - telemetry.identityCustody(action: "import", outcome: FieldTelemetry.userPresenceOutcome(for: error)) - throw error - } - } - - public func signOut() { - telemetry.identityCustody(action: "lock", outcome: "success") - setLocked(true) - statusTask?.cancel() - statusTask = nil - relayConfigured = false - relaySourceAvailable = false - relaySinkAvailable = false - relayLight = .red - Task { - await lockRuntimeIdentity() - } - } - - public func resetLocalIdentity() async throws { - let service = try requireRuntimeService() - do { - try await requireUserPresence(for: .deleteIdentity) - await backgroundExecution?.updateRuntimeState(service: service, identityUnlocked: false) - await backgroundExecution?.cancelAll() - try secureIdentityStoreOrConfigured().deleteSelectedSecret() - try identityMetadataStoreOrConfigured().delete() - try await resetRuntimeIdentityState(using: service) - applyNoIdentity() - setLocked(false) - relayConfigured = false - relaySourceAvailable = false - relaySinkAvailable = false - relayLight = .red - relayLastError = nil - canOpenNostrProfile = false - externalActionStatus = nil - await refreshRuntimeState(using: service) - try refreshFileAccessProbe( - bundleIdentifier: try bundleIdentifier(), - resetLocalStateRequested: false, - identityResetObserved: true - ) - statusTask?.cancel() - statusTask = nil - telemetry.identityCustody(action: "delete", outcome: "success") - } catch { - telemetry.identityCustody(action: "delete", outcome: FieldTelemetry.userPresenceOutcome(for: error)) - throw error - } - } - - public func requireRuntimeService() throws -> FieldRuntimeService { - guard let service = runtimeService else { - throw FieldAppRuntimeError.runtimeNotReady - } - return service - } - - public func refreshLocationCheckInStatus() async { - switch locationCheckInState { - case .idle: - break - case .checking, .checkedIn, .failed: - return - } - let refreshedState = await locationCheckIn.status() - switch locationCheckInState { - case .idle: - locationCheckInState = refreshedState - case .checking, .checkedIn, .failed: - return - } - } - - public func performLocationCheckIn() async { - let currentState = await locationCheckIn.status() - if let availability = currentState.availability { - locationCheckInState = .checking(availability) - } - locationCheckInState = await locationCheckIn.checkIn() - } - - public func refreshCaptureIntakeState() async { - guard let captureIntake else { - captureIntakeState.lastError = FieldCaptureIntakeError.serviceNotReady.localizedDescription - captureIntakeState.recoveryAction = nil - return - } - await refreshCaptureIntakeState(using: captureIntake) - } - - public func importPhotoEvidence() async { - await performCaptureIntakeOperation(.importingPhoto) { captureIntake, records in - try await captureIntake.importPhoto(records: records) - } - } - - public func capturePhotoEvidence() async { - await performCaptureIntakeOperation(.capturingPhoto) { captureIntake, records in - try await captureIntake.capturePhoto(records: records) - } - } - - public func scanDocumentEvidence() async { - await performCaptureIntakeOperation(.scanningDocument) { captureIntake, records in - try await captureIntake.scanDocument(records: records) - } - } - - public func refreshNostrProfileExternalActionCapability() async { - guard let npub else { - canOpenNostrProfile = false - return - } - canOpenNostrProfile = await externalActions.canOpenPublicNostrProfile(npub: npub) - } - - public func openAppSettingsRecovery() async { - await requestExternalAction { - try await externalActions.openAppSettings() - } - } - - public func openCurrentNostrProfile() async { - guard let npub else { - externalActionStatus = "No public Nostr identity is selected." - canOpenNostrProfile = false - return - } - await requestExternalAction { - try await externalActions.openPublicNostrProfile(npub: npub) - } - } - - func prepareDiagnosticsDocumentExport() throws -> RadrootsPreparedExportDocument { - do { - let relays = try effectiveRelaySettings().relays - let document = try documentInterchange().prepareDiagnosticsExport( - infoJSONString: infoJSONString, - relays: relays, - configured: relayConfigured, - sourceAvailable: relaySourceAvailable, - sinkAvailable: relaySinkAvailable, - lastError: relayLastError - ) - telemetry.documentInterchange(operation: "diagnostics_export", outcome: "success", relayCount: relays.count) - return document - } catch { - telemetry.documentInterchange( - operation: "diagnostics_export", - outcome: FieldTelemetry.documentInterchangeOutcome(for: error) - ) - throw error - } - } - - func prepareRelayConfigDocumentExport() throws -> RadrootsPreparedExportDocument { - do { - let relays = try effectiveRelaySettings().relays - let document = try documentInterchange().prepareRelayConfigExport(relays: relays) - telemetry.documentInterchange(operation: "relay_config_export", outcome: "success", relayCount: relays.count) - return document - } catch { - telemetry.documentInterchange( - operation: "relay_config_export", - outcome: FieldTelemetry.documentInterchangeOutcome(for: error) - ) - throw error - } - } - - func importedRelayConfig(from importedDocument: RadrootsImportedDocument) throws -> [String] { - do { - let relays = try documentInterchange().importedRelayConfig(from: importedDocument) - telemetry.documentInterchange(operation: "relay_config_import", outcome: "success", relayCount: relays.count) - return relays - } catch { - telemetry.documentInterchange( - operation: "relay_config_import", - outcome: FieldTelemetry.documentInterchangeOutcome(for: error) - ) - throw error - } - } - - func applyImportedRelayConfig(from importedDocument: RadrootsImportedDocument) async throws -> [String] { - do { - let relays = try documentInterchange().importedRelayConfig(from: importedDocument) - let snapshot = try RelaySettings.storeUserImportedRelays( - relays, - bundleIdentifier: bundleIdentifier() - ) - apply(relaySettings: snapshot) - if let service = runtimeService, runtimeIdentityReady && !isLocked { - relayConfigured = !snapshot.relays.isEmpty - relaySourceAvailable = false - relaySinkAvailable = false - relayLight = .yellow - relayLastError = nil - try await service.nostrSetDefaultRelays(snapshot.relays) - try await service.nostrConnectIfKeyPresent() - await refreshRelayStatus(using: service) - await backgroundExecution?.updateRuntimeState( - service: service, - identityUnlocked: true - ) - } - telemetry.documentInterchange(operation: "relay_config_import", outcome: "success", relayCount: relays.count) - return snapshot.relays - } catch { - telemetry.documentInterchange( - operation: "relay_config_import", - outcome: FieldTelemetry.documentInterchangeOutcome(for: error) - ) - throw error - } - } - - func publicPostShareRequest(content: String) throws -> RadrootsShareRequest { - do { - let request = try documentInterchange().publicPostShareRequest(content: content) - telemetry.documentInterchange(operation: "public_share_prepare", outcome: "success") - return request - } catch { - telemetry.documentInterchange( - operation: "public_share_prepare", - outcome: FieldTelemetry.documentInterchangeOutcome(for: error) - ) - throw error - } - } - - func documentFileAccess() throws -> RadrootsAppleFileAccess { - try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier()) - } - - func releasePreparedDocumentExport(_ preparedExport: RadrootsPreparedExportDocument) { - try? documentFileAccess().releasePreparedExport(preparedExport) - } - - private func documentInterchange() throws -> FieldDocumentInterchange { - try FieldDocumentInterchange(bundleIdentifier: bundleIdentifier()) - } - - private func refreshRelaySettingsSnapshot(bundleIdentifier: String) throws { - apply(relaySettings: try RelaySettings.effectiveSnapshot(bundleIdentifier: bundleIdentifier)) - } - - private func effectiveRelaySettings() throws -> RelaySettingsSnapshot { - let snapshot = try RelaySettings.effectiveSnapshot(bundleIdentifier: bundleIdentifier()) - apply(relaySettings: snapshot) - return snapshot - } - - private func apply(relaySettings snapshot: RelaySettingsSnapshot) { - configuredRelayURLs = snapshot.relays - relaySettingsSourceLabel = snapshot.source.displayName - } - - private func refreshCaptureIntakeState(using captureIntake: FieldCaptureIntake) async { - captureIntakeState.operation = .refreshing - captureIntakeState.lastError = nil - captureIntakeState.recoveryAction = nil - do { - captureIntakeState.records = try captureIntake.loadRecords() - captureIntakeState.support = try await captureIntake.support() - captureIntakeState.operation = .idle - telemetry.captureSupportRefreshed( - support: captureIntakeState.support, - recordCount: captureIntakeState.records.count, - outcome: "success" - ) - } catch { - captureIntakeState.support = .unavailable - captureIntakeState.operation = .idle - captureIntakeState.lastError = error.fieldRuntimeMessage - captureIntakeState.recoveryAction = nil - telemetry.captureSupportRefreshed( - support: captureIntakeState.support, - recordCount: captureIntakeState.records.count, - outcome: FieldTelemetry.captureOutcome(for: error) - ) - } - } - - private func performCaptureIntakeOperation( - _ operation: FieldCaptureIntakeOperation, - action: (FieldCaptureIntake, [FieldCaptureRecord]) async throws -> [FieldCaptureRecord] - ) async { - guard let captureIntake else { - captureIntakeState.lastError = FieldCaptureIntakeError.serviceNotReady.localizedDescription - return - } - captureIntakeState.operation = operation - captureIntakeState.lastError = nil - captureIntakeState.recoveryAction = nil - do { - let updatedRecords = try await action(captureIntake, captureIntakeState.records) - captureIntakeState.records = updatedRecords - captureIntakeState.support = try await captureIntake.support() - captureIntakeState.operation = .idle - captureIntakeState.recoveryAction = nil - telemetry.captureOperation( - operation: operation, - outcome: "success", - recordCount: captureIntakeState.records.count, - recoveryAction: nil - ) - } catch { - captureIntakeState.operation = .idle - captureIntakeState.lastError = error.fieldRuntimeMessage - captureIntakeState.recoveryAction = captureRecoveryAction(for: error) - telemetry.captureOperation( - operation: operation, - outcome: FieldTelemetry.captureOutcome(for: error), - recordCount: captureIntakeState.records.count, - recoveryAction: captureIntakeState.recoveryAction - ) - } - } - - private func captureRecoveryAction(for error: Error) -> FieldExternalActionRecovery? { - guard let captureError = error as? RadrootsCaptureIntakeError else { - return nil - } - switch captureError { - case .permissionDenied: - return .appSettings - case .invalidRequest, .unavailable, .userCancelled, .transientFailure, .permanentFailure: - return nil - } - } - - private var isFailed: Bool { - if case .failed = bootstrapPhase { - return true - } - return false - } - - private var uiTestWasRequested: Bool { - #if DEBUG - return FieldUITestHarness.isRequested - #else - return false - #endif - } - - private var uiTestBootstrapSplashHoldNanoseconds: UInt64? { - #if DEBUG - guard uiTestWasRequested else { return nil } - guard let raw = FieldUITestHarness.string("RADROOTS_FIELD_IOS_UI_TEST_BOOTSTRAP_SPLASH_HOLD_SECONDS"), - let seconds = Double(raw), - seconds.isFinite, - seconds > 0 else { - return nil - } - return UInt64(seconds * 1_000_000_000) - #else - return nil - #endif - } - - private func holdBootstrapSplashForUITestIfRequested() async throws { - guard let nanoseconds = uiTestBootstrapSplashHoldNanoseconds else { return } - try await Task.sleep(nanoseconds: nanoseconds) - } - - private var startupFailureWasRequested: Bool { - #if DEBUG - guard uiTestWasRequested else { - return false - } - let arguments = ProcessInfo.processInfo.arguments - if BuildConfig.string(.runtimeMode) == "ui-test-startup-failure" { - return true - } - if FieldUITestHarness.bool("RADROOTS_FIELD_IOS_FORCE_STARTUP_FAILURE", default: false) { - return true - } - return arguments.contains("--radroots-field-ios-force-startup-failure") - #else - return false - #endif - } - - private func configureRelays(using service: FieldRuntimeService) async throws { - try await service.nostrSetDefaultRelays(try effectiveRelaySettings().relays) - } - - private func connect(using service: FieldRuntimeService) async throws { - try await configureRelays(using: service) - try await service.nostrConnectIfKeyPresent() - await refreshRelayStatus(using: service) - relayLastError = nil - } - - private func refreshRuntimeState() async { - guard let service = runtimeService else { return } - await refreshRuntimeState(using: service) - } - - private func refreshRuntimeState(using service: FieldRuntimeService) async { - infoJSONString = await service.infoJson() - do { - let snapshot = try await service.nostrIdentitySnapshot() - apply(identity: snapshot) - } catch { - relayLastError = error.fieldRuntimeMessage - } - await refreshRelayStatus(using: service) - await backgroundExecution?.updateRuntimeState( - service: service, - identityUnlocked: runtimeIdentityReady && !isLocked - ) - } - - private func refreshRelayStatus(using service: FieldRuntimeService) async { - do { - let status = try await service.nostrConnectionStatus() - relayConfigured = status.configured - relaySourceAvailable = status.sourceAvailable - relaySinkAvailable = status.sinkAvailable - relayLastError = status.lastError ?? relayLastError - switch status.light { - case .green: - relayLight = .green - case .yellow: - relayLight = .yellow - case .red: - relayLight = .red - } - } catch { - relaySourceAvailable = false - relaySinkAvailable = false - relayLight = .red - relayLastError = error.fieldRuntimeMessage - } - let telemetryStatus = FieldTelemetryRelayStatus( - configured: relayConfigured, - sourceAvailable: relaySourceAvailable, - sinkAvailable: relaySinkAvailable, - configuredRelayCount: configuredRelayURLs.count, - light: relayLight.telemetryValue - ) - if telemetryStatus != lastTelemetryRelayStatus { - lastTelemetryRelayStatus = telemetryStatus - telemetry.relayStatusChanged( - configured: telemetryStatus.configured, - sourceAvailable: telemetryStatus.sourceAvailable, - sinkAvailable: telemetryStatus.sinkAvailable, - configuredRelayCount: telemetryStatus.configuredRelayCount, - light: telemetryStatus.light - ) - } - } - - private func apply(identity snapshot: NostrIdentitySnapshot) { - runtimeIdentityReady = snapshot.hasSelectedSigningIdentity - identities = snapshot.identities - if snapshot.hasSelectedSigningIdentity { - storedIdentityAvailable = true - hasKey = true - npub = snapshot.selectedNpub - identityLabel = snapshot.identities.first(where: { $0.isSelected })?.label - } else if storedIdentityAvailable { - hasKey = true - } else { - hasKey = false - npub = nil - identityLabel = nil - canOpenNostrProfile = false - } - } - - private func lockRuntimeIdentityState(using service: FieldRuntimeService) async throws { - try await service.nostrIdentityLockHostCustodyRuntime() - runtimeIdentityReady = false - identities = [] - } - - private func resetRuntimeIdentityState(using service: FieldRuntimeService) async throws { - try await service.nostrIdentityResetHostCustodyRuntime() - runtimeIdentityReady = false - identities = [] - } - - private func loadStoredIdentityMetadata(_ metadataStore: FieldIdentityPublicMetadataStore) { - guard let metadata = metadataStore.load() else { - applyNoIdentity() - setLocked(false) - return - } - apply(storedIdentity: metadata) - setLocked(true) - } - - private func restoreStoredIdentity(using service: FieldRuntimeService) async throws { - let existingMetadata = try identityMetadataStoreOrConfigured().load() - let record = try await secureIdentityStoreOrConfigured().restoreStoredIdentity( - label: existingMetadata?.label ?? "Radroots Field", - using: service - ) - try persistIdentity(record) - } - - private func requireUserPresence(for action: FieldUserPresenceAction) async throws { - do { - let record = try await userPresenceGate.requirePresence(for: action) - userPresenceStatus = record.statusText - telemetry.userPresence(action: action, outcome: "success") - } catch { - userPresenceStatus = error.fieldRuntimeMessage - telemetry.userPresence(action: action, outcome: FieldTelemetry.userPresenceOutcome(for: error)) - throw error - } - } - - private func createHostCustodyIdentity(using service: FieldRuntimeService) async throws { - let record = try await secureIdentityStoreOrConfigured().createIdentity( - label: "Radroots Field", - using: service - ) - try persistIdentity(record) - } - - private func persistIdentity(_ record: NostrIdentityRecord) throws { - let metadata = FieldIdentityPublicMetadata(record: record) - try identityMetadataStoreOrConfigured().save(metadata) - apply(storedIdentity: metadata) - runtimeIdentityReady = true - hasKey = true - identities = [record] - } - - private func lockRuntimeIdentity() async { - guard let service = runtimeService else { - runtimeIdentityReady = false - identities = [] - hasKey = storedIdentityAvailable - return - } - do { - try await lockRuntimeIdentityState(using: service) - } catch { - relayLastError = error.fieldRuntimeMessage - } - hasKey = storedIdentityAvailable - await refreshRelayStatus(using: service) - await backgroundExecution?.updateRuntimeState(service: service, identityUnlocked: false) - } - - private func apply(storedIdentity metadata: FieldIdentityPublicMetadata) { - storedIdentityAvailable = true - hasKey = true - npub = metadata.publicKeyNpub - identityLabel = metadata.label - } - - private func applyNoIdentity() { - hasKey = false - storedIdentityAvailable = false - runtimeIdentityReady = false - npub = nil - identityLabel = nil - identities = [] - canOpenNostrProfile = false - } - - private func secureIdentityStoreOrConfigured() throws -> FieldSecureIdentityStore { - if let secureIdentityStore { - return secureIdentityStore - } - let configured = try FieldSecureIdentityStore.configured() - secureIdentityStore = configured - return configured - } - - private func identityMetadataStoreOrConfigured() throws -> FieldIdentityPublicMetadataStore { - if let identityMetadataStore { - return identityMetadataStore - } - let configured = try FieldIdentityPublicMetadataStore.configured() - identityMetadataStore = configured - return configured - } - - private func bundleIdentifier() throws -> String { - guard let bundleIdentifier = Bundle.main.bundleIdentifier, - !bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw FieldSecureIdentityStoreError.missingBundleIdentifier - } - return bundleIdentifier - } - - private func refreshFileAccessProbe( - bundleIdentifier: String, - resetLocalStateRequested: Bool, - identityResetObserved: Bool - ) throws { - let loggingSettings = LoggingSettings.load() - if identityResetObserved { - fileAccessProbeValue = try FieldFileAccessUITestProbe.identityResetValue( - bundleIdentifier: bundleIdentifier, - loggingFileEnabled: loggingSettings.fileEnabled, - loggingFileName: loggingSettings.fileName - ) - } else { - fileAccessProbeValue = try FieldFileAccessUITestProbe.startupValue( - bundleIdentifier: bundleIdentifier, - resetLocalStateRequested: resetLocalStateRequested, - loggingFileEnabled: loggingSettings.fileEnabled, - loggingFileName: loggingSettings.fileName - ) - } - } - - private func refreshDocumentInterchangeProbe(bundleIdentifier: String) async throws { - documentInterchangeProbeValue = try FieldDocumentInterchangeUITestProbe.startupValue( - bundleIdentifier: bundleIdentifier, - infoJSONString: infoJSONString, - relays: effectiveRelaySettings().relays, - configured: relayConfigured, - sourceAvailable: relaySourceAvailable, - sinkAvailable: relaySinkAvailable, - lastError: relayLastError - ) - guard FieldDocumentInterchangeUITestProbe.isRequested else { - return - } - let diagnosticsExport = try prepareDiagnosticsDocumentExport() - releasePreparedDocumentExport(diagnosticsExport) - let relayConfigExport = try prepareRelayConfigDocumentExport() - releasePreparedDocumentExport(relayConfigExport) - if let relayImportDocument = try FieldDocumentInterchangeUITestProbe.relayImportDocument( - bundleIdentifier: bundleIdentifier - ) { - let importedRelays = try await applyImportedRelayConfig(from: relayImportDocument) - documentInterchangeProbeValue = [ - documentInterchangeProbeValue, - "relay_import_applied=true", - "relay_settings_source=\(relaySettingsSourceLabel)", - "relay_settings_count=\(importedRelays.count)", - "relay_settings_contains_production=\(importedRelays.contains("wss://radroots.org"))" - ].compactMap { $0 }.joined(separator: ";") - } - _ = try publicPostShareRequest(content: " public field update ") - } - - private func requestExternalAction( - _ action: () async throws -> FieldExternalActionRequestRecord - ) async { - do { - let record = try await action() - externalActionStatus = record.statusText - telemetry.externalAction(operation: "open", kind: record.kind, outcome: "success") - } catch { - externalActionStatus = error.fieldRuntimeMessage - telemetry.externalAction( - operation: "open", - kind: nil, - outcome: FieldTelemetry.externalActionOutcome(for: error) - ) - } - } - - private func setLocked(_ value: Bool) { - isLocked = value - UserDefaults.standard.set(value, forKey: lockKey) - } - - private func startConnectingAndPollingStatus(using service: FieldRuntimeService) { - statusTask?.cancel() - statusTask = Task { [weak self] in - do { - try await self?.connect(using: service) - } catch { - self?.relayLastError = error.fieldRuntimeMessage - self?.relayLight = .red - } - while !Task.isCancelled { - await self?.refreshRuntimeState(using: service) - try? await Task.sleep(nanoseconds: 1_000_000_000) - } - } - } - - private func startTelemetryProbeRefreshForUITest() { - guard FieldTelemetryUITestProbe.isRequested else { - return - } - telemetryProbeTask?.cancel() - telemetryProbeTask = Task { [weak self] in - while !Task.isCancelled { - await self?.refreshTelemetryProbeValue() - try? await Task.sleep(nanoseconds: 250_000_000) - } - } - } - - private func refreshTelemetryProbeValue() async { - telemetryProbeValue = await FieldTelemetryUITestProbe.value(recordedBy: telemetry) - } - - private func refreshBackgroundExecutionProbe(using backgroundExecution: FieldBackgroundExecution) async { - backgroundExecutionProbeValue = await backgroundExecution.uiTestProbeValue() - } - - private func shortNpub(_ value: String) -> String { - guard value.count > 18 else { return value } - return "\(value.prefix(12))...\(value.suffix(6))" - } -} - -private struct FieldTelemetryRelayStatus: Equatable { - let configured: Bool - let sourceAvailable: Bool - let sinkAvailable: Bool - let configuredRelayCount: Int - let light: String -} - -private extension AppState.RelayLight { - var telemetryValue: String { - switch self { - case .red: - "red" - case .yellow: - "yellow" - case .green: - "green" - } - } -} diff --git a/Radroots/App/RadrootsAppModel.swift b/Radroots/App/RadrootsAppModel.swift @@ -2,66 +2,81 @@ import Foundation @MainActor final class RadrootsAppModel: ObservableObject { - enum Phase: Equatable { - case starting - case identityRequired - case running(RadrootsRuntimeSnapshot) - case failed(RadrootsRuntimeFailure) - case stopped - } + typealias Phase = RadrootsSessionPhase @Published private(set) var phase: Phase = .starting - private let runtimeClient: RadrootsRuntimeClient - private let configuration: RadrootsRuntimeLaunchConfiguration? + private let sessionStore: RadrootsSessionStore? + private let bootstrapFailure: RadrootsRuntimeFailure? + private var generation: UInt64 = 0 - init( - runtimeClient: RadrootsRuntimeClient = .production(), - configuration: RadrootsRuntimeLaunchConfiguration? = nil - ) { - self.runtimeClient = runtimeClient - self.configuration = configuration + init(sessionStore: RadrootsSessionStore? = nil) { + if let sessionStore { + self.sessionStore = sessionStore + bootstrapFailure = nil + } else { + do { + self.sessionStore = try .production() + bootstrapFailure = nil + } catch let error as LocalizedError { + self.sessionStore = nil + bootstrapFailure = .local( + operation: "app.bootstrap", + code: "ios.app.configuration_invalid", + safeMessage: error.errorDescription ?? "Radroots configuration is invalid." + ) + } catch { + self.sessionStore = nil + bootstrapFailure = .local( + operation: "app.bootstrap", + code: "ios.app.configuration_invalid", + safeMessage: "Radroots configuration is invalid." + ) + } + } } func start() async { - guard let configuration else { - phase = .identityRequired - return - } - phase = .starting - do { - phase = try await .running(runtimeClient.start(configuration: configuration)) - } catch let RadrootsRuntimeClientError.startup(failure) { - phase = .failed(failure) - } catch { - phase = .failed( - .local( - operation: "app.start", - code: "ios.app.start_failed", - safeMessage: "Radroots could not start." - ) - ) - } + await run { store in await store.start() } } func retry() async { await start() } + func createIdentity() async { + await run { store in await store.createIdentity() } + } + + func unlockIdentity() async { + await run { store in await store.unlockIdentity() } + } + + func recoverIdentity() async { + await run { store in await store.recoverIdentity() } + } + func stop() async { - do { - _ = try await runtimeClient.stop() - phase = .stopped - } catch let RadrootsRuntimeClientError.shutdown(failure) { - phase = .failed(failure) - } catch { + await run { store in await store.stop() } + } + + private func run( + _ operation: @escaping @Sendable (RadrootsSessionStore) async -> Phase + ) async { + generation &+= 1 + let requestedGeneration = generation + guard let sessionStore else { phase = .failed( - .local( - operation: "app.stop", - code: "ios.app.stop_failed", - safeMessage: "Radroots could not finish shutting down." + bootstrapFailure ?? .local( + operation: "app.bootstrap", + code: "ios.app.bootstrap_failed", + safeMessage: "Radroots could not start." ) ) + return } + let result = await operation(sessionStore) + guard generation == requestedGeneration else { return } + phase = result } } diff --git a/Radroots/Config/Base.xcconfig b/Radroots/Config/Base.xcconfig @@ -7,6 +7,7 @@ RADROOTS_FIELD_IOS_LOGGING_FILTER = info RADROOTS_FIELD_IOS_LOGGING_FILE_ENABLED = true RADROOTS_FIELD_IOS_LOGGING_FILE_NAME = field-ios.log RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS = wss:$(SLASH)$(SLASH)radroots.org +RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS = RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX = org.radroots.field_ios RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY = user_presence_local RADROOTS_FIELD_IOS_RESET_LOCAL_STATE = false diff --git a/Radroots/Config/Debug.xcconfig b/Radroots/Config/Debug.xcconfig @@ -7,6 +7,7 @@ RADROOTS_FIELD_IOS_LOGGING_FILTER = debug RADROOTS_FIELD_IOS_LOGGING_FILE_ENABLED = false RADROOTS_FIELD_IOS_LOGGING_FILE_NAME = field-ios-debug.log RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS = ws:$(SLASH)$(SLASH)127.0.0.1:8080 +RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS = http:$(SLASH)$(SLASH)127.0.0.1:3000 RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX = org.radroots.field_ios.local RADROOTS_FIELD_IOS_RESET_LOCAL_STATE = false diff --git a/Radroots/Info.plist b/Radroots/Info.plist @@ -17,7 +17,7 @@ <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleShortVersionString</key> - <string>1.0</string> + <string>0.1.0-alpha</string> <key>CFBundleVersion</key> <string>1</string> <key>BGTaskSchedulerPermittedIdentifiers</key> @@ -96,6 +96,8 @@ <string>$(RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS)</string> <key>RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX</key> <string>$(RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX)</string> + <key>RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS</key> + <string>$(RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS)</string> <key>RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY</key> <string>$(RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY)</string> <key>RADROOTS_FIELD_IOS_RESET_LOCAL_STATE</key> diff --git a/Radroots/Runtime/BuildConfig.swift b/Radroots/Runtime/BuildConfig.swift @@ -1,143 +0,0 @@ -import Foundation - -enum BuildConfigKey: String { - case envFile = "RADROOTS_FIELD_IOS_ENV_FILE" - case runtimeMode = "RADROOTS_FIELD_IOS_RUNTIME_MODE" - case loggingStdout = "RADROOTS_FIELD_IOS_LOGGING_STDOUT" - case loggingFilter = "RADROOTS_FIELD_IOS_LOGGING_FILTER" - case loggingFileEnabled = "RADROOTS_FIELD_IOS_LOGGING_FILE_ENABLED" - case loggingFileName = "RADROOTS_FIELD_IOS_LOGGING_FILE_NAME" - case nostrRelayUrls = "RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS" - case keychainServicePrefix = "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX" - case keychainAccessPolicy = "RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY" - case resetLocalState = "RADROOTS_FIELD_IOS_RESET_LOCAL_STATE" -} - -enum BuildConfig { - static func string(_ key: BuildConfigKey) -> String? { - debugLaunchOverrideString(key) ?? infoString(key).map { stripOuterQuotes($0) } - } - - static func bool(_ key: BuildConfigKey) -> Bool? { - if let raw = debugLaunchOverrideString(key), - let parsed = parseBool(raw) { - return parsed - } - if let v = infoValue(for: key.rawValue) { - if let b = v as? Bool { return b } - if let s = v as? String, let parsed = parseBool(s) { return parsed } - if let n = v as? NSNumber { return n.boolValue } - } - return nil - } - - static func array(_ key: BuildConfigKey, splitBy set: CharacterSet = .whitespacesAndNewlines) -> [String]? { - if let raw = debugLaunchOverrideString(key) { - return parseArray(raw, splitBy: set) - } - if let direct = infoArray(key) { - return direct - .map { stripOuterQuotes($0).trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - } - guard let raw = infoString(key) else { return nil } - return parseArray(raw, splitBy: set) - } - - static func effectiveDictionary(keys: [BuildConfigKey]) -> [String: Any] { - var out: [String: Any] = [:] - for k in keys { - switch k { - case .loggingStdout, .loggingFileEnabled, .resetLocalState: - if let b = bool(k) { - out[k.rawValue] = b - } - case .nostrRelayUrls: - if let arr = array(.nostrRelayUrls) { - out[k.rawValue] = arr - } - default: - if let s = string(k) { - out[k.rawValue] = s - } - } - } - return out - } - - private static func parseArray(_ value: String, splitBy set: CharacterSet) -> [String]? { - var raw = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !raw.isEmpty else { return nil } - if raw.first == "[" { - if let data = raw.data(using: .utf8), - let arr = try? JSONSerialization.jsonObject(with: data) as? [String] { - return arr - .map { stripOuterQuotes($0).trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - } - } - raw = stripOuterQuotes(raw) - let separators = set.union(CharacterSet(charactersIn: ",;")) - raw = raw.replacingOccurrences(of: "\n", with: " ") - .replacingOccurrences(of: "\r", with: " ") - return raw - .components(separatedBy: separators) - .map { stripOuterQuotes($0).trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - } - - private static func infoString(_ key: BuildConfigKey) -> String? { - if let v = infoValue(for: key.rawValue) as? String, !v.isEmpty { return v } - if let n = infoValue(for: key.rawValue) as? NSNumber { return n.stringValue } - return nil - } - - private static func infoArray(_ key: BuildConfigKey) -> [String]? { - if let v = infoValue(for: key.rawValue) as? [String] { return v } - if let nested = Bundle.main.object(forInfoDictionaryKey: "Radroots") as? [String: Any], - let v = nested[key.rawValue] as? [String] { - return v - } - return nil - } - - private static func infoValue(for key: String) -> Any? { - if let v = Bundle.main.object(forInfoDictionaryKey: key) { - return v - } - if let nested = Bundle.main.object(forInfoDictionaryKey: "Radroots") as? [String: Any] { - return nested[key] - } - return nil - } - - private static func parseBool(_ s: String) -> Bool? { - switch s.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "1", "true", "yes": return true - case "0", "false", "no": return false - default: return nil - } - } - - private static func stripOuterQuotes(_ s: String) -> String { - guard s.count >= 2 else { return s } - if (s.hasPrefix("\"") && s.hasSuffix("\"")) || (s.hasPrefix("'") && s.hasSuffix("'")) { - return String(s.dropFirst().dropLast()) - } - return s - } - - #if DEBUG - private static func debugLaunchOverrideString(_ key: BuildConfigKey) -> String? { - guard FieldUITestHarness.isRequested else { - return nil - } - return FieldUITestHarness.string(key.rawValue) - .flatMap { $0.isEmpty ? nil : stripOuterQuotes($0) } - } - #else - private static func debugLaunchOverrideString(_ key: BuildConfigKey) -> String? { - nil - } - #endif -} diff --git a/Radroots/Runtime/FieldIdentityPublicMetadataStore.swift b/Radroots/Runtime/FieldIdentityPublicMetadataStore.swift @@ -1,50 +0,0 @@ -import Foundation - -struct FieldIdentityPublicMetadata: Codable, Equatable, Sendable { - let selectedIdentityId: String - let publicKeyHex: String - let publicKeyNpub: String - let label: String? - let updatedAtUnix: UInt64 - - init(record: NostrIdentityRecord, updatedAtUnix: UInt64 = UInt64(Date().timeIntervalSince1970)) { - self.selectedIdentityId = record.id - self.publicKeyHex = record.publicKeyHex - self.publicKeyNpub = record.publicKeyNpub - self.label = record.label - self.updatedAtUnix = updatedAtUnix - } -} - -struct FieldIdentityPublicMetadataStore { - private let userDefaults: UserDefaults - private let key: String - - init(servicePrefix: String, userDefaults: UserDefaults = .standard) { - self.userDefaults = userDefaults - self.key = "field_ios.identity.public_metadata.\(servicePrefix)" - } - - static func configured() throws -> FieldIdentityPublicMetadataStore { - guard let servicePrefix = BuildConfig.string(.keychainServicePrefix) else { - throw FieldSecureIdentityStoreError.missingSecureStoreServicePrefix - } - return FieldIdentityPublicMetadataStore(servicePrefix: servicePrefix) - } - - func load() -> FieldIdentityPublicMetadata? { - guard let data = userDefaults.data(forKey: key) else { - return nil - } - return try? JSONDecoder().decode(FieldIdentityPublicMetadata.self, from: data) - } - - func save(_ metadata: FieldIdentityPublicMetadata) throws { - let data = try JSONEncoder().encode(metadata) - userDefaults.set(data, forKey: key) - } - - func delete() { - userDefaults.removeObject(forKey: key) - } -} diff --git a/Radroots/Runtime/FieldLocalState.swift b/Radroots/Runtime/FieldLocalState.swift @@ -1,56 +0,0 @@ -import Foundation -import RadrootsKit - -enum FieldLocalStateError: LocalizedError { - case missingBundleIdentifier - case invalidLogFileName(String) - - var errorDescription: String? { - switch self { - case .missingBundleIdentifier: - "Missing field iOS bundle identifier." - case .invalidLogFileName(let value): - "Invalid RADROOTS_FIELD_IOS_LOGGING_FILE_NAME: \(value)." - } - } -} - -enum FieldLocalState { - static func roots(bundleIdentifier: String) throws -> RadrootsAppleFileRoots { - let appIdentifier = try normalizedBundleIdentifier(bundleIdentifier) - return try RadrootsAppleFileRoots.appContainer(appIdentifier: appIdentifier) - } - - static func fileAccess(bundleIdentifier: String) throws -> RadrootsAppleFileAccess { - try RadrootsAppleFileAccess(roots: roots(bundleIdentifier: bundleIdentifier)) - } - - static func logFileURL(bundleIdentifier: String, fileName: String) throws -> URL { - let normalizedFileName = try normalizedLogFileName(fileName) - let file = RadrootsFileReference(scope: .logs, relativePath: normalizedFileName) - return try roots(bundleIdentifier: bundleIdentifier).resolvedURL(for: file) - } - - static func resetFileRoots(bundleIdentifier: String) throws { - try fileAccess(bundleIdentifier: bundleIdentifier).resetFileRoots() - } - - private static func normalizedBundleIdentifier(_ bundleIdentifier: String) throws -> String { - let trimmed = bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - throw FieldLocalStateError.missingBundleIdentifier - } - return trimmed - } - - private static func normalizedLogFileName(_ fileName: String) throws -> String { - let trimmed = fileName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, - !trimmed.contains("/"), - !trimmed.contains("\\"), - !trimmed.contains("\0") else { - throw FieldLocalStateError.invalidLogFileName(fileName) - } - return trimmed - } -} diff --git a/Radroots/Runtime/FieldSecureIdentityStore.swift b/Radroots/Runtime/FieldSecureIdentityStore.swift @@ -1,273 +0,0 @@ -import Foundation -import RadrootsKit -import Security - -enum FieldSecureIdentityStoreError: LocalizedError { - case missingSecureStoreServicePrefix - case missingBundleIdentifier - case invalidStoredSecret - case missingSelectedSecret - case missingSecureStoreAccessPolicy - case invalidSecureStoreAccessPolicy(String) - case randomSecretGenerationFailed(Int32) - case forcedImportRestoreFailure - - var errorDescription: String? { - switch self { - case .missingSecureStoreServicePrefix: - "Missing RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX." - case .missingBundleIdentifier: - "Missing field iOS bundle identifier." - case .invalidStoredSecret: - "Stored Nostr identity secret is invalid." - case .missingSelectedSecret: - "No selected Nostr identity secret is available in secure store." - case .missingSecureStoreAccessPolicy: - "Missing RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY." - case .invalidSecureStoreAccessPolicy(let value): - "Invalid RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY: \(value)." - case .randomSecretGenerationFailed(let status): - "Secure Nostr identity generation failed with status \(status)." - case .forcedImportRestoreFailure: - "Forced identity import restore failure." - } - } -} - -enum FieldSecureIdentityAccessPolicy: String { - case userPresenceLocal = "user_presence_local" - case secureLocal = "secure_local" - - var storePolicy: RadrootsSecretAccessPolicy { - switch self { - case .userPresenceLocal: - .userPresenceLocalSecret - case .secureLocal: - .secureLocalSecret - } - } -} - -struct FieldSecureIdentityStore { - static let namespace = "nostr_identity" - static let selectedSecretName = "selected_secret_hex" - - let servicePrefix: String - private let store: any RadrootsSecureStore - - init(servicePrefix: String) { - self.servicePrefix = servicePrefix - self.store = RadrootsAppleKeychainSecureStore(servicePrefix: servicePrefix) - } - - init(servicePrefix: String, store: any RadrootsSecureStore) { - self.servicePrefix = servicePrefix - self.store = store - } - - static func configured() throws -> FieldSecureIdentityStore { - guard let servicePrefix = BuildConfig.string(.keychainServicePrefix) else { - throw FieldSecureIdentityStoreError.missingSecureStoreServicePrefix - } - return FieldSecureIdentityStore(servicePrefix: servicePrefix) - } - - func loadSelectedSecretHex() throws -> String? { - guard let data = try store.get(Self.selectedSecretKey) else { - return nil - } - guard let value = String(data: data, encoding: .utf8) else { - throw FieldSecureIdentityStoreError.invalidStoredSecret - } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - func restoreStoredIdentity( - label: String?, - using service: FieldRuntimeService - ) async throws -> NostrIdentityRecord { - guard let secret = try loadSelectedSecretHex() else { - throw FieldSecureIdentityStoreError.missingSelectedSecret - } - return try await service.nostrIdentityRestoreHostCustodySecret( - secretKey: secret, - label: label, - makeSelected: true - ) - } - - func importSecret( - _ secret: String, - label: String?, - using service: FieldRuntimeService - ) async throws -> NostrIdentityRecord { - let trimmed = try normalizedSecret(secret) - let previousSecret = try loadSelectedSecretHex() - _ = try await service.nostrIdentityValidateHostCustodySecret(secretKey: trimmed) - let stagedRecord = try await restoreHostCustodySecret( - trimmed, - label: label, - makeSelected: false, - using: service - ) - do { - try saveSelectedSecret(trimmed) - } catch { - await restorePreviousRuntimeIdentity(previousSecret, using: service) - await removeStagedIdentityIfNeeded(stagedRecord, previousSecret: previousSecret, using: service) - throw error - } - do { - return try await restoreHostCustodySecret( - trimmed, - label: label, - makeSelected: true, - using: service - ) - } catch { - try? restorePreviousSelectedSecret(previousSecret) - await restorePreviousRuntimeIdentity(previousSecret, using: service) - await removeStagedIdentityIfNeeded(stagedRecord, previousSecret: previousSecret, using: service) - throw error - } - } - - func createIdentity( - label: String?, - using service: FieldRuntimeService - ) async throws -> NostrIdentityRecord { - var lastError: Error? - for _ in 0..<8 { - let secret = try Self.generateSecretHex() - do { - return try await importSecret(secret, label: label, using: service) - } catch { - lastError = error - } - } - throw lastError ?? FieldSecureIdentityStoreError.missingSelectedSecret - } - - func deleteSelectedSecret() throws { - try store.delete(Self.selectedSecretKey) - } - - static func secureStoreServiceName(servicePrefix: String) throws -> String { - try selectedSecretKey.serviceName(servicePrefix: servicePrefix) - } - - static func configuredAccessPolicy() throws -> FieldSecureIdentityAccessPolicy { - guard let rawValue = BuildConfig.string(.keychainAccessPolicy) else { - throw FieldSecureIdentityStoreError.missingSecureStoreAccessPolicy - } - guard let policy = FieldSecureIdentityAccessPolicy(rawValue: rawValue) else { - throw FieldSecureIdentityStoreError.invalidSecureStoreAccessPolicy(rawValue) - } - return policy - } - - static func generateSecretHex() throws -> String { - var bytes = [UInt8](repeating: 0, count: 32) - let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - guard status == errSecSuccess else { - throw FieldSecureIdentityStoreError.randomSecretGenerationFailed(status) - } - return bytes.map { String(format: "%02x", $0) }.joined() - } - - private func saveSelectedSecret(_ secret: String) throws { - let trimmed = try normalizedSecret(secret) - try store.put( - Data(trimmed.utf8), - for: Self.selectedSecretKey, - policy: try Self.configuredAccessPolicy().storePolicy - ) - } - - private func restorePreviousSelectedSecret(_ previousSecret: String?) throws { - if let previousSecret { - try saveSelectedSecret(previousSecret) - } else { - try deleteSelectedSecret() - } - } - - private func restoreHostCustodySecret( - _ secret: String, - label: String?, - makeSelected: Bool, - using service: FieldRuntimeService - ) async throws -> NostrIdentityRecord { - #if DEBUG - try FieldSecureIdentityImportRestoreFailureUITestHook.throwIfRequested(makeSelected: makeSelected) - #endif - return try await service.nostrIdentityRestoreHostCustodySecret( - secretKey: secret, - label: label, - makeSelected: makeSelected - ) - } - - private func restorePreviousRuntimeIdentity( - _ previousSecret: String?, - using service: FieldRuntimeService - ) async { - guard let previousSecret else { - return - } - _ = try? await service.nostrIdentityRestoreHostCustodySecret( - secretKey: previousSecret, - label: nil, - makeSelected: true - ) - } - - private func removeStagedIdentityIfNeeded( - _ stagedRecord: NostrIdentityRecord, - previousSecret: String?, - using service: FieldRuntimeService - ) async { - if let previousSecret, - let previous = try? await service.nostrIdentityValidateHostCustodySecret(secretKey: previousSecret), - previous.id == stagedRecord.id { - return - } - try? await service.nostrIdentityRemove(identityId: stagedRecord.id) - } - - private func normalizedSecret(_ secret: String) throws -> String { - let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - throw FieldSecureIdentityStoreError.missingSelectedSecret - } - return trimmed - } - - private static var selectedSecretKey: RadrootsSecureStoreKey { - RadrootsSecureStoreKey(namespace: namespace, name: selectedSecretName) - } -} - -#if DEBUG -private enum FieldSecureIdentityImportRestoreFailureUITestHook { - private static let phaseKey = "RADROOTS_FIELD_IOS_UI_TEST_IDENTITY_IMPORT_RESTORE_FAILURE_PHASE" - - static func throwIfRequested(makeSelected: Bool) throws { - guard FieldUITestHarness.isRequested, - let rawPhase = FieldUITestHarness.string(phaseKey)?.lowercased() else { - return - } - switch rawPhase { - case "any": - throw FieldSecureIdentityStoreError.forcedImportRestoreFailure - case "stage" where !makeSelected: - throw FieldSecureIdentityStoreError.forcedImportRestoreFailure - case "select" where makeSelected: - throw FieldSecureIdentityStoreError.forcedImportRestoreFailure - default: - return - } - } -} -#endif diff --git a/Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift b/Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift @@ -1,6 +1,45 @@ import Foundation import RadrootsKitBindings +private final class RadrootsGeneratedHostSigner: RadrootsHostSigner, @unchecked Sendable { + private let signer: any RadrootsRuntimeSigner + + init(signer: any RadrootsRuntimeSigner) { + self.signer = signer + } + + func signerStatus() async -> SignerStatusRecord { + await SignerStatusRecord( + schemaVersion: 1, + availability: signer.availability().generatedValue + ) + } + + func sign(request: HostSigningRequest) async -> HostSigningResult { + let purpose = request.purpose.appValue + let outcome = await signer.sign( + RadrootsRuntimeSigningRequest( + operationID: request.operationId, + signerRequestID: request.signerRequestId, + publicKeyHex: request.publicKey, + purpose: purpose, + deadlineUnixMilliseconds: request.deadlineUnixMs, + digest: request.eventIdDigest + ) + ) + return HostSigningResult( + schemaVersion: 1, + outcome: outcome.generatedOutcome, + operationId: request.operationId, + signerRequestId: request.signerRequestId, + publicKey: request.publicKey, + purpose: request.purpose, + signatureHex: outcome.signatureHex, + completedAtUnixMs: UInt64(Date().timeIntervalSince1970 * 1000) + ) + } +} + private final class RadrootsGeneratedRuntimeObserver: RadrootsRuntimeObserver, @unchecked Sendable { private let continuation: AsyncStream<RadrootsRuntimeChange>.Continuation @@ -142,12 +181,13 @@ private final class RadrootsGeneratedRuntimeBackend: RadrootsRuntimeBackend, @un ) async throws -> RadrootsRuntimeBackendStart { var createdRuntime: RadrootsRuntime? do { - let runtime = try await RadrootsRuntime( + let runtime = try await RadrootsRuntime.withHostSigner( applicationSupportDirectory: configuration.applicationSupportDirectory, publicKeyHex: configuration.publicKeyHex, sourceGenerationHex: configuration.sourceGenerationHex, sourceGenerationCreatedAtUnixMs: configuration.sourceGenerationCreatedAtUnixMilliseconds, - protectedData: configuration.protectedData.generatedValue + protectedData: configuration.protectedData.generatedValue, + hostSigner: RadrootsGeneratedHostSigner(signer: configuration.signer) ) createdRuntime = runtime runtime.setAppInfoPlatform( @@ -161,13 +201,19 @@ private final class RadrootsGeneratedRuntimeBackend: RadrootsRuntimeBackend, @un switch configuration.networkProfile { case .publicNetwork: try runtime.configurePublicRelays(writableRelays: configuration.writableRelays) - try runtime.configurePublicBlossom(origins: configuration.blossomOrigins) + if !configuration.blossomOrigins.isEmpty { + try runtime.configurePublicBlossom(origins: configuration.blossomOrigins) + } case .simulator: try runtime.configureSimulatorRelays(loopbackRelays: configuration.writableRelays) - try runtime.configureSimulatorBlossom(origins: configuration.blossomOrigins) + if !configuration.blossomOrigins.isEmpty { + try runtime.configureSimulatorBlossom(origins: configuration.blossomOrigins) + } case .device: try runtime.configureDeviceRelays(writableRelays: configuration.writableRelays) - try runtime.configureDeviceBlossom(origins: configuration.blossomOrigins) + if !configuration.blossomOrigins.isEmpty { + try runtime.configureDeviceBlossom(origins: configuration.blossomOrigins) + } } let backend = RadrootsGeneratedRuntimeBackend(runtime: runtime) @@ -207,6 +253,48 @@ private final class RadrootsGeneratedRuntimeBackend: RadrootsRuntimeBackend, @un } } +private extension RadrootsRuntimeSignerAvailability { + var generatedValue: SignerAvailabilityRecord { + switch self { + case .ready: .ready + case .busy: .busy + case .locked: .locked + case .unavailable: .unavailable + } + } +} + +private extension HostSigningPurpose { + var appValue: RadrootsRuntimeSigningPurpose { + switch self { + case .nostrEvent: .nostrEvent + case .blossomUpload: .blossomUpload + } + } +} + +private extension RadrootsRuntimeSigningOutcome { + var generatedOutcome: HostSigningOutcome { + switch self { + case .signed: .signed + case .locked: .locked + case .cancelled: .cancelled + case .rejected: .rejected + case .timedOut: .timedOut + case .unavailable: .unavailable + case .invalidated: .invalidated + case .failed: .failed + } + } + + var signatureHex: String? { + if case let .signed(signatureHex) = self { + return signatureHex + } + return nil + } +} + extension RadrootsRuntimeClient { static func production() -> RadrootsRuntimeClient { RadrootsRuntimeClient { configuration in diff --git a/Radroots/Runtime/RadrootsRuntimeModels.swift b/Radroots/Runtime/RadrootsRuntimeModels.swift @@ -18,7 +18,44 @@ struct RadrootsRuntimeAppMetadata: Sendable, Equatable { let buildSHA: String? } -struct RadrootsRuntimeLaunchConfiguration: Sendable, Equatable { +enum RadrootsRuntimeSignerAvailability: Sendable, Equatable { + case ready + case busy + case locked + case unavailable +} + +enum RadrootsRuntimeSigningPurpose: Sendable, Equatable { + case nostrEvent + case blossomUpload +} + +struct RadrootsRuntimeSigningRequest: Sendable, Equatable { + let operationID: String + let signerRequestID: String + let publicKeyHex: String + let purpose: RadrootsRuntimeSigningPurpose + let deadlineUnixMilliseconds: UInt64 + let digest: Data +} + +enum RadrootsRuntimeSigningOutcome: Sendable, Equatable { + case signed(signatureHex: String) + case locked + case cancelled + case rejected + case timedOut + case unavailable + case invalidated + case failed +} + +protocol RadrootsRuntimeSigner: Sendable { + func availability() async -> RadrootsRuntimeSignerAvailability + func sign(_ request: RadrootsRuntimeSigningRequest) async -> RadrootsRuntimeSigningOutcome +} + +struct RadrootsRuntimeLaunchConfiguration: Sendable { let applicationSupportDirectory: String let publicKeyHex: String let sourceGenerationHex: String @@ -28,6 +65,24 @@ struct RadrootsRuntimeLaunchConfiguration: Sendable, Equatable { let writableRelays: [String] let blossomOrigins: [String] let app: RadrootsRuntimeAppMetadata + let signerGeneration: String + let signer: any RadrootsRuntimeSigner +} + +extension RadrootsRuntimeLaunchConfiguration: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.applicationSupportDirectory == rhs.applicationSupportDirectory + && lhs.publicKeyHex == rhs.publicKeyHex + && lhs.sourceGenerationHex == rhs.sourceGenerationHex + && lhs.sourceGenerationCreatedAtUnixMilliseconds + == rhs.sourceGenerationCreatedAtUnixMilliseconds + && lhs.protectedData == rhs.protectedData + && lhs.networkProfile == rhs.networkProfile + && lhs.writableRelays == rhs.writableRelays + && lhs.blossomOrigins == rhs.blossomOrigins + && lhs.app == rhs.app + && lhs.signerGeneration == rhs.signerGeneration + } } struct RadrootsRuntimeIdentity: Sendable, Equatable { diff --git a/Radroots/Runtime/RelaySettings.swift b/Radroots/Runtime/RelaySettings.swift @@ -1,138 +0,0 @@ -import Foundation -import RadrootsKit - -public enum RelaySettingsError: LocalizedError { - case noRelaysConfigured - case invalidRelayURL(String) - case invalidStoredRelaySettings - - public var errorDescription: String? { - switch self { - case .noRelaysConfigured: - "No Nostr relays configured. Set 'RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS'." - case .invalidRelayURL(let value): - "Invalid Nostr relay URL: \(value)." - case .invalidStoredRelaySettings: - "Stored Nostr relay settings are invalid." - } - } -} - -public enum RelaySettingsSource: String { - case buildConfig - case userImported - - var displayName: String { - switch self { - case .buildConfig: - "Build Config" - case .userImported: - "Imported" - } - } -} - -public struct RelaySettingsSnapshot: Equatable { - public let source: RelaySettingsSource - public let relays: [String] -} - -public enum RelaySettings { - private struct StoredRelaySettingsDocument: Codable { - static let format = "radroots_field_ios_relay_settings_v1" - - let format: String - let relays: [String] - } - - private static let storedSettingsFile = RadrootsFileReference( - scope: .data, - relativePath: "settings/relay_settings.json" - ) - - public static func relays() throws -> [String] { - guard let parts = BuildConfig.array(.nostrRelayUrls) else { - throw RelaySettingsError.noRelaysConfigured - } - return try validatedRelays(parts) - } - - public static func effectiveSnapshot(bundleIdentifier: String) throws -> RelaySettingsSnapshot { - if let importedRelays = try userImportedRelays(bundleIdentifier: bundleIdentifier) { - return RelaySettingsSnapshot(source: .userImported, relays: importedRelays) - } - return RelaySettingsSnapshot(source: .buildConfig, relays: try relays()) - } - - @discardableResult - public static func storeUserImportedRelays( - _ relays: [String], - bundleIdentifier: String - ) throws -> RelaySettingsSnapshot { - let normalized = try validatedRelays(relays) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let document = StoredRelaySettingsDocument( - format: StoredRelaySettingsDocument.format, - relays: normalized - ) - let data = try encoder.encode(document) - try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier).write( - .inline(data), - to: storedSettingsFile - ) - return RelaySettingsSnapshot(source: .userImported, relays: normalized) - } - - public static func clearUserImportedRelays(bundleIdentifier: String) throws { - try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier).delete(storedSettingsFile) - } - - public static func validatedRelays(_ urls: [String]) throws -> [String] { - var seen = Set<String>() - var out: [String] = [] - for u in urls { - let trimmed = u.trimmingCharacters(in: .whitespacesAndNewlines) - let unquoted = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) - guard !unquoted.isEmpty else { - continue - } - guard let components = URLComponents(string: unquoted), - let scheme = components.scheme?.lowercased(), - scheme == "ws" || scheme == "wss", - components.host != nil, - unquoted.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else { - throw RelaySettingsError.invalidRelayURL(u) - } - let lower = unquoted.lowercased() - if seen.insert(lower).inserted { - out.append(unquoted) - } - } - guard !out.isEmpty else { - throw RelaySettingsError.noRelaysConfigured - } - return out - } - - private static func userImportedRelays(bundleIdentifier: String) throws -> [String]? { - do { - let result = try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier).read( - storedSettingsFile, - mode: .inline - ) - guard case .inline(let data) = result else { - throw RelaySettingsError.invalidStoredRelaySettings - } - let document = try JSONDecoder().decode(StoredRelaySettingsDocument.self, from: data) - guard document.format == StoredRelaySettingsDocument.format else { - throw RelaySettingsError.invalidStoredRelaySettings - } - return try validatedRelays(document.relays) - } catch RadrootsAppleFileError.notFound(_) { - return nil - } catch is DecodingError { - throw RelaySettingsError.invalidStoredRelaySettings - } - } -} diff --git a/Radroots/State/RadrootsConfigurationStore.swift b/Radroots/State/RadrootsConfigurationStore.swift @@ -0,0 +1,447 @@ +import Darwin +import Foundation +import RadrootsKit +import Security + +enum RadrootsAppNetworkProfile: String, Codable, Sendable, Equatable { + case publicNetwork = "public" + case simulator + case device + + var runtimeValue: RadrootsRuntimeNetworkProfile { + switch self { + case .publicNetwork: .publicNetwork + case .simulator: .simulator + case .device: .device + } + } +} + +struct RadrootsAppConfiguration: Sendable, Equatable { + let profile: RadrootsAppNetworkProfile + let writableRelays: [String] + let blossomOrigins: [String] + let keychainServicePrefix: String + let bundleIdentifier: String + let appMetadata: RadrootsRuntimeAppMetadata +} + +struct RadrootsConfigurationBootstrap: Sendable, Equatable { + let runtimeMode: String + let relayURLs: [String] + let blossomOrigins: [String] + let keychainServicePrefix: String + let bundleIdentifier: String + let appMetadata: RadrootsRuntimeAppMetadata +} + +struct RadrootsSourceGeneration: Codable, Sendable, Equatable { + let schemaVersion: UInt16 + let generationHex: String + let createdAtUnixMilliseconds: UInt64 +} + +enum RadrootsConfigurationError: Error, Sendable, Equatable { + case missing(String) + case invalid(String) + case corruptStoredConfiguration + case corruptSourceGeneration + case persistenceFailed +} + +extension RadrootsConfigurationError: LocalizedError { + var errorDescription: String? { + switch self { + case .missing: + "Required Radroots configuration is missing." + case .invalid: + "Radroots network configuration is invalid." + case .corruptStoredConfiguration: + "Stored Radroots settings are corrupt and require recovery." + case .corruptSourceGeneration: + "Stored Radroots local-state identity is corrupt and requires recovery." + case .persistenceFailed: + "Radroots could not persist its local configuration." + } + } +} + +actor RadrootsConfigurationStore { + private struct StoredConfiguration: Codable { + static let format = "radroots_ios_configuration_v2" + + let format: String + let profile: RadrootsAppNetworkProfile + let writableRelays: [String] + let blossomOrigins: [String] + } + + private struct LegacyRelaySettings: Codable { + static let format = "radroots_field_ios_relay_settings_v1" + + let format: String + let relays: [String] + } + + private static let configurationFile = RadrootsFileReference( + scope: .data, + relativePath: "settings/radroots_configuration_v2.json" + ) + private static let legacyRelayFile = RadrootsFileReference( + scope: .data, + relativePath: "settings/relay_settings.json" + ) + private static let sourceGenerationFile = RadrootsFileReference( + scope: .data, + relativePath: "state/source_generation_v1.json" + ) + + private let bootstrap: RadrootsConfigurationBootstrap + private let fileAccess: RadrootsAppleFileAccess + + init(bootstrap: RadrootsConfigurationBootstrap, roots: RadrootsAppleFileRoots) { + self.bootstrap = bootstrap + fileAccess = RadrootsAppleFileAccess(roots: roots) + } + + func load() throws -> RadrootsAppConfiguration { + let profile = try Self.profile(for: bootstrap.runtimeMode) + let stored = try readStoredConfiguration() + let selected: StoredConfiguration + if let stored { + guard stored.format == StoredConfiguration.format, stored.profile == profile else { + throw RadrootsConfigurationError.corruptStoredConfiguration + } + selected = stored + } else if let legacy = try readLegacyConfiguration() { + guard legacy.format == LegacyRelaySettings.format else { + throw RadrootsConfigurationError.corruptStoredConfiguration + } + selected = StoredConfiguration( + format: StoredConfiguration.format, + profile: profile, + writableRelays: legacy.relays, + blossomOrigins: bootstrap.blossomOrigins + ) + try persist(selected) + } else { + selected = StoredConfiguration( + format: StoredConfiguration.format, + profile: profile, + writableRelays: bootstrap.relayURLs, + blossomOrigins: bootstrap.blossomOrigins + ) + } + + let relays = try RadrootsNetworkValidator.relays( + selected.writableRelays, + profile: profile + ) + let origins = try RadrootsNetworkValidator.blossomOrigins( + selected.blossomOrigins, + profile: profile + ) + guard !bootstrap.keychainServicePrefix.isEmpty, + !bootstrap.bundleIdentifier.isEmpty + else { + throw RadrootsConfigurationError.missing("identity") + } + return RadrootsAppConfiguration( + profile: profile, + writableRelays: relays, + blossomOrigins: origins, + keychainServicePrefix: bootstrap.keychainServicePrefix, + bundleIdentifier: bootstrap.bundleIdentifier, + appMetadata: bootstrap.appMetadata + ) + } + + func sourceGeneration() throws -> RadrootsSourceGeneration { + if let data = try read(Self.sourceGenerationFile) { + guard let value = try? JSONDecoder().decode(RadrootsSourceGeneration.self, from: data), + value.schemaVersion == 1, + value.generationHex.count == 64, + value.generationHex.allSatisfy(\.isHexDigit), + value.generationHex == value.generationHex.lowercased(), + value.createdAtUnixMilliseconds > 0 + else { + throw RadrootsConfigurationError.corruptSourceGeneration + } + return value + } + var bytes = [UInt8](repeating: 0, count: 32) + guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { + throw RadrootsConfigurationError.persistenceFailed + } + let value = RadrootsSourceGeneration( + schemaVersion: 1, + generationHex: bytes.map { String(format: "%02x", $0) }.joined(), + createdAtUnixMilliseconds: max(1, UInt64(Date().timeIntervalSince1970 * 1000)) + ) + do { + let data = try JSONEncoder.radroots.encode(value) + try fileAccess.write(.inline(data), to: Self.sourceGenerationFile) + return value + } catch let error as RadrootsConfigurationError { + throw error + } catch { + throw RadrootsConfigurationError.persistenceFailed + } + } + + private func readStoredConfiguration() throws -> StoredConfiguration? { + guard let data = try read(Self.configurationFile) else { return nil } + guard let stored = try? JSONDecoder().decode(StoredConfiguration.self, from: data) else { + throw RadrootsConfigurationError.corruptStoredConfiguration + } + return stored + } + + private func readLegacyConfiguration() throws -> LegacyRelaySettings? { + guard let data = try read(Self.legacyRelayFile) else { return nil } + guard let legacy = try? JSONDecoder().decode(LegacyRelaySettings.self, from: data) else { + throw RadrootsConfigurationError.corruptStoredConfiguration + } + return legacy + } + + private func read(_ file: RadrootsFileReference) throws -> Data? { + do { + guard case let .inline(data) = try fileAccess.read(file, mode: .inline) else { + throw RadrootsConfigurationError.persistenceFailed + } + return data + } catch RadrootsAppleFileError.notFound { + return nil + } catch let error as RadrootsConfigurationError { + throw error + } catch { + throw RadrootsConfigurationError.persistenceFailed + } + } + + private func persist(_ configuration: StoredConfiguration) throws { + do { + let data = try JSONEncoder.radroots.encode(configuration) + try fileAccess.write(.inline(data), to: Self.configurationFile) + } catch { + throw RadrootsConfigurationError.persistenceFailed + } + } + + private static func profile(for runtimeMode: String) throws -> RadrootsAppNetworkProfile { + switch runtimeMode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "production": .publicNetwork + case "localhost-dev", "simulator": .simulator + case "device-development", "device": .device + default: throw RadrootsConfigurationError.invalid("runtime_mode") + } + } +} + +enum RadrootsNetworkValidator { + static let publicReadOnlyRelay = "wss://radroots.org" + + static func relays( + _ values: [String], + profile: RadrootsAppNetworkProfile + ) throws -> [String] { + var output: [String] = [] + var seen = Set<String>() + for raw in values { + let canonical = try relay(raw, profile: profile) + if profile != .simulator, canonical == publicReadOnlyRelay { + continue + } + guard seen.insert(canonical).inserted else { + throw RadrootsConfigurationError.invalid("duplicate_relay") + } + output.append(canonical) + } + if profile != .publicNetwork, output.isEmpty { + throw RadrootsConfigurationError.invalid("empty_relay_set") + } + let totalCount = output.count + (profile == .simulator ? 0 : 1) + guard totalCount <= 64 else { + throw RadrootsConfigurationError.invalid("too_many_relays") + } + return output + } + + static func blossomOrigins( + _ values: [String], + profile: RadrootsAppNetworkProfile + ) throws -> [String] { + var output: [String] = [] + var seen = Set<String>() + for raw in values { + guard raw == raw.trimmingCharacters(in: .whitespacesAndNewlines), + raw.utf8.allSatisfy({ $0 < 128 }), + let components = URLComponents(string: raw), + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil, + components.path.isEmpty || components.path == "/", + let scheme = components.scheme?.lowercased(), + let host = components.host?.lowercased(), + components.port != 0 + else { + throw RadrootsConfigurationError.invalid("blossom_origin") + } + guard scheme == "https" || scheme == "http" && profile == .simulator, + hostAllowed(host, profile: profile) + else { + throw RadrootsConfigurationError.invalid("blossom_policy") + } + var canonical = "\(scheme)://\(hostForURL(host))" + if let port = components.port, + !((scheme == "https" && port == 443) || (scheme == "http" && port == 80)) + { + canonical += ":\(port)" + } + guard seen.insert(canonical).inserted else { + throw RadrootsConfigurationError.invalid("duplicate_blossom_origin") + } + output.append(canonical) + } + guard output.count <= 8 else { + throw RadrootsConfigurationError.invalid("too_many_blossom_origins") + } + return output + } + + private static func relay( + _ raw: String, + profile: RadrootsAppNetworkProfile + ) throws -> String { + guard raw == raw.trimmingCharacters(in: .whitespacesAndNewlines), + raw.utf8.count <= 2048, + !raw.contains(where: { $0.isASCII && ($0.isWhitespace || $0.asciiValue ?? 32 < 32) }), + !raw.contains("?"), + !raw.contains("#"), + !raw.contains("\\"), + let components = URLComponents(string: raw), + components.user == nil, + components.password == nil, + let scheme = components.scheme?.lowercased(), + let host = components.host?.lowercased(), + components.port != 0, + components.path.isEmpty || components.path == "/" + else { + throw RadrootsConfigurationError.invalid("relay_url") + } + guard scheme == "wss" || scheme == "ws" && profile == .simulator, + hostAllowed(host, profile: profile) + else { + throw RadrootsConfigurationError.invalid("relay_policy") + } + var canonical = "\(scheme)://\(hostForURL(host))" + if let port = components.port, + !((scheme == "wss" && port == 443) || (scheme == "ws" && port == 80)) + { + canonical += ":\(port)" + } + return canonical + } + + private static func hostForURL(_ host: String) -> String { + host.contains(":") ? "[\(host)]" : host + } + + private static func hostAllowed( + _ host: String, + profile: RadrootsAppNetworkProfile + ) -> Bool { + if profile == .simulator { + return host == "localhost" || host == "127.0.0.1" || host == "::1" + } + if host == "localhost" || host.hasSuffix(".localhost") { + return false + } + if let ipv4 = IPv4Address(host) { + return profile == .publicNetwork ? ipv4.isPublic : ipv4.isTrustedDevice + } + if let ipv6 = IPv6Address(host) { + return profile == .publicNetwork ? ipv6.isPublic : ipv6.isTrustedDevice + } + if profile == .device { + return true + } + let normalized = host.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return normalized.contains(".") + && !normalized.hasSuffix(".local") + && !normalized.hasSuffix(".home.arpa") + } +} + +private struct IPv4Address { + private var address = in_addr() + + init?(_ value: String) { + guard inet_pton(AF_INET, value, &address) == 1 else { return nil } + } + + var isTrustedDevice: Bool { + var address = address + let octets = withUnsafeBytes(of: &address.s_addr) { Array($0) } + return octets[0] != 0 && octets[0] != 127 && !(224 ... 239).contains(octets[0]) + && octets != [255, 255, 255, 255] + } + + var isPublic: Bool { + var address = address + let octets = withUnsafeBytes(of: &address.s_addr) { Array($0) } + guard isTrustedDevice else { return false } + return !(octets[0] == 10 + || octets[0] == 169 && octets[1] == 254 + || octets[0] == 172 && (16 ... 31).contains(octets[1]) + || octets[0] == 192 && octets[1] == 168 + || octets[0] == 100 && (64 ... 127).contains(octets[1]) + || octets[0] == 192 && octets[1] == 0 && octets[2] == 0 + || octets[0] == 192 && octets[1] == 0 && octets[2] == 2 + || octets[0] == 192 && octets[1] == 88 && octets[2] == 99 + || octets[0] == 198 && (octets[1] == 18 || octets[1] == 19) + || octets[0] == 198 && octets[1] == 51 && octets[2] == 100 + || octets[0] == 203 && octets[1] == 0 && octets[2] == 113 + || octets[0] >= 240) + } +} + +private struct IPv6Address { + private var address = in6_addr() + + init?(_ value: String) { + guard inet_pton(AF_INET6, value, &address) == 1 else { return nil } + } + + var isTrustedDevice: Bool { + var address = address + let bytes = withUnsafeBytes(of: &address) { Array($0) } + let allZero = bytes.allSatisfy { $0 == 0 } + let loopback = bytes.dropLast().allSatisfy { $0 == 0 } && bytes.last == 1 + let multicast = bytes.first == 0xFF + return !allZero && !loopback && !multicast + } + + var isPublic: Bool { + var address = address + let bytes = withUnsafeBytes(of: &address) { Array($0) } + guard isTrustedDevice, bytes.count == 16 else { return false } + let segment0 = UInt16(bytes[0]) << 8 | UInt16(bytes[1]) + let segment1 = UInt16(bytes[2]) << 8 | UInt16(bytes[3]) + return segment0 & 0xE000 == 0x2000 + && !(segment0 == 0x2001 && segment1 <= 0x01FF) + && !(segment0 == 0x2001 && segment1 == 0x0DB8) + && segment0 != 0x2002 + && !(segment0 == 0x3FFF && segment1 & 0xF000 == 0) + } +} + +private extension JSONEncoder { + static var radroots: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } +} diff --git a/Radroots/State/RadrootsIdentityStore.swift b/Radroots/State/RadrootsIdentityStore.swift @@ -0,0 +1,335 @@ +import CryptoKit +import Foundation +import RadrootsKit + +enum RadrootsAppIdentityState: String, Sendable, Equatable { + case absent + case locked + case unlocked + case protectedDataUnavailable + case recoveryRequired + case corrupt +} + +struct RadrootsAppIdentity: Sendable, Equatable { + let state: RadrootsAppIdentityState + let identityHandle: String? + let publicKeyHex: String? + let label: String? + let signerGeneration: String? + let recoveryCode: String? +} + +struct RadrootsStableVisualIdentity: Sendable, Equatable { + let digestHex: String + let paletteIndex: Int + + init(publicKeyHex: String, paletteCount: Int = 12) { + let digest = SHA256.hash(data: Data("radroots.avatar.v1:\(publicKeyHex)".utf8)) + digestHex = digest.map { String(format: "%02x", $0) }.joined() + paletteIndex = Int(Array(digest)[0]) % max(1, paletteCount) + } +} + +enum RadrootsIdentityStoreError: Error, Sendable, Equatable { + case corruptLegacyMetadata + case custody(String) + case unavailable +} + +extension RadrootsIdentityStoreError: LocalizedError { + var errorDescription: String? { + switch self { + case .corruptLegacyMetadata: + "Legacy identity metadata is corrupt and requires recovery." + case .custody: + "The local identity needs attention before Radroots can continue." + case .unavailable: + "The local identity is unavailable." + } + } +} + +actor RadrootsIdentityStore { + private struct LegacyMetadata: Codable { + let selectedIdentityId: String + let publicKeyHex: String + let publicKeyNpub: String + let label: String? + let updatedAtUnix: UInt64 + } + + private let custody: RadrootsIdentityCustody + private let secureStore: any RadrootsSecureStore + private let servicePrefix: String + private let userDefaults: UserDefaults + + init( + custody: RadrootsIdentityCustody, + secureStore: any RadrootsSecureStore, + servicePrefix: String, + userDefaults: UserDefaults = .standard + ) { + self.custody = custody + self.secureStore = secureStore + self.servicePrefix = servicePrefix + self.userDefaults = userDefaults + } + + @MainActor + static func production( + servicePrefix: String, + protectedDataAvailable: Bool + ) throws -> RadrootsIdentityStore { + let namespace = "radroots_identity_v1" + let secureStore = RadrootsAppleKeychainSecureStore(servicePrefix: servicePrefix) + let custody = try RadrootsIdentityCustody( + configuration: RadrootsIdentityCustodyConfiguration(namespace: namespace), + secureStore: secureStore, + metadataStore: RadrootsAppleIdentityMetadataStore( + namespace: namespace, + keyPrefix: "org.radroots.ios.identity" + ), + userPresence: RadrootsAppleUserPresence(), + protectedData: RadrootsProtectedDataProvider { + protectedDataAvailable ? .available : .unavailable + } + ) + return RadrootsIdentityStore( + custody: custody, + secureStore: secureStore, + servicePrefix: servicePrefix + ) + } + + func loadAndMigrate() async throws -> RadrootsAppIdentity { + let initial = await custody.snapshot() + guard initial.state == .absent else { + return Self.appIdentity(initial) + } + + let legacySecretKey = RadrootsSecureStoreKey( + namespace: "nostr_identity", + name: "selected_secret_hex" + ) + let hasLegacySecret: Bool + do { + hasLegacySecret = try secureStore.contains(legacySecretKey) + } catch { + throw RadrootsIdentityStoreError.unavailable + } + let legacyMetadata = try loadLegacyMetadata() + guard hasLegacySecret || legacyMetadata != nil else { + return Self.appIdentity(initial) + } + guard hasLegacySecret else { + throw RadrootsIdentityStoreError.corruptLegacyMetadata + } + return RadrootsAppIdentity( + state: .recoveryRequired, + identityHandle: nil, + publicKeyHex: legacyMetadata?.publicKeyHex.lowercased(), + label: legacyMetadata?.label, + signerGeneration: nil, + recoveryCode: "identity.legacy_migration_required" + ) + } + + private func migrateLegacyIdentity() async throws -> RadrootsAppIdentity { + let legacySecretKey = RadrootsSecureStoreKey( + namespace: "nostr_identity", + name: "selected_secret_hex" + ) + let legacyMetadata = try loadLegacyMetadata() + do { + let migrated = try await custody.migrateLegacyIdentity( + from: legacySecretKey, + label: legacyMetadata?.label + ) + if let metadata = legacyMetadata, + metadata.publicKeyHex.lowercased() != migrated.identity?.publicKeyHex + { + throw RadrootsIdentityStoreError.corruptLegacyMetadata + } + deleteLegacyMetadata() + return Self.appIdentity(migrated) + } catch let error as RadrootsIdentityStoreError { + throw error + } catch let error as RadrootsIdentityCustodyError { + throw RadrootsIdentityStoreError.custody(error.code) + } catch { + throw RadrootsIdentityStoreError.unavailable + } + } + + func create(label: String? = nil) async throws -> RadrootsAppIdentity { + try await custody.createIdentity(label: label).appValue + } + + func importIdentity(_ text: String, label: String? = nil) async throws -> RadrootsAppIdentity { + let material = try RadrootsIdentitySecretMaterial(importText: text) + return try await custody.importIdentity(material, label: label).appValue + } + + func unlock() async throws -> RadrootsAppIdentity { + try await custody.unlockIdentity().appValue + } + + func recover() async throws -> RadrootsAppIdentity { + let snapshot = await custody.snapshot() + if snapshot.state == .absent { + let legacyKey = RadrootsSecureStoreKey( + namespace: "nostr_identity", + name: "selected_secret_hex" + ) + if try secureStore.contains(legacyKey) { + return try await migrateLegacyIdentity() + } + } + return try await custody.recover().appValue + } + + func lock() async { + await custody.lockIdentity() + } + + func signer(for identity: RadrootsAppIdentity) throws -> any RadrootsRuntimeSigner { + guard identity.state == .unlocked, + let signerHandle = identity.signerGeneration, + let publicKeyHex = identity.publicKeyHex + else { + throw RadrootsIdentityStoreError.unavailable + } + return RadrootsAppleCustodySigner( + custody: custody, + signerHandle: signerHandle, + publicKeyHex: publicKeyHex + ) + } + + private func loadLegacyMetadata() throws -> LegacyMetadata? { + let key = "field_ios.identity.public_metadata.\(servicePrefix)" + guard let data = userDefaults.data(forKey: key) else { return nil } + guard let value = try? JSONDecoder().decode(LegacyMetadata.self, from: data), + value.publicKeyHex.count == 64, + value.publicKeyHex.allSatisfy(\.isHexDigit) + else { + throw RadrootsIdentityStoreError.corruptLegacyMetadata + } + return value + } + + private func deleteLegacyMetadata() { + userDefaults.removeObject( + forKey: "field_ios.identity.public_metadata.\(servicePrefix)" + ) + } + + private static func appIdentity(_ snapshot: RadrootsIdentitySnapshot) -> RadrootsAppIdentity { + RadrootsAppIdentity( + state: snapshot.state.appValue, + identityHandle: snapshot.identity?.identityHandle, + publicKeyHex: snapshot.identity?.publicKeyHex, + label: snapshot.identity?.label, + signerGeneration: snapshot.signerHandle, + recoveryCode: snapshot.recoveryCode + ) + } +} + +private final class RadrootsAppleCustodySigner: RadrootsRuntimeSigner, @unchecked Sendable { + private let custody: RadrootsIdentityCustody + private let signerHandle: String + private let publicKeyHex: String + + init(custody: RadrootsIdentityCustody, signerHandle: String, publicKeyHex: String) { + self.custody = custody + self.signerHandle = signerHandle + self.publicKeyHex = publicKeyHex + } + + func availability() async -> RadrootsRuntimeSignerAvailability { + let snapshot = await custody.snapshot() + return switch snapshot.state { + case .unlocked where snapshot.signerHandle == signerHandle: + RadrootsRuntimeSignerAvailability.ready + case .locked: + RadrootsRuntimeSignerAvailability.locked + default: + RadrootsRuntimeSignerAvailability.unavailable + } + } + + func sign(_ request: RadrootsRuntimeSigningRequest) async -> RadrootsRuntimeSigningOutcome { + guard request.publicKeyHex == publicKeyHex, + request.digest.count == 32 + else { + return .invalidated + } + do { + let result = try await custody.sign( + RadrootsOpaqueSignRequest( + operationID: request.signerRequestID, + signerHandle: signerHandle, + publicKeyHex: request.publicKeyHex, + digest: request.digest, + purpose: request.purpose.appleValue, + deadlineUnixMilliseconds: request.deadlineUnixMilliseconds + ) + ) + return .signed(signatureHex: result.signature.map { String(format: "%02x", $0) }.joined()) + } catch let error as RadrootsIdentityCustodyError { + return switch error { + case .identityLocked, .userPresenceRequired: + RadrootsRuntimeSigningOutcome.locked + case .cancelled: + RadrootsRuntimeSigningOutcome.cancelled + case .timedOut: + RadrootsRuntimeSigningOutcome.timedOut + case .staleSigner, .invalidSignRequest, .invalidSignature: + RadrootsRuntimeSigningOutcome.invalidated + case .protectedDataUnavailable, .storageUnavailable: + RadrootsRuntimeSigningOutcome.unavailable + default: + RadrootsRuntimeSigningOutcome.failed + } + } catch { + return .failed + } + } +} + +private extension RadrootsIdentitySnapshot { + var appValue: RadrootsAppIdentity { + RadrootsAppIdentity( + state: state.appValue, + identityHandle: identity?.identityHandle, + publicKeyHex: identity?.publicKeyHex, + label: identity?.label, + signerGeneration: signerHandle, + recoveryCode: recoveryCode + ) + } +} + +private extension RadrootsIdentityState { + var appValue: RadrootsAppIdentityState { + switch self { + case .absent: .absent + case .locked: .locked + case .unlocked: .unlocked + case .protectedDataUnavailable: .protectedDataUnavailable + case .recoveryRequired: .recoveryRequired + case .corrupt: .corrupt + } + } +} + +private extension RadrootsRuntimeSigningPurpose { + var appleValue: RadrootsOpaqueSignPurpose { + switch self { + case .nostrEvent: .nostrEvent + case .blossomUpload: .blossomUpload + } + } +} diff --git a/Radroots/State/RadrootsSessionStore.swift b/Radroots/State/RadrootsSessionStore.swift @@ -0,0 +1,277 @@ +import Foundation +import RadrootsKit +import UIKit + +enum RadrootsSessionPhase: Sendable, Equatable { + case starting + case identityRequired + case identityLocked(RadrootsAppIdentity) + case protectedDataUnavailable(RadrootsAppIdentity) + case recoveryRequired(RadrootsAppIdentity) + case corruptIdentity(RadrootsAppIdentity) + case running(RadrootsRuntimeSnapshot) + case stopped + case failed(RadrootsRuntimeFailure) +} + +actor RadrootsSessionStore { + private let configurationStore: RadrootsConfigurationStore + private let identityStore: RadrootsIdentityStore + private let runtimeClient: RadrootsRuntimeClient + private let roots: RadrootsAppleFileRoots + private let protectedDataAvailable: Bool + private var generation: UInt64 = 0 + private var phase: RadrootsSessionPhase = .starting + + init( + configurationStore: RadrootsConfigurationStore, + identityStore: RadrootsIdentityStore, + runtimeClient: RadrootsRuntimeClient, + roots: RadrootsAppleFileRoots, + protectedDataAvailable: Bool + ) { + self.configurationStore = configurationStore + self.identityStore = identityStore + self.runtimeClient = runtimeClient + self.roots = roots + self.protectedDataAvailable = protectedDataAvailable + } + + @MainActor + static func production( + bundle: Bundle = .main, + runtimeClient: RadrootsRuntimeClient = .production() + ) throws -> RadrootsSessionStore { + guard let bundleIdentifier = bundle.bundleIdentifier else { + throw RadrootsConfigurationError.missing("bundle_identifier") + } + let servicePrefix = try requiredString( + "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX", + bundle: bundle + ) + let bootstrap = try RadrootsConfigurationBootstrap( + runtimeMode: requiredString("RADROOTS_FIELD_IOS_RUNTIME_MODE", bundle: bundle), + relayURLs: array("RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS", bundle: bundle), + blossomOrigins: array("RADROOTS_FIELD_IOS_BLOSSOM_ORIGINS", bundle: bundle), + keychainServicePrefix: servicePrefix, + bundleIdentifier: bundleIdentifier, + appMetadata: RadrootsRuntimeAppMetadata( + bundleIdentifier: bundleIdentifier, + version: bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0", + buildNumber: bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0", + buildSHA: normalizedOptional( + bundle.object(forInfoDictionaryKey: "GIT_SHA") as? String + ) + ) + ) + let roots = try RadrootsAppleFileRoots.appContainer(appIdentifier: bundleIdentifier) + let protectedDataAvailable = UIApplication.shared.isProtectedDataAvailable + return try RadrootsSessionStore( + configurationStore: RadrootsConfigurationStore(bootstrap: bootstrap, roots: roots), + identityStore: .production( + servicePrefix: servicePrefix, + protectedDataAvailable: protectedDataAvailable + ), + runtimeClient: runtimeClient, + roots: roots, + protectedDataAvailable: protectedDataAvailable + ) + } + + func currentPhase() -> RadrootsSessionPhase { + phase + } + + func start() async -> RadrootsSessionPhase { + generation &+= 1 + let requestedGeneration = generation + phase = .starting + do { + let configuration = try await configurationStore.load() + let identity = try await identityStore.loadAndMigrate() + guard generation == requestedGeneration else { + throw RadrootsRuntimeClientError.superseded + } + switch identity.state { + case .absent: + phase = .identityRequired + case .locked: + phase = .identityLocked(identity) + case .protectedDataUnavailable: + phase = .protectedDataUnavailable(identity) + case .recoveryRequired: + phase = .recoveryRequired(identity) + case .corrupt: + phase = .corruptIdentity(identity) + case .unlocked: + phase = try await startRuntime( + configuration: configuration, + identity: identity, + generation: requestedGeneration + ) + } + } catch RadrootsRuntimeClientError.superseded { + return phase + } catch let RadrootsRuntimeClientError.startup(failure) { + guard generation == requestedGeneration else { return phase } + phase = .failed(failure) + } catch let error as LocalizedError { + guard generation == requestedGeneration else { return phase } + phase = .failed( + .local( + operation: "session.start", + code: "ios.session.start_failed", + safeMessage: error.errorDescription ?? "Radroots could not start." + ) + ) + } catch { + guard generation == requestedGeneration else { return phase } + phase = .failed( + .local( + operation: "session.start", + code: "ios.session.start_failed", + safeMessage: "Radroots could not start." + ) + ) + } + return phase + } + + func createIdentity(label: String? = nil) async -> RadrootsSessionPhase { + do { + _ = try await identityStore.create(label: label) + } catch { + return failIdentityOperation(error) + } + return await start() + } + + func importIdentity(_ text: String, label: String? = nil) async -> RadrootsSessionPhase { + do { + _ = try await identityStore.importIdentity(text, label: label) + } catch { + return failIdentityOperation(error) + } + return await start() + } + + func unlockIdentity() async -> RadrootsSessionPhase { + do { + _ = try await identityStore.unlock() + } catch { + return failIdentityOperation(error) + } + return await start() + } + + func recoverIdentity() async -> RadrootsSessionPhase { + do { + _ = try await identityStore.recover() + } catch { + return failIdentityOperation(error) + } + return await start() + } + + func stop() async -> RadrootsSessionPhase { + generation &+= 1 + do { + _ = try await runtimeClient.stop() + await identityStore.lock() + phase = .stopped + } catch let RadrootsRuntimeClientError.shutdown(failure) { + phase = .failed(failure) + } catch { + phase = .failed( + .local( + operation: "session.stop", + code: "ios.session.stop_failed", + safeMessage: "Radroots could not finish shutting down." + ) + ) + } + return phase + } + + private func startRuntime( + configuration: RadrootsAppConfiguration, + identity: RadrootsAppIdentity, + generation requestedGeneration: UInt64 + ) async throws -> RadrootsSessionPhase { + guard let publicKeyHex = identity.publicKeyHex, + let signerGeneration = identity.signerGeneration + else { + throw RadrootsIdentityStoreError.unavailable + } + let mobileStore = try RadrootsAppleMobileStore.prepare( + roots: roots, + publicKeyHex: publicKeyHex, + protectedDataAvailability: protectedDataAvailable ? .available : .unavailable + ) + let sourceGeneration = try await configurationStore.sourceGeneration() + let signer = try await identityStore.signer(for: identity) + let snapshot = try await runtimeClient.start( + configuration: RadrootsRuntimeLaunchConfiguration( + applicationSupportDirectory: mobileStore.applicationSupportDirectory.path, + publicKeyHex: publicKeyHex, + sourceGenerationHex: sourceGeneration.generationHex, + sourceGenerationCreatedAtUnixMilliseconds: sourceGeneration.createdAtUnixMilliseconds, + protectedData: protectedDataAvailable ? .available : .unavailable, + networkProfile: configuration.profile.runtimeValue, + writableRelays: configuration.writableRelays, + blossomOrigins: configuration.blossomOrigins, + app: configuration.appMetadata, + signerGeneration: signerGeneration, + signer: signer + ) + ) + guard generation == requestedGeneration else { + _ = try? await runtimeClient.stop() + throw RadrootsRuntimeClientError.superseded + } + return .running(snapshot) + } + + private func failIdentityOperation(_ error: Error) -> RadrootsSessionPhase { + generation &+= 1 + let message = (error as? LocalizedError)?.errorDescription + ?? "The local identity operation could not be completed." + phase = .failed( + .local( + operation: "identity.operation", + code: "ios.identity.operation_failed", + safeMessage: message + ) + ) + return phase + } + + @MainActor + private static func requiredString(_ key: String, bundle: Bundle) throws -> String { + guard let value = normalizedOptional(bundle.object(forInfoDictionaryKey: key) as? String) else { + throw RadrootsConfigurationError.missing(key) + } + return value + } + + @MainActor + private static func array(_ key: String, bundle: Bundle) -> [String] { + if let values = bundle.object(forInfoDictionaryKey: key) as? [String] { + return values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + guard let raw = normalizedOptional(bundle.object(forInfoDictionaryKey: key) as? String) else { + return [] + } + return raw.components(separatedBy: CharacterSet(charactersIn: ",; \n\r\t")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + @MainActor + private static func normalizedOptional(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty || trimmed == "unknown" ? nil : trimmed + } +} diff --git a/Radroots/Views/RuntimeStatusView.swift b/Radroots/Views/RuntimeStatusView.swift @@ -3,6 +3,9 @@ import SwiftUI struct RuntimeStatusView: View { let phase: RadrootsAppModel.Phase let retry: () -> Void + let createIdentity: () -> Void + let unlockIdentity: () -> Void + let recoverIdentity: () -> Void var body: some View { NavigationStack { @@ -18,7 +21,19 @@ struct RuntimeStatusView: View { .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - if case .failed = phase { + if case .identityRequired = phase { + Button("Create identity", action: createIdentity) + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("radroots.identity.create") + } else if case .identityLocked = phase { + Button("Unlock identity", action: unlockIdentity) + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("radroots.identity.unlock") + } else if case .recoveryRequired = phase { + Button("Recover identity", action: recoverIdentity) + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("radroots.identity.recover") + } else if case .failed = phase { Button("Retry", action: retry) .buttonStyle(.borderedProminent) .accessibilityIdentifier("radroots.runtime.retry") @@ -35,6 +50,10 @@ struct RuntimeStatusView: View { switch phase { case .starting: "leaf" case .identityRequired: "person.badge.key" + case .identityLocked: "lock" + case .protectedDataUnavailable: "lock.iphone" + case .recoveryRequired: "wrench.and.screwdriver" + case .corruptIdentity: "exclamationmark.shield" case .running: "checkmark.circle" case .failed: "exclamationmark.triangle" case .stopped: "pause.circle" @@ -43,7 +62,7 @@ struct RuntimeStatusView: View { private var symbolColor: Color { switch phase { - case .failed: .red + case .failed, .corruptIdentity: .red case .running: .green default: .accentColor } @@ -53,6 +72,10 @@ struct RuntimeStatusView: View { switch phase { case .starting: "Starting Radroots" case .identityRequired: "Set up your identity" + case .identityLocked: "Unlock your identity" + case .protectedDataUnavailable: "Unlock this device" + case .recoveryRequired: "Recover your identity" + case .corruptIdentity: "Identity data needs repair" case .running: "Radroots is ready" case .failed: "Radroots needs attention" case .stopped: "Radroots is paused" @@ -65,6 +88,14 @@ struct RuntimeStatusView: View { "Preparing your local Radroots data." case .identityRequired: "Create or import an identity to connect your local food network." + case .identityLocked: + "Your local Nostr secret remains protected until you explicitly unlock it." + case .protectedDataUnavailable: + "Protected local data is unavailable while this device is locked." + case let .recoveryRequired(identity): + identity.recoveryCode ?? "A previous identity operation needs recovery." + case let .corruptIdentity(identity): + identity.recoveryCode ?? "Stored identity state is corrupt; it was not treated as absent." case let .running(snapshot): "Runtime \(snapshot.crateVersion) is connected to your local data." case let .failed(failure): diff --git a/RadrootsTests/RadrootsRuntimeClientTests.swift b/RadrootsTests/RadrootsRuntimeClientTests.swift @@ -154,7 +154,9 @@ final class RadrootsRuntimeClientTests: XCTestCase { version: "0.1.0-alpha", buildNumber: "1", buildSHA: nil - ) + ), + signerGeneration: generation, + signer: TestRuntimeSigner() ) } @@ -168,6 +170,16 @@ final class RadrootsRuntimeClientTests: XCTestCase { } } +private struct TestRuntimeSigner: RadrootsRuntimeSigner { + func availability() async -> RadrootsRuntimeSignerAvailability { + .ready + } + + func sign(_: RadrootsRuntimeSigningRequest) async -> RadrootsRuntimeSigningOutcome { + .failed + } +} + private actor RuntimeHarness { private let startDelayNanoseconds: UInt64 private let shutdownFailure: RadrootsRuntimeFailure? diff --git a/RadrootsTests/RadrootsStateMigrationTests.swift b/RadrootsTests/RadrootsStateMigrationTests.swift @@ -0,0 +1,286 @@ +import Foundation +@testable import Radroots +import RadrootsKit +import XCTest + +final class RadrootsStateMigrationTests: XCTestCase { + func testRelayValidationMatchesRustProfiles() throws { + XCTAssertEqual( + try RadrootsNetworkValidator.relays( + ["wss://radroots.org", "WSS://WRITE.EXAMPLE:443/"], + profile: .publicNetwork + ), + ["wss://write.example"] + ) + XCTAssertEqual( + try RadrootsNetworkValidator.relays( + ["ws://127.0.0.1:7447"], + profile: .simulator + ), + ["ws://127.0.0.1:7447"] + ) + XCTAssertNoThrow( + try RadrootsNetworkValidator.relays( + ["wss://10.0.0.5:7447"], + profile: .device + ) + ) + for denied in [ + "ws://public.example", + "wss://localhost", + "wss://10.0.0.1", + "wss://user@example.com", + "wss://relay.example?token=value", + ] { + XCTAssertThrowsError( + try RadrootsNetworkValidator.relays([denied], profile: .publicNetwork), + "Expected public policy to deny \(denied)" + ) + } + XCTAssertThrowsError( + try RadrootsNetworkValidator.relays( + ["wss://127.0.0.1:7447"], + profile: .device + ) + ) + } + + func testLegacyRelayMigrationIsIdempotentAndCorruptionIsNotAbsence() async throws { + let fixture = try StateFixture() + defer { fixture.remove() } + let fileAccess = RadrootsAppleFileAccess(roots: fixture.roots) + try fileAccess.write( + .inline( + Data( + """ + {"format":"radroots_field_ios_relay_settings_v1","relays":["ws://127.0.0.1:7447"]} + """.utf8 + ) + ), + to: RadrootsFileReference( + scope: .data, + relativePath: "settings/relay_settings.json" + ) + ) + let store = RadrootsConfigurationStore( + bootstrap: fixture.bootstrap, + roots: fixture.roots + ) + let first = try await store.load() + let second = try await store.load() + XCTAssertEqual(first, second) + XCTAssertEqual(first.writableRelays, ["ws://127.0.0.1:7447"]) + + try fileAccess.write( + .inline(Data("not-json".utf8)), + to: RadrootsFileReference( + scope: .data, + relativePath: "settings/radroots_configuration_v2.json" + ) + ) + do { + _ = try await store.load() + XCTFail("Corrupt stored configuration must fail closed") + } catch { + XCTAssertEqual(error as? RadrootsConfigurationError, .corruptStoredConfiguration) + } + } + + func testSourceGenerationAndVisualIdentitySurviveStoreRecreation() async throws { + let fixture = try StateFixture() + defer { fixture.remove() } + let firstStore = RadrootsConfigurationStore( + bootstrap: fixture.bootstrap, + roots: fixture.roots + ) + let first = try await firstStore.sourceGeneration() + let secondStore = RadrootsConfigurationStore( + bootstrap: fixture.bootstrap, + roots: fixture.roots + ) + let second = try await secondStore.sourceGeneration() + XCTAssertEqual(first, second) + + let key = String(repeating: "ab", count: 32) + XCTAssertEqual( + RadrootsStableVisualIdentity(publicKeyHex: key), + RadrootsStableVisualIdentity(publicKeyHex: key) + ) + XCTAssertNotEqual( + RadrootsStableVisualIdentity(publicKeyHex: key).digestHex, + RadrootsStableVisualIdentity(publicKeyHex: String(repeating: "cd", count: 32)).digestHex + ) + } + + func testLegacyIdentityMigrationIsTransactionalAndIdempotent() async throws { + let secureStore = InMemorySecureStore() + let metadataStore = InMemoryIdentityMetadataStore() + let servicePrefix = "org.radroots.tests.identity.\(UUID().uuidString.lowercased())" + let defaults = try XCTUnwrap(UserDefaults(suiteName: servicePrefix)) + defer { + UserDefaults(suiteName: servicePrefix)?.removePersistentDomain(forName: servicePrefix) + } + let legacyKey = RadrootsSecureStoreKey( + namespace: "nostr_identity", + name: "selected_secret_hex" + ) + try secureStore.put( + Data(String(repeating: "01", count: 32).utf8), + for: legacyKey, + policy: .secureLocalSecret + ) + let custody = try RadrootsIdentityCustody( + configuration: RadrootsIdentityCustodyConfiguration( + namespace: "radroots_identity_v1", + secretPolicy: .secureLocalSecret + ), + secureStore: secureStore, + metadataStore: metadataStore, + userPresence: AllowingUserPresence() + ) + let store = RadrootsIdentityStore( + custody: custody, + secureStore: secureStore, + servicePrefix: servicePrefix, + userDefaults: defaults + ) + let first = try await store.loadAndMigrate() + XCTAssertEqual(first.state, .recoveryRequired) + XCTAssertTrue(try secureStore.contains(legacyKey)) + let migrated = try await store.recover() + let second = try await store.loadAndMigrate() + XCTAssertEqual(migrated.publicKeyHex, second.publicKeyHex) + XCTAssertEqual(migrated.state, .unlocked) + XCTAssertFalse(try secureStore.contains(legacyKey)) + } + + func testMalformedLegacyIdentityMetadataIsNotTreatedAsMissing() async throws { + let secureStore = InMemorySecureStore() + let servicePrefix = "org.radroots.tests.corrupt.\(UUID().uuidString.lowercased())" + let defaults = try XCTUnwrap(UserDefaults(suiteName: servicePrefix)) + defer { + UserDefaults(suiteName: servicePrefix)?.removePersistentDomain(forName: servicePrefix) + } + defaults.set( + Data("not-json".utf8), + forKey: "field_ios.identity.public_metadata.\(servicePrefix)" + ) + let custody = try RadrootsIdentityCustody( + configuration: RadrootsIdentityCustodyConfiguration( + namespace: "radroots_identity_v1", + secretPolicy: .secureLocalSecret + ), + secureStore: secureStore, + metadataStore: InMemoryIdentityMetadataStore(), + userPresence: AllowingUserPresence() + ) + let store = RadrootsIdentityStore( + custody: custody, + secureStore: secureStore, + servicePrefix: servicePrefix, + userDefaults: defaults + ) + do { + _ = try await store.loadAndMigrate() + XCTFail("Malformed legacy metadata must be classified as corrupt") + } catch { + XCTAssertEqual(error as? RadrootsIdentityStoreError, .corruptLegacyMetadata) + } + } +} + +private struct StateFixture { + let root: URL + let roots: RadrootsAppleFileRoots + let bootstrap: RadrootsConfigurationBootstrap + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("radroots-state-tests-\(UUID().uuidString.lowercased())") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + roots = try RadrootsAppleFileRoots( + appIdentifier: "org.radroots.tests", + dataRoot: root.appendingPathComponent("data"), + cacheRoot: root.appendingPathComponent("cache"), + temporaryRoot: root.appendingPathComponent("tmp") + ) + bootstrap = RadrootsConfigurationBootstrap( + runtimeMode: "localhost-dev", + relayURLs: ["ws://127.0.0.1:8080"], + blossomOrigins: ["http://127.0.0.1:3000"], + keychainServicePrefix: "org.radroots.tests", + bundleIdentifier: "org.radroots.tests", + appMetadata: RadrootsRuntimeAppMetadata( + bundleIdentifier: "org.radroots.tests", + version: "0.1.0-alpha", + buildNumber: "1", + buildSHA: nil + ) + ) + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } +} + +private final class InMemorySecureStore: RadrootsSecureStore, @unchecked Sendable { + private let lock = NSLock() + private var values: [RadrootsSecureStoreKey: Data] = [:] + + func put( + _ value: Data, + for key: RadrootsSecureStoreKey, + policy _: RadrootsSecretAccessPolicy + ) throws { + lock.withLock { values[key] = value } + } + + func contains(_ key: RadrootsSecureStoreKey) throws -> Bool { + lock.withLock { values[key] != nil } + } + + func get(_ key: RadrootsSecureStoreKey) throws -> Data? { + lock.withLock { values[key] } + } + + func delete(_ key: RadrootsSecureStoreKey) throws { + lock.withLock { _ = values.removeValue(forKey: key) } + } + + func deleteNamespace(_ namespace: String) throws { + lock.withLock { values = values.filter { $0.key.namespace != namespace } } + } +} + +private final class InMemoryIdentityMetadataStore: RadrootsIdentityMetadataStore, @unchecked Sendable { + private let lock = NSLock() + private var values: [RadrootsIdentityMetadataSlot: Data] = [:] + + func data(for slot: RadrootsIdentityMetadataSlot) throws -> Data? { + lock.withLock { values[slot] } + } + + func put(_ data: Data, for slot: RadrootsIdentityMetadataSlot) throws { + lock.withLock { values[slot] = data } + } + + func delete(_ slot: RadrootsIdentityMetadataSlot) throws { + lock.withLock { _ = values.removeValue(forKey: slot) } + } +} + +private struct AllowingUserPresence: RadrootsUserPresence { + func currentStatus() async throws -> RadrootsUserPresenceStatus { + RadrootsUserPresenceStatus( + support: .deviceCredential, + biometryKind: .none, + canEvaluateDeviceCredential: true, + canEvaluateBiometrics: false + ) + } + + func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult { + RadrootsUserPresenceResult(policy: request.policy, verified: true) + } +} diff --git a/project.yml b/project.yml @@ -42,6 +42,9 @@ targets: - path: Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift - path: Radroots/Runtime/RadrootsRuntimeClient.swift - path: Radroots/Runtime/RadrootsRuntimeModels.swift + - path: Radroots/State/RadrootsConfigurationStore.swift + - path: Radroots/State/RadrootsIdentityStore.swift + - path: Radroots/State/RadrootsSessionStore.swift - path: Radroots/Views/RuntimeStatusView.swift - path: Radroots/Resources settings: @@ -68,6 +71,7 @@ targets: deploymentTarget: "18.0" sources: - path: RadrootsTests/RadrootsRuntimeClientTests.swift + - path: RadrootsTests/RadrootsStateMigrationTests.swift settings: base: "EXCLUDED_ARCHS[sdk=iphonesimulator*]": x86_64