Public Access
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.
This commit is contained in:
1 parent
412c314aec
commit
4c7f47c026
4 files changed
+193
-2
No files matched your search
@@ -187,7 +187,13 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
IProgress<SshConnectionPhase>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var gate = new HostKeyGate(knownHosts, request, progress, cancellationToken);
|
||||
var endpointPins = await knownHosts
|
||||
.FindForEndpointAsync(request.Host, request.Port, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
PreferPinnedAlgorithms(client.ConnectionInfo, endpointPins);
|
||||
|
||||
var gate = new HostKeyGate(knownHosts, request, endpointPins, progress, cancellationToken);
|
||||
|
||||
client.HostKeyReceived += gate.OnHostKeyReceived;
|
||||
|
||||
@@ -217,17 +223,73 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
return gate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offers the host key algorithms this endpoint has pins for ahead of every other.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The server takes the first algorithm in the client's list that it also supports, so this is what
|
||||
/// makes a server that still holds its pinned key negotiate that key — rather than, after it gained an
|
||||
/// <c>ed25519</c> key alongside the <c>rsa</c> one that was pinned, a key nobody has approved. Without it
|
||||
/// the gate below would have to choose between refusing a legitimate server and prompting for one that
|
||||
/// may not be, which is exactly the choice it must not be left with.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Reordered rather than restricted, so a host whose pinned keys are all gone still completes the key
|
||||
/// exchange and reaches the gate, which reports it as a changed key with both fingerprints. Restricting
|
||||
/// would fail the exchange with "no matching host key algorithm", which is true and tells nobody what
|
||||
/// happened. Cleared and refilled because that is the only way a dictionary promises an order.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static void PreferPinnedAlgorithms(
|
||||
ConnectionInfo info,
|
||||
IReadOnlyList<HostKeyPresentation> pins)
|
||||
{
|
||||
if (pins.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pinned = pins
|
||||
.Select(pin => pin.Algorithm)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var ordered = info.HostKeyAlgorithms
|
||||
.OrderBy(algorithm => pinned.Contains(algorithm.Key) ? 0 : 1)
|
||||
.ToList();
|
||||
|
||||
info.HostKeyAlgorithms.Clear();
|
||||
|
||||
foreach (var algorithm in ordered)
|
||||
{
|
||||
info.HostKeyAlgorithms.Add(algorithm.Key, algorithm.Value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decides host key trust during the handshake, and remembers enough to explain a refusal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Separate from the connect method because the decision is the security-relevant part and reads
|
||||
/// better on its own: pinned and equal accepts, pinned and different refuses as a mismatch,
|
||||
/// unpinned refuses as unknown. There is no fourth branch, and there is no prompt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>"Pinned" means pinned for this endpoint, not for this algorithm.</b> Pins are stored per
|
||||
/// algorithm, and an endpoint whose pins are all for other algorithms used to fall through to "unknown"
|
||||
/// — so somebody in the middle who simply offered a different key type got the first-use question
|
||||
/// instead of the hard refusal a pin exists to produce. Now a key under an algorithm with no pin of its
|
||||
/// own is accepted only if it is the same key as one that is pinned (an RSA key is one key under
|
||||
/// <c>rsa-sha2-256</c> and <c>rsa-sha2-512</c>, and its fingerprint says so), and is otherwise a
|
||||
/// mismatch against the endpoint's pin. <see cref="PreferPinnedAlgorithms"/> is what keeps that from
|
||||
/// refusing a server that still has its pinned key.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private sealed class HostKeyGate(
|
||||
IKnownHostStore knownHosts,
|
||||
SshConnectionRequest request,
|
||||
IReadOnlyList<HostKeyPresentation> endpointPins,
|
||||
IProgress<SshConnectionPhase>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -264,13 +326,21 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
|
||||
if (pinned is null)
|
||||
if (pinned is null && endpointPins.Count == 0)
|
||||
{
|
||||
// Refused, not prompted. The caller decides, off the handshake thread.
|
||||
e.CanTrust = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing pinned under this algorithm, but the endpoint has pins: the same key under another
|
||||
// algorithm's name if any pin's fingerprint is this one, and otherwise a changed key, measured
|
||||
// against the endpoint's first pin. See the class remarks.
|
||||
pinned ??= endpointPins
|
||||
.FirstOrDefault(pin => SshHostKeyFingerprint.Equal(pin.Fingerprint, presentation.Fingerprint))
|
||||
?.Fingerprint
|
||||
?? endpointPins[0].Fingerprint;
|
||||
|
||||
var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint);
|
||||
mismatch = !matches;
|
||||
e.CanTrust = matches;
|
||||
|
||||
Reference in new issue
Block a user