From 15295d5cf72c82788747b18158d39f60d0694c05 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 22 Oct 2026 18:31:00 +0700 Subject: [PATCH 1/2] fix(ssh): name servers limited to SHA-1 and other dropped SSH algorithms --- .../SSHNegotiationFailure.swift | 127 +++++++++++++++- .../SSHPrivateKeyTraits.swift | 33 ---- .../SSHServerProposal.swift | 76 ++++++++++ .../TableProSSHTransport/SSHWireReader.swift | 38 +++++ .../SSHNegotiationFailureTests.swift | 142 +++++++++++++++++- .../SSHServerProposalTests.swift | 74 +++++++++ .../Core/SSH/Auth/AgentAuthenticator.swift | 45 +++--- .../SSH/Auth/PublicKeyAuthenticator.swift | 16 +- .../TablePro/Core/SSH/LibSSH2Handshake.swift | 24 ++- .../Core/SSH/LibSSH2TunnelFactory.swift | 16 +- .../TablePro/Resources/Localizable.xcstrings | 24 +++ .../TableProMobile/Localizable.xcstrings | 24 +++ .../SSH/LibSSH2Authentication.swift | 10 ++ .../SSH/LibSSH2AuthenticationTests.swift | 31 +++- .../Core/SSH/LibSSH2NegotiationTests.swift | 33 +++- .../probes/check-libssh2-algorithms.sh | 85 +++++++++++ .../scripts/probes/check-ssh-tunnel-matrix.sh | 32 +++- apple/scripts/ssh-test-servers.sh | 10 +- .../ssh-test-servers/rsa-host-key.conf | 2 + .../ssh-test-servers/rsa-sha1-signature.conf | 3 + apple/scripts/ssh-test-servers/sha1-kex.conf | 2 + apple/scripts/ssh-test-servers/sha1-mac.conf | 4 + 22 files changed, 754 insertions(+), 97 deletions(-) create mode 100644 apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHServerProposal.swift create mode 100644 apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHWireReader.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHServerProposalTests.swift create mode 100755 apple/scripts/probes/check-libssh2-algorithms.sh create mode 100644 apple/scripts/ssh-test-servers/rsa-host-key.conf create mode 100644 apple/scripts/ssh-test-servers/rsa-sha1-signature.conf create mode 100644 apple/scripts/ssh-test-servers/sha1-kex.conf create mode 100644 apple/scripts/ssh-test-servers/sha1-mac.conf diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHNegotiationFailure.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHNegotiationFailure.swift index 3216f6407c..c3597fb942 100644 --- a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHNegotiationFailure.swift +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHNegotiationFailure.swift @@ -5,6 +5,8 @@ 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 } @@ -12,12 +14,50 @@ public struct SSHNegotiationFailure: Equatable, Sendable { 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. @@ -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 { @@ -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.")) } @@ -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 { @@ -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 } } @@ -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 = [] + 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 = ["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 = [ + "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 = [ + "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) + } } diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHPrivateKeyTraits.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHPrivateKeyTraits.swift index 52d3d5e659..18b942330a 100644 --- a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHPrivateKeyTraits.swift +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHPrivateKeyTraits.swift @@ -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. @@ -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? { - guard count >= 0, count <= data.count - offset else { return nil } - defer { offset += count } - return data[offset.. Int? { - bytes(4)?.reduce(0) { ($0 << 8) | Int($1) } - } - - mutating func field() -> ArraySlice? { - guard let length = uint32() else { return nil } - return bytes(length) - } - - mutating func string() -> String? { - field().flatMap { String(bytes: $0, encoding: .utf8) } - } -} diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHServerProposal.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHServerProposal.swift new file mode 100644 index 0000000000..f16f7ff86c --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHServerProposal.swift @@ -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.. ArraySlice? { + guard count >= 0, count <= data.count - offset else { return nil } + defer { offset += count } + return data[offset.. UInt8? { + bytes(1)?.first + } + + mutating func uint32() -> Int? { + bytes(4)?.reduce(0) { ($0 << 8) | Int($1) } + } + + mutating func field() -> ArraySlice? { + 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) } + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHNegotiationFailureTests.swift b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHNegotiationFailureTests.swift index a6c12dbc5b..7a247c087b 100644 --- a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHNegotiationFailureTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHNegotiationFailureTests.swift @@ -34,13 +34,18 @@ struct SSHNegotiationFailureTests { ("SSH-2.0-OpenSSH_9.9p2 Debian-1", "OpenSSH_9.9p2"), ("SSH-2.0-OpenSSH_9.6p1-hpn\r\n", "OpenSSH_9.6p1-hpn"), ("SSH-1.99-dropbear_2024.86", "dropbear_2024.86"), + ("SSH-2.0-OpenSSH_7.4\u{1B}[31m", "OpenSSH_7.4"), + ("SSH-2.0-OpenSSH_7.4\u{202E}lmth.exe", "OpenSSH_7.4"), ] ) func serverSoftware(banner: String, software: String) { #expect(SSHNegotiationFailure.serverSoftware(banner: banner) == software) } - @Test("A missing or malformed banner has no server software", arguments: [nil, "", "SSH-2.0-", "SSH-2.0", "HTTP/1.1 400 Bad Request"]) + @Test( + "A missing or malformed banner has no server software", + arguments: [nil, "", "SSH-2.0-", "SSH-2.0", "HTTP/1.1 400 Bad Request", "SSH-2.0-\u{202E}OpenSSH"] + ) func noServerSoftware(banner: String?) { #expect(SSHNegotiationFailure.serverSoftware(banner: banner) == nil) } @@ -52,7 +57,7 @@ struct SSHNegotiationFailureTests { "diffie-hellman-group14-sha256", "ext-info-c", "kex-strict-c-v00@openssh.com", ] - #expect(SSHNegotiationFailure.clientMethods(libssh2) == [ + #expect(SSHNegotiationFailure.withoutPseudoMethods(libssh2) == [ "curve25519-sha256", "curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "diffie-hellman-group14-sha256", ]) } @@ -61,7 +66,7 @@ struct SSHNegotiationFailureTests { func clientMethodsKeepRealMethods() { let hostKeys = ["ssh-ed25519", "ecdsa-sha2-nistp256", "rsa-sha2-512", "rsa-sha2-256"] - #expect(SSHNegotiationFailure.clientMethods(hostKeys) == hostKeys) + #expect(SSHNegotiationFailure.withoutPseudoMethods(hostKeys) == hostKeys) } @Test("The failure carries what the app shows") @@ -127,4 +132,135 @@ struct SSHNegotiationFailureTests { #expect(failure.explanation == "TablePro and the SSH server could not agree on how to encrypt the connection.") } + + @Test("A server whose key exchange methods are all ones TablePro dropped is told so, without its pseudo-methods") + func retiredKeyExchange() { + let failure = SSHNegotiationFailure( + stage: .keyExchangeOrHostKey, + serverSoftware: "OpenSSH_7.4", + supported: ["curve25519-sha256"], + supportedHostKeys: ["ssh-ed25519"], + proposal: Self.proposal( + kex: ["diffie-hellman-group14-sha1", "ext-info-s", "kex-strict-s-v00@openssh.com"], + hostKeys: ["ssh-ed25519"] + ) + ) + + #expect(failure.offered == ["diffie-hellman-group14-sha1"]) + #expect(failure.explanation.contains( + "The server runs OpenSSH_7.4. The server offers only key exchange methods TablePro no longer supports: diffie-hellman-group14-sha1. TablePro supports" + )) + #expect(!failure.explanation.contains("host key types TablePro no longer supports")) + } + + @Test("A server with only an ssh-rsa host key names the host key, not the key exchange") + func retiredHostKey() { + let failure = SSHNegotiationFailure( + stage: .keyExchangeOrHostKey, + serverSoftware: nil, + supported: ["curve25519-sha256"], + supportedHostKeys: ["ssh-ed25519", "rsa-sha2-256"], + proposal: Self.proposal(kex: ["curve25519-sha256"], hostKeys: ["ssh-rsa"]) + ) + + #expect(failure.offeredHostKeys == ["ssh-rsa"]) + #expect(failure.explanation.contains("The server offers only host key types TablePro no longer supports: ssh-rsa.")) + #expect(!failure.explanation.contains("key exchange methods TablePro no longer supports")) + } + + @Test( + "A cipher or MAC stage names the server's offer when every method in it is one TablePro dropped", + arguments: [ + (SSHNegotiationFailure.Stage.cipher, ["3des-cbc", "blowfish-cbc"], "The server offers only ciphers TablePro no longer supports: "), + (.mac, ["hmac-sha1", "hmac-md5"], "The server offers only MAC algorithms TablePro no longer supports: "), + ] + ) + func retiredCipherOrMac(stage: SSHNegotiationFailure.Stage, offered: [String], sentence: String) { + let failure = SSHNegotiationFailure( + stage: stage, + serverSoftware: nil, + supported: ["aes128-ctr", "hmac-sha2-256"], + supportedHostKeys: [], + proposal: Self.proposal(ciphers: offered, macs: offered) + ) + + #expect(failure.offered == offered) + #expect(failure.explanation.contains(sentence + offered.formatted(.list(type: .and)) + ".")) + } + + @Test("A name the server repeats is named once") + func repeatedOfferIsNamedOnce() { + let failure = SSHNegotiationFailure( + stage: .keyExchangeOrHostKey, + serverSoftware: nil, + supported: ["curve25519-sha256"], + supportedHostKeys: ["ssh-ed25519"], + proposal: Self.proposal(kex: ["curve25519-sha256"], hostKeys: Array(repeating: "ssh-rsa", count: 5_000)) + ) + + #expect(failure.offeredHostKeys == ["ssh-rsa"]) + #expect(failure.explanation.contains("The server offers only host key types TablePro no longer supports: ssh-rsa. ")) + } + + @Test("An offer that holds a method TablePro never had is not called dropped") + func mixedOfferIsNotRetired() { + let failure = SSHNegotiationFailure( + stage: .mac, + serverSoftware: nil, + supported: ["hmac-sha2-256"], + supportedHostKeys: [], + proposal: Self.proposal(macs: ["hmac-sha1", "umac-128-etm@openssh.com"]) + ) + + #expect(failure.offered == ["hmac-sha1", "umac-128-etm@openssh.com"]) + #expect(!failure.explanation.contains("no longer supports")) + } + + @Test("The offer named is the direction that has nothing in common with TablePro") + func offerIsTheFailingDirection() { + let failure = SSHNegotiationFailure( + stage: .cipher, + serverSoftware: nil, + supported: ["aes128-ctr"], + supportedHostKeys: [], + proposal: Self.proposal(ciphers: ["aes128-ctr"], ciphersServerToClient: ["3des-cbc"]) + ) + + #expect(failure.offered == ["3des-cbc"]) + } + + @Test("Without the server's KEXINIT nothing is named as offered") + func noProposal() { + let failure = SSHNegotiationFailure(stage: .mac, serverSoftware: nil, supported: ["hmac-sha2-256"], supportedHostKeys: []) + + #expect(failure.offered.isEmpty) + #expect(!failure.explanation.contains("The server offers")) + } + + @Test("A key with no signature algorithm in common says how TablePro signs RSA keys") + func keySignature() { + let failure = SSHNegotiationFailure.keySignature(serverBanner: "SSH-2.0-OpenSSH_7.4\r\n") + + #expect(failure.stage == .keySignature) + #expect(failure.explanation == "TablePro and the SSH server have no signature algorithm in common for this key. " + + "The server runs OpenSSH_7.4. " + + "TablePro signs RSA keys with rsa-sha2-256 or rsa-sha2-512, not with ssh-rsa, which uses SHA-1.") + } + + private static func proposal( + kex: [String] = ["curve25519-sha256"], + hostKeys: [String] = ["ssh-ed25519"], + ciphers: [String] = ["aes128-ctr"], + ciphersServerToClient: [String]? = nil, + macs: [String] = ["hmac-sha2-256"] + ) -> SSHServerProposal { + SSHServerProposal( + keyExchange: kex, + hostKeys: hostKeys, + ciphersClientToServer: ciphers, + ciphersServerToClient: ciphersServerToClient ?? ciphers, + macsClientToServer: macs, + macsServerToClient: macs + ) + } } diff --git a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHServerProposalTests.swift b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHServerProposalTests.swift new file mode 100644 index 0000000000..5925dea182 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHServerProposalTests.swift @@ -0,0 +1,74 @@ +import Foundation +import Testing + +@testable import TableProSSHTransport + +struct SSHServerProposalTests { + static let banner = Array("SSH-2.0-OpenSSH_7.4\r\n".utf8) + + @Test("The server's KEXINIT after its identification line gives each name-list") + func readsTheKexInit() throws { + let proposal = try #require(SSHServerProposal(received: Self.banner + Self.kexInit())) + + #expect(proposal.keyExchange == ["diffie-hellman-group14-sha1", "ext-info-s"]) + #expect(proposal.hostKeys == ["ssh-rsa"]) + #expect(proposal.ciphersClientToServer == ["aes128-ctr", "3des-cbc"]) + #expect(proposal.ciphersServerToClient == ["aes256-ctr"]) + #expect(proposal.macsClientToServer == ["hmac-sha1"]) + #expect(proposal.macsServerToClient == ["hmac-md5"]) + } + + @Test("Lines before the identification line and an IGNORE before the KEXINIT are skipped") + func skipsWhatComesFirst() throws { + let received = Array("Authorized use only\r\nSSH-2.0-OpenSSH_7.4\r\n".utf8) + + Self.packet([2] + Self.string("padding the server sends first")) + + Self.kexInit() + + let proposal = try #require(SSHServerProposal(received: received)) + + #expect(proposal.hostKeys == ["ssh-rsa"]) + } + + @Test( + "Anything short of a whole KEXINIT reads as nothing", + arguments: [ + [], + SSHServerProposalTests.banner, + SSHServerProposalTests.kexInit(), + Array((SSHServerProposalTests.banner + SSHServerProposalTests.kexInit()).dropLast(12)), + SSHServerProposalTests.banner + SSHServerProposalTests.packet([1, 0, 0, 0, 2]), + ] + ) + func incompleteReadsAsNothing(received: [UInt8]) { + #expect(SSHServerProposal(received: received) == nil) + } + + // RFC 4253, 7.1. + static func kexInit() -> [UInt8] { + var payload: [UInt8] = [20] + [UInt8](repeating: 0x5A, count: 16) + for list in [ + "diffie-hellman-group14-sha1,ext-info-s", "ssh-rsa", "aes128-ctr,3des-cbc", "aes256-ctr", + "hmac-sha1", "hmac-md5", "none", "none", "", "", + ] { + payload += string(list) + } + payload += [0, 0, 0, 0, 0] + return packet(payload) + } + + // RFC 4253, 6: no MAC before the first NEWKEYS. + static func packet(_ payload: [UInt8]) -> [UInt8] { + var padding = 8 - (5 + payload.count) % 8 + if padding < 4 { padding += 8 } + return uint32(UInt32(1 + payload.count + padding)) + [UInt8(padding)] + payload + + [UInt8](repeating: 0, count: padding) + } + + private static func string(_ value: String) -> [UInt8] { + uint32(UInt32(value.utf8.count)) + Array(value.utf8) + } + + private static func uint32(_ value: UInt32) -> [UInt8] { + withUnsafeBytes(of: value.bigEndian) { Array($0) } + } +} diff --git a/apple/TablePro/Core/SSH/Auth/AgentAuthenticator.swift b/apple/TablePro/Core/SSH/Auth/AgentAuthenticator.swift index 876b9d9ca3..2c375f19a4 100644 --- a/apple/TablePro/Core/SSH/Auth/AgentAuthenticator.swift +++ b/apple/TablePro/Core/SSH/Auth/AgentAuthenticator.swift @@ -1,13 +1,9 @@ -// -// AgentAuthenticator.swift -// TablePro -// - import Foundation import os import CLibSSH2 import TableProSSHConfig +import TableProSSHTransport internal struct AgentAuthenticator: SSHAuthenticator { private static let logger = Logger(subsystem: "com.TablePro", category: "AgentAuthenticator") @@ -15,13 +11,13 @@ internal struct AgentAuthenticator: SSHAuthenticator { let socketPath: String? let socketOrigin: AgentSocketOrigin - /// The `IdentityFile` entries resolved for this host, from `~/.ssh/config` or the form. + // The `IdentityFile` entries resolved for this host, from `~/.ssh/config` or the form. var identityFiles: [String] = [] - /// `IdentitiesOnly yes`, which drops every agent key that no identity file named. + // `IdentitiesOnly yes`, which drops every agent key that no identity file named. var identitiesOnly: Bool = false - /// Resolve SSH_AUTH_SOCK via launchctl for GUI apps that don't inherit shell env. + // An app launched from Finder does not inherit the shell's SSH_AUTH_SOCK. private static func resolveSocketViaLaunchctl() -> String? { let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/launchctl") @@ -98,22 +94,35 @@ internal struct AgentAuthenticator: SSHAuthenticator { } let order = try offerOrder(for: identities) + var unsigned = 0 for (position, index) in order.enumerated() { let authRc = libssh2_agent_userauth(agent, username, identities[index]) if authRc == 0 { Self.logger.info("SSH agent authentication succeeded on key \(position + 1) of \(order.count)") return } + if authRc == LIBSSH2_ERROR_METHOD_NONE { + unsigned += 1 + } + } + + // No key reached the server, which an RSA key does when the server takes only ssh-rsa signatures. + if unsigned > 0, unsigned == order.count { + Self.logger.error("SSH agent authentication failed: no agent key has a signature the server takes") + throw SSHTunnelError.negotiationFailed( + .keySignature(serverBanner: LibSSH2Handshake.banner(session)), + jumpHost: nil + ) } Self.logger.error("SSH agent authentication failed: none of \(order.count) identities accepted") throw SSHTunnelError.authenticationFailed(reason: .agentRejected) } - /// Walks the whole identity list before anything is offered, because the order to offer them - /// in is not the order the agent lists them in. The pointers belong to the agent handle and - /// stay valid until `libssh2_agent_free`, which the caller's `defer` runs after the last - /// `libssh2_agent_userauth`. + // Walks the whole identity list before anything is offered, because the order to offer them + // in is not the order the agent lists them in. The pointers belong to the agent handle and + // stay valid until `libssh2_agent_free`, which the caller's `defer` runs after the last + // `libssh2_agent_userauth`. private func collectIdentities( from agent: OpaquePointer ) throws -> [UnsafeMutablePointer] { @@ -163,14 +172,10 @@ internal struct AgentAuthenticator: SSHAuthenticator { return order } - /// Reached when no identity file yielded a public key, which is two different situations. - /// - /// No identity file at all is the ordinary case: the agent's own order stands and every key is - /// offered, exactly as before there was a preference. An identity file that named nothing - /// readable is a configuration mistake, and quietly offering every key there would hand the - /// user back the `MaxAuthTries` failure they set the file to avoid, with no clue why. Under - /// `IdentitiesOnly` that is refused outright; without it, `ssh` also falls back to the rest of - /// the agent, so the warning is the whole report. + // No identity file at all leaves the agent's own order. An identity file that named nothing + // readable is a configuration mistake, and offering every key there would hand back the + // `MaxAuthTries` failure the file was set to avoid. Under `IdentitiesOnly` that is refused; + // without it, `ssh` also falls back to the rest of the agent, so the warning is the whole report. private func unfilteredOrder( for identities: [UnsafeMutablePointer] ) throws -> [Int] { diff --git a/apple/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift b/apple/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift index a2be19604e..d801376ccd 100644 --- a/apple/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift +++ b/apple/TablePro/Core/SSH/Auth/PublicKeyAuthenticator.swift @@ -1,16 +1,8 @@ -// -// PublicKeyAuthenticator.swift -// TablePro -// -// Pure libssh2 public key authenticator. Takes a path and passphrase, -// performs authentication. No UI, no Keychain, no prompts — those -// responsibilities belong to SSHPassphraseResolver at the factory level. -// - import Foundation import CLibSSH2 import TableProSSHConfig +import TableProSSHTransport internal struct PublicKeyAuthenticator: SSHAuthenticator { let privateKeyPath: String @@ -54,6 +46,12 @@ internal struct PublicKeyAuthenticator: SSHAuthenticator { ) } + if rc == LIBSSH2_ERROR_METHOD_NONE { + throw SSHTunnelError.negotiationFailed( + .keySignature(serverBanner: LibSSH2Handshake.banner(session)), + jumpHost: nil + ) + } guard rc == 0 else { var msgPtr: UnsafeMutablePointer? var msgLen: Int32 = 0 diff --git a/apple/TablePro/Core/SSH/LibSSH2Handshake.swift b/apple/TablePro/Core/SSH/LibSSH2Handshake.swift index f4ba630427..6838c5b707 100644 --- a/apple/TablePro/Core/SSH/LibSSH2Handshake.swift +++ b/apple/TablePro/Core/SSH/LibSSH2Handshake.swift @@ -33,7 +33,7 @@ nonisolated enum LibSSH2Handshake { case (LIBSSH2_ERROR_KEX_FAILURE, .closed?): return .failed(String(localized: "The SSH server closed the connection during the handshake.")) case (LIBSSH2_ERROR_KEX_FAILURE, nil): - return .negotiation(negotiation(session)) + return .negotiation(negotiation(session, proposal: SSHServerProposal(received: watch.received))) case (LIBSSH2_ERROR_TIMEOUT, _): return .timedOut default: @@ -41,7 +41,7 @@ nonisolated enum LibSSH2Handshake { } } - private static func negotiation(_ session: OpaquePointer) -> SSHNegotiationFailure { + private static func negotiation(_ session: OpaquePointer, proposal: SSHServerProposal?) -> SSHNegotiationFailure { let stage = SSHNegotiationFailure.stage( kex: method(session, LIBSSH2_METHOD_KEX), cipher: agreed(session, LIBSSH2_METHOD_CRYPT_CS, LIBSSH2_METHOD_CRYPT_SC), @@ -52,13 +52,13 @@ nonisolated enum LibSSH2Handshake { var supportedHostKeys: [String] = [] switch stage { case .keyExchangeOrHostKey: - supported = SSHNegotiationFailure.clientMethods(algorithms(session, LIBSSH2_METHOD_KEX)) + supported = SSHNegotiationFailure.withoutPseudoMethods(algorithms(session, LIBSSH2_METHOD_KEX)) supportedHostKeys = algorithms(session, LIBSSH2_METHOD_HOSTKEY) case .cipher: supported = algorithms(session, LIBSSH2_METHOD_CRYPT_CS) case .mac: supported = algorithms(session, LIBSSH2_METHOD_MAC_CS) - case .undetermined: + case .keySignature, .undetermined: break } @@ -66,7 +66,8 @@ nonisolated enum LibSSH2Handshake { stage: stage, serverSoftware: SSHNegotiationFailure.serverSoftware(banner: banner(session)), supported: supported, - supportedHostKeys: supportedHostKeys + supportedHostKeys: supportedHostKeys, + proposal: proposal ) } @@ -91,7 +92,7 @@ nonisolated enum LibSSH2Handshake { return (0 ..< Int(count)).compactMap { index in names[index].map { String(cString: $0) } } } - private static func banner(_ session: OpaquePointer) -> String? { + static func banner(_ session: OpaquePointer) -> String? { guard let banner = libssh2_session_banner_get(session) else { return nil } return String(cString: banner) } @@ -134,6 +135,10 @@ nonisolated private final class TransportWatch { fileprivate let recv: LibSSH2Recv? fileprivate let send: LibSSH2Send? fileprivate var end: TransportEnd? + // Holds the server's KEXINIT, so a failed negotiation can name what the server offered. + fileprivate private(set) var received: [UInt8] = [] + // Well past the size of a KEXINIT, and a stop for a server that keeps sending. + private static let receivedLimit = 262_144 init(_ session: OpaquePointer) { self.session = session @@ -168,6 +173,12 @@ nonisolated private final class TransportWatch { if end == nil { end = .closed } } + fileprivate func noteReceived(_ buffer: UnsafeMutableRawPointer?, count: Int) { + guard let buffer, count > 0, received.count < Self.receivedLimit else { return } + let kept = min(count, Self.receivedLimit - received.count) + received.append(contentsOf: UnsafeRawBufferPointer(start: buffer, count: kept)) + } + fileprivate static func of(_ abstract: UnsafeMutablePointer?) -> TransportWatch? { guard let pointer = abstract?.pointee else { return nil } return Unmanaged.fromOpaque(pointer).takeUnretainedValue() @@ -186,6 +197,7 @@ nonisolated private func watchedRecv( if received == 0 || (received < 0 && received != -Int(EAGAIN)) { watch.noteClosed() } + watch.noteReceived(buffer, count: received) return received } diff --git a/apple/TablePro/Core/SSH/LibSSH2TunnelFactory.swift b/apple/TablePro/Core/SSH/LibSSH2TunnelFactory.swift index b9e969c43d..d59a6bf610 100644 --- a/apple/TablePro/Core/SSH/LibSSH2TunnelFactory.swift +++ b/apple/TablePro/Core/SSH/LibSSH2TunnelFactory.swift @@ -858,9 +858,8 @@ internal enum LibSSH2TunnelFactory { ) } - /// Authenticator that resolves the passphrase at AUTH time (not build time), - /// then delegates to PublicKeyAuthenticator. Saves to Keychain and adds to - /// agent only after authentication succeeds. + // Resolves the passphrase at sign-in, not when the chain is built, and saves it or adds the key + // to the agent only once the server accepts the key. private struct KeyFileAuthenticator: SSHAuthenticator { let keyPath: String let providedPassphrase: String? @@ -894,11 +893,14 @@ internal enum LibSSH2TunnelFactory { return } catch { // A wire-level rejection (or a partial success the server accepted as one - // factor of publickey,keyboard-interactive) reports AUTHENTICATION_FAILED, and a - // missing or unreadable file fails before libssh2 runs, leaving its errno stale. - // No passphrase can change either, so rethrow instead of prompting. + // factor of publickey,keyboard-interactive) reports AUTHENTICATION_FAILED, a key + // with no signature algorithm the server takes reports METHOD_NONE, and a missing + // or unreadable file fails before libssh2 runs, leaving its errno stale. No + // passphrase can change any of them, so rethrow instead of prompting. + let failure = libssh2_session_last_errno(session) guard FileManager.default.isReadableFile(atPath: expandedPath), - libssh2_session_last_errno(session) != LIBSSH2_ERROR_AUTHENTICATION_FAILED else { + failure != LIBSSH2_ERROR_AUTHENTICATION_FAILED, + failure != LIBSSH2_ERROR_METHOD_NONE else { throw error } } diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index b7a17b1beb..3b0a92576b 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -620,6 +620,14 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "TablePro and the SSH server have no signature algorithm in common for this key." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "TablePro signs RSA keys with rsa-sha2-256 or rsa-sha2-512, not with ssh-rsa, which uses SHA-1." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "TablePro supports these ciphers: %@." : { "extractionState" : "manual", "generatesSymbol" : false @@ -704,6 +712,22 @@ }, "The server refused a change. The changes before it were saved." : { + }, + "The server offers only ciphers TablePro no longer supports: %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The server offers only host key types TablePro no longer supports: %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The server offers only key exchange methods TablePro no longer supports: %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The server offers only MAC algorithms TablePro no longer supports: %@." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "The server runs %@." : { "extractionState" : "manual", diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index 880ccd0cc6..5af40e4fcd 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -22788,6 +22788,14 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "TablePro and the SSH server have no signature algorithm in common for this key." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "TablePro signs RSA keys with rsa-sha2-256 or rsa-sha2-512, not with ssh-rsa, which uses SHA-1." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "TablePro can't open “%@”." : { "localizations" : { "ko" : { @@ -25747,6 +25755,22 @@ } } }, + "The server offers only ciphers TablePro no longer supports: %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The server offers only host key types TablePro no longer supports: %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The server offers only key exchange methods TablePro no longer supports: %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The server offers only MAC algorithms TablePro no longer supports: %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The server runs %@." : { "extractionState" : "manual", "generatesSymbol" : false diff --git a/apple/TableProMobile/TableProMobile/SSH/LibSSH2Authentication.swift b/apple/TableProMobile/TableProMobile/SSH/LibSSH2Authentication.swift index eca06288c2..b3ad4ae818 100644 --- a/apple/TableProMobile/TableProMobile/SSH/LibSSH2Authentication.swift +++ b/apple/TableProMobile/TableProMobile/SSH/LibSSH2Authentication.swift @@ -7,6 +7,7 @@ nonisolated protocol SSHUserAuthSession { func methodList() -> String? func isAuthenticated() -> Bool func lastError() -> String? + func serverBanner() -> String? func password(_ password: String) -> Int32 func keyboardInteractive(_ answers: KeyboardInteractiveAnswers) -> Int32 func publicKeyFromFile(privateKeyPath: String, publicKeyPath: String?, passphrase: String?) -> Int32 @@ -216,6 +217,11 @@ nonisolated struct LibSSH2Authentication { } if rc == 0 { return } + // libssh2 sent nothing, since it makes no signature for this key that the server takes. + if rc == LIBSSH2_ERROR_METHOD_NONE { + throw SSHTunnelError.negotiationFailed(.keySignature(serverBanner: session.serverBanner()), jumpHost: nil) + } + // These come from reading the key before anything reaches the server, so the list is // unchanged and reading it again would only cost a try, since OpenSSH counts a second "none". if Self.localKeyFailures.contains(rc) { @@ -334,6 +340,10 @@ nonisolated struct LibSSH2UserAuthSession: SSHUserAuthSession { return String(cString: message) } + func serverBanner() -> String? { + LibSSH2Handshake.banner(session) + } + func password(_ password: String) -> Int32 { libssh2_userauth_password_ex( session, diff --git a/apple/TableProMobile/TableProMobileTests/SSH/LibSSH2AuthenticationTests.swift b/apple/TableProMobile/TableProMobileTests/SSH/LibSSH2AuthenticationTests.swift index 45dcc552bb..0e73e76c51 100644 --- a/apple/TableProMobile/TableProMobileTests/SSH/LibSSH2AuthenticationTests.swift +++ b/apple/TableProMobile/TableProMobileTests/SSH/LibSSH2AuthenticationTests.swift @@ -7,6 +7,7 @@ import Testing struct LibSSH2AuthenticationTests { // libssh2's return codes, named here because the test target does not import CLibSSH2. static let rcFile: Int32 = -16 + static let rcMethodNone: Int32 = -17 static let rcAuthenticationFailed: Int32 = -18 static let rcPublicKeyUnverified: Int32 = -19 static let rcKeyFileAuthFailed: Int32 = -48 @@ -458,6 +459,27 @@ struct LibSSH2AuthenticationTests { #expect(!session.calls.dropFirst().contains(.methodList)) } + @Test( + "A key with no signature algorithm the server takes is named, with no keyboard-interactive after it", + arguments: [true, false] + ) + func keySignatureMismatchIsNamed(encrypted: Bool) { + let session = ScriptedAuthSession( + lists: ["publickey,keyboard-interactive"], + key: .failing(Self.rcMethodNone, "No signing signature matched"), + banner: "SSH-2.0-OpenSSH_7.4" + ) + + #expect(throws: SSHTunnelError.negotiationFailed(.keySignature(serverBanner: "SSH-2.0-OpenSSH_7.4"), jumpHost: nil)) { + try LibSSH2Authentication(session: session).publicKey( + .memory(Self.openSSHKey(type: "ssh-rsa", encrypted: encrypted)), + passphrase: "hunter2", + then: KeyboardInteractiveAnswers(password: "hunter2") + ) + } + #expect(session.calls == [.methodList, .keyMemory]) + } + @Test("An encrypted key file goes without its .pub and a plain one keeps it", arguments: [true, false]) func encryptedKeyFileDropsThePublicKey(encrypted: Bool) throws { let session = ScriptedAuthSession(lists: ["publickey"]) @@ -595,6 +617,7 @@ nonisolated final class ScriptedAuthSession: SSHUserAuthSession { private let prompts: [String] private let keyboardInteractiveReply: Reply private let keyReply: Reply + private let banner: String? private var currentError: String? private(set) var calls: [Call] = [] @@ -607,7 +630,8 @@ nonisolated final class ScriptedAuthSession: SSHUserAuthSession { password: Reply = .accepted, prompts: [String] = [], keyboardInteractive: Reply = .accepted, - key: Reply = .accepted + key: Reply = .accepted, + banner: String? = nil ) { self.lists = lists self.authenticated = authenticated @@ -616,6 +640,7 @@ nonisolated final class ScriptedAuthSession: SSHUserAuthSession { self.prompts = prompts self.keyboardInteractiveReply = keyboardInteractive self.keyReply = key + self.banner = banner } func methodList() -> String? { @@ -637,6 +662,10 @@ nonisolated final class ScriptedAuthSession: SSHUserAuthSession { return currentError } + func serverBanner() -> String? { + banner + } + func password(_ password: String) -> Int32 { calls.append(.password) return reply(passwordReply) diff --git a/apple/TableProTests/Core/SSH/LibSSH2NegotiationTests.swift b/apple/TableProTests/Core/SSH/LibSSH2NegotiationTests.swift index 693051055f..bb93ee71d0 100644 --- a/apple/TableProTests/Core/SSH/LibSSH2NegotiationTests.swift +++ b/apple/TableProTests/Core/SSH/LibSSH2NegotiationTests.swift @@ -40,6 +40,37 @@ struct LibSSH2NegotiationTests { #expect(failure.supported.contains("hmac-sha2-256")) } + @Test( + "A server that offers only methods TablePro dropped is told so, at the stage that failed", + arguments: [ + ( + ServerKexInit(kex: "diffie-hellman-group14-sha1,diffie-hellman-group1-sha1,ext-info-s"), + SSHNegotiationFailure.Stage.keyExchangeOrHostKey, + "The server offers only key exchange methods TablePro no longer supports: ", + ["diffie-hellman-group14-sha1", "diffie-hellman-group1-sha1"] + ), + ( + ServerKexInit(hostKey: "ssh-rsa"), + .keyExchangeOrHostKey, + "The server offers only host key types TablePro no longer supports: ", + ["ssh-rsa"] + ), + (ServerKexInit(cipher: "3des-cbc"), .cipher, "The server offers only ciphers TablePro no longer supports: ", ["3des-cbc"]), + ( + ServerKexInit(mac: "hmac-sha1,hmac-md5"), + .mac, + "The server offers only MAC algorithms TablePro no longer supports: ", + ["hmac-sha1", "hmac-md5"] + ), + ] + ) + func retiredOffer(kexInit: ServerKexInit, stage: SSHNegotiationFailure.Stage, sentence: String, offered: [String]) throws { + let failure = try negotiationFailure(offering: kexInit) + + #expect(failure.stage == stage) + #expect(failure.explanation.contains(sentence + offered.formatted(.list(type: .and)) + ".")) + } + @Test("A server that hangs up after its banner is not a negotiation failure") func hangUpAfterBannerIsNotANegotiation() throws { let error = try handshakeError(serverWrites: Self.banner, thenHangsUp: true) @@ -146,7 +177,7 @@ private enum SSHPacket { } // RFC 4253, 7.1. -private struct ServerKexInit { +struct ServerKexInit: Sendable { var kex = "curve25519-sha256" var hostKey = "ssh-ed25519" var cipher = "aes128-ctr" diff --git a/apple/scripts/probes/check-libssh2-algorithms.sh b/apple/scripts/probes/check-libssh2-algorithms.sh new file mode 100755 index 0000000000..e882ea1477 --- /dev/null +++ b/apple/scripts/probes/check-libssh2-algorithms.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# +# Lists the SSH methods an older libs- release's libssh2 offered and Libs/libssh2.a does not, and +# the other way round, by key exchange, host key, cipher and MAC. Each list comes from +# libssh2_session_supported_algs, the lists a handshake offers. +# +# SSHNegotiationFailure in TableProSSHTransport tells the user a server offers only methods TablePro +# no longer supports from a list written by hand. Run this after rebuilding libssh2 and compare. +# +# Usage: scripts/probes/check-libssh2-algorithms.sh [libs-] # default libs-2, which 0.83.0-beta.1 ships +# +# Needs gh, or curl, to fetch that release's macOS archive. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OLD="${1:-libs-2}" +REPO="TableProApp/TablePro" +HEADERS="$ROOT/TablePro/Core/SSH/CLibSSH2/include" +ARCHIVE="tablepro-libs-${OLD#libs-}.tar.gz" + +for required in "$ROOT/Libs/libssh2.a" "$ROOT/Libs/libssl.a" "$ROOT/Libs/libcrypto.a" "$HEADERS/libssh2.h"; do + if [ ! -e "$required" ]; then + echo "error: $required is missing. Run scripts/download-libs.sh first." >&2 + exit 1 + fi +done + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +mkdir -p "$WORK/old" + +if command -v gh > /dev/null 2>&1; then + gh release download "$OLD" --repo "$REPO" --pattern "$ARCHIVE" --dir "$WORK" > /dev/null +else + curl -fsSL -o "$WORK/$ARCHIVE" "https://github.com/$REPO/releases/download/$OLD/$ARCHIVE" +fi +tar xzf "$WORK/$ARCHIVE" -C "$WORK/old" 'libssh2*.a' 'libssl*.a' 'libcrypto*.a' + +cat > "$WORK/algorithms.c" << 'EOF' +#include +#include + +static void print(LIBSSH2_SESSION *session, int type, const char *label) { + const char **names = NULL; + int count = libssh2_session_supported_algs(session, type, &names); + for (int index = 0; index < count; index++) { + printf("%-8s %s\n", label, names[index]); + } + if (count > 0) { + libssh2_free(session, names); + } +} + +int main(void) { + libssh2_init(0); + LIBSSH2_SESSION *session = libssh2_session_init(); + print(session, LIBSSH2_METHOD_KEX, "kex"); + print(session, LIBSSH2_METHOD_HOSTKEY, "hostkey"); + print(session, LIBSSH2_METHOD_CRYPT_CS, "cipher"); + print(session, LIBSSH2_METHOD_MAC_CS, "mac"); + fprintf(stderr, "libssh2 %s\n", libssh2_version(0)); + libssh2_session_free(session); + libssh2_exit(); + return 0; +} +EOF + +list() { + local libs="$1" name="$2" + clang -arch "$(uname -m)" -mmacosx-version-min=13.0 -I "$HEADERS" "$WORK/algorithms.c" \ + "$libs/libssh2.a" "$libs/libssl.a" "$libs/libcrypto.a" -lz -o "$WORK/$name-probe" + "$WORK/$name-probe" 2> "$WORK/$name.version" | sort > "$WORK/$name.txt" +} + +list "$WORK/old" old +list "$ROOT/Libs" current + +echo "$OLD: $(cat "$WORK/old.version"), Libs/: $(cat "$WORK/current.version")" +echo "" +echo "Offered by $OLD, not by Libs/:" +comm -23 "$WORK/old.txt" "$WORK/current.txt" +echo "" +echo "Offered by Libs/, not by $OLD:" +comm -13 "$WORK/old.txt" "$WORK/current.txt" diff --git a/apple/scripts/probes/check-ssh-tunnel-matrix.sh b/apple/scripts/probes/check-ssh-tunnel-matrix.sh index eb6fbbd8a3..ce51c5e621 100755 --- a/apple/scripts/probes/check-ssh-tunnel-matrix.sh +++ b/apple/scripts/probes/check-ssh-tunnel-matrix.sh @@ -10,10 +10,12 @@ # ssh-tunnel-matrix/build-ios-harness.sh for the simulator, where every cell runs through # `xcrun simctl spawn -s` with no booted device, and build-mac-harness.sh for this Mac, where cells # run with an empty home (CFFIXED_USER_HOME, so no ~/.ssh/config) and an ssh-agent started here. -# A server limited to a method the harness's libssh2 lacks expects that app's named negotiation +# A server limited to a method the app's libssh2 lacks expects that app's named negotiation # failure, and a connection once a later libssh2 offers the method: mlkem768x25519-sha256 on pq10 -# and pq9, sntrup761x25519-sha512 on kex10, 3des-cbc on cipher10. The servers come from -# scripts/ssh-test-servers.sh. It is a probe, not a test, because no CI job has Docker. +# and pq9, sntrup761x25519-sha512 on kex10, 3des-cbc on cipher10. sha1kex, rsahost, sha1mac and +# rsasig allow only methods the bundled libssh2 no longer builds, and expect the failure to say so. +# The servers come from scripts/ssh-test-servers.sh. It is a probe, not a test, because no CI job +# has Docker. # # Usage: # scripts/probes/check-ssh-tunnel-matrix.sh # every cell, then remove the servers @@ -143,6 +145,7 @@ fi eval "$("$SERVERS" env)" || setup_failure "could not read the SSH test servers' settings" if wanted macos; then start_agent agent ed25519 + start_agent rsa-agent rsa # More keys ahead of the right one than maxauth allows tries (#2601). # shellcheck disable=SC2086 # one key name per word start_agent crowded-agent $SSH_TEST_DECOY_KEYS ed25519 @@ -230,6 +233,11 @@ loss_cell() { KEX_TEXT="TablePro and the SSH server have no key exchange or host key method in common." CIPHER_TEXT="TablePro and the SSH server have no cipher in common." MAC_TEXT="TablePro and the SSH server have no MAC algorithm in common." +SIGNATURE_TEXT="TablePro and the SSH server have no signature algorithm in common for this key." +DROPPED_KEX="The server offers only key exchange methods TablePro no longer supports: " +DROPPED_HOST_KEY="The server offers only host key types TablePro no longer supports: " +DROPPED_CIPHER="The server offers only ciphers TablePro no longer supports: " +DROPPED_MAC="The server offers only MAC algorithms TablePro no longer supports: " # Each app names any other failure in its own words. A key the Mac cannot use ends at the # passphrase prompt, which the macOS harness dismisses, as the user would to give up. @@ -237,11 +245,13 @@ expectations() { case "$APP" in ios) HARNESS="$IOS_HARNESS" + LIBSSH2="$ROOT/Libs/ios/LibSSH2.xcframework/ios-arm64-simulator/libssh2.a" RUN=(xcrun simctl spawn -s "$UDID" "$HARNESS") KEY_SOURCES="file memory" KEX="ERROR negotiationFailed(*Stage.keyExchangeOrHostKey,*) | $KEX_TEXT*" CIPHER="ERROR negotiationFailed(*Stage.cipher,*) | $CIPHER_TEXT*" MAC="ERROR negotiationFailed(*Stage.mac,*) | $MAC_TEXT*" + SIGNATURE="ERROR negotiationFailed(*Stage.keySignature,*) | $SIGNATURE_TEXT*" SECURITY_KEY="ERROR securityKeyUnsupported |*" NO_PASSPHRASE="ERROR passphraseRequired |*" WRONG_PASSPHRASE="ERROR passphraseIncorrect |*" @@ -250,11 +260,13 @@ expectations() { ;; macos) HARNESS="$MAC_HARNESS" + LIBSSH2="$ROOT/Libs/libssh2.a" RUN=(env "CFFIXED_USER_HOME=$WORK/home" "SSH_AUTH_SOCK=$WORK/agent.sock" "$HARNESS") KEY_SOURCES="file" KEX="ERROR negotiationFailed(*Stage.keyExchangeOrHostKey,*jumpHost: nil) | $KEX_TEXT*" CIPHER="ERROR negotiationFailed(*Stage.cipher,*jumpHost: nil) | $CIPHER_TEXT*" MAC="ERROR negotiationFailed(*Stage.mac,*jumpHost: nil) | $MAC_TEXT*" + SIGNATURE="ERROR negotiationFailed(*Stage.keySignature,*jumpHost: nil) | $SIGNATURE_TEXT*" SECURITY_KEY="ERROR authenticationFailed(reason: *securityKeyFile) |*" NO_PASSPHRASE="ERROR authenticationFailed(reason: *privateKey) |*" WRONG_PASSPHRASE="$NO_PASSPHRASE" @@ -269,9 +281,10 @@ hop_kex() { echo "ERROR negotiationFailed(*Stage.keyExchangeOrHostKey,*jumpHost: Optional(\"$1\")) | Jump host $1 failed: $KEX_TEXT*" } -# The expectation for a server limited to $1: a connection once the harness's libssh2 offers it. +# The expectation for a server limited to $1: a connection once the app's libssh2 offers it. The +# archive, not the harness, since the app's own code names the methods libssh2 dropped. lacking() { - if grep -a -q -- "$1" "$HARNESS"; then + if grep -a -q -- "$1" "$LIBSSH2"; then echo "CONNECTED" else echo "$2" @@ -320,8 +333,15 @@ run_cells() { cell pq10/password "$(lacking mlkem768x25519-sha256 "$KEX")" "$SSH_TEST_PORT_PQ10" "${PASSWORD[@]}" cell pq9/password "$(lacking mlkem768x25519-sha256 "$KEX")" "$SSH_TEST_PORT_PQ9" "${PASSWORD[@]}" cell kex10/password "$(lacking sntrup761x25519-sha512 "$KEX")" "$SSH_TEST_PORT_KEX10" "${PASSWORD[@]}" - cell cipher10/password "$(lacking 3des-cbc "$CIPHER")" "$SSH_TEST_PORT_CIPHER10" "${PASSWORD[@]}" + cell cipher10/password "$(lacking 3des-cbc "$CIPHER$DROPPED_CIPHER*")" "$SSH_TEST_PORT_CIPHER10" "${PASSWORD[@]}" cell mac10/password "$MAC" "$SSH_TEST_PORT_MAC10" "${PASSWORD[@]}" + cell sha1kex/password "$KEX$DROPPED_KEX*" "$SSH_TEST_PORT_SHA1KEX" "${PASSWORD[@]}" + cell rsahost/password "$KEX$DROPPED_HOST_KEY*" "$SSH_TEST_PORT_RSAHOST" "${PASSWORD[@]}" + cell sha1mac/password "$MAC$DROPPED_MAC*" "$SSH_TEST_PORT_SHA1MAC" "${PASSWORD[@]}" + # An RSA key has no signature this server takes, and no passphrase would change that. + cell rsasig/rsa/file "$SIGNATURE" "$SSH_TEST_PORT_RSASIG" --auth key --key-file "$SSH_TEST_KEYS/rsa" + on ios && cell rsasig/rsa/memory "$SIGNATURE" "$SSH_TEST_PORT_RSASIG" --auth key --key-memory "$SSH_TEST_KEYS/rsa" + on macos && cell rsasig/rsa/agent "$SIGNATURE" "$SSH_TEST_PORT_RSASIG" --auth agent --agent-socket "$WORK/rsa-agent.sock" for source in $KEY_SOURCES; do flag="--key-$source" diff --git a/apple/scripts/ssh-test-servers.sh b/apple/scripts/ssh-test-servers.sh index 434e2ecc93..4d6bec50d2 100755 --- a/apple/scripts/ssh-test-servers.sh +++ b/apple/scripts/ssh-test-servers.sh @@ -20,8 +20,8 @@ # ssh-test-servers.sh start # start a killed server again and wait for its banner # ssh-test-servers.sh env # NAME=value lines for a runner to eval # -# Servers: v10 v9 pq10 pq9 kex10 cipher10 mac10 rsa256 rsa512 kbdpass passtotp passtotp1 keytotp totp -# maxauth hop1 hop2 loss. +# Servers: v10 v9 pq10 pq9 kex10 cipher10 mac10 sha1kex rsahost sha1mac rsasig rsa256 rsa512 kbdpass passtotp +# passtotp1 keytotp totp maxauth hop1 hop2 loss. # A bare up removes what an earlier up left and makes new keys; up with names restarts only those. # Ports run from TABLEPRO_SSH_TEST_PORT_BASE + 1 (default 22601). Keys live in TABLEPRO_SSH_TEST_DIR # (default $TMPDIR/tablepro-ssh-test-servers). decoy-1 to decoy-4 are keys no server accepts, for an @@ -45,7 +45,7 @@ PASSWORD="tppass" KEY_PASSPHRASE="tp-key-pass" # The usual documentation example: it signs in to these throwaway servers and nowhere else. TOTP_SECRET="JBSWY3DPEHPK3PXP" -SERVERS="v10 v9 pq10 pq9 kex10 cipher10 mac10 rsa256 rsa512 kbdpass passtotp passtotp1 keytotp totp maxauth hop1 hop2 loss" +SERVERS="v10 v9 pq10 pq9 kex10 cipher10 mac10 sha1kex rsahost sha1mac rsasig rsa256 rsa512 kbdpass passtotp passtotp1 keytotp totp maxauth hop1 hop2 loss" AUTHORIZED_KEYS="ed25519 ed25519-pass rsa rsa-pass ecdsa ecdsa-pass rsa-pem rsa-pem-pass ecdsa-pem ecdsa-pem-pass sk-ed25519" DECOY_KEYS="decoy-1 decoy-2 decoy-3 decoy-4" @@ -70,6 +70,10 @@ server_spec() { mac10) echo "10 5 mac-only true" ;; kex10) echo "10 16 kex-only true" ;; cipher10) echo "10 17 cipher-only true" ;; + sha1kex) echo "10 18 sha1-kex true" ;; + rsahost) echo "10 19 rsa-host-key true" ;; + sha1mac) echo "10 20 sha1-mac true" ;; + rsasig) echo "10 21 rsa-sha1-signature true" ;; rsa256) echo "10 6 rsa-sha2-256 true" ;; rsa512) echo "10 7 rsa-sha2-512 true" ;; kbdpass) echo "10 8 kbdint-password true" ;; diff --git a/apple/scripts/ssh-test-servers/rsa-host-key.conf b/apple/scripts/ssh-test-servers/rsa-host-key.conf new file mode 100644 index 0000000000..9ae211cbf0 --- /dev/null +++ b/apple/scripts/ssh-test-servers/rsa-host-key.conf @@ -0,0 +1,2 @@ +# Only the RSA host key signed with SHA-1, which the bundled libssh2 no longer builds. +HostKeyAlgorithms ssh-rsa diff --git a/apple/scripts/ssh-test-servers/rsa-sha1-signature.conf b/apple/scripts/ssh-test-servers/rsa-sha1-signature.conf new file mode 100644 index 0000000000..56dc9fa622 --- /dev/null +++ b/apple/scripts/ssh-test-servers/rsa-sha1-signature.conf @@ -0,0 +1,3 @@ +# server-sig-algs then lists only ssh-rsa, the RSA signature with SHA-1 the bundled libssh2 no +# longer makes. +PubkeyAcceptedAlgorithms ssh-rsa diff --git a/apple/scripts/ssh-test-servers/sha1-kex.conf b/apple/scripts/ssh-test-servers/sha1-kex.conf new file mode 100644 index 0000000000..b40632a53d --- /dev/null +++ b/apple/scripts/ssh-test-servers/sha1-kex.conf @@ -0,0 +1,2 @@ +# Only the SHA-1 key exchanges the bundled libssh2 no longer builds. +KexAlgorithms diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1,diffie-hellman-group1-sha1 diff --git a/apple/scripts/ssh-test-servers/sha1-mac.conf b/apple/scripts/ssh-test-servers/sha1-mac.conf new file mode 100644 index 0000000000..62a04beaa9 --- /dev/null +++ b/apple/scripts/ssh-test-servers/sha1-mac.conf @@ -0,0 +1,4 @@ +# Key exchange and cipher agree, and only SHA-1 and MD5 MACs, which the bundled libssh2 no longer +# builds, are left. +Ciphers aes128-ctr +MACs hmac-sha1,hmac-sha1-etm@openssh.com,hmac-md5 From 38ae5cfeb202a3c837da9e6d0680c6696f2c0412 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 22 Oct 2026 18:31:30 +0700 Subject: [PATCH 2/2] docs(changelog): note the SSH algorithms tunnels no longer offer --- apple/changelog.d/3782.changed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3782.changed.md diff --git a/apple/changelog.d/3782.changed.md b/apple/changelog.d/3782.changed.md new file mode 100644 index 0000000000..67a98e8522 --- /dev/null +++ b/apple/changelog.d/3782.changed.md @@ -0,0 +1 @@ +SSH tunnels no longer offer SHA-1 key exchange, ssh-rsa, SHA-1 and MD5 MACs, or 3DES, Blowfish and RC4 ciphers.