Merge branch 'newcreation.stab' of github.com:maxgoedjen/secretive into newcreation.stab

This commit is contained in:
Max Goedjen
2022-12-18 14:47:00 -08:00
17 changed files with 270 additions and 47 deletions

View File

@@ -4,6 +4,25 @@ import OSLog
import SecretKit
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.
public class Agent {
@@ -11,13 +30,15 @@ public class Agent {
private let witness: SigningWitness?
private let writer = OpenSSHKeyWriter()
private let requestTracer = SigningRequestTracer()
private let certsPath = (NSHomeDirectory() as NSString).appendingPathComponent("PublicKeys") as String
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent.agent", category: "")
/// 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) {
Logger().debug("Agent is running")
logger.debug("Agent is running")
self.storeList = storeList
self.witness = witness
}
@@ -33,16 +54,16 @@ extension Agent {
/// - Return value:
/// - Boolean if data could be read
@discardableResult public func handle(reader: FileHandleReader, writer: FileHandleWriter) -> Bool {
Logger().debug("Agent handling new data")
logger.debug("Agent handling new data")
let data = Data(reader.availableData)
guard data.count > 4 else { return false}
let requestTypeInt = data[4]
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
writer.write(OpenSSHKeyWriter().lengthAndData(of: SSHAgent.ResponseType.agentFailure.data))
Logger().debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
return true
}
Logger().debug("Agent handling request of type \(requestType.debugDescription)")
logger.debug("Agent handling request of type \(requestType.debugDescription)")
let subData = Data(data[5...])
let response = handle(requestType: requestType, data: subData, reader: reader)
writer.write(response)
@@ -50,23 +71,25 @@ extension Agent {
}
func handle(requestType: SSHAgent.RequestType, data: Data, reader: FileHandleReader) -> Data {
// Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting
reloadSecretsIfNeccessary()
var response = Data()
do {
switch requestType {
case .requestIdentities:
response.append(SSHAgent.ResponseType.agentIdentitiesAnswer.data)
response.append(identities())
Logger().debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)")
logger.debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)")
case .signRequest:
let provenance = requestTracer.provenance(from: reader)
response.append(SSHAgent.ResponseType.agentSignResponse.data)
response.append(try sign(data: data, provenance: provenance))
Logger().debug("Agent returned \(SSHAgent.ResponseType.agentSignResponse.debugDescription)")
logger.debug("Agent returned \(SSHAgent.ResponseType.agentSignResponse.debugDescription)")
}
} catch {
response.removeAll()
response.append(SSHAgent.ResponseType.agentFailure.data)
Logger().debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
}
let full = OpenSSHKeyWriter().lengthAndData(of: response)
return full
@@ -83,14 +106,24 @@ extension Agent {
var count = UInt32(secrets.count).bigEndian
let countData = Data(bytes: &count, count: UInt32.bitWidth/8)
var keyData = Data()
let writer = OpenSSHKeyWriter()
for secret in secrets {
let keyBlob = writer.data(secret: secret)
let keyBlob: Data
let curveData: Data
if let (certBlob, certName) = try? checkForCert(secret: secret) {
keyBlob = certBlob
curveData = certName
} else {
keyBlob = writer.data(secret: secret)
curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
}
keyData.append(writer.lengthAndData(of: keyBlob))
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
keyData.append(writer.lengthAndData(of: curveData))
}
Logger().debug("Agent enumerated \(secrets.count) identities")
logger.log("Agent enumerated \(secrets.count) identities")
return countData + keyData
}
@@ -101,9 +134,15 @@ extension Agent {
/// - Returns: An OpenSSH formatted Data payload containing the signed data response.
func sign(data: Data, provenance: SigningRequestProvenance) throws -> Data {
let reader = OpenSSHReader(data: data)
let hash = reader.readNextChunk()
var hash = reader.readNextChunk()
// Check if hash is actually an openssh certificate and reconstruct the public key if it is
if let certPublicKey = try? getPublicKeyFromCert(certBlob: hash) {
hash = certPublicKey
}
guard let (store, secret) = secret(matching: hash) else {
Logger().debug("Agent did not have a key matching \(hash as NSData)")
logger.debug("Agent did not have a key matching \(hash as NSData)")
throw AgentError.noMatchingKey
}
@@ -157,15 +196,93 @@ extension Agent {
try witness.witness(accessTo: secret, from: store, by: provenance)
}
Logger().debug("Agent signed request")
logger.debug("Agent signed request")
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
}
}
extension Agent {
/// Gives any store with no loaded secrets a chance to reload.
func reloadSecretsIfNeccessary() {
for store in storeList.stores {
if store.secrets.isEmpty {
logger.debug("Store \(store.name, privacy: .public) has no loaded secrets. Reloading.")
store.reloadSecrets()
}
}
}
/// Finds a ``Secret`` matching a specified hash whos signature was requested.
/// - Parameter hash: The hash to match against.
/// - Returns: A ``Secret`` and the ``SecretStore`` containing it, if a match is found.

View File

@@ -40,7 +40,10 @@ extension SigningRequestTracer {
func process(from pid: Int32) -> SigningRequestProvenance.Process {
var pidAndNameInfo = self.pidAndNameInfo(from: pid)
let ppid = pidAndNameInfo.kp_eproc.e_ppid != 0 ? pidAndNameInfo.kp_eproc.e_ppid : nil
let procName = String(cString: &pidAndNameInfo.kp_proc.p_comm.0)
let procName = withUnsafeMutablePointer(to: &pidAndNameInfo.kp_proc.p_comm.0) { pointer in
String(cString: pointer)
}
let pathPointer = UnsafeMutablePointer<UInt8>.allocate(capacity: Int(MAXPATHLEN))
_ = proc_pidpath(pid, pathPointer, UInt32(MAXPATHLEN))
let path = String(cString: pathPointer)

View File

@@ -12,6 +12,7 @@ public class AnySecretStore: SecretStore {
private let _sign: (Data, AnySecret, SigningRequestProvenance) throws -> Data
private let _existingPersistedAuthenticationContext: (AnySecret) -> PersistedAuthenticationContext?
private let _persistAuthentication: (AnySecret, TimeInterval) throws -> Void
private let _reloadSecrets: () -> Void
private var sink: AnyCancellable?
@@ -24,6 +25,7 @@ public class AnySecretStore: SecretStore {
_sign = { try secretStore.sign(data: $0, with: $1.base as! SecretStoreType.SecretType, for: $2) }
_existingPersistedAuthenticationContext = { secretStore.existingPersistedAuthenticationContext(secret: $0.base as! SecretStoreType.SecretType) }
_persistAuthentication = { try secretStore.persistAuthentication(secret: $0.base as! SecretStoreType.SecretType, forDuration: $1) }
_reloadSecrets = { secretStore.reloadSecrets() }
sink = secretStore.objectWillChange.sink { _ in
self.objectWillChange.send()
}
@@ -57,6 +59,10 @@ public class AnySecretStore: SecretStore {
try _persistAuthentication(secret, duration)
}
public func reloadSecrets() {
_reloadSecrets()
}
}
public class AnySecretStoreModifiable: AnySecretStore, SecretStoreModifiable {

View File

@@ -15,15 +15,21 @@ public class PublicKeyFileStoreController {
/// Writes out the keys specified to disk.
/// - Parameter secrets: The Secrets to generate keys for.
/// - Parameter clear: Whether or not the directory should be erased before writing keys.
/// - Parameter clear: Whether or not any untracked files in the directory should be removed.
public func generatePublicKeys(for secrets: [AnySecret], clear: Bool = false) throws {
logger.log("Writing public keys to disk")
if clear {
try? FileManager.default.removeItem(at: URL(fileURLWithPath: directory))
let validPaths = Set(secrets.map { publicKeyPath(for: $0) }).union(Set(secrets.map { sshCertificatePath(for: $0) }))
let untracked = Set(try FileManager.default.contentsOfDirectory(atPath: directory)
.map { "\(directory)/\($0)" })
.subtracting(validPaths)
for path in untracked {
try? FileManager.default.removeItem(at: URL(fileURLWithPath: path))
}
}
try? FileManager.default.createDirectory(at: URL(fileURLWithPath: directory), withIntermediateDirectories: false, attributes: nil)
for secret in secrets {
let path = path(for: secret)
let path = publicKeyPath(for: secret)
guard let data = keyWriter.openSSHString(secret: secret).data(using: .utf8) else { continue }
FileManager.default.createFile(atPath: path, contents: data, attributes: nil)
}
@@ -34,9 +40,18 @@ public class PublicKeyFileStoreController {
/// - Parameter secret: The Secret to return the path for.
/// - Returns: The path to the Secret's public key.
/// - Warning: This method returning a path does not imply that a key has been written to disk already. This method only describes where it will be written to.
public func path<SecretType: Secret>(for secret: SecretType) -> String {
public func publicKeyPath<SecretType: Secret>(for secret: SecretType) -> String {
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
return directory.appending("/").appending("\(minimalHex).pub")
}
/// 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 {
let minimalHex = keyWriter.openSSHMD5Fingerprint(secret: secret).replacingOccurrences(of: ":", with: "")
return directory.appending("/").appending("\(minimalHex)-cert.pub")
}
}

View File

@@ -36,6 +36,9 @@ public protocol SecretStore: ObservableObject, Identifiable {
/// - Note: This is used for temporarily unlocking access to a secret which would otherwise require authentication every single use. This is useful for situations where the user anticipates several rapid accesses to a authorization-guarded secret.
func persistAuthentication(secret: SecretType, forDuration duration: TimeInterval) throws
/// Requests that the store reload secrets from any backing store, if neccessary.
func reloadSecrets()
}
/// A SecretStore that the Secretive admin app can modify.

View File

@@ -24,7 +24,7 @@ extension SecureEnclave {
/// Initializes a Store.
public init() {
DistributedNotificationCenter.default().addObserver(forName: .secretStoreUpdated, object: nil, queue: .main) { _ in
self.reloadSecrets(notifyAgent: false)
self.reloadSecretsInternal(notifyAgent: false)
}
loadSecrets()
}
@@ -68,7 +68,7 @@ extension SecureEnclave {
throw KeychainError(statusCode: nil)
}
try savePublicKey(publicKey, name: name)
reloadSecrets()
reloadSecretsInternal()
}
public func delete(secret: Secret) throws {
@@ -80,7 +80,7 @@ extension SecureEnclave {
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
reloadSecrets()
reloadSecretsInternal()
}
public func update(secret: Secret, name: String) throws {
@@ -97,7 +97,7 @@ extension SecureEnclave {
if status != errSecSuccess {
throw KeychainError(statusCode: status)
}
reloadSecrets()
reloadSecretsInternal()
}
public func sign(data: Data, with secret: SecretType, for provenance: SigningRequestProvenance) throws -> Data {
@@ -163,6 +163,10 @@ extension SecureEnclave {
}
}
public func reloadSecrets() {
reloadSecretsInternal(notifyAgent: false)
}
}
}
@@ -171,12 +175,15 @@ extension SecureEnclave.Store {
/// Reloads all secrets from the store.
/// - Parameter notifyAgent: A boolean indicating whether a distributed notification should be posted, notifying other processes (ie, the SecretAgent) to reload their stores as well.
private func reloadSecrets(notifyAgent: Bool = true) {
private func reloadSecretsInternal(notifyAgent: Bool = true) {
let before = secrets
secrets.removeAll()
loadSecrets()
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
if notifyAgent {
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: nil, deliverImmediately: true)
if secrets != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
if notifyAgent {
DistributedNotificationCenter.default().postNotificationName(.secretStoreUpdated, object: nil, deliverImmediately: true)
}
}
}

View File

@@ -89,6 +89,19 @@ extension SmartCard {
public func persistAuthentication(secret: SmartCard.Secret, forDuration: TimeInterval) throws {
}
/// Reloads all secrets from the store.
public func reloadSecrets() {
DispatchQueue.main.async {
self.isAvailable = self.tokenID != nil
let before = self.secrets
self.secrets.removeAll()
self.loadSecrets()
if self.secrets != before {
NotificationCenter.default.post(name: .secretStoreReloaded, object: self)
}
}
}
}
}
@@ -102,15 +115,6 @@ extension SmartCard.Store {
reloadSecrets()
}
/// Reloads all secrets from the store.
private func reloadSecrets() {
DispatchQueue.main.async {
self.isAvailable = self.tokenID != nil
self.secrets.removeAll()
self.loadSecrets()
}
}
/// Loads all secrets from the store.
private func loadSecrets() {
guard let tokenID = tokenID else { return }

View File

@@ -78,6 +78,9 @@ extension Stub {
public func persistAuthentication(secret: Stub.Secret, forDuration duration: TimeInterval) throws {
}
public func reloadSecrets() {
}
}
}

View File

@@ -154,7 +154,7 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
case Notifier.Constants.persistAuthenticationCategoryIdentitifier:
handlePersistAuthenticationResponse(response: response)
default:
fatalError()
break
}
completionHandler()

View File

@@ -47,6 +47,9 @@ extension Preview {
func persistAuthentication(secret: Preview.Secret, forDuration duration: TimeInterval) throws {
}
func reloadSecrets() {
}
}
class StoreModifiable: Store, SecretStoreModifiable {

View File

@@ -21,7 +21,7 @@ struct SecretDetailView<SecretType: Secret>: View {
CopyableView(title: "Public Key", image: Image(systemName: "key"), text: keyString)
Spacer()
.frame(height: 20)
CopyableView(title: "Public Key Path", image: Image(systemName: "lock.doc"), text: publicKeyFileStoreController.path(for: secret))
CopyableView(title: "Public Key Path", image: Image(systemName: "lock.doc"), text: publicKeyFileStoreController.publicKeyPath(for: secret))
Spacer()
}
}