Skip to content

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) #1799

@sorinapersa

Description

@sorinapersa

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions