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:
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