CryptoKit migration (#628)

* WIP.

* WIP

* WIP Edit

* Key selection.

* WIP

* WIP

* Proxy through

* WIP

* Remove verify.

* Migration.

* Comment

* Add param

* Semi-offering key

* Ignore updates if test build.

* Fix rsa public key gen

* Messily fix RSA

* Remove 1024 bit rsa

* Cleanup

* Cleanup

* Clean out MLDSA refs for now

* Dump notifier changes

* Put back UI tweaks

* Fixes.
This commit is contained in:
Max Goedjen 2025-08-24 15:35:15 -07:00 committed by GitHub
parent 5b0135d694
commit 3d3d123484
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 923 additions and 763 deletions

View File

@ -1,6 +1,9 @@
{ {
"sourceLanguage" : "en", "sourceLanguage" : "en",
"strings" : { "strings" : {
"Advanced" : {
},
"agent_not_running_notice_title" : { "agent_not_running_notice_title" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
@ -1083,6 +1086,16 @@
} }
} }
}, },
"auth_context_request_signature_description_%@_%@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "auth_context_request_signature_description_%1$@_%2$@"
}
}
}
},
"auth_context_request_verify_description" : { "auth_context_request_verify_description" : {
"comment" : "When the user performs a signature verification action using a secret, they are shown a prompt to approve the action. This is the description, showing which secret will be used. The placeholder is the name of the secret. NOTE: This is currently not exposed in UI.", "comment" : "When the user performs a signature verification action using a secret, they are shown a prompt to approve the action. This is the description, showing which secret will be used. The placeholder is the name of the secret. NOTE: This is currently not exposed in UI.",
"extractionState" : "manual", "extractionState" : "manual",
@ -1475,72 +1488,72 @@
"ca" : { "ca" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Nom:" "value" : "Nom"
} }
}, },
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Name:" "value" : "Name"
} }
}, },
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Name:" "value" : "Name"
} }
}, },
"fi" : { "fi" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Nimi:" "value" : "Nimi"
} }
}, },
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Nom :" "value" : "Nom"
} }
}, },
"it" : { "it" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Nome:" "value" : "Nome"
} }
}, },
"ja" : { "ja" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "名前:" "value" : "名前"
} }
}, },
"ko" : { "ko" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "이름:" "value" : "이름"
} }
}, },
"pl" : { "pl" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Nazwa:" "value" : "Nazwa"
} }
}, },
"pt-BR" : { "pt-BR" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Nome:" "value" : "Nome"
} }
}, },
"ru" : { "ru" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Название:" "value" : "Название"
} }
}, },
"zh-Hans" : { "zh-Hans" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "needs_review",
"value" : "名称" "value" : "名称"
} }
} }
@ -2007,6 +2020,9 @@
} }
} }
} }
},
"Current Biometrics" : {
}, },
"delete_confirmation_cancel_button" : { "delete_confirmation_cancel_button" : {
"extractionState" : "manual", "extractionState" : "manual",
@ -2091,72 +2107,72 @@
"ca" : { "ca" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Confirma el nom:" "value" : "Confirma el nom"
} }
}, },
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Name bestätigen:" "value" : "Name bestätigen"
} }
}, },
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Confirm Name:" "value" : "Confirm Name"
} }
}, },
"fi" : { "fi" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Vahvista nimi:" "value" : "Vahvista nimi"
} }
}, },
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Confirmer le nom :" "value" : "Confirmer le nom"
} }
}, },
"it" : { "it" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Conferma nome:" "value" : "Conferma nome"
} }
}, },
"ja" : { "ja" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "名前の確認:" "value" : "名前の確認"
} }
}, },
"ko" : { "ko" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "확인 이름:" "value" : "확인 이름"
} }
}, },
"pl" : { "pl" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Powtórz nazwę:" "value" : "Powtórz nazwę"
} }
}, },
"pt-BR" : { "pt-BR" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Confirmar Nome:" "value" : "Confirmar Nome"
} }
}, },
"ru" : { "ru" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Подтвердить название:" "value" : "Подтвердить название"
} }
}, },
"zh-Hans" : { "zh-Hans" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "needs_review",
"value" : "确认名称" "value" : "确认名称"
} }
} }
@ -2777,6 +2793,15 @@
} }
} }
} }
},
"If you change your biometric settings in _any way_, including adding a new fingerprint, this key will no longer be accessible." : {
},
"Key Attribution" : {
},
"Key Type" : {
}, },
"no_secure_storage_description" : { "no_secure_storage_description" : {
"extractionState" : "manual", "extractionState" : "manual",
@ -3377,6 +3402,9 @@
} }
} }
} }
},
"Require authentication with current set of biometrics." : {
}, },
"secret_detail_md5_fingerprint_label" : { "secret_detail_md5_fingerprint_label" : {
"extractionState" : "manual", "extractionState" : "manual",
@ -3733,72 +3761,72 @@
} }
} }
}, },
"secret_list_rename_button" : { "secret_list_edit_button" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
"ca" : { "ca" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "needs_review",
"value" : "Canvia el nom" "value" : "Canvia el nom"
} }
}, },
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "needs_review",
"value" : "Umbenennen" "value" : "Umbenennen"
} }
}, },
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Rename" "value" : "Edit"
} }
}, },
"fr" : { "fr" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "needs_review",
"value" : "Renommer" "value" : "Renommer"
} }
}, },
"it" : { "it" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "needs_review",
"value" : "Rinomina" "value" : "Rinomina"
} }
}, },
"ja" : { "ja" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "needs_review",
"value" : "名前を変更" "value" : "名前を変更"
} }
}, },
"ko" : { "ko" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "needs_review",
"value" : "이름 변경" "value" : "이름 변경"
} }
}, },
"pl" : { "pl" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "needs_review",
"value" : "Zmień nazwę" "value" : "Zmień nazwę"
} }
}, },
"pt-BR" : { "pt-BR" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "needs_review",
"value" : "Renomear" "value" : "Renomear"
} }
}, },
"ru" : { "ru" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "needs_review",
"value" : "Переименовать" "value" : "Переименовать"
} }
}, },
"zh-Hans" : { "zh-Hans" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "needs_review",
"value" : "重命名" "value" : "重命名"
} }
} }
@ -5172,6 +5200,12 @@
} }
} }
} }
},
"test@example.com" : {
},
"This shows at the end of your public key." : {
}, },
"unnamed_secret" : { "unnamed_secret" : {
"extractionState" : "manual", "extractionState" : "manual",

View File

@ -9,7 +9,8 @@ public final class Agent: Sendable {
private let storeList: SecretStoreList private let storeList: SecretStoreList
private let witness: SigningWitness? private let witness: SigningWitness?
private let writer = OpenSSHKeyWriter() private let publicKeyWriter = OpenSSHPublicKeyWriter()
private let signatureWriter = OpenSSHSignatureWriter()
private let requestTracer = SigningRequestTracer() private let requestTracer = SigningRequestTracer()
private let certificateHandler = OpenSSHCertificateHandler() private let certificateHandler = OpenSSHCertificateHandler()
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent") private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
@ -43,7 +44,7 @@ extension Agent {
guard data.count > 4 else { return false} guard data.count > 4 else { return false}
let requestTypeInt = data[4] let requestTypeInt = data[4]
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else { guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
writer.write(OpenSSHKeyWriter().lengthAndData(of: SSHAgent.ResponseType.agentFailure.data)) writer.write(SSHAgent.ResponseType.agentFailure.data.lengthAndData)
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)") logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
return true return true
} }
@ -75,8 +76,7 @@ extension Agent {
response.append(SSHAgent.ResponseType.agentFailure.data) response.append(SSHAgent.ResponseType.agentFailure.data)
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)") logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
} }
let full = OpenSSHKeyWriter().lengthAndData(of: response) return response.lengthAndData
return full
} }
} }
@ -92,14 +92,14 @@ extension Agent {
var keyData = Data() var keyData = Data()
for secret in secrets { for secret in secrets {
let keyBlob = writer.data(secret: secret) let keyBlob = publicKeyWriter.data(secret: secret)
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)! let curveData = publicKeyWriter.openSSHIdentifier(for: secret.keyType)
keyData.append(writer.lengthAndData(of: keyBlob)) keyData.append(keyBlob.lengthAndData)
keyData.append(writer.lengthAndData(of: curveData)) keyData.append(curveData.lengthAndData)
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) { if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
keyData.append(writer.lengthAndData(of: certificateData)) keyData.append(certificateData.lengthAndData)
keyData.append(writer.lengthAndData(of: name)) keyData.append(name.lengthAndData)
count += 1 count += 1
} }
} }
@ -135,46 +135,8 @@ extension Agent {
} }
let dataToSign = reader.readNextChunk() let dataToSign = reader.readNextChunk()
let signed = try await store.sign(data: dataToSign, with: secret, for: provenance) let rawRepresentation = try await store.sign(data: dataToSign, with: secret, for: provenance)
let derSignature = signed let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
// Convert from DER formatted rep to raw (r||s)
let rawRepresentation: Data
switch (secret.algorithm, secret.keySize) {
case (.ellipticCurve, 256):
rawRepresentation = try CryptoKit.P256.Signing.ECDSASignature(derRepresentation: derSignature).rawRepresentation
case (.ellipticCurve, 384):
rawRepresentation = try CryptoKit.P384.Signing.ECDSASignature(derRepresentation: derSignature).rawRepresentation
default:
throw AgentError.unsupportedKeyType
}
let rawLength = rawRepresentation.count/2
// Check if we need to pad with 0x00 to prevent certain
// ssh servers from thinking r or s is negative
let paddingRange: ClosedRange<UInt8> = 0x80...0xFF
var r = Data(rawRepresentation[0..<rawLength])
if paddingRange ~= r.first! {
r.insert(0x00, at: 0)
}
var s = Data(rawRepresentation[rawLength...])
if paddingRange ~= s.first! {
s.insert(0x00, at: 0)
}
var signatureChunk = Data()
signatureChunk.append(writer.lengthAndData(of: r))
signatureChunk.append(writer.lengthAndData(of: s))
var signedData = Data()
var sub = Data()
sub.append(writer.lengthAndData(of: curveData))
sub.append(writer.lengthAndData(of: signatureChunk))
signedData.append(writer.lengthAndData(of: sub))
if let witness = witness { if let witness = witness {
try await witness.witness(accessTo: secret, from: store, by: provenance) try await witness.witness(accessTo: secret, from: store, by: provenance)
@ -206,7 +168,7 @@ extension Agent {
func secret(matching hash: Data) async -> (AnySecretStore, AnySecret)? { func secret(matching hash: Data) async -> (AnySecretStore, AnySecret)? {
for store in await storeList.stores { for store in await storeList.stores {
let allMatching = await store.secrets.filter { secret in let allMatching = await store.secrets.filter { secret in
hash == writer.data(secret: secret) hash == publicKeyWriter.data(secret: secret)
} }
if let matching = allMatching.first { if let matching = allMatching.first {
return (store, matching) return (store, matching)

View File

@ -22,7 +22,7 @@ SecretKit is a collection of protocols describing secrets and stores.
### OpenSSH ### OpenSSH
- ``OpenSSHKeyWriter`` - ``OpenSSHPublicKeyWriter``
- ``OpenSSHReader`` - ``OpenSSHReader``
### Signing Process ### Signing Process

View File

@ -3,14 +3,12 @@ import Foundation
/// Type eraser for Secret. /// Type eraser for Secret.
public struct AnySecret: Secret, @unchecked Sendable { public struct AnySecret: Secret, @unchecked Sendable {
public let base: Any public let base: any Secret
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 _algorithm: () -> Algorithm
private let _keySize: () -> Int
private let _requiresAuthentication: () -> Bool
private let _publicKey: () -> Data private let _publicKey: () -> Data
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
_algorithm = secret._algorithm
_keySize = secret._keySize
_requiresAuthentication = secret._requiresAuthentication
_publicKey = secret._publicKey _publicKey = secret._publicKey
_attributes = secret._attributes
} else { } else {
base = secret as Any base = secret
self.hashable = secret self.hashable = secret
_id = { secret.id as AnyHashable } _id = { secret.id as AnyHashable }
_name = { secret.name } _name = { secret.name }
_algorithm = { secret.algorithm }
_keySize = { secret.keySize }
_requiresAuthentication = { secret.requiresAuthentication }
_publicKey = { secret.publicKey } _publicKey = { secret.publicKey }
_attributes = { secret.attributes }
} }
} }
@ -42,22 +36,14 @@ public struct AnySecret: Secret, @unchecked Sendable {
_name() _name()
} }
public var algorithm: Algorithm {
_algorithm()
}
public var keySize: Int {
_keySize()
}
public var requiresAuthentication: Bool {
_requiresAuthentication()
}
public var publicKey: Data { public var publicKey: Data {
_publicKey() _publicKey()
} }
public var attributes: Attributes {
_attributes()
}
public static func == (lhs: AnySecret, rhs: AnySecret) -> Bool { public static func == (lhs: AnySecret, rhs: AnySecret) -> Bool {
lhs.hashable == rhs.hashable lhs.hashable == rhs.hashable
} }

View File

@ -3,7 +3,7 @@ import Foundation
/// Type eraser for SecretStore. /// Type eraser for SecretStore.
open class AnySecretStore: SecretStore, @unchecked Sendable { open class AnySecretStore: SecretStore, @unchecked Sendable {
let base: any Sendable let base: any SecretStore
private let _isAvailable: @MainActor @Sendable () -> Bool private let _isAvailable: @MainActor @Sendable () -> Bool
private let _id: @Sendable () -> UUID private let _id: @Sendable () -> UUID
private let _name: @MainActor @Sendable () -> String private let _name: @MainActor @Sendable () -> String
@ -61,27 +61,33 @@ open class AnySecretStore: SecretStore, @unchecked Sendable {
public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable, @unchecked Sendable { public final class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable, @unchecked Sendable {
private let _create: @Sendable (String, Bool) 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]
public init<SecretStoreType>(modifiable secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable { public init<SecretStoreType>(_ secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable {
_create = { try await secretStore.create(name: $0, requiresAuthentication: $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 }
super.init(secretStore) super.init(secretStore)
} }
public func create(name: String, requiresAuthentication: Bool) async throws { public func create(name: String, attributes: Attributes) async throws {
try await _create(name, requiresAuthentication) try await _create(name, attributes)
} }
public func delete(secret: AnySecret) async throws { public func delete(secret: AnySecret) async throws {
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] {
_supportedKeyTypes()
} }
} }

View File

@ -51,19 +51,17 @@ public extension SecretStore {
/// Returns the appropriate keychian signature algorithm to use for a given secret. /// Returns the appropriate keychian signature algorithm to use for a given secret.
/// - Parameters: /// - Parameters:
/// - secret: The secret which will be used for signing. /// - secret: The secret which will be used for signing.
/// - allowRSA: Whether or not RSA key types should be permited.
/// - Returns: The appropriate algorithm. /// - Returns: The appropriate algorithm.
func signatureAlgorithm(for secret: SecretType, allowRSA: Bool = false) -> SecKeyAlgorithm { func signatureAlgorithm(for secret: SecretType) -> SecKeyAlgorithm? {
switch (secret.algorithm, secret.keySize) { switch (secret.keyType.algorithm, secret.keyType.size) {
case (.ellipticCurve, 256): case (.ecdsa, 256):
return .ecdsaSignatureMessageX962SHA256 .ecdsaSignatureMessageX962SHA256
case (.ellipticCurve, 384): case (.ecdsa, 384):
return .ecdsaSignatureMessageX962SHA384 .ecdsaSignatureMessageX962SHA384
case (.rsa, 1024), (.rsa, 2048): case (.rsa, 2048):
guard allowRSA else { fatalError() } .rsaSignatureMessagePKCS1v15SHA512
return .rsaSignatureMessagePKCS1v15SHA512
default: default:
fatalError() nil
} }
} }

View File

@ -0,0 +1,23 @@
import Foundation
extension Data {
/// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload.
/// - Returns: OpenSSH data.
package var lengthAndData: Data {
let rawLength = UInt32(count)
var endian = rawLength.bigEndian
return Data(bytes: &endian, count: UInt32.bitWidth/8) + self
}
}
extension String {
/// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload.
/// - Returns: OpenSSH data.
package var lengthAndData: Data {
Data(utf8).lengthAndData
}
}

View File

@ -6,7 +6,7 @@ public actor OpenSSHCertificateHandler: Sendable {
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory()) private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler") private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
private let writer = OpenSSHKeyWriter() private let writer = OpenSSHPublicKeyWriter()
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:] private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
/// Initializes an OpenSSHCertificateHandler. /// Initializes an OpenSSHCertificateHandler.
@ -40,10 +40,10 @@ public actor OpenSSHCertificateHandler: Sendable {
let curveIdentifier = reader.readNextChunk() let curveIdentifier = reader.readNextChunk()
let publicKey = reader.readNextChunk() let publicKey = reader.readNextChunk()
let curveType = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "").data(using: .utf8)! let openSSHIdentifier = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
return writer.lengthAndData(of: curveType) + return openSSHIdentifier.lengthAndData +
writer.lengthAndData(of: curveIdentifier) + curveIdentifier.lengthAndData +
writer.lengthAndData(of: publicKey) publicKey.lengthAndData
default: default:
return nil return nil
} }
@ -78,14 +78,13 @@ public actor OpenSSHCertificateHandler: Sendable {
throw OpenSSHCertificateError.parsingFailed throw OpenSSHCertificateError.parsingFailed
} }
if certElements.count >= 3, let certName = certElements[2].data(using: .utf8) { if certElements.count >= 3 {
let certName = Data(certElements[2].utf8)
return (certDecoded, certName) return (certDecoded, certName)
} else if let certName = secret.name.data(using: .utf8) { }
let certName = Data(secret.name.utf8)
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead") logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
return (certDecoded, certName) return (certDecoded, certName)
} else {
throw OpenSSHCertificateError.parsingFailed
}
} }
} }

View File

@ -1,89 +0,0 @@
import Foundation
import CryptoKit
/// Generates OpenSSH representations of Secrets.
public struct OpenSSHKeyWriter: Sendable {
/// Initializes the writer.
public init() {
}
/// Generates an OpenSSH data payload identifying the secret.
/// - Returns: OpenSSH data payload identifying the secret.
public func data<SecretType: Secret>(secret: SecretType) -> Data {
lengthAndData(of: curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!) +
lengthAndData(of: curveIdentifier(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!) +
lengthAndData(of: secret.publicKey)
}
/// Generates an OpenSSH string representation of the secret.
/// - Returns: OpenSSH string representation of the secret.
public func openSSHString<SecretType: Secret>(secret: SecretType, comment: String? = nil) -> String {
[curveType(for: secret.algorithm, length: secret.keySize), data(secret: secret).base64EncodedString(), comment]
.compactMap { $0 }
.joined(separator: " ")
}
/// Generates an OpenSSH SHA256 fingerprint string.
/// - Returns: OpenSSH SHA256 fingerprint string.
public func openSSHSHA256Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
// OpenSSL format seems to strip the padding at the end.
let base64 = Data(SHA256.hash(data: data(secret: secret))).base64EncodedString()
let paddingRange = base64.index(base64.endIndex, offsetBy: -2)..<base64.endIndex
let cleaned = base64.replacingOccurrences(of: "=", with: "", range: paddingRange)
return "SHA256:\(cleaned)"
}
/// Generates an OpenSSH MD5 fingerprint string.
/// - Returns: OpenSSH MD5 fingerprint string.
public func openSSHMD5Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
Insecure.MD5.hash(data: data(secret: secret))
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
.joined(separator: ":")
}
}
extension OpenSSHKeyWriter {
/// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload.
/// - Parameter data: The data payload.
/// - Returns: OpenSSH data.
public func lengthAndData(of data: Data) -> Data {
let rawLength = UInt32(data.count)
var endian = rawLength.bigEndian
return Data(bytes: &endian, count: UInt32.bitWidth/8) + data
}
/// The fully qualified OpenSSH identifier for the algorithm.
/// - Parameters:
/// - algorithm: The algorithm to identify.
/// - length: The key length of the algorithm.
/// - Returns: The OpenSSH identifier for the algorithm.
public func curveType(for algorithm: Algorithm, length: Int) -> String {
switch algorithm {
case .ellipticCurve:
return "ecdsa-sha2-nistp" + String(describing: length)
case .rsa:
// All RSA keys use the same 512 bit hash function, per
// https://security.stackexchange.com/questions/255074/why-are-rsa-sha2-512-and-rsa-sha2-256-supported-but-not-reported-by-ssh-q-key
return "rsa-sha2-512"
}
}
/// The OpenSSH identifier for an algorithm.
/// - Parameters:
/// - algorithm: The algorithm to identify.
/// - length: The key length of the algorithm.
/// - Returns: The OpenSSH identifier for the algorithm.
private func curveIdentifier(for algorithm: Algorithm, length: Int) -> String {
switch algorithm {
case .ellipticCurve:
return "nistp" + String(describing: length)
case .rsa:
// All RSA keys use the same 512 bit hash function
return "rsa-sha2-512"
}
}
}

View File

@ -0,0 +1,103 @@
import Foundation
import CryptoKit
/// Generates OpenSSH representations of the public key sof secrets.
public struct OpenSSHPublicKeyWriter: Sendable {
/// Initializes the writer.
public init() {
}
/// Generates an OpenSSH data payload identifying the secret.
/// - Returns: OpenSSH data payload identifying the secret.
public func data<SecretType: Secret>(secret: SecretType) -> Data {
switch secret.keyType.algorithm {
case .ecdsa:
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
openSSHIdentifier(for: secret.keyType).lengthAndData +
("nistp" + String(describing: secret.keyType.size)).lengthAndData +
secret.publicKey.lengthAndData
case .rsa:
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
openSSHIdentifier(for: secret.keyType).lengthAndData +
rsaPublicKeyBlob(secret: secret)
}
}
/// Generates an OpenSSH string representation of the secret.
/// - Returns: OpenSSH string representation of the secret.
public func openSSHString<SecretType: Secret>(secret: SecretType) -> String {
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 [openSSHIdentifier(for: secret.keyType), data(secret: secret).base64EncodedString(), resolvedComment]
.compactMap { $0 }
.joined(separator: " ")
}
/// Generates an OpenSSH SHA256 fingerprint string.
/// - Returns: OpenSSH SHA256 fingerprint string.
public func openSSHSHA256Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
// OpenSSL format seems to strip the padding at the end.
let base64 = Data(SHA256.hash(data: data(secret: secret))).base64EncodedString()
let paddingRange = base64.index(base64.endIndex, offsetBy: -2)..<base64.endIndex
let cleaned = base64.replacingOccurrences(of: "=", with: "", range: paddingRange)
return "SHA256:\(cleaned)"
}
/// Generates an OpenSSH MD5 fingerprint string.
/// - Returns: OpenSSH MD5 fingerprint string.
public func openSSHMD5Fingerprint<SecretType: Secret>(secret: SecretType) -> String {
Insecure.MD5.hash(data: data(secret: secret))
.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }
.joined(separator: ":")
}
}
extension OpenSSHPublicKeyWriter {
/// The fully qualified OpenSSH identifier for the algorithm.
/// - Parameters:
/// - algorithm: The algorithm to identify.
/// - length: The key length of the algorithm.
/// - Returns: The OpenSSH identifier for the algorithm.
public func openSSHIdentifier(for keyType: KeyType) -> String {
switch (keyType.algorithm, keyType.size) {
case (.ecdsa, 256), (.ecdsa, 384):
"ecdsa-sha2-nistp" + String(describing: keyType.size)
case (.rsa, _):
"ssh-rsa"
default:
"unknown"
}
}
}
extension OpenSSHPublicKeyWriter {
public func rsaPublicKeyBlob<SecretType: Secret>(secret: SecretType) -> Data {
// Cheap way to pull out e and n as defined in https://datatracker.ietf.org/doc/html/rfc4253
// Keychain stores it as a thin ASN.1 wrapper with this format:
// [4 byte prefix][2 byte prefix][n][2 byte prefix][e]
// Rather than parse out the whole ASN.1 blob, we'll cheat and pull values directly since
// we only support one key type, and the keychain always gives it in a specific format.
let keySize = secret.keyType.size
guard secret.keyType.algorithm == .rsa && keySize == 2048 else { fatalError() }
let length = secret.keyType.size/8
let data = secret.publicKey
let n = Data(data[8..<(9+length)])
let e = Data(data[(2+9+length)...])
return e.lengthAndData + n.lengthAndData
}
}

View File

@ -17,9 +17,7 @@ public final class OpenSSHReader {
let lengthRange = 0..<(UInt32.bitWidth/8) let lengthRange = 0..<(UInt32.bitWidth/8)
let lengthChunk = remaining[lengthRange] let lengthChunk = remaining[lengthRange]
remaining.removeSubrange(lengthRange) remaining.removeSubrange(lengthRange)
let littleEndianLength = lengthChunk.withUnsafeBytes { pointer in let littleEndianLength = lengthChunk.bytes.unsafeLoad(as: UInt32.self)
return pointer.load(as: UInt32.self)
}
let length = Int(littleEndianLength.bigEndian) let length = Int(littleEndianLength.bigEndian)
let dataRange = 0..<length let dataRange = 0..<length
let ret = Data(remaining[dataRange]) let ret = Data(remaining[dataRange])

View File

@ -0,0 +1,63 @@
import Foundation
import CryptoKit
/// Generates OpenSSH representations of Secrets.
public struct OpenSSHSignatureWriter: Sendable {
/// Initializes the writer.
public init() {
}
/// Generates an OpenSSH data payload identifying the secret.
/// - Returns: OpenSSH data payload identifying the secret.
public func data<SecretType: Secret>(secret: SecretType, signature: Data) -> Data {
switch secret.keyType.algorithm {
case .ecdsa:
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
ecdsaSignature(signature, keyType: secret.keyType)
case .rsa:
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
rsaSignature(signature)
}
}
}
extension OpenSSHSignatureWriter {
func ecdsaSignature(_ rawRepresentation: Data, keyType: KeyType) -> Data {
let rawLength = rawRepresentation.count/2
// Check if we need to pad with 0x00 to prevent certain
// ssh servers from thinking r or s is negative
let paddingRange: ClosedRange<UInt8> = 0x80...0xFF
var r = Data(rawRepresentation[0..<rawLength])
if paddingRange ~= r.first! {
r.insert(0x00, at: 0)
}
var s = Data(rawRepresentation[rawLength...])
if paddingRange ~= s.first! {
s.insert(0x00, at: 0)
}
var signatureChunk = Data()
signatureChunk.append(r.lengthAndData)
signatureChunk.append(s.lengthAndData)
var mutSignedData = Data()
var sub = Data()
sub.append(OpenSSHPublicKeyWriter().openSSHIdentifier(for: keyType).lengthAndData)
sub.append(signatureChunk.lengthAndData)
mutSignedData.append(sub.lengthAndData)
return mutSignedData
}
func rsaSignature(_ rawRepresentation: Data) -> Data {
var mutSignedData = Data()
var sub = Data()
sub.append("rsa-sha2-512".lengthAndData)
sub.append(rawRepresentation.lengthAndData)
mutSignedData.append(sub.lengthAndData)
return mutSignedData
}
}

View File

@ -6,7 +6,7 @@ public final class PublicKeyFileStoreController: Sendable {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController") private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController")
private let directory: String private let directory: String
private let keyWriter = OpenSSHKeyWriter() private let keyWriter = OpenSSHPublicKeyWriter()
/// Initializes a PublicKeyFileStoreController. /// Initializes a PublicKeyFileStoreController.
public init(homeDirectory: String) { public init(homeDirectory: String) {
@ -32,7 +32,7 @@ public final class PublicKeyFileStoreController: Sendable {
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil) try? FileManager.default.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil)
for secret in secrets { for secret in secrets {
let path = publicKeyPath(for: secret) let path = publicKeyPath(for: secret)
guard let data = keyWriter.openSSHString(secret: secret).data(using: .utf8) else { continue } let data = Data(keyWriter.openSSHString(secret: secret).utf8)
FileManager.default.createFile(atPath: path, contents: data, attributes: nil) FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
} }
logger.log("Finished writing public keys") logger.log("Finished writing public keys")

View File

@ -20,7 +20,7 @@ import Observation
/// Adds a non-type-erased modifiable SecretStore. /// Adds a non-type-erased modifiable SecretStore.
public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) { public func add<SecretStoreType: SecretStoreModifiable>(store: SecretStoreType) {
let modifiable = AnySecretStoreModifiable(modifiable: store) let modifiable = AnySecretStoreModifiable(store)
if modifiableStore == nil { if modifiableStore == nil {
modifiableStore = modifiable modifiableStore = modifiable
} }

View File

@ -0,0 +1,55 @@
import Foundation
public struct Attributes: Sendable, Codable, Hashable {
/// The type of key involved.
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.
public let authentication: AuthenticationRequirement
/// The string appended to the end of the SSH Public Key.
/// If nil, a default value will be used.
public var publicKeyAttribution: String?
public init(
keyType: KeyType,
authentication: AuthenticationRequirement,
publicKeyAttribution: String? = nil
) {
self.keyType = keyType
self.authentication = authentication
self.publicKeyAttribution = publicKeyAttribution
}
public struct UnsupportedOptionError: Error {
package init() {}
}
}
/// The option specified
public enum AuthenticationRequirement: String, Hashable, Sendable, Codable, Identifiable {
/// Authentication is not required for usage.
case notRequired
/// The user needs to authenticate, using either a biometric option, a connected authorized watch, or password entry..
case presenceRequired
/// ONLY the current set of biometric data, as matching at time of creation, is accepted.
/// - Warning: This is a dangerous option prone to data loss. The user should be warned before configuring this key that if they modify their enrolled biometry INCLUDING by simply adding a new entry (ie, adding another fingeprting), the key will no longer be able to be accessed. This cannot be overridden with a password.
case biometryCurrent
/// The authentication requirement was not recorded at creation, and is unknown.
case unknown
/// Whether or not the key is known to require authentication.
public var required: Bool {
self == .presenceRequired || self == .biometryCurrent
}
public var id: AuthenticationRequirement {
self
}
}

View File

@ -5,43 +5,72 @@ 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 algorithm: Algorithm { get }
/// The key size for the secret.
var keySize: Int { get }
/// Whether the secret requires authentication before use.
var requiresAuthentication: Bool { 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 }
} }
/// The type of algorithm the Secret uses. Currently, only elliptic curve algorithms are supported. public extension Secret {
public enum Algorithm: Hashable, Sendable {
case ellipticCurve /// 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.
var publicKeyAttribution: String? {
attributes.publicKeyAttribution
}
}
/// The type of algorithm the Secret uses.
public struct KeyType: Hashable, Sendable, Codable, CustomStringConvertible {
public enum Algorithm: Hashable, Sendable, Codable {
case ecdsa
case rsa case rsa
}
public var algorithm: Algorithm
public var size: Int
public init(algorithm: Algorithm, size: Int) {
self.algorithm = algorithm
self.size = size
}
/// Initializes the Algorithm with a secAttr representation of an algorithm. /// Initializes the Algorithm with a secAttr representation of an algorithm.
/// - Parameter secAttr: the secAttr, represented as an NSNumber. /// - Parameter secAttr: the secAttr, represented as an NSNumber.
public init(secAttr: NSNumber) { public init?(secAttr: NSNumber, size: Int) {
let secAttrString = secAttr.stringValue as CFString let secAttrString = secAttr.stringValue as CFString
switch secAttrString { switch secAttrString {
case kSecAttrKeyTypeEC: case kSecAttrKeyTypeEC:
self = .ellipticCurve algorithm = .ecdsa
case kSecAttrKeyTypeRSA: case kSecAttrKeyTypeRSA:
self = .rsa algorithm = .rsa
default: default:
fatalError() return nil
}
self.size = size
}
public var secAttrKeyType: CFString? {
switch algorithm {
case .ecdsa:
kSecAttrKeyTypeEC
case .rsa:
kSecAttrKeyTypeRSA
} }
} }
public var secAttrKeyType: CFString { public var description: String {
switch self { "\(algorithm)-\(size)"
case .ellipticCurve:
return kSecAttrKeyTypeEC
case .rsa:
return kSecAttrKeyTypeRSA
}
} }
} }

View File

@ -1,7 +1,7 @@
import Foundation import Foundation
/// Manages access to Secrets, and performs signature operations on data using those Secrets. /// Manages access to Secrets, and performs signature operations on data using those Secrets.
public protocol SecretStore: Identifiable, Sendable { public protocol SecretStore<SecretType>: Identifiable, Sendable {
associatedtype SecretType: Secret associatedtype SecretType: Secret
@ -41,13 +41,13 @@ public protocol SecretStore: Identifiable, Sendable {
} }
/// A SecretStore that the Secretive admin app can modify. /// A SecretStore that the Secretive admin app can modify.
public protocol SecretStoreModifiable: SecretStore { public protocol SecretStoreModifiable<SecretType>: SecretStore {
/// Creates a new ``Secret`` in the store. /// Creates a new ``Secret`` in the store.
/// - Parameters: /// - Parameters:
/// - name: The user-facing name for the ``Secret``. /// - name: The user-facing name for the ``Secret``.
/// - requiresAuthentication: A boolean indicating whether or not the user will be required to authenticate before performing signature operations with the secret. /// - attributes: A struct describing the options for creating the key.
func create(name: String, requiresAuthentication: Bool) async throws func create(name: String, attributes: Attributes) async throws
/// Deletes a Secret in the store. /// Deletes a Secret in the store.
/// - Parameters: /// - Parameters:
@ -58,7 +58,10 @@ 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 }
} }

View File

@ -0,0 +1,99 @@
import Foundation
import Security
import CryptoTokenKit
import CryptoKit
import SecretKit
import os
extension SecureEnclave {
public struct CryptoKitMigrator {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CryptoKitMigrator")
public init() {
}
/// Keys prior to 3.0 were created and stored directly using the keychain as kSecClassKey items. CryptoKit operates a little differently, in that it creates a key on your behalf which you can persist using an opaque data blob to a generic keychain item. Keychain created keys _also_ use this blob under the hood, but it's stored in the "toid" attribute. This migrates the old keys from kSecClassKey to generic items, copying the "toid" to be the main stored data. If the key is migrated successfully, the old key's identifier is renamed to indicate it's been migrated.
/// - Note: Migration is non-destructive users can still see and use their keys in older versions of Secretive.
@MainActor public func migrate(to store: Store) throws {
let privateAttributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyType: Constants.oldKeyType,
kSecAttrApplicationTag: SecureEnclave.Store.Constants.keyTag,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true
])
var privateUntyped: CFTypeRef?
SecItemCopyMatching(privateAttributes, &privateUntyped)
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
let migratedPublicKeys = Set(store.secrets.map(\.publicKey))
var migrated = false
for key in privateTyped {
let name = key[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
let id = key[kSecAttrApplicationLabel] as! Data
guard !id.contains(Constants.migrationMagicNumber) else {
logger.log("Skipping \(name), already migrated.")
continue
}
let ref = key[kSecValueRef] as! SecKey
let attributes = SecKeyCopyAttributes(ref) as! [CFString: Any]
let tokenObjectID = attributes[Constants.tokenObjectID] as! Data
let accessControl = attributes[kSecAttrAccessControl] as! SecAccessControl
// Best guess.
let auth: AuthenticationRequirement = String(describing: accessControl)
.contains("DeviceOwnerAuthentication") ? .presenceRequired : .unknown
let parsed = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: tokenObjectID)
let secret = Secret(id: id, name: name, publicKey: parsed.publicKey.x963Representation, attributes: Attributes(keyType: .init(algorithm: .ecdsa, size: 256), authentication: auth))
guard !migratedPublicKeys.contains(parsed.publicKey.x963Representation) else {
logger.log("Skipping \(name), public key already present. Marking as migrated.")
try markMigrated(secret: secret)
continue
}
logger.log("Migrating \(name).")
try store.saveKey(tokenObjectID, name: name, attributes: secret.attributes)
logger.log("Migrated \(name).")
try markMigrated(secret: secret)
migrated = true
}
if migrated {
store.reloadSecrets()
}
}
public func markMigrated(secret: Secret) throws {
let updateQuery = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrApplicationLabel: secret.id as CFData
])
let newID = secret.id + Constants.migrationMagicNumber
let updatedAttributes = KeychainDictionary([
kSecAttrApplicationLabel: newID as CFData
])
let status = SecItemUpdate(updateQuery, updatedAttributes)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
}
}
}
extension SecureEnclave.CryptoKitMigrator {
enum Constants {
public static let oldKeyType = kSecAttrKeyTypeECSECPrimeRandom as String
public static let migrationMagicNumber = Data("_cryptokit_1".utf8)
// https://github.com/apple-opensource/Security/blob/5e9101b3bd1fb096bae4f40e79d50426ba1db8e9/OSX/sec/Security/SecItemConstants.c#L111
public static nonisolated(unsafe) let tokenObjectID = "toid" as CFString
}
}

View File

@ -8,10 +8,20 @@ extension SecureEnclave {
public let id: Data public let id: Data
public let name: String public let name: String
public let algorithm = Algorithm.ellipticCurve
public let keySize = 256
public let requiresAuthentication: Bool
public let publicKey: Data public let publicKey: Data
public let attributes: Attributes
init(
id: Data,
name: String,
publicKey: Data,
attributes: Attributes
) {
self.id = id
self.name = name
self.publicKey = publicKey
self.attributes = attributes
}
} }

View File

@ -2,12 +2,13 @@ import Foundation
import Observation import Observation
import Security import Security
import CryptoKit import CryptoKit
import LocalAuthentication @preconcurrency import LocalAuthentication
import SecretKit import SecretKit
import os
extension SecureEnclave { extension SecureEnclave {
/// An implementation of Store backed by the Secure Enclave. /// An implementation of Store backed by the Secure Enclave using CryptoKit API.
@Observable public final class Store: SecretStoreModifiable { @Observable public final class Store: SecretStoreModifiable {
@MainActor public var secrets: [Secret] = [] @MainActor public var secrets: [Secret] = []
@ -23,66 +24,119 @@ extension SecureEnclave {
loadSecrets() loadSecrets()
Task { Task {
for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) { for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) {
await reloadSecretsInternal(notifyAgent: false) reloadSecrets()
} }
} }
} }
// MARK: Public API // MARK: - Public API
public func create(name: String, requiresAuthentication: Bool) async throws { // MARK: SecretStore
var accessError: SecurityError?
let flags: SecAccessControlCreateFlags public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
if requiresAuthentication { var context: LAContext
flags = [.privateKeyUsage, .userPresence] if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) {
context = existing.context
} else { } else {
flags = .privateKeyUsage let newContext = LAContext()
newContext.localizedCancelTitle = String(localized: "auth_context_request_deny_button")
context = newContext
}
context.localizedReason = String(localized: "auth_context_request_signature_description_\(provenance.origin.displayName)_\(secret.name)")
let queryAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecUseDataProtectionKeychain: true,
kSecAttrAccount: String(decoding: secret.id, as: UTF8.self),
kSecReturnAttributes: true,
kSecReturnData: true
])
var untyped: CFTypeRef?
let status = SecItemCopyMatching(queryAttributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
guard let untypedSafe = untyped as? [CFString: Any] else {
throw KeychainError(statusCode: errSecSuccess)
}
guard let attributesData = untypedSafe[kSecAttrGeneric] as? Data,
let keyData = untypedSafe[kSecValueData] as? Data else {
throw MissingAttributesError()
}
let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData)
switch (attributes.keyType.algorithm, attributes.keyType.size) {
case (.ecdsa, 256):
let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData)
return try key.signature(for: data).rawRepresentation
default:
throw UnsupportedAlgorithmError()
}
}
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
}
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
}
@MainActor public func reloadSecrets() {
reloadSecretsInternal(notifyAgent: false)
}
// MARK: SecretStoreModifiable
public func create(name: String, attributes: Attributes) async throws {
var accessError: SecurityError?
let flags: SecAccessControlCreateFlags = switch attributes.authentication {
case .notRequired:
[]
case .presenceRequired:
.userPresence
case .biometryCurrent:
.biometryCurrentSet
case .unknown:
fatalError()
} }
let access = let access =
SecAccessControlCreateWithFlags(kCFAllocatorDefault, SecAccessControlCreateWithFlags(kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly, kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
flags, flags,
&accessError) as Any &accessError)
if let error = accessError { if let error = accessError {
throw error.takeRetainedValue() as Error throw error.takeRetainedValue() as Error
} }
let dataRep: Data
let attributes = KeychainDictionary([ switch (attributes.keyType.algorithm, attributes.keyType.size) {
kSecAttrLabel: name, case (.ecdsa, 256):
kSecAttrKeyType: Constants.keyType, let created = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(accessControl: access!)
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave, dataRep = created.dataRepresentation
kSecAttrApplicationTag: Constants.keyTag, default:
kSecPrivateKeyAttrs: [ throw Attributes.UnsupportedOptionError()
kSecAttrIsPermanent: true,
kSecAttrAccessControl: access
]
])
var createKeyError: SecurityError?
let keypair = SecKeyCreateRandomKey(attributes, &createKeyError)
if let error = createKeyError {
throw error.takeRetainedValue() as Error
} }
guard let keypair = keypair, let publicKey = SecKeyCopyPublicKey(keypair) else { try saveKey(dataRep, name: name, attributes: attributes)
throw KeychainError(statusCode: nil) await reloadSecrets()
}
try savePublicKey(publicKey, name: name)
await reloadSecretsInternal()
} }
public func delete(secret: Secret) async throws { public func delete(secret: Secret) async throws {
let deleteAttributes = KeychainDictionary([ let deleteAttributes = KeychainDictionary([
kSecClass: kSecClassKey, kSecClass: Constants.keyClass,
kSecAttrApplicationLabel: secret.id as CFData kSecAttrService: Constants.keyTag,
kSecUseDataProtectionKeychain: true,
kSecAttrAccount: String(decoding: secret.id, as: UTF8.self)
]) ])
let status = SecItemDelete(deleteAttributes) let status = SecItemDelete(deleteAttributes)
if status != errSecSuccess { if status != errSecSuccess {
throw KeychainError(statusCode: status) throw KeychainError(statusCode: status)
} }
await reloadSecretsInternal() await reloadSecrets()
} }
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
@ -96,56 +150,13 @@ extension SecureEnclave {
if status != errSecSuccess { if status != errSecSuccess {
throw KeychainError(statusCode: status) throw KeychainError(statusCode: status)
} }
await reloadSecretsInternal() await reloadSecrets()
} }
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { public var supportedKeyTypes: [KeyType] {
let context: LAContext [
if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) { .init(algorithm: .ecdsa, size: 256),
context = existing.context ]
} else {
let newContext = LAContext()
newContext.localizedCancelTitle = String(localized: .authContextRequestDenyButton)
context = newContext
}
context.localizedReason = String(localized: .authContextRequestSignatureDescription(appName: provenance.origin.displayName, secretName: secret.name))
let attributes = KeychainDictionary([
kSecClass: kSecClassKey,
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
kSecAttrApplicationLabel: secret.id as CFData,
kSecAttrKeyType: Constants.keyType,
kSecAttrTokenID: kSecAttrTokenIDSecureEnclave,
kSecAttrApplicationTag: Constants.keyTag,
kSecUseAuthenticationContext: context,
kSecReturnRef: true
])
var untyped: CFTypeRef?
let status = SecItemCopyMatching(attributes, &untyped)
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
var signError: SecurityError?
guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &signError) else {
throw SigningError(error: signError)
}
return signature as Data
}
public func existingPersistedAuthenticationContext(secret: Secret) async -> PersistedAuthenticationContext? {
await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret)
}
public func persistAuthentication(secret: Secret, forDuration duration: TimeInterval) async throws {
try await persistentAuthenticationHandler.persistAuthentication(secret: secret, forDuration: duration)
}
public func reloadSecrets() async {
await reloadSecretsInternal(notifyAgent: false)
} }
} }
@ -154,9 +165,7 @@ extension SecureEnclave {
extension SecureEnclave.Store { extension SecureEnclave.Store {
/// Reloads all secrets from the store. @MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) {
/// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well.
@MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) async {
let before = secrets let before = secrets
secrets.removeAll() secrets.removeAll()
loadSecrets() loadSecrets()
@ -170,75 +179,62 @@ extension SecureEnclave.Store {
/// Loads all secrets from the store. /// Loads all secrets from the store.
@MainActor private func loadSecrets() { @MainActor private func loadSecrets() {
let publicAttributes = KeychainDictionary([ let queryAttributes = KeychainDictionary([
kSecClass: kSecClassKey, kSecClass: Constants.keyClass,
kSecAttrKeyType: SecureEnclave.Constants.keyType, kSecAttrService: Constants.keyTag,
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag, kSecUseDataProtectionKeychain: true,
kSecAttrKeyClass: kSecAttrKeyClassPublic, kSecReturnData: true,
kSecReturnRef: true,
kSecMatchLimit: kSecMatchLimitAll, kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true kSecReturnAttributes: true
]) ])
var publicUntyped: CFTypeRef? var untyped: CFTypeRef?
SecItemCopyMatching(publicAttributes, &publicUntyped) SecItemCopyMatching(queryAttributes, &untyped)
guard let publicTyped = publicUntyped as? [[CFString: Any]] else { return } guard let typed = untyped as? [[CFString: Any]] else { return }
let privateAttributes = KeychainDictionary([ let wrapped: [SecureEnclave.Secret] = typed.compactMap {
kSecClass: kSecClassKey, do {
kSecAttrKeyType: SecureEnclave.Constants.keyType, let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret")
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag, guard let attributesData = $0[kSecAttrGeneric] as? Data,
kSecAttrKeyClass: kSecAttrKeyClassPrivate, let idString = $0[kSecAttrAccount] as? String else {
kSecReturnRef: true, throw MissingAttributesError()
kSecMatchLimit: kSecMatchLimitAll,
kSecReturnAttributes: true
])
var privateUntyped: CFTypeRef?
SecItemCopyMatching(privateAttributes, &privateUntyped)
guard let privateTyped = privateUntyped as? [[CFString: Any]] else { return }
let privateMapped = privateTyped.reduce(into: [:] as [Data: [CFString: Any]]) { partialResult, next in
let id = next[kSecAttrApplicationLabel] as! Data
partialResult[id] = next
} }
let authNotRequiredAccessControl: SecAccessControl = let id = Data(idString.utf8)
SecAccessControlCreateWithFlags(kCFAllocatorDefault, let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData)
kSecAttrAccessibleWhenUnlockedThisDeviceOnly, let keyData = $0[kSecValueData] as! Data
[.privateKeyUsage], let publicKey: Data
nil)! switch (attributes.keyType.algorithm, attributes.keyType.size) {
case (.ecdsa, 256):
let wrapped: [SecureEnclave.Secret] = publicTyped.map { let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData)
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret) publicKey = key.publicKey.x963Representation
let id = $0[kSecAttrApplicationLabel] as! Data default:
let publicKeyRef = $0[kSecValueRef] as! SecKey throw UnsupportedAlgorithmError()
let publicKeyAttributes = SecKeyCopyAttributes(publicKeyRef) as! [CFString: Any] }
let publicKey = publicKeyAttributes[kSecValueData] as! Data return SecureEnclave.Secret(id: id, name: name, publicKey: publicKey, attributes: attributes)
let privateKey = privateMapped[id] } catch {
let requiresAuth: Bool return nil
if let authRequirements = privateKey?[kSecAttrAccessControl] {
// Unfortunately we can't inspect the access control object directly, but it does behave predicatable with equality.
requiresAuth = authRequirements as! SecAccessControl != authNotRequiredAccessControl
} else {
requiresAuth = false
} }
return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey)
} }
secrets.append(contentsOf: wrapped) secrets.append(contentsOf: wrapped)
} }
/// Saves a public key. /// Saves a public key.
/// - Parameters: /// - Parameters:
/// - publicKey: The public key to save. /// - key: The data representation key to save.
/// - name: A user-facing name for the key. /// - name: A user-facing name for the key.
private func savePublicKey(_ publicKey: SecKey, name: String) throws { /// - attributes: Attributes of the key.
let attributes = KeychainDictionary([ /// - Note: Despite the name, the "Data" of the key is _not_ actual key material. This is an opaque data representation that the SEP can manipulate.
kSecClass: kSecClassKey, func saveKey(_ key: Data, name: String, attributes: Attributes) throws {
kSecAttrKeyType: SecureEnclave.Constants.keyType, let attributes = try JSONEncoder().encode(attributes)
kSecAttrKeyClass: kSecAttrKeyClassPublic, let keychainAttributes = KeychainDictionary([
kSecAttrApplicationTag: SecureEnclave.Constants.keyTag, kSecClass: Constants.keyClass,
kSecValueRef: publicKey, kSecAttrService: Constants.keyTag,
kSecAttrIsPermanent: true, kSecUseDataProtectionKeychain: true,
kSecReturnData: true, kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
kSecAttrLabel: name kSecAttrAccount: UUID().uuidString,
kSecValueData: key,
kSecAttrLabel: name,
kSecAttrGeneric: attributes
]) ])
let status = SecItemAdd(attributes, nil) let status = SecItemAdd(keychainAttributes, nil)
if status != errSecSuccess { if status != errSecSuccess {
throw KeychainError(statusCode: status) throw KeychainError(statusCode: status)
} }
@ -246,12 +242,14 @@ extension SecureEnclave.Store {
} }
extension SecureEnclave { extension SecureEnclave.Store {
public enum Constants { enum Constants {
public static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8) static let keyClass = kSecClassGenericPassword as String
public static let keyType = kSecAttrKeyTypeECSECPrimeRandom as String static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8)
static let unauthenticatedThreshold: TimeInterval = 0.05
} }
struct UnsupportedAlgorithmError: Error {}
struct MissingAttributesError: Error {}
} }

View File

@ -8,10 +8,8 @@ extension SmartCard {
public let id: Data public let id: Data
public let name: String public let name: String
public let algorithm: Algorithm
public let keySize: Int
public let requiresAuthentication: Bool = false
public let publicKey: Data public let publicKey: Data
public var attributes: Attributes
} }

View File

@ -56,14 +56,6 @@ extension SmartCard {
// MARK: Public API // MARK: Public API
public func create(name: String) throws {
fatalError("Keys must be created on the smart card.")
}
public func delete(secret: Secret) throws {
fatalError("Keys must be deleted on the smart card.")
}
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data {
guard let tokenID = await state.tokenID else { fatalError() } guard let tokenID = await state.tokenID else { fatalError() }
let context = LAContext() let context = LAContext()
@ -87,7 +79,8 @@ extension SmartCard {
} }
let key = untypedSafe as! SecKey let key = untypedSafe as! SecKey
var signError: SecurityError? var signError: SecurityError?
guard let signature = SecKeyCreateSignature(key, signatureAlgorithm(for: secret, allowRSA: true), data as CFData, &signError) else { guard let algorithm = signatureAlgorithm(for: secret) else { throw UnsupportKeyType() }
guard let signature = SecKeyCreateSignature(key, algorithm, data as CFData, &signError) else {
throw SigningError(error: signError) throw SigningError(error: signError)
} }
return signature as Data return signature as Data
@ -161,16 +154,19 @@ extension SmartCard.Store {
var untyped: CFTypeRef? var untyped: CFTypeRef?
SecItemCopyMatching(attributes, &untyped) SecItemCopyMatching(attributes, &untyped)
guard let typed = untyped as? [[CFString: Any]] else { return } guard let typed = untyped as? [[CFString: Any]] else { return }
let wrapped = typed.map { let wrapped: [SecretType] = typed.compactMap {
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret) let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
let tokenID = $0[kSecAttrApplicationLabel] as! Data let tokenID = $0[kSecAttrApplicationLabel] as! Data
let algorithm = Algorithm(secAttr: $0[kSecAttrKeyType] as! NSNumber) let algorithmSecAttr = $0[kSecAttrKeyType] as! NSNumber
let keySize = $0[kSecAttrKeySizeInBits] as! Int let keySize = $0[kSecAttrKeySizeInBits] as! Int
let publicKeyRef = $0[kSecValueRef] as! SecKey let publicKeyRef = $0[kSecValueRef] as! SecKey
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, algorithm: algorithm, keySize: keySize, publicKey: publicKey) let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, authentication: .unknown)
let secret = SmartCard.Secret(id: tokenID, name: name, publicKey: publicKey, attributes: attributes)
guard signatureAlgorithm(for: secret) != nil else { return nil }
return secret
} }
state.secrets.append(contentsOf: wrapped) state.secrets.append(contentsOf: wrapped)
} }
@ -185,3 +181,9 @@ extension TKTokenWatcher {
} }
} }
extension SmartCard {
public struct UnsupportKeyType: Error {}
}

View File

@ -35,7 +35,7 @@ import CryptoKit
#expect(stubWriter.data == Constants.Responses.requestFailure) #expect(stubWriter.data == Constants.Responses.requestFailure)
} }
@Test func signature() async throws { @Test func ecdsaSignature() async throws {
let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature)
let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...]) let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...])
_ = requestReader.readNextChunk() _ = requestReader.readNextChunk()

View File

@ -45,20 +45,15 @@ extension Stub {
let privateData = (privateAttributes[kSecValueData] as! Data) let privateData = (privateAttributes[kSecValueData] as! Data)
let secret = Secret(keySize: size, publicKey: publicData, privateKey: privateData) let secret = Secret(keySize: size, publicKey: publicData, privateKey: privateData)
print(secret) print(secret)
print("Public Key OpenSSH: \(OpenSSHKeyWriter().openSSHString(secret: secret))") print("Public Key OpenSSH: \(OpenSSHPublicKeyWriter().openSSHString(secret: secret))")
} }
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data { public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {
guard !shouldThrow else { guard !shouldThrow else {
throw NSError(domain: "test", code: 0, userInfo: nil) throw NSError(domain: "test", code: 0, userInfo: nil)
} }
let privateKey = SecKeyCreateWithData(secret.privateKey as CFData, KeychainDictionary([ let privateKey = try CryptoKit.P256.Signing.PrivateKey(x963Representation: secret.privateKey)
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, return try privateKey.signature(for: data).rawRepresentation
kSecAttrKeySizeInBits: secret.keySize,
kSecAttrKeyClass: kSecAttrKeyClassPrivate
])
, nil)!
return SecKeyCreateSignature(privateKey, signatureAlgorithm(for: secret), data as CFData, nil)! as Data
} }
public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? { public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? {
@ -79,24 +74,22 @@ extension Stub {
struct Secret: SecretKit.Secret, CustomDebugStringConvertible { struct Secret: SecretKit.Secret, CustomDebugStringConvertible {
let id = UUID().uuidString.data(using: .utf8)! let id = Data(UUID().uuidString.utf8)
let name = UUID().uuidString let name = UUID().uuidString
let algorithm = Algorithm.ellipticCurve let attributes: Attributes
let keySize: Int
let publicKey: Data let publicKey: Data
let requiresAuthentication = false let requiresAuthentication = false
let privateKey: Data let privateKey: Data
init(keySize: Int, publicKey: Data, privateKey: Data) { init(keySize: Int, publicKey: Data, privateKey: Data) {
self.keySize = keySize self.attributes = Attributes(keyType: .init(algorithm: .ecdsa, size: keySize), authentication: .notRequired)
self.publicKey = publicKey self.publicKey = publicKey
self.privateKey = privateKey self.privateKey = privateKey
} }
var debugDescription: String { var debugDescription: String {
""" """
Key Size \(keySize) Key Size \(keyType.size)
Private: \(privateKey.base64EncodedString()) Private: \(privateKey.base64EncodedString())
Public: \(publicKey.base64EncodedString()) Public: \(publicKey.base64EncodedString())
""" """

View File

@ -4,15 +4,16 @@ import Testing
@testable import SecureEnclaveSecretKit @testable import SecureEnclaveSecretKit
@testable import SmartCardSecretKit @testable import SmartCardSecretKit
@Suite struct AnySecretTests { @Suite struct AnySecretTests {
@Test func eraser() { @Test func eraser() {
let secret = SmartCard.Secret(id: UUID().uuidString.data(using: .utf8)!, name: "Name", algorithm: .ellipticCurve, keySize: 256, publicKey: UUID().uuidString.data(using: .utf8)!) let data = Data(UUID().uuidString.utf8)
let secret = SmartCard.Secret(id: data, name: "Name", publicKey: data, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired))
let erased = AnySecret(secret) let erased = AnySecret(secret)
#expect(erased.id == secret.id as AnyHashable) #expect(erased.id == secret.id as AnyHashable)
#expect(erased.name == secret.name) #expect(erased.name == secret.name)
#expect(erased.algorithm == secret.algorithm) #expect(erased.keyType == secret.keyType)
#expect(erased.keySize == secret.keySize)
#expect(erased.publicKey == secret.publicKey) #expect(erased.publicKey == secret.publicKey)
} }

View File

@ -4,9 +4,9 @@ import Testing
@testable import SecureEnclaveSecretKit @testable import SecureEnclaveSecretKit
@testable import SmartCardSecretKit @testable import SmartCardSecretKit
@Suite struct OpenSSHWriterTests { @Suite struct OpenSSHPublicKeyWriterTests {
let writer = OpenSSHKeyWriter() let writer = OpenSSHPublicKeyWriter()
@Test func ecdsa256MD5Fingerprint() { @Test func ecdsa256MD5Fingerprint() {
#expect(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa256Secret) == "dc:60:4d:ff:c2:d9:18:8b:2f:24:40:b5:7f:43:47:e5") #expect(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa256Secret) == "dc:60:4d:ff:c2:d9:18:8b:2f:24:40:b5:7f:43:47:e5")
@ -18,7 +18,7 @@ import Testing
@Test func ecdsa256PublicKey() { @Test func ecdsa256PublicKey() {
#expect(writer.openSSHString(secret: Constants.ecdsa256Secret) == #expect(writer.openSSHString(secret: Constants.ecdsa256Secret) ==
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=") "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo= test@example.com")
} }
@Test func ecdsa256Hash() { @Test func ecdsa256Hash() {
@ -35,7 +35,7 @@ import Testing
@Test func ecdsa384PublicKey() { @Test func ecdsa384PublicKey() {
#expect(writer.openSSHString(secret: Constants.ecdsa384Secret) == #expect(writer.openSSHString(secret: Constants.ecdsa384Secret) ==
"ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==") "ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ== test@example.com")
} }
@Test func ecdsa384Hash() { @Test func ecdsa384Hash() {
@ -44,11 +44,11 @@ import Testing
} }
extension OpenSSHWriterTests { extension OpenSSHPublicKeyWriterTests {
enum Constants { enum Constants {
static let ecdsa256Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 256)", algorithm: .ellipticCurve, keySize: 256, publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!) static let ecdsa256Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 256)", publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
static let ecdsa384Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 384)", algorithm: .ellipticCurve, keySize: 384, publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!) static let ecdsa384Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 384)", publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 384), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
} }

View File

@ -12,7 +12,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@MainActor private let storeList: SecretStoreList = { @MainActor private let storeList: SecretStoreList = {
let list = SecretStoreList() let list = SecretStoreList()
list.add(store: SecureEnclave.Store()) let cryptoKit = SecureEnclave.Store()
let migrator = SecureEnclave.CryptoKitMigrator()
try? migrator.migrate(to: cryptoKit)
list.add(store: cryptoKit)
list.add(store: SmartCard.Store()) list.add(store: SmartCard.Store())
return list return list
}() }()
@ -46,6 +49,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
updater.update updater.update
} onChange: { [updater, notifier] in } onChange: { [updater, notifier] in
Task { Task {
guard !updater.testBuild else { return }
await notifier.notify(update: updater.update!) { release in await notifier.notify(update: updater.update!) { release in
await updater.ignore(release: release) await updater.ignore(release: release)
} }

View File

@ -69,7 +69,7 @@ final class Notifier: Sendable {
notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description
notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description
notificationContent.interruptionLevel = .timeSensitive notificationContent.interruptionLevel = .timeSensitive
if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.requiresAuthentication { if await store.existingPersistedAuthenticationContext(secret: secret) == nil && secret.authenticationRequirement.required {
notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier notificationContent.categoryIdentifier = Constants.persistAuthenticationCategoryIdentitifier
} }
if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) { if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) {

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

@ -1,4 +1,3 @@
import Cocoa
import SwiftUI import SwiftUI
import SecretKit import SecretKit
import SecureEnclaveSecretKit import SecureEnclaveSecretKit
@ -10,7 +9,10 @@ extension EnvironmentValues {
// This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip). // This is injected through .environment modifier below instead of @Entry for performance reasons (basially, restrictions around init/mainactor causing delay in loading secrets/"empty screen" blip).
@MainActor fileprivate static let _secretStoreList: SecretStoreList = { @MainActor fileprivate static let _secretStoreList: SecretStoreList = {
let list = SecretStoreList() let list = SecretStoreList()
list.add(store: SecureEnclave.Store()) let cryptoKit = SecureEnclave.Store()
let migrator = SecureEnclave.CryptoKitMigrator()
try? migrator.migrate(to: cryptoKit)
list.add(store: cryptoKit)
list.add(store: SmartCard.Store()) list.add(store: SmartCard.Store())
return list return list
}() }()

View File

@ -56,7 +56,7 @@ struct ShellConfigurationController {
} catch { } catch {
return false return false
} }
handle.write("\n# Secretive Config\n\(shellInstructions.text)\n".data(using: .utf8)!) handle.write(Data("\n# Secretive Config\n\(shellInstructions.text)\n".utf8))
return true return true
} }

View File

@ -9,11 +9,13 @@ extension Preview {
let id = UUID().uuidString let id = UUID().uuidString
let name: String let name: String
let algorithm = Algorithm.ellipticCurve let publicKey = Data(UUID().uuidString.utf8)
let keySize = 256 var attributes: Attributes {
let requiresAuthentication: Bool = false Attributes(
let publicKey = UUID().uuidString.data(using: .utf8)! keyType: .init(algorithm: .ecdsa, size: 256),
authentication: .presenceRequired,
)
}
} }
} }
@ -58,6 +60,11 @@ extension Preview {
let id = UUID() let id = UUID()
var name: String { "Modifiable Preview Store" } var name: String { "Modifiable Preview Store" }
let secrets: [Secret] let secrets: [Secret]
var supportedKeyTypes: [KeyType] {
[
.init(algorithm: .ecdsa, size: 256),
]
}
init(secrets: [Secret]) { init(secrets: [Secret]) {
self.secrets = secrets self.secrets = secrets
@ -83,13 +90,13 @@ extension Preview {
} }
func create(name: String, requiresAuthentication: Bool) throws { func create(name: String, attributes: Attributes) throws {
} }
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

@ -30,7 +30,7 @@ struct ContentView: View {
} }
.frame(minWidth: 640, minHeight: 320) .frame(minWidth: 640, minHeight: 320)
.toolbar { .toolbar {
// toolbarItem(updateNoticeView, id: "update") toolbarItem(updateNoticeView, id: "update")
toolbarItem(runningOrRunSetupView, id: "setup") toolbarItem(runningOrRunSetupView, id: "setup")
toolbarItem(appPathNoticeView, id: "appPath") toolbarItem(appPathNoticeView, id: "appPath")
toolbarItem(newItemView, id: "new") toolbarItem(newItemView, id: "new")

View File

@ -7,244 +7,123 @@ struct CreateSecretView<StoreType: SecretStoreModifiable>: View {
@Binding var showing: Bool @Binding var showing: Bool
@State private var name = "" @State private var name = ""
@State private var requiresAuthentication = true @State private var keyAttribution = ""
@State private var authenticationRequirement: AuthenticationRequirement = .presenceRequired
@State private var keyType: KeyType?
@State var advanced = false
private var authenticationOptions: [AuthenticationRequirement] {
if advanced || authenticationRequirement == .biometryCurrent {
[.presenceRequired, .notRequired, .biometryCurrent]
} else {
[.presenceRequired, .notRequired]
}
}
var body: some View { var body: some View {
VStack(alignment: .trailing) {
Form {
Section {
TextField(String(localized: .createSecretNameLabel), text: $name, prompt: Text(.createSecretNamePlaceholder))
VStack(alignment: .leading, spacing: 10) {
Picker(.createSecretRequireAuthenticationTitle, selection: $authenticationRequirement) {
ForEach(authenticationOptions) { option in
HStack {
switch option {
case .notRequired:
Image(systemName: "bell")
Text(.createSecretNotifyTitle)
case .presenceRequired:
Image(systemName: "lock")
Text(.createSecretRequireAuthenticationTitle)
case .biometryCurrent:
Image(systemName: "lock.trianglebadge.exclamationmark.fill")
Text("Current Biometrics")
case .unknown:
EmptyView()
}
}
.tag(option)
}
}
Group {
switch authenticationRequirement {
case .notRequired:
Text(.createSecretNotifyDescription)
case .presenceRequired:
Text(.createSecretRequireAuthenticationDescription)
case .biometryCurrent:
Text("Require authentication with current set of biometrics.")
case .unknown:
EmptyView()
}
}
.font(.subheadline)
.foregroundStyle(.secondary)
if authenticationRequirement == .biometryCurrent {
Text("If you change your biometric settings in _any way_, including adding a new fingerprint, this key will no longer be accessible.")
.padding(.horizontal, 10)
.padding(.vertical, 3)
.background(.red.opacity(0.5), in: RoundedRectangle(cornerRadius: 5))
}
}
}
if advanced {
Section {
VStack { VStack {
Picker("Key Type", selection: $keyType) {
ForEach(store.supportedKeyTypes, id: \.self) { option in
Text(String(describing: option))
.tag(option)
.font(.caption)
}
}
}
VStack(alignment: .leading) {
TextField("Key Attribution", text: $keyAttribution, prompt: Text("test@example.com"))
Text("This shows at the end of your public key.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
}
HStack { HStack {
VStack { Toggle("Advanced", isOn: $advanced)
HStack { .toggleStyle(.button)
Text(.createSecretTitle)
.font(.largeTitle)
Spacer() Spacer()
} Button(.createSecretCancelButton, role: .cancel) {
HStack {
Text(.createSecretNameLabel)
TextField(String(localized: .createSecretNamePlaceholder), text: $name)
.focusable()
}
ThumbnailPickerView(items: [
ThumbnailPickerView.Item(value: true, name: .createSecretRequireAuthenticationTitle, description: .createSecretRequireAuthenticationDescription, thumbnail: AuthenticationView()),
ThumbnailPickerView.Item(value: false, name: .createSecretNotifyTitle,
description: .createSecretNotifyDescription,
thumbnail: NotificationView())
], selection: $requiresAuthentication)
}
}
HStack {
Spacer()
Button(.createSecretCancelButton) {
showing = false showing = false
} }
.keyboardShortcut(.cancelAction)
Button(.createSecretCreateButton, action: save) Button(.createSecretCreateButton, action: save)
.disabled(name.isEmpty) .disabled(name.isEmpty)
.keyboardShortcut(.defaultAction)
} }
}.padding() .padding()
}
.onAppear {
keyType = store.supportedKeyTypes.first
}
.formStyle(.grouped)
} }
func save() { func save() {
let attribution = keyAttribution.isEmpty ? nil : keyAttribution
Task { Task {
try! await store.create(name: name, requiresAuthentication: requiresAuthentication) try! await store.create(
name: name,
attributes: .init(
keyType: keyType!,
authentication: authenticationRequirement,
publicKeyAttribution: attribution
)
)
showing = false showing = false
} }
} }
} }
struct ThumbnailPickerView<ValueType: Hashable>: View { #Preview {
private let items: [Item<ValueType>]
@Binding var selection: ValueType
init(items: [ThumbnailPickerView<ValueType>.Item<ValueType>], selection: Binding<ValueType>) {
self.items = items
_selection = selection
}
var body: some View {
HStack(alignment: .top) {
ForEach(items) { item in
VStack(alignment: .leading, spacing: 15) {
item.thumbnail
.frame(height: 200)
.overlay(RoundedRectangle(cornerRadius: 10)
.stroke(lineWidth: item.value == selection ? 15 : 0))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.foregroundColor(.accentColor)
VStack(alignment: .leading, spacing: 5) {
Text(item.name)
.bold()
Text(item.description)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(width: 250)
.onTapGesture {
withAnimation(.spring()) {
selection = item.value
}
}
}
.padding(5)
}
}
}
extension ThumbnailPickerView {
struct Item<InnerValueType: Hashable>: Identifiable {
let id = UUID()
let value: InnerValueType
let name: LocalizedStringResource
let description: LocalizedStringResource
let thumbnail: AnyView
init<ViewType: View>(value: InnerValueType, name: LocalizedStringResource, description: LocalizedStringResource, thumbnail: ViewType) {
self.value = value
self.name = name
self.description = description
self.thumbnail = AnyView(thumbnail)
}
}
}
@MainActor @Observable class SystemBackground {
static let shared = SystemBackground()
var image: NSImage?
private init() {
if let mainScreen = NSScreen.main, let imageURL = NSWorkspace.shared.desktopImageURL(for: mainScreen) {
image = NSImage(contentsOf: imageURL)
} else {
image = nil
}
}
}
struct SystemBackgroundView: View {
let anchor: UnitPoint
var body: some View {
if let image = SystemBackground.shared.image {
Image(nsImage: image)
.resizable()
.scaleEffect(3, anchor: anchor)
.clipped()
.allowsHitTesting(false)
} else {
Rectangle()
.foregroundColor(Color(.systemPurple))
}
}
}
struct AuthenticationView: View {
var body: some View {
ZStack {
SystemBackgroundView(anchor: .center)
GeometryReader { geometry in
VStack {
Image(systemName: "touchid")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundColor(Color(.systemRed))
Text(verbatim: "Touch ID Prompt")
.font(.headline)
.foregroundColor(.primary)
.redacted(reason: .placeholder)
VStack {
Text(verbatim: "Touch ID Detail prompt.Detail two.")
.font(.caption2)
.foregroundColor(.primary)
Text(verbatim: "Touch ID Detail prompt.Detail two.")
.font(.caption2)
.foregroundColor(.primary)
}
.redacted(reason: .placeholder)
RoundedRectangle(cornerRadius: 5)
.frame(width: geometry.size.width, height: 20, alignment: .center)
.foregroundColor(.accentColor)
RoundedRectangle(cornerRadius: 5)
.frame(width: geometry.size.width, height: 20, alignment: .center)
.foregroundColor(Color(.unemphasizedSelectedContentBackgroundColor))
}
}
.padding()
.frame(width: 150)
.background(
RoundedRectangle(cornerRadius: 15)
.foregroundStyle(.ultraThickMaterial)
)
.padding()
}
}
}
struct NotificationView: View {
var body: some View {
ZStack {
SystemBackgroundView(anchor: .topTrailing)
VStack {
Rectangle()
.background(Color.clear)
.foregroundStyle(.thinMaterial)
.frame(height: 35)
VStack {
HStack {
Spacer()
HStack {
Image(nsImage: NSApplication.shared.applicationIconImage)
.resizable()
.frame(width: 64, height: 64)
.foregroundColor(.primary)
VStack(alignment: .leading) {
Text(verbatim: "Secretive")
.font(.title)
.foregroundColor(.primary)
Text(verbatim: "Secretive wants to sign")
.font(.body)
.foregroundColor(.primary)
}
}.padding()
.redacted(reason: .placeholder)
.background(
RoundedRectangle(cornerRadius: 15)
.foregroundStyle(.ultraThickMaterial)
)
}
Spacer()
}
.padding()
}
}
}
}
#if DEBUG
struct CreateSecretView_Previews: PreviewProvider {
static var previews: some View {
Group {
CreateSecretView(store: Preview.StoreModifiable(), showing: .constant(true)) CreateSecretView(store: Preview.StoreModifiable(), showing: .constant(true))
AuthenticationView().environment(\.colorScheme, .dark)
AuthenticationView().environment(\.colorScheme, .light)
NotificationView().environment(\.colorScheme, .dark)
NotificationView().environment(\.colorScheme, .light)
} }
}
}
#endif

View File

@ -0,0 +1,57 @@
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))
VStack(alignment: .leading) {
TextField("Key Attribution", text: $publicKeyAttribution, prompt: Text("test@example.com"))
Text("This shows at the end of your public key.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
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

@ -5,7 +5,7 @@ struct SecretDetailView<SecretType: Secret>: View {
let secret: SecretType let secret: SecretType
private let keyWriter = OpenSSHKeyWriter() private let keyWriter = OpenSSHPublicKeyWriter()
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory().replacingOccurrences(of: Bundle.main.hostBundleID, with: Bundle.main.agentBundleID)) private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory().replacingOccurrences(of: Bundle.main.hostBundleID, with: Bundle.main.agentBundleID))
var body: some View { var body: some View {
@ -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

@ -26,7 +26,7 @@ struct SecretListItemView: View {
var body: some View { var body: some View {
NavigationLink(value: secret) { NavigationLink(value: secret) {
if secret.requiresAuthentication { if secret.authenticationRequirement.required {
HStack { HStack {
Text(secret.name) Text(secret.name)
Spacer() Spacer()
@ -39,14 +39,16 @@ struct SecretListItemView: View {
.contextMenu { .contextMenu {
if store is AnySecretStoreModifiable { if store is AnySecretStoreModifiable {
Button(action: { isRenaming = true }) { Button(action: { isRenaming = true }) {
Text(.secretListRenameButton) Image(systemName: "pencil")
Text(.secretListEditButton)
} }
Button(action: { isDeleting = true }) { Button(action: { isDeleting = true }) {
Image(systemName: "trash")
Text(.secretListDeleteButton) Text(.secretListDeleteButton)
} }
} }
} }
.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 +58,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)