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:
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() }
}