Compare commits

...

7 Commits

Author SHA1 Message Date
Max Goedjen
11f1f83113 Cleanup 2025-08-31 17:04:56 -07:00
Max Goedjen
3e128d2a81 WIP 2025-08-31 16:47:19 -07:00
Max Goedjen
935ac32ea2 Factor out comment writer. (#651) 2025-08-31 23:07:35 +00:00
Max Goedjen
a0a632f245 Save nil if empty string. (#650) 2025-08-31 23:03:59 +00:00
Max Goedjen
51fed9e593 Fix potential timing bug (#649) 2025-08-31 14:22:08 -07:00
Max Goedjen
f652d1d961 Return name as identities comment. (#647) 2025-08-31 20:50:16 +00:00
Max Goedjen
8aacd428b1 Fix deleting key attribution (#648) 2025-08-30 22:41:15 +00:00
8 changed files with 183 additions and 50 deletions

View File

@@ -43,7 +43,7 @@ extension Agent {
} }
let requestTypeInt = data[4] let requestTypeInt = data[4]
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else { guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)") logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription) for unknown request type \(requestTypeInt)")
return SSHAgent.ResponseType.agentFailure.data.lengthAndData return SSHAgent.ResponseType.agentFailure.data.lengthAndData
} }
logger.debug("Agent handling request of type \(requestType.debugDescription)") logger.debug("Agent handling request of type \(requestType.debugDescription)")
@@ -66,10 +66,25 @@ extension Agent {
response.append(SSHAgent.ResponseType.agentSignResponse.data) response.append(SSHAgent.ResponseType.agentSignResponse.data)
response.append(try await sign(data: data, provenance: provenance)) response.append(try await sign(data: data, provenance: provenance))
logger.debug("Agent returned \(SSHAgent.ResponseType.agentSignResponse.debugDescription)") logger.debug("Agent returned \(SSHAgent.ResponseType.agentSignResponse.debugDescription)")
case .protocolExtension:
response.append(SSHAgent.ResponseType.agentExtensionResponse.data)
try await handleExtension(data)
default:
let reader = OpenSSHReader(data: data)
while true {
do {
let payloadHash = try reader.readNextChunk()
print(String(String(decoding: payloadHash, as: UTF8.self)))
print(payloadHash)
} catch {
break
}
}
logger.debug("Agent received valid request of type \(requestType.debugDescription), but not currently supported.")
response.append(SSHAgent.ResponseType.agentFailure.data)
} }
} catch { } catch {
response.removeAll() response = SSHAgent.ResponseType.agentFailure.data
response.append(SSHAgent.ResponseType.agentFailure.data)
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)") logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
} }
return response.lengthAndData return response.lengthAndData
@@ -77,6 +92,28 @@ extension Agent {
} }
// PROTOCOL EXTENSIONS
extension Agent {
func handleExtension(_ data: Data) async throws {
let reader = OpenSSHReader(data: data)
guard try reader.readNextChunkAsString() == "session-bind@openssh.com" else { throw UnsupportedExtensionError() }
let hostKey = try reader.readNextChunk()
let keyReader = OpenSSHReader(data: hostKey)
_ = try keyReader.readNextChunkAsString() // Key Type
let keyData = try keyReader.readNextChunk()
let sessionID = try reader.readNextChunk()
let signatureData = try reader.readNextChunk()
let forwarding = try reader.readNextBytes(as: Bool.self)
let signatureReader = OpenSSHSignatureReader()
guard try signatureReader.verify(signatureData, for: sessionID, with: keyData) else { throw SignatureVerificationFailedError() }
print("Fowarding: \(forwarding)")
}
struct UnsupportedExtensionError: Error {}
struct SignatureVerificationFailedError: Error {}
}
extension Agent { extension Agent {
/// Lists the identities available for signing operations /// Lists the identities available for signing operations
@@ -89,9 +126,8 @@ extension Agent {
for secret in secrets { for secret in secrets {
let keyBlob = publicKeyWriter.data(secret: secret) let keyBlob = publicKeyWriter.data(secret: secret)
let curveData = publicKeyWriter.openSSHIdentifier(for: secret.keyType)
keyData.append(keyBlob.lengthAndData) keyData.append(keyBlob.lengthAndData)
keyData.append(curveData.lengthAndData) keyData.append(publicKeyWriter.comment(secret: secret).lengthAndData)
count += 1 count += 1
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) { if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
@@ -113,7 +149,7 @@ 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) async throws -> Data { func sign(data: Data, provenance: SigningRequestProvenance) async throws -> Data {
let reader = OpenSSHReader(data: data) let reader = OpenSSHReader(data: data)
let payloadHash = reader.readNextChunk() let payloadHash = try reader.readNextChunk()
let hash: Data 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
@@ -130,7 +166,7 @@ extension Agent {
try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance) try await witness?.speakNowOrForeverHoldYourPeace(forAccessTo: secret, from: store, by: provenance)
let dataToSign = reader.readNextChunk() let dataToSign = try reader.readNextChunk()
let rawRepresentation = try await store.sign(data: dataToSign, with: secret, for: provenance) let rawRepresentation = try await store.sign(data: dataToSign, with: secret, for: provenance)
let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation) let signedData = signatureWriter.data(secret: secret, signature: rawRepresentation)

View File

@@ -10,13 +10,32 @@ extension SSHAgent {
case requestIdentities = 11 case requestIdentities = 11
case signRequest = 13 case signRequest = 13
case addIdentity = 17
case removeIdentity = 18
case removeAllIdentities = 19
case addIDConstrained = 25
case addSmartcardKey = 20
case removeSmartcardKey = 21
case lock = 22
case unlock = 23
case addSmartcardKeyConstrained = 26
case protocolExtension = 27
public var debugDescription: String { public var debugDescription: String {
switch self { switch self {
case .requestIdentities: case .requestIdentities: "SSH_AGENTC_REQUEST_IDENTITIES"
return "RequestIdentities" case .signRequest: "SSH_AGENTC_SIGN_REQUEST"
case .signRequest: case .addIdentity: "SSH_AGENTC_ADD_IDENTITY"
return "SignRequest" case .removeIdentity: "SSH_AGENTC_REMOVE_IDENTITY"
case .removeAllIdentities: "SSH_AGENTC_REMOVE_ALL_IDENTITIES"
case .addIDConstrained: "SSH_AGENTC_ADD_ID_CONSTRAINED"
case .addSmartcardKey: "SSH_AGENTC_ADD_SMARTCARD_KEY"
case .removeSmartcardKey: "SSH_AGENTC_REMOVE_SMARTCARD_KEY"
case .lock: "SSH_AGENTC_LOCK"
case .unlock: "SSH_AGENTC_UNLOCK"
case .addSmartcardKeyConstrained: "SSH_AGENTC_ADD_SMARTCARD_KEY_CONSTRAINED"
case .protocolExtension: "SSH_AGENTC_EXTENSION"
} }
} }
} }
@@ -28,17 +47,17 @@ extension SSHAgent {
case agentSuccess = 6 case agentSuccess = 6
case agentIdentitiesAnswer = 12 case agentIdentitiesAnswer = 12
case agentSignResponse = 14 case agentSignResponse = 14
case agentExtensionFailure = 28
case agentExtensionResponse = 29
public var debugDescription: String { public var debugDescription: String {
switch self { switch self {
case .agentFailure: case .agentFailure: "SSH_AGENT_FAILURE"
return "AgentFailure" case .agentSuccess: "SSH_AGENT_SUCCESS"
case .agentSuccess: case .agentIdentitiesAnswer: "SSH_AGENT_IDENTITIES_ANSWER"
return "AgentSuccess" case .agentSignResponse: "SSH_AGENT_SIGN_RESPONSE"
case .agentIdentitiesAnswer: case .agentExtensionFailure: "SSH_AGENT_EXTENSION_FAILURE"
return "AgentIdentitiesAnswer" case .agentExtensionResponse: "SSH_AGENT_EXTENSION_RESPONSE"
case .agentSignResponse:
return "AgentSignResponse"
} }
} }
} }

View File

@@ -78,7 +78,6 @@ extension SocketController {
provenance = SigningRequestTracer().provenance(from: fileHandle) provenance = SigningRequestTracer().provenance(from: fileHandle)
(messages, messagesContinuation) = AsyncStream.makeStream() (messages, messagesContinuation) = AsyncStream.makeStream()
Task { [messagesContinuation, logger] in Task { [messagesContinuation, logger] in
await fileHandle.waitForDataInBackgroundAndNotifyOnMainActor()
for await _ in NotificationCenter.default.notifications(named: .NSFileHandleDataAvailable, object: fileHandle) { for await _ in NotificationCenter.default.notifications(named: .NSFileHandleDataAvailable, object: fileHandle) {
let data = fileHandle.availableData let data = fileHandle.availableData
guard !data.isEmpty else { guard !data.isEmpty else {
@@ -91,6 +90,9 @@ extension SocketController {
logger.debug("Socket controller yielded data.") logger.debug("Socket controller yielded data.")
} }
} }
Task {
await fileHandle.waitForDataInBackgroundAndNotifyOnMainActor()
}
} }
/// Writes new data to the socket. /// Writes new data to the socket.

View File

@@ -30,14 +30,15 @@ public actor OpenSSHCertificateHandler: Sendable {
/// - Returns: A ``Data`` object containing the public key in OpenSSH wire format if the ``Data`` is an OpenSSH certificate hash, otherwise nil. /// - 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? { public func publicKeyHash(from hash: Data) -> Data? {
let reader = OpenSSHReader(data: hash) let reader = OpenSSHReader(data: hash)
let certType = String(decoding: reader.readNextChunk(), as: UTF8.self) do {
let certType = String(decoding: try reader.readNextChunk(), as: UTF8.self)
switch certType { switch certType {
case "ecdsa-sha2-nistp256-cert-v01@openssh.com", case "ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com", "ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com": "ecdsa-sha2-nistp521-cert-v01@openssh.com":
_ = reader.readNextChunk() // nonce _ = try reader.readNextChunk() // nonce
let curveIdentifier = reader.readNextChunk() let curveIdentifier = try reader.readNextChunk()
let publicKey = reader.readNextChunk() let publicKey = try reader.readNextChunk()
let openSSHIdentifier = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "") let openSSHIdentifier = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
return openSSHIdentifier.lengthAndData + return openSSHIdentifier.lengthAndData +
@@ -46,6 +47,9 @@ public actor OpenSSHCertificateHandler: Sendable {
default: default:
return nil return nil
} }
} catch {
return nil
}
} }
/// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret`` /// Attempts to find an OpenSSH Certificate that corresponds to a ``Secret``

View File

@@ -31,18 +31,7 @@ public struct OpenSSHPublicKeyWriter: Sendable {
/// Generates an OpenSSH string representation of the secret. /// Generates an OpenSSH string representation of the secret.
/// - Returns: OpenSSH string representation of the secret. /// - Returns: OpenSSH string representation of the secret.
public func openSSHString<SecretType: Secret>(secret: SecretType) -> String { public func openSSHString<SecretType: Secret>(secret: SecretType) -> String {
let resolvedComment: String return [openSSHIdentifier(for: secret.keyType), data(secret: secret).base64EncodedString(), comment(secret: secret)]
if let comment = secret.publicKeyAttribution {
resolvedComment = comment
} else {
let dashedKeyName = secret.name.replacingOccurrences(of: " ", with: "-")
let dashedHostName = ["secretive", Host.current().localizedName, "local"]
.compactMap { $0 }
.joined(separator: ".")
.replacingOccurrences(of: " ", with: "-")
resolvedComment = "\(dashedKeyName)@\(dashedHostName)"
}
return [openSSHIdentifier(for: secret.keyType), data(secret: secret).base64EncodedString(), resolvedComment]
.compactMap { $0 } .compactMap { $0 }
.joined(separator: " ") .joined(separator: " ")
} }
@@ -65,6 +54,19 @@ public struct OpenSSHPublicKeyWriter: Sendable {
.joined(separator: ":") .joined(separator: ":")
} }
public func comment<SecretType: Secret>(secret: SecretType) -> String {
if let comment = secret.publicKeyAttribution {
return comment
} else {
let dashedKeyName = secret.name.replacingOccurrences(of: " ", with: "-")
let dashedHostName = ["secretive", Host.current().localizedName, "local"]
.compactMap { $0 }
.joined(separator: ".")
.replacingOccurrences(of: " ", with: "-")
return "\(dashedKeyName)@\(dashedHostName)"
}
}
} }
extension OpenSSHPublicKeyWriter { extension OpenSSHPublicKeyWriter {
@@ -95,7 +97,7 @@ extension OpenSSHPublicKeyWriter {
extension OpenSSHPublicKeyWriter { extension OpenSSHPublicKeyWriter {
public func rsaPublicKeyBlob<SecretType: Secret>(secret: SecretType) -> Data { func rsaPublicKeyBlob<SecretType: Secret>(secret: SecretType) -> Data {
// Cheap way to pull out e and n as defined in https://datatracker.ietf.org/doc/html/rfc4253 // Cheap way to pull out e and n as defined in https://datatracker.ietf.org/doc/html/rfc4253
// Keychain stores it as a thin ASN.1 wrapper with this format: // Keychain stores it as a thin ASN.1 wrapper with this format:
// [4 byte prefix][2 byte prefix][n][2 byte prefix][e] // [4 byte prefix][2 byte prefix][n][2 byte prefix][e]

View File

@@ -13,7 +13,8 @@ public final class OpenSSHReader {
/// Reads the next chunk of data from the playload. /// Reads the next chunk of data from the playload.
/// - Returns: The next chunk of data. /// - Returns: The next chunk of data.
public func readNextChunk() -> Data { public func readNextChunk() throws -> Data {
guard remaining.count > UInt32.bitWidth/8 else { throw EndOfData() }
let lengthRange = 0..<(UInt32.bitWidth/8) let lengthRange = 0..<(UInt32.bitWidth/8)
let lengthChunk = remaining[lengthRange] let lengthChunk = remaining[lengthRange]
remaining.removeSubrange(lengthRange) remaining.removeSubrange(lengthRange)
@@ -25,4 +26,18 @@ public final class OpenSSHReader {
return ret return ret
} }
public func readNextBytes<T>(as: T.Type) throws -> T {
let lengthRange = 0..<MemoryLayout<T>.size
let lengthChunk = remaining[lengthRange]
remaining.removeSubrange(lengthRange)
return lengthChunk.bytes.unsafeLoad(as: T.self)
}
public func readNextChunkAsString() throws -> String {
try String(decoding: readNextChunk(), as: UTF8.self)
}
public struct EndOfData: Error {}
} }

View File

@@ -0,0 +1,57 @@
import Foundation
import CryptoKit
import Security
/// Reads OpenSSH representations of Secrets.
public struct OpenSSHSignatureReader: Sendable {
/// Initializes the reader.
public init() {
}
public func verify(_ signatureData: Data, for signedData: Data, with publicKey: Data) throws -> Bool {
let reader = OpenSSHReader(data: signatureData)
let signatureType = try reader.readNextChunkAsString()
let signatureData = try reader.readNextChunk()
switch signatureType {
case "ssh-rsa":
let attributes = KeychainDictionary([
kSecAttrKeyType: kSecAttrKeyTypeRSA,
kSecAttrKeySizeInBits: 2048,
kSecAttrKeyClass: kSecAttrKeyClassPublic
])
var verifyError: SecurityError?
let untyped: CFTypeRef? = SecKeyCreateWithData(publicKey as CFData, attributes, &verifyError)
guard let untypedSafe = untyped else {
throw KeychainError(statusCode: errSecSuccess)
}
let key = untypedSafe as! SecKey
return SecKeyVerifySignature(key, .rsaSignatureMessagePKCS1v15SHA512, signedData as CFData, signatureData as CFData, nil)
case "ecdsa-sha2-nistp256":
return try P256.Signing.PublicKey(rawRepresentation: publicKey).isValidSignature(.init(rawRepresentation: signatureData), for: signedData)
case "ecdsa-sha2-nistp384":
return try P384.Signing.PublicKey(rawRepresentation: publicKey).isValidSignature(.init(rawRepresentation: signatureData), for: signedData)
case "ecdsa-sha2-nistp521":
return try P521.Signing.PublicKey(rawRepresentation: publicKey).isValidSignature(.init(rawRepresentation: signatureData), for: signedData)
case "ssh-ed25519":
return try Curve25519.Signing.PublicKey(rawRepresentation: publicKey).isValidSignature(signatureData, for: signedData)
case "ssh-mldsa-65":
if #available(macOS 26.0, *) {
return try MLDSA65.PublicKey(rawRepresentation: publicKey).isValidSignature(signatureData, for: signedData)
} else {
throw UnsupportedSignatureType()
}
case "ssh-mldsa-87":
if #available(macOS 26.0, *) {
return try MLDSA87.PublicKey(rawRepresentation: publicKey).isValidSignature(signatureData, for: signedData)
} else {
throw UnsupportedSignatureType()
}
default:
throw UnsupportedSignatureType()
}
}
public struct UnsupportedSignatureType: Error {}
}

View File

@@ -53,9 +53,7 @@ struct EditSecretView<StoreType: SecretStoreModifiable>: View {
func rename() { func rename() {
var attributes = secret.attributes var attributes = secret.attributes
if !publicKeyAttribution.isEmpty { attributes.publicKeyAttribution = publicKeyAttribution.isEmpty ? nil : publicKeyAttribution
attributes.publicKeyAttribution = publicKeyAttribution
}
Task { Task {
do { do {
try await store.update(secret: secret, name: name, attributes: attributes) try await store.update(secret: secret, name: name, attributes: attributes)