field_ios

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

commit da64b73eb5af90f07efe9cdb028425767c5d07e0
parent f389c69a922cf47ce41de9d7db9c9f6ef64390d0
Author: triesap <tyson@radroots.org>
Date:   Fri, 12 Jun 2026 00:05:46 -0700

app: persist nostr identity in keychain

- add field-specific Nostr identity custody over RadrootsKit secure storage

- restore the selected identity into field_lib on launch

- make reset-local-state clear keychain custody and FFI identity state

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 4++++
MRadroots/App/AppState.swift | 53+++++++++++++++++++++++++++++++++++++++++++++++++----
ARadroots/Runtime/FieldIdentityCustodyStore.swift | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/Runtime/FieldRuntimeService.swift | 8++++++++
4 files changed, 152 insertions(+), 4 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 360F23EFE80FDBDC6983FB15 /* AppEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D448C9655B708CA3FA8712B9 /* AppEntry.swift */; }; 3A7FA9E5BCC7590B2EAC5349 /* RelaySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FBB081610305940C7849C7C /* RelaySettings.swift */; }; 3B6020E24A2DAD8ADFC2F155 /* BuildConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71EFADBC7D54AF5B9314773 /* BuildConfig.swift */; }; + 3DA8DBB426B71C53724F09B4 /* FieldIdentityCustodyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60FA44F589B6E94223CE685 /* FieldIdentityCustodyStore.swift */; }; 4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7F9BFC3C8CE2F86FB7DB74B /* DebugDump.swift */; }; 4B44B723FF06ECC363A486BA /* TradeListingDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BAE32CD1D46033DDA1A5BB /* TradeListingDetailView.swift */; }; 505A5731ACDBBB0296134340 /* TradeListingCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947ADA6D32E42ED2B40A5351 /* TradeListingCreateView.swift */; }; @@ -95,6 +96,7 @@ F21554DA87EEC1E5C5F38365 /* PostFeedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFeedViewModel.swift; sourceTree = "<group>"; }; F3C0EFACAD213A69C12D5064 /* PostDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostDetailView.swift; sourceTree = "<group>"; }; F4C7DE4207398DE242519F9C /* CopyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyButton.swift; sourceTree = "<group>"; }; + F60FA44F589B6E94223CE685 /* FieldIdentityCustodyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldIdentityCustodyStore.swift; sourceTree = "<group>"; }; F6D224B525028DE5D8C8E28D /* TradeSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeSettings.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ @@ -170,6 +172,7 @@ isa = PBXGroup; children = ( A71EFADBC7D54AF5B9314773 /* BuildConfig.swift */, + F60FA44F589B6E94223CE685 /* FieldIdentityCustodyStore.swift */, E883FDB7004A210C9D7BE27A /* FieldRuntimeService.swift */, D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */, 63189EB90A86A9929BECD9ED /* Nostr.swift */, @@ -404,6 +407,7 @@ 9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */, D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */, 4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */, + 3DA8DBB426B71C53724F09B4 /* FieldIdentityCustodyStore.swift in Sources */, D25C1E1DC99F5CF8E99AE970 /* FieldRuntimeService.swift in Sources */, 1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */, C512BD267A3E2B8F10FABB3B /* Logger.swift in Sources */, diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -63,6 +63,7 @@ public final class AppState: ObservableObject { private let lockKey = "field_ios.identity_locked" private var statusTask: Task<Void, Never>? + private var identityCustodyStore: FieldIdentityCustodyStore? public init(radroots: Radroots = Radroots()) { self.radroots = radroots @@ -78,9 +79,14 @@ public final class AppState: ObservableObject { bootstrapPhase = .starting do { let service = try radroots.start() + let custodyStore = try FieldIdentityCustodyStore.configured() + identityCustodyStore = custodyStore if BuildConfig.bool(.resetLocalState) == true { + try custodyStore.resetLocalState(bundleIdentifier: try bundleIdentifier()) try await removeAllIdentities(using: service) setLocked(false) + } else { + try await restorePersistedIdentity(using: service, custodyStore: custodyStore) } try await configureRelays(using: service) try await refreshRuntimeState(using: service) @@ -113,6 +119,7 @@ public final class AppState: ObservableObject { public func continueWithLocalIdentity() async throws { let service = try requireRuntimeService() + try await persistSelectedIdentity(using: service) setLocked(false) try await connect(using: service) await refreshRuntimeState(using: service) @@ -122,6 +129,7 @@ public final class AppState: ObservableObject { public func createLocalIdentity() async throws { let service = try requireRuntimeService() _ = try await service.nostrIdentityGenerate(label: "Radroots Field", makeSelected: true) + try await persistSelectedIdentity(using: service) setLocked(false) try await connect(using: service) await refreshRuntimeState(using: service) @@ -137,6 +145,7 @@ public final class AppState: ObservableObject { label: "Imported Field Identity", makeSelected: true ) + try await persistSelectedIdentity(using: service) setLocked(false) try await connect(using: service) await refreshRuntimeState(using: service) @@ -151,6 +160,7 @@ public final class AppState: ObservableObject { public func resetLocalIdentity() async throws { let service = try requireRuntimeService() + try identityCustodyStoreOrConfigured().resetLocalState(bundleIdentifier: try bundleIdentifier()) try await removeAllIdentities(using: service) setLocked(false) relayConnectedCount = 0 @@ -226,16 +236,51 @@ public final class AppState: ObservableObject { } private func removeAllIdentities(using service: FieldRuntimeService) async throws { - let existing = try await service.nostrIdentityList() - for identity in existing { - try await service.nostrIdentityRemove(identityId: identity.id) - } + try await service.nostrIdentityResetAll() hasKey = false npub = nil identityLabel = nil identities = [] } + private func restorePersistedIdentity( + using service: FieldRuntimeService, + custodyStore: FieldIdentityCustodyStore + ) async throws { + let snapshot = try await service.nostrIdentitySnapshot() + guard !snapshot.hasSelectedSigningIdentity else { return } + guard let secretHex = try custodyStore.loadSelectedSecretHex() else { return } + _ = try await service.nostrIdentityImportSecret( + secretKey: secretHex, + label: "Radroots Field", + makeSelected: true + ) + } + + private func persistSelectedIdentity(using service: FieldRuntimeService) async throws { + guard let secretHex = try await service.nostrIdentityExportSelectedSecretHex() else { + throw FieldIdentityCustodyError.missingSelectedSecret + } + try identityCustodyStoreOrConfigured().saveSelectedSecretHex(secretHex) + } + + private func identityCustodyStoreOrConfigured() throws -> FieldIdentityCustodyStore { + if let identityCustodyStore { + return identityCustodyStore + } + let configured = try FieldIdentityCustodyStore.configured() + identityCustodyStore = configured + return configured + } + + private func bundleIdentifier() throws -> String { + guard let bundleIdentifier = Bundle.main.bundleIdentifier, + !bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw FieldIdentityCustodyError.missingBundleIdentifier + } + return bundleIdentifier + } + private func setLocked(_ value: Bool) { isLocked = value UserDefaults.standard.set(value, forKey: lockKey) diff --git a/Radroots/Runtime/FieldIdentityCustodyStore.swift b/Radroots/Runtime/FieldIdentityCustodyStore.swift @@ -0,0 +1,91 @@ +import Foundation +import RadrootsKit + +enum FieldIdentityCustodyError: LocalizedError { + case missingKeychainServicePrefix + case missingBundleIdentifier + case invalidStoredSecret + case missingSelectedSecret + + var errorDescription: String? { + switch self { + case .missingKeychainServicePrefix: + "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 to secure." + } + } +} + +struct FieldIdentityCustodyStore { + 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 -> FieldIdentityCustodyStore { + guard let servicePrefix = BuildConfig.string(.keychainServicePrefix) else { + throw FieldIdentityCustodyError.missingKeychainServicePrefix + } + return FieldIdentityCustodyStore(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 FieldIdentityCustodyError.invalidStoredSecret + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + func saveSelectedSecretHex(_ secretHex: String) throws { + let trimmed = secretHex.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw FieldIdentityCustodyError.missingSelectedSecret + } + try store.put(Data(trimmed.utf8), for: Self.selectedSecretKey, policy: .secureLocalSecret) + } + + func deleteSelectedSecret() throws { + try store.delete(Self.selectedSecretKey) + } + + func resetLocalState(bundleIdentifier: String) throws { + let trimmedBundleIdentifier = bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedBundleIdentifier.isEmpty else { + throw FieldIdentityCustodyError.missingBundleIdentifier + } + try RadrootsAppLocalStateReset.reset( + RadrootsAppLocalStateResetRequest( + appIdentifier: trimmedBundleIdentifier, + keychainServiceNames: [try Self.keychainServiceName(servicePrefix: servicePrefix)] + ) + ) + } + + static func keychainServiceName(servicePrefix: String) throws -> String { + try selectedSecretKey.serviceName(servicePrefix: servicePrefix) + } + + private static var selectedSecretKey: RadrootsSecureStoreKey { + RadrootsSecureStoreKey(namespace: namespace, name: selectedSecretName) + } +} diff --git a/Radroots/Runtime/FieldRuntimeService.swift b/Radroots/Runtime/FieldRuntimeService.swift @@ -74,6 +74,14 @@ public final class FieldRuntimeService: @unchecked Sendable { try await run { try $0.nostrIdentityRemove(identityId: identityId) } } + public func nostrIdentityResetAll() async throws { + try await run { try $0.nostrIdentityResetAll() } + } + + public func nostrIdentityExportSelectedSecretHex() async throws -> String? { + try await run { try $0.nostrIdentityExportSelectedSecretHex() } + } + public func nostrProfileForSelf() async -> NostrProfileEventMetadata? { await runValue { $0.nostrProfileForSelf() } }