Public Access
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.
70 lines
2.9 KiB
C#
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));
|
|
}
|