2020-03-04 07:14:38 +00:00
|
|
|
import Foundation
|
|
|
|
import CryptoKit
|
|
|
|
import OSLog
|
|
|
|
import SecretKit
|
2020-03-17 07:56:55 +00:00
|
|
|
import AppKit
|
2020-03-04 07:14:38 +00:00
|
|
|
|
2022-10-27 05:19:21 +00:00
|
|
|
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"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-01-02 06:54:22 +00:00
|
|
|
/// 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.
|
2020-03-17 06:39:34 +00:00
|
|
|
public class Agent {
|
2020-03-04 07:14:38 +00:00
|
|
|
|
2020-05-16 06:19:00 +00:00
|
|
|
private let storeList: SecretStoreList
|
|
|
|
private let witness: SigningWitness?
|
|
|
|
private let writer = OpenSSHKeyWriter()
|
|
|
|
private let requestTracer = SigningRequestTracer()
|
2022-10-27 05:19:21 +00:00
|
|
|
private let certsPath = (NSHomeDirectory() as NSString).appendingPathComponent("PublicKeys") as String
|
2022-12-18 07:16:56 +00:00
|
|
|
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent.agent", category: "")
|
2020-03-04 07:14:38 +00:00
|
|
|
|
2022-01-02 06:54:22 +00:00
|
|
|
/// Initializes an agent with a store list and a witness.
|
|
|
|
/// - Parameters:
|
|
|
|
/// - storeList: The `SecretStoreList` to make available.
|
|
|
|
/// - witness: A witness to notify of requests.
|
2020-03-17 06:39:34 +00:00
|
|
|
public init(storeList: SecretStoreList, witness: SigningWitness? = nil) {
|
2022-12-18 07:16:56 +00:00
|
|
|
logger.debug("Agent is running")
|
2020-03-09 04:11:59 +00:00
|
|
|
self.storeList = storeList
|
2020-03-17 06:39:34 +00:00
|
|
|
self.witness = witness
|
2020-03-04 07:14:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
extension Agent {
|
|
|
|
|
2022-01-02 06:54:22 +00:00
|
|
|
/// Handles an incoming request.
|
|
|
|
/// - Parameters:
|
|
|
|
/// - reader: A ``FileHandleReader`` to read the content of the request.
|
|
|
|
/// - writer: A ``FileHandleWriter`` to write the response to.
|
2022-01-31 07:53:02 +00:00
|
|
|
/// - Return value:
|
|
|
|
/// - Boolean if data could be read
|
2022-02-25 06:57:29 +00:00
|
|
|
@discardableResult public func handle(reader: FileHandleReader, writer: FileHandleWriter) -> Bool {
|
2022-12-18 07:16:56 +00:00
|
|
|
logger.debug("Agent handling new data")
|
2021-12-24 06:56:47 +00:00
|
|
|
let data = Data(reader.availableData)
|
2022-01-31 07:53:02 +00:00
|
|
|
guard data.count > 4 else { return false}
|
2020-03-04 07:14:38 +00:00
|
|
|
let requestTypeInt = data[4]
|
2020-03-24 06:22:22 +00:00
|
|
|
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
|
|
|
|
writer.write(OpenSSHKeyWriter().lengthAndData(of: SSHAgent.ResponseType.agentFailure.data))
|
2022-12-18 07:16:56 +00:00
|
|
|
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
|
2022-01-31 07:53:02 +00:00
|
|
|
return true
|
2020-03-24 06:22:22 +00:00
|
|
|
}
|
2022-12-18 07:16:56 +00:00
|
|
|
logger.debug("Agent handling request of type \(requestType.debugDescription)")
|
2020-03-04 07:14:38 +00:00
|
|
|
let subData = Data(data[5...])
|
2020-03-24 06:22:22 +00:00
|
|
|
let response = handle(requestType: requestType, data: subData, reader: reader)
|
|
|
|
writer.write(response)
|
2022-01-31 07:53:02 +00:00
|
|
|
return true
|
2020-03-04 07:14:38 +00:00
|
|
|
}
|
|
|
|
|
2020-03-24 06:22:22 +00:00
|
|
|
func handle(requestType: SSHAgent.RequestType, data: Data, reader: FileHandleReader) -> Data {
|
2022-12-18 07:16:56 +00:00
|
|
|
// Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting
|
|
|
|
reloadSecretsIfNeccessary()
|
2020-03-04 07:14:38 +00:00
|
|
|
var response = Data()
|
|
|
|
do {
|
|
|
|
switch requestType {
|
|
|
|
case .requestIdentities:
|
|
|
|
response.append(SSHAgent.ResponseType.agentIdentitiesAnswer.data)
|
2020-03-24 06:22:22 +00:00
|
|
|
response.append(identities())
|
2022-12-18 07:16:56 +00:00
|
|
|
logger.debug("Agent returned \(SSHAgent.ResponseType.agentIdentitiesAnswer.debugDescription)")
|
2020-03-04 07:14:38 +00:00
|
|
|
case .signRequest:
|
2020-03-24 06:22:22 +00:00
|
|
|
let provenance = requestTracer.provenance(from: reader)
|
2020-03-04 07:14:38 +00:00
|
|
|
response.append(SSHAgent.ResponseType.agentSignResponse.data)
|
2020-03-24 06:22:22 +00:00
|
|
|
response.append(try sign(data: data, provenance: provenance))
|
2022-12-18 07:16:56 +00:00
|
|
|
logger.debug("Agent returned \(SSHAgent.ResponseType.agentSignResponse.debugDescription)")
|
2020-03-04 07:14:38 +00:00
|
|
|
}
|
|
|
|
} catch {
|
|
|
|
response.removeAll()
|
|
|
|
response.append(SSHAgent.ResponseType.agentFailure.data)
|
2022-12-18 07:16:56 +00:00
|
|
|
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
|
2020-03-04 07:14:38 +00:00
|
|
|
}
|
|
|
|
let full = OpenSSHKeyWriter().lengthAndData(of: response)
|
2020-03-24 06:22:22 +00:00
|
|
|
return full
|
2020-03-04 07:14:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
extension Agent {
|
|
|
|
|
2022-01-02 06:54:22 +00:00
|
|
|
/// Lists the identities available for signing operations
|
|
|
|
/// - Returns: An OpenSSH formatted Data payload listing the identities available for signing operations.
|
2020-03-24 06:22:22 +00:00
|
|
|
func identities() -> Data {
|
2020-04-05 23:05:45 +00:00
|
|
|
let secrets = storeList.stores.flatMap(\.secrets)
|
2020-03-09 04:11:59 +00:00
|
|
|
var count = UInt32(secrets.count).bigEndian
|
2020-03-04 07:14:38 +00:00
|
|
|
let countData = Data(bytes: &count, count: UInt32.bitWidth/8)
|
|
|
|
var keyData = Data()
|
2022-10-27 05:19:21 +00:00
|
|
|
|
2020-03-09 04:11:59 +00:00
|
|
|
for secret in secrets {
|
2022-10-27 05:19:21 +00:00
|
|
|
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)!
|
|
|
|
}
|
|
|
|
|
2020-03-04 07:14:38 +00:00
|
|
|
keyData.append(writer.lengthAndData(of: keyBlob))
|
|
|
|
keyData.append(writer.lengthAndData(of: curveData))
|
2022-10-27 05:19:21 +00:00
|
|
|
|
2020-03-04 07:14:38 +00:00
|
|
|
}
|
2022-12-18 07:16:56 +00:00
|
|
|
logger.log("Agent enumerated \(secrets.count) identities")
|
2020-03-04 07:14:38 +00:00
|
|
|
return countData + keyData
|
|
|
|
}
|
|
|
|
|
2022-01-02 06:54:22 +00:00
|
|
|
/// Notifies witnesses of a pending signature request, and performs the signing operation if none object.
|
|
|
|
/// - Parameters:
|
|
|
|
/// - data: The data to sign.
|
|
|
|
/// - provenance: A ``SecretKit.SigningRequestProvenance`` object describing the origin of the request.
|
|
|
|
/// - Returns: An OpenSSH formatted Data payload containing the signed data response.
|
2020-03-24 06:22:22 +00:00
|
|
|
func sign(data: Data, provenance: SigningRequestProvenance) throws -> Data {
|
2020-03-04 07:14:38 +00:00
|
|
|
let reader = OpenSSHReader(data: data)
|
2022-10-27 05:19:21 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2020-03-17 06:39:34 +00:00
|
|
|
guard let (store, secret) = secret(matching: hash) else {
|
2022-12-18 07:16:56 +00:00
|
|
|
logger.debug("Agent did not have a key matching \(hash as NSData)")
|
2020-03-04 07:14:38 +00:00
|
|
|
throw AgentError.noMatchingKey
|
|
|
|
}
|
2020-03-17 06:39:34 +00:00
|
|
|
|
|
|
|
if let witness = witness {
|
2021-11-08 01:41:59 +00:00
|
|
|
try witness.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
|
2020-03-17 06:39:34 +00:00
|
|
|
}
|
|
|
|
|
2020-03-22 02:28:08 +00:00
|
|
|
let dataToSign = reader.readNextChunk()
|
2021-11-08 01:41:59 +00:00
|
|
|
let signed = try store.sign(data: dataToSign, with: secret, for: provenance)
|
2022-02-25 06:59:35 +00:00
|
|
|
let derSignature = signed
|
2020-03-17 06:39:34 +00:00
|
|
|
|
2020-03-09 05:17:59 +00:00
|
|
|
let curveData = writer.curveType(for: secret.algorithm, length: secret.keySize).data(using: .utf8)!
|
2020-03-04 07:14:38 +00:00
|
|
|
|
|
|
|
// Convert from DER formatted rep to raw (r||s)
|
2020-03-09 05:17:59 +00:00
|
|
|
|
|
|
|
let rawRepresentation: Data
|
|
|
|
switch (secret.algorithm, secret.keySize) {
|
|
|
|
case (.ellipticCurve, 256):
|
|
|
|
rawRepresentation = try CryptoKit.P256.Signing.ECDSASignature(derRepresentation: derSignature).rawRepresentation
|
|
|
|
case (.ellipticCurve, 384):
|
|
|
|
rawRepresentation = try CryptoKit.P384.Signing.ECDSASignature(derRepresentation: derSignature).rawRepresentation
|
|
|
|
default:
|
2020-03-24 06:22:22 +00:00
|
|
|
throw AgentError.unsupportedKeyType
|
2020-03-09 05:17:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let rawLength = rawRepresentation.count/2
|
2020-04-02 07:43:45 +00:00
|
|
|
// Check if we need to pad with 0x00 to prevent certain
|
|
|
|
// ssh servers from thinking r or s is negative
|
|
|
|
let paddingRange: ClosedRange<UInt8> = 0x80...0xFF
|
|
|
|
var r = Data(rawRepresentation[0..<rawLength])
|
|
|
|
if paddingRange ~= r.first! {
|
|
|
|
r.insert(0x00, at: 0)
|
|
|
|
}
|
|
|
|
var s = Data(rawRepresentation[rawLength...])
|
|
|
|
if paddingRange ~= s.first! {
|
|
|
|
s.insert(0x00, at: 0)
|
|
|
|
}
|
2020-03-04 07:14:38 +00:00
|
|
|
|
|
|
|
var signatureChunk = Data()
|
|
|
|
signatureChunk.append(writer.lengthAndData(of: r))
|
|
|
|
signatureChunk.append(writer.lengthAndData(of: s))
|
|
|
|
|
|
|
|
var signedData = Data()
|
|
|
|
var sub = Data()
|
|
|
|
sub.append(writer.lengthAndData(of: curveData))
|
|
|
|
sub.append(writer.lengthAndData(of: signatureChunk))
|
|
|
|
signedData.append(writer.lengthAndData(of: sub))
|
|
|
|
|
2020-03-19 03:04:24 +00:00
|
|
|
if let witness = witness {
|
2022-02-25 06:59:35 +00:00
|
|
|
try witness.witness(accessTo: secret, from: store, by: provenance)
|
2020-03-19 03:04:24 +00:00
|
|
|
}
|
|
|
|
|
2022-12-18 07:16:56 +00:00
|
|
|
logger.debug("Agent signed request")
|
2020-03-04 07:14:38 +00:00
|
|
|
|
|
|
|
return signedData
|
|
|
|
}
|
2022-10-27 05:19:21 +00:00
|
|
|
|
|
|
|
/// 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) {
|
2022-12-18 07:16:56 +00:00
|
|
|
logger.debug("Found certificate for \(secret.name)")
|
2022-10-27 05:19:21 +00:00
|
|
|
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) {
|
2022-12-18 07:16:56 +00:00
|
|
|
logger.info("Certificate for \(secret.name) does not have a name tag, using secret name instead")
|
2022-10-27 05:19:21 +00:00
|
|
|
return (certDecoded, certName)
|
|
|
|
} else {
|
|
|
|
throw OpenSSHCertificateError.parsingFailed
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
2022-12-18 07:16:56 +00:00
|
|
|
logger.warning("Certificate found for \(secret.name) but failed to decode base64 key")
|
2022-10-27 05:19:21 +00:00
|
|
|
throw OpenSSHCertificateError.parsingFailed
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch {
|
2022-12-18 07:16:56 +00:00
|
|
|
logger.warning("Certificate found for \(secret.name) but failed to load")
|
2022-10-27 05:19:21 +00:00
|
|
|
throw OpenSSHCertificateError.parsingFailed
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
throw OpenSSHCertificateError.doesNotExist
|
|
|
|
}
|
2020-03-04 07:14:38 +00:00
|
|
|
|
|
|
|
}
|
|
|
|
|
2020-03-17 06:39:34 +00:00
|
|
|
extension Agent {
|
|
|
|
|
2022-12-18 07:16:56 +00:00
|
|
|
/// 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()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-01-02 06:54:22 +00:00
|
|
|
/// 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.
|
2020-03-17 06:39:34 +00:00
|
|
|
func secret(matching hash: Data) -> (AnySecretStore, AnySecret)? {
|
|
|
|
storeList.stores.compactMap { store -> (AnySecretStore, AnySecret)? in
|
|
|
|
let allMatching = store.secrets.filter { secret in
|
|
|
|
hash == writer.data(secret: secret)
|
|
|
|
}
|
|
|
|
if let matching = allMatching.first {
|
|
|
|
return (store, matching)
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}.first
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
2020-03-04 07:14:38 +00:00
|
|
|
|
|
|
|
extension Agent {
|
|
|
|
|
2022-01-02 06:54:22 +00:00
|
|
|
/// An error involving agent operations..
|
2020-03-04 07:14:38 +00:00
|
|
|
enum AgentError: Error {
|
|
|
|
case unhandledType
|
|
|
|
case noMatchingKey
|
2020-03-24 06:22:22 +00:00
|
|
|
case unsupportedKeyType
|
2020-03-04 07:14:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
extension SSHAgent.ResponseType {
|
|
|
|
|
|
|
|
var data: Data {
|
|
|
|
var raw = self.rawValue
|
|
|
|
return Data(bytes: &raw, count: UInt8.bitWidth/8)
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|