Messy WIP for agent extensions

This commit is contained in:
Max Goedjen
2026-03-11 14:57:38 -07:00
parent faa622e379
commit f848eb659e
7 changed files with 290 additions and 13 deletions

View File

@@ -19,7 +19,7 @@ public struct OpenSSHPublicKeyWriter: Sendable {
("nistp" + String(describing: secret.keyType.size)).lengthAndData + ("nistp" + String(describing: secret.keyType.size)).lengthAndData +
secret.publicKey.lengthAndData secret.publicKey.lengthAndData
case .mldsa: case .mldsa:
// https://www.ietf.org/archive/id/draft-sfluhrer-ssh-mldsa-04.txt // https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-05
openSSHIdentifier(for: secret.keyType).lengthAndData + openSSHIdentifier(for: secret.keyType).lengthAndData +
secret.publicKey.lengthAndData secret.publicKey.lengthAndData
case .rsa: case .rsa:

View File

@@ -39,6 +39,18 @@ public final class OpenSSHReader {
return convertEndianness ? T(value.bigEndian) : T(value) return convertEndianness ? T(value.bigEndian) : T(value)
} }
public func readNextByteAsBool() throws(OpenSSHReaderError) -> Bool {
let size = MemoryLayout<Bool>.size
guard remaining.count >= size else { throw .beyondBounds }
let lengthRange = 0..<size
let lengthChunk = remaining[lengthRange]
remaining.removeSubrange(lengthRange)
if remaining.isEmpty {
done = true
}
return unsafe lengthChunk.bytes.unsafeLoad(as: Bool.self)
}
public func readNextChunkAsString(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> String { public func readNextChunkAsString(convertEndianness: Bool = true) throws(OpenSSHReaderError) -> String {
try String(decoding: readNextChunk(convertEndianness: convertEndianness), as: UTF8.self) try String(decoding: readNextChunk(convertEndianness: convertEndianness), as: UTF8.self)
} }

View File

@@ -17,7 +17,7 @@ public struct OpenSSHSignatureWriter: Sendable {
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1 // https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
ecdsaSignature(signature, keyType: secret.keyType) ecdsaSignature(signature, keyType: secret.keyType)
case .mldsa: case .mldsa:
// https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-00#name-public-key-algorithms // https://datatracker.ietf.org/doc/html/draft-sfluhrer-ssh-mldsa-05
mldsaSignature(signature, keyType: secret.keyType) mldsaSignature(signature, keyType: secret.keyType)
case .rsa: case .rsa:
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6 // https://datatracker.ietf.org/doc/html/rfc4253#section-6.6

View File

@@ -19,7 +19,7 @@ extension SSHAgent {
case lock case lock
case unlock case unlock
case addSmartcardKeyConstrained case addSmartcardKeyConstrained
case protocolExtension case protocolExtension(ProtocolExtension)
case unknown(UInt8) case unknown(UInt8)
public var protocolID: UInt8 { public var protocolID: UInt8 {
@@ -60,18 +60,82 @@ extension SSHAgent {
public struct SignatureRequestContext: Sendable, Codable { public struct SignatureRequestContext: Sendable, Codable {
public let keyBlob: Data public let keyBlob: Data
public let dataToSign: Data public let dataToSign: SignaturePayload
public init(keyBlob: Data, dataToSign: Data) { public init(keyBlob: Data, dataToSign: SignaturePayload) {
self.keyBlob = keyBlob self.keyBlob = keyBlob
self.dataToSign = dataToSign self.dataToSign = dataToSign
} }
public static var empty: SignatureRequestContext { public static var empty: SignatureRequestContext {
SignatureRequestContext(keyBlob: Data(), dataToSign: Data()) SignatureRequestContext(keyBlob: Data(), dataToSign: SignaturePayload(raw: Data(), decoded: nil))
}
public struct SignaturePayload: Sendable, Codable {
public let raw: Data
public let decoded: DecodedPayload?
public init(
raw: Data,
decoded: DecodedPayload?
) {
self.raw = raw
self.decoded = decoded
}
public enum DecodedPayload: Sendable, Codable {
case sshConnection(SSHConnectionPayload)
case sshSig(SSHSigPayload)
public struct SSHConnectionPayload: Sendable, Codable {
public let username: String
public let hasSignature: Bool
public let publicKeyAlgorithm: String
public let publicKey: Data
public let hostKey: Data
public init(
username: String,
hasSignature: Bool,
publicKeyAlgorithm: String,
publicKey: Data,
hostKey: Data
) {
self.username = username
self.hasSignature = hasSignature
self.publicKeyAlgorithm = publicKeyAlgorithm
self.publicKey = publicKey
self.hostKey = hostKey
}
}
public struct SSHSigPayload: Sendable, Codable {
public let namespace: String
public let hashAlgorithm: String
public let hash: Data
public init(
namespace: String,
hashAlgorithm: String,
hash: Data,
) {
self.namespace = namespace
self.hashAlgorithm = hashAlgorithm
self.hash = hash
}
}
}
} }
} }
} }
/// 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://datatracker.ietf.org/doc/html/draft-miller-ssh-agent#section-5.1
@@ -88,8 +152,8 @@ extension SSHAgent {
switch self { switch self {
case .agentFailure: "SSH_AGENT_FAILURE" case .agentFailure: "SSH_AGENT_FAILURE"
case .agentSuccess: "SSH_AGENT_SUCCESS" case .agentSuccess: "SSH_AGENT_SUCCESS"
case .agentIdentitiesAnswer: "SSH_AGENT_IDENTITIES_ANSWER" case .agentIdentitiesAnswer: "SSH2_AGENT_IDENTITIES_ANSWER"
case .agentSignResponse: "SSH_AGENT_SIGN_RESPONSE" case .agentSignResponse: "SSH2_AGENT_SIGN_RESPONSE"
case .agentExtensionFailure: "SSH_AGENT_EXTENSION_FAILURE" case .agentExtensionFailure: "SSH_AGENT_EXTENSION_FAILURE"
case .agentExtensionResponse: "SSH_AGENT_EXTENSION_RESPONSE" case .agentExtensionResponse: "SSH_AGENT_EXTENSION_RESPONSE"
} }

View File

@@ -0,0 +1,76 @@
import Foundation
// Extensions, as defined in https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.agent
extension SSHAgent {
public enum ProtocolExtension: CustomDebugStringConvertible, Codable, Sendable {
case openSSH(OpenSSHExtension)
case unknown(String)
public var debugDescription: String {
switch self {
case let .openSSH(protocolExtension):
protocolExtension.debugDescription
case .unknown(let string):
"Unknown (\(string))"
}
}
public static var empty: ProtocolExtension {
.unknown("empty")
}
private struct ProtocolExtensionParsingError: Error {}
}
}
extension SSHAgent.ProtocolExtension {
public enum OpenSSHExtension: CustomDebugStringConvertible, Codable, Sendable {
case sessionBind(SessionBindContext)
case unknown(String)
public static var domain: String {
"openssh.com"
}
public var name: String {
switch self {
case .sessionBind:
"session-bind"
case .unknown(let name):
name
}
}
public var debugDescription: String {
"\(name)@\(OpenSSHExtension.domain)"
}
}
}
extension SSHAgent.ProtocolExtension.OpenSSHExtension {
public struct SessionBindContext: Codable, Sendable {
public let hostKey: Data
public let sessionID: Data
public let signature: Data
public let forwarding: Bool
public init(hostKey: Data, sessionID: Data, signature: Data, forwarding: Bool) {
self.hostKey = hostKey
self.sessionID = sessionID
self.signature = signature
self.forwarding = forwarding
}
public static let empty = SessionBindContext(hostKey: Data(), sessionID: Data(), signature: Data(), forwarding: false)
}
}

View File

@@ -33,6 +33,7 @@ public final class Agent: Sendable {
extension Agent { extension Agent {
public func handle(request: SSHAgent.Request, provenance: SigningRequestProvenance) async -> Data { public func handle(request: SSHAgent.Request, provenance: SigningRequestProvenance) async -> Data {
logger.debug("Agent received request of type \(request.debugDescription)")
// Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting // Depending on the launch context (such as after macOS update), the agent may need to reload secrets before acting
await reloadSecretsIfNeccessary() await reloadSecretsIfNeccessary()
var response = Data() var response = Data()
@@ -44,8 +45,14 @@ extension Agent {
logger.debug("Agent returned \(SSHAgent.Response.agentIdentitiesAnswer.debugDescription)") logger.debug("Agent returned \(SSHAgent.Response.agentIdentitiesAnswer.debugDescription)")
case .signRequest(let context): case .signRequest(let context):
response.append(SSHAgent.Response.agentSignResponse.data) response.append(SSHAgent.Response.agentSignResponse.data)
response.append(try await sign(data: context.dataToSign, keyBlob: context.keyBlob, provenance: provenance)) response.append(try await sign(data: context.dataToSign.raw, keyBlob: context.keyBlob, provenance: provenance))
logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)") logger.debug("Agent returned \(SSHAgent.Response.agentSignResponse.debugDescription)")
case .protocolExtension(.openSSH(.sessionBind(let bind))):
response = SSHAgent.Response.agentSuccess.data
_ = bind
// FIXME: STORE BIND IN KEYCHAIN
// FIXME: CLEAR OUT BINDS BASED ON EXPIRATION?
logger.debug("Agent returned \(SSHAgent.Response.agentSuccess.debugDescription)")
case .unknown(let value): case .unknown(let value):
logger.error("Agent received unknown request of type \(value).") logger.error("Agent received unknown request of type \(value).")
throw UnhandledRequestError() throw UnhandledRequestError()

View File

@@ -3,6 +3,8 @@ import OSLog
import SecretKit import SecretKit
import SSHProtocolKit import SSHProtocolKit
import CryptoKit
public protocol SSHAgentInputParserProtocol { public protocol SSHAgentInputParserProtocol {
func parse(data: Data) async throws -> SSHAgent.Request func parse(data: Data) async throws -> SSHAgent.Request
@@ -53,8 +55,10 @@ public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
return .unlock return .unlock
case SSHAgent.Request.addSmartcardKeyConstrained.protocolID: case SSHAgent.Request.addSmartcardKeyConstrained.protocolID:
return .addSmartcardKeyConstrained return .addSmartcardKeyConstrained
case SSHAgent.Request.protocolExtension.protocolID: case SSHAgent.Request.protocolExtension(.empty).protocolID:
return .protocolExtension return .protocolExtension(try protocolExtension(from: body))
// case SSHAgent.Request.constrainExtension(.empty).protocolID:
// return .constrainExtension(try constrainExtension(from: body))
default: default:
return .unknown(rawRequestInt) return .unknown(rawRequestInt)
} }
@@ -64,12 +68,126 @@ public struct SSHAgentInputParser: SSHAgentInputParserProtocol {
extension SSHAgentInputParser { extension SSHAgentInputParser {
private enum Constants {
static let userAuthMagic: UInt8 = 50 // SSH2_MSG_USERAUTH_REQUEST
static let sshSigMagic = Data("SSHSIG".utf8)
}
func signatureRequestContext(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext { func signatureRequestContext(from data: Data) throws(OpenSSHReaderError) -> SSHAgent.Request.SignatureRequestContext {
let reader = OpenSSHReader(data: data) let reader = OpenSSHReader(data: data)
let rawKeyBlob = try reader.readNextChunk() let rawKeyBlob = try reader.readNextChunk()
let keyBlob = certificatePublicKeyBlob(from: rawKeyBlob) ?? rawKeyBlob let keyBlob = certificatePublicKeyBlob(from: rawKeyBlob) ?? rawKeyBlob
let dataToSign = try reader.readNextChunk() let rawPayload = try reader.readNextChunk()
return SSHAgent.Request.SignatureRequestContext(keyBlob: keyBlob, dataToSign: dataToSign) let payload: SSHAgent.Request.SignatureRequestContext.SignaturePayload
if rawPayload.count > 6 && rawPayload[0..<6] == Constants.sshSigMagic {
// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig#L79
let payloadReader = OpenSSHReader(data: rawPayload[6...])
let namespace = try payloadReader.readNextChunkAsString()
_ = try payloadReader.readNextChunk() // reserved
let hashAlgorithm = try payloadReader.readNextChunkAsString()
let hash = try payloadReader.readNextChunk()
payload = .init(
raw: data,
decoded: .init(
.sshSig(
.init(
namespace: namespace,
hashAlgorithm: hashAlgorithm,
hash: hash
)
)
)
)
} else {
let payloadReader = OpenSSHReader(data: rawPayload)
do {
_ = try payloadReader.readNextChunk()
let magic = try payloadReader.readNextBytes(as: UInt8.self, convertEndianness: false)
if magic == Constants.userAuthMagic {
let username = try payloadReader.readNextChunkAsString()
_ = try payloadReader.readNextChunkAsString() // "ssh-connection"
_ = try payloadReader.readNextChunkAsString() // "publickey-hostbound-v00@openssh.com"
let hasSignature = try payloadReader.readNextByteAsBool()
let pkAlg = try payloadReader.readNextChunkAsString()
let pk = try payloadReader.readNextChunk()
let hostKey = try payloadReader.readNextChunk()
payload = .init(
raw: rawPayload,
decoded: .init(
.sshConnection(
.init(
username: username,
hasSignature: hasSignature,
publicKeyAlgorithm: pkAlg,
publicKey: pk,
hostKey: hostKey
)
)
)
)
} else {
throw AgentParsingError.unknownRequest
}
} catch {
payload = .init(raw: rawPayload, decoded: nil)
}
}
return SSHAgent.Request.SignatureRequestContext(keyBlob: keyBlob, dataToSign: payload)
}
func protocolExtension(from data: Data) throws(AgentParsingError) -> SSHAgent.ProtocolExtension {
do {
let reader = OpenSSHReader(data: data)
let nameRaw = try reader.readNextChunkAsString()
let nameSplit = nameRaw.split(separator: "@")
guard nameSplit.count == 2 else {
throw AgentParsingError.invalidData
}
let (name, domain) = (nameSplit[0], nameSplit[1])
switch domain {
case SSHAgent.ProtocolExtension.OpenSSHExtension.domain:
switch name {
case SSHAgent.ProtocolExtension.OpenSSHExtension.sessionBind(.empty).name:
let hostkeyBlob = try reader.readNextChunkAsSubReader()
let hostKeyType = try hostkeyBlob.readNextChunkAsString()
let hostKeyData = try hostkeyBlob.readNextChunk()
let sessionID = try reader.readNextChunk()
let signatureBlob = try reader.readNextChunkAsSubReader()
_ = try signatureBlob.readNextChunk() // key type again
let signature = try signatureBlob.readNextChunk()
let forwarding = try reader.readNextByteAsBool()
switch hostKeyType {
// FIXME: FACTOR OUT?
// FIXME: HANDLE OTHER KEYS
case "ssh-ed25519":
let hostKey = try CryptoKit.Curve25519.Signing.PublicKey(rawRepresentation: hostKeyData)
guard hostKey.isValidSignature(signature, for: sessionID) else {
throw AgentParsingError.invalidData
}
default:
throw AgentParsingError.unhandledRequest
}
let context = SSHAgent.ProtocolExtension.OpenSSHExtension.SessionBindContext(
hostKey: hostKeyData,
sessionID: sessionID,
signature: signature,
forwarding: forwarding
)
return .openSSH(.sessionBind(context))
default:
return .openSSH(.unknown(String(name)))
}
default:
return .unknown(nameRaw)
}
} catch let error as OpenSSHReaderError {
throw .openSSHReader(error)
} catch let error as AgentParsingError {
throw error
} catch {
throw .unknownRequest
}
} }
func certificatePublicKeyBlob(from hash: Data) -> Data? { func certificatePublicKeyBlob(from hash: Data) -> Data? {