diff --git a/Sources/Packages/Localizable.xcstrings b/Sources/Packages/Localizable.xcstrings index 8ef0c49..e7f74bd 100644 --- a/Sources/Packages/Localizable.xcstrings +++ b/Sources/Packages/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "en", "strings" : { + "Advanced" : { + + }, "agent_not_running_notice_title" : { "extractionState" : "manual", "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" : { "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", @@ -1475,72 +1488,72 @@ "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Nom:" + "value" : "Nom" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Name:" + "value" : "Name" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Name:" + "value" : "Name" } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "Nimi:" + "value" : "Nimi" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Nom :" + "value" : "Nom" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Nome:" + "value" : "Nome" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "名前:" + "value" : "名前" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이름:" + "value" : "이름" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Nazwa:" + "value" : "Nazwa" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Nome:" + "value" : "Nome" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Название:" + "value" : "Название" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "名称" } } @@ -2007,6 +2020,9 @@ } } } + }, + "Current Biometrics" : { + }, "delete_confirmation_cancel_button" : { "extractionState" : "manual", @@ -2091,72 +2107,72 @@ "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Confirma el nom:" + "value" : "Confirma el nom" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Name bestätigen:" + "value" : "Name bestätigen" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Confirm Name:" + "value" : "Confirm Name" } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "Vahvista nimi:" + "value" : "Vahvista nimi" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Confirmer le nom :" + "value" : "Confirmer le nom" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Conferma nome:" + "value" : "Conferma nome" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "名前の確認:" + "value" : "名前の確認" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "확인 이름:" + "value" : "확인 이름" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Powtórz nazwę:" + "value" : "Powtórz nazwę" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Confirmar Nome:" + "value" : "Confirmar Nome" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Подтвердить название:" + "value" : "Подтвердить название" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "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" : { "extractionState" : "manual", @@ -3377,6 +3402,9 @@ } } } + }, + "Require authentication with current set of biometrics." : { + }, "secret_detail_md5_fingerprint_label" : { "extractionState" : "manual", @@ -3733,72 +3761,72 @@ } } }, - "secret_list_rename_button" : { + "secret_list_edit_button" : { "extractionState" : "manual", "localizations" : { "ca" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Canvia el nom" } }, "de" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Umbenennen" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Rename" + "value" : "Edit" } }, "fr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Renommer" } }, "it" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Rinomina" } }, "ja" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "名前を変更" } }, "ko" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "이름 변경" } }, "pl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Zmień nazwę" } }, "pt-BR" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Renomear" } }, "ru" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Переименовать" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "重命名" } } @@ -5172,6 +5200,12 @@ } } } + }, + "test@example.com" : { + + }, + "This shows at the end of your public key." : { + }, "unnamed_secret" : { "extractionState" : "manual", diff --git a/Sources/Packages/Sources/SecretAgentKit/Agent.swift b/Sources/Packages/Sources/SecretAgentKit/Agent.swift index 4638e3b..7f5d6bf 100644 --- a/Sources/Packages/Sources/SecretAgentKit/Agent.swift +++ b/Sources/Packages/Sources/SecretAgentKit/Agent.swift @@ -9,7 +9,8 @@ public final class Agent: Sendable { private let storeList: SecretStoreList private let witness: SigningWitness? - private let writer = OpenSSHKeyWriter() + private let publicKeyWriter = OpenSSHPublicKeyWriter() + private let signatureWriter = OpenSSHSignatureWriter() private let requestTracer = SigningRequestTracer() private let certificateHandler = OpenSSHCertificateHandler() private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent") @@ -43,7 +44,7 @@ extension Agent { guard data.count > 4 else { return false} let requestTypeInt = data[4] 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)") return true } @@ -75,8 +76,7 @@ extension Agent { response.append(SSHAgent.ResponseType.agentFailure.data) logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)") } - let full = OpenSSHKeyWriter().lengthAndData(of: response) - return full + return response.lengthAndData } } @@ -92,14 +92,14 @@ extension Agent { var keyData = Data() for secret in secrets { - let keyBlob = writer.data(secret: secret) - let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)! - keyData.append(writer.lengthAndData(of: keyBlob)) - keyData.append(writer.lengthAndData(of: curveData)) - + let keyBlob = publicKeyWriter.data(secret: secret) + let curveData = publicKeyWriter.openSSHIdentifier(for: secret.keyType) + keyData.append(keyBlob.lengthAndData) + keyData.append(curveData.lengthAndData) + if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) { - keyData.append(writer.lengthAndData(of: certificateData)) - keyData.append(writer.lengthAndData(of: name)) + keyData.append(certificateData.lengthAndData) + keyData.append(name.lengthAndData) count += 1 } } @@ -135,46 +135,8 @@ extension Agent { } let dataToSign = reader.readNextChunk() - let signed = try await store.sign(data: dataToSign, with: secret, for: provenance) - let derSignature = signed - - 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 = 0x80...0xFF - var r = Data(rawRepresentation[0.. (AnySecretStore, AnySecret)? { for store in await storeList.stores { let allMatching = await store.secrets.filter { secret in - hash == writer.data(secret: secret) + hash == publicKeyWriter.data(secret: secret) } if let matching = allMatching.first { return (store, matching) diff --git a/Sources/Packages/Sources/SecretKit/Documentation.docc/Documentation.md b/Sources/Packages/Sources/SecretKit/Documentation.docc/Documentation.md index a7fed06..8798ca6 100644 --- a/Sources/Packages/Sources/SecretKit/Documentation.docc/Documentation.md +++ b/Sources/Packages/Sources/SecretKit/Documentation.docc/Documentation.md @@ -22,7 +22,7 @@ SecretKit is a collection of protocols describing secrets and stores. ### OpenSSH -- ``OpenSSHKeyWriter`` +- ``OpenSSHPublicKeyWriter`` - ``OpenSSHReader`` ### Signing Process diff --git a/Sources/Packages/Sources/SecretKit/Erasers/AnySecret.swift b/Sources/Packages/Sources/SecretKit/Erasers/AnySecret.swift index 7f04fe1..3d3bc73 100644 --- a/Sources/Packages/Sources/SecretKit/Erasers/AnySecret.swift +++ b/Sources/Packages/Sources/SecretKit/Erasers/AnySecret.swift @@ -3,14 +3,12 @@ import Foundation /// Type eraser for Secret. public struct AnySecret: Secret, @unchecked Sendable { - public let base: Any + public let base: any Secret private let hashable: AnyHashable private let _id: () -> AnyHashable private let _name: () -> String - private let _algorithm: () -> Algorithm - private let _keySize: () -> Int - private let _requiresAuthentication: () -> Bool private let _publicKey: () -> Data + private let _attributes: () -> Attributes public init(_ secret: T) where T: Secret { if let secret = secret as? AnySecret { @@ -18,19 +16,15 @@ public struct AnySecret: Secret, @unchecked Sendable { hashable = secret.hashable _id = secret._id _name = secret._name - _algorithm = secret._algorithm - _keySize = secret._keySize - _requiresAuthentication = secret._requiresAuthentication _publicKey = secret._publicKey + _attributes = secret._attributes } else { - base = secret as Any + base = secret self.hashable = secret _id = { secret.id as AnyHashable } _name = { secret.name } - _algorithm = { secret.algorithm } - _keySize = { secret.keySize } - _requiresAuthentication = { secret.requiresAuthentication } _publicKey = { secret.publicKey } + _attributes = { secret.attributes } } } @@ -42,21 +36,13 @@ public struct AnySecret: Secret, @unchecked Sendable { _name() } - public var algorithm: Algorithm { - _algorithm() - } - - public var keySize: Int { - _keySize() - } - - public var requiresAuthentication: Bool { - _requiresAuthentication() - } - public var publicKey: Data { _publicKey() } + + public var attributes: Attributes { + _attributes() + } public static func == (lhs: AnySecret, rhs: AnySecret) -> Bool { lhs.hashable == rhs.hashable diff --git a/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift b/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift index 675572d..cb47e95 100644 --- a/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift +++ b/Sources/Packages/Sources/SecretKit/Erasers/AnySecretStore.swift @@ -3,7 +3,7 @@ import Foundation /// Type eraser for SecretStore. open class AnySecretStore: SecretStore, @unchecked Sendable { - let base: any Sendable + let base: any SecretStore private let _isAvailable: @MainActor @Sendable () -> Bool private let _id: @Sendable () -> UUID private let _name: @MainActor @Sendable () -> String @@ -61,27 +61,33 @@ open class AnySecretStore: SecretStore, @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 _update: @Sendable (AnySecret, String) async throws -> Void + private let _update: @Sendable (AnySecret, String, Attributes) async throws -> Void + private let _supportedKeyTypes: @Sendable () -> [KeyType] - public init(modifiable secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable { - _create = { try await secretStore.create(name: $0, requiresAuthentication: $1) } + public init(_ secretStore: SecretStoreType) where SecretStoreType: SecretStoreModifiable { + _create = { try await secretStore.create(name: $0, attributes: $1) } _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) } - public func create(name: String, requiresAuthentication: Bool) async throws { - try await _create(name, requiresAuthentication) + public func create(name: String, attributes: Attributes) async throws { + try await _create(name, attributes) } public func delete(secret: AnySecret) async throws { try await _delete(secret) } - public func update(secret: AnySecret, name: String) async throws { - try await _update(secret, name) + public func update(secret: AnySecret, name: String, attributes: Attributes) async throws { + try await _update(secret, name, attributes) + } + + public var supportedKeyTypes: [KeyType] { + _supportedKeyTypes() } } diff --git a/Sources/Packages/Sources/SecretKit/KeychainTypes.swift b/Sources/Packages/Sources/SecretKit/KeychainTypes.swift index cfea466..debb2e1 100644 --- a/Sources/Packages/Sources/SecretKit/KeychainTypes.swift +++ b/Sources/Packages/Sources/SecretKit/KeychainTypes.swift @@ -51,19 +51,17 @@ public extension SecretStore { /// Returns the appropriate keychian signature algorithm to use for a given secret. /// - Parameters: /// - secret: The secret which will be used for signing. - /// - allowRSA: Whether or not RSA key types should be permited. /// - Returns: The appropriate algorithm. - func signatureAlgorithm(for secret: SecretType, allowRSA: Bool = false) -> SecKeyAlgorithm { - switch (secret.algorithm, secret.keySize) { - case (.ellipticCurve, 256): - return .ecdsaSignatureMessageX962SHA256 - case (.ellipticCurve, 384): - return .ecdsaSignatureMessageX962SHA384 - case (.rsa, 1024), (.rsa, 2048): - guard allowRSA else { fatalError() } - return .rsaSignatureMessagePKCS1v15SHA512 + func signatureAlgorithm(for secret: SecretType) -> SecKeyAlgorithm? { + switch (secret.keyType.algorithm, secret.keyType.size) { + case (.ecdsa, 256): + .ecdsaSignatureMessageX962SHA256 + case (.ecdsa, 384): + .ecdsaSignatureMessageX962SHA384 + case (.rsa, 2048): + .rsaSignatureMessagePKCS1v15SHA512 default: - fatalError() + nil } } diff --git a/Sources/Packages/Sources/SecretKit/OpenSSH/LengthAndData.swift b/Sources/Packages/Sources/SecretKit/OpenSSH/LengthAndData.swift new file mode 100644 index 0000000..33acc06 --- /dev/null +++ b/Sources/Packages/Sources/SecretKit/OpenSSH/LengthAndData.swift @@ -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 + } + +} diff --git a/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHCertificateHandler.swift b/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHCertificateHandler.swift index dbfcc7a..8d955ba 100644 --- a/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHCertificateHandler.swift +++ b/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHCertificateHandler.swift @@ -6,7 +6,7 @@ public actor OpenSSHCertificateHandler: Sendable { private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory()) 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)] = [:] /// Initializes an OpenSSHCertificateHandler. @@ -40,10 +40,10 @@ public actor OpenSSHCertificateHandler: Sendable { let curveIdentifier = reader.readNextChunk() let publicKey = reader.readNextChunk() - let curveType = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "").data(using: .utf8)! - return writer.lengthAndData(of: curveType) + - writer.lengthAndData(of: curveIdentifier) + - writer.lengthAndData(of: publicKey) + let openSSHIdentifier = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "") + return openSSHIdentifier.lengthAndData + + curveIdentifier.lengthAndData + + publicKey.lengthAndData default: return nil } @@ -78,14 +78,13 @@ public actor OpenSSHCertificateHandler: Sendable { 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) - } else if let certName = secret.name.data(using: .utf8) { - logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead") - return (certDecoded, certName) - } else { - throw OpenSSHCertificateError.parsingFailed } + let certName = Data(secret.name.utf8) + logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead") + return (certDecoded, certName) } } diff --git a/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHKeyWriter.swift b/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHKeyWriter.swift deleted file mode 100644 index cca64df..0000000 --- a/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHKeyWriter.swift +++ /dev/null @@ -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(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(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(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)..(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" - } - } - -} diff --git a/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHPublicKeyWriter.swift b/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHPublicKeyWriter.swift new file mode 100644 index 0000000..25016a8 --- /dev/null +++ b/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHPublicKeyWriter.swift @@ -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(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(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(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)..(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(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 + } + +} diff --git a/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHReader.swift b/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHReader.swift index 6b7bc08..e3ef8aa 100644 --- a/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHReader.swift +++ b/Sources/Packages/Sources/SecretKit/OpenSSH/OpenSSHReader.swift @@ -17,9 +17,7 @@ public final class OpenSSHReader { let lengthRange = 0..<(UInt32.bitWidth/8) let lengthChunk = remaining[lengthRange] remaining.removeSubrange(lengthRange) - let littleEndianLength = lengthChunk.withUnsafeBytes { pointer in - return pointer.load(as: UInt32.self) - } + let littleEndianLength = lengthChunk.bytes.unsafeLoad(as: UInt32.self) let length = Int(littleEndianLength.bigEndian) let dataRange = 0..(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 = 0x80...0xFF + var r = Data(rawRepresentation[0.. Data { + var mutSignedData = Data() + var sub = Data() + sub.append("rsa-sha2-512".lengthAndData) + sub.append(rawRepresentation.lengthAndData) + mutSignedData.append(sub.lengthAndData) + return mutSignedData + } + +} diff --git a/Sources/Packages/Sources/SecretKit/PublicKeyStandinFileController.swift b/Sources/Packages/Sources/SecretKit/PublicKeyStandinFileController.swift index 736c0f8..ada02d7 100644 --- a/Sources/Packages/Sources/SecretKit/PublicKeyStandinFileController.swift +++ b/Sources/Packages/Sources/SecretKit/PublicKeyStandinFileController.swift @@ -6,7 +6,7 @@ public final class PublicKeyFileStoreController: Sendable { private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController") private let directory: String - private let keyWriter = OpenSSHKeyWriter() + private let keyWriter = OpenSSHPublicKeyWriter() /// Initializes a PublicKeyFileStoreController. 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) for secret in secrets { 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) } logger.log("Finished writing public keys") diff --git a/Sources/Packages/Sources/SecretKit/SecretStoreList.swift b/Sources/Packages/Sources/SecretKit/SecretStoreList.swift index d8d4074..8b96e3e 100644 --- a/Sources/Packages/Sources/SecretKit/SecretStoreList.swift +++ b/Sources/Packages/Sources/SecretKit/SecretStoreList.swift @@ -20,7 +20,7 @@ import Observation /// Adds a non-type-erased modifiable SecretStore. public func add(store: SecretStoreType) { - let modifiable = AnySecretStoreModifiable(modifiable: store) + let modifiable = AnySecretStoreModifiable(store) if modifiableStore == nil { modifiableStore = modifiable } diff --git a/Sources/Packages/Sources/SecretKit/Types/CreationOptions.swift b/Sources/Packages/Sources/SecretKit/Types/CreationOptions.swift new file mode 100644 index 0000000..99ab8f3 --- /dev/null +++ b/Sources/Packages/Sources/SecretKit/Types/CreationOptions.swift @@ -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 + } +} diff --git a/Sources/Packages/Sources/SecretKit/Types/Secret.swift b/Sources/Packages/Sources/SecretKit/Types/Secret.swift index e4cdb22..bb70fc8 100644 --- a/Sources/Packages/Sources/SecretKit/Types/Secret.swift +++ b/Sources/Packages/Sources/SecretKit/Types/Secret.swift @@ -5,43 +5,72 @@ public protocol Secret: Identifiable, Hashable, Sendable { /// A user-facing string identifying the Secret. 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. 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 enum Algorithm: Hashable, Sendable { +public extension Secret { - case ellipticCurve - case rsa + /// 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 + } + + 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. /// - Parameter secAttr: the secAttr, represented as an NSNumber. - public init(secAttr: NSNumber) { + public init?(secAttr: NSNumber, size: Int) { let secAttrString = secAttr.stringValue as CFString switch secAttrString { case kSecAttrKeyTypeEC: - self = .ellipticCurve + algorithm = .ecdsa case kSecAttrKeyTypeRSA: - self = .rsa + algorithm = .rsa default: - fatalError() + return nil + } + self.size = size + } + + public var secAttrKeyType: CFString? { + switch algorithm { + case .ecdsa: + kSecAttrKeyTypeEC + case .rsa: + kSecAttrKeyTypeRSA } } - public var secAttrKeyType: CFString { - switch self { - case .ellipticCurve: - return kSecAttrKeyTypeEC - case .rsa: - return kSecAttrKeyTypeRSA - } + public var description: String { + "\(algorithm)-\(size)" } } diff --git a/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift b/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift index 3e6b07b..c1dcdec 100644 --- a/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift +++ b/Sources/Packages/Sources/SecretKit/Types/SecretStore.swift @@ -1,7 +1,7 @@ import Foundation /// Manages access to Secrets, and performs signature operations on data using those Secrets. -public protocol SecretStore: Identifiable, Sendable { +public protocol SecretStore: Identifiable, Sendable { associatedtype SecretType: Secret @@ -41,13 +41,13 @@ public protocol SecretStore: Identifiable, Sendable { } /// A SecretStore that the Secretive admin app can modify. -public protocol SecretStoreModifiable: SecretStore { +public protocol SecretStoreModifiable: SecretStore { /// Creates a new ``Secret`` in the store. /// - Parameters: /// - 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. - func create(name: String, requiresAuthentication: Bool) async throws + /// - attributes: A struct describing the options for creating the key. + func create(name: String, attributes: Attributes) async throws /// Deletes a Secret in the store. /// - Parameters: @@ -58,7 +58,10 @@ public protocol SecretStoreModifiable: SecretStore { /// - Parameters: /// - secret: The ``Secret`` to update. /// - 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 } } diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/CryptoKitMigrator.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/CryptoKitMigrator.swift new file mode 100644 index 0000000..c03944a --- /dev/null +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/CryptoKitMigrator.swift @@ -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 + } + +} diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveSecret.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveSecret.swift index ace6442..27e782e 100644 --- a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveSecret.swift +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveSecret.swift @@ -8,10 +8,20 @@ extension SecureEnclave { public let id: Data public let name: String - public let algorithm = Algorithm.ellipticCurve - public let keySize = 256 - public let requiresAuthentication: Bool 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 + } } diff --git a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift index 9007447..0f467b5 100644 --- a/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift +++ b/Sources/Packages/Sources/SecureEnclaveSecretKit/SecureEnclaveStore.swift @@ -2,12 +2,13 @@ import Foundation import Observation import Security import CryptoKit -import LocalAuthentication +@preconcurrency import LocalAuthentication import SecretKit +import os 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 { @MainActor public var secrets: [Secret] = [] @@ -23,66 +24,119 @@ extension SecureEnclave { loadSecrets() Task { for await _ in DistributedNotificationCenter.default().notifications(named: .secretStoreUpdated) { - await reloadSecretsInternal(notifyAgent: false) + reloadSecrets() } } } - // MARK: Public API - - public func create(name: String, requiresAuthentication: Bool) async throws { - var accessError: SecurityError? - let flags: SecAccessControlCreateFlags - if requiresAuthentication { - flags = [.privateKeyUsage, .userPresence] + // MARK: - Public API + + // MARK: SecretStore + + public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { + var context: LAContext + if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) { + context = existing.context } 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 = SecAccessControlCreateWithFlags(kCFAllocatorDefault, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, flags, - &accessError) as Any + &accessError) if let error = accessError { throw error.takeRetainedValue() as Error } - - let attributes = KeychainDictionary([ - kSecAttrLabel: name, - kSecAttrKeyType: Constants.keyType, - kSecAttrTokenID: kSecAttrTokenIDSecureEnclave, - kSecAttrApplicationTag: Constants.keyTag, - kSecPrivateKeyAttrs: [ - kSecAttrIsPermanent: true, - kSecAttrAccessControl: access - ] - ]) - - var createKeyError: SecurityError? - let keypair = SecKeyCreateRandomKey(attributes, &createKeyError) - if let error = createKeyError { - throw error.takeRetainedValue() as Error + let dataRep: Data + switch (attributes.keyType.algorithm, attributes.keyType.size) { + case (.ecdsa, 256): + let created = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(accessControl: access!) + dataRep = created.dataRepresentation + default: + throw Attributes.UnsupportedOptionError() } - guard let keypair = keypair, let publicKey = SecKeyCopyPublicKey(keypair) else { - throw KeychainError(statusCode: nil) - } - try savePublicKey(publicKey, name: name) - await reloadSecretsInternal() + try saveKey(dataRep, name: name, attributes: attributes) + await reloadSecrets() } public func delete(secret: Secret) async throws { let deleteAttributes = KeychainDictionary([ - kSecClass: kSecClassKey, - kSecAttrApplicationLabel: secret.id as CFData + kSecClass: Constants.keyClass, + kSecAttrService: Constants.keyTag, + kSecUseDataProtectionKeychain: true, + kSecAttrAccount: String(decoding: secret.id, as: UTF8.self) ]) let status = SecItemDelete(deleteAttributes) if status != errSecSuccess { 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([ kSecClass: kSecClassKey, kSecAttrApplicationLabel: secret.id as CFData @@ -96,56 +150,13 @@ extension SecureEnclave { if status != errSecSuccess { throw KeychainError(statusCode: status) } - await reloadSecretsInternal() + await reloadSecrets() } - public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) async throws -> Data { - let context: LAContext - if let existing = await persistentAuthenticationHandler.existingPersistedAuthenticationContext(secret: secret) { - 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) + public var supportedKeyTypes: [KeyType] { + [ + .init(algorithm: .ecdsa, size: 256), + ] } } @@ -154,9 +165,7 @@ extension SecureEnclave { extension SecureEnclave.Store { - /// Reloads all secrets from the store. - /// - 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 { + @MainActor private func reloadSecretsInternal(notifyAgent: Bool = true) { let before = secrets secrets.removeAll() loadSecrets() @@ -170,88 +179,77 @@ extension SecureEnclave.Store { /// Loads all secrets from the store. @MainActor private func loadSecrets() { - let publicAttributes = KeychainDictionary([ - kSecClass: kSecClassKey, - kSecAttrKeyType: SecureEnclave.Constants.keyType, - kSecAttrApplicationTag: SecureEnclave.Constants.keyTag, - kSecAttrKeyClass: kSecAttrKeyClassPublic, - kSecReturnRef: true, + let queryAttributes = KeychainDictionary([ + kSecClass: Constants.keyClass, + kSecAttrService: Constants.keyTag, + kSecUseDataProtectionKeychain: true, + kSecReturnData: true, kSecMatchLimit: kSecMatchLimitAll, kSecReturnAttributes: true ]) - var publicUntyped: CFTypeRef? - SecItemCopyMatching(publicAttributes, &publicUntyped) - guard let publicTyped = publicUntyped as? [[CFString: Any]] else { return } - let privateAttributes = KeychainDictionary([ - kSecClass: kSecClassKey, - kSecAttrKeyType: SecureEnclave.Constants.keyType, - kSecAttrApplicationTag: SecureEnclave.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 privateMapped = privateTyped.reduce(into: [:] as [Data: [CFString: Any]]) { partialResult, next in - let id = next[kSecAttrApplicationLabel] as! Data - partialResult[id] = next - } - let authNotRequiredAccessControl: SecAccessControl = - SecAccessControlCreateWithFlags(kCFAllocatorDefault, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - [.privateKeyUsage], - nil)! - - let wrapped: [SecureEnclave.Secret] = publicTyped.map { - let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret) - let id = $0[kSecAttrApplicationLabel] as! Data - let publicKeyRef = $0[kSecValueRef] as! SecKey - let publicKeyAttributes = SecKeyCopyAttributes(publicKeyRef) as! [CFString: Any] - let publicKey = publicKeyAttributes[kSecValueData] as! Data - let privateKey = privateMapped[id] - let requiresAuth: Bool - 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 + var untyped: CFTypeRef? + SecItemCopyMatching(queryAttributes, &untyped) + guard let typed = untyped as? [[CFString: Any]] else { return } + let wrapped: [SecureEnclave.Secret] = typed.compactMap { + do { + let name = $0[kSecAttrLabel] as? String ?? String(localized: "unnamed_secret") + guard let attributesData = $0[kSecAttrGeneric] as? Data, + let idString = $0[kSecAttrAccount] as? String else { + throw MissingAttributesError() + } + let id = Data(idString.utf8) + let attributes = try JSONDecoder().decode(Attributes.self, from: attributesData) + let keyData = $0[kSecValueData] as! Data + let publicKey: Data + switch (attributes.keyType.algorithm, attributes.keyType.size) { + case (.ecdsa, 256): + let key = try CryptoKit.SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: keyData) + publicKey = key.publicKey.x963Representation + default: + throw UnsupportedAlgorithmError() + } + return SecureEnclave.Secret(id: id, name: name, publicKey: publicKey, attributes: attributes) + } catch { + return nil } - return SecureEnclave.Secret(id: id, name: name, requiresAuthentication: requiresAuth, publicKey: publicKey) } secrets.append(contentsOf: wrapped) } /// Saves a public key. /// - Parameters: - /// - publicKey: The public key to save. + /// - key: The data representation key to save. /// - name: A user-facing name for the key. - private func savePublicKey(_ publicKey: SecKey, name: String) throws { - let attributes = KeychainDictionary([ - kSecClass: kSecClassKey, - kSecAttrKeyType: SecureEnclave.Constants.keyType, - kSecAttrKeyClass: kSecAttrKeyClassPublic, - kSecAttrApplicationTag: SecureEnclave.Constants.keyTag, - kSecValueRef: publicKey, - kSecAttrIsPermanent: true, - kSecReturnData: true, - kSecAttrLabel: name - ]) - let status = SecItemAdd(attributes, nil) + /// - attributes: Attributes of the key. + /// - 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. + func saveKey(_ key: Data, name: String, attributes: Attributes) throws { + let attributes = try JSONEncoder().encode(attributes) + let keychainAttributes = KeychainDictionary([ + kSecClass: Constants.keyClass, + kSecAttrService: Constants.keyTag, + kSecUseDataProtectionKeychain: true, + kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + kSecAttrAccount: UUID().uuidString, + kSecValueData: key, + kSecAttrLabel: name, + kSecAttrGeneric: attributes + ]) + let status = SecItemAdd(keychainAttributes, nil) if status != errSecSuccess { throw KeychainError(statusCode: status) } } - + } -extension SecureEnclave { +extension SecureEnclave.Store { - public enum Constants { - public static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8) - public static let keyType = kSecAttrKeyTypeECSECPrimeRandom as String - static let unauthenticatedThreshold: TimeInterval = 0.05 + enum Constants { + static let keyClass = kSecClassGenericPassword as String + static let keyTag = Data("com.maxgoedjen.secretive.secureenclave.key".utf8) } + + struct UnsupportedAlgorithmError: Error {} + struct MissingAttributesError: Error {} } diff --git a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardSecret.swift b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardSecret.swift index 348926f..977355e 100644 --- a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardSecret.swift +++ b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardSecret.swift @@ -8,10 +8,8 @@ extension SmartCard { public let id: Data public let name: String - public let algorithm: Algorithm - public let keySize: Int - public let requiresAuthentication: Bool = false public let publicKey: Data + public var attributes: Attributes } diff --git a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift index d52c1c8..3cf29b7 100644 --- a/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift +++ b/Sources/Packages/Sources/SmartCardSecretKit/SmartCardStore.swift @@ -56,14 +56,6 @@ extension SmartCard { // 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 { guard let tokenID = await state.tokenID else { fatalError() } let context = LAContext() @@ -87,7 +79,8 @@ extension SmartCard { } let key = untypedSafe as! SecKey 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) } return signature as Data @@ -161,16 +154,19 @@ extension SmartCard.Store { var untyped: CFTypeRef? SecItemCopyMatching(attributes, &untyped) 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 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 publicKeyRef = $0[kSecValueRef] as! SecKey let publicKeySecRef = SecKeyCopyPublicKey(publicKeyRef)! let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any] 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) } @@ -185,3 +181,9 @@ extension TKTokenWatcher { } } + +extension SmartCard { + + public struct UnsupportKeyType: Error {} + +} diff --git a/Sources/Packages/Tests/SecretAgentKitTests/AgentTests.swift b/Sources/Packages/Tests/SecretAgentKitTests/AgentTests.swift index 7cd519e..4112820 100644 --- a/Sources/Packages/Tests/SecretAgentKitTests/AgentTests.swift +++ b/Sources/Packages/Tests/SecretAgentKitTests/AgentTests.swift @@ -35,7 +35,7 @@ import CryptoKit #expect(stubWriter.data == Constants.Responses.requestFailure) } - @Test func signature() async throws { + @Test func ecdsaSignature() async throws { let stubReader = StubFileHandleReader(availableData: Constants.Requests.requestSignature) let requestReader = OpenSSHReader(data: Constants.Requests.requestSignature[5...]) _ = requestReader.readNextChunk() diff --git a/Sources/Packages/Tests/SecretAgentKitTests/StubStore.swift b/Sources/Packages/Tests/SecretAgentKitTests/StubStore.swift index b168614..6f37469 100644 --- a/Sources/Packages/Tests/SecretAgentKitTests/StubStore.swift +++ b/Sources/Packages/Tests/SecretAgentKitTests/StubStore.swift @@ -45,20 +45,15 @@ extension Stub { let privateData = (privateAttributes[kSecValueData] as! Data) let secret = Secret(keySize: size, publicKey: publicData, privateKey: privateData) 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 { guard !shouldThrow else { throw NSError(domain: "test", code: 0, userInfo: nil) } - let privateKey = SecKeyCreateWithData(secret.privateKey as CFData, KeychainDictionary([ - kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, - kSecAttrKeySizeInBits: secret.keySize, - kSecAttrKeyClass: kSecAttrKeyClassPrivate - ]) - , nil)! - return SecKeyCreateSignature(privateKey, signatureAlgorithm(for: secret), data as CFData, nil)! as Data + let privateKey = try CryptoKit.P256.Signing.PrivateKey(x963Representation: secret.privateKey) + return try privateKey.signature(for: data).rawRepresentation } public func existingPersistedAuthenticationContext(secret: Stub.Secret) -> PersistedAuthenticationContext? { @@ -79,24 +74,22 @@ extension Stub { struct Secret: SecretKit.Secret, CustomDebugStringConvertible { - let id = UUID().uuidString.data(using: .utf8)! + let id = Data(UUID().uuidString.utf8) let name = UUID().uuidString - let algorithm = Algorithm.ellipticCurve - - let keySize: Int + let attributes: Attributes let publicKey: Data let requiresAuthentication = false let 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.privateKey = privateKey } var debugDescription: String { """ - Key Size \(keySize) + Key Size \(keyType.size) Private: \(privateKey.base64EncodedString()) Public: \(publicKey.base64EncodedString()) """ diff --git a/Sources/Packages/Tests/SecretKitTests/AnySecretTests.swift b/Sources/Packages/Tests/SecretKitTests/AnySecretTests.swift index f8229bd..b8e4b2b 100644 --- a/Sources/Packages/Tests/SecretKitTests/AnySecretTests.swift +++ b/Sources/Packages/Tests/SecretKitTests/AnySecretTests.swift @@ -4,15 +4,16 @@ import Testing @testable import SecureEnclaveSecretKit @testable import SmartCardSecretKit + @Suite struct AnySecretTests { @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) #expect(erased.id == secret.id as AnyHashable) #expect(erased.name == secret.name) - #expect(erased.algorithm == secret.algorithm) - #expect(erased.keySize == secret.keySize) + #expect(erased.keyType == secret.keyType) #expect(erased.publicKey == secret.publicKey) } diff --git a/Sources/Packages/Tests/SecretKitTests/OpenSSHWriterTests.swift b/Sources/Packages/Tests/SecretKitTests/OpenSSHPublicKeyWriterTests.swift similarity index 69% rename from Sources/Packages/Tests/SecretKitTests/OpenSSHWriterTests.swift rename to Sources/Packages/Tests/SecretKitTests/OpenSSHPublicKeyWriterTests.swift index e471992..92c3132 100644 --- a/Sources/Packages/Tests/SecretKitTests/OpenSSHWriterTests.swift +++ b/Sources/Packages/Tests/SecretKitTests/OpenSSHPublicKeyWriterTests.swift @@ -4,9 +4,9 @@ import Testing @testable import SecureEnclaveSecretKit @testable import SmartCardSecretKit -@Suite struct OpenSSHWriterTests { +@Suite struct OpenSSHPublicKeyWriterTests { - let writer = OpenSSHKeyWriter() + let writer = OpenSSHPublicKeyWriter() @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") @@ -18,7 +18,7 @@ import Testing @Test func ecdsa256PublicKey() { #expect(writer.openSSHString(secret: Constants.ecdsa256Secret) == - "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=") + "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo= test@example.com") } @Test func ecdsa256Hash() { @@ -35,7 +35,7 @@ import Testing @Test func ecdsa384PublicKey() { #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() { @@ -44,11 +44,11 @@ import Testing } -extension OpenSSHWriterTests { +extension OpenSSHPublicKeyWriterTests { 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 ecdsa384Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 384)", algorithm: .ellipticCurve, keySize: 384, publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!) + 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)", publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 384), authentication: .notRequired, publicKeyAttribution: "test@example.com")) } diff --git a/Sources/SecretAgent/AppDelegate.swift b/Sources/SecretAgent/AppDelegate.swift index c714dbf..e4f8749 100644 --- a/Sources/SecretAgent/AppDelegate.swift +++ b/Sources/SecretAgent/AppDelegate.swift @@ -12,7 +12,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { @MainActor private let storeList: 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()) return list }() @@ -46,6 +49,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { updater.update } onChange: { [updater, notifier] in Task { + guard !updater.testBuild else { return } await notifier.notify(update: updater.update!) { release in await updater.ignore(release: release) } diff --git a/Sources/SecretAgent/Notifier.swift b/Sources/SecretAgent/Notifier.swift index 62540b8..fa48cdd 100644 --- a/Sources/SecretAgent/Notifier.swift +++ b/Sources/SecretAgent/Notifier.swift @@ -69,7 +69,7 @@ final class Notifier: Sendable { notificationContent.userInfo[Constants.persistSecretIDKey] = secret.id.description notificationContent.userInfo[Constants.persistStoreIDKey] = store.id.description 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 } if let iconURL = provenance.origin.iconURL, let attachment = try? UNNotificationAttachment(identifier: "icon", url: iconURL, options: nil) { diff --git a/Sources/Secretive.xcodeproj/project.pbxproj b/Sources/Secretive.xcodeproj/project.pbxproj index add5a1e..8f60638 100644 --- a/Sources/Secretive.xcodeproj/project.pbxproj +++ b/Sources/Secretive.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* 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 */; }; 50033AC327813F1700253856 /* BundleIDs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50033AC227813F1700253856 /* BundleIDs.swift */; }; 5003EF3B278005E800DF2006 /* SecretKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5003EF3A278005E800DF2006 /* SecretKit */; }; @@ -98,7 +98,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 2C4A9D2E2636FFD3008CC8E2 /* RenameSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameSecretView.swift; sourceTree = ""; }; + 2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditSecretView.swift; sourceTree = ""; }; 50020BAF24064869003D4025 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 50033AC227813F1700253856 /* BundleIDs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleIDs.swift; sourceTree = ""; }; 5003EF39278005C800DF2006 /* Packages */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Packages; sourceTree = ""; }; @@ -246,7 +246,7 @@ 50C385A42407A76D00AF2719 /* SecretDetailView.swift */, 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */, 50B8550C24138C4F009958AC /* DeleteSecretView.swift */, - 2C4A9D2E2636FFD3008CC8E2 /* RenameSecretView.swift */, + 2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */, 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */, 506772C82425BB8500034DED /* NoStoresView.swift */, 50153E1F250AFCB200525160 /* UpdateView.swift */, @@ -430,7 +430,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 2C4A9D2F2636FFD3008CC8E2 /* RenameSecretView.swift in Sources */, + 2C4A9D2F2636FFD3008CC8E2 /* EditSecretView.swift in Sources */, 5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */, 5066A6C22516F303004B5A36 /* SetupView.swift in Sources */, 5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */, diff --git a/Sources/Secretive/App.swift b/Sources/Secretive/App.swift index 2c1ec9d..177beaf 100644 --- a/Sources/Secretive/App.swift +++ b/Sources/Secretive/App.swift @@ -1,4 +1,3 @@ -import Cocoa import SwiftUI import SecretKit 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). @MainActor fileprivate static let _secretStoreList: 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()) return list }() diff --git a/Sources/Secretive/Controllers/ShellConfigurationController.swift b/Sources/Secretive/Controllers/ShellConfigurationController.swift index 2f3e4c6..2ecb17e 100644 --- a/Sources/Secretive/Controllers/ShellConfigurationController.swift +++ b/Sources/Secretive/Controllers/ShellConfigurationController.swift @@ -56,7 +56,7 @@ struct ShellConfigurationController { } catch { 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 } diff --git a/Sources/Secretive/Preview Content/PreviewStore.swift b/Sources/Secretive/Preview Content/PreviewStore.swift index 4cbcac6..e17c232 100644 --- a/Sources/Secretive/Preview Content/PreviewStore.swift +++ b/Sources/Secretive/Preview Content/PreviewStore.swift @@ -9,11 +9,13 @@ extension Preview { let id = UUID().uuidString let name: String - let algorithm = Algorithm.ellipticCurve - let keySize = 256 - let requiresAuthentication: Bool = false - let publicKey = UUID().uuidString.data(using: .utf8)! - + let publicKey = Data(UUID().uuidString.utf8) + var attributes: Attributes { + Attributes( + keyType: .init(algorithm: .ecdsa, size: 256), + authentication: .presenceRequired, + ) + } } } @@ -58,6 +60,11 @@ extension Preview { let id = UUID() var name: String { "Modifiable Preview Store" } let secrets: [Secret] + var supportedKeyTypes: [KeyType] { + [ + .init(algorithm: .ecdsa, size: 256), + ] + } init(secrets: [Secret]) { 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 update(secret: Preview.Secret, name: String) throws { + func update(secret: Preview.Secret, name: String, attributes: Attributes) throws { } } } diff --git a/Sources/Secretive/Views/ContentView.swift b/Sources/Secretive/Views/ContentView.swift index e57f5d8..dfc6dff 100644 --- a/Sources/Secretive/Views/ContentView.swift +++ b/Sources/Secretive/Views/ContentView.swift @@ -30,7 +30,7 @@ struct ContentView: View { } .frame(minWidth: 640, minHeight: 320) .toolbar { -// toolbarItem(updateNoticeView, id: "update") + toolbarItem(updateNoticeView, id: "update") toolbarItem(runningOrRunSetupView, id: "setup") toolbarItem(appPathNoticeView, id: "appPath") toolbarItem(newItemView, id: "new") diff --git a/Sources/Secretive/Views/CreateSecretView.swift b/Sources/Secretive/Views/CreateSecretView.swift index 7900f16..918301c 100644 --- a/Sources/Secretive/Views/CreateSecretView.swift +++ b/Sources/Secretive/Views/CreateSecretView.swift @@ -7,244 +7,123 @@ struct CreateSecretView: View { @Binding var showing: Bool @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 { - VStack { - HStack { - VStack { - HStack { - Text(.createSecretTitle) - .font(.largeTitle) - Spacer() + 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)) + } + } - HStack { - Text(.createSecretNameLabel) - TextField(String(localized: .createSecretNamePlaceholder), text: $name) - .focusable() + } + if advanced { + Section { + 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) + } } - 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 { + Toggle("Advanced", isOn: $advanced) + .toggleStyle(.button) Spacer() - Button(.createSecretCancelButton) { + Button(.createSecretCancelButton, role: .cancel) { showing = false } - .keyboardShortcut(.cancelAction) Button(.createSecretCreateButton, action: save) .disabled(name.isEmpty) - .keyboardShortcut(.defaultAction) } - }.padding() + .padding() + } + .onAppear { + keyType = store.supportedKeyTypes.first + } + .formStyle(.grouped) } func save() { + let attribution = keyAttribution.isEmpty ? nil : keyAttribution 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 } } } -struct ThumbnailPickerView: View { - - private let items: [Item] - @Binding var selection: ValueType - - init(items: [ThumbnailPickerView.Item], selection: Binding) { - 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) - } - } - +#Preview { + CreateSecretView(store: Preview.StoreModifiable(), showing: .constant(true)) } - -extension ThumbnailPickerView { - - struct Item: Identifiable { - let id = UUID() - let value: InnerValueType - let name: LocalizedStringResource - let description: LocalizedStringResource - let thumbnail: AnyView - - init(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)) - AuthenticationView().environment(\.colorScheme, .dark) - AuthenticationView().environment(\.colorScheme, .light) - NotificationView().environment(\.colorScheme, .dark) - NotificationView().environment(\.colorScheme, .light) - } - } -} - -#endif diff --git a/Sources/Secretive/Views/EditSecretView.swift b/Sources/Secretive/Views/EditSecretView.swift new file mode 100644 index 0000000..ab9f8b4 --- /dev/null +++ b/Sources/Secretive/Views/EditSecretView.swift @@ -0,0 +1,57 @@ +import SwiftUI +import SecretKit + +struct EditSecretView: 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) + } + } +} diff --git a/Sources/Secretive/Views/RenameSecretView.swift b/Sources/Secretive/Views/RenameSecretView.swift deleted file mode 100644 index edd3a61..0000000 --- a/Sources/Secretive/Views/RenameSecretView.swift +++ /dev/null @@ -1,52 +0,0 @@ -import SwiftUI -import SecretKit - -struct RenameSecretView: 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) - } - } -} diff --git a/Sources/Secretive/Views/SecretDetailView.swift b/Sources/Secretive/Views/SecretDetailView.swift index a361bbc..68a1e05 100644 --- a/Sources/Secretive/Views/SecretDetailView.swift +++ b/Sources/Secretive/Views/SecretDetailView.swift @@ -5,7 +5,7 @@ struct SecretDetailView: View { 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)) var body: some View { @@ -30,19 +30,9 @@ struct SecretDetailView: View { .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 { - keyWriter.openSSHString(secret: secret, comment: "\(dashedKeyName)@\(dashedHostName)") + keyWriter.openSSHString(secret: secret) } } diff --git a/Sources/Secretive/Views/SecretListItemView.swift b/Sources/Secretive/Views/SecretListItemView.swift index 58b0f32..357dc25 100644 --- a/Sources/Secretive/Views/SecretListItemView.swift +++ b/Sources/Secretive/Views/SecretListItemView.swift @@ -26,7 +26,7 @@ struct SecretListItemView: View { var body: some View { NavigationLink(value: secret) { - if secret.requiresAuthentication { + if secret.authenticationRequirement.required { HStack { Text(secret.name) Spacer() @@ -39,14 +39,16 @@ struct SecretListItemView: View { .contextMenu { if store is AnySecretStoreModifiable { Button(action: { isRenaming = true }) { - Text(.secretListRenameButton) + Image(systemName: "pencil") + Text(.secretListEditButton) } Button(action: { isDeleting = true }) { + Image(systemName: "trash") Text(.secretListDeleteButton) } } } - .popover(isPresented: showingPopup) { + .sheet(isPresented: showingPopup) { if let modifiable = store as? AnySecretStoreModifiable { if isDeleting { DeleteSecretView(store: modifiable, secret: secret) { deleted in @@ -56,7 +58,7 @@ struct SecretListItemView: View { } } } else if isRenaming { - RenameSecretView(store: modifiable, secret: secret) { renamed in + EditSecretView(store: modifiable, secret: secret) { renamed in isRenaming = false if renamed { renamedSecret(secret)