diff --git a/Yeti/Assets/Localizable.xcstrings b/Yeti/Assets/Localizable.xcstrings index 6078513..7b9ac65 100644 --- a/Yeti/Assets/Localizable.xcstrings +++ b/Yeti/Assets/Localizable.xcstrings @@ -14,11 +14,11 @@ "comment" : "Button to create a key." }, "Done" : { - "comment" : "Button to go to the next view that adds the user's entered private key." - }, - "Got it!" : { "comment" : "Button to go to the next view that adds the user’s entered private key." }, + "Home" : { + "comment" : "Navigation title of home view." + }, "Hooo-raaaayyy!" : { "comment" : "Title of view that confirms the user’s selection of the signing policy." }, @@ -39,6 +39,9 @@ }, "Select a signing policy" : { "comment" : "Title of view to select a signing policy." + }, + "Should I approve Nostr events automatically or would you like to review them for each app?" : { + }, "Yeti: Nostr Helper" : { "comment" : "Application title." @@ -46,7 +49,7 @@ "You’re all set! I’ll take care of most of the approval for you.\nIf I see an event I don’t recognize, I’ll ask you to review it." : { "comment" : "Description of what the user should expect after selecting the basic signing policy." }, - "You’re set for now. You’ll need to come back here with every new app and approve some nostr events." : { + "You’re set for now. You’ll need to come back here with every new app and approve some Nostr events." : { "comment" : "Description of what the user should expect after selecting the manual signing policy." }, "Your private key is stored locally. Only you can see it." : { diff --git a/Yeti/Item.swift b/Yeti/Item.swift deleted file mode 100644 index 4f0e7f5..0000000 --- a/Yeti/Item.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Item.swift -// Yeti -// -// Created by Terry Yiu on 1/19/25. -// - -import Foundation -import SwiftData - -@Model -final class Item { - var timestamp: Date - - init(timestamp: Date) { - self.timestamp = timestamp - } -} diff --git a/Yeti/Model/SigningPolicyModel.swift b/Yeti/Model/SigningPolicyModel.swift new file mode 100644 index 0000000..eca59a6 --- /dev/null +++ b/Yeti/Model/SigningPolicyModel.swift @@ -0,0 +1,60 @@ +// +// SigningPolicyModel.swift +// Yeti +// +// Created by Terry Yiu on 1/20/25. +// + +import Foundation +import SwiftData + +@Model +final class SigningPolicyModel { + var signingPolicy: SigningPolicy + + init(signingPolicy: SigningPolicy) { + self.signingPolicy = signingPolicy + } +} + +enum SigningPolicy: Int, Codable, CaseIterable { + case basic = 0 + case manual = 1 + + var name: String { + switch self { + case .basic: + return String( + localized: "Approve basic actions", + comment: "Name of event signing policy that approves basic actions." + ) + case .manual: + return String( + localized: "Manually approve each app", + comment: "Name of event signing policy that requires manual approval to sign each event." + ) + } + } + + var description: String { + switch self { + case .basic: + return String( + localized: +""" +Recommended for most people. This policy will minimize the number of interruptions during your app usage. +""", + comment: "Description of event signing policy that approves basic actions." + ) + case .manual: + return String( + localized: +""" +Recommended for privacy-minded people who would like control over each app. +Choosing this policy will prompt you to set a preference every time you try a new app. +""", + comment: "Description of event signing policy that requires manual approval to sign each event." + ) + } + } +} diff --git a/Yeti/Utilities/PrivateKeySecureStorage.swift b/Yeti/Utilities/PrivateKeySecureStorage.swift new file mode 100644 index 0000000..92a8168 --- /dev/null +++ b/Yeti/Utilities/PrivateKeySecureStorage.swift @@ -0,0 +1,72 @@ +// +// PrivateKeySecureStorage.swift +// Yeti +// +// Created by Terry Yiu on 1/20/25. +// + +import Foundation +import NostrSDK + +class PrivateKeySecureStorage { + + static let shared = PrivateKeySecureStorage() + + private let service = "yeti-private-keys" + + func keypair(for publicKey: PublicKey) -> Keypair? { + let query = [ + kSecAttrService: service, + kSecAttrAccount: publicKey.hex, + kSecClass: kSecClassGenericPassword, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne + ] as [CFString: Any] as CFDictionary + + var result: AnyObject? + let status = SecItemCopyMatching(query, &result) + + if status == errSecSuccess, + let data = result as? Data, + let privateKeyHex = String(data: data, encoding: .utf8) { + return Keypair(hex: privateKeyHex) + } else { + return nil + } + } + + func store(for keypair: Keypair) { + let query = [ + kSecAttrService: service, + kSecAttrAccount: keypair.publicKey.hex, + kSecClass: kSecClassGenericPassword, + kSecValueData: keypair.privateKey.hex.data(using: .utf8) as Any + ] as [CFString: Any] as CFDictionary + + var status = SecItemAdd(query, nil) + + if status == errSecDuplicateItem { + let query = [ + kSecAttrService: service, + kSecAttrAccount: keypair.publicKey.hex, + kSecClass: kSecClassGenericPassword + ] as [CFString: Any] as CFDictionary + + let updates = [ + kSecValueData: keypair.privateKey.hex.data(using: .utf8) as Any + ] as CFDictionary + + status = SecItemUpdate(query, updates) + } + } + + func delete(for publicKey: PublicKey) { + let query = [ + kSecAttrService: service, + kSecAttrAccount: publicKey.hex, + kSecClass: kSecClassGenericPassword + ] as [CFString: Any] as CFDictionary + + _ = SecItemDelete(query) + } +} diff --git a/Yeti/Views/AddKeyView.swift b/Yeti/Views/AddKeyView.swift index 53624b6..0303df4 100644 --- a/Yeti/Views/AddKeyView.swift +++ b/Yeti/Views/AddKeyView.swift @@ -5,10 +5,15 @@ // Created by Terry Yiu on 1/19/25. // +import Combine +import NostrSDK import SwiftUI struct AddKeyView: View { - @State private var key: String = "" + @State private var keypair: Keypair? + @State private var nostrIdentifier: String = "" + + @State private var navigationDestinationPresented: Bool = false var body: some View { NavigationStack { @@ -26,8 +31,17 @@ struct AddKeyView: View { localized: "nsec / private key", comment: "Prompt asking user to enter in a Nostr private key." ), - text: $key + text: $nostrIdentifier ) + .autocorrectionDisabled(false) + .textContentType(.password) + .textInputAutocapitalization(.never) + .onReceive(Just(nostrIdentifier)) { newValue in + let filtered = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + nostrIdentifier = filtered + + self.keypair = Keypair(nsec: filtered) + } }, footer: { Text( @@ -40,12 +54,26 @@ Footer text explaining that the private key is stored locally and only the user } ) - NavigationLink(destination: SigningPolicySelectionView()) { - Text("Next", comment: "Button to go to the next view that adds the user’s entered private key.") + Button("Next", action: { + navigationDestinationPresented = true + }) + .disabled(!validPrivateKey) + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .listRowInsets(EdgeInsets()) + .background(Color(UIColor.systemGroupedBackground)) + } + .navigationDestination(isPresented: $navigationDestinationPresented) { + if let keypair { + SigningPolicySelectionView(keypair: keypair) } } } } + + private var validPrivateKey: Bool { + keypair != nil + } } #Preview { diff --git a/Yeti/Views/ContentView.swift b/Yeti/Views/ContentView.swift index fffc537..0cb1f42 100644 --- a/Yeti/Views/ContentView.swift +++ b/Yeti/Views/ContentView.swift @@ -37,10 +37,12 @@ struct ContentView: View { .buttonStyle(.borderedProminent) } } + .navigationTitle(String(localized: "Home", comment: "Navigation title of home view.")) + .toolbar(removing: .title) } } #Preview { ContentView() - .modelContainer(for: Item.self, inMemory: true) + .modelContainer(for: SigningPolicyModel.self, inMemory: true) } diff --git a/Yeti/Views/SigningPolicyConfirmationView.swift b/Yeti/Views/SigningPolicyConfirmationView.swift index 747b88a..16d8e3b 100644 --- a/Yeti/Views/SigningPolicyConfirmationView.swift +++ b/Yeti/Views/SigningPolicyConfirmationView.swift @@ -24,15 +24,18 @@ If I see an event I don’t recognize, I’ll ask you to review it. """, comment: "Description of what the user should expect after selecting the basic signing policy." ) + .font(.caption) case .manual: Text( """ -You’re set for now. You’ll need to come back here with every new app and approve some nostr events. +You’re set for now. You’ll need to come back here with every new app and approve some Nostr events. """, comment: "Description of what the user should expect after selecting the manual signing policy." ) + .font(.caption) } } + .toolbar(.hidden) } } diff --git a/Yeti/Views/SigningPolicySelectionView.swift b/Yeti/Views/SigningPolicySelectionView.swift index 9cc90e7..d4c49a4 100644 --- a/Yeti/Views/SigningPolicySelectionView.swift +++ b/Yeti/Views/SigningPolicySelectionView.swift @@ -5,11 +5,16 @@ // Created by Terry Yiu on 1/20/25. // +import NostrSDK import SwiftUI struct SigningPolicySelectionView: View { + let keypair: Keypair + @State var signingPolicy: SigningPolicy = .basic + @State private var navigationDestinationPresented: Bool = false + var body: some View { NavigationStack { Form { @@ -19,6 +24,12 @@ struct SigningPolicySelectionView: View { .listRowInsets(EdgeInsets()) .background(Color(UIColor.systemGroupedBackground)) + Text("Should I approve Nostr events automatically or would you like to review them for each app?") + .font(.caption) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .listRowInsets(EdgeInsets()) + .background(Color(UIColor.systemGroupedBackground)) + Section( content: { Picker( @@ -40,56 +51,22 @@ struct SigningPolicySelectionView: View { } ) - NavigationLink(destination: SigningPolicyConfirmationView(signingPolicy: signingPolicy)) { - Text("Done", comment: "Button to go to the next view that adds the user’s entered private key.") - } + Button("Done", action: { + PrivateKeySecureStorage.shared.store(for: keypair) + navigationDestinationPresented = true + }) + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .listRowInsets(EdgeInsets()) + .background(Color(UIColor.systemGroupedBackground)) + } + .navigationDestination(isPresented: $navigationDestinationPresented) { + SigningPolicyConfirmationView(signingPolicy: signingPolicy) } } } } -enum SigningPolicy: CaseIterable { - case basic - case manual - - var name: String { - switch self { - case .basic: - return String( - localized: "Approve basic actions", - comment: "Name of event signing policy that approves basic actions." - ) - case .manual: - return String( - localized: "Manually approve each app", - comment: "Name of event signing policy that requires manual approval to sign each event." - ) - } - } - - var description: String { - switch self { - case .basic: - return String( - localized: -""" -Recommended for most people. This policy will minimize the number of interruptions during your app usage. -""", - comment: "Description of event signing policy that approves basic actions." - ) - case .manual: - return String( - localized: -""" -Recommended for privacy-minded people who would like control over each app. -Choosing this policy will prompt you to set a preference every time you try a new app. -""", - comment: "Description of event signing policy that requires manual approval to sign each event." - ) - } - } -} - #Preview { - SigningPolicySelectionView() + SigningPolicySelectionView(keypair: Keypair()!) } diff --git a/Yeti/YetiApp.swift b/Yeti/YetiApp.swift index e2c6cbe..1c30315 100644 --- a/Yeti/YetiApp.swift +++ b/Yeti/YetiApp.swift @@ -14,7 +14,7 @@ struct YetiApp: App { var sharedModelContainer: ModelContainer = { let schema = Schema([ - Item.self + SigningPolicyModel.self ]) let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)