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

@@ -22,9 +22,15 @@ let package = Package(
.library( .library(
name: "SmartCardSecretKit", name: "SmartCardSecretKit",
targets: ["SmartCardSecretKit"]), targets: ["SmartCardSecretKit"]),
.library(
name: "CertificateKit",
targets: ["CertificateKit"]),
.library( .library(
name: "SSHProtocolKit", name: "SSHProtocolKit",
targets: ["SSHProtocolKit"]), targets: ["SSHProtocolKit"]),
.library(
name: "Formatters",
targets: ["Formatters"]),
], ],
dependencies: [ dependencies: [
], ],
@@ -56,9 +62,16 @@ let package = Package(
resources: [localization], resources: [localization],
swiftSettings: swiftSettings swiftSettings: swiftSettings
), ),
.target(
name: "CertificateKit",
dependencies: ["SecretKit", "Formatters"],
path: "Sources/Packages/Sources/CertificateKit",
resources: [localization],
swiftSettings: swiftSettings,
),
.target( .target(
name: "SSHProtocolKit", name: "SSHProtocolKit",
dependencies: ["SecretKit"], dependencies: ["SecretKit", "CertificateKit"],
path: "Sources/Packages/Sources/SSHProtocolKit", path: "Sources/Packages/Sources/SSHProtocolKit",
resources: [localization], resources: [localization],
swiftSettings: swiftSettings, swiftSettings: swiftSettings,
@@ -69,6 +82,13 @@ let package = Package(
path: "Sources/Packages/Tests/SSHProtocolKitTests", path: "Sources/Packages/Tests/SSHProtocolKitTests",
swiftSettings: swiftSettings, swiftSettings: swiftSettings,
), ),
.target(
name: "Formatters",
dependencies: [],
path: "Sources/Packages/Sources/Formatters",
resources: [localization],
swiftSettings: swiftSettings,
),
] ]
) )

View File

@@ -25,6 +25,9 @@ let package = Package(
.library( .library(
name: "SecretAgentKit", name: "SecretAgentKit",
targets: ["SecretAgentKit"]), targets: ["SecretAgentKit"]),
.library(
name: "Formatters",
targets: ["Formatters"]),
.library( .library(
name: "Common", name: "Common",
targets: ["Common"]), targets: ["Common"]),
@@ -69,13 +72,13 @@ let package = Package(
), ),
.target( .target(
name: "CertificateKit", name: "CertificateKit",
dependencies: ["SecretKit", "SSHProtocolKit"], dependencies: ["SecretKit", "Formatters"],
resources: [localization], resources: [localization],
swiftSettings: swiftSettings, swiftSettings: swiftSettings,
), ),
.target( .target(
name: "SecretAgentKit", name: "SecretAgentKit",
dependencies: ["SecretKit", "SSHProtocolKit", "CertificateKit", "Common"], dependencies: ["SecretKit", "SSHProtocolKit", "CertificateKit", "Common", "Formatters"],
resources: [localization], resources: [localization],
swiftSettings: swiftSettings, swiftSettings: swiftSettings,
), ),
@@ -85,7 +88,7 @@ let package = Package(
), ),
.target( .target(
name: "SSHProtocolKit", name: "SSHProtocolKit",
dependencies: ["SecretKit"], dependencies: ["SecretKit", "CertificateKit"],
resources: [localization], resources: [localization],
swiftSettings: swiftSettings, swiftSettings: swiftSettings,
), ),
@@ -94,6 +97,12 @@ let package = Package(
dependencies: ["SSHProtocolKit"], dependencies: ["SSHProtocolKit"],
swiftSettings: swiftSettings, swiftSettings: swiftSettings,
), ),
.target(
name: "Formatters",
dependencies: [],
resources: [localization],
swiftSettings: swiftSettings,
),
.target( .target(
name: "Common", name: "Common",
dependencies: ["SSHProtocolKit", "SecretKit"], dependencies: ["SSHProtocolKit", "SecretKit"],
@@ -102,7 +111,7 @@ let package = Package(
), ),
.target( .target(
name: "SharedXPCServices", name: "SharedXPCServices",
dependencies: ["CertificateKit"], dependencies: ["CertificateKit", "SSHProtocolKit"],
resources: [localization], resources: [localization],
swiftSettings: swiftSettings, 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" : { "certificate_detail_key_id_label" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "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" : { "certificate_detail_valid_after_label" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "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 Security
import os import os
import SecretKit import SecretKit
import SSHProtocolKit
@Observable @MainActor public final class CertificateStore: Sendable { @Observable @MainActor public final class CertificateStore: Sendable {
public private(set) var certificates: [OpenSSHCertificate] = [] public private(set) var certificates: [Certificate] = []
/// Initializes a Store. /// Initializes a Store.
public init() { public init() {
@@ -33,15 +32,15 @@ import SSHProtocolKit
} }
} }
public func save(certificate: OpenSSHCertificate, originalData: Data) throws { public func save(certificate: Certificate) throws {
let attributes = try JSONEncoder().encode(certificate) let attributes = try JSONEncoder().encode(certificate.openSSHCertificate)
let keychainAttributes = KeychainDictionary([ let keychainAttributes = KeychainDictionary([
kSecClass: Constants.keyClass, kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag, kSecAttrService: Constants.keyTag,
kSecAttrAccount: certificate.id, kSecAttrAccount: certificate.id,
kSecUseDataProtectionKeychain: true, kSecUseDataProtectionKeychain: true,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
kSecValueData: originalData, kSecValueData: certificate.rawData,
kSecAttrGeneric: attributes kSecAttrGeneric: attributes
]) ])
let status = SecItemAdd(keychainAttributes, nil) let status = SecItemAdd(keychainAttributes, nil)
@@ -51,7 +50,7 @@ import SSHProtocolKit
reloadCertificates() reloadCertificates()
} }
public func delete(certificate: OpenSSHCertificate) throws { public func delete(certificate: Certificate) throws {
let deleteAttributes = KeychainDictionary([ let deleteAttributes = KeychainDictionary([
kSecClass: Constants.keyClass, kSecClass: Constants.keyClass,
kSecAttrService: Constants.keyTag, kSecAttrService: Constants.keyTag,
@@ -65,13 +64,13 @@ import SSHProtocolKit
reloadCertificates() reloadCertificates()
} }
public func update(certificate: OpenSSHCertificate) throws { public func update(certificate: Certificate) throws {
let updateQuery = KeychainDictionary([ let updateQuery = KeychainDictionary([
kSecClass: Constants.keyClass, kSecClass: Constants.keyClass,
kSecAttrAccount: certificate.id, kSecAttrAccount: certificate.id,
]) ])
let cert = try JSONEncoder().encode(certificate) let cert = try JSONEncoder().encode(certificate.openSSHCertificate)
let updatedAttributes = KeychainDictionary([ let updatedAttributes = KeychainDictionary([
kSecAttrGeneric: cert, kSecAttrGeneric: cert,
]) ])
@@ -83,8 +82,8 @@ import SSHProtocolKit
reloadCertificates() reloadCertificates()
} }
public func certificates(for secret: any Secret) -> [OpenSSHCertificate] { public func certificates(for secret: any Secret) -> [Certificate] {
certificates.filter { $0.publicKey == secret.publicKey } certificates.filter { $0.openSSHCertificate.publicKey.data == secret.publicKey }
} }
@@ -106,12 +105,13 @@ extension CertificateStore {
unsafe SecItemCopyMatching(queryAttributes, &untyped) unsafe SecItemCopyMatching(queryAttributes, &untyped)
guard let typed = untyped as? [[CFString: Any]] else { return } guard let typed = untyped as? [[CFString: Any]] else { return }
let decoder = JSONDecoder() let decoder = JSONDecoder()
let wrapped: [OpenSSHCertificate] = typed.compactMap { let wrapped: [Certificate] = typed.compactMap {
do { 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() throw MissingAttributesError()
} }
return try decoder.decode(OpenSSHCertificate.self, from: attributesData) return Certificate(openSSHCertificate: try decoder.decode(OpenSSHCertificate.self, from: attributesData), rawData: data)
} catch { } catch {
return nil return nil
} }
@@ -123,6 +123,7 @@ extension CertificateStore {
true true
} }
} }
certificates.append(contentsOf: wrapped) 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 Foundation
import SSHProtocolKit import SSHProtocolKit
import CertificateKit
import SecretKit import SecretKit
extension URL { extension URL {
@@ -35,11 +36,11 @@ extension URL {
} }
/// The path for a certificate. /// The path for a certificate.
/// - Parameter certificate: The OpenSSHCertificate to return the path for. /// - Parameter certificate: The Certificate to return the path for.
/// - Returns: The path to the OpenSSHCertificate. /// - 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. /// - 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 { public static func certificatePath(for certificateID: String, in directory: URL) -> String {
return directory.appending(component: "\(certificate.id)-cert.pub").path() 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 Foundation
import OSLog import CertificateKit
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: "")
}
}
}
public protocol OpenSSHCertificateParserProtocol { public protocol OpenSSHCertificateParserProtocol {
func parse(data: Data) async throws -> OpenSSHCertificate func parse(data: Data) async throws -> OpenSSHCertificate
@@ -41,8 +7,6 @@ public protocol OpenSSHCertificateParserProtocol {
public struct OpenSSHCertificateParser: OpenSSHCertificateParserProtocol, Sendable { public struct OpenSSHCertificateParser: OpenSSHCertificateParserProtocol, Sendable {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive", category: "OpenSSHCertificateParser")
public init() { public init() {
assert(Bundle.main.bundleURL.pathExtension == "xpc" || ProcessInfo.processInfo.processName == "xctest", "Potentially unsafe parsing code should run in an XPC service") 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 let comment = elements.first
do { do {
let dataParser = OpenSSHReader(data: decoded) 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.readNextChunk() // Nonce
_ = try dataParser.readNextChunkAsString() // curve let publicKeyCurveName = try dataParser.readNextChunkAsString()
let publicKey = try dataParser.readNextChunk() 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 serialNumber = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true)
let role = try dataParser.readNextBytes(as: UInt32.self, convertEndianness: true) let role = try dataParser.readNextBytes(as: UInt32.self, convertEndianness: true)
_ = role _ = role
@@ -80,6 +46,28 @@ public struct OpenSSHCertificateParser: OpenSSHCertificateParserProtocol, Sendab
let validAfter = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true) let validAfter = try dataParser.readNextBytes(as: UInt64.self, convertEndianness: true)
let validBefore = 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 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( return OpenSSHCertificate(
type: type, type: type,
@@ -89,7 +77,10 @@ public struct OpenSSHCertificateParser: OpenSSHCertificateParserProtocol, Sendab
principals: principals, principals: principals,
keyID: keyIdentifier, keyID: keyIdentifier,
serial: serialNumber, serial: serialNumber,
validityRange: validityRange validityRange: validityRange,
criticalOptions: criticalOptions,
extensions: extensions,
signingKey: signingKey,
) )
} catch { } catch {
throw .parsingFailed throw .parsingFailed

View File

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

View File

@@ -2,6 +2,7 @@ import Foundation
import OSLog import OSLog
import SecretKit import SecretKit
import SSHProtocolKit import SSHProtocolKit
import CertificateKit
import Common import Common
/// Controller responsible for writing public keys to disk, so that they're easily accessible by scripts. /// 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. /// Writes out the certificates specified to disk.
/// - Parameter certificates: The Secrets to generate keys for. /// - Parameter certificates: The Secrets to generate keys for.
/// - Parameter clear: Whether or not any untracked files in the directory should be removed. /// - 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") logger.log("Writing certificates to disk")
if clear { 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 contentsOfDirectory = (try? FileManager.default.contentsOfDirectory(atPath: certificatesURL.path())) ?? []
let fullPathContents = contentsOfDirectory.map { certificatesURL.appending(path: $0).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) try? FileManager.default.createDirectory(at: certificatesURL, withIntermediateDirectories: false, attributes: nil)
for certificate in certificates { for certificate in certificates {
let path = URL.certificatePath(for: certificate, in: certificatesURL) let path = URL.certificatePath(for: certificate.id, in: certificatesURL)
FileManager.default.createFile(atPath: path, contents: certificate.data, attributes: nil) FileManager.default.createFile(atPath: path, contents: certificate.rawData, attributes: nil)
} }
logger.log("Finished writing certificates") 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. /// - 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 { private func legacySSHCertificatePath<SecretType: Secret>(for secret: SecretType) -> String {
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "") 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 { public struct CertificateMigrator {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.migration", category: "CertificateKitMigrator") 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 private let certificateStore: CertificateStore
/// Initializes a PublicKeyFileStoreController. /// Initializes a PublicKeyFileStoreController.
public init(homeDirectory: URL, certificateStore: CertificateStore) { public init(homeDirectory: URL, certificateStore: CertificateStore) {
directory = homeDirectory.appending(component: "PublicKeys") publicKeysDirectory = homeDirectory.appending(component: "PublicKeys")
certificatesDirectory = homeDirectory.appending(component: "Certificates")
self.certificateStore = certificateStore self.certificateStore = certificateStore
} }
@MainActor public func migrate() throws { @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 let fileCerts = try FileManager.default
.contentsOfDirectory(atPath: directory.path()) .contentsOfDirectory(atPath: directory.path())
.filter { $0.hasSuffix("-cert.pub") } .filter { $0.hasSuffix("-cert.pub") }
@@ -29,7 +36,7 @@ public struct CertificateMigrator {
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
let parser = try await XPCCertificateParser() let parser = try await XPCCertificateParser()
let cert = try await parser.parse(data: data) 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 { do {
try FileManager.default.removeItem(at: url) try FileManager.default.removeItem(at: url)
} catch { } catch {

View File

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

View File

@@ -30,7 +30,7 @@ public struct CertificateMigrator {
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
let parser = try await XPCCertificateParser() let parser = try await XPCCertificateParser()
let cert = try await parser.parse(data: data) 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 { do {
try FileManager.default.removeItem(at: url) try FileManager.default.removeItem(at: url)
} catch { } catch {

View File

@@ -86,6 +86,9 @@
50E205802FAB291E00402380 /* CertificateMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E2057F2FAB291E00402380 /* CertificateMigrator.swift */; }; 50E205802FAB291E00402380 /* CertificateMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E2057F2FAB291E00402380 /* CertificateMigrator.swift */; };
50E205822FAB293B00402380 /* SharedXPCServices in Frameworks */ = {isa = PBXBuildFile; productRef = 50E205812FAB293B00402380 /* SharedXPCServices */; }; 50E205822FAB293B00402380 /* SharedXPCServices in Frameworks */ = {isa = PBXBuildFile; productRef = 50E205812FAB293B00402380 /* SharedXPCServices */; };
50E205842FAB296A00402380 /* SharedXPCServices in Frameworks */ = {isa = PBXBuildFile; productRef = 50E205832FAB296A00402380 /* SharedXPCServices */; }; 50E205842FAB296A00402380 /* SharedXPCServices in Frameworks */ = {isa = PBXBuildFile; productRef = 50E205832FAB296A00402380 /* SharedXPCServices */; };
50E205862FAC2EA000402380 /* Formatters in Frameworks */ = {isa = PBXBuildFile; productRef = 50E205852FAC2EA000402380 /* Formatters */; };
50E205882FAC2EAB00402380 /* Formatters in Frameworks */ = {isa = PBXBuildFile; productRef = 50E205872FAC2EAB00402380 /* Formatters */; };
50E2058A2FAC2EB600402380 /* Formatters in Frameworks */ = {isa = PBXBuildFile; productRef = 50E205892FAC2EB600402380 /* Formatters */; };
50E4C4532E73C78C00C73783 /* WindowBackgroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E4C4522E73C78900C73783 /* WindowBackgroundStyle.swift */; }; 50E4C4532E73C78C00C73783 /* WindowBackgroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E4C4522E73C78900C73783 /* WindowBackgroundStyle.swift */; };
50E4C4C32E7765DF00C73783 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E4C4C22E7765DF00C73783 /* AboutView.swift */; }; 50E4C4C32E7765DF00C73783 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E4C4C22E7765DF00C73783 /* AboutView.swift */; };
50E4C4C82E777E4200C73783 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 50E4C4C72E777E4200C73783 /* AppIcon.icon */; }; 50E4C4C82E777E4200C73783 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 50E4C4C72E777E4200C73783 /* AppIcon.icon */; };
@@ -292,6 +295,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
50E0145C2EDB9CDF00B121F1 /* Common in Frameworks */, 50E0145C2EDB9CDF00B121F1 /* Common in Frameworks */,
50E2058A2FAC2EB600402380 /* Formatters in Frameworks */,
5003EF3B278005E800DF2006 /* SecretKit in Frameworks */, 5003EF3B278005E800DF2006 /* SecretKit in Frameworks */,
501421622781262300BBAA70 /* Brief in Frameworks */, 501421622781262300BBAA70 /* Brief in Frameworks */,
50E205842FAB296A00402380 /* SharedXPCServices in Frameworks */, 50E205842FAB296A00402380 /* SharedXPCServices in Frameworks */,
@@ -305,6 +309,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
50E205862FAC2EA000402380 /* Formatters in Frameworks */,
50692D2D2E6FDC000043C7BB /* XPCWrappers in Frameworks */, 50692D2D2E6FDC000043C7BB /* XPCWrappers in Frameworks */,
50692D312E6FDC390043C7BB /* Brief in Frameworks */, 50692D312E6FDC390043C7BB /* Brief in Frameworks */,
); );
@@ -337,6 +342,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
50E205882FAC2EAB00402380 /* Formatters in Frameworks */,
50E205332FAAB95A00402380 /* SSHProtocolKit in Frameworks */, 50E205332FAAB95A00402380 /* SSHProtocolKit in Frameworks */,
50E205312FAAB95500402380 /* XPCWrappers in Frameworks */, 50E205312FAAB95500402380 /* XPCWrappers in Frameworks */,
); );
@@ -583,6 +589,7 @@
50E0145B2EDB9CDF00B121F1 /* Common */, 50E0145B2EDB9CDF00B121F1 /* Common */,
505F5EF12FA9635700C45824 /* CertificateKit */, 505F5EF12FA9635700C45824 /* CertificateKit */,
50E205832FAB296A00402380 /* SharedXPCServices */, 50E205832FAB296A00402380 /* SharedXPCServices */,
50E205892FAC2EB600402380 /* Formatters */,
); );
productName = Secretive; productName = Secretive;
productReference = 50617D7F23FCE48E0099B055 /* Secretive.app */; productReference = 50617D7F23FCE48E0099B055 /* Secretive.app */;
@@ -604,6 +611,7 @@
packageProductDependencies = ( packageProductDependencies = (
50692D2C2E6FDC000043C7BB /* XPCWrappers */, 50692D2C2E6FDC000043C7BB /* XPCWrappers */,
50692D302E6FDC390043C7BB /* Brief */, 50692D302E6FDC390043C7BB /* Brief */,
50E205852FAC2EA000402380 /* Formatters */,
); );
productName = SecretiveUpdater; productName = SecretiveUpdater;
productReference = 50692D122E6FDB880043C7BB /* SecretiveUpdater.xpc */; productReference = 50692D122E6FDB880043C7BB /* SecretiveUpdater.xpc */;
@@ -678,6 +686,7 @@
packageProductDependencies = ( packageProductDependencies = (
50E205302FAAB95500402380 /* XPCWrappers */, 50E205302FAAB95500402380 /* XPCWrappers */,
50E205322FAAB95A00402380 /* SSHProtocolKit */, 50E205322FAAB95A00402380 /* SSHProtocolKit */,
50E205872FAC2EAB00402380 /* Formatters */,
); );
productName = SecretAgentCertificateParser; productName = SecretAgentCertificateParser;
productReference = 50E205142FAAB81C00402380 /* SecretiveCertificateParser.xpc */; productReference = 50E205142FAAB81C00402380 /* SecretiveCertificateParser.xpc */;
@@ -1857,6 +1866,18 @@
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = SharedXPCServices; productName = SharedXPCServices;
}; };
50E205852FAC2EA000402380 /* Formatters */ = {
isa = XCSwiftPackageProductDependency;
productName = Formatters;
};
50E205872FAC2EAB00402380 /* Formatters */ = {
isa = XCSwiftPackageProductDependency;
productName = Formatters;
};
50E205892FAC2EB600402380 /* Formatters */ = {
isa = XCSwiftPackageProductDependency;
productName = Formatters;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = 50617D7723FCE48D0099B055 /* Project object */; rootObject = 50617D7723FCE48D0099B055 /* Project object */;

View File

@@ -1,11 +1,12 @@
import SwiftUI import SwiftUI
import SecretKit import SecretKit
import Common import Common
import CertificateKit
import SSHProtocolKit import SSHProtocolKit
import CryptoKit
struct CertificateDetailView: View { struct CertificateDetailView: View {
let certificate: OpenSSHCertificate let certificate: Certificate
var body: some View { var body: some View {
ScrollView { ScrollView {
@@ -25,6 +26,26 @@ struct CertificateDetailView: View {
) )
Spacer() Spacer()
.frame(height: 20) .frame(height: 20)
CopyableView(
title: .secretDetailSha256FingerprintLabel,
image: Image(systemName: "touchid"),
text: OpenSSHCertificateWriter().openSSHSHA256KeyFingerprint(publicKey: certificate.publicKey)
)
Spacer()
.frame(height: 20)
CopyableView(
title: .secretDetailSha256FingerprintLabel,
image: Image(systemName: "touchid"),
text: OpenSSHCertificateWriter().openSSHSHA256KeyFingerprint(publicKey: certificate.signingKey)
)
Spacer()
.frame(height: 20)
CopyableView(
title: .certificateDetailPathLabel,
image: Image(systemName: "checkmark.seal.text.page"),
text: URL.certificatePath(for: certificate.id, in: URL.certificatesDirectory),
showRevealInFinder: true
)
if let validityRange = certificate.validityRange { if let validityRange = certificate.validityRange {
let epoch = Date(timeIntervalSince1970: 0) let epoch = Date(timeIntervalSince1970: 0)
let end = Date(timeIntervalSince1970: TimeInterval(UInt64.max)) let end = Date(timeIntervalSince1970: TimeInterval(UInt64.max))
@@ -32,30 +53,34 @@ struct CertificateDetailView: View {
case (epoch, end): case (epoch, end):
EmptyView() EmptyView()
case (epoch, let otherEnd): case (epoch, let otherEnd):
Spacer()
.frame(height: 20)
MultilineInfoView(title: .certificateDetailValidUntilLabel, image: Image(systemName: "calendar.badge.clock"), items: [otherEnd.formatted()]) MultilineInfoView(title: .certificateDetailValidUntilLabel, image: Image(systemName: "calendar.badge.clock"), items: [otherEnd.formatted()])
Spacer()
.frame(height: 20)
case (let otherStart, end): case (let otherStart, end):
Spacer()
.frame(height: 20)
MultilineInfoView(title: .certificateDetailValidAfterLabel, image: Image(systemName: "calendar.badge.clock"), items: [otherStart.formatted()]) MultilineInfoView(title: .certificateDetailValidAfterLabel, image: Image(systemName: "calendar.badge.clock"), items: [otherStart.formatted()])
Spacer()
.frame(height: 20)
default: default:
MultilineInfoView(title: .certificateDetailValidityRangeLabel, image: Image(systemName: "calendar.badge.clock"), items: [validityRange.formatted()])
Spacer() Spacer()
.frame(height: 20) .frame(height: 20)
MultilineInfoView(title: .certificateDetailValidityRangeLabel, image: Image(systemName: "calendar.badge.clock"), items: [validityRange.formatted()])
} }
} }
if !certificate.principals.isEmpty { if !certificate.principals.isEmpty {
MultilineInfoView(title: .certificateDetailPrincipalsLabel, image: Image(systemName: "person.2"), items: certificate.principals)
Spacer() Spacer()
.frame(height: 20) .frame(height: 20)
MultilineInfoView(title: .certificateDetailPrincipalsLabel, image: Image(systemName: "person.2"), items: certificate.principals)
}
if !certificate.criticalOptions.isEmpty {
Spacer()
.frame(height: 20)
MultilineInfoView(title: .certificateDetailCriticalOptionsLabel, image: Image(systemName: "person.2"), items: certificate.criticalOptions)
}
if !certificate.extensions.isEmpty {
Spacer()
.frame(height: 20)
MultilineInfoView(title: .certificateDetailExtensionsLabel, image: Image(systemName: "person.2"), items: certificate.extensions)
} }
CopyableView(
title: .certificateDetailPathLabel,
image: Image(systemName: "checkmark.seal.text.page"),
text: URL.certificatePath(for: certificate, in: URL.certificatesDirectory),
showRevealInFinder: true
)
Spacer() Spacer()
} }
} }

View File

@@ -6,13 +6,13 @@ struct CertificateListItemView: View {
@Environment(\.certificateStore) private var store @Environment(\.certificateStore) private var store
var certificate: OpenSSHCertificate var certificate: Certificate
@State var isDeleting: Bool = false @State var isDeleting: Bool = false
@State var isRenaming: Bool = false @State var isRenaming: Bool = false
var deletedCertificate: (OpenSSHCertificate) -> Void var deletedCertificate: (Certificate) -> Void
var renamedCertificate: (OpenSSHCertificate) -> Void var renamedCertificate: (Certificate) -> Void
var body: some View { var body: some View {
NavigationLink(value: certificate) { NavigationLink(value: certificate) {

View File

@@ -4,7 +4,7 @@ import SSHProtocolKit
extension View { extension View {
func showingDeleteConfirmation(isPresented: Binding<Bool>, _ certificate: OpenSSHCertificate, _ store: CertificateStore, dismissalBlock: @escaping (Bool) -> ()) -> some View { func showingDeleteConfirmation(isPresented: Binding<Bool>, _ certificate: Certificate, _ store: CertificateStore, dismissalBlock: @escaping (Bool) -> ()) -> some View {
modifier(DeleteCertificateConfirmationModifier(isPresented: isPresented, certificate: certificate, store: store, dismissalBlock: dismissalBlock)) modifier(DeleteCertificateConfirmationModifier(isPresented: isPresented, certificate: certificate, store: store, dismissalBlock: dismissalBlock))
} }
@@ -13,7 +13,7 @@ extension View {
struct DeleteCertificateConfirmationModifier: ViewModifier { struct DeleteCertificateConfirmationModifier: ViewModifier {
var isPresented: Binding<Bool> var isPresented: Binding<Bool>
var certificate: OpenSSHCertificate var certificate: Certificate
var store: CertificateStore var store: CertificateStore
var dismissalBlock: (Bool) -> () var dismissalBlock: (Bool) -> ()
@State var confirmedSecretName = "" @State var confirmedSecretName = ""

View File

@@ -5,14 +5,14 @@ import CertificateKit
struct EditCertificateView: View { struct EditCertificateView: View {
let store: CertificateStore let store: CertificateStore
let certificate: OpenSSHCertificate let certificate: Certificate
@State private var name: String @State private var name: String
@State var errorText: String? @State var errorText: String?
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
init(store: CertificateStore, certificate: OpenSSHCertificate) { init(store: CertificateStore, certificate: Certificate) {
self.store = store self.store = store
self.certificate = certificate self.certificate = certificate
name = certificate.name name = certificate.name
@@ -49,7 +49,7 @@ struct EditCertificateView: View {
Task { Task {
do { do {
var updated = certificate var updated = certificate
updated.name = name updated.openSSHCertificate.name = name
try store.update(certificate: updated) try store.update(certificate: updated)
dismiss() dismiss()
} catch { } catch {

View File

@@ -1,13 +1,14 @@
import SwiftUI import SwiftUI
import SecretKit import SecretKit
import Common import Common
import CertificateKit
import SSHProtocolKit import SSHProtocolKit
struct SecretDetailView<SecretType: Secret>: View { struct SecretDetailView<SecretType: Secret>: View {
let secret: SecretType let secret: SecretType
let certificates: [OpenSSHCertificate] let certificates: [Certificate]
let navigateToCertificate: ((OpenSSHCertificate) -> Void)? let navigateToCertificate: ((Certificate) -> Void)?
private let keyWriter = OpenSSHPublicKeyWriter() private let keyWriter = OpenSSHPublicKeyWriter()

View File

@@ -1,12 +1,13 @@
import SwiftUI import SwiftUI
import SecretKit import SecretKit
import SSHProtocolKit import SSHProtocolKit
import CertificateKit
struct StoreListView: View { struct StoreListView: View {
enum StoreListSelection: Hashable { enum StoreListSelection: Hashable {
case secret(AnySecret) case secret(AnySecret)
case certificate(OpenSSHCertificate) case certificate(Certificate)
} }
@Binding var selection: StoreListSelection? @Binding var selection: StoreListSelection?

View File

@@ -5,6 +5,7 @@ import SmartCardSecretKit
import Brief import Brief
import SSHProtocolKit import SSHProtocolKit
import SharedXPCServices import SharedXPCServices
import CertificateKit
struct ContentView: View { struct ContentView: View {
@@ -52,8 +53,9 @@ struct ContentView: View {
let data = try Data(contentsOf: url) let data = try Data(contentsOf: url)
let parser = try await XPCCertificateParser() let parser = try await XPCCertificateParser()
let cert = try await parser.parse(data: data) let cert = try await parser.parse(data: data)
try certificateStore.save(certificate: cert, originalData: data) let wrapped = Certificate(openSSHCertificate: cert, rawData: data)
selection = .certificate(cert) try certificateStore.save(certificate: wrapped)
selection = .certificate(wrapped)
} catch { } catch {
} }

View File

@@ -2,6 +2,7 @@ import Foundation
import OSLog import OSLog
import XPCWrappers import XPCWrappers
import SSHProtocolKit import SSHProtocolKit
import CertificateKit
final class SecretiveCertificateParser: NSObject, XPCProtocol { final class SecretiveCertificateParser: NSObject, XPCProtocol {
@@ -10,7 +11,7 @@ final class SecretiveCertificateParser: NSObject, XPCProtocol {
func process(_ data: Data) async throws -> OpenSSHCertificate { func process(_ data: Data) async throws -> OpenSSHCertificate {
let parser = OpenSSHCertificateParser() let parser = OpenSSHCertificateParser()
let result = try parser.parse(data: data) let result = try parser.parse(data: data)
logger.log("Parser parsed certificate \(result.debugDescription)") logger.log("Parser parsed certificate")
return result return result
} }