sshnet / sshnet/SSH.NET

Host-key verification regression in 2025.1.0: KeyExchange.Finish throws "Host key could not be verified." against legacy non-OpenSSH SFTP servers (works on 2025.0.0)

Open
#1,799 7 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
C#
Stars
4.4k
Forks
993
Avg merge
9d 21h
Merged PRs (30d)
1

Description

SftpClient.Connect()throwsSshConnectionException: Host key could not be verified.insideKeyExchange.Finishagainst a RoboFTP v3.5.2 (2019) SFTP server with SSH.NET **2025.1.0**. TheHostKeyReceived` event never fires — verification fails before our event handler can inspect the fingerprint.

The same code path works with SSH.NET 2025.0.0 and 2024.2.0 against the same server, and works with 2025.1.0 against a modern OpenSSH server (atmoz/sftp:alpine). All other variablariables (private key, fingerprint, target host, .NET 10 runtime, Linux client) are identical across runs.

This appears to be a regression introduced between 2025.0.0 and 2025.1.0 — most likely in the major KEX-layer refactor (KeyExchange.cs +61/-55, consolidation of 10 DH classes, KeyExchangeECDH.cs, KeyExchangeECCurve25519.cs, KeyExchangeMLKem768X25519Sha256.cs, KeyExchangeSNtruP761X25519Sha512.cs all touched).

Environment

  • SSH.NET versions tested: 2024.2.0, 2025.0.0, 2025.1.0
  • Runtime: .NET 10 (net10.0, Linux x64 / WSL2)
  • Failing server: RoboFTP v3.5.2 (2019), advertises older OpenSSH-compatible algorithms:
    • KEX offered: ecdh-sha2-nistp256, ecdh-sha2-nistp384, ecdh-sha2-nistp521, diffie-hellman-group18-sha512, diffie-hellman-group16-sha512, diffie-hellman-group-exchange-sha256, diffie-hellman-group14-sha1, diffie-hellman-group1-sha1
    • Host key: RSA 2048
    • HKA chosen (via ssh -vvv): rsa-sha2-512
  • Control server (works on 2025.1.0): atmoz/sftp:alpine (modern OpenSSH 9.x)
  • OpenSSH client: connects successfully to the same RoboFTP server with the same key — server-side is not at fault.

Stack trace

Renci.SshNet.Common.SshConnectionException: Host key could not be verified.
   at Renci.SshNet.Security.KeyExchange.Finish()
   at Renci.SshNet.Security.KeyExchangeECDH.Finish()
   at Renci.SshNet.Security.KeyExchangeECDH.Session_KeyExchangeEcdhReplyMessageReceived(Object sender, MessageEventArgs`1 e)
   at Renci.SshNet.Session.OnKeyExchangeEcdhReplyMessageReceived(KeyExchangeEcdhReplyMessage message)
   at Renci.SshNet.Messages.Transport.KeyExchangeEcdhReplyMessage.Process(Session session)
   at Renci.SshNet.Session.MessageListener()
--- End of stack trace from previous location ---
   at Renci.SshNet.Session.WaitOnHandle(WaitHandle waitHandle, TimeSpan timeout)
   at Renci.SshNet.Session.Connect()
   at Renci.SshNet.BaseClient.CreateAndConnectSession()
   at Renci.SshNet.BaseClient.Connect()

HostKeyReceived is never raised — the failure happens inside KeyExchange.ValidateExchangeHash (called from Finish), so CanTrustHostKey is never reached.

Reproduction (minimal, ~80 lines)

SshNetRepro.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="SSH.NET" Version="2025.1.0" />
  </ItemGroup>
</Project>

Program.cs:

using Renci.SshNet;
using Renci.SshNet.Common;

string host = Environment.GetEnvironmentVariable("SFTP_HOST")!;
int port = int.Parse(Environment.GetEnvironmentVariable("SFTP_PORT") ?? "22");
string username = Environment.GetEnvironmentVariable("SFTP_USER")!;
string keyPath = Environment.GetEnvironmentVariable("SFTP_KEY")!;
string expectedFingerprint = Environment.GetEnvironmentVariable("SFTP_FINGERPRINT")!.TrimEnd('=');
string? passphrase = Environment.GetEnvironmentVariable("SFTP_PASSPHRASE");

Console.WriteLine($"SSH.NET: {typeof(SftpClient).Assembly.GetName().Version}");

PrivateKeyFile key = string.IsNullOrEmpty(passphrase)
    ? new PrivateKeyFile(keyPath)
    : new PrivateKeyFile(keyPath, passphrase);

ConnectionInfo info = new(host, port, username,
    new PrivateKeyAuthenticationMethod(username, key));

using SftpClient client = new(info);
client.HostKeyReceived += (_, e) =>
{
    string fp = (e.FingerPrintSHA256 ?? "").TrimEnd('=');
    Console.WriteLine($"HostKeyReceived fired — received='{fp}' match={fp == expectedFingerprint}");
    e.CanTrust = fp == expectedFingerprint;
};

try
{
    client.Connect();
    Console.WriteLine("Connect() succeeded");
}
catch (SshConnectionException ex)
{
    Console.Error.WriteLine($"FAIL: {ex.Message}");
    Console.Error.WriteLine(ex.StackTrace);
}

Run with env vars pointing at the affected server. Swap the SSH.NET version to A/B-test:

SSH.NET Result against RoboFTP server Result against atmoz/sftp
2024.2.0 ✅ HostKeyReceived fires, connect succeeds
2025.0.0 ✅ HostKeyReceived fires, connect succeeds
2025.1.0 ❌ HostKeyReceived NEVER fires, KeyExchange.Finish throws

KEX algorithm bisect on 2025.1.0 vs RoboFTP

Restricting ConnectionInfo.KeyExchangeAlgorithms to one category at a time:

KEX restriction Negotiated? Outcome
ecdh-sha2-nistp256 yes KeyExchange.Finish
ecdh-sha2-nistp384 yes KeyExchange.Finish
ecdh-sha2-nistp521 yes KeyExchange.Finish
diffie-hellman-group16-sha512 yes KeyExchange.Finish
diffie-hellman-group-exchange-sha256 yes KeyExchange.Finish
curve25519-sha256 no (server doesn't offer) "No matching key exchange algorithm"
mlkem768x25519-sha256 no "No matching key exchange algorithm"
sntrup761x25519-sha512 no "No matching key exchange algorithm"

Every KEX the server actually negotiates fails identically → not an algorithm-specific bug; the regression is in shared code (likely KeyExchange.ValidateExchangeHash or the signature-verification path it depends on).

What is ruled out

  • Not a server-side fault: OpenSSH client (sftp -i ... user@server) connects successfully to the same host with the same key.
  • Not a fingerprint mismatch: HostKeyReceived is never raised on 2025.1.0 — verification fails before fingerprint comparison. The same fingerprint matches and succeeds on 2025.0.0 (event fires, CanTrust = true).
  • Not host-key-algorithm related: explicitly restricting HostKeyAlgorithms (RSA-only, including/excluding *-cert-v01@openssh.com variants) does not change the outcome on the affected server. The same algorithm restriction works on atmoz/sftp.
  • Not a single KEX algorithm: see bisect table — every KEX that negotiates fails identically.
  • Not a .NET 10 crypto issue alone: 2025.0.0 on the same .NET 10 runtime works fine against the same server.

Suspected region

The 2025.0.0 → 2025.1.0 diff touches KeyExchange.cs substantially (+61/-55). One notable change is that host-key-algorithm selection was moved into the base class KeyExchange.Start():

// New in 2025.1.0: KeyExchange.Start()
var hostKeyAlgorithmName = (from b in session.ConnectionInfo.HostKeyAlgorithms.Keys
                            from a in message.ServerHostKeyAlgorithms
                            where a == b
                            select a).FirstOrDefault();
session.ConnectionInfo.CurrentHostKeyAlgorithm = hostKeyAlgorithmName;
_hostKeyAlgorithmFactory = session.ConnectionInfo.HostKeyAlgorithms[hostKeyAlgorithmName];

The 10 DH-classes-into-one consolidation (KeyExchangeDiffieHellmanGroupExchange.cs +244, several KeyExchangeDiffieHellmanGroup*.cs removed) and the EC class rewrites (KeyExchangeEC.cs, KeyExchangeECCurve25519.cs) are the other large changes in the same release.

It's plausible that one of these refactors changed how the exchange hash is computed or how the server's signature blob is decoded for at least the older RoboFTP-style server's format, while behaving identically for modern OpenSSH.

What would help

If a maintainer can point at the specific commit/PR in the 2025.0.0 → 2025.1.0 range that touched ValidateExchangeHash or RSA signature verification, I can also test intermediate develop-branch builds locally — happy to do so.

Filed in the meantime: a downstream pin to SSH.NET 2025.0.0.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the minimal Program.cs reproduction and compare SSH.NET 2025.0.0 with 2025.1.0 against RoboFTP and the OpenSSH control server. Then inspect KeyExchange.Start, KeyExchange.ValidateExchangeHash, KeyExchangeECDH.cs, and the RSA signature-verification path. Done means the legacy server reaches HostKeyReceived and connects on 2025.1.0 without regressing the modern-server case.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
networking
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.