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