Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,59 @@ public struct SSHNegotiationFailure: Equatable, Sendable {
case keyExchangeOrHostKey
case cipher
case mac
// Public key sign-in, where the key's signature algorithm is agreed (RFC 8332, RFC 8308).
case keySignature
case undetermined
}

public let stage: Stage
public let serverSoftware: String?
public let supported: [String]
public let supportedHostKeys: [String]
// What the server offered for the stage; empty when its KEXINIT could not be read.
public let offered: [String]
public let offeredHostKeys: [String]

public init(stage: Stage, serverSoftware: String?, supported: [String], supportedHostKeys: [String]) {
public init(
stage: Stage,
serverSoftware: String?,
supported: [String],
supportedHostKeys: [String],
proposal: SSHServerProposal? = nil
) {
self.stage = stage
self.serverSoftware = serverSoftware
self.supported = supported
self.supportedHostKeys = supportedHostKeys

var offered: [String] = []
var offeredHostKeys: [String] = []
if let proposal {
switch stage {
case .keyExchangeOrHostKey:
offered = Self.withoutPseudoMethods(proposal.keyExchange)
offeredHostKeys = Self.unique(proposal.hostKeys)
case .cipher:
offered = Self.unmatched(proposal.ciphersClientToServer, proposal.ciphersServerToClient, supported: supported)
case .mac:
offered = Self.unmatched(proposal.macsClientToServer, proposal.macsServerToClient, supported: supported)
case .keySignature, .undetermined:
break
}
}
self.offered = offered
self.offeredHostKeys = offeredHostKeys
}

// libssh2 fails before sending anything when no signature algorithm it makes for the key is one
// the server's server-sig-algs lists, or when the key type has no signer at all.
public static func keySignature(serverBanner: String?) -> SSHNegotiationFailure {
SSHNegotiationFailure(
stage: .keySignature,
serverSoftware: serverSoftware(banner: serverBanner),
supported: [],
supportedHostKeys: []
)
}

// libssh2 records a key exchange only once one with a usable host key agrees, so a nil one cannot tell the two apart.
Expand All @@ -34,8 +74,9 @@ public struct SSHNegotiationFailure: Equatable, Sendable {
}
let fields = line.split(separator: "-", maxSplits: 2, omittingEmptySubsequences: false)
guard fields.count == 3 else { return nil }
let software = fields[2].prefix { !$0.isWhitespace }
return software.isEmpty ? nil : String(software)
// RFC 4253, 4.2: printable US-ASCII, so a control or bidi character the server sends ends it.
let software = fields[2].unicodeScalars.prefix { (0x21 ... 0x7E).contains($0.value) }
return software.isEmpty ? nil : String(String.UnicodeScalarView(software))
}

public var explanation: String {
Expand All @@ -48,6 +89,8 @@ public struct SSHNegotiationFailure: Equatable, Sendable {
sentences.append(String(localized: "TablePro and the SSH server have no cipher in common."))
case .mac:
sentences.append(String(localized: "TablePro and the SSH server have no MAC algorithm in common."))
case .keySignature:
sentences.append(String(localized: "TablePro and the SSH server have no signature algorithm in common for this key."))
case .undetermined:
sentences.append(String(localized: "TablePro and the SSH server could not agree on how to encrypt the connection."))
}
Expand All @@ -56,6 +99,12 @@ public struct SSHNegotiationFailure: Equatable, Sendable {
sentences.append(String(format: String(localized: "The server runs %@."), serverSoftware))
}

sentences.append(contentsOf: retiredOffers)

if stage == .keySignature {
sentences.append(String(localized: "TablePro signs RSA keys with rsa-sha2-256 or rsa-sha2-512, not with ssh-rsa, which uses SHA-1."))
}

if !supported.isEmpty {
let list = supported.formatted(.list(type: .and))
switch stage {
Expand All @@ -65,7 +114,7 @@ public struct SSHNegotiationFailure: Equatable, Sendable {
sentences.append(String(format: String(localized: "TablePro supports these ciphers: %@."), list))
case .mac:
sentences.append(String(format: String(localized: "TablePro supports these MAC algorithms: %@."), list))
case .undetermined:
case .keySignature, .undetermined:
break
}
}
Expand All @@ -84,10 +133,72 @@ public struct SSHNegotiationFailure: Equatable, Sendable {
String(localized: "Ask the SSH server's administrator to turn on a method TablePro supports.")
}

public static func clientMethods(_ names: [String]) -> [String] {
names.filter { !pseudoMethods.contains($0) }
public static func withoutPseudoMethods(_ names: [String]) -> [String] {
unique(names).filter { !pseudoMethods.contains($0) }
}

// A server can repeat a name any number of times in its KEXINIT.
private static func unique(_ names: [String]) -> [String] {
var seen: Set<String> = []
return names.filter { seen.insert($0).inserted }
}

// Extension negotiation and strict key exchange ride in the key exchange list but no server can pick them.
private static let pseudoMethods: Set<String> = ["ext-info-c", "kex-strict-c-v00@openssh.com"]
// Extension negotiation and strict key exchange ride in the key exchange list but no side can pick them.
private static let pseudoMethods: Set<String> = [
"ext-info-c", "ext-info-s", "kex-strict-c-v00@openssh.com", "kex-strict-s-v00@openssh.com",
]

// What libssh2 1.11.1 in TablePro 0.83.0-beta.1 offered and the bundled libssh2 does not.
// apple/scripts/probes/check-libssh2-algorithms.sh prints the difference.
private static let retired: Set<String> = [
"diffie-hellman-group1-sha1", "diffie-hellman-group14-sha1", "diffie-hellman-group-exchange-sha1",
"ssh-rsa", "ssh-rsa-cert-v01@openssh.com",
"3des-cbc", "blowfish-cbc", "cast128-cbc", "arcfour", "arcfour128",
"hmac-sha1", "hmac-sha1-96", "hmac-sha1-etm@openssh.com", "hmac-md5", "hmac-md5-96",
"hmac-ripemd160", "hmac-ripemd160@openssh.com",
]

private var retiredOffers: [String] {
switch stage {
case .keyExchangeOrHostKey:
var sentences: [String] = []
if let list = Self.onlyRetired(offered) {
sentences.append(String(
format: String(localized: "The server offers only key exchange methods TablePro no longer supports: %@."),
list
))
}
if let list = Self.onlyRetired(offeredHostKeys) {
sentences.append(String(
format: String(localized: "The server offers only host key types TablePro no longer supports: %@."),
list
))
}
return sentences
case .cipher:
return Self.onlyRetired(offered).map {
[String(format: String(localized: "The server offers only ciphers TablePro no longer supports: %@."), $0)]
} ?? []
case .mac:
return Self.onlyRetired(offered).map {
[String(format: String(localized: "The server offers only MAC algorithms TablePro no longer supports: %@."), $0)]
} ?? []
case .keySignature, .undetermined:
return []
}
}

private static func onlyRetired(_ offered: [String]) -> String? {
guard !offered.isEmpty, offered.allSatisfy(retired.contains) else { return nil }
return offered.formatted(.list(type: .and))
}

// libssh2 agrees each direction on its own, so the one with nothing in common is the one that failed.
private static func unmatched(_ clientToServer: [String], _ serverToClient: [String], supported: [String]) -> [String] {
let common = Set(supported)
if !serverToClient.contains(where: common.contains), clientToServer.contains(where: common.contains) {
return unique(serverToClient)
}
return unique(clientToServer)
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
//
// SSHPrivateKeyTraits.swift
// TableProSSHTransport
//

import Foundation

// openssh-key-v1 keeps the cipher name and the public key in the clear, so both read without the passphrase.
Expand Down Expand Up @@ -45,31 +40,3 @@ public struct SSHPrivateKeyTraits: Equatable, Sendable {
return blob.string()
}
}

private struct SSHWireReader {
private let data: [UInt8]
private var offset = 0

init(_ data: [UInt8]) {
self.data = data
}

mutating func bytes(_ count: Int) -> ArraySlice<UInt8>? {
guard count >= 0, count <= data.count - offset else { return nil }
defer { offset += count }
return data[offset..<offset + count]
}

mutating func uint32() -> Int? {
bytes(4)?.reduce(0) { ($0 << 8) | Int($1) }
}

mutating func field() -> ArraySlice<UInt8>? {
guard let length = uint32() else { return nil }
return bytes(length)
}

mutating func string() -> String? {
field().flatMap { String(bytes: $0, encoding: .utf8) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Foundation

// The name-lists of the server's first SSH_MSG_KEXINIT (RFC 4253, 7.1). It travels before any keys
// exist, so it reads in the clear from the bytes the handshake received.
public struct SSHServerProposal: Equatable, Sendable {
public let keyExchange: [String]
public let hostKeys: [String]
public let ciphersClientToServer: [String]
public let ciphersServerToClient: [String]
public let macsClientToServer: [String]
public let macsServerToClient: [String]

init(
keyExchange: [String],
hostKeys: [String],
ciphersClientToServer: [String],
ciphersServerToClient: [String],
macsClientToServer: [String],
macsServerToClient: [String]
) {
self.keyExchange = keyExchange
self.hostKeys = hostKeys
self.ciphersClientToServer = ciphersClientToServer
self.ciphersServerToClient = ciphersServerToClient
self.macsClientToServer = macsClientToServer
self.macsServerToClient = macsServerToClient
}

public init?(received: [UInt8]) {
guard let start = Self.packetsStart(in: received) else { return nil }
var stream = SSHWireReader(Array(received[start...]))

// Before the first NEWKEYS a packet has no MAC. A server may send IGNORE or DEBUG first.
while let length = stream.uint32(), let padding = stream.byte(), length > Int(padding) {
guard let payload = stream.bytes(length - Int(padding) - 1), stream.bytes(Int(padding)) != nil else {
return nil
}
guard payload.first == Self.kexInit else { continue }

var reader = SSHWireReader(Array(payload.dropFirst(1 + Self.cookieLength)))
guard let keyExchange = reader.nameList(),
let hostKeys = reader.nameList(),
let ciphersClientToServer = reader.nameList(),
let ciphersServerToClient = reader.nameList(),
let macsClientToServer = reader.nameList(),
let macsServerToClient = reader.nameList()
else { return nil }
self.init(
keyExchange: keyExchange,
hostKeys: hostKeys,
ciphersClientToServer: ciphersClientToServer,
ciphersServerToClient: ciphersServerToClient,
macsClientToServer: macsClientToServer,
macsServerToClient: macsServerToClient
)
return
}
return nil
}

private static let kexInit: UInt8 = 20
private static let cookieLength = 16

// RFC 4253, 4.2: the server may send other lines before its identification line.
private static func packetsStart(in received: [UInt8]) -> Int? {
let identification = Array("SSH-".utf8)
var lineStart = received.startIndex
while let newline = received[lineStart...].firstIndex(of: UInt8(ascii: "\n")) {
if received[lineStart..<newline].starts(with: identification) {
return newline + 1
}
lineStart = newline + 1
}
return nil
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation

// RFC 4251, 5.
struct SSHWireReader {
private let data: [UInt8]
private var offset = 0

init(_ data: [UInt8]) {
self.data = data
}

mutating func bytes(_ count: Int) -> ArraySlice<UInt8>? {
guard count >= 0, count <= data.count - offset else { return nil }
defer { offset += count }
return data[offset..<offset + count]
}

mutating func byte() -> UInt8? {
bytes(1)?.first
}

mutating func uint32() -> Int? {
bytes(4)?.reduce(0) { ($0 << 8) | Int($1) }
}

mutating func field() -> ArraySlice<UInt8>? {
guard let length = uint32() else { return nil }
return bytes(length)
}

mutating func string() -> String? {
field().flatMap { String(bytes: $0, encoding: .utf8) }
}

mutating func nameList() -> [String]? {
string().map { $0.split(separator: ",").map(String.init) }
}
}
Loading
Loading