mirror of
https://github.com/maxgoedjen/secretive.git
synced 2026-05-07 16:08:58 +02:00
Certificate UI/Import (#798)
* Sketching out. * WIP * WIP * Dump * Apply stash * Merge + WIP * UI * More WIP * Agent config * UI cleanup * Restore dirty files * XPC * Edit/delete * UI fixes * Cleanup * Change id for OpenSSHCertificate to hex of md5 * Fix runtime warning for confirmation dialog * Mark strings as reviewed * Cleanup * Fix agent tests
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
152
Sources/Packages/Sources/CertificateKit/CertificateStore.swift
Normal file
152
Sources/Packages/Sources/CertificateKit/CertificateStore.swift
Normal file
@@ -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")
|
||||
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -28,6 +28,7 @@ extension FormatStyle where Self == HexDataStyle<Data> {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension FormatStyle where Self == HexDataStyle<Insecure.MD5Digest> {
|
||||
|
||||
public static func hex(separator: String = ":") -> HexDataStyle<Insecure.MD5Digest> {
|
||||
@@ -35,3 +36,39 @@ extension FormatStyle where Self == HexDataStyle<Insecure.MD5Digest> {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public struct Base64DataStyle<SequenceType: Sequence>: 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)..<base64.endIndex
|
||||
return base64.replacingOccurrences(of: "=", with: "", range: paddingRange)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension FormatStyle where Self == Base64DataStyle<Data> {
|
||||
|
||||
public static func base64(stripPadding: Bool) -> Base64DataStyle<Data> {
|
||||
Base64DataStyle(stripPadding: stripPadding)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension FormatStyle where Self == Base64DataStyle<SHA256.Digest> {
|
||||
|
||||
public static func base64(stripPadding: Bool) -> Base64DataStyle<SHA256.Digest> {
|
||||
Base64DataStyle(stripPadding: stripPadding)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
104
Sources/Packages/Sources/SSHProtocolKit/OpenSSHCertificate.swift
Normal file
104
Sources/Packages/Sources/SSHProtocolKit/OpenSSHCertificate.swift
Normal file
@@ -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<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 {
|
||||
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))..<Date(timeIntervalSince1970: TimeInterval(validBefore))
|
||||
|
||||
return OpenSSHCertificate(
|
||||
type: type,
|
||||
name: comment ?? keyIdentifier,
|
||||
data: decoded,
|
||||
publicKey: publicKey,
|
||||
principals: principals,
|
||||
keyID: keyIdentifier,
|
||||
serial: serialNumber,
|
||||
validityRange: validityRange
|
||||
)
|
||||
} catch {
|
||||
throw .parsingFailed
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public enum OpenSSHCertificateError: Error, Codable {
|
||||
case unsupportedType
|
||||
case parsingFailed
|
||||
}
|
||||
@@ -41,9 +41,7 @@ public struct OpenSSHPublicKeyWriter: Sendable {
|
||||
/// - Returns: OpenSSH SHA256 fingerprint string.
|
||||
public func openSSHSHA256Fingerprint<SecretType: Secret>(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)..<base64.endIndex
|
||||
let cleaned = base64.replacingOccurrences(of: "=", with: "", range: paddingRange)
|
||||
let cleaned = SHA256.hash(data: data(secret: secret)).formatted(.base64(stripPadding: true))
|
||||
return "SHA256:\(cleaned)"
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ public final class OpenSSHReader {
|
||||
/// - Parameter data: The data to read.
|
||||
public init(data: Data) {
|
||||
remaining = Data(data)
|
||||
if remaining.count == 0 {
|
||||
done = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the next chunk of data from the playload.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SecretKit
|
||||
import SSHProtocolKit
|
||||
|
||||
public protocol SSHAgentInputParserProtocol {
|
||||
|
||||
@@ -14,7 +13,7 @@ public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
|
||||
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "InputParser")
|
||||
|
||||
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(AgentParsingError) -> 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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<SecretType: Secret>(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<SecretType: Secret>(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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<SecretType: Secret>(for secret: SecretType) -> String {
|
||||
private func legacySSHCertificatePath<SecretType: Secret>(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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<OpenSSHCertificate, OpenSSHCertificateError>
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user