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()