Compare commits

..

3 Commits

Author SHA1 Message Date
8597d86ee8 Move file 2022-12-22 18:06:58 -05:00
efef3b3412 Toolbar button style 2022-12-22 17:55:53 -05:00
d429d090cf Switch toolbar items to viewbuilders 2022-12-22 17:20:03 -05:00
11 changed files with 113 additions and 173 deletions

View File

@ -4,6 +4,25 @@ import OSLog
import SecretKit import SecretKit
import AppKit import AppKit
enum OpenSSHCertificateError: Error {
case unsupportedType
case parsingFailed
case doesNotExist
}
extension OpenSSHCertificateError: CustomStringConvertible {
public var description: 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"
}
}
}
/// The `Agent` is an implementation of an SSH agent. It manages coordination and access between a socket, traces requests, notifies witnesses and passes requests to stores. /// The `Agent` is an implementation of an SSH agent. It manages coordination and access between a socket, traces requests, notifies witnesses and passes requests to stores.
public class Agent { public class Agent {
@ -11,7 +30,7 @@ public class Agent {
private let witness: SigningWitness? private let witness: SigningWitness?
private let writer = OpenSSHKeyWriter() private let writer = OpenSSHKeyWriter()
private let requestTracer = SigningRequestTracer() private let requestTracer = SigningRequestTracer()
private let certificateHandler = OpenSSHCertificateHandler() private let certsPath = (NSHomeDirectory() as NSString).appendingPathComponent("PublicKeys") as String
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent.agent", category: "") private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent.agent", category: "")
/// Initializes an agent with a store list and a witness. /// Initializes an agent with a store list and a witness.
@ -22,7 +41,6 @@ public class Agent {
logger.debug("Agent is running") logger.debug("Agent is running")
self.storeList = storeList self.storeList = storeList
self.witness = witness self.witness = witness
certificateHandler.reloadCertificates(for: storeList.allSecrets)
} }
} }
@ -84,8 +102,7 @@ extension Agent {
/// Lists the identities available for signing operations /// Lists the identities available for signing operations
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations. /// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
func identities() -> Data { func identities() -> Data {
let secrets = storeList.allSecrets let secrets = storeList.stores.flatMap(\.secrets)
certificateHandler.reloadCertificates(for: secrets)
var count = UInt32(secrets.count).bigEndian var count = UInt32(secrets.count).bigEndian
let countData = Data(bytes: &count, count: UInt32.bitWidth/8) let countData = Data(bytes: &count, count: UInt32.bitWidth/8)
var keyData = Data() var keyData = Data()
@ -94,9 +111,9 @@ extension Agent {
let keyBlob: Data let keyBlob: Data
let curveData: Data let curveData: Data
if let (certificateData, name) = try? certificateHandler.keyBlobAndName(for: secret) { if let (certBlob, certName) = try? checkForCert(secret: secret) {
keyBlob = certificateData keyBlob = certBlob
curveData = name curveData = certName
} else { } else {
keyBlob = writer.data(secret: secret) keyBlob = writer.data(secret: secret)
curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)! curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
@ -117,13 +134,11 @@ extension Agent {
/// - Returns: An OpenSSH formatted Data payload containing the signed data response. /// - Returns: An OpenSSH formatted Data payload containing the signed data response.
func sign(data: Data, provenance: SigningRequestProvenance) throws -> Data { func sign(data: Data, provenance: SigningRequestProvenance) throws -> Data {
let reader = OpenSSHReader(data: data) let reader = OpenSSHReader(data: data)
let payloadHash = reader.readNextChunk() var hash = reader.readNextChunk()
let hash: Data
// Check if hash is actually an openssh certificate and reconstruct the public key if it is // Check if hash is actually an openssh certificate and reconstruct the public key if it is
if let certificatePublicKey = certificateHandler.publicKeyHash(from: payloadHash) { if let certPublicKey = try? getPublicKeyFromCert(certBlob: hash) {
hash = certificatePublicKey hash = certPublicKey
} else {
hash = payloadHash
} }
guard let (store, secret) = secret(matching: hash) else { guard let (store, secret) = secret(matching: hash) else {
@ -185,6 +200,74 @@ extension Agent {
return signedData return signedData
} }
/// Reconstructs a public key from a ``Data`` object that contains an OpenSSH certificate. Currently only ecdsa certificates are supported
/// - Parameter certBlock: The openssh certificate to extract the public key from
/// - Returns: A ``Data`` object containing the public key in OpenSSH wire format
func getPublicKeyFromCert(certBlob: Data) throws -> Data {
let reader = OpenSSHReader(data: certBlob)
let certType = String(decoding: 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":
_ = reader.readNextChunk() // nonce
let curveIdentifier = reader.readNextChunk()
let publicKey = reader.readNextChunk()
if let curveType = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "").data(using: .utf8) {
return writer.lengthAndData(of: curveType) +
writer.lengthAndData(of: curveIdentifier) +
writer.lengthAndData(of: publicKey)
} else {
throw OpenSSHCertificateError.parsingFailed
}
default:
throw OpenSSHCertificateError.unsupportedType
}
}
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``
/// - Parameter secret: The secret to search for a certificate with
/// - Returns: Two ``Data`` objects containing the certificate and certificate name respectively
func checkForCert(secret: AnySecret) throws -> (Data, Data) {
let minimalHex = writer.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
let certificatePath = certsPath.appending("/").appending("\(minimalHex)-cert.pub")
if FileManager.default.fileExists(atPath: certificatePath) {
logger.debug("Found certificate for \(secret.name)")
do {
let certContent = try String(contentsOfFile:certificatePath, encoding: .utf8)
let certElements = certContent.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: " ")
if certElements.count >= 2 {
if let certDecoded = Data(base64Encoded: certElements[1] as String) {
if certElements.count >= 3 {
if let certName = certElements[2].data(using: .utf8) {
return (certDecoded, certName)
} else if let certName = secret.name.data(using: .utf8) {
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
return (certDecoded, certName)
} else {
throw OpenSSHCertificateError.parsingFailed
}
}
} else {
logger.warning("Certificate found for \(secret.name) but failed to decode base64 key")
throw OpenSSHCertificateError.parsingFailed
}
}
} catch {
logger.warning("Certificate found for \(secret.name) but failed to load")
throw OpenSSHCertificateError.parsingFailed
}
}
throw OpenSSHCertificateError.doesNotExist
}
} }
@ -225,7 +308,6 @@ extension Agent {
case unhandledType case unhandledType
case noMatchingKey case noMatchingKey
case unsupportedKeyType case unsupportedKeyType
case notOpenSSHCertificate
} }
} }

View File

@ -1,11 +1,11 @@
import Foundation import Foundation
/// A namespace for the SSH Agent Protocol, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1 /// A namespace for the SSH Agent Protocol, as described in https://tools.ietf.org/id/draft-miller-ssh-agent-01.html
public enum SSHAgent {} public enum SSHAgent {}
extension SSHAgent { extension SSHAgent {
/// The type of the SSH Agent Request, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1 /// The type of the SSH Agent Request, as described in https://tools.ietf.org/id/draft-miller-ssh-agent-01.html#rfc.section.5.1
public enum RequestType: UInt8, CustomDebugStringConvertible { public enum RequestType: UInt8, CustomDebugStringConvertible {
case requestIdentities = 11 case requestIdentities = 11
@ -21,11 +21,10 @@ extension SSHAgent {
} }
} }
/// The type of the SSH Agent Response, as described in https://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1 /// The type of the SSH Agent Response, as described in https://tools.ietf.org/id/draft-miller-ssh-agent-01.html#rfc.section.5.1
public enum ResponseType: UInt8, CustomDebugStringConvertible { public enum ResponseType: UInt8, CustomDebugStringConvertible {
case agentFailure = 5 case agentFailure = 5
case agentSuccess = 6
case agentIdentitiesAnswer = 12 case agentIdentitiesAnswer = 12
case agentSignResponse = 14 case agentSignResponse = 14
@ -33,8 +32,6 @@ extension SSHAgent {
switch self { switch self {
case .agentFailure: case .agentFailure:
return "AgentFailure" return "AgentFailure"
case .agentSuccess:
return "AgentSuccess"
case .agentIdentitiesAnswer: case .agentIdentitiesAnswer:
return "AgentIdentitiesAnswer" return "AgentIdentitiesAnswer"
case .agentSignResponse: case .agentSignResponse:

View File

@ -1,120 +0,0 @@
import Foundation
import OSLog
/// Manages storage and lookup for OpenSSH certificates.
public class OpenSSHCertificateHandler {
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
private let logger = Logger()
private let writer = OpenSSHKeyWriter()
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)
}
}
/// Whether or not the certificate handler has a certifiicate associated with a given secret.
/// - Parameter secret: The secret to check for a certificate.
/// - Returns: A boolean describing whether or not the certificate handler has a certifiicate associated with a given secret
public func hasCertificate<SecretType: Secret>(for secret: SecretType) -> Bool {
keyBlobsAndNames[AnySecret(secret)] != nil
}
/// Reconstructs a public key from a ``Data``, if that ``Data`` contains an OpenSSH certificate hash. Currently only ecdsa certificates are supported
/// - Parameter certBlock: The openssh certificate to extract the public key from
/// - Returns: A ``Data`` object containing the public key in OpenSSH wire format if the ``Data`` is an OpenSSH certificate hash, otherwise nil.
public func publicKeyHash(from hash: Data) -> Data? {
let reader = OpenSSHReader(data: hash)
let certType = String(decoding: 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":
_ = reader.readNextChunk() // nonce
let curveIdentifier = reader.readNextChunk()
let publicKey = reader.readNextChunk()
let curveType = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "").data(using: .utf8)!
return writer.lengthAndData(of: curveType) +
writer.lengthAndData(of: curveIdentifier) +
writer.lengthAndData(of: publicKey)
default:
return nil
}
}
/// 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 = certElements[2].data(using: .utf8) {
return (certDecoded, certName)
} else if let certName = secret.name.data(using: .utf8) {
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
return (certDecoded, certName)
} else {
throw OpenSSHCertificateError.parsingFailed
}
}
}
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"
}
}
}
}

View File

@ -45,18 +45,6 @@ public class PublicKeyFileStoreController {
return directory.appending("/").appending("\(minimalHex).pub") return directory.appending("/").appending("\(minimalHex).pub")
} }
/// 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)
.filter { $0.hasSuffix("-cert.pub") }
.isEmpty == false
} catch {
return false
}
}
/// The path for a Secret's SSH Certificate public key. /// The path for a Secret's SSH Certificate public key.
/// - Parameter secret: The Secret to return the path for. /// - Parameter secret: The Secret to return the path for.
/// - Returns: The path to the SSH Certificate public key. /// - Returns: The path to the SSH Certificate public key.

View File

@ -8,7 +8,7 @@ public class SecretStoreList: ObservableObject {
@Published public var stores: [AnySecretStore] = [] @Published public var stores: [AnySecretStore] = []
/// A modifiable store, if one is available. /// A modifiable store, if one is available.
@Published public var modifiableStore: AnySecretStoreModifiable? @Published public var modifiableStore: AnySecretStoreModifiable?
private var cancellables: Set<AnyCancellable> = [] private var sinks: [AnyCancellable] = []
/// Initializes a SecretStoreList. /// Initializes a SecretStoreList.
public init() { public init() {
@ -31,19 +31,16 @@ public class SecretStoreList: ObservableObject {
stores.reduce(false, { $0 || $1.isAvailable }) stores.reduce(false, { $0 || $1.isAvailable })
} }
public var allSecrets: [AnySecret] {
stores.flatMap(\.secrets)
}
} }
extension SecretStoreList { extension SecretStoreList {
private func addInternal(store: AnySecretStore) { private func addInternal(store: AnySecretStore) {
stores.append(store) stores.append(store)
store.objectWillChange.sink { let sink = store.objectWillChange.sink {
self.objectWillChange.send() self.objectWillChange.send()
}.store(in: &cancellables) }
sinks.append(sink)
} }
} }

View File

@ -34,9 +34,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
self.socketController.handler = self.agent.handle(reader:writer:) self.socketController.handler = self.agent.handle(reader:writer:)
} }
NotificationCenter.default.addObserver(forName: .secretStoreReloaded, object: nil, queue: .main) { [self] _ in NotificationCenter.default.addObserver(forName: .secretStoreReloaded, object: nil, queue: .main) { [self] _ in
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true) try? publicKeyFileStoreController.generatePublicKeys(for: storeList.stores.flatMap({ $0.secrets }), clear: true)
} }
try? publicKeyFileStoreController.generatePublicKeys(for: storeList.allSecrets, clear: true) try? publicKeyFileStoreController.generatePublicKeys(for: storeList.stores.flatMap({ $0.secrets }), clear: true)
notifier.prompt() notifier.prompt()
updateSink = updater.$update.sink { update in updateSink = updater.$update.sink { update in
guard let update = update else { return } guard let update = update else { return }

View File

@ -266,7 +266,6 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
50617D8423FCE48E0099B055 /* ContentView.swift */, 50617D8423FCE48E0099B055 /* ContentView.swift */,
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */,
5079BA0E250F29BF00EA86F4 /* StoreListView.swift */, 5079BA0E250F29BF00EA86F4 /* StoreListView.swift */,
50153E21250DECA300525160 /* SecretListItemView.swift */, 50153E21250DECA300525160 /* SecretListItemView.swift */,
50C385A42407A76D00AF2719 /* SecretDetailView.swift */, 50C385A42407A76D00AF2719 /* SecretDetailView.swift */,
@ -278,6 +277,7 @@
50153E1F250AFCB200525160 /* UpdateView.swift */, 50153E1F250AFCB200525160 /* UpdateView.swift */,
5066A6C12516F303004B5A36 /* SetupView.swift */, 5066A6C12516F303004B5A36 /* SetupView.swift */,
5066A6C72516FE6E004B5A36 /* CopyableView.swift */, 5066A6C72516FE6E004B5A36 /* CopyableView.swift */,
5065E312295517C500E16645 /* ToolbarButtonStyle.swift */,
); );
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";

View File

@ -10,7 +10,6 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
@Binding var runningSetup: Bool @Binding var runningSetup: Bool
@Binding var hasRunSetup: Bool @Binding var hasRunSetup: Bool
@State var showingAgentInfo = false @State var showingAgentInfo = false
@State var activeSecret: AnySecret.ID?
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@EnvironmentObject private var storeList: SecretStoreList @EnvironmentObject private var storeList: SecretStoreList
@ -23,7 +22,7 @@ struct ContentView<UpdaterType: UpdaterProtocol, AgentStatusCheckerType: AgentSt
var body: some View { var body: some View {
VStack { VStack {
if storeList.anyAvailable { if storeList.anyAvailable {
StoreListView(activeSecret: $activeSecret) StoreListView(showingCreation: $showingCreation)
} else { } else {
NoStoresView() NoStoresView()
} }
@ -105,10 +104,6 @@ extension ContentView {
.sheet(isPresented: $showingCreation) { .sheet(isPresented: $showingCreation) {
if let modifiable = storeList.modifiableStore { if let modifiable = storeList.modifiableStore {
CreateSecretView(store: modifiable, showing: $showingCreation) CreateSecretView(store: modifiable, showing: $showingCreation)
.onDisappear {
guard let newest = modifiable.secrets.last?.id else { return }
activeSecret = newest
}
} }
} }
} }

View File

@ -35,6 +35,7 @@ struct DeleteSecretView<StoreType: SecretStoreModifiable>: View {
Spacer() Spacer()
Button("Delete", action: delete) Button("Delete", action: delete)
.disabled(confirm != secret.name) .disabled(confirm != secret.name)
.keyboardShortcut(.delete)
Button("Don't Delete") { Button("Don't Delete") {
dismissalBlock(false) dismissalBlock(false)
} }

View File

@ -29,8 +29,7 @@ struct SecretListItemView: View {
} else { } else {
Text(secret.name) Text(secret.name)
} }
} }.contextMenu {
.contextMenu {
if store is AnySecretStoreModifiable { if store is AnySecretStoreModifiable {
Button(action: { isRenaming = true }) { Button(action: { isRenaming = true }) {
Text("Rename") Text("Rename")

View File

@ -1,11 +1,12 @@
import SwiftUI import SwiftUI
import Combine
import SecretKit import SecretKit
struct StoreListView: View { struct StoreListView: View {
@Binding var activeSecret: AnySecret.ID? @Binding var showingCreation: Bool
@State private var activeSecret: AnySecret.ID?
@EnvironmentObject private var storeList: SecretStoreList @EnvironmentObject private var storeList: SecretStoreList
private func secretDeleted(secret: AnySecret) { private func secretDeleted(secret: AnySecret) {
@ -13,7 +14,7 @@ struct StoreListView: View {
} }
private func secretRenamed(secret: AnySecret) { private func secretRenamed(secret: AnySecret) {
activeSecret = secret.id activeSecret = nextDefaultSecret
} }
var body: some View { var body: some View {