field_ios

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

commit 31e470c385a3dfd5fc697324f23a5a5c14650dd3
parent 44ea2b2d22d3bf1b4a982adbb796fef10a68464f
Author: triesap <tyson@radroots.org>
Date:   Fri, 19 Jun 2026 16:02:18 -0700

identity: preserve stored key on failed import

- stage imported Nostr identities before selecting or persisting them.
- roll secure storage back when final runtime selection fails.
- add a debug import-failure probe without exposing secret material.
- keep the Swift app Nostr-only while hardening identity custody.

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 4++++
MRadroots/App/AppEntry.swift | 7+++++++
MRadroots/App/AppState.swift | 7+++++++
ARadroots/Runtime/FieldIdentityImportFailureUITestProbe.swift | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/Runtime/FieldSecureIdentityStore.swift | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
5 files changed, 172 insertions(+), 5 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ 8F6D0970610DF68816DE1A98 /* Radroots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F0F21496E7A8490EB14AC5B /* Radroots.swift */; }; 9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C7DE4207398DE242519F9C /* CopyButton.swift */; }; 9346DA48630668A65D37E14A /* FieldTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B4E53FD4C4AADF63855888A /* FieldTelemetry.swift */; }; + 98FD8B614B481333C3F7708E /* FieldIdentityImportFailureUITestProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 481E7791F4F6CF82FA3117C1 /* FieldIdentityImportFailureUITestProbe.swift */; }; A1B921027DA7ACD7343BE250 /* SectionWideButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17CA8F5611075F60F214A00 /* SectionWideButton.swift */; }; A54E244A554EC6B46DF8DE48 /* RadrootsAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19D317BC9F759709098490DD /* RadrootsAppDelegate.swift */; }; ABBA5CC10933CA087E14A0E8 /* FieldBackgroundExecution.swift in Sources */ = {isa = PBXBuildFile; fileRef = C614F2A8813E63C85261F492 /* FieldBackgroundExecution.swift */; }; @@ -80,6 +81,7 @@ 3E6187FA7C4786EC662718B2 /* FieldExternalActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldExternalActions.swift; sourceTree = "<group>"; }; 41A4289F43625DD65E6C4B25 /* CopyRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyRow.swift; sourceTree = "<group>"; }; 466BFA2F60BE3113EDD1BA3B /* TradeOrderRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeOrderRequestView.swift; sourceTree = "<group>"; }; + 481E7791F4F6CF82FA3117C1 /* FieldIdentityImportFailureUITestProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldIdentityImportFailureUITestProbe.swift; sourceTree = "<group>"; }; 491D57E540BAB04F24619737 /* FieldFileAccessUITestProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldFileAccessUITestProbe.swift; sourceTree = "<group>"; }; 4BC4B7D0BB4C6D8E4B0AA4AD /* radroots.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.xcconfig; sourceTree = "<group>"; }; 4E150C6C18B2A06F2F3227C6 /* FieldIdentityPolicyUITestProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldIdentityPolicyUITestProbe.swift; sourceTree = "<group>"; }; @@ -211,6 +213,7 @@ EF05B944D348DAA4EF28B463 /* FieldDocumentInterchangeUITestProbe.swift */, 3E6187FA7C4786EC662718B2 /* FieldExternalActions.swift */, 491D57E540BAB04F24619737 /* FieldFileAccessUITestProbe.swift */, + 481E7791F4F6CF82FA3117C1 /* FieldIdentityImportFailureUITestProbe.swift */, 4E150C6C18B2A06F2F3227C6 /* FieldIdentityPolicyUITestProbe.swift */, CA942715DB13FFD494FD35A0 /* FieldIdentityPublicMetadataStore.swift */, 9EB0A2CFCBBFA9D204C6992B /* FieldLocalState.swift */, @@ -451,6 +454,7 @@ E432FD39ECC8F03764EEED81 /* FieldDocumentInterchangeUITestProbe.swift in Sources */, 82903551F5E15FBDAC388D20 /* FieldExternalActions.swift in Sources */, 7E650F6DA30931E310F842E2 /* FieldFileAccessUITestProbe.swift in Sources */, + 98FD8B614B481333C3F7708E /* FieldIdentityImportFailureUITestProbe.swift in Sources */, 5FB3E3E450EE7DFF30F3A005 /* FieldIdentityPolicyUITestProbe.swift in Sources */, D4CFDE54747B6D6957977025 /* FieldIdentityPublicMetadataStore.swift in Sources */, 6E15D30653861F26AC45B501 /* FieldLocalState.swift in Sources */, diff --git a/Radroots/App/AppEntry.swift b/Radroots/App/AppEntry.swift @@ -51,6 +51,13 @@ public struct AppEntry<Main: View>: View { .accessibilityIdentifier("field_ios.identity_policy.probe") .accessibilityValue(probeValue) } + if let probeValue = appState.identityImportFailureProbeValue { + Color.clear + .frame(width: 1, height: 1) + .accessibilityElement() + .accessibilityIdentifier("field_ios.identity_import_failure.probe") + .accessibilityValue(probeValue) + } if let probeValue = appState.telemetryProbeValue { Color.clear .frame(width: 1, height: 1) diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -46,6 +46,7 @@ public final class AppState: ObservableObject { @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? @@ -152,6 +153,12 @@ public final class AppState: ObservableObject { 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) } diff --git a/Radroots/Runtime/FieldIdentityImportFailureUITestProbe.swift b/Radroots/Runtime/FieldIdentityImportFailureUITestProbe.swift @@ -0,0 +1,56 @@ +#if DEBUG +import Foundation + +enum FieldIdentityImportFailureUITestProbe { + private static let enabledKey = "RADROOTS_FIELD_IOS_UI_TEST_IDENTITY_IMPORT_FAILURE_PROBE" + private static let candidateSecretKey = "RADROOTS_FIELD_IOS_UI_TEST_IDENTITY_IMPORT_FAILURE_SECRET" + private static let defaultCandidateSecret = "0000000000000000000000000000000000000000000000000000000000000002" + + static var isRequested: Bool { + FieldUITestHarness.string(enabledKey) == "true" + } + + static func value( + secureStore: FieldSecureIdentityStore, + service: FieldRuntimeService + ) async -> String? { + guard isRequested else { + return nil + } + let previousSecret = try? secureStore.loadSelectedSecretHex() + let candidateSecret = FieldUITestHarness.string(candidateSecretKey) ?? defaultCandidateSecret + var importFailed = false + var errorContainsForcedRestore = false + + do { + _ = try await secureStore.importSecret( + candidateSecret, + label: "UI Test Import Failure", + using: service + ) + } catch { + importFailed = true + errorContainsForcedRestore = error.localizedDescription.contains("Forced identity import restore failure") + } + + let selectedSecretAfterImport = try? secureStore.loadSelectedSecretHex() + var runtimePreviousRestored = false + if previousSecret != nil, + let restored = try? await secureStore.restoreStoredIdentity( + label: "Radroots Field", + using: service + ) { + runtimePreviousRestored = restored.isSelected + } + + return [ + "previous_secret_present=\(previousSecret != nil)", + "import_failed=\(importFailed)", + "error_contains_forced_restore=\(errorContainsForcedRestore)", + "selected_secret_preserved=\(selectedSecretAfterImport == previousSecret)", + "candidate_secret_selected=\(selectedSecretAfterImport == candidateSecret)", + "runtime_previous_restored=\(runtimePreviousRestored)" + ].joined(separator: ";") + } +} +#endif diff --git a/Radroots/Runtime/FieldSecureIdentityStore.swift b/Radroots/Runtime/FieldSecureIdentityStore.swift @@ -10,6 +10,7 @@ enum FieldSecureIdentityStoreError: LocalizedError { case missingSecureStoreAccessPolicy case invalidSecureStoreAccessPolicy(String) case randomSecretGenerationFailed(Int32) + case forcedImportRestoreFailure var errorDescription: String? { switch self { @@ -27,6 +28,8 @@ enum FieldSecureIdentityStoreError: LocalizedError { "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." } } } @@ -100,16 +103,32 @@ struct FieldSecureIdentityStore { using service: FieldRuntimeService ) async throws -> NostrIdentityRecord { let trimmed = try normalizedSecret(secret) + let previousSecret = try loadSelectedSecretHex() _ = try await service.nostrIdentityValidateHostCustodySecret(secretKey: trimmed) - try saveSelectedSecret(trimmed) + let stagedRecord = try await restoreHostCustodySecret( + trimmed, + label: label, + makeSelected: false, + using: service + ) do { - return try await service.nostrIdentityRestoreHostCustodySecret( - secretKey: trimmed, + 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 + makeSelected: true, + using: service ) } catch { - try? deleteSelectedSecret() + try? restorePreviousSelectedSecret(previousSecret) + await restorePreviousRuntimeIdentity(previousSecret, using: service) + await removeStagedIdentityIfNeeded(stagedRecord, previousSecret: previousSecret, using: service) throw error } } @@ -166,6 +185,57 @@ struct FieldSecureIdentityStore { ) } + 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 { @@ -178,3 +248,26 @@ struct FieldSecureIdentityStore { 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