Expand parsing + bug fixes for cert UI (#802)

* Expand parsing and display of cert types, some additional cleanup

* Tweak cert
This commit is contained in:
Max Goedjen
2026-05-06 20:01:40 -07:00
committed by GitHub
parent 9bdf9775d2
commit 437386b87e
24 changed files with 363 additions and 100 deletions

View File

@@ -25,6 +25,9 @@ let package = Package(
.library(
name: "SecretAgentKit",
targets: ["SecretAgentKit"]),
.library(
name: "Formatters",
targets: ["Formatters"]),
.library(
name: "Common",
targets: ["Common"]),
@@ -69,13 +72,13 @@ let package = Package(
),
.target(
name: "CertificateKit",
dependencies: ["SecretKit", "SSHProtocolKit"],
dependencies: ["SecretKit", "Formatters"],
resources: [localization],
swiftSettings: swiftSettings,
),
.target(
name: "SecretAgentKit",
dependencies: ["SecretKit", "SSHProtocolKit", "CertificateKit", "Common"],
dependencies: ["SecretKit", "SSHProtocolKit", "CertificateKit", "Common", "Formatters"],
resources: [localization],
swiftSettings: swiftSettings,
),
@@ -85,7 +88,7 @@ let package = Package(
),
.target(
name: "SSHProtocolKit",
dependencies: ["SecretKit"],
dependencies: ["SecretKit", "CertificateKit"],
resources: [localization],
swiftSettings: swiftSettings,
),
@@ -94,6 +97,12 @@ let package = Package(
dependencies: ["SSHProtocolKit"],
swiftSettings: swiftSettings,
),
.target(
name: "Formatters",
dependencies: [],
resources: [localization],
swiftSettings: swiftSettings,
),
.target(
name: "Common",
dependencies: ["SSHProtocolKit", "SecretKit"],
@@ -102,7 +111,7 @@ let package = Package(
),
.target(
name: "SharedXPCServices",
dependencies: ["CertificateKit"],
dependencies: ["CertificateKit", "SSHProtocolKit"],
resources: [localization],
swiftSettings: swiftSettings,
),

View File

@@ -5548,6 +5548,28 @@
}
}
},
"certificate_detail_critical_options_label" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Critical Options"
}
}
}
},
"certificate_detail_extensions_label" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Extensions"
}
}
}
},
"certificate_detail_key_id_label" : {
"extractionState" : "manual",
"localizations" : {
@@ -5592,6 +5614,28 @@
}
}
},
"certificate_detail_sha256_public_key_fingerprint_label" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Public Key Fingerprint"
}
}
}
},
"certificate_detail_sha256_signing_key_fingerprint_label" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Signing CA Fingerprint"
}
}
}
},
"certificate_detail_valid_after_label" : {
"extractionState" : "manual",
"localizations" : {

View File

@@ -0,0 +1,24 @@
import Foundation
import CryptoKit
import Formatters
@dynamicMemberLookup
public struct Certificate: Sendable, Codable, Equatable, Hashable, Identifiable, CustomDebugStringConvertible {
public var openSSHCertificate: OpenSSHCertificate
public let rawData: Data
public init(openSSHCertificate: OpenSSHCertificate, rawData: Data) {
self.openSSHCertificate = openSSHCertificate
self.rawData = rawData
}
public var id: String { Insecure.MD5.hash(data: rawData).formatted(.hex(separator: "")) }
public var debugDescription: String { openSSHCertificate.debugDescription }
public subscript<T>(dynamicMember keyPath: KeyPath<OpenSSHCertificate, T>) -> T {
openSSHCertificate[keyPath: keyPath]
}
}

View File

@@ -3,11 +3,10 @@ import Observation
import Security
import os
import SecretKit
import SSHProtocolKit
@Observable @MainActor public final class CertificateStore: Sendable {
public private(set) var certificates: [OpenSSHCertificate] = []
public private(set) var certificates: [Certificate] = []
/// Initializes a Store.
public init() {
@@ -33,15 +32,15 @@ import SSHProtocolKit
}
}
public func save(certificate: OpenSSHCertificate, originalData: Data) throws {
let attributes = try JSONEncoder().encode(certificate)
public func save(certificate: Certificate) throws {
let attributes = try JSONEncoder().encode(certificate.openSSHCertificate)
let keychainAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
kSecAttrAccount: certificate.id,
kSecUseDataProtectionKeychain: true,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
kSecValueData: originalData,
kSecValueData: certificate.rawData,
kSecAttrGeneric: attributes
])
let status = SecItemAdd(keychainAttributes, nil)
@@ -51,7 +50,7 @@ import SSHProtocolKit
reloadCertificates()
}
public func delete(certificate: OpenSSHCertificate) throws {
public func delete(certificate: Certificate) throws {
let deleteAttributes = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag,
@@ -65,13 +64,13 @@ import SSHProtocolKit
reloadCertificates()
}
public func update(certificate: OpenSSHCertificate) throws {
public func update(certificate: Certificate) throws {
let updateQuery = KeychainDictionary([
kSecClass: Constants.keyClass,
kSecAttrAccount: certificate.id,
])
let cert = try JSONEncoder().encode(certificate)
let cert = try JSONEncoder().encode(certificate.openSSHCertificate)
let updatedAttributes = KeychainDictionary([
kSecAttrGeneric: cert,
])
@@ -83,8 +82,8 @@ import SSHProtocolKit
reloadCertificates()
}
public func certificates(for secret: any Secret) -> [OpenSSHCertificate] {
certificates.filter { $0.publicKey == secret.publicKey }
public func certificates(for secret: any Secret) -> [Certificate] {
certificates.filter { $0.openSSHCertificate.publicKey.data == secret.publicKey }
}
@@ -106,12 +105,13 @@ extension CertificateStore {
unsafe SecItemCopyMatching(queryAttributes, &untyped)
guard let typed = untyped as? [[CFString: Any]] else { return }
let decoder = JSONDecoder()
let wrapped: [OpenSSHCertificate] = typed.compactMap {
let wrapped: [Certificate] = typed.compactMap {
do {
guard let attributesData = $0[kSecAttrGeneric] as? Data else {
guard let data = $0[kSecValueData] as? Data,
let attributesData = $0[kSecAttrGeneric] as? Data else {
throw MissingAttributesError()
}
return try decoder.decode(OpenSSHCertificate.self, from: attributesData)
return Certificate(openSSHCertificate: try decoder.decode(OpenSSHCertificate.self, from: attributesData), rawData: data)
} catch {
return nil
}
@@ -123,6 +123,7 @@ extension CertificateStore {
true
}
}
certificates.append(contentsOf: wrapped)
}

View File

@@ -0,0 +1,82 @@
import Foundation
import Formatters
public struct OpenSSHCertificate: Sendable, Codable, Equatable, Hashable, CustomDebugStringConvertible {
public var type: CertificateType
public var name: String
public var data: Data
public var publicKey: PublicKey
public var principals: [String]
public var keyID: String
public var serial: UInt64
public var validityRange: Range<Date>?
public var criticalOptions: [String]
public var extensions: [String]
public var signingKey: PublicKey
public init(
type: OpenSSHCertificate.CertificateType,
name: String,
data: Data,
publicKey: PublicKey,
principals: [String],
keyID: String,
serial: UInt64,
validityRange: Range<Date>? = nil,
criticalOptions: [String],
extensions: [String],
signingKey: PublicKey,
) {
self.type = type
self.name = name
self.data = data
self.publicKey = publicKey
self.principals = principals
self.keyID = keyID
self.serial = serial
self.validityRange = validityRange
self.criticalOptions = criticalOptions
self.extensions = extensions
self.signingKey = signingKey
}
public var debugDescription: String {
"OpenSSH Certificate \(name, default: "Unnamed"): \(data.formatted(.hex()))"
}
}
extension OpenSSHCertificate {
public enum CertificateType: String, Sendable, Codable {
case ecdsa256 = "ecdsa-sha2-nistp256-cert-v01@openssh.com"
case ecdsa384 = "ecdsa-sha2-nistp384-cert-v01@openssh.com"
case nistp521 = "ecdsa-sha2-nistp521-cert-v01@openssh.com"
public var keyIdentifier: String {
rawValue.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
}
}
}
extension OpenSSHCertificate {
public struct PublicKey: Hashable, Sendable, Codable {
public let keyType: String
public let curveName: String
public let data: Data
public init(keyType: String, curveName: String, data: Data) {
self.keyType = keyType
self.curveName = curveName
self.data = data
}
}
}

View File

@@ -1,5 +1,6 @@
import Foundation
import SSHProtocolKit
import CertificateKit
import SecretKit
extension URL {
@@ -35,11 +36,11 @@ extension URL {
}
/// The path for a certificate.
/// - Parameter certificate: The OpenSSHCertificate to return the path for.
/// - Returns: The path to the OpenSSHCertificate.
/// - Parameter certificate: The Certificate to return the path for.
/// - Returns: The path to the Certificate.
/// - Warning: This method returning a path does not imply that a certificate has been written to disk already. This method only describes where it will be written to.
public static func certificatePath(for certificate: OpenSSHCertificate, in directory: URL) -> String {
return directory.appending(component: "\(certificate.id)-cert.pub").path()
public static func certificatePath(for certificateID: String, in directory: URL) -> String {
return directory.appending(component: "\(certificateID)-cert.pub").path()
}
}

View File

@@ -0,0 +1,30 @@
import Foundation
import CryptoKit
import CertificateKit
import Formatters
/// Generates OpenSSH representations of Certificates.
public struct OpenSSHCertificateWriter: Sendable {
/// Initializes the writer.
public init() {
}
/// Generates an OpenSSH data payload identifying the certificate.
/// - Returns: OpenSSH data payload identifying the certificate.
public func data(publicKey: OpenSSHCertificate.PublicKey) -> Data {
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
publicKey.keyType.lengthAndData +
publicKey.curveName.lengthAndData +
publicKey.data.lengthAndData
}
/// Generates an OpenSSH SHA256 fingerprint string.
/// - Returns: OpenSSH SHA256 fingerprint string.
public func openSSHSHA256KeyFingerprint(publicKey: OpenSSHCertificate.PublicKey) -> String {
// OpenSSL format seems to strip the padding at the end.
let cleaned = SHA256.hash(data: data(publicKey: publicKey)).formatted(.base64(stripPadding: true))
return "SHA256:\(cleaned)"
}
}

View File

@@ -1,39 +1,5 @@
import Foundation
import OSLog
import CryptoKit
public struct OpenSSHCertificate: Sendable, Codable, Equatable, Hashable, Identifiable, CustomDebugStringConvertible {
public var id: String { Insecure.MD5.hash(data: data).formatted(.hex(separator: "")) }
public var type: CertificateType
public var name: String
public let data: Data
public var publicKey: Data
public var principals: [String]
public var keyID: String
public var serial: UInt64
public var validityRange: Range<Date>?
public var debugDescription: String {
"OpenSSH Certificate \(name, default: "Unnamed"): \(data.formatted(.hex()))"
}
}
extension OpenSSHCertificate {
public enum CertificateType: String, Sendable, Codable {
case ecdsa256 = "ecdsa-sha2-nistp256-cert-v01@openssh.com"
case ecdsa384 = "ecdsa-sha2-nistp384-cert-v01@openssh.com"
case nistp521 = "ecdsa-sha2-nistp521-cert-v01@openssh.com"
var keyIdentifier: String {
rawValue.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
}
}
}
import CertificateKit
public protocol OpenSSHCertificateParserProtocol {
func parse(data: Data) async throws -> OpenSSHCertificate
@@ -41,8 +7,6 @@ public protocol OpenSSHCertificateParserProtocol {
public struct OpenSSHCertificateParser: OpenSSHCertificateParserProtocol, Sendable {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "OpenSSHCertificateParser")
public init() {
assert(Bundle.main.bundleURL.pathExtension == "xpc" || ProcessInfo.processInfo.processName == "xctest", "Potentially unsafe parsing code should run in an XPC service")
}
@@ -64,10 +28,12 @@ public struct OpenSSHCertificateParser: OpenSSHCertificateParserProtocol, Sendab
let comment = elements.first
do {
let dataParser = OpenSSHReader(data: decoded)
_ = try dataParser.readNextChunkAsString() // Redundant key type
let publicKeyType = try dataParser.readNextChunkAsString() // Theoretically the same as typeString, but
.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
_ = try dataParser.readNextChunk() // Nonce
_ = try dataParser.readNextChunkAsString() // curve
let publicKey = try dataParser.readNextChunk()
let publicKeyCurveName = try dataParser.readNextChunkAsString()
let publicKeyData = try dataParser.readNextChunk()
let publicKey = OpenSSHCertificate.PublicKey(keyType: publicKeyType, curveName: publicKeyCurveName, data: publicKeyData)
let serialNumber = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true)
let role = try dataParser.readNextBytes(as: UInt32.self, convertEndianness: true)
_ = role
@@ -80,6 +46,28 @@ public struct OpenSSHCertificateParser: OpenSSHCertificateParserProtocol, Sendab
let validAfter = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true)
let validBefore = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true)
let validityRange = Date(timeIntervalSince1970: TimeInterval(validAfter))..<Date(timeIntervalSince1970: TimeInterval(validBefore))
let criticalOptionsReader = try dataParser.readNextChunkAsSubReader()
var criticalOptions: [String] = []
while !criticalOptionsReader.done {
let next = try criticalOptionsReader.readNextChunkAsString()
if !next.isEmpty {
criticalOptions.append(next)
}
}
let extensionsReader = try dataParser.readNextChunkAsSubReader()
var extensions: [String] = []
while !extensionsReader.done {
let next = try extensionsReader.readNextChunkAsString()
if !next.isEmpty {
extensions.append(next)
}
}
_ = try dataParser.readNextChunk() // reserved
let signingKeyReader = try dataParser.readNextChunkAsSubReader()
let signingKeyType = try signingKeyReader.readNextChunkAsString()
let signingKeyCurveName = try signingKeyReader.readNextChunkAsString()
let signingKeyData = try signingKeyReader.readNextChunk()
let signingKey = OpenSSHCertificate.PublicKey(keyType: signingKeyType, curveName: signingKeyCurveName, data: signingKeyData)
return OpenSSHCertificate(
type: type,
@@ -89,7 +77,10 @@ public struct OpenSSHCertificateParser: OpenSSHCertificateParserProtocol, Sendab
principals: principals,
keyID: keyIdentifier,
serial: serialNumber,
validityRange: validityRange
validityRange: validityRange,
criticalOptions: criticalOptions,
extensions: extensions,
signingKey: signingKey,
)
} catch {
throw .parsingFailed

View File

@@ -1,6 +1,7 @@
import Foundation
import OSLog
import SecretKit
import CertificateKit
public protocol SSHAgentInputParserProtocol {

View File

@@ -2,6 +2,7 @@ import Foundation
import OSLog
import SecretKit
import SSHProtocolKit
import CertificateKit
import Common
/// Controller responsible for writing public keys to disk, so that they're easily accessible by scripts.
@@ -48,10 +49,10 @@ public final class PublicKeyFileStoreController: Sendable {
/// 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 {
public func generateCertificates(for certificates: [Certificate], clear: Bool = false) throws {
logger.log("Writing certificates to disk")
if clear {
let validPaths = Set(certificates.map { URL.certificatePath(for: $0, in: certificatesURL) })
let validPaths = Set(certificates.map { URL.certificatePath(for: $0.id, in: certificatesURL) })
let contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: certificatesURL.path())) ?? []
let fullPathContents = contentsOfDirectory.map { certificatesURL.appending(path: $0).path() }
@@ -64,8 +65,8 @@ public final class PublicKeyFileStoreController: Sendable {
}
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)
let path = URL.certificatePath(for: certificate.id, in: certificatesURL)
FileManager.default.createFile(atPath: path, contents: certificate.rawData, attributes: nil)
}
logger.log("Finished writing certificates")
}
@@ -76,7 +77,7 @@ public final class PublicKeyFileStoreController: Sendable {
/// - Warning: This method returning a path does not imply that a key has a SSH certificates. This method only describes where it will be.
private func legacySSHCertificatePath<SecretType: Secret>(for secret: SecretType) -> String {
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
return publicKeysURL.appending(component: "\(minimalHex)-cert.pub").path()
return publicKeysURL.appending(component: "\(minimalHex).pub").path()
}
}

View File

@@ -9,16 +9,23 @@ import CertificateKit
public struct CertificateMigrator {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CertificateKitMigrator")
private let directory: URL
private let publicKeysDirectory: URL
private let certificatesDirectory: URL
private let certificateStore: CertificateStore
/// Initializes a PublicKeyFileStoreController.
public init(homeDirectory: URL, certificateStore: CertificateStore) {
directory = homeDirectory.appending(component: "PublicKeys")
publicKeysDirectory = homeDirectory.appending(component: "PublicKeys")
certificatesDirectory = homeDirectory.appending(component: "Certificates")
self.certificateStore = certificateStore
}
@MainActor public func migrate() throws {
try migrate(directory: publicKeysDirectory)
try migrate(directory: certificatesDirectory)
}
@MainActor public func migrate(directory: URL) throws {
let fileCerts = try FileManager.default
.contentsOfDirectory(atPath: directory.path())
.filter { $0.hasSuffix("-cert.pub") }
@@ -29,7 +36,7 @@ public struct CertificateMigrator {
let data = try Data(contentsOf: url)
let parser = try await XPCCertificateParser()
let cert = try await parser.parse(data: data)
try certificateStore.save(certificate: cert, originalData: data)
try certificateStore.save(certificate: Certificate(openSSHCertificate: cert, rawData: data))
do {
try FileManager.default.removeItem(at: url)
} catch {

View File

@@ -1,6 +1,7 @@
import Foundation
import OSLog
import SSHProtocolKit
import CertificateKit
import XPCWrappers
/// Delegates all agent input parsing to an XPC service which wraps OpenSSH