Add private key validation and storage

This commit is contained in:
2025-01-20 15:41:34 -05:00
parent d25a73cf9f
commit 999a964f7a
9 changed files with 202 additions and 75 deletions

View File

@@ -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 users entered private key."
},
"Home" : {
"comment" : "Navigation title of home view."
},
"Hooo-raaaayyy!" : {
"comment" : "Title of view that confirms the users 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 @@
"Youre all set! Ill take care of most of the approval for you.\nIf I see an event I dont recognize, Ill ask you to review it." : {
"comment" : "Description of what the user should expect after selecting the basic signing policy."
},
"Youre set for now. Youll need to come back here with every new app and approve some nostr events." : {
"Youre set for now. Youll 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." : {

View File

@@ -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
}
}

View File

@@ -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."
)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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 users 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 {

View File

@@ -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)
}

View File

@@ -24,15 +24,18 @@ If I see an event I dont recognize, Ill ask you to review it.
""",
comment: "Description of what the user should expect after selecting the basic signing policy."
)
.font(.caption)
case .manual:
Text(
"""
Youre set for now. Youll need to come back here with every new app and approve some nostr events.
Youre set for now. Youll 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)
}
}

View File

@@ -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 users 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()!)
}

View File

@@ -14,7 +14,7 @@ struct YetiApp: App {
var sharedModelContainer: ModelContainer = {
let schema = Schema([
Item.self
SigningPolicyModel.self
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)