field_ios

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

commit ef2fb7e6f3f70be6530edfbe4d172f404914dc66
parent e1ce6d1f43d910aef6960c94af0052d618b958a6
Author: triesap <tyson@radroots.org>
Date:   Sat, 13 Jun 2026 15:17:56 -0700

app: route identity through secure store

- replace custody-store naming with secure identity storage
- call host-custody FFI methods for validation and restore
- separate durable secret storage from public metadata updates
- lock and reset transient runtime identity through explicit APIs

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 8++++----
MRadroots/App/AppState.swift | 82++++++++++++++++++++++++++++++++++---------------------------------------------
DRadroots/Runtime/FieldIdentityCustodyStore.swift | 138-------------------------------------------------------------------------------
MRadroots/Runtime/FieldIdentityPublicMetadataStore.swift | 2+-
MRadroots/Runtime/FieldRuntimeService.swift | 30++++++++----------------------
ARadroots/Runtime/FieldSecureIdentityStore.swift | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 241 insertions(+), 212 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -19,7 +19,6 @@ 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 */; }; @@ -41,6 +40,7 @@ D4CFDE54747B6D6957977025 /* FieldIdentityPublicMetadataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA942715DB13FFD494FD35A0 /* FieldIdentityPublicMetadataStore.swift */; }; D5C58A98C950D45AD027962A /* TradeListing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D69D200DB1F5FA7AA561CD7 /* TradeListing.swift */; }; D62E9461833A0AA5E622A1E6 /* ToastModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 227028B4EBDC6703999FB9DA /* ToastModifier.swift */; }; + D9BF5BE7E4AB5EACBF342539 /* FieldSecureIdentityStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8246B707FA9D218414EC4038 /* FieldSecureIdentityStore.swift */; }; DCE468F668A3C346E716B04C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CCF0F7B3C57D8D770F178329 /* Assets.xcassets */; }; E1EDAEE6B182025ACAF754A6 /* RadrootsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15DBA726450712D6DE88E951 /* RadrootsProvider.swift */; }; E3864E34D67BAD0744B93180 /* Bundle+Build.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA7BAA021EE13E829390B /* Bundle+Build.swift */; }; @@ -71,6 +71,7 @@ 7BCA99336E305EC789152DDE /* radroots.local.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.local.xcconfig; sourceTree = "<group>"; }; 7C294E8EF50F5E1E73F5C135 /* Common.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Common.xcconfig; sourceTree = "<group>"; }; 7D69D200DB1F5FA7AA561CD7 /* TradeListing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeListing.swift; sourceTree = "<group>"; }; + 8246B707FA9D218414EC4038 /* FieldSecureIdentityStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldSecureIdentityStore.swift; sourceTree = "<group>"; }; 8F0F21496E7A8490EB14AC5B /* Radroots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Radroots.swift; sourceTree = "<group>"; }; 93AA285819DD1269C3EAD80A /* Radroots.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Radroots.app; sourceTree = BUILT_PRODUCTS_DIR; }; 93D729E070C32490545FA837 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; }; @@ -97,7 +98,6 @@ 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 */ @@ -173,9 +173,9 @@ isa = PBXGroup; children = ( A71EFADBC7D54AF5B9314773 /* BuildConfig.swift */, - F60FA44F589B6E94223CE685 /* FieldIdentityCustodyStore.swift */, CA942715DB13FFD494FD35A0 /* FieldIdentityPublicMetadataStore.swift */, E883FDB7004A210C9D7BE27A /* FieldRuntimeService.swift */, + 8246B707FA9D218414EC4038 /* FieldSecureIdentityStore.swift */, D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */, 63189EB90A86A9929BECD9ED /* Nostr.swift */, 8F0F21496E7A8490EB14AC5B /* Radroots.swift */, @@ -400,9 +400,9 @@ 9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */, D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */, 4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */, - 3DA8DBB426B71C53724F09B4 /* FieldIdentityCustodyStore.swift in Sources */, D4CFDE54747B6D6957977025 /* FieldIdentityPublicMetadataStore.swift in Sources */, D25C1E1DC99F5CF8E99AE970 /* FieldRuntimeService.swift in Sources */, + D9BF5BE7E4AB5EACBF342539 /* FieldSecureIdentityStore.swift in Sources */, 1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */, C512BD267A3E2B8F10FABB3B /* Logger.swift in Sources */, B8A3BBDE3A1FC0248512BF76 /* LoggingSettings.swift in Sources */, diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -68,7 +68,7 @@ public final class AppState: ObservableObject { private let lockKey = "field_ios.identity_locked" private var statusTask: Task<Void, Never>? - private var identityCustodyStore: FieldIdentityCustodyStore? + private var secureIdentityStore: FieldSecureIdentityStore? private var identityMetadataStore: FieldIdentityPublicMetadataStore? public init(radroots: Radroots = Radroots()) { @@ -89,14 +89,14 @@ public final class AppState: ObservableObject { throw FieldAppRuntimeError.forcedStartupFailure } let service = try radroots.start() - let custodyStore = try FieldIdentityCustodyStore.configured() + let secureStore = try FieldSecureIdentityStore.configured() let metadataStore = try FieldIdentityPublicMetadataStore.configured() - identityCustodyStore = custodyStore + secureIdentityStore = secureStore identityMetadataStore = metadataStore if BuildConfig.bool(.resetLocalState) == true { - try custodyStore.resetLocalState(bundleIdentifier: try bundleIdentifier()) + try secureStore.resetLocalState(bundleIdentifier: try bundleIdentifier()) metadataStore.delete() - try await clearRuntimeIdentityState(using: service) + try await resetRuntimeIdentityState(using: service) applyNoIdentity() setLocked(false) } else { @@ -152,12 +152,12 @@ public final class AppState: ObservableObject { let trimmed = secretKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } let service = try requireRuntimeService() - let record = try await service.nostrIdentityImportSecret( - secretKey: trimmed, + let record = try await secureIdentityStoreOrConfigured().importSecret( + trimmed, label: "Imported Field Identity", - makeSelected: true + using: service ) - try persistIdentity(record, secret: trimmed) + try persistIdentity(record) setLocked(false) try await connect(using: service) await refreshRuntimeState(using: service) @@ -178,9 +178,9 @@ public final class AppState: ObservableObject { public func resetLocalIdentity() async throws { let service = try requireRuntimeService() - try identityCustodyStoreOrConfigured().resetLocalState(bundleIdentifier: try bundleIdentifier()) + try secureIdentityStoreOrConfigured().resetLocalState(bundleIdentifier: try bundleIdentifier()) try identityMetadataStoreOrConfigured().delete() - try await clearRuntimeIdentityState(using: service) + try await resetRuntimeIdentityState(using: service) applyNoIdentity() setLocked(false) relayConnectedCount = 0 @@ -303,8 +303,14 @@ public final class AppState: ObservableObject { } } - private func clearRuntimeIdentityState(using service: FieldRuntimeService) async throws { - try await service.nostrIdentityClearRuntimeState() + 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 = [] } @@ -320,41 +326,23 @@ public final class AppState: ObservableObject { } private func restoreStoredIdentity(using service: FieldRuntimeService) async throws { - guard let secret = try identityCustodyStoreOrConfigured().loadSelectedSecretHex() else { - throw FieldIdentityCustodyError.missingSelectedSecret - } let existingMetadata = try identityMetadataStoreOrConfigured().load() - let record = try await service.nostrIdentityRestoreHostSecret( - secretKey: secret, + let record = try await secureIdentityStoreOrConfigured().restoreStoredIdentity( label: existingMetadata?.label ?? "Radroots Field", - makeSelected: true + using: service ) - try persistIdentity(record, secret: secret) + try persistIdentity(record) } private func createHostCustodyIdentity(using service: FieldRuntimeService) async throws { - var lastError: Error? - for _ in 0..<8 { - let secret = try FieldIdentityCustodyStore.generateSecretHex() - let record: NostrIdentityRecord - do { - record = try await service.nostrIdentityRestoreHostSecret( - secretKey: secret, - label: "Radroots Field", - makeSelected: true - ) - } catch { - lastError = error - continue - } - try persistIdentity(record, secret: secret) - return - } - throw lastError ?? FieldIdentityCustodyError.missingSelectedSecret + let record = try await secureIdentityStoreOrConfigured().createIdentity( + label: "Radroots Field", + using: service + ) + try persistIdentity(record) } - private func persistIdentity(_ record: NostrIdentityRecord, secret: String) throws { - try identityCustodyStoreOrConfigured().saveSelectedSecret(secret) + private func persistIdentity(_ record: NostrIdentityRecord) throws { let metadata = FieldIdentityPublicMetadata(record: record) try identityMetadataStoreOrConfigured().save(metadata) apply(storedIdentity: metadata) @@ -371,7 +359,7 @@ public final class AppState: ObservableObject { return } do { - try await clearRuntimeIdentityState(using: service) + try await lockRuntimeIdentityState(using: service) } catch { relayLastError = error.localizedDescription } @@ -395,12 +383,12 @@ public final class AppState: ObservableObject { identities = [] } - private func identityCustodyStoreOrConfigured() throws -> FieldIdentityCustodyStore { - if let identityCustodyStore { - return identityCustodyStore + private func secureIdentityStoreOrConfigured() throws -> FieldSecureIdentityStore { + if let secureIdentityStore { + return secureIdentityStore } - let configured = try FieldIdentityCustodyStore.configured() - identityCustodyStore = configured + let configured = try FieldSecureIdentityStore.configured() + secureIdentityStore = configured return configured } @@ -416,7 +404,7 @@ public final class AppState: ObservableObject { private func bundleIdentifier() throws -> String { guard let bundleIdentifier = Bundle.main.bundleIdentifier, !bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw FieldIdentityCustodyError.missingBundleIdentifier + throw FieldSecureIdentityStoreError.missingBundleIdentifier } return bundleIdentifier } diff --git a/Radroots/Runtime/FieldIdentityCustodyStore.swift b/Radroots/Runtime/FieldIdentityCustodyStore.swift @@ -1,138 +0,0 @@ -import Foundation -import RadrootsKit -import Security - -enum FieldIdentityCustodyError: LocalizedError { - case missingKeychainServicePrefix - case missingBundleIdentifier - case invalidStoredSecret - case missingSelectedSecret - case missingKeychainAccessPolicy - case invalidKeychainAccessPolicy(String) - case randomSecretGenerationFailed(Int32) - - 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." - case .missingKeychainAccessPolicy: - "Missing RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY." - case .invalidKeychainAccessPolicy(let value): - "Invalid RADROOTS_FIELD_IOS_KEYCHAIN_ACCESS_POLICY: \(value)." - case .randomSecretGenerationFailed(let status): - "Secure Nostr identity generation failed with status \(status)." - } - } -} - -enum FieldIdentityKeychainAccessPolicy: String { - case userPresenceLocal = "user_presence_local" - case secureLocal = "secure_local" - - var storePolicy: RadrootsSecretAccessPolicy { - switch self { - case .userPresenceLocal: - .userPresenceLocalSecret - case .secureLocal: - .secureLocalSecret - } - } -} - -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 saveSelectedSecret(_ secret: String) throws { - let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - throw FieldIdentityCustodyError.missingSelectedSecret - } - try store.put( - Data(trimmed.utf8), - for: Self.selectedSecretKey, - policy: try Self.configuredAccessPolicy().storePolicy - ) - } - - 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) - } - - static func configuredAccessPolicy() throws -> FieldIdentityKeychainAccessPolicy { - guard let rawValue = BuildConfig.string(.keychainAccessPolicy) else { - throw FieldIdentityCustodyError.missingKeychainAccessPolicy - } - guard let policy = FieldIdentityKeychainAccessPolicy(rawValue: rawValue) else { - throw FieldIdentityCustodyError.invalidKeychainAccessPolicy(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 FieldIdentityCustodyError.randomSecretGenerationFailed(status) - } - return bytes.map { String(format: "%02x", $0) }.joined() - } - - private static var selectedSecretKey: RadrootsSecureStoreKey { - RadrootsSecureStoreKey(namespace: namespace, name: selectedSecretName) - } -} diff --git a/Radroots/Runtime/FieldIdentityPublicMetadataStore.swift b/Radroots/Runtime/FieldIdentityPublicMetadataStore.swift @@ -27,7 +27,7 @@ struct FieldIdentityPublicMetadataStore { static func configured() throws -> FieldIdentityPublicMetadataStore { guard let servicePrefix = BuildConfig.string(.keychainServicePrefix) else { - throw FieldIdentityCustodyError.missingKeychainServicePrefix + throw FieldSecureIdentityStoreError.missingSecureStoreServicePrefix } return FieldIdentityPublicMetadataStore(servicePrefix: servicePrefix) } diff --git a/Radroots/Runtime/FieldRuntimeService.swift b/Radroots/Runtime/FieldRuntimeService.swift @@ -52,31 +52,17 @@ public final class FieldRuntimeService: @unchecked Sendable { try await run { try $0.nostrIdentityList() } } - public func nostrIdentityGenerate(label: String?, makeSelected: Bool) async throws -> NostrIdentityRecord { - try await run { try $0.nostrIdentityGenerate(label: label, makeSelected: makeSelected) } + public func nostrIdentityValidateHostCustodySecret(secretKey: String) async throws -> NostrHostCustodyIdentity { + try await run { try $0.nostrIdentityValidateHostCustodySecret(secretKey: secretKey) } } - public func nostrIdentityImportSecret( + public func nostrIdentityRestoreHostCustodySecret( secretKey: String, label: String?, makeSelected: Bool ) async throws -> NostrIdentityRecord { try await run { - try $0.nostrIdentityImportSecret( - secretKey: secretKey, - label: label, - makeSelected: makeSelected - ) - } - } - - public func nostrIdentityRestoreHostSecret( - secretKey: String, - label: String?, - makeSelected: Bool - ) async throws -> NostrIdentityRecord { - try await run { - try $0.nostrIdentityRestoreHostSecret( + try $0.nostrIdentityRestoreHostCustodySecret( secretKey: secretKey, label: label, makeSelected: makeSelected @@ -88,12 +74,12 @@ public final class FieldRuntimeService: @unchecked Sendable { try await run { try $0.nostrIdentityRemove(identityId: identityId) } } - public func nostrIdentityClearRuntimeState() async throws { - try await run { try $0.nostrIdentityClearRuntimeState() } + public func nostrIdentityLockHostCustodyRuntime() async throws { + try await run { try $0.nostrIdentityLockHostCustodyRuntime() } } - public func nostrIdentityResetAll() async throws { - try await run { try $0.nostrIdentityResetAll() } + public func nostrIdentityResetHostCustodyRuntime() async throws { + try await run { try $0.nostrIdentityResetHostCustodyRuntime() } } public func nostrProfileForSelf() async -> NostrProfileEventMetadata? { diff --git a/Radroots/Runtime/FieldSecureIdentityStore.swift b/Radroots/Runtime/FieldSecureIdentityStore.swift @@ -0,0 +1,193 @@ +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) + + 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)." + } + } +} + +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) + _ = try await service.nostrIdentityValidateHostCustodySecret(secretKey: trimmed) + try saveSelectedSecret(trimmed) + do { + return try await service.nostrIdentityRestoreHostCustodySecret( + secretKey: trimmed, + label: label, + makeSelected: true + ) + } catch { + try? deleteSelectedSecret() + 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) + } + + func resetLocalState(bundleIdentifier: String) throws { + let trimmedBundleIdentifier = bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedBundleIdentifier.isEmpty else { + throw FieldSecureIdentityStoreError.missingBundleIdentifier + } + try RadrootsAppLocalStateReset.reset( + RadrootsAppLocalStateResetRequest( + appIdentifier: trimmedBundleIdentifier, + keychainServiceNames: [try Self.secureStoreServiceName(servicePrefix: servicePrefix)] + ) + ) + } + + 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 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) + } +}