This commit is contained in:
Max Goedjen 2025-08-17 23:51:05 -05:00
parent 276ca02b39
commit 7aba3c374d
No known key found for this signature in database
17 changed files with 129 additions and 125 deletions

View File

@ -7,10 +7,8 @@ public struct AnySecret: Secret, @unchecked Sendable {
private let hashable: AnyHashable private let hashable: AnyHashable
private let _id: () -> AnyHashable private let _id: () -> AnyHashable
private let _name: () -> String private let _name: () -> String
private let _keyType: () -> KeyType
private let _authenticationRequirement: () -> AuthenticationRequirement
private let _publicKey: () -> Data private let _publicKey: () -> Data
private let _publicKeyAttribution: () -> String? private let _attributes: () -> Attributes
public init<T>(_ secret: T) where T: Secret { public init<T>(_ secret: T) where T: Secret {
if let secret = secret as? AnySecret { if let secret = secret as? AnySecret {
@ -18,19 +16,15 @@ public struct AnySecret: Secret, @unchecked Sendable {
hashable = secret.hashable hashable = secret.hashable
_id = secret._id _id = secret._id
_name = secret._name _name = secret._name
_keyType = secret._keyType
_authenticationRequirement = secret._authenticationRequirement
_publicKey = secret._publicKey _publicKey = secret._publicKey
_publicKeyAttribution = secret._publicKeyAttribution _attributes = secret._attributes
} else { } else {
base = secret as Any base = secret as Any
self.hashable = secret self.hashable = secret
_id = { secret.id as AnyHashable } _id = { secret.id as AnyHashable }
_name = { secret.name } _name = { secret.name }
_keyType = { secret.keyType }
_authenticationRequirement = { secret.authenticationRequirement }
_publicKey = { secret.publicKey } _publicKey = { secret.publicKey }
_publicKeyAttribution = { secret.publicKeyAttribution } _attributes = { secret.attributes }
} }
} }
@ -42,21 +36,12 @@ public struct AnySecret: Secret, @unchecked Sendable {
_name() _name()
} }
public var keyType: KeyType {
_keyType()
}
public var authenticationRequirement: AuthenticationRequirement {
_authenticationRequirement()
}
public var publicKey: Data { public var publicKey: Data {
_publicKey() _publicKey()
} }
public var publicKeyAttribution: String? { public var attributes: Attributes {
_publicKeyAttribution() _attributes()
} }
public static func == (lhs: AnySecret, rhs: AnySecret) -> Bool { public static func == (lhs: AnySecret, rhs: AnySecret) -> Bool {

View File

@ -70,13 +70,13 @@ public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiab
private let _create: @Sendable (String, Attributes) async throws -> Void private let _create: @Sendable (String, Attributes) async throws -> Void
private let _delete: @Sendable (AnySecret) async throws -> Void private let _delete: @Sendable (AnySecret) async throws -> Void
private let _update: @Sendable (AnySecret, String) async throws -> Void private let _update: @Sendable (AnySecret, String, Attributes) async throws -> Void
private let _supportedKeyTypes: @Sendable () -> [KeyType] private let _supportedKeyTypes: @Sendable () -> [KeyType]
public init<SecretStoreType>(modifiable secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable { public init<SecretStoreType>(modifiable secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
_create = { try await secretStore.create(name: $0, attributes: $1) } _create = { try await secretStore.create(name: $0, attributes: $1) }
_delete = { try await secretStore.delete(secret: $0.base as! SecretStoreType.SecretType) } _delete = { try await secretStore.delete(secret: $0.base as! SecretStoreType.SecretType) }
_update = { try await secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1) } _update = { try await secretStore.update(secret: $0.base as! SecretStoreType.SecretType, name: $1, attributes: $2) }
_supportedKeyTypes = { secretStore.supportedKeyTypes } _supportedKeyTypes = { secretStore.supportedKeyTypes }
super.init(secretStore) super.init(secretStore)
} }
@ -89,8 +89,8 @@ public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiab
try await _delete(secret) try await _delete(secret)
} }
public func update(secret: AnySecret, name: String) async throws { public func update(secret: AnySecret, name: String, attributes: Attributes) async throws {
try await _update(secret, name) try await _update(secret, name, attributes)
} }
public var supportedKeyTypes: [KeyType] { public var supportedKeyTypes: [KeyType] {

View File

@ -18,8 +18,19 @@ public struct OpenSSHKeyWriter: Sendable {
/// Generates an OpenSSH string representation of the secret. /// Generates an OpenSSH string representation of the secret.
/// - Returns: OpenSSH string representation of the secret. /// - Returns: OpenSSH string representation of the secret.
public func openSSHString<SecretType: Secret>(secret: SecretType, comment: String? = nil) -> String { public func openSSHString<SecretType: Secret>(secret: SecretType) -> String {
[curveType(for: secret.keyType), data(secret: secret).base64EncodedString(), comment] let resolvedComment: String
if let comment = secret.publicKeyAttribution {
resolvedComment = comment
} else {
let dashedKeyName = secret.name.replacingOccurrences(of: " ", with: "-")
let dashedHostName = ["secretive", Host.current().localizedName, "local"]
.compactMap { $0 }
.joined(separator: ".")
.replacingOccurrences(of: " ", with: "-")
resolvedComment = "\(dashedKeyName)@\(dashedHostName)"
}
return [curveType(for: secret.keyType), data(secret: secret).base64EncodedString(), resolvedComment]
.compactMap { $0 } .compactMap { $0 }
.joined(separator: " ") .joined(separator: " ")
} }

View File

@ -1,9 +1,9 @@
import Foundation import Foundation
public struct Attributes: Sendable, Codable { public struct Attributes: Sendable, Codable, Hashable {
/// The type of key involved. /// The type of key involved.
public var keyType: KeyType public let keyType: KeyType
/// The authentication requirements for the key. This is simply a description of the option recorded at creation modifying it doers not modify the key's authentication requirements. /// The authentication requirements for the key. This is simply a description of the option recorded at creation modifying it doers not modify the key's authentication requirements.
public let authentication: AuthenticationRequirement public let authentication: AuthenticationRequirement

View File

@ -5,14 +5,28 @@ public protocol Secret: Identifiable, Hashable, Sendable {
/// A user-facing string identifying the Secret. /// A user-facing string identifying the Secret.
var name: String { get } var name: String { get }
/// The algorithm this secret uses.
var keyType: KeyType { get }
/// Whether the secret requires authentication before use.
var authenticationRequirement: AuthenticationRequirement { get }
/// The public key data for the secret. /// The public key data for the secret.
var publicKey: Data { get } var publicKey: Data { get }
/// The attributes of the key.
var attributes: Attributes { get }
}
public extension Secret {
/// The algorithm and key size this secret uses.
var keyType: KeyType {
attributes.keyType
}
/// Whether the secret requires authentication before use.
var authenticationRequirement: AuthenticationRequirement {
attributes.authentication
}
/// An attribution string to apply to the generated public key. /// An attribution string to apply to the generated public key.
var publicKeyAttribution: String? { get } var publicKeyAttribution: String? {
attributes.publicKeyAttribution
}
} }

View File

@ -67,7 +67,8 @@ public protocol SecretStoreModifiable: SecretStore {
/// - Parameters: /// - Parameters:
/// - secret: The ``Secret`` to update. /// - secret: The ``Secret`` to update.
/// - name: The new name for the Secret. /// - name: The new name for the Secret.
func update(secret: SecretType, name: String) async throws /// - attributes: The new attributes for the secret.
func update(secret: SecretType, name: String, attributes: Attributes) async throws
var supportedKeyTypes: [KeyType] { get } var supportedKeyTypes: [KeyType] { get }

View File

@ -188,7 +188,7 @@ extension SecureEnclave {
await reloadSecretsInternal() await reloadSecretsInternal()
} }
public func update(secret: Secret, name: String) async throws { public func update(secret: Secret, name: String, attributes: Attributes) async throws {
let updateQuery = KeychainDictionary([ let updateQuery = KeychainDictionary([
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id as CFData kSecAttrApplicationLabel: secret.id as CFData

View File

@ -9,10 +9,8 @@ extension SecureEnclave {
public let id: Data public let id: Data
public let name: String public let name: String
public let keyType: KeyType
public let authenticationRequirement: AuthenticationRequirement
public let publicKeyAttribution: String?
public let publicKey: Data public let publicKey: Data
public let attributes: Attributes
init( init(
id: Data, id: Data,
@ -22,10 +20,15 @@ extension SecureEnclave {
) { ) {
self.id = id self.id = id
self.name = name self.name = name
self.keyType = .init(algorithm: .ecdsa, size: 256)
self.authenticationRequirement = authenticationRequirement
self.publicKeyAttribution = nil
self.publicKey = publicKey self.publicKey = publicKey
self.attributes = Attributes(
keyType: .init(
algorithm: .ecdsa,
size: 256
),
authentication: authenticationRequirement,
publicKeyAttribution: nil
)
} }
init( init(
@ -36,10 +39,8 @@ extension SecureEnclave {
) { ) {
self.id = Data(id.utf8) self.id = Data(id.utf8)
self.name = name self.name = name
self.keyType = attributes.keyType
self.authenticationRequirement = attributes.authentication
self.publicKeyAttribution = attributes.publicKeyAttribution
self.publicKey = publicKey self.publicKey = publicKey
self.attributes = attributes
} }
} }

View File

@ -174,7 +174,7 @@ extension SecureEnclave {
await reloadSecretsInternal() await reloadSecretsInternal()
} }
public func update(secret: Secret, name: String) async throws { public func update(secret: Secret, name: String, attributes: Attributes) async throws {
let updateQuery = KeychainDictionary([ let updateQuery = KeychainDictionary([
kSecClass: kSecClassKey, kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id as CFData kSecAttrApplicationLabel: secret.id as CFData

View File

@ -9,10 +9,8 @@ extension SmartCard {
public let id: Data public let id: Data
public let name: String public let name: String
public let keyType: KeyType
public let authenticationRequirement: AuthenticationRequirement = .unknown
public let publicKey: Data public let publicKey: Data
public var publicKeyAttribution: String? = nil public var attributes: Attributes
} }

View File

@ -180,7 +180,8 @@ extension SmartCard.Store {
let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)! let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)!
let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any] let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any]
let publicKey = publicKeyAttributes[kSecValueData] as! Data let publicKey = publicKeyAttributes[kSecValueData] as! Data
return SmartCard.Secret(id: tokenID, name: name, keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, publicKey: publicKey) let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!)
return SmartCard.Secret(id: tokenID, name: name, publicKey: publicKey, attributes: attributes)
} }
state.secrets.append(contentsOf: wrapped) state.secrets.append(contentsOf: wrapped)
} }

View File

@ -7,7 +7,7 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
2C4A9D2F2636FFD3008CC8E2 /* RenameSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4A9D2E2636FFD3008CC8E2 /* RenameSecretView.swift */; }; 2C4A9D2F2636FFD3008CC8E2 /* EditSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */; };
50020BB024064869003D4025 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50020BAF24064869003D4025 /* AppDelegate.swift */; }; 50020BB024064869003D4025 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50020BAF24064869003D4025 /* AppDelegate.swift */; };
50033AC327813F1700253856 /* BundleIDs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50033AC227813F1700253856 /* BundleIDs.swift */; }; 50033AC327813F1700253856 /* BundleIDs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50033AC227813F1700253856 /* BundleIDs.swift */; };
5003EF3B278005E800DF2006 /* SecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF3A278005E800DF2006 /* SecretKit */; }; 5003EF3B278005E800DF2006 /* SecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF3A278005E800DF2006 /* SecretKit */; };
@ -98,7 +98,7 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
2C4A9D2E2636FFD3008CC8E2 /* RenameSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameSecretView.swift; sourceTree = "<group>"; }; 2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditSecretView.swift; sourceTree = "<group>"; };
50020BAF24064869003D4025 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 50020BAF24064869003D4025 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
50033AC227813F1700253856 /* BundleIDs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleIDs.swift; sourceTree = "<group>"; }; 50033AC227813F1700253856 /* BundleIDs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleIDs.swift; sourceTree = "<group>"; };
5003EF39278005C800DF2006 /* Packages */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Packages; sourceTree = "<group>"; }; 5003EF39278005C800DF2006 /* Packages */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Packages; sourceTree = "<group>"; };
@ -246,7 +246,7 @@
50C385A42407A76D00AF2719 /* SecretDetailView.swift */, 50C385A42407A76D00AF2719 /* SecretDetailView.swift */,
5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */, 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */,
50B8550C24138C4F009958AC /* DeleteSecretView.swift */, 50B8550C24138C4F009958AC /* DeleteSecretView.swift */,
2C4A9D2E2636FFD3008CC8E2 /* RenameSecretView.swift */, 2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */,
50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */, 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */,
506772C82425BB8500034DED /* NoStoresView.swift */, 506772C82425BB8500034DED /* NoStoresView.swift */,
50153E1F250AFCB200525160 /* UpdateView.swift */, 50153E1F250AFCB200525160 /* UpdateView.swift */,
@ -430,7 +430,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
2C4A9D2F2636FFD3008CC8E2 /* RenameSecretView.swift in Sources */, 2C4A9D2F2636FFD3008CC8E2 /* EditSecretView.swift in Sources */,
5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */, 5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */,
5066A6C22516F303004B5A36 /* SetupView.swift in Sources */, 5066A6C22516F303004B5A36 /* SetupView.swift in Sources */,
5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */, 5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */,

View File

@ -9,10 +9,13 @@ extension Preview {
let id = UUID().uuidString let id = UUID().uuidString
let name: String let name: String
let keyType = KeyType(algorithm: .ecdsa, size: 256)
let authenticationRequirement = AuthenticationRequirement.presenceRequired
let publicKey = UUID().uuidString.data(using: .utf8)! let publicKey = UUID().uuidString.data(using: .utf8)!
var publicKeyAttribution: String? var attributes: Attributes {
Attributes(
keyType: .init(algorithm: .ecdsa, size: 256),
authentication: .presenceRequired,
)
}
} }
} }
@ -99,7 +102,7 @@ extension Preview {
func delete(secret: Preview.Secret) throws { func delete(secret: Preview.Secret) throws {
} }
func update(secret: Preview.Secret, name: String) throws { func update(secret: Preview.Secret, name: String, attributes: Attributes) throws {
} }
} }
} }

View File

@ -0,0 +1,52 @@
import SwiftUI
import SecretKit
struct EditSecretView<StoreType: SecretStoreModifiable>: View {
let store: StoreType
let secret: StoreType.SecretType
let dismissalBlock: (_ renamed: Bool) -> ()
@State private var name: String
@State private var publicKeyAttribution: String
init(store: StoreType, secret: StoreType.SecretType, dismissalBlock: @escaping (Bool) -> ()) {
self.store = store
self.secret = secret
self.dismissalBlock = dismissalBlock
name = secret.name
publicKeyAttribution = secret.publicKeyAttribution ?? ""
}
var body: some View {
VStack(alignment: .trailing) {
Form {
Section {
TextField(String(localized: .createSecretNameLabel), text: $name, prompt: Text(.createSecretNamePlaceholder))
TextField("Key Attribution", text: $publicKeyAttribution, prompt: Text("test@example.com"))
}
}
HStack {
Button(.renameRenameButton, action: rename)
.disabled(name.isEmpty)
.keyboardShortcut(.return)
Button(.renameCancelButton) {
dismissalBlock(false)
}.keyboardShortcut(.cancelAction)
}
.padding()
}
.formStyle(.grouped)
}
func rename() {
var attributes = secret.attributes
if !publicKeyAttribution.isEmpty {
attributes.publicKeyAttribution = publicKeyAttribution
}
Task {
try? await store.update(secret: secret, name: name, attributes: attributes)
dismissalBlock(true)
}
}
}

View File

@ -1,52 +0,0 @@
import SwiftUI
import SecretKit
struct RenameSecretView<StoreType: SecretStoreModifiable>: View {
@State var store: StoreType
let secret: StoreType.SecretType
var dismissalBlock: (_ renamed: Bool) -> ()
@State private var newName = ""
var body: some View {
VStack {
HStack {
Image(nsImage: NSApplication.shared.applicationIconImage)
.resizable()
.frame(width: 64, height: 64)
.padding()
VStack {
HStack {
Text(.renameTitle(secretName: secret.name))
Spacer()
}
HStack {
TextField(secret.name, text: $newName).focusable()
}
}
}
HStack {
Spacer()
Button(.renameRenameButton, action: rename)
.disabled(newName.count == 0)
.keyboardShortcut(.return)
Button(.renameCancelButton) {
dismissalBlock(false)
}.keyboardShortcut(.cancelAction)
}
}
.padding()
.frame(minWidth: 400)
.onExitCommand {
dismissalBlock(false)
}
}
func rename() {
Task {
try? await store.update(secret: secret, name: newName)
dismissalBlock(true)
}
}
}

View File

@ -30,19 +30,9 @@ struct SecretDetailView<SecretType: Secret>: View {
.frame(minHeight: 200, maxHeight: .infinity) .frame(minHeight: 200, maxHeight: .infinity)
} }
var dashedKeyName: String {
secret.name.replacingOccurrences(of: " ", with: "-")
}
var dashedHostName: String {
["secretive", Host.current().localizedName, "local"]
.compactMap { $0 }
.joined(separator: ".")
.replacingOccurrences(of: " ", with: "-")
}
var keyString: String { var keyString: String {
keyWriter.openSSHString(secret: secret, comment: "\(dashedKeyName)@\(dashedHostName)") keyWriter.openSSHString(secret: secret)
} }
} }

View File

@ -46,7 +46,7 @@ struct SecretListItemView: View {
} }
} }
} }
.popover(isPresented: showingPopup) { .sheet(isPresented: showingPopup) {
if let modifiable = store as? AnySecretStoreModifiable { if let modifiable = store as? AnySecretStoreModifiable {
if isDeleting { if isDeleting {
DeleteSecretView(store: modifiable, secret: secret) { deleted in DeleteSecretView(store: modifiable, secret: secret) { deleted in
@ -56,7 +56,7 @@ struct SecretListItemView: View {
} }
} }
} else if isRenaming { } else if isRenaming {
RenameSecretView(store: modifiable, secret: secret) { renamed in EditSecretView(store: modifiable, secret: secret) { renamed in
isRenaming = false isRenaming = false
if renamed { if renamed {
renamedSecret(secret) renamedSecret(secret)