From 2f4d10d70d6638b93ece89985e029b9ea7b4735d Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 6 May 2026 00:07:04 -0700 Subject: [PATCH 1/5] Establish AI/LLM contribution policy (#799) Added a policy regarding the use of AI or LLM tools for contributions. --- CONTRIBUTING.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 902fddc..2b01144 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,10 @@ Security is obviously paramount for a project like Secretive. As such, any contr Secretive is designed to be easily auditable by people who are considering using it. In keeping with this, Secretive has no third party dependencies, and any contributions which bring in new dependencies will be rejected. +### AI/LLM Policy + +For security and auditing reasons similar to the policy Secretive has on dependencies, any code generated with AI or LLM tools will not be accepted. + ## Code of Conduct All contributors must abide by the [Code of Conduct](CODE_OF_CONDUCT.md) From b337b246412e9eab03369786dec94a4548b3056b Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 6 May 2026 01:03:21 -0700 Subject: [PATCH 2/5] Certificate UI/Import (#798) * Sketching out. * WIP * WIP * Dump * Apply stash * Merge + WIP * UI * More WIP * Agent config * UI cleanup * Restore dirty files * XPC * Edit/delete * UI fixes * Cleanup * Change id for OpenSSHCertificate to hex of md5 * Fix runtime warning for confirmation dialog * Mark strings as reviewed * Cleanup * Fix agent tests --- Sources/Packages/Package.swift | 20 +- .../Packages/Resources/Localizable.xcstrings | 175 +++++++++--- .../CertificateKit/CertificateStore.swift | 152 ++++++++++ Sources/Packages/Sources/Common/URLs.swift | 12 + .../Sources/SSHProtocolKit/Data+Hex.swift | 37 +++ .../SSHProtocolKit/OpenSSHCertificate.swift | 104 +++++++ .../OpenSSHPublicKeyWriter.swift | 4 +- .../SSHProtocolKit/OpenSSHReader.swift | 3 + .../SSHAgentInputParser.swift | 26 +- .../Sources/SecretAgentKit/Agent.swift | 19 +- .../OpenSSHCertificateHandler.swift | 89 ------ .../PublicKeyStandinFileController.swift | 54 ++-- .../CertificateMigrator.swift | 46 +++ .../XPCCertificateParser.swift | 28 ++ .../SecretAgentKitTests/AgentTests.swift | 25 +- Sources/SecretAgent/AppDelegate.swift | 27 +- Sources/SecretAgent/CertificateMigrator.swift | 47 ++++ Sources/SecretAgent/XPCInputParser.swift | 3 +- Sources/Secretive.xcodeproj/project.pbxproj | 262 +++++++++++++++++- .../xcshareddata/xcschemes/Secretive.xcscheme | 2 +- Sources/Secretive/App.swift | 13 +- .../Views/Secrets/CertificateDetailView.swift | 68 +++++ .../Secrets/CertificateListItemView.swift | 42 +++ .../Views/Secrets/DeleteCertificateView.swift | 52 ++++ .../Views/Secrets/DeleteSecretView.swift | 2 +- .../Views/Secrets/EditCertificateView.swift | 60 ++++ .../Views/Secrets/SecretDetailView.swift | 44 ++- .../Views/Secrets/StoreListView.swift | 61 ++-- .../Secretive/Views/Views/ContentView.swift | 24 +- .../Secretive/Views/Views/CopyableView.swift | 16 +- .../Views/Views/MultilineInfoView.swift | 167 +++++++++++ Sources/SecretiveCertificateParser/Info.plist | 11 + .../SecretiveCertificateParser.entitlements | 22 ++ .../SecretiveCertificateParser.swift | 17 ++ Sources/SecretiveCertificateParser/main.swift | 7 + 35 files changed, 1516 insertions(+), 225 deletions(-) create mode 100644 Sources/Packages/Sources/CertificateKit/CertificateStore.swift create mode 100644 Sources/Packages/Sources/SSHProtocolKit/OpenSSHCertificate.swift rename Sources/Packages/Sources/{SecretAgentKit => SSHProtocolKit}/SSHAgentInputParser.swift (80%) delete mode 100644 Sources/Packages/Sources/SecretAgentKit/OpenSSHCertificateHandler.swift create mode 100644 Sources/Packages/Sources/SharedXPCServices/CertificateMigrator.swift create mode 100644 Sources/Packages/Sources/SharedXPCServices/XPCCertificateParser.swift create mode 100644 Sources/SecretAgent/CertificateMigrator.swift create mode 100644 Sources/Secretive/Views/Secrets/CertificateDetailView.swift create mode 100644 Sources/Secretive/Views/Secrets/CertificateListItemView.swift create mode 100644 Sources/Secretive/Views/Secrets/DeleteCertificateView.swift create mode 100644 Sources/Secretive/Views/Secrets/EditCertificateView.swift create mode 100644 Sources/Secretive/Views/Views/MultilineInfoView.swift create mode 100644 Sources/SecretiveCertificateParser/Info.plist create mode 100644 Sources/SecretiveCertificateParser/SecretiveCertificateParser.entitlements create mode 100644 Sources/SecretiveCertificateParser/SecretiveCertificateParser.swift create mode 100644 Sources/SecretiveCertificateParser/main.swift diff --git a/Sources/Packages/Package.swift b/Sources/Packages/Package.swift index 3a8c855..966e36b 100644 --- a/Sources/Packages/Package.swift +++ b/Sources/Packages/Package.swift @@ -19,12 +19,18 @@ let package = Package( .library( name: "SmartCardSecretKit", targets: ["SmartCardSecretKit"]), + .library( + name: "CertificateKit", + targets: ["CertificateKit"]), .library( name: "SecretAgentKit", targets: ["SecretAgentKit"]), .library( name: "Common", targets: ["Common"]), + .library( + name: "SharedXPCServices", + targets: ["SharedXPCServices"]), .library( name: "Brief", targets: ["Brief"]), @@ -61,9 +67,15 @@ let package = Package( resources: [localization], swiftSettings: swiftSettings, ), + .target( + name: "CertificateKit", + dependencies: ["SecretKit", "SSHProtocolKit"], + resources: [localization], + swiftSettings: swiftSettings, + ), .target( name: "SecretAgentKit", - dependencies: ["SecretKit", "SSHProtocolKit", "Common"], + dependencies: ["SecretKit", "SSHProtocolKit", "CertificateKit", "Common"], resources: [localization], swiftSettings: swiftSettings, ), @@ -88,6 +100,12 @@ let package = Package( resources: [localization], swiftSettings: swiftSettings, ), + .target( + name: "SharedXPCServices", + dependencies: ["CertificateKit"], + resources: [localization], + swiftSettings: swiftSettings, + ), .target( name: "Brief", dependencies: ["XPCWrappers", "SSHProtocolKit"], diff --git a/Sources/Packages/Resources/Localizable.xcstrings b/Sources/Packages/Resources/Localizable.xcstrings index 3513013..a9ad383 100644 --- a/Sources/Packages/Resources/Localizable.xcstrings +++ b/Sources/Packages/Resources/Localizable.xcstrings @@ -5547,6 +5547,86 @@ } } } + }, + "certificate_detail_key_id_label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Key ID" + } + } + } + }, + "certificate_detail_path_label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Certificate Path" + } + } + } + }, + "certificate_detail_principals_label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Principals" + } + } + } + }, + "certificate_detail_serial_label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serial Number" + } + } + } + }, + "certificate_detail_valid_after_label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valid After" + } + } + } + }, + "certificate_detail_valid_until_label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valid Until" + } + } + } + }, + "certificate_detail_validity_range_label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Validity Range" + } + } + } + }, + "Certificates" : { + }, "copyable_click_to_copy_button" : { "extractionState" : "manual", @@ -9994,181 +10074,181 @@ "af" : { "stringUnit" : { "state" : "new", - "value" : "Delete %1$(secretName)@?" + "value" : "Delete %1$(name)@?" } }, "ar" : { "stringUnit" : { "state" : "new", - "value" : "Delete %1$(secretName)@?" + "value" : "Delete %1$(name)@?" } }, "ca" : { "stringUnit" : { "state" : "translated", - "value" : "Esborrar %1$(secretName)@?" + "value" : "Esborrar %1$(name)@?" } }, "cs" : { "stringUnit" : { "state" : "new", - "value" : "Delete %1$(secretName)@?" + "value" : "Delete %1$(name)@?" } }, "da" : { "stringUnit" : { "state" : "new", - "value" : "Delete %1$(secretName)@?" + "value" : "Delete %1$(name)@?" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "%1$(secretName)@ Löschen?" + "value" : "%1$(name)@ Löschen?" } }, "el" : { "stringUnit" : { "state" : "new", - "value" : "Delete %1$(secretName)@?" + "value" : "Delete %1$(name)@?" } }, "en" : { "stringUnit" : { - "state" : "new", - "value" : "Delete %1$(secretName)@?" + "state" : "translated", + "value" : "Delete %1$(name)@?" } }, "es" : { "stringUnit" : { "state" : "new", - "value" : "Delete %1$(secretName)@?" + "value" : "Delete %1$(name)@?" } }, "fi" : { "stringUnit" : { "state" : "translated", - "value" : "Poista %1$(secretName)@?" + "value" : "Poista %1$(name)@?" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Supprimer %1$(secretName)@?" + "value" : "Supprimer %1$(name)@?" } }, "he" : { "stringUnit" : { "state" : "new", - "value" : "Delete %1$(secretName)@?" + "value" : "Delete %1$(name)@?" } }, "hu" : { "stringUnit" : { "state" : "new", - "value" : "Delete %1$(secretName)@?" + "value" : "Delete %1$(name)@?" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Eliminare %1$(secretName)@?" + "value" : "Eliminare %1$(name)@?" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "%1$(secretName)@を削除しますか?" + "value" : "%1$(name)@を削除しますか?" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "%1$(secretName)@를 지우겠습니까?" + "value" : "%1$(name)@를 지우겠습니까?" } }, "nb" : { "stringUnit" : { "state" : "new", - "value" : "Delete %1$(secretName)@?" + "value" : "Delete %1$(name)@?" } }, "nl" : { "stringUnit" : { "state" : "new", - "value" : "Delete %1$(secretName)@?" + "value" : "Delete %1$(name)@?" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Usunąć %1$(secretName)@?" + "value" : "Usunąć %1$(name)@?" } }, "pt" : { "stringUnit" : { "state" : "new", - "value" : "Delete %1$(secretName)@?" + "value" : "Delete %1$(name)@?" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "Deletar %1$(secretName)@?" + "value" : "Deletar %1$(name)@?" } }, "ro" : { "stringUnit" : { "state" : "new", - "value" : "Delete %1$(secretName)@?" + "value" : "Delete %1$(name)@?" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Удалить %1$(secretName)@?" + "value" : "Удалить %1$(name)@?" } }, "sr" : { "stringUnit" : { "state" : "new", - "value" : "Delete %1$(secretName)@?" + "value" : "Delete %1$(name)@?" } }, "sv" : { "stringUnit" : { "state" : "new", - "value" : "Delete %1$(secretName)@?" + "value" : "Delete %1$(name)@?" } }, "tr" : { "stringUnit" : { "state" : "new", - "value" : "Delete %1$(secretName)@?" + "value" : "Delete %1$(name)@?" } }, "uk" : { "stringUnit" : { "state" : "new", - "value" : "Delete %1$(secretName)@?" + "value" : "Delete %1$(name)@?" } }, "vi" : { "stringUnit" : { "state" : "new", - "value" : "Delete %1$(secretName)@?" + "value" : "Delete %1$(name)@?" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "删除“%1$(secretName)@”吗?" + "value" : "删除“%1$(name)@”吗?" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "刪除「%1$(secretName)@」嗎?" + "value" : "刪除「%1$(name)@」嗎?" } } } @@ -19637,6 +19717,28 @@ } } }, + "rename_certificate_label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name" + } + } + } + }, + "rename_certificate_name_placeholder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Certificate Name" + } + } + } + }, "reveal_in_finder_button" : { "extractionState" : "manual", "localizations" : { @@ -19822,6 +19924,17 @@ } } }, + "secret_detail_certificate_path_label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Matching Certificates" + } + } + } + }, "secret_detail_md5_fingerprint_label" : { "extractionState" : "manual", "localizations" : { diff --git a/Sources/Packages/Sources/CertificateKit/CertificateStore.swift b/Sources/Packages/Sources/CertificateKit/CertificateStore.swift new file mode 100644 index 0000000..f44bfc5 --- /dev/null +++ b/Sources/Packages/Sources/CertificateKit/CertificateStore.swift @@ -0,0 +1,152 @@ +import Foundation +import Observation +import Security +import os +import SecretKit +import SSHProtocolKit + +@Observable @MainActor public final class CertificateStore: Sendable { + + public private(set) var certificates: [OpenSSHCertificate] = [] + + /// Initializes a Store. + public init() { + loadCertificates() + Task { + for await note in DistributedNotificationCenter.default().notifications(named: .certificateStoreUpdated) { + guard Constants.notificationToken != (note.object as? String) else { + // Don't reload if we're the ones triggering this by reloading. + continue + } + loadCertificates() + } + } + } + + public func reloadCertificates() { + let before = certificates + certificates.removeAll() + loadCertificates() + if certificates != before { + NotificationCenter.default.post(name: .certificateStoreReloaded, object: self) + DistributedNotificationCenter.default().postNotificationName(.certificateStoreUpdated, object: Constants.notificationToken, deliverImmediately: true) + } + } + + public func save(certificate: OpenSSHCertificate, originalData: Data) throws { + let attributes = try JSONEncoder().encode(certificate) + let keychainAttributes = KeychainDictionary([ + kSecClass: Constants.keyClass, + kSecAttrService: Constants.keyTag, + kSecAttrAccount: certificate.id, + kSecUseDataProtectionKeychain: true, + kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + kSecValueData: originalData, + kSecAttrGeneric: attributes + ]) + let status = SecItemAdd(keychainAttributes, nil) + if status != errSecSuccess && status != errSecDuplicateItem { + throw KeychainError(statusCode: status) + } + reloadCertificates() + } + + public func delete(certificate: OpenSSHCertificate) throws { + let deleteAttributes = KeychainDictionary([ + kSecClass: Constants.keyClass, + kSecAttrService: Constants.keyTag, + kSecUseDataProtectionKeychain: true, + kSecAttrAccount: certificate.id, + ]) + let status = SecItemDelete(deleteAttributes) + if status != errSecSuccess { + throw KeychainError(statusCode: status) + } + reloadCertificates() + } + + public func update(certificate: OpenSSHCertificate) throws { + let updateQuery = KeychainDictionary([ + kSecClass: Constants.keyClass, + kSecAttrAccount: certificate.id, + ]) + + let cert = try JSONEncoder().encode(certificate) + let updatedAttributes = KeychainDictionary([ + kSecAttrGeneric: cert, + ]) + + let status = SecItemUpdate(updateQuery, updatedAttributes) + if status != errSecSuccess { + throw KeychainError(statusCode: status) + } + reloadCertificates() + } + + public func certificates(for secret: any Secret) -> [OpenSSHCertificate] { + certificates.filter { $0.publicKey == secret.publicKey } + } + + +} + +extension CertificateStore { + + /// Loads all certificates from the store. + private func loadCertificates() { + let queryAttributes = KeychainDictionary([ + kSecClass: Constants.keyClass, + kSecAttrService: Constants.keyTag, + kSecUseDataProtectionKeychain: true, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitAll, + kSecReturnAttributes: true + ]) + var untyped: CFTypeRef? + unsafe SecItemCopyMatching(queryAttributes, &untyped) + guard let typed = untyped as? [[CFString: Any]] else { return } + let decoder = JSONDecoder() + let wrapped: [OpenSSHCertificate] = typed.compactMap { + do { + guard let attributesData = $0[kSecAttrGeneric] as? Data else { + throw MissingAttributesError() + } + return try decoder.decode(OpenSSHCertificate.self, from: attributesData) + } catch { + return nil + } + } + .filter { + if let validityRange = $0.validityRange { + validityRange.contains(Date()) + } else { + true + } + } + certificates.append(contentsOf: wrapped) + } + + +} + +extension CertificateStore { + + enum Constants { + static let keyClass = kSecClassGenericPassword as String + static let keyTag = Data("com.maxgoedjen.certificatestore.opensshcertificate".utf8) + static let notificationToken = UUID().uuidString + } + + struct UnsupportedAlgorithmError: Error {} + struct MissingAttributesError: Error {} + +} + +extension NSNotification.Name { + + // Distributed notification that keys were modified out of process (ie, that the management tool added/removed certificates) + public static let certificateStoreUpdated = NSNotification.Name("com.maxgoedjen.Secretive.certificateStore.updated") + // Internal notification that certificates were reloaded from the backing store. + public static let certificateStoreReloaded = NSNotification.Name("com.maxgoedjen.Secretive.certificateStore.reloaded") + +} diff --git a/Sources/Packages/Sources/Common/URLs.swift b/Sources/Packages/Sources/Common/URLs.swift index 9dfee59..e3d21e0 100644 --- a/Sources/Packages/Sources/Common/URLs.swift +++ b/Sources/Packages/Sources/Common/URLs.swift @@ -20,6 +20,10 @@ extension URL { agentHomeURL.appending(component: "PublicKeys") } + public static var certificatesDirectory: URL { + agentHomeURL.appending(component: "Certificates") + } + /// The path for a Secret's public key. /// - Parameter secret: The Secret to return the path for. /// - Returns: The path to the Secret's public key. @@ -30,6 +34,14 @@ extension URL { return directory.appending(component: "\(minimalHex).pub").path() } + /// The path for a certificate. + /// - Parameter certificate: The OpenSSHCertificate to return the path for. + /// - Returns: The path to the OpenSSHCertificate. + /// - Warning: This method returning a path does not imply that a certificate has been written to disk already. This method only describes where it will be written to. + public static func certificatePath(for certificate: OpenSSHCertificate, in directory: URL) -> String { + return directory.appending(component: "\(certificate.id)-cert.pub").path() + } + } extension String { diff --git a/Sources/Packages/Sources/SSHProtocolKit/Data+Hex.swift b/Sources/Packages/Sources/SSHProtocolKit/Data+Hex.swift index bf1bb1d..c426ae9 100644 --- a/Sources/Packages/Sources/SSHProtocolKit/Data+Hex.swift +++ b/Sources/Packages/Sources/SSHProtocolKit/Data+Hex.swift @@ -28,6 +28,7 @@ extension FormatStyle where Self == HexDataStyle { } } + extension FormatStyle where Self == HexDataStyle { public static func hex(separator: String = ":") -> HexDataStyle { @@ -35,3 +36,39 @@ extension FormatStyle where Self == HexDataStyle { } } + +public struct Base64DataStyle: Hashable, Codable { + + private let stripPadding: Bool + + public init(stripPadding: Bool) { + self.stripPadding = stripPadding + } + +} + +extension Base64DataStyle: FormatStyle where SequenceType.Element == UInt8 { + + public func format(_ value: SequenceType) -> String { + let base64 = Data(value).base64EncodedString() + let paddingRange = base64.index(base64.endIndex, offsetBy: -2).. { + + public static func base64(stripPadding: Bool) -> Base64DataStyle { + Base64DataStyle(stripPadding: stripPadding) + } + +} + +extension FormatStyle where Self == Base64DataStyle { + + public static func base64(stripPadding: Bool) -> Base64DataStyle { + Base64DataStyle(stripPadding: stripPadding) + } + +} diff --git a/Sources/Packages/Sources/SSHProtocolKit/OpenSSHCertificate.swift b/Sources/Packages/Sources/SSHProtocolKit/OpenSSHCertificate.swift new file mode 100644 index 0000000..f1354a5 --- /dev/null +++ b/Sources/Packages/Sources/SSHProtocolKit/OpenSSHCertificate.swift @@ -0,0 +1,104 @@ +import Foundation +import OSLog +import CryptoKit + +public struct OpenSSHCertificate: Sendable, Codable, Equatable, Hashable, Identifiable, CustomDebugStringConvertible { + + public var id: String { Insecure.MD5.hash(data: data).formatted(.hex(separator: "")) } + public var type: CertificateType + public var name: String + public let data: Data + + public var publicKey: Data + public var principals: [String] + public var keyID: String + public var serial: UInt64 + public var validityRange: Range? + + public var debugDescription: String { + "OpenSSH Certificate \(name, default: "Unnamed"): \(data.formatted(.hex()))" + } + +} + +extension OpenSSHCertificate { + + public enum CertificateType: String, Sendable, Codable { + case ecdsa256 = "ecdsa-sha2-nistp256-cert-v01@openssh.com" + case ecdsa384 = "ecdsa-sha2-nistp384-cert-v01@openssh.com" + case nistp521 = "ecdsa-sha2-nistp521-cert-v01@openssh.com" + + var keyIdentifier: String { + rawValue.replacingOccurrences(of: "-cert-v01@openssh.com", with: "") + } + } + +} + +public protocol OpenSSHCertificateParserProtocol { + func parse(data: Data) async throws -> OpenSSHCertificate +} + +public struct OpenSSHCertificateParser: OpenSSHCertificateParserProtocol, Sendable { + + private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "OpenSSHCertificateParser") + + public init() { + assert(Bundle.main.bundleURL.pathExtension == "xpc" || ProcessInfo.processInfo.processName == "xctest", "Potentially unsafe parsing code should run in an XPC service") + } + + public func parse(data: Data) throws(OpenSSHCertificateError) -> OpenSSHCertificate { + let string = String(decoding: data, as: UTF8.self) + var elements = string + .trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: " ") + guard elements.count >= 2 else { + throw OpenSSHCertificateError.parsingFailed + } + let typeString = elements.removeFirst() + guard let type = OpenSSHCertificate.CertificateType(rawValue: typeString) else { throw .unsupportedType } + let encodedKey = elements.removeFirst() + guard let decoded = Data(base64Encoded: encodedKey) else { + throw OpenSSHCertificateError.parsingFailed + } + let comment = elements.first + do { + let dataParser = OpenSSHReader(data: decoded) + _ = try dataParser.readNextChunkAsString() // Redundant key type + _ = try dataParser.readNextChunk() // Nonce + _ = try dataParser.readNextChunkAsString() // curve + let publicKey = try dataParser.readNextChunk() + let serialNumber = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true) + let role = try dataParser.readNextBytes(as: UInt32.self, convertEndianness: true) + _ = role + let keyIdentifier = try dataParser.readNextChunkAsString() + let principalsReader = try dataParser.readNextChunkAsSubReader() + var principals: [String] = [] + while !principalsReader.done { + try principals.append(principalsReader.readNextChunkAsString()) + } + let validAfter = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true) + let validBefore = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true) + let validityRange = Date(timeIntervalSince1970: TimeInterval(validAfter))..(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).. SSHAgent.Request { @@ -75,21 +74,16 @@ extension SSHAgentInputParser { func certificatePublicKeyBlob(from hash: Data) -> Data? { let reader = OpenSSHReader(data: hash) do { - let certType = String(decoding: try reader.readNextChunk(), as: UTF8.self) - switch certType { - case "ecdsa-sha2-nistp256-cert-v01@openssh.com", - "ecdsa-sha2-nistp384-cert-v01@openssh.com", - "ecdsa-sha2-nistp521-cert-v01@openssh.com": - _ = try reader.readNextChunk() // nonce - let curveIdentifier = try reader.readNextChunk() - let publicKey = try reader.readNextChunk() - let openSSHIdentifier = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "") - return openSSHIdentifier.lengthAndData + - curveIdentifier.lengthAndData + + let certType = try reader.readNextChunkAsString() + guard let certType = OpenSSHCertificate.CertificateType(rawValue: certType) else { return nil } + _ = try reader.readNextChunk() // nonce + let curveIdentifier = try reader.readNextChunk() + let publicKey = try reader.readNextChunk() + let openSSHIdentifier = certType.keyIdentifier + return openSSHIdentifier.lengthAndData + + curveIdentifier.lengthAndData + publicKey.lengthAndData - default: - return nil - } + } catch { return nil } diff --git a/Sources/Packages/Sources/SecretAgentKit/Agent.swift b/Sources/Packages/Sources/SecretAgentKit/Agent.swift index ba66603..d3445ef 100644 --- a/Sources/Packages/Sources/SecretAgentKit/Agent.swift +++ b/Sources/Packages/Sources/SecretAgentKit/Agent.swift @@ -2,6 +2,7 @@ import Foundation import CryptoKit import OSLog import SecretKit +import CertificateKit import AppKit import SSHProtocolKit @@ -9,23 +10,21 @@ import SSHProtocolKit public final class Agent: Sendable { private let storeList: SecretStoreList + private let certificateStore: CertificateStore private let witness: SigningWitness? private let publicKeyWriter = OpenSSHPublicKeyWriter() private let signatureWriter = OpenSSHSignatureWriter() - private let certificateHandler = OpenSSHCertificateHandler() private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent") /// Initializes an agent with a store list and a witness. /// - Parameters: /// - storeList: The `SecretStoreList` to make available. /// - witness: A witness to notify of requests. - public init(storeList: SecretStoreList, witness: SigningWitness? = nil) { + public init(storeList: SecretStoreList, certificateStore: CertificateStore, witness: SigningWitness? = nil) { logger.debug("Agent is running") self.storeList = storeList + self.certificateStore = certificateStore self.witness = witness - Task { @MainActor in - await certificateHandler.reloadCertificates(for: storeList.allSecrets) - } } } @@ -68,7 +67,6 @@ extension Agent { /// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations. func identities() async -> Data { let secrets = await storeList.allSecrets - await certificateHandler.reloadCertificates(for: secrets) var count = 0 var keyData = Data() @@ -77,10 +75,9 @@ extension Agent { keyData.append(keyBlob.lengthAndData) keyData.append(publicKeyWriter.comment(secret: secret).lengthAndData) count += 1 - - if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) { - keyData.append(certificateData.lengthAndData) - keyData.append(name.lengthAndData) + for certificate in await certificateStore.certificates(for: secret) { + keyData.append(certificate.data.lengthAndData) + keyData.append(certificate.name.lengthAndData) count += 1 } } @@ -97,7 +94,7 @@ extension Agent { /// - Returns: An OpenSSH formatted Data payload containing the signed data response. func sign(data: Data, keyBlob: Data, provenance: SigningRequestProvenance) async throws -> Data { guard let (secret, store) = await secret(matching: keyBlob) else { - let keyBlobHex = keyBlob.compactMap { ("0" + String($0, radix: 16, uppercase: false)).suffix(2) }.joined() + let keyBlobHex = keyBlob.formatted(.hex()) logger.debug("Agent did not have a key matching \(keyBlobHex)") throw NoMatchingKeyError() } diff --git a/Sources/Packages/Sources/SecretAgentKit/OpenSSHCertificateHandler.swift b/Sources/Packages/Sources/SecretAgentKit/OpenSSHCertificateHandler.swift deleted file mode 100644 index 7fbb0b3..0000000 --- a/Sources/Packages/Sources/SecretAgentKit/OpenSSHCertificateHandler.swift +++ /dev/null @@ -1,89 +0,0 @@ -import Foundation -import OSLog -import SecretKit -import SSHProtocolKit - -/// Manages storage and lookup for OpenSSH certificates. -public actor OpenSSHCertificateHandler: Sendable { - - private let publicKeyFileStoreController = PublicKeyFileStoreController(directory: URL.publicKeyDirectory) - private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler") - private let writer = OpenSSHPublicKeyWriter() - private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:] - - /// Initializes an OpenSSHCertificateHandler. - public init() { - } - - /// Reloads any certificates in the PublicKeys folder. - /// - Parameter secrets: the secrets to look up corresponding certificates for. - public func reloadCertificates(for secrets: [AnySecret]) { - guard publicKeyFileStoreController.hasAnyCertificates else { - logger.log("No certificates, short circuiting") - return - } - keyBlobsAndNames = secrets.reduce(into: [:]) { partialResult, next in - partialResult[next] = try? loadKeyblobAndName(for: next) - } - } - - /// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret`` - /// - Parameter secret: The secret to search for a certificate with - /// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively. - public func keyBlobAndName(for secret: SecretType) throws -> (Data, Data)? { - keyBlobsAndNames[AnySecret(secret)] - } - - /// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret`` - /// - Parameter secret: The secret to search for a certificate with - /// - Returns: A (``Data``, ``Data``) tuple containing the certificate and certificate name, respectively. - private func loadKeyblobAndName(for secret: SecretType) throws -> (Data, Data)? { - let certificatePath = publicKeyFileStoreController.sshCertificatePath(for: secret) - guard FileManager.default.fileExists(atPath: certificatePath) else { - return nil - } - - logger.debug("Found certificate for \(secret.name)") - let certContent = try String(contentsOfFile:certificatePath, encoding: .utf8) - let certElements = certContent.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: " ") - - guard certElements.count >= 2 else { - logger.warning("Certificate found for \(secret.name) but failed to load") - throw OpenSSHCertificateError.parsingFailed - } - guard let certDecoded = Data(base64Encoded: certElements[1] as String) else { - logger.warning("Certificate found for \(secret.name) but failed to decode base64 key") - throw OpenSSHCertificateError.parsingFailed - } - - if certElements.count >= 3 { - let certName = Data(certElements[2].utf8) - return (certDecoded, certName) - } - 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) - } - -} - -extension OpenSSHCertificateHandler { - - enum OpenSSHCertificateError: LocalizedError { - case unsupportedType - case parsingFailed - case doesNotExist - - public var errorDescription: String? { - switch self { - case .unsupportedType: - return "The key type was unsupported" - case .parsingFailed: - return "Failed to properly parse the SSH certificate" - case .doesNotExist: - return "Certificate does not exist" - } - } - } - -} diff --git a/Sources/Packages/Sources/SecretAgentKit/PublicKeyStandinFileController.swift b/Sources/Packages/Sources/SecretAgentKit/PublicKeyStandinFileController.swift index a8aaffd..ff8fb02 100644 --- a/Sources/Packages/Sources/SecretAgentKit/PublicKeyStandinFileController.swift +++ b/Sources/Packages/Sources/SecretAgentKit/PublicKeyStandinFileController.swift @@ -8,12 +8,14 @@ import Common public final class PublicKeyFileStoreController: Sendable { private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController") - private let directory: URL + private let publicKeysURL: URL + private let certificatesURL: URL private let keyWriter = OpenSSHPublicKeyWriter() /// Initializes a PublicKeyFileStoreController. - public init(directory: URL) { - self.directory = directory + public init(publicKeysURL: URL, certificatesURL: URL) { + self.publicKeysURL = publicKeysURL + self.certificatesURL = certificatesURL } /// Writes out the keys specified to disk. @@ -22,10 +24,10 @@ public final class PublicKeyFileStoreController: Sendable { public func generatePublicKeys(for secrets: [AnySecret], clear: Bool = false) throws { logger.log("Writing public keys to disk") if clear { - let validPaths = Set(secrets.map { URL.publicKeyPath(for: $0, in: directory) }) - .union(Set(secrets.map { sshCertificatePath(for: $0) })) - let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: directory.path())) ?? [] - let fullPathContents = contentsOfDirectory.map { directory.appending(path: $0).path() } + let validPaths = Set(secrets.map { URL.publicKeyPath(for: $0, in: publicKeysURL) }) + .union(Set(secrets.map { legacySSHCertificatePath(for: $0) })) + let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: publicKeysURL.path())) ?? [] + let fullPathContents = contentsOfDirectory.map { publicKeysURL.appending(path: $0).path() } let untracked = Set(fullPathContents) .subtracting(validPaths) @@ -34,35 +36,47 @@ public final class PublicKeyFileStoreController: Sendable { try? FileManager.default.removeItem(at: URL(string: path)!) } } - try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, attributes: nil) + try? FileManager.default.createDirectory(at: publicKeysURL, withIntermediateDirectories: false, attributes: nil) for secret in secrets { - let path = URL.publicKeyPath(for: secret, in: directory) + let path = URL.publicKeyPath(for: secret, in: publicKeysURL) let data = Data(keyWriter.openSSHString(secret: secret).utf8) FileManager.default.createFile(atPath: path, contents: data, attributes: nil) } logger.log("Finished writing public keys") } + /// Writes out the certificates specified to disk. + /// - Parameter certificates: The Secrets to generate keys for. + /// - Parameter clear: Whether or not any untracked files in the directory should be removed. + public func generateCertificates(for certificates: [OpenSSHCertificate], clear: Bool = false) throws { + logger.log("Writing certificates to disk") + if clear { + let validPaths = Set(certificates.map { URL.certificatePath(for: $0, in: certificatesURL) }) + let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: certificatesURL.path())) ?? [] + let fullPathContents = contentsOfDirectory.map { certificatesURL.appending(path: $0).path() } - /// Short-circuit check to ship enumerating a bunch of paths if there's nothing in the cert directory. - public var hasAnyCertificates: Bool { - do { - return try FileManager.default - .contentsOfDirectory(atPath: directory.path()) - .filter { $0.hasSuffix("-cert.pub") } - .isEmpty == false - } catch { - return false + let untracked = Set(fullPathContents) + .subtracting(validPaths) + for path in untracked { + // string instead of fileURLWithPath since we're already using fileURL format. + try? FileManager.default.removeItem(at: URL(string: path)!) + } } + try? FileManager.default.createDirectory(at: certificatesURL, withIntermediateDirectories: false, attributes: nil) + for certificate in certificates { + let path = URL.certificatePath(for: certificate, in: certificatesURL) + FileManager.default.createFile(atPath: path, contents: certificate.data, attributes: nil) + } + logger.log("Finished writing certificates") } /// The path for a Secret's SSH Certificate public key. /// - Parameter secret: The Secret to return the path for. /// - Returns: The path to the SSH Certificate public key. /// - Warning: This method returning a path does not imply that a key has a SSH certificates. This method only describes where it will be. - public func sshCertificatePath(for secret: SecretType) -> String { + private func legacySSHCertificatePath(for secret: SecretType) -> String { let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "") - return directory.appending(component: "\(minimalHex)-cert.pub").path() + return publicKeysURL.appending(component: "\(minimalHex)-cert.pub").path() } } diff --git a/Sources/Packages/Sources/SharedXPCServices/CertificateMigrator.swift b/Sources/Packages/Sources/SharedXPCServices/CertificateMigrator.swift new file mode 100644 index 0000000..8de3f14 --- /dev/null +++ b/Sources/Packages/Sources/SharedXPCServices/CertificateMigrator.swift @@ -0,0 +1,46 @@ +import Foundation +import Security +import CryptoTokenKit +import CryptoKit +import os +import SSHProtocolKit +import CertificateKit + +public struct CertificateMigrator { + + private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CertificateKitMigrator") + private let directory: URL + private let certificateStore: CertificateStore + + /// Initializes a PublicKeyFileStoreController. + public init(homeDirectory: URL, certificateStore: CertificateStore) { + directory = homeDirectory.appending(component: "PublicKeys") + self.certificateStore = certificateStore + } + + @MainActor public func migrate() throws { + let fileCerts = try FileManager.default + .contentsOfDirectory(atPath: directory.path()) + .filter { $0.hasSuffix("-cert.pub") } + Task { + for path in fileCerts { + do { + let url = directory.appending(component: path) + let data = try Data(contentsOf: url) + let parser = try await XPCCertificateParser() + let cert = try await parser.parse(data: data) + try certificateStore.save(certificate: cert, originalData: data) + do { + try FileManager.default.removeItem(at: url) + } catch { + logger.error("Failed to delete successfully migrated cert: \(path)") + } + } catch { + logger.error("Failed to migrate cert: \(path)") + } + } + + } + } + +} diff --git a/Sources/Packages/Sources/SharedXPCServices/XPCCertificateParser.swift b/Sources/Packages/Sources/SharedXPCServices/XPCCertificateParser.swift new file mode 100644 index 0000000..11a8eae --- /dev/null +++ b/Sources/Packages/Sources/SharedXPCServices/XPCCertificateParser.swift @@ -0,0 +1,28 @@ +import Foundation +import OSLog +import SSHProtocolKit +import XPCWrappers + +/// Delegates all agent input parsing to an XPC service which wraps OpenSSH +public final class XPCCertificateParser: OpenSSHCertificateParserProtocol { + + private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "XPCCertificateParser") + private let session: XPCTypedSession + + public init() async throws { + logger.debug("Creating XPCCertificateParser") + session = try await XPCTypedSession(serviceName: "com.maxgoedjen.Secretive.SecretiveCertificateParser", warmup: true) + logger.debug("XPCCertificateParser is warmed up.") + } + + public func parse(data: Data) async throws -> OpenSSHCertificate { + logger.debug("Parsing input") + defer { logger.debug("Parsed input") } + return try await session.send(data) + } + + deinit { + session.complete() + } + +} diff --git a/Sources/Packages/Tests/SecretAgentKitTests/AgentTests.swift b/Sources/Packages/Tests/SecretAgentKitTests/AgentTests.swift index f946524..b8bd9c5 100644 --- a/Sources/Packages/Tests/SecretAgentKitTests/AgentTests.swift +++ b/Sources/Packages/Tests/SecretAgentKitTests/AgentTests.swift @@ -1,16 +1,17 @@ import Foundation import Testing import CryptoKit +import CertificateKit @testable import SSHProtocolKit @testable import SecretKit @testable import SecretAgentKit -@Suite struct AgentTests { +@Suite @MainActor struct AgentTests { // MARK: Identity Listing @Test func emptyStores() async throws { - let agent = Agent(storeList: SecretStoreList()) + let agent = Agent(storeList: SecretStoreList(), certificateStore: CertificateStore()) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities) let response = await agent.handle(request: request, provenance: .test) #expect(response == Constants.Responses.requestIdentitiesEmpty) @@ -18,7 +19,7 @@ import CryptoKit @Test func identitiesList() async throws { let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) - let agent = Agent(storeList: list) + let agent = Agent(storeList: list, certificateStore: CertificateStore()) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestIdentities) let response = await agent.handle(request: request, provenance: .test) @@ -32,7 +33,7 @@ import CryptoKit @Test func noMatchingIdentities() async throws { let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) - let agent = Agent(storeList: list) + let agent = Agent(storeList: list, certificateStore: CertificateStore()) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignatureWithNoneMatching) let response = await agent.handle(request: request, provenance: .test) #expect(response == Constants.Responses.requestFailure) @@ -42,7 +43,7 @@ import CryptoKit let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature) guard case SSHAgent.Request.signRequest(let context) = request else { return } let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) - let agent = Agent(storeList: list) + let agent = Agent(storeList: list, certificateStore: CertificateStore()) let response = await agent.handle(request: request, provenance: .test) let responseReader = OpenSSHReader(data: response) let length = try responseReader.readNextBytes(as: UInt32.self) @@ -77,7 +78,7 @@ import CryptoKit let witness = StubWitness(speakNow: { _,_ in return true }, witness: { _, _ in }) - let agent = Agent(storeList: list, witness: witness) + let agent = Agent(storeList: list, certificateStore: CertificateStore(), witness: witness) let response = await agent.handle(request: .signRequest(.empty), provenance: .test) #expect(response == Constants.Responses.requestFailure) } @@ -90,7 +91,7 @@ import CryptoKit }, witness: { _, trace in witnessed = true }) - let agent = Agent(storeList: list, witness: witness) + let agent = Agent(storeList: list, certificateStore: CertificateStore(), witness: witness) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature) _ = await agent.handle(request: request, provenance: .test) #expect(witnessed) @@ -106,7 +107,7 @@ import CryptoKit }, witness: { _, trace in witnessTrace = trace }) - let agent = Agent(storeList: list, witness: witness) + let agent = Agent(storeList: list, certificateStore: CertificateStore(), witness: witness) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature) _ = await agent.handle(request: request, provenance: .test) #expect(witnessTrace == speakNowTrace) @@ -117,9 +118,9 @@ import CryptoKit @Test func signatureException() async throws { let list = await storeList(with: [Constants.Secrets.ecdsa256Secret, Constants.Secrets.ecdsa384Secret]) - let store = await list.stores.first?.base as! Stub.Store + let store = list.stores.first?.base as! Stub.Store store.shouldThrow = true - let agent = Agent(storeList: list) + let agent = Agent(storeList: list, certificateStore: CertificateStore()) let request = try SSHAgentInputParser().parse(data: Constants.Requests.requestSignature) let response = await agent.handle(request: request, provenance: .test) #expect(response == Constants.Responses.requestFailure) @@ -128,7 +129,7 @@ import CryptoKit // MARK: Unsupported @Test func unhandledAdd() async throws { - let agent = Agent(storeList: SecretStoreList()) + let agent = Agent(storeList: SecretStoreList(), certificateStore: CertificateStore()) let response = await agent.handle(request: .addIdentity, provenance: .test) #expect(response == Constants.Responses.requestFailure) } @@ -143,7 +144,7 @@ extension SigningRequestProvenance { extension AgentTests { - @MainActor func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList { + func storeList(with secrets: [Stub.Secret]) async -> SecretStoreList { let store = Stub.Store() store.secrets.append(contentsOf: secrets) let storeList = SecretStoreList() diff --git a/Sources/SecretAgent/AppDelegate.swift b/Sources/SecretAgent/AppDelegate.swift index 40a11a3..a3a7507 100644 --- a/Sources/SecretAgent/AppDelegate.swift +++ b/Sources/SecretAgent/AppDelegate.swift @@ -6,8 +6,21 @@ import SmartCardSecretKit import SecretAgentKit import Brief import Observation +import SSHProtocolKit +import CertificateKit import Common +import SwiftUI +extension EnvironmentValues { + + @MainActor fileprivate static let _certificateStore: CertificateStore = CertificateStore() + + @MainActor var certificateStore: CertificateStore { + EnvironmentValues._certificateStore + } + + +} @main class AppDelegate: NSObject, NSApplicationDelegate { @@ -18,13 +31,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { try? migrator.migrate(to: cryptoKit) list.add(store: cryptoKit) list.add(store: SmartCard.Store()) + let certsMigrator = CertificateMigrator(homeDirectory: URL.homeDirectory, certificateStore: EnvironmentValues._certificateStore) + try? certsMigrator.migrate() return list }() private let updater = Updater(checkOnLaunch: true) private let notifier = Notifier() - private let publicKeyFileStoreController = PublicKeyFileStoreController(directory: URL.publicKeyDirectory) - private lazy var agent: Agent = { - Agent(storeList: storeList, witness: notifier) + private let publicKeyFileStoreController = PublicKeyFileStoreController(publicKeysURL: URL.publicKeyDirectory, certificatesURL: URL.certificatesDirectory) + @MainActor private lazy var agent: Agent = { + Agent(storeList: storeList, certificateStore: EnvironmentValues._certificateStore, witness: notifier) }() private lazy var socketController: SocketController = { let path = URL.socketPath as String @@ -55,7 +70,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true) } } + Task { + for await _ in NotificationCenter.default.notifications(named: .certificateStoreReloaded) { + try? publicKeyFileStoreController.generateCertificates(for: EnvironmentValues._certificateStore.certificates, clear: true) + } + } try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true) + try? publicKeyFileStoreController.generateCertificates(for: EnvironmentValues._certificateStore.certificates, clear: true) notifier.prompt() _ = withObservationTracking { updater.update diff --git a/Sources/SecretAgent/CertificateMigrator.swift b/Sources/SecretAgent/CertificateMigrator.swift new file mode 100644 index 0000000..0c0966b --- /dev/null +++ b/Sources/SecretAgent/CertificateMigrator.swift @@ -0,0 +1,47 @@ +import Foundation +import Security +import CryptoTokenKit +import CryptoKit +import os +import SSHProtocolKit +import CertificateKit +import SharedXPCServices + +public struct CertificateMigrator { + + private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CertificateKitMigrator") + private let directory: URL + private let certificateStore: CertificateStore + + /// Initializes a PublicKeyFileStoreController. + public init(homeDirectory: URL, certificateStore: CertificateStore) { + directory = homeDirectory.appending(component: "PublicKeys") + self.certificateStore = certificateStore + } + + @MainActor public func migrate() throws { + let fileCerts = try FileManager.default + .contentsOfDirectory(atPath: directory.path()) + .filter { $0.hasSuffix("-cert.pub") } + Task { + for path in fileCerts { + do { + let url = directory.appending(component: path) + let data = try Data(contentsOf: url) + let parser = try await XPCCertificateParser() + let cert = try await parser.parse(data: data) + try certificateStore.save(certificate: cert, originalData: data) + do { + try FileManager.default.removeItem(at: url) + } catch { + logger.error("Failed to delete successfully migrated cert: \(path)") + } + } catch { + logger.error("Failed to migrate cert: \(path)") + } + } + + } + } + +} diff --git a/Sources/SecretAgent/XPCInputParser.swift b/Sources/SecretAgent/XPCInputParser.swift index a3d7f28..f2f298e 100644 --- a/Sources/SecretAgent/XPCInputParser.swift +++ b/Sources/SecretAgent/XPCInputParser.swift @@ -1,5 +1,6 @@ import Foundation -import SecretAgentKit +import OSLog +import SSHProtocolKit import Brief import XPCWrappers import OSLog diff --git a/Sources/Secretive.xcodeproj/project.pbxproj b/Sources/Secretive.xcodeproj/project.pbxproj index d7a7aea..6b4f965 100644 --- a/Sources/Secretive.xcodeproj/project.pbxproj +++ b/Sources/Secretive.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 504788F62E68206F00B4556F /* GettingStartedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504788F52E68206F00B4556F /* GettingStartedView.swift */; }; 504789232E697DD300B4556F /* BoxBackgroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504789222E697DD300B4556F /* BoxBackgroundStyle.swift */; }; 50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50571E0224393C2600F76F6C /* JustUpdatedChecker.swift */; }; + 505F5EF22FA9635700C45824 /* CertificateKit in Frameworks */ = {isa = PBXBuildFile; productRef = 505F5EF12FA9635700C45824 /* CertificateKit */; }; 50617D8323FCE48E0099B055 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617D8223FCE48E0099B055 /* App.swift */; }; 50617D8523FCE48E0099B055 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50617D8423FCE48E0099B055 /* ContentView.swift */; }; 50617D8A23FCE48E0099B055 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50617D8923FCE48E0099B055 /* Preview Assets.xcassets */; }; @@ -71,6 +72,20 @@ 50CF4ABC2E601B0F005588DC /* ActionButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */; }; 50E0145C2EDB9CDF00B121F1 /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 50E0145B2EDB9CDF00B121F1 /* Common */; }; 50E0145E2EDB9CE400B121F1 /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 50E0145D2EDB9CE400B121F1 /* Common */; }; + 50E204E92FA9D12700402380 /* CertificateDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E204E82FA9D12700402380 /* CertificateDetailView.swift */; }; + 50E204ED2FAA997F00402380 /* CertificateListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E204EC2FAA997F00402380 /* CertificateListItemView.swift */; }; + 50E204EF2FAA9C1400402380 /* MultilineInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E204EE2FAA9C1400402380 /* MultilineInfoView.swift */; }; + 50E2051D2FAAB81C00402380 /* SecretiveCertificateParser.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 50E205142FAAB81C00402380 /* SecretiveCertificateParser.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 50E205282FAAB82700402380 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E205242FAAB82700402380 /* main.swift */; }; + 50E2052C2FAAB85000402380 /* SecretiveCertificateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E2052B2FAAB85000402380 /* SecretiveCertificateParser.swift */; }; + 50E2052D2FAAB92000402380 /* SecretiveCertificateParser.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 50E205142FAAB81C00402380 /* SecretiveCertificateParser.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 50E205312FAAB95500402380 /* XPCWrappers in Frameworks */ = {isa = PBXBuildFile; productRef = 50E205302FAAB95500402380 /* XPCWrappers */; }; + 50E205332FAAB95A00402380 /* SSHProtocolKit in Frameworks */ = {isa = PBXBuildFile; productRef = 50E205322FAAB95A00402380 /* SSHProtocolKit */; }; + 50E205362FAABC6300402380 /* EditCertificateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E205352FAABC6300402380 /* EditCertificateView.swift */; }; + 50E205372FAABC6300402380 /* DeleteCertificateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E205342FAABC6300402380 /* DeleteCertificateView.swift */; }; + 50E205802FAB291E00402380 /* CertificateMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E2057F2FAB291E00402380 /* CertificateMigrator.swift */; }; + 50E205822FAB293B00402380 /* SharedXPCServices in Frameworks */ = {isa = PBXBuildFile; productRef = 50E205812FAB293B00402380 /* SharedXPCServices */; }; + 50E205842FAB296A00402380 /* SharedXPCServices in Frameworks */ = {isa = PBXBuildFile; productRef = 50E205832FAB296A00402380 /* SharedXPCServices */; }; 50E4C4532E73C78C00C73783 /* WindowBackgroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E4C4522E73C78900C73783 /* WindowBackgroundStyle.swift */; }; 50E4C4C32E7765DF00C73783 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E4C4C22E7765DF00C73783 /* AboutView.swift */; }; 50E4C4C82E777E4200C73783 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 50E4C4C72E777E4200C73783 /* AppIcon.icon */; }; @@ -120,6 +135,20 @@ remoteGlobalIDString = 50692E4F2E6FF9D20043C7BB; remoteInfo = SecretAgentInputParser; }; + 50E2051B2FAAB81C00402380 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 50617D7723FCE48D0099B055 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 50E205132FAAB81C00402380; + remoteInfo = SecretAgentCertificateParser; + }; + 50E2052E2FAAB92000402380 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 50617D7723FCE48D0099B055 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 50E205132FAAB81C00402380; + remoteInfo = SecretiveCertificateParser; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -129,6 +158,7 @@ dstPath = "$(CONTENTS_FOLDER_PATH)/XPCServices"; dstSubfolderSpec = 16; files = ( + 50E2051D2FAAB81C00402380 /* SecretiveCertificateParser.xpc in Embed XPC Services */, 50692D1D2E6FDB880043C7BB /* SecretiveUpdater.xpc in Embed XPC Services */, 50692E5B2E6FF9D20043C7BB /* SecretAgentInputParser.xpc in Embed XPC Services */, ); @@ -142,6 +172,7 @@ dstSubfolderSpec = 16; files = ( 50692E6D2E6FFA5F0043C7BB /* SecretiveUpdater.xpc in Embed XPC Services */, + 50E2052D2FAAB92000402380 /* SecretiveCertificateParser.xpc in Embed XPC Services */, 50692E702E6FFA6E0043C7BB /* SecretAgentInputParser.xpc in Embed XPC Services */, ); name = "Embed XPC Services"; @@ -238,6 +269,17 @@ 50BDCB752E6450950072D2E7 /* ConfigurationItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationItemView.swift; sourceTree = ""; }; 50C385A42407A76D00AF2719 /* SecretDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretDetailView.swift; sourceTree = ""; }; 50CF4ABB2E601B0F005588DC /* ActionButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButtonStyle.swift; sourceTree = ""; }; + 50E204E82FA9D12700402380 /* CertificateDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateDetailView.swift; sourceTree = ""; }; + 50E204EC2FAA997F00402380 /* CertificateListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateListItemView.swift; sourceTree = ""; }; + 50E204EE2FAA9C1400402380 /* MultilineInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultilineInfoView.swift; sourceTree = ""; }; + 50E205142FAAB81C00402380 /* SecretiveCertificateParser.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = SecretiveCertificateParser.xpc; sourceTree = BUILT_PRODUCTS_DIR; }; + 50E205232FAAB82700402380 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50E205242FAAB82700402380 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + 50E2052A2FAAB85000402380 /* SecretiveCertificateParser.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SecretiveCertificateParser.entitlements; sourceTree = ""; }; + 50E2052B2FAAB85000402380 /* SecretiveCertificateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretiveCertificateParser.swift; sourceTree = ""; }; + 50E205342FAABC6300402380 /* DeleteCertificateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteCertificateView.swift; sourceTree = ""; }; + 50E205352FAABC6300402380 /* EditCertificateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCertificateView.swift; sourceTree = ""; }; + 50E2057F2FAB291E00402380 /* CertificateMigrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateMigrator.swift; sourceTree = ""; }; 50E4C4522E73C78900C73783 /* WindowBackgroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowBackgroundStyle.swift; sourceTree = ""; }; 50E4C4C22E7765DF00C73783 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 50E4C4C72E777E4200C73783 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; @@ -252,8 +294,10 @@ 50E0145C2EDB9CDF00B121F1 /* Common in Frameworks */, 5003EF3B278005E800DF2006 /* SecretKit in Frameworks */, 501421622781262300BBAA70 /* Brief in Frameworks */, + 50E205842FAB296A00402380 /* SharedXPCServices in Frameworks */, 5003EF5F2780081600DF2006 /* SecureEnclaveSecretKit in Frameworks */, 5003EF612780081600DF2006 /* SmartCardSecretKit in Frameworks */, + 505F5EF22FA9635700C45824 /* CertificateKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -282,12 +326,22 @@ 5003EF3D278005F300DF2006 /* Brief in Frameworks */, 5003EF632780081B00DF2006 /* SecureEnclaveSecretKit in Frameworks */, 5003EF652780081B00DF2006 /* SmartCardSecretKit in Frameworks */, + 50E205822FAB293B00402380 /* SharedXPCServices in Frameworks */, 5003EF3F278005F300DF2006 /* SecretAgentKit in Frameworks */, 5003EF41278005FA00DF2006 /* SecretKit in Frameworks */, 50E0145E2EDB9CE400B121F1 /* Common in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; + 50E205112FAAB81C00402380 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 50E205332FAAB95A00402380 /* SSHProtocolKit in Frameworks */, + 50E205312FAAB95500402380 /* XPCWrappers in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -309,10 +363,14 @@ 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */, 50B8550C24138C4F009958AC /* DeleteSecretView.swift */, 2C4A9D2E2636FFD3008CC8E2 /* EditSecretView.swift */, + 50E205342FAABC6300402380 /* DeleteCertificateView.swift */, + 50E205352FAABC6300402380 /* EditCertificateView.swift */, 50BB046A2418AAAE00D6E079 /* EmptyStoreView.swift */, 506772C82425BB8500034DED /* NoStoresView.swift */, 50C385A42407A76D00AF2719 /* SecretDetailView.swift */, + 50E204E82FA9D12700402380 /* CertificateDetailView.swift */, 50153E21250DECA300525160 /* SecretListItemView.swift */, + 50E204EC2FAA997F00402380 /* CertificateListItemView.swift */, 5079BA0E250F29BF00EA86F4 /* StoreListView.swift */, ); path = Secrets; @@ -338,6 +396,7 @@ 50BDCB712E63BAF20072D2E7 /* AgentStatusView.swift */, 50617D8423FCE48E0099B055 /* ContentView.swift */, 5066A6C72516FE6E004B5A36 /* CopyableView.swift */, + 50E204EE2FAA9C1400402380 /* MultilineInfoView.swift */, 50153E1F250AFCB200525160 /* UpdateView.swift */, ); path = Views; @@ -352,6 +411,7 @@ 508A58AF241E144C0069DC07 /* Config */, 50692D272E6FDB8D0043C7BB /* SecretiveUpdater */, 50692E662E6FF9E20043C7BB /* SecretAgentInputParser */, + 50E205262FAAB82700402380 /* SecretiveCertificateParser */, 50617D8023FCE48E0099B055 /* Products */, 5099A08B240243730062B6F2 /* Frameworks */, ); @@ -364,6 +424,7 @@ 50A3B78A24026B7500D209EA /* SecretAgent.app */, 50692D122E6FDB880043C7BB /* SecretiveUpdater.xpc */, 50692E502E6FF9D20043C7BB /* SecretAgentInputParser.xpc */, + 50E205142FAAB81C00402380 /* SecretiveCertificateParser.xpc */, ); name = Products; sourceTree = ""; @@ -462,6 +523,7 @@ 50020BAF24064869003D4025 /* AppDelegate.swift */, 5018F54E24064786002EB505 /* Notifier.swift */, 501578122E6C0479004A37D0 /* XPCInputParser.swift */, + 50E2057F2FAB291E00402380 /* CertificateMigrator.swift */, 50A3B79524026B7600D209EA /* Main.storyboard */, 50A3B79824026B7600D209EA /* Info.plist */, 508BF29425B4F140009EFB7E /* InternetAccessPolicy.plist */, @@ -479,6 +541,17 @@ path = "Preview Content"; sourceTree = ""; }; + 50E205262FAAB82700402380 /* SecretiveCertificateParser */ = { + isa = PBXGroup; + children = ( + 50E205232FAAB82700402380 /* Info.plist */, + 50E205242FAAB82700402380 /* main.swift */, + 50E2052A2FAAB85000402380 /* SecretiveCertificateParser.entitlements */, + 50E2052B2FAAB85000402380 /* SecretiveCertificateParser.swift */, + ); + path = SecretiveCertificateParser; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -499,6 +572,7 @@ 50142167278126B500BBAA70 /* PBXTargetDependency */, 50692D1C2E6FDB880043C7BB /* PBXTargetDependency */, 50692E5A2E6FF9D20043C7BB /* PBXTargetDependency */, + 50E2051C2FAAB81C00402380 /* PBXTargetDependency */, ); name = Secretive; packageProductDependencies = ( @@ -507,6 +581,8 @@ 5003EF602780081600DF2006 /* SmartCardSecretKit */, 501421612781262300BBAA70 /* Brief */, 50E0145B2EDB9CDF00B121F1 /* Common */, + 505F5EF12FA9635700C45824 /* CertificateKit */, + 50E205832FAB296A00402380 /* SharedXPCServices */, ); productName = Secretive; productReference = 50617D7F23FCE48E0099B055 /* Secretive.app */; @@ -570,6 +646,7 @@ 501577D42E6BC5DD004A37D0 /* PBXTargetDependency */, 50692E6F2E6FFA5F0043C7BB /* PBXTargetDependency */, 50692E722E6FFA6E0043C7BB /* PBXTargetDependency */, + 50E2052F2FAAB92000402380 /* PBXTargetDependency */, ); name = SecretAgent; packageProductDependencies = ( @@ -579,11 +656,33 @@ 5003EF622780081B00DF2006 /* SecureEnclaveSecretKit */, 5003EF642780081B00DF2006 /* SmartCardSecretKit */, 50E0145D2EDB9CE400B121F1 /* Common */, + 50E205812FAB293B00402380 /* SharedXPCServices */, ); productName = SecretAgent; productReference = 50A3B78A24026B7500D209EA /* SecretAgent.app */; productType = "com.apple.product-type.application"; }; + 50E205132FAAB81C00402380 /* SecretiveCertificateParser */ = { + isa = PBXNativeTarget; + buildConfigurationList = 50E2051F2FAAB81C00402380 /* Build configuration list for PBXNativeTarget "SecretiveCertificateParser" */; + buildPhases = ( + 50E205102FAAB81C00402380 /* Sources */, + 50E205112FAAB81C00402380 /* Frameworks */, + 50E205122FAAB81C00402380 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SecretiveCertificateParser; + packageProductDependencies = ( + 50E205302FAAB95500402380 /* XPCWrappers */, + 50E205322FAAB95A00402380 /* SSHProtocolKit */, + ); + productName = SecretAgentCertificateParser; + productReference = 50E205142FAAB81C00402380 /* SecretiveCertificateParser.xpc */; + productType = "com.apple.product-type.xpc-service"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -591,7 +690,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 2600; + LastSwiftUpdateCheck = 2650; LastUpgradeCheck = 2640; ORGANIZATIONNAME = "Max Goedjen"; TargetAttributes = { @@ -607,6 +706,9 @@ 50A3B78924026B7500D209EA = { CreatedOnToolsVersion = 11.4; }; + 50E205132FAAB81C00402380 = { + CreatedOnToolsVersion = 26.5; + }; }; }; buildConfigurationList = 50617D7A23FCE48D0099B055 /* Build configuration list for PBXProject "Secretive" */; @@ -634,6 +736,7 @@ 50A3B78924026B7500D209EA /* SecretAgent */, 50692D112E6FDB880043C7BB /* SecretiveUpdater */, 50692E4F2E6FF9D20043C7BB /* SecretAgentInputParser */, + 50E205132FAAB81C00402380 /* SecretiveCertificateParser */, ); }; /* End PBXProject section */ @@ -677,6 +780,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 50E205122FAAB81C00402380 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -689,13 +799,16 @@ 50E4C4C32E7765DF00C73783 /* AboutView.swift in Sources */, 2C4A9D2F2636FFD3008CC8E2 /* EditSecretView.swift in Sources */, 50E4C4532E73C78C00C73783 /* WindowBackgroundStyle.swift in Sources */, + 50E204E92FA9D12700402380 /* CertificateDetailView.swift in Sources */, 5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */, 504789232E697DD300B4556F /* BoxBackgroundStyle.swift in Sources */, + 50E204EF2FAA9C1400402380 /* MultilineInfoView.swift in Sources */, 5066A6C22516F303004B5A36 /* SetupView.swift in Sources */, 5065E313295517C500E16645 /* ToolbarButtonStyle.swift in Sources */, 50617D8523FCE48E0099B055 /* ContentView.swift in Sources */, 504788F62E68206F00B4556F /* GettingStartedView.swift in Sources */, 50CF4ABC2E601B0F005588DC /* ActionButtonStyle.swift in Sources */, + 50E204ED2FAA997F00402380 /* CertificateListItemView.swift in Sources */, 50571E0324393C2600F76F6C /* JustUpdatedChecker.swift in Sources */, 5079BA0F250F29BF00EA86F4 /* StoreListView.swift in Sources */, 50617DD223FCEFA90099B055 /* PreviewStore.swift in Sources */, @@ -708,12 +821,14 @@ 5066A6C82516FE6E004B5A36 /* CopyableView.swift in Sources */, 50B8550D24138C4F009958AC /* DeleteSecretView.swift in Sources */, 50BB046B2418AAAE00D6E079 /* EmptyStoreView.swift in Sources */, + 50E205372FAABC6300402380 /* DeleteCertificateView.swift in Sources */, 50BDCB762E6450950072D2E7 /* ConfigurationItemView.swift in Sources */, 50617D8323FCE48E0099B055 /* App.swift in Sources */, 504788F42E681F6900B4556F /* ToolConfigurationView.swift in Sources */, 506772C92425BB8500034DED /* NoStoresView.swift in Sources */, 50153E22250DECA300525160 /* SecretListItemView.swift in Sources */, 508A58B5241ED48F0069DC07 /* PreviewAgentStatusChecker.swift in Sources */, + 50E205362FAABC6300402380 /* EditCertificateView.swift in Sources */, 508A58AA241E06B40069DC07 /* PreviewUpdater.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -740,12 +855,22 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 50E205802FAB291E00402380 /* CertificateMigrator.swift in Sources */, 50020BB024064869003D4025 /* AppDelegate.swift in Sources */, 5018F54F24064786002EB505 /* Notifier.swift in Sources */, 501578132E6C0479004A37D0 /* XPCInputParser.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + 50E205102FAAB81C00402380 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 50E205282FAAB82700402380 /* main.swift in Sources */, + 50E2052C2FAAB85000402380 /* SecretiveCertificateParser.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -778,6 +903,16 @@ target = 50692E4F2E6FF9D20043C7BB /* SecretAgentInputParser */; targetProxy = 50692E712E6FFA6E0043C7BB /* PBXContainerItemProxy */; }; + 50E2051C2FAAB81C00402380 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 50E205132FAAB81C00402380 /* SecretiveCertificateParser */; + targetProxy = 50E2051B2FAAB81C00402380 /* PBXContainerItemProxy */; + }; + 50E2052F2FAAB92000402380 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 50E205132FAAB81C00402380 /* SecretiveCertificateParser */; + targetProxy = 50E2052E2FAAB92000402380 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -1479,6 +1614,101 @@ }; name = Release; }; + 50E205202FAAB81C00402380 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = SecretiveCertificateParser/SecretiveCertificateParser.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(SECRETIVE_DEVELOPMENT_TEAM)"; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SecretiveCertificateParser/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = SecretiveCertificateParser; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Max Goedjen. All rights reserved."; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(SECRETIVE_BASE_BUNDLE_ID).SecretiveCertificateParser"; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 50E205212FAAB81C00402380 /* Test */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = SecretiveCertificateParser/SecretiveCertificateParser.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SecretiveCertificateParser/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = SecretiveCertificateParser; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Max Goedjen. All rights reserved."; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(SECRETIVE_BASE_BUNDLE_ID).SecretiveCertificateParser"; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Test; + }; + 50E205222FAAB81C00402380 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = SecretiveCertificateParser/SecretiveCertificateParser.entitlements; + CODE_SIGN_IDENTITY = "Developer ID Application"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(SECRETIVE_DEVELOPMENT_TEAM)"; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SecretiveCertificateParser/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = SecretiveCertificateParser; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Max Goedjen. All rights reserved."; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(SECRETIVE_BASE_BUNDLE_ID).SecretiveCertificateParser"; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -1532,6 +1762,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 50E2051F2FAAB81C00402380 /* Build configuration list for PBXNativeTarget "SecretiveCertificateParser" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 50E205202FAAB81C00402380 /* Debug */, + 50E205212FAAB81C00402380 /* Test */, + 50E205222FAAB81C00402380 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1575,6 +1815,10 @@ isa = XCSwiftPackageProductDependency; productName = Brief; }; + 505F5EF12FA9635700C45824 /* CertificateKit */ = { + isa = XCSwiftPackageProductDependency; + productName = CertificateKit; + }; 50692D2C2E6FDC000043C7BB /* XPCWrappers */ = { isa = XCSwiftPackageProductDependency; productName = XPCWrappers; @@ -1595,6 +1839,22 @@ isa = XCSwiftPackageProductDependency; productName = Common; }; + 50E205302FAAB95500402380 /* XPCWrappers */ = { + isa = XCSwiftPackageProductDependency; + productName = XPCWrappers; + }; + 50E205322FAAB95A00402380 /* SSHProtocolKit */ = { + isa = XCSwiftPackageProductDependency; + productName = SSHProtocolKit; + }; + 50E205812FAB293B00402380 /* SharedXPCServices */ = { + isa = XCSwiftPackageProductDependency; + productName = SharedXPCServices; + }; + 50E205832FAB296A00402380 /* SharedXPCServices */ = { + isa = XCSwiftPackageProductDependency; + productName = SharedXPCServices; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 50617D7723FCE48D0099B055 /* Project object */; diff --git a/Sources/Secretive.xcodeproj/xcshareddata/xcschemes/Secretive.xcscheme b/Sources/Secretive.xcodeproj/xcshareddata/xcschemes/Secretive.xcscheme index 6f89739..b7eccb7 100644 --- a/Sources/Secretive.xcodeproj/xcshareddata/xcschemes/Secretive.xcscheme +++ b/Sources/Secretive.xcodeproj/xcshareddata/xcschemes/Secretive.xcscheme @@ -23,7 +23,7 @@ diff --git a/Sources/Secretive/App.swift b/Sources/Secretive/App.swift index ea2f156..fa405d9 100644 --- a/Sources/Secretive/App.swift +++ b/Sources/Secretive/App.swift @@ -3,6 +3,7 @@ import SecretKit import SecureEnclaveSecretKit import SmartCardSecretKit import Brief +import CertificateKit @main struct Secretive: App { @@ -14,6 +15,7 @@ struct Secretive: App { WindowGroup { ContentView() .environment(EnvironmentValues._secretStoreList) + .environment(EnvironmentValues._certificateStore) .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in Task { @AppStorage("defaultsHasRunSetup") var hasRunSetup = false @@ -92,15 +94,18 @@ extension EnvironmentValues { @MainActor fileprivate static let _secretStoreList: SecretStoreList = { let list = SecretStoreList() let cryptoKit = SecureEnclave.Store() - let migrator = SecureEnclave.CryptoKitMigrator() - try? migrator.migrate(to: cryptoKit) + let cryptoKitMigrator = SecureEnclave.CryptoKitMigrator() + try? cryptoKitMigrator.migrate(to: cryptoKit) list.add(store: cryptoKit) list.add(store: SmartCard.Store()) return list }() + @MainActor fileprivate static let _certificateStore: CertificateStore = CertificateStore() + private static let _agentLaunchController = AgentLaunchController() @Entry var agentLaunchController: any AgentLaunchControllerProtocol = _agentLaunchController + private static let _updater: any UpdaterProtocol = { @AppStorage("defaultsHasRunSetup") var hasRunSetup = false return Updater(checkOnLaunch: hasRunSetup) @@ -113,6 +118,10 @@ extension EnvironmentValues { @MainActor var secretStoreList: SecretStoreList { EnvironmentValues._secretStoreList } + + @MainActor var certificateStore: CertificateStore { + EnvironmentValues._certificateStore + } } extension FocusedValues { diff --git a/Sources/Secretive/Views/Secrets/CertificateDetailView.swift b/Sources/Secretive/Views/Secrets/CertificateDetailView.swift new file mode 100644 index 0000000..92be0df --- /dev/null +++ b/Sources/Secretive/Views/Secrets/CertificateDetailView.swift @@ -0,0 +1,68 @@ +import SwiftUI +import SecretKit +import Common +import SSHProtocolKit + +struct CertificateDetailView: View { + + let certificate: OpenSSHCertificate + + var body: some View { + ScrollView { + Form { + Section { + CopyableView( + title: .certificateDetailKeyIdLabel, + image: Image(systemName: "person.text.rectangle"), + text: certificate.keyID + ) + Spacer() + .frame(height: 20) + CopyableView( + title: .certificateDetailSerialLabel, + image: Image(systemName: "number.circle"), + text: certificate.serial.formatted() + ) + Spacer() + .frame(height: 20) + if let validityRange = certificate.validityRange { + let epoch = Date(timeIntervalSince1970: 0) + let end = Date(timeIntervalSince1970: TimeInterval(UInt64.max)) + switch (validityRange.lowerBound, validityRange.upperBound) { + case (epoch, end): + EmptyView() + case (epoch, let otherEnd): + MultilineInfoView(title: .certificateDetailValidUntilLabel, image: Image(systemName: "calendar.badge.clock"), items: [otherEnd.formatted()]) + Spacer() + .frame(height: 20) + case (let otherStart, end): + MultilineInfoView(title: .certificateDetailValidAfterLabel, image: Image(systemName: "calendar.badge.clock"), items: [otherStart.formatted()]) + Spacer() + .frame(height: 20) + default: + MultilineInfoView(title: .certificateDetailValidityRangeLabel, image: Image(systemName: "calendar.badge.clock"), items: [validityRange.formatted()]) + Spacer() + .frame(height: 20) + } + } + if !certificate.principals.isEmpty { + MultilineInfoView(title: .certificateDetailPrincipalsLabel, image: Image(systemName: "person.2"), items: certificate.principals) + Spacer() + .frame(height: 20) + } + CopyableView( + title: .certificateDetailPathLabel, + image: Image(systemName: "checkmark.seal.text.page"), + text: URL.certificatePath(for: certificate, in: URL.certificatesDirectory), + showRevealInFinder: true + ) + Spacer() + } + } + .padding() + } + .frame(minHeight: 200, maxHeight: .infinity) + } + + +} diff --git a/Sources/Secretive/Views/Secrets/CertificateListItemView.swift b/Sources/Secretive/Views/Secrets/CertificateListItemView.swift new file mode 100644 index 0000000..32da8cd --- /dev/null +++ b/Sources/Secretive/Views/Secrets/CertificateListItemView.swift @@ -0,0 +1,42 @@ +import SwiftUI +import CertificateKit +import SSHProtocolKit + +struct CertificateListItemView: View { + + @Environment(\.certificateStore) private var store + + var certificate: OpenSSHCertificate + + @State var isDeleting: Bool = false + @State var isRenaming: Bool = false + + var deletedCertificate: (OpenSSHCertificate) -> Void + var renamedCertificate: (OpenSSHCertificate) -> Void + + var body: some View { + NavigationLink(value: certificate) { + Text(certificate.name) + } + .sheet(isPresented: $isRenaming, onDismiss: { + renamedCertificate(certificate) + }, content: { + EditCertificateView(store: store, certificate: certificate) + }) + .showingDeleteConfirmation(isPresented: $isDeleting, certificate, store) { deleted in + if deleted { + deletedCertificate(certificate) + } + } + .contextMenu { + Button(action: { isRenaming = true }) { + Image(systemName: "pencil") + Text(.secretListEditButton) + } + Button(action: { isDeleting = true }) { + Image(systemName: "trash") + Text(.secretListDeleteButton) + } + } + } +} diff --git a/Sources/Secretive/Views/Secrets/DeleteCertificateView.swift b/Sources/Secretive/Views/Secrets/DeleteCertificateView.swift new file mode 100644 index 0000000..6d2a589 --- /dev/null +++ b/Sources/Secretive/Views/Secrets/DeleteCertificateView.swift @@ -0,0 +1,52 @@ +import SwiftUI +import CertificateKit +import SSHProtocolKit + +extension View { + + func showingDeleteConfirmation(isPresented: Binding, _ certificate: OpenSSHCertificate, _ store: CertificateStore, dismissalBlock: @escaping (Bool) -> ()) -> some View { + modifier(DeleteCertificateConfirmationModifier(isPresented: isPresented, certificate: certificate, store: store, dismissalBlock: dismissalBlock)) + } + +} + +struct DeleteCertificateConfirmationModifier: ViewModifier { + + var isPresented: Binding + var certificate: OpenSSHCertificate + var store: CertificateStore + var dismissalBlock: (Bool) -> () + @State var confirmedSecretName = "" + @State private var errorText: String? + + func body(content: Content) -> some View { + content + .confirmationDialog( + String(localized: .deleteConfirmationTitle(name: certificate.name)), + isPresented: isPresented, + titleVisibility: .visible, + actions: { + Button(.deleteConfirmationDeleteButton, action: delete) + Button(.deleteConfirmationCancelButton, role: .cancel) { + dismissalBlock(false) + } + }, + ) + .dialogIcon(Image(systemName: "lock.trianglebadge.exclamationmark.fill")) + .onExitCommand { + dismissalBlock(false) + } + } + + func delete() { + Task { + do { + try store.delete(certificate: certificate) + dismissalBlock(true) + } catch { + errorText = error.localizedDescription + } + } + } + +} diff --git a/Sources/Secretive/Views/Secrets/DeleteSecretView.swift b/Sources/Secretive/Views/Secrets/DeleteSecretView.swift index 17f6610..425a668 100644 --- a/Sources/Secretive/Views/Secrets/DeleteSecretView.swift +++ b/Sources/Secretive/Views/Secrets/DeleteSecretView.swift @@ -21,7 +21,7 @@ struct DeleteSecretConfirmationModifier: ViewModifier { func body(content: Content) -> some View { content .confirmationDialog( - .deleteConfirmationTitle(secretName: secret.name), + .deleteConfirmationTitle(name: secret.name), isPresented: isPresented, titleVisibility: .visible, actions: { diff --git a/Sources/Secretive/Views/Secrets/EditCertificateView.swift b/Sources/Secretive/Views/Secrets/EditCertificateView.swift new file mode 100644 index 0000000..4568eed --- /dev/null +++ b/Sources/Secretive/Views/Secrets/EditCertificateView.swift @@ -0,0 +1,60 @@ +import SwiftUI +import SSHProtocolKit +import CertificateKit + +struct EditCertificateView: View { + + let store: CertificateStore + let certificate: OpenSSHCertificate + + @State private var name: String + @State var errorText: String? + + @Environment(\.dismiss) var dismiss + + init(store: CertificateStore, certificate: OpenSSHCertificate) { + self.store = store + self.certificate = certificate + name = certificate.name + } + + var body: some View { + VStack(alignment: .trailing) { + Form { + Section { + TextField(String(localized: .renameCertificateLabel), text: $name, prompt: Text(.renameCertificateNamePlaceholder)) + } footer: { + if let errorText { + Text(verbatim: errorText) + .errorStyle() + } + } + } + HStack { + Button(.editCancelButton) { + dismiss() + } + .keyboardShortcut(.cancelAction) + Button(.editSaveButton, action: rename) + .disabled(name.isEmpty) + .keyboardShortcut(.return) + .primaryButton() + } + .padding() + } + .formStyle(.grouped) + } + + func rename() { + Task { + do { + var updated = certificate + updated.name = name + try store.update(certificate: updated) + dismiss() + } catch { + errorText = error.localizedDescription + } + } + } +} diff --git a/Sources/Secretive/Views/Secrets/SecretDetailView.swift b/Sources/Secretive/Views/Secrets/SecretDetailView.swift index da9cf75..4fd30de 100644 --- a/Sources/Secretive/Views/Secrets/SecretDetailView.swift +++ b/Sources/Secretive/Views/Secrets/SecretDetailView.swift @@ -6,6 +6,8 @@ import SSHProtocolKit struct SecretDetailView: View { let secret: SecretType + let certificates: [OpenSSHCertificate] + let navigateToCertificate: ((OpenSSHCertificate) -> Void)? private let keyWriter = OpenSSHPublicKeyWriter() @@ -13,16 +15,42 @@ struct SecretDetailView: View { ScrollView { Form { Section { - CopyableView(title: .secretDetailSha256FingerprintLabel, image: Image(systemName: "touchid"), text: keyWriter.openSSHSHA256Fingerprint(secret: secret)) + CopyableView( + title: .secretDetailSha256FingerprintLabel, + image: Image(systemName: "touchid"), + text: keyWriter.openSSHSHA256Fingerprint(secret: secret) + ) Spacer() .frame(height: 20) - CopyableView(title: .secretDetailMd5FingerprintLabel, image: Image(systemName: "touchid"), text: keyWriter.openSSHMD5Fingerprint(secret: secret)) + CopyableView( + title: .secretDetailMd5FingerprintLabel, + image: Image(systemName: "touchid"), + text: keyWriter.openSSHMD5Fingerprint(secret: secret) + ) Spacer() .frame(height: 20) - CopyableView(title: .secretDetailPublicKeyLabel, image: Image(systemName: "key"), text: keyString) - Spacer() - .frame(height: 20) - CopyableView(title: .secretDetailPublicKeyPathLabel, image: Image(systemName: "lock.doc"), text: URL.publicKeyPath(for: secret, in: URL.publicKeyDirectory), showRevealInFinder: true) + CopyableView( + title: .secretDetailPublicKeyPathLabel, + image: Image(systemName: "lock.doc"), + text: URL.publicKeyPath(for: secret, in: URL.publicKeyDirectory), + showRevealInFinder: true + ) + if !certificates.isEmpty { + Spacer() + .frame(height: 20) + MultilineInfoView( + title: .secretDetailCertificatePathLabel, + image: Image( + systemName: "checkmark.seal.text.page" + ), + items: certificates.map({ certificate in + MultilineInfoView.Item( + text: certificate.name, + action: (Image(systemName: "chevron.forward"), { navigateToCertificate?(certificate) }) + ) + }) + ) + } Spacer() } } @@ -32,10 +60,6 @@ struct SecretDetailView: View { } - var keyString: String { - keyWriter.openSSHString(secret: secret) - } - } //#Preview { diff --git a/Sources/Secretive/Views/Secrets/StoreListView.swift b/Sources/Secretive/Views/Secrets/StoreListView.swift index a140b60..12e2419 100644 --- a/Sources/Secretive/Views/Secrets/StoreListView.swift +++ b/Sources/Secretive/Views/Secrets/StoreListView.swift @@ -1,25 +1,32 @@ import SwiftUI import SecretKit +import SSHProtocolKit struct StoreListView: View { - @Binding var activeSecret: AnySecret? + enum StoreListSelection: Hashable { + case secret(AnySecret) + case certificate(OpenSSHCertificate) + } + + @Binding var selection: StoreListSelection? @Environment(\.secretStoreList) private var storeList + @Environment(\.certificateStore) private var certificateStore private func secretDeleted(secret: AnySecret) { - activeSecret = nextDefaultSecret + selection = nextDefaultSecret.map(StoreListSelection.secret) } private func secretRenamed(secret: AnySecret) { // Pull new version from store, so we get all updated attributes - activeSecret = nil - activeSecret = storeList.allSecrets.first(where: { $0.id == secret.id }) + selection = nil + selection = storeList.allSecrets.first(where: { $0.id == secret.id }).map(StoreListSelection.secret) } var body: some View { NavigationSplitView { - List(selection: $activeSecret) { + List(selection: $selection) { ForEach(storeList.stores) { store in if store.isAvailable { Section(header: Text(store.name)) { @@ -30,29 +37,51 @@ struct StoreListView: View { deletedSecret: secretDeleted, renamedSecret: secretRenamed, ) + .tag(StoreListSelection.secret(secret)) } } } } + if !certificateStore.certificates.isEmpty { + Section("Certificates") { + ForEach(certificateStore.certificates) { certificate in + CertificateListItemView( + certificate: certificate, + deletedCertificate: { _ in }, + renamedCertificate: { _ in } + ) + .tag(StoreListSelection.certificate(certificate)) + } + } + } } } detail: { - if let activeSecret { - SecretDetailView(secret: activeSecret) - } else if let nextDefaultSecret { - // This just means onAppear hasn't executed yet. - // Do this to avoid a blip. - SecretDetailView(secret: nextDefaultSecret) - } else { - if let modifiable = storeList.modifiableStore, modifiable.isAvailable { - EmptyStoreView(store: modifiable) + switch selection { + case .secret(let secret): + SecretDetailView(secret: secret, certificates: certificateStore.certificates(for: secret)) { + selection = .certificate($0) + } + case .certificate(let certificate): + CertificateDetailView(certificate: certificate) + case nil: + if let nextDefaultSecret { + // This just means onAppear hasn't executed yet. + // Do this to avoid a blip. + SecretDetailView(secret: nextDefaultSecret, certificates: certificateStore.certificates(for: nextDefaultSecret)) { + selection = .certificate($0) + } } else { - EmptyStoreView(store: storeList.stores.first(where: \.isAvailable)) + if let modifiable = storeList.modifiableStore, modifiable.isAvailable { + EmptyStoreView(store: modifiable) + } else { + EmptyStoreView(store: storeList.stores.first(where: \.isAvailable)) + } } } } .navigationSplitViewStyle(.balanced) .onAppear { - activeSecret = nextDefaultSecret + selection = nextDefaultSecret.map(StoreListSelection.secret) } .frame(minWidth: 100, idealWidth: 240) diff --git a/Sources/Secretive/Views/Views/ContentView.swift b/Sources/Secretive/Views/Views/ContentView.swift index c44f0da..2d58ae9 100644 --- a/Sources/Secretive/Views/Views/ContentView.swift +++ b/Sources/Secretive/Views/Views/ContentView.swift @@ -3,16 +3,19 @@ import SecretKit import SecureEnclaveSecretKit import SmartCardSecretKit import Brief +import SSHProtocolKit +import SharedXPCServices struct ContentView: View { - @State var activeSecret: AnySecret? + @State var selection: StoreListView.StoreListSelection? @State private var selectedUpdate: Release? @Environment(\.colorScheme) private var colorScheme @Environment(\.openWindow) private var openWindow @Environment(\.secretStoreList) private var storeList + @Environment(\.certificateStore) private var certificateStore @Environment(\.updater) private var updater @Environment(\.agentLaunchController) private var agentLaunchController @@ -25,7 +28,7 @@ struct ContentView: View { var body: some View { VStack { if storeList.anyAvailable { - StoreListView(activeSecret: $activeSecret) + StoreListView(selection: $selection) } else { NoStoresView() } @@ -42,6 +45,21 @@ struct ContentView: View { runningSetup = true } } + .dropDestination(for: URL.self) { items, location in + guard let url = items.first, url.pathExtension == "pub" else { return false } + Task { + do { + let data = try Data(contentsOf: url) + let parser = try await XPCCertificateParser() + let cert = try await parser.parse(data: data) + try certificateStore.save(certificate: cert, originalData: data) + selection = .certificate(cert) + } catch { + + } + } + return true + } isTargeted: { _ in } .focusedSceneValue(\.showCreateSecret, .init(isEnabled: !runningSetup) { showingCreation = true }) @@ -49,7 +67,7 @@ struct ContentView: View { if let modifiable = storeList.modifiableStore { CreateSecretView(store: modifiable) { created in if let created { - activeSecret = created + selection = .secret(created) } } } diff --git a/Sources/Secretive/Views/Views/CopyableView.swift b/Sources/Secretive/Views/Views/CopyableView.swift index e56ab20..8765972 100644 --- a/Sources/Secretive/Views/Views/CopyableView.swift +++ b/Sources/Secretive/Views/Views/CopyableView.swift @@ -4,12 +4,13 @@ import UniformTypeIdentifiers struct CopyableView: View { var title: LocalizedStringResource + var subtitle: String? var image: Image var text: String var showRevealInFinder = false @State private var interactionState: InteractionState = .normal - + var content: some View { VStack(alignment: .leading, spacing: 15) { HStack { @@ -17,9 +18,16 @@ struct CopyableView: View { .renderingMode(.template) .imageScale(.large) .foregroundColor(primaryTextColor) - Text(title) - .font(.headline) - .foregroundColor(primaryTextColor) + VStack(alignment: .leading) { + Text(title) + .font(.headline) + .foregroundColor(primaryTextColor) + if let subtitle { + Text(subtitle) + .font(.system(.subheadline, design: .monospaced)) + .foregroundColor(secondaryTextColor) + } + } Spacer() if interactionState != .normal { HStack { diff --git a/Sources/Secretive/Views/Views/MultilineInfoView.swift b/Sources/Secretive/Views/Views/MultilineInfoView.swift new file mode 100644 index 0000000..396f29e --- /dev/null +++ b/Sources/Secretive/Views/Views/MultilineInfoView.swift @@ -0,0 +1,167 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct MultilineInfoView: View { + + struct Item { + let text: String + let action: (Image, () -> Void)? + } + + var title: LocalizedStringResource + var image: Image + var items: [Item] + + init(title: LocalizedStringResource, image: Image, items: [Item]) { + self.title = title + self.image = image + self.items = items + } + + init(title: LocalizedStringResource, image: Image, items: [String]) { + self.title = title + self.image = image + self.items = items.map({ Item(text: $0, action: nil) }) + } + + @State private var interactionState: InteractionState = .normal + @State private var interactionStateIndex: Int? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + image + .renderingMode(.template) + .imageScale(.large) + .foregroundColor(primaryTextColor) + Text(title) + .font(.headline) + .foregroundColor(primaryTextColor) + Spacer() + } + .safeAreaPadding(20) + ForEach(Array(items.enumerated()), id: \.offset) { item in + Divider() + .ignoresSafeArea() + .opacity(item.offset == 0 ? 1 : 0.75) + HStack { + Text(item.element.text) + Spacer() + if let (image, _) = item.element.action { + image + .foregroundStyle(.secondary) + } + } + .safeAreaPadding(20) + ._background(interactionState: interactionStateIndex == item.offset ? interactionState : .normal, cornerRadius: 0) + .onHover { hovering in + withAnimation { + guard item.element.action != nil else { return } + interactionState = hovering ? .hovering : .normal + interactionStateIndex = item.offset + } + } + .gesture( + TapGesture() + .onEnded { + item.element.action?.1() + withAnimation { + interactionState = .normal + interactionStateIndex = nil + } + } + ) + + } + } + ._background(interactionState: .normal) + .frame(minWidth: 150, maxWidth: .infinity) + } + + var primaryTextColor: Color { + switch interactionState { + case .normal, .hovering: + return Color(.textColor) + } + } + + var secondaryTextColor: Color { + switch interactionState { + case .normal, .hovering: + return Color(.secondaryLabelColor) + } + } + +} + +fileprivate enum InteractionState { + case normal, hovering +} + +extension View { + + fileprivate func _background(interactionState: InteractionState, cornerRadius: Double = 15) -> some View { + modifier(BackgroundViewModifier(interactionState: interactionState, cornerRadius: cornerRadius)) + } + +} + +fileprivate struct BackgroundViewModifier: ViewModifier { + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.appearsActive) private var appearsActive + + let interactionState: InteractionState + let cornerRadius: Double + + func body(content: Content) -> some View { + if #available(macOS 26.0, *) { + content + // Very thin opacity lets user hover anywhere over the view, glassEffect doesn't allow. + .background(.white.opacity(0.01), in: RoundedRectangle(cornerRadius: 15)) + .glassEffect(.regular.tint(backgroundColor(interactionState: interactionState)), in: RoundedRectangle(cornerRadius: cornerRadius)) + .mask(RoundedRectangle(cornerRadius: cornerRadius)) + .shadow(color: .black.opacity(0.1), radius: 5) + } else { + content + .background(backgroundColor(interactionState: interactionState)) + .cornerRadius(10) + } + } + + func backgroundColor(interactionState: InteractionState) -> Color { + guard appearsActive else { return Color.clear } + if #available(macOS 26.0, *) { + let base = colorScheme == .dark ? Color(white: 0.2) : Color(white: 1) + switch interactionState { + case .normal: + return base + case .hovering: + return base.mix(with: .accentColor, by: colorScheme == .dark ? 0.2 : 0.1) + } + } else { + switch interactionState { + case .normal: + return colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.885) + case .hovering: + return colorScheme == .dark ? Color(white: 0.275) : Color(white: 0.82) + } + } + } + + +} + +#Preview { + MultilineInfoView(title: "Multiple", image: Image(systemName: "figure.wave"), items: [ + MultilineInfoView.Item(text: "hello", action: (Image(systemName: "chevron.forward"), {})), + MultilineInfoView.Item(text: "World", action: (Image(systemName: "chevron.forward"), {})), + ]) + .padding() +} + + +#Preview { + MultilineInfoView(title: "One", image: Image(systemName: "figure.wave"), items: ["Hello world."]) + .padding() +} diff --git a/Sources/SecretiveCertificateParser/Info.plist b/Sources/SecretiveCertificateParser/Info.plist new file mode 100644 index 0000000..c123a5d --- /dev/null +++ b/Sources/SecretiveCertificateParser/Info.plist @@ -0,0 +1,11 @@ + + + + + XPCService + + ServiceType + Application + + + diff --git a/Sources/SecretiveCertificateParser/SecretiveCertificateParser.entitlements b/Sources/SecretiveCertificateParser/SecretiveCertificateParser.entitlements new file mode 100644 index 0000000..08818a6 --- /dev/null +++ b/Sources/SecretiveCertificateParser/SecretiveCertificateParser.entitlements @@ -0,0 +1,22 @@ + + + + + com.apple.security.hardened-process + + com.apple.security.hardened-process.checked-allocations + + com.apple.security.hardened-process.checked-allocations.enable-pure-data + + com.apple.security.hardened-process.checked-allocations.no-tagged-receive + + com.apple.security.hardened-process.dyld-ro + + com.apple.security.hardened-process.enhanced-security-version + 1 + com.apple.security.hardened-process.hardened-heap + + com.apple.security.hardened-process.platform-restrictions + 2 + + diff --git a/Sources/SecretiveCertificateParser/SecretiveCertificateParser.swift b/Sources/SecretiveCertificateParser/SecretiveCertificateParser.swift new file mode 100644 index 0000000..8c7aab7 --- /dev/null +++ b/Sources/SecretiveCertificateParser/SecretiveCertificateParser.swift @@ -0,0 +1,17 @@ +import Foundation +import OSLog +import XPCWrappers +import SSHProtocolKit + +final class SecretiveCertificateParser: NSObject, XPCProtocol { + + private let logger = Logger(subsystem: "com.maxgoedjen.secretive.SecretiveCertificateParser", category: "SecretiveCertificateParser") + + func process(_ data: Data) async throws -> OpenSSHCertificate { + let parser = OpenSSHCertificateParser() + let result = try parser.parse(data: data) + logger.log("Parser parsed certificate \(result.debugDescription)") + return result + } + +} diff --git a/Sources/SecretiveCertificateParser/main.swift b/Sources/SecretiveCertificateParser/main.swift new file mode 100644 index 0000000..c1516f0 --- /dev/null +++ b/Sources/SecretiveCertificateParser/main.swift @@ -0,0 +1,7 @@ +import Foundation +import XPCWrappers + +let delegate = XPCServiceDelegate(exportedObject: SecretiveCertificateParser()) +let listener = NSXPCListener.service() +listener.delegate = delegate +listener.resume() From 03a31fb4744543e6289c6b22b371142917632829 Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 6 May 2026 14:57:09 -0700 Subject: [PATCH 3/5] Fix deployment version for new xpc service (#800) --- Sources/Secretive.xcodeproj/project.pbxproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Secretive.xcodeproj/project.pbxproj b/Sources/Secretive.xcodeproj/project.pbxproj index 6b4f965..e10d283 100644 --- a/Sources/Secretive.xcodeproj/project.pbxproj +++ b/Sources/Secretive.xcodeproj/project.pbxproj @@ -1632,7 +1632,7 @@ INFOPLIST_KEY_CFBundleDisplayName = SecretiveCertificateParser; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Max Goedjen. All rights reserved."; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.5; + MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "$(SECRETIVE_BASE_BUNDLE_ID).SecretiveCertificateParser"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1663,7 +1663,7 @@ INFOPLIST_KEY_CFBundleDisplayName = SecretiveCertificateParser; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Max Goedjen. All rights reserved."; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.5; + MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "$(SECRETIVE_BASE_BUNDLE_ID).SecretiveCertificateParser"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1695,7 +1695,7 @@ INFOPLIST_KEY_CFBundleDisplayName = SecretiveCertificateParser; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Max Goedjen. All rights reserved."; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.5; + MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "$(SECRETIVE_BASE_BUNDLE_ID).SecretiveCertificateParser"; PRODUCT_NAME = "$(TARGET_NAME)"; From 9bdf9775d278997e6ba3abcc457fa005bb1b63ea Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 6 May 2026 15:09:33 -0700 Subject: [PATCH 4/5] Fix xpc signing (#801) * Fix deployment version for new xpc service * Fix signing on xpc service --- Sources/Secretive.xcodeproj/project.pbxproj | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/Secretive.xcodeproj/project.pbxproj b/Sources/Secretive.xcodeproj/project.pbxproj index e10d283..c3b7c5b 100644 --- a/Sources/Secretive.xcodeproj/project.pbxproj +++ b/Sources/Secretive.xcodeproj/project.pbxproj @@ -1683,10 +1683,11 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = SecretiveCertificateParser/SecretiveCertificateParser.entitlements; CODE_SIGN_IDENTITY = "Developer ID Application"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(SECRETIVE_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = Z72PRUAWF6; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -1699,6 +1700,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "$(SECRETIVE_BASE_BUNDLE_ID).SecretiveCertificateParser"; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; From 437386b87e702d8391303927be82bee0cd40ae8a Mon Sep 17 00:00:00 2001 From: Max Goedjen Date: Wed, 6 May 2026 20:01:40 -0700 Subject: [PATCH 5/5] Expand parsing + bug fixes for cert UI (#802) * Expand parsing and display of cert types, some additional cleanup * Tweak cert --- Package.swift | 22 ++++- Sources/Packages/Package.swift | 17 +++- .../Packages/Resources/Localizable.xcstrings | 44 ++++++++++ .../Sources/CertificateKit/Certificate.swift | 24 ++++++ .../CertificateKit/CertificateStore.swift | 27 +++--- .../CertificateKit/OpenSSHCertificate.swift | 82 +++++++++++++++++++ Sources/Packages/Sources/Common/URLs.swift | 9 +- .../Data+Hex.swift | 0 .../OpenSSHCertficateWriter.swift | 30 +++++++ ...e.swift => OpenSSHCertificateParser.swift} | 73 ++++++++--------- .../SSHProtocolKit/SSHAgentInputParser.swift | 1 + .../PublicKeyStandinFileController.swift | 11 +-- .../CertificateMigrator.swift | 13 ++- .../XPCCertificateParser.swift | 1 + Sources/SecretAgent/CertificateMigrator.swift | 2 +- Sources/Secretive.xcodeproj/project.pbxproj | 21 +++++ .../Views/Secrets/CertificateDetailView.swift | 53 ++++++++---- .../Secrets/CertificateListItemView.swift | 6 +- .../Views/Secrets/DeleteCertificateView.swift | 4 +- .../Views/Secrets/EditCertificateView.swift | 6 +- .../Views/Secrets/SecretDetailView.swift | 5 +- .../Views/Secrets/StoreListView.swift | 3 +- .../Secretive/Views/Views/ContentView.swift | 6 +- .../SecretiveCertificateParser.swift | 3 +- 24 files changed, 363 insertions(+), 100 deletions(-) create mode 100644 Sources/Packages/Sources/CertificateKit/Certificate.swift create mode 100644 Sources/Packages/Sources/CertificateKit/OpenSSHCertificate.swift rename Sources/Packages/Sources/{SSHProtocolKit => Formatters}/Data+Hex.swift (100%) create mode 100644 Sources/Packages/Sources/SSHProtocolKit/OpenSSHCertficateWriter.swift rename Sources/Packages/Sources/SSHProtocolKit/{OpenSSHCertificate.swift => OpenSSHCertificateParser.swift} (58%) diff --git a/Package.swift b/Package.swift index 3ca29ce..8a57e55 100644 --- a/Package.swift +++ b/Package.swift @@ -22,9 +22,15 @@ let package = Package( .library( name: "SmartCardSecretKit", targets: ["SmartCardSecretKit"]), + .library( + name: "CertificateKit", + targets: ["CertificateKit"]), .library( name: "SSHProtocolKit", targets: ["SSHProtocolKit"]), + .library( + name: "Formatters", + targets: ["Formatters"]), ], dependencies: [ ], @@ -56,9 +62,16 @@ let package = Package( resources: [localization], swiftSettings: swiftSettings ), + .target( + name: "CertificateKit", + dependencies: ["SecretKit", "Formatters"], + path: "Sources/Packages/Sources/CertificateKit", + resources: [localization], + swiftSettings: swiftSettings, + ), .target( name: "SSHProtocolKit", - dependencies: ["SecretKit"], + dependencies: ["SecretKit", "CertificateKit"], path: "Sources/Packages/Sources/SSHProtocolKit", resources: [localization], swiftSettings: swiftSettings, @@ -69,6 +82,13 @@ let package = Package( path: "Sources/Packages/Tests/SSHProtocolKitTests", swiftSettings: swiftSettings, ), + .target( + name: "Formatters", + dependencies: [], + path: "Sources/Packages/Sources/Formatters", + resources: [localization], + swiftSettings: swiftSettings, + ), ] ) diff --git a/Sources/Packages/Package.swift b/Sources/Packages/Package.swift index 966e36b..866fe79 100644 --- a/Sources/Packages/Package.swift +++ b/Sources/Packages/Package.swift @@ -25,6 +25,9 @@ let package = Package( .library( name: "SecretAgentKit", targets: ["SecretAgentKit"]), + .library( + name: "Formatters", + targets: ["Formatters"]), .library( name: "Common", targets: ["Common"]), @@ -69,13 +72,13 @@ let package = Package( ), .target( name: "CertificateKit", - dependencies: ["SecretKit", "SSHProtocolKit"], + dependencies: ["SecretKit", "Formatters"], resources: [localization], swiftSettings: swiftSettings, ), .target( name: "SecretAgentKit", - dependencies: ["SecretKit", "SSHProtocolKit", "CertificateKit", "Common"], + dependencies: ["SecretKit", "SSHProtocolKit", "CertificateKit", "Common", "Formatters"], resources: [localization], swiftSettings: swiftSettings, ), @@ -85,7 +88,7 @@ let package = Package( ), .target( name: "SSHProtocolKit", - dependencies: ["SecretKit"], + dependencies: ["SecretKit", "CertificateKit"], resources: [localization], swiftSettings: swiftSettings, ), @@ -94,6 +97,12 @@ let package = Package( dependencies: ["SSHProtocolKit"], swiftSettings: swiftSettings, ), + .target( + name: "Formatters", + dependencies: [], + resources: [localization], + swiftSettings: swiftSettings, + ), .target( name: "Common", dependencies: ["SSHProtocolKit", "SecretKit"], @@ -102,7 +111,7 @@ let package = Package( ), .target( name: "SharedXPCServices", - dependencies: ["CertificateKit"], + dependencies: ["CertificateKit", "SSHProtocolKit"], resources: [localization], swiftSettings: swiftSettings, ), diff --git a/Sources/Packages/Resources/Localizable.xcstrings b/Sources/Packages/Resources/Localizable.xcstrings index a9ad383..d66c69a 100644 --- a/Sources/Packages/Resources/Localizable.xcstrings +++ b/Sources/Packages/Resources/Localizable.xcstrings @@ -5548,6 +5548,28 @@ } } }, + "certificate_detail_critical_options_label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Critical Options" + } + } + } + }, + "certificate_detail_extensions_label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensions" + } + } + } + }, "certificate_detail_key_id_label" : { "extractionState" : "manual", "localizations" : { @@ -5592,6 +5614,28 @@ } } }, + "certificate_detail_sha256_public_key_fingerprint_label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Public Key Fingerprint" + } + } + } + }, + "certificate_detail_sha256_signing_key_fingerprint_label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signing CA Fingerprint" + } + } + } + }, "certificate_detail_valid_after_label" : { "extractionState" : "manual", "localizations" : { diff --git a/Sources/Packages/Sources/CertificateKit/Certificate.swift b/Sources/Packages/Sources/CertificateKit/Certificate.swift new file mode 100644 index 0000000..3e2a40f --- /dev/null +++ b/Sources/Packages/Sources/CertificateKit/Certificate.swift @@ -0,0 +1,24 @@ +import Foundation +import CryptoKit +import Formatters + +@dynamicMemberLookup +public struct Certificate: Sendable, Codable, Equatable, Hashable, Identifiable, CustomDebugStringConvertible { + + public var openSSHCertificate: OpenSSHCertificate + public let rawData: Data + + public init(openSSHCertificate: OpenSSHCertificate, rawData: Data) { + self.openSSHCertificate = openSSHCertificate + self.rawData = rawData + } + + public var id: String { Insecure.MD5.hash(data: rawData).formatted(.hex(separator: "")) } + + public var debugDescription: String { openSSHCertificate.debugDescription } + + public subscript(dynamicMember keyPath: KeyPath) -> T { + openSSHCertificate[keyPath: keyPath] + } + +} diff --git a/Sources/Packages/Sources/CertificateKit/CertificateStore.swift b/Sources/Packages/Sources/CertificateKit/CertificateStore.swift index f44bfc5..27c873b 100644 --- a/Sources/Packages/Sources/CertificateKit/CertificateStore.swift +++ b/Sources/Packages/Sources/CertificateKit/CertificateStore.swift @@ -3,11 +3,10 @@ import Observation import Security import os import SecretKit -import SSHProtocolKit @Observable @MainActor public final class CertificateStore: Sendable { - public private(set) var certificates: [OpenSSHCertificate] = [] + public private(set) var certificates: [Certificate] = [] /// Initializes a Store. public init() { @@ -33,15 +32,15 @@ import SSHProtocolKit } } - public func save(certificate: OpenSSHCertificate, originalData: Data) throws { - let attributes = try JSONEncoder().encode(certificate) + public func save(certificate: Certificate) throws { + let attributes = try JSONEncoder().encode(certificate.openSSHCertificate) let keychainAttributes = KeychainDictionary([ kSecClass: Constants.keyClass, kSecAttrService: Constants.keyTag, kSecAttrAccount: certificate.id, kSecUseDataProtectionKeychain: true, kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - kSecValueData: originalData, + kSecValueData: certificate.rawData, kSecAttrGeneric: attributes ]) let status = SecItemAdd(keychainAttributes, nil) @@ -51,7 +50,7 @@ import SSHProtocolKit reloadCertificates() } - public func delete(certificate: OpenSSHCertificate) throws { + public func delete(certificate: Certificate) throws { let deleteAttributes = KeychainDictionary([ kSecClass: Constants.keyClass, kSecAttrService: Constants.keyTag, @@ -65,13 +64,13 @@ import SSHProtocolKit reloadCertificates() } - public func update(certificate: OpenSSHCertificate) throws { + public func update(certificate: Certificate) throws { let updateQuery = KeychainDictionary([ kSecClass: Constants.keyClass, kSecAttrAccount: certificate.id, ]) - let cert = try JSONEncoder().encode(certificate) + let cert = try JSONEncoder().encode(certificate.openSSHCertificate) let updatedAttributes = KeychainDictionary([ kSecAttrGeneric: cert, ]) @@ -83,8 +82,8 @@ import SSHProtocolKit reloadCertificates() } - public func certificates(for secret: any Secret) -> [OpenSSHCertificate] { - certificates.filter { $0.publicKey == secret.publicKey } + public func certificates(for secret: any Secret) -> [Certificate] { + certificates.filter { $0.openSSHCertificate.publicKey.data == secret.publicKey } } @@ -106,12 +105,13 @@ extension CertificateStore { unsafe SecItemCopyMatching(queryAttributes, &untyped) guard let typed = untyped as? [[CFString: Any]] else { return } let decoder = JSONDecoder() - let wrapped: [OpenSSHCertificate] = typed.compactMap { + let wrapped: [Certificate] = typed.compactMap { do { - guard let attributesData = $0[kSecAttrGeneric] as? Data else { + guard let data = $0[kSecValueData] as? Data, + let attributesData = $0[kSecAttrGeneric] as? Data else { throw MissingAttributesError() } - return try decoder.decode(OpenSSHCertificate.self, from: attributesData) + return Certificate(openSSHCertificate: try decoder.decode(OpenSSHCertificate.self, from: attributesData), rawData: data) } catch { return nil } @@ -123,6 +123,7 @@ extension CertificateStore { true } } + certificates.append(contentsOf: wrapped) } diff --git a/Sources/Packages/Sources/CertificateKit/OpenSSHCertificate.swift b/Sources/Packages/Sources/CertificateKit/OpenSSHCertificate.swift new file mode 100644 index 0000000..c65c2a7 --- /dev/null +++ b/Sources/Packages/Sources/CertificateKit/OpenSSHCertificate.swift @@ -0,0 +1,82 @@ +import Foundation +import Formatters + +public struct OpenSSHCertificate: Sendable, Codable, Equatable, Hashable, CustomDebugStringConvertible { + + public var type: CertificateType + public var name: String + public var data: Data + + public var publicKey: PublicKey + public var principals: [String] + public var keyID: String + public var serial: UInt64 + public var validityRange: Range? + public var criticalOptions: [String] + public var extensions: [String] + public var signingKey: PublicKey + + public init( + type: OpenSSHCertificate.CertificateType, + name: String, + data: Data, + publicKey: PublicKey, + principals: [String], + keyID: String, + serial: UInt64, + validityRange: Range? = nil, + criticalOptions: [String], + extensions: [String], + signingKey: PublicKey, + ) { + self.type = type + self.name = name + self.data = data + self.publicKey = publicKey + self.principals = principals + self.keyID = keyID + self.serial = serial + self.validityRange = validityRange + self.criticalOptions = criticalOptions + self.extensions = extensions + self.signingKey = signingKey + } + + public var debugDescription: String { + "OpenSSH Certificate \(name, default: "Unnamed"): \(data.formatted(.hex()))" + } + +} + +extension OpenSSHCertificate { + + public enum CertificateType: String, Sendable, Codable { + case ecdsa256 = "ecdsa-sha2-nistp256-cert-v01@openssh.com" + case ecdsa384 = "ecdsa-sha2-nistp384-cert-v01@openssh.com" + case nistp521 = "ecdsa-sha2-nistp521-cert-v01@openssh.com" + + public var keyIdentifier: String { + rawValue.replacingOccurrences(of: "-cert-v01@openssh.com", with: "") + } + + } + +} + +extension OpenSSHCertificate { + + public struct PublicKey: Hashable, Sendable, Codable { + + public let keyType: String + public let curveName: String + public let data: Data + + public init(keyType: String, curveName: String, data: Data) { + self.keyType = keyType + self.curveName = curveName + self.data = data + } + + } + +} diff --git a/Sources/Packages/Sources/Common/URLs.swift b/Sources/Packages/Sources/Common/URLs.swift index e3d21e0..d6638b6 100644 --- a/Sources/Packages/Sources/Common/URLs.swift +++ b/Sources/Packages/Sources/Common/URLs.swift @@ -1,5 +1,6 @@ import Foundation import SSHProtocolKit +import CertificateKit import SecretKit extension URL { @@ -35,11 +36,11 @@ extension URL { } /// The path for a certificate. - /// - Parameter certificate: The OpenSSHCertificate to return the path for. - /// - Returns: The path to the OpenSSHCertificate. + /// - Parameter certificate: The Certificate to return the path for. + /// - Returns: The path to the Certificate. /// - Warning: This method returning a path does not imply that a certificate has been written to disk already. This method only describes where it will be written to. - public static func certificatePath(for certificate: OpenSSHCertificate, in directory: URL) -> String { - return directory.appending(component: "\(certificate.id)-cert.pub").path() + public static func certificatePath(for certificateID: String, in directory: URL) -> String { + return directory.appending(component: "\(certificateID)-cert.pub").path() } } diff --git a/Sources/Packages/Sources/SSHProtocolKit/Data+Hex.swift b/Sources/Packages/Sources/Formatters/Data+Hex.swift similarity index 100% rename from Sources/Packages/Sources/SSHProtocolKit/Data+Hex.swift rename to Sources/Packages/Sources/Formatters/Data+Hex.swift diff --git a/Sources/Packages/Sources/SSHProtocolKit/OpenSSHCertficateWriter.swift b/Sources/Packages/Sources/SSHProtocolKit/OpenSSHCertficateWriter.swift new file mode 100644 index 0000000..84892cd --- /dev/null +++ b/Sources/Packages/Sources/SSHProtocolKit/OpenSSHCertficateWriter.swift @@ -0,0 +1,30 @@ +import Foundation +import CryptoKit +import CertificateKit +import Formatters + +/// Generates OpenSSH representations of Certificates. +public struct OpenSSHCertificateWriter: Sendable { + + /// Initializes the writer. + public init() { + } + + /// Generates an OpenSSH data payload identifying the certificate. + /// - Returns: OpenSSH data payload identifying the certificate. + public func data(publicKey: OpenSSHCertificate.PublicKey) -> Data { + // https://datatracker.ietf.org/doc/html/rfc5656#section-3.1 + publicKey.keyType.lengthAndData + + publicKey.curveName.lengthAndData + + publicKey.data.lengthAndData + } + + /// Generates an OpenSSH SHA256 fingerprint string. + /// - Returns: OpenSSH SHA256 fingerprint string. + public func openSSHSHA256KeyFingerprint(publicKey: OpenSSHCertificate.PublicKey) -> String { + // OpenSSL format seems to strip the padding at the end. + let cleaned = SHA256.hash(data: data(publicKey: publicKey)).formatted(.base64(stripPadding: true)) + return "SHA256:\(cleaned)" + } + +} diff --git a/Sources/Packages/Sources/SSHProtocolKit/OpenSSHCertificate.swift b/Sources/Packages/Sources/SSHProtocolKit/OpenSSHCertificateParser.swift similarity index 58% rename from Sources/Packages/Sources/SSHProtocolKit/OpenSSHCertificate.swift rename to Sources/Packages/Sources/SSHProtocolKit/OpenSSHCertificateParser.swift index f1354a5..efedc96 100644 --- a/Sources/Packages/Sources/SSHProtocolKit/OpenSSHCertificate.swift +++ b/Sources/Packages/Sources/SSHProtocolKit/OpenSSHCertificateParser.swift @@ -1,39 +1,5 @@ import Foundation -import OSLog -import CryptoKit - -public struct OpenSSHCertificate: Sendable, Codable, Equatable, Hashable, Identifiable, CustomDebugStringConvertible { - - public var id: String { Insecure.MD5.hash(data: data).formatted(.hex(separator: "")) } - public var type: CertificateType - public var name: String - public let data: Data - - public var publicKey: Data - public var principals: [String] - public var keyID: String - public var serial: UInt64 - public var validityRange: Range? - - public var debugDescription: String { - "OpenSSH Certificate \(name, default: "Unnamed"): \(data.formatted(.hex()))" - } - -} - -extension OpenSSHCertificate { - - public enum CertificateType: String, Sendable, Codable { - case ecdsa256 = "ecdsa-sha2-nistp256-cert-v01@openssh.com" - case ecdsa384 = "ecdsa-sha2-nistp384-cert-v01@openssh.com" - case nistp521 = "ecdsa-sha2-nistp521-cert-v01@openssh.com" - - var keyIdentifier: String { - rawValue.replacingOccurrences(of: "-cert-v01@openssh.com", with: "") - } - } - -} +import CertificateKit public protocol OpenSSHCertificateParserProtocol { func parse(data: Data) async throws -> OpenSSHCertificate @@ -41,8 +7,6 @@ public protocol OpenSSHCertificateParserProtocol { public struct OpenSSHCertificateParser: OpenSSHCertificateParserProtocol, Sendable { - private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "OpenSSHCertificateParser") - public init() { assert(Bundle.main.bundleURL.pathExtension == "xpc" || ProcessInfo.processInfo.processName == "xctest", "Potentially unsafe parsing code should run in an XPC service") } @@ -64,10 +28,12 @@ public struct OpenSSHCertificateParser: OpenSSHCertificateParserProtocol, Sendab let comment = elements.first do { let dataParser = OpenSSHReader(data: decoded) - _ = try dataParser.readNextChunkAsString() // Redundant key type + let publicKeyType = try dataParser.readNextChunkAsString() // Theoretically the same as typeString, but + .replacingOccurrences(of: "-cert-v01@openssh.com", with: "") _ = try dataParser.readNextChunk() // Nonce - _ = try dataParser.readNextChunkAsString() // curve - let publicKey = try dataParser.readNextChunk() + let publicKeyCurveName = try dataParser.readNextChunkAsString() + let publicKeyData = try dataParser.readNextChunk() + let publicKey = OpenSSHCertificate.PublicKey(keyType: publicKeyType, curveName: publicKeyCurveName, data: publicKeyData) let serialNumber = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true) let role = try dataParser.readNextBytes(as: UInt32.self, convertEndianness: true) _ = role @@ -80,6 +46,28 @@ public struct OpenSSHCertificateParser: OpenSSHCertificateParserProtocol, Sendab let validAfter = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true) let validBefore = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true) let validityRange = Date(timeIntervalSince1970: TimeInterval(validAfter))..(for secret: SecretType) -> String { let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "") - return publicKeysURL.appending(component: "\(minimalHex)-cert.pub").path() + return publicKeysURL.appending(component: "\(minimalHex).pub").path() } } diff --git a/Sources/Packages/Sources/SharedXPCServices/CertificateMigrator.swift b/Sources/Packages/Sources/SharedXPCServices/CertificateMigrator.swift index 8de3f14..e15f792 100644 --- a/Sources/Packages/Sources/SharedXPCServices/CertificateMigrator.swift +++ b/Sources/Packages/Sources/SharedXPCServices/CertificateMigrator.swift @@ -9,16 +9,23 @@ import CertificateKit public struct CertificateMigrator { private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CertificateKitMigrator") - private let directory: URL + private let publicKeysDirectory: URL + private let certificatesDirectory: URL private let certificateStore: CertificateStore /// Initializes a PublicKeyFileStoreController. public init(homeDirectory: URL, certificateStore: CertificateStore) { - directory = homeDirectory.appending(component: "PublicKeys") + publicKeysDirectory = homeDirectory.appending(component: "PublicKeys") + certificatesDirectory = homeDirectory.appending(component: "Certificates") self.certificateStore = certificateStore } @MainActor public func migrate() throws { + try migrate(directory: publicKeysDirectory) + try migrate(directory: certificatesDirectory) + } + + @MainActor public func migrate(directory: URL) throws { let fileCerts = try FileManager.default .contentsOfDirectory(atPath: directory.path()) .filter { $0.hasSuffix("-cert.pub") } @@ -29,7 +36,7 @@ public struct CertificateMigrator { let data = try Data(contentsOf: url) let parser = try await XPCCertificateParser() let cert = try await parser.parse(data: data) - try certificateStore.save(certificate: cert, originalData: data) + try certificateStore.save(certificate: Certificate(openSSHCertificate: cert, rawData: data)) do { try FileManager.default.removeItem(at: url) } catch { diff --git a/Sources/Packages/Sources/SharedXPCServices/XPCCertificateParser.swift b/Sources/Packages/Sources/SharedXPCServices/XPCCertificateParser.swift index 11a8eae..fea407f 100644 --- a/Sources/Packages/Sources/SharedXPCServices/XPCCertificateParser.swift +++ b/Sources/Packages/Sources/SharedXPCServices/XPCCertificateParser.swift @@ -1,6 +1,7 @@ import Foundation import OSLog import SSHProtocolKit +import CertificateKit import XPCWrappers /// Delegates all agent input parsing to an XPC service which wraps OpenSSH diff --git a/Sources/SecretAgent/CertificateMigrator.swift b/Sources/SecretAgent/CertificateMigrator.swift index 0c0966b..9a37032 100644 --- a/Sources/SecretAgent/CertificateMigrator.swift +++ b/Sources/SecretAgent/CertificateMigrator.swift @@ -30,7 +30,7 @@ public struct CertificateMigrator { let data = try Data(contentsOf: url) let parser = try await XPCCertificateParser() let cert = try await parser.parse(data: data) - try certificateStore.save(certificate: cert, originalData: data) + try certificateStore.save(certificate: Certificate(openSSHCertificate: cert, rawData: data)) do { try FileManager.default.removeItem(at: url) } catch { diff --git a/Sources/Secretive.xcodeproj/project.pbxproj b/Sources/Secretive.xcodeproj/project.pbxproj index c3b7c5b..54f5f08 100644 --- a/Sources/Secretive.xcodeproj/project.pbxproj +++ b/Sources/Secretive.xcodeproj/project.pbxproj @@ -86,6 +86,9 @@ 50E205802FAB291E00402380 /* CertificateMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E2057F2FAB291E00402380 /* CertificateMigrator.swift */; }; 50E205822FAB293B00402380 /* SharedXPCServices in Frameworks */ = {isa = PBXBuildFile; productRef = 50E205812FAB293B00402380 /* SharedXPCServices */; }; 50E205842FAB296A00402380 /* SharedXPCServices in Frameworks */ = {isa = PBXBuildFile; productRef = 50E205832FAB296A00402380 /* SharedXPCServices */; }; + 50E205862FAC2EA000402380 /* Formatters in Frameworks */ = {isa = PBXBuildFile; productRef = 50E205852FAC2EA000402380 /* Formatters */; }; + 50E205882FAC2EAB00402380 /* Formatters in Frameworks */ = {isa = PBXBuildFile; productRef = 50E205872FAC2EAB00402380 /* Formatters */; }; + 50E2058A2FAC2EB600402380 /* Formatters in Frameworks */ = {isa = PBXBuildFile; productRef = 50E205892FAC2EB600402380 /* Formatters */; }; 50E4C4532E73C78C00C73783 /* WindowBackgroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E4C4522E73C78900C73783 /* WindowBackgroundStyle.swift */; }; 50E4C4C32E7765DF00C73783 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E4C4C22E7765DF00C73783 /* AboutView.swift */; }; 50E4C4C82E777E4200C73783 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 50E4C4C72E777E4200C73783 /* AppIcon.icon */; }; @@ -292,6 +295,7 @@ buildActionMask = 2147483647; files = ( 50E0145C2EDB9CDF00B121F1 /* Common in Frameworks */, + 50E2058A2FAC2EB600402380 /* Formatters in Frameworks */, 5003EF3B278005E800DF2006 /* SecretKit in Frameworks */, 501421622781262300BBAA70 /* Brief in Frameworks */, 50E205842FAB296A00402380 /* SharedXPCServices in Frameworks */, @@ -305,6 +309,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 50E205862FAC2EA000402380 /* Formatters in Frameworks */, 50692D2D2E6FDC000043C7BB /* XPCWrappers in Frameworks */, 50692D312E6FDC390043C7BB /* Brief in Frameworks */, ); @@ -337,6 +342,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 50E205882FAC2EAB00402380 /* Formatters in Frameworks */, 50E205332FAAB95A00402380 /* SSHProtocolKit in Frameworks */, 50E205312FAAB95500402380 /* XPCWrappers in Frameworks */, ); @@ -583,6 +589,7 @@ 50E0145B2EDB9CDF00B121F1 /* Common */, 505F5EF12FA9635700C45824 /* CertificateKit */, 50E205832FAB296A00402380 /* SharedXPCServices */, + 50E205892FAC2EB600402380 /* Formatters */, ); productName = Secretive; productReference = 50617D7F23FCE48E0099B055 /* Secretive.app */; @@ -604,6 +611,7 @@ packageProductDependencies = ( 50692D2C2E6FDC000043C7BB /* XPCWrappers */, 50692D302E6FDC390043C7BB /* Brief */, + 50E205852FAC2EA000402380 /* Formatters */, ); productName = SecretiveUpdater; productReference = 50692D122E6FDB880043C7BB /* SecretiveUpdater.xpc */; @@ -678,6 +686,7 @@ packageProductDependencies = ( 50E205302FAAB95500402380 /* XPCWrappers */, 50E205322FAAB95A00402380 /* SSHProtocolKit */, + 50E205872FAC2EAB00402380 /* Formatters */, ); productName = SecretAgentCertificateParser; productReference = 50E205142FAAB81C00402380 /* SecretiveCertificateParser.xpc */; @@ -1857,6 +1866,18 @@ isa = XCSwiftPackageProductDependency; productName = SharedXPCServices; }; + 50E205852FAC2EA000402380 /* Formatters */ = { + isa = XCSwiftPackageProductDependency; + productName = Formatters; + }; + 50E205872FAC2EAB00402380 /* Formatters */ = { + isa = XCSwiftPackageProductDependency; + productName = Formatters; + }; + 50E205892FAC2EB600402380 /* Formatters */ = { + isa = XCSwiftPackageProductDependency; + productName = Formatters; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 50617D7723FCE48D0099B055 /* Project object */; diff --git a/Sources/Secretive/Views/Secrets/CertificateDetailView.swift b/Sources/Secretive/Views/Secrets/CertificateDetailView.swift index 92be0df..debd863 100644 --- a/Sources/Secretive/Views/Secrets/CertificateDetailView.swift +++ b/Sources/Secretive/Views/Secrets/CertificateDetailView.swift @@ -1,11 +1,12 @@ import SwiftUI import SecretKit import Common +import CertificateKit import SSHProtocolKit - +import CryptoKit struct CertificateDetailView: View { - let certificate: OpenSSHCertificate + let certificate: Certificate var body: some View { ScrollView { @@ -25,6 +26,26 @@ struct CertificateDetailView: View { ) Spacer() .frame(height: 20) + CopyableView( + title: .secretDetailSha256FingerprintLabel, + image: Image(systemName: "touchid"), + text: OpenSSHCertificateWriter().openSSHSHA256KeyFingerprint(publicKey: certificate.publicKey) + ) + Spacer() + .frame(height: 20) + CopyableView( + title: .secretDetailSha256FingerprintLabel, + image: Image(systemName: "touchid"), + text: OpenSSHCertificateWriter().openSSHSHA256KeyFingerprint(publicKey: certificate.signingKey) + ) + Spacer() + .frame(height: 20) + CopyableView( + title: .certificateDetailPathLabel, + image: Image(systemName: "checkmark.seal.text.page"), + text: URL.certificatePath(for: certificate.id, in: URL.certificatesDirectory), + showRevealInFinder: true + ) if let validityRange = certificate.validityRange { let epoch = Date(timeIntervalSince1970: 0) let end = Date(timeIntervalSince1970: TimeInterval(UInt64.max)) @@ -32,30 +53,34 @@ struct CertificateDetailView: View { case (epoch, end): EmptyView() case (epoch, let otherEnd): + Spacer() + .frame(height: 20) MultilineInfoView(title: .certificateDetailValidUntilLabel, image: Image(systemName: "calendar.badge.clock"), items: [otherEnd.formatted()]) - Spacer() - .frame(height: 20) case (let otherStart, end): + Spacer() + .frame(height: 20) MultilineInfoView(title: .certificateDetailValidAfterLabel, image: Image(systemName: "calendar.badge.clock"), items: [otherStart.formatted()]) - Spacer() - .frame(height: 20) default: - MultilineInfoView(title: .certificateDetailValidityRangeLabel, image: Image(systemName: "calendar.badge.clock"), items: [validityRange.formatted()]) Spacer() .frame(height: 20) + MultilineInfoView(title: .certificateDetailValidityRangeLabel, image: Image(systemName: "calendar.badge.clock"), items: [validityRange.formatted()]) } } if !certificate.principals.isEmpty { - MultilineInfoView(title: .certificateDetailPrincipalsLabel, image: Image(systemName: "person.2"), items: certificate.principals) Spacer() .frame(height: 20) + MultilineInfoView(title: .certificateDetailPrincipalsLabel, image: Image(systemName: "person.2"), items: certificate.principals) + } + if !certificate.criticalOptions.isEmpty { + Spacer() + .frame(height: 20) + MultilineInfoView(title: .certificateDetailCriticalOptionsLabel, image: Image(systemName: "person.2"), items: certificate.criticalOptions) + } + if !certificate.extensions.isEmpty { + Spacer() + .frame(height: 20) + MultilineInfoView(title: .certificateDetailExtensionsLabel, image: Image(systemName: "person.2"), items: certificate.extensions) } - CopyableView( - title: .certificateDetailPathLabel, - image: Image(systemName: "checkmark.seal.text.page"), - text: URL.certificatePath(for: certificate, in: URL.certificatesDirectory), - showRevealInFinder: true - ) Spacer() } } diff --git a/Sources/Secretive/Views/Secrets/CertificateListItemView.swift b/Sources/Secretive/Views/Secrets/CertificateListItemView.swift index 32da8cd..e5095d1 100644 --- a/Sources/Secretive/Views/Secrets/CertificateListItemView.swift +++ b/Sources/Secretive/Views/Secrets/CertificateListItemView.swift @@ -6,13 +6,13 @@ struct CertificateListItemView: View { @Environment(\.certificateStore) private var store - var certificate: OpenSSHCertificate + var certificate: Certificate @State var isDeleting: Bool = false @State var isRenaming: Bool = false - var deletedCertificate: (OpenSSHCertificate) -> Void - var renamedCertificate: (OpenSSHCertificate) -> Void + var deletedCertificate: (Certificate) -> Void + var renamedCertificate: (Certificate) -> Void var body: some View { NavigationLink(value: certificate) { diff --git a/Sources/Secretive/Views/Secrets/DeleteCertificateView.swift b/Sources/Secretive/Views/Secrets/DeleteCertificateView.swift index 6d2a589..562351e 100644 --- a/Sources/Secretive/Views/Secrets/DeleteCertificateView.swift +++ b/Sources/Secretive/Views/Secrets/DeleteCertificateView.swift @@ -4,7 +4,7 @@ import SSHProtocolKit extension View { - func showingDeleteConfirmation(isPresented: Binding, _ certificate: OpenSSHCertificate, _ store: CertificateStore, dismissalBlock: @escaping (Bool) -> ()) -> some View { + func showingDeleteConfirmation(isPresented: Binding, _ certificate: Certificate, _ store: CertificateStore, dismissalBlock: @escaping (Bool) -> ()) -> some View { modifier(DeleteCertificateConfirmationModifier(isPresented: isPresented, certificate: certificate, store: store, dismissalBlock: dismissalBlock)) } @@ -13,7 +13,7 @@ extension View { struct DeleteCertificateConfirmationModifier: ViewModifier { var isPresented: Binding - var certificate: OpenSSHCertificate + var certificate: Certificate var store: CertificateStore var dismissalBlock: (Bool) -> () @State var confirmedSecretName = "" diff --git a/Sources/Secretive/Views/Secrets/EditCertificateView.swift b/Sources/Secretive/Views/Secrets/EditCertificateView.swift index 4568eed..9b72e06 100644 --- a/Sources/Secretive/Views/Secrets/EditCertificateView.swift +++ b/Sources/Secretive/Views/Secrets/EditCertificateView.swift @@ -5,14 +5,14 @@ import CertificateKit struct EditCertificateView: View { let store: CertificateStore - let certificate: OpenSSHCertificate + let certificate: Certificate @State private var name: String @State var errorText: String? @Environment(\.dismiss) var dismiss - init(store: CertificateStore, certificate: OpenSSHCertificate) { + init(store: CertificateStore, certificate: Certificate) { self.store = store self.certificate = certificate name = certificate.name @@ -49,7 +49,7 @@ struct EditCertificateView: View { Task { do { var updated = certificate - updated.name = name + updated.openSSHCertificate.name = name try store.update(certificate: updated) dismiss() } catch { diff --git a/Sources/Secretive/Views/Secrets/SecretDetailView.swift b/Sources/Secretive/Views/Secrets/SecretDetailView.swift index 4fd30de..a092bcf 100644 --- a/Sources/Secretive/Views/Secrets/SecretDetailView.swift +++ b/Sources/Secretive/Views/Secrets/SecretDetailView.swift @@ -1,13 +1,14 @@ import SwiftUI import SecretKit import Common +import CertificateKit import SSHProtocolKit struct SecretDetailView: View { let secret: SecretType - let certificates: [OpenSSHCertificate] - let navigateToCertificate: ((OpenSSHCertificate) -> Void)? + let certificates: [Certificate] + let navigateToCertificate: ((Certificate) -> Void)? private let keyWriter = OpenSSHPublicKeyWriter() diff --git a/Sources/Secretive/Views/Secrets/StoreListView.swift b/Sources/Secretive/Views/Secrets/StoreListView.swift index 12e2419..b461ad5 100644 --- a/Sources/Secretive/Views/Secrets/StoreListView.swift +++ b/Sources/Secretive/Views/Secrets/StoreListView.swift @@ -1,12 +1,13 @@ import SwiftUI import SecretKit import SSHProtocolKit +import CertificateKit struct StoreListView: View { enum StoreListSelection: Hashable { case secret(AnySecret) - case certificate(OpenSSHCertificate) + case certificate(Certificate) } @Binding var selection: StoreListSelection? diff --git a/Sources/Secretive/Views/Views/ContentView.swift b/Sources/Secretive/Views/Views/ContentView.swift index 2d58ae9..ef9d8f5 100644 --- a/Sources/Secretive/Views/Views/ContentView.swift +++ b/Sources/Secretive/Views/Views/ContentView.swift @@ -5,6 +5,7 @@ import SmartCardSecretKit import Brief import SSHProtocolKit import SharedXPCServices +import CertificateKit struct ContentView: View { @@ -52,8 +53,9 @@ struct ContentView: View { let data = try Data(contentsOf: url) let parser = try await XPCCertificateParser() let cert = try await parser.parse(data: data) - try certificateStore.save(certificate: cert, originalData: data) - selection = .certificate(cert) + let wrapped = Certificate(openSSHCertificate: cert, rawData: data) + try certificateStore.save(certificate: wrapped) + selection = .certificate(wrapped) } catch { } diff --git a/Sources/SecretiveCertificateParser/SecretiveCertificateParser.swift b/Sources/SecretiveCertificateParser/SecretiveCertificateParser.swift index 8c7aab7..2d453e8 100644 --- a/Sources/SecretiveCertificateParser/SecretiveCertificateParser.swift +++ b/Sources/SecretiveCertificateParser/SecretiveCertificateParser.swift @@ -2,6 +2,7 @@ import Foundation import OSLog import XPCWrappers import SSHProtocolKit +import CertificateKit final class SecretiveCertificateParser: NSObject, XPCProtocol { @@ -10,7 +11,7 @@ final class SecretiveCertificateParser: NSObject, XPCProtocol { func process(_ data: Data) async throws -> OpenSSHCertificate { let parser = OpenSSHCertificateParser() let result = try parser.parse(data: data) - logger.log("Parser parsed certificate \(result.debugDescription)") + logger.log("Parser parsed certificate") return result }