Files
DodoSSH/tests/DodoSSH.Client.Ssh.Tests/HostKeyPinningTests.cs
jaap-jan 4c7f47c026 Refuse a pinned host that answers with a key of another type, rather than asking whether to trust it
Pins are stored per algorithm, so an endpoint pinned for ssh-ed25519 that was answered with only an
ECDSA or RSA key found no pin and raised SshHostKeyUnknownException: the first-use question, where a
pin exists to produce a hard refusal. Anybody in the middle needed nothing more than a key of a
different type.

The connection factory now reads every pin for the endpoint before the handshake, offers the pinned
algorithms first so a server that still holds its pinned key negotiates it, and in the gate accepts a
key under an unpinned algorithm only when its fingerprint matches one of the endpoint's pins (one RSA
key signs as rsa-sha2-256 and rsa-sha2-512). Anything else is a mismatch. IKnownHostStore gains
FindForEndpointAsync for this.
2026-10-02 13:16:30 +02:00

70 lines
2.9 KiB
C#

namespace DodoSSH.Client.Ssh.Tests;
/// <summary>
/// A pin is a pin for the endpoint, whichever key type the server answers with.
/// </summary>
/// <remarks>
/// Pins are stored per algorithm, and the gap these close was that an endpoint whose only pins were for
/// other algorithms read as a host never seen before. Somebody in the middle needed nothing more than a key
/// of a different type to turn the hard refusal a pin exists for into a first-use question the user is
/// invited to click through.
/// </remarks>
[Collection(SshCollection.Name)]
public sealed class HostKeyPinningTests(SshServerFixture fixture)
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <summary>
/// The endpoint is pinned under a key type this server does not have, which is exactly what a server in
/// the middle looks like from here: the pinned key is not on offer, and a key of another type is.
/// </summary>
[Fact]
public async Task AKeyOfAnotherTypeOnAPinnedEndpoint_IsAMismatchRatherThanAnUnknownHost()
{
var knownHosts = new InMemoryKnownHostStore();
await knownHosts.TrustAsync(
new HostKeyPresentation(fixture.Host, fixture.Port, "ssh-dss", "SHA256:the-key-that-was-approved"),
Token);
var mismatch = await Should.ThrowAsync<SshHostKeyMismatchException>(async () =>
await new SshNetConnectionFactory(knownHosts).ConnectAsync(Request(), progress: null, Token));
mismatch.PinnedFingerprint.ShouldBe("SHA256:the-key-that-was-approved");
}
/// <summary>
/// The same public key under another algorithm's name — an RSA key signs as <c>rsa-sha2-256</c> or
/// <c>rsa-sha2-512</c> — is the key that was approved, and its fingerprint is what says so.
/// </summary>
[Fact]
public async Task TheApprovedKeyUnderAnotherAlgorithmName_IsStillTrusted()
{
var learned = new InMemoryKnownHostStore();
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
await new SshNetConnectionFactory(learned).ConnectAsync(Request(), progress: null, Token));
var knownHosts = new InMemoryKnownHostStore();
await knownHosts.TrustAsync(unknown.Presentation with { Algorithm = "x-renamed-algorithm" }, Token);
await using var connection = await new SshNetConnectionFactory(knownHosts)
.ConnectAsync(Request(), progress: null, Token);
connection.ShouldNotBeNull();
}
[Fact]
public async Task AnEndpointWithNoPinsAtAll_IsStillAnUnknownHost() =>
await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
await new SshNetConnectionFactory(new InMemoryKnownHostStore())
.ConnectAsync(Request(), progress: null, Token));
private SshConnectionRequest Request() => new(
fixture.Host,
fixture.Port,
SshServerFixture.Username,
new SshPasswordCredential(SshServerFixture.Password));
}