Files
DodoSSH/src/DodoSSH.Client.Ssh/HostKeyTrust.cs
T
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

230 lines
11 KiB
C#

using System.Globalization;
namespace DodoSSH.Client.Ssh;
/// <summary>A host key as the server presented it during the handshake.</summary>
/// <param name="Host">Host as dialled.</param>
/// <param name="Port">Port as dialled.</param>
/// <param name="Algorithm">Key algorithm, e.g. <c>ssh-ed25519</c>.</param>
/// <param name="Fingerprint">OpenSSH-style fingerprint, from <see cref="SshHostKeyFingerprint"/>.</param>
public sealed record HostKeyPresentation(string Host, int Port, string Algorithm, string Fingerprint);
/// <summary>
/// What makes two pins the same pin.
/// </summary>
/// <remarks>
/// <para>
/// Shared by every <see cref="IKnownHostStore"/> rather than written per implementation, because the two that
/// exist have to agree: one is what ships and the other is what this project's own SSH tests run against, and
/// an identity rule that differed between them would mean the tested behaviour was not the shipped behaviour.
/// </para>
/// <para>
/// <b>Case-insensitive, and the host is stored verbatim anyway.</b> DNS names are case-insensitive, so
/// <c>DB.internal</c> and <c>db.internal</c> are one machine and must be one pin — a store that treated them
/// as two would ask the user to approve the same server twice. Comparing case-insensitively rather than
/// lower-casing what gets stored keeps the stored value the one that was actually dialled, which matters for
/// an internationalised name or an IPv6 literal with a zone id: rewriting those means rewriting a value this
/// layer does not fully understand. Algorithm names are compared the same way for the same reason, one step
/// weaker — they are ASCII tokens, and no server has been seen to vary their case.
/// </para>
/// </remarks>
public static class KnownHostIdentity
{
/// <summary>The comparer an identity must be compared with.</summary>
public static StringComparer Comparer => StringComparer.OrdinalIgnoreCase;
/// <summary>The identity of one pin, as a single comparable value.</summary>
public static string For(string host, int port, string algorithm) =>
string.Create(CultureInfo.InvariantCulture, $"{host}:{port}/{algorithm}");
}
/// <summary>
/// The trusted host keys a user has accumulated.
/// </summary>
/// <remarks>
/// Known hosts live in the end-to-end encrypted vault as a synced entity, not in a local file. Trust
/// then follows the user to every device, and the server cannot tamper with it — which matters,
/// because a server that could silently drop a pin could downgrade every connection to first-use.
/// </remarks>
public interface IKnownHostStore
{
/// <summary>Returns the pinned fingerprint for a host and key algorithm, if there is one.</summary>
/// <remarks>
/// <para>
/// Keyed on algorithm as well as host, because a server legitimately offers several host keys and
/// which one is negotiated can change between connections. Pinning only one and rejecting the
/// others would make a normal server look hostile.
/// </para>
/// <para>
/// <b>Implementations must answer this without I/O.</b> It is called from inside SSH.NET's synchronous
/// host key event — see <c>SshNetConnectionFactory</c> — so the caller has no choice but to block the
/// thread completing the key exchange on it. Asynchronous in signature because a store may need to be
/// asynchronous to <em>fill</em> itself; not because this call may go and look something up.
/// </para>
/// </remarks>
ValueTask<string?> FindAsync(string host, int port, string algorithm, CancellationToken cancellationToken);
/// <summary>Returns every key pinned for one endpoint, whatever the algorithm.</summary>
/// <remarks>
/// <para>
/// ◆ <b>What stops a pin being sidestepped by changing algorithm.</b> <see cref="FindAsync"/> answers
/// per algorithm, so on its own a host pinned for <c>ssh-ed25519</c> that is answered by somebody offering
/// only <c>ecdsa-sha2-nistp256</c> looks like a host never seen before — and "never seen before" is a
/// question the user is asked, where "changed" is a refusal. Knowing the endpoint has pins at all is what
/// lets the connection factory tell the two apart; see <c>SshNetConnectionFactory</c>.
/// </para>
/// <para>The same rule as <see cref="FindAsync"/>: no I/O.</para>
/// </remarks>
ValueTask<IReadOnlyList<HostKeyPresentation>> FindForEndpointAsync(
string host,
int port,
CancellationToken cancellationToken);
/// <summary>Records a host key as trusted.</summary>
ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken);
/// <summary>
/// Withdraws trust from every key pinned for one endpoint, whatever the algorithm.
/// </summary>
/// <remarks>
/// The counterpart to a pin that now outlives the process, and not an optional extra: a server that is
/// legitimately rebuilt gets a new host key, <see cref="SshHostKeyMismatchException"/> is a hard refusal
/// with no way past it, and without this the host would be unreachable for ever. Every algorithm goes at
/// once because the user's decision is about the machine, not about one of the keys it happens to offer.
/// </remarks>
/// <returns>How many pins were removed, so a caller can say whether there was anything to forget.</returns>
ValueTask<int> ForgetAsync(string host, int port, CancellationToken cancellationToken);
}
/// <summary>
/// The host has never been seen, so there is nothing to compare against.
/// </summary>
/// <remarks>
/// A distinct exception rather than a prompt inside the handshake, and that is a deliberate design
/// choice. SSH.NET raises host key verification as a synchronous event, so consulting the user from
/// inside it would mean blocking the handshake thread on a UI round trip — sync-over-async, and a
/// deadlock the first time the prompt needs the UI thread. Failing the connection and letting the
/// caller prompt keeps everything asynchronous, at the cost of a second TCP connection the first
/// time a host is used.
/// </remarks>
public sealed class SshHostKeyUnknownException(HostKeyPresentation presentation)
: Exception($"The host key for {presentation.Host}:{presentation.Port} is not trusted yet.")
{
/// <summary>The key the server offered, to show the user before they trust it.</summary>
public HostKeyPresentation Presentation { get; } = presentation;
}
/// <summary>
/// The host presented a different key from the one pinned for it.
/// </summary>
/// <remarks>
/// This must stay a hard block with no "continue anyway" in the connect path. A dialog offering to
/// proceed is how users are trained to click through the one warning that actually indicates an
/// interception. A legitimate key change — a rebuilt server — is handled by explicitly forgetting the
/// pin in the host's settings, which is a deliberate act performed away from the moment of
/// connecting. See <see cref="IKnownHostStore.ForgetAsync"/>.
/// </remarks>
public sealed class SshHostKeyMismatchException(HostKeyPresentation presentation, string pinnedFingerprint)
: Exception(
$"The host key for {presentation.Host}:{presentation.Port} has changed. "
+ $"Pinned {pinnedFingerprint}, but the server offered {presentation.Fingerprint}.")
{
/// <summary>The key the server offered.</summary>
public HostKeyPresentation Presentation { get; } = presentation;
/// <summary>The key previously trusted for this host.</summary>
public string PinnedFingerprint { get; } = pinnedFingerprint;
}
/// <summary>
/// A known-host store held in memory.
/// </summary>
/// <remarks>
/// <para>
/// <b>Not the one that ships.</b> <c>VaultKnownHostStore</c> is: it keeps trust in the vault, so a
/// fingerprint approved once is approved on every device and survives a restart. This is what a store looks
/// like with nothing behind it, and it exists because this project deliberately has no project references at
/// all — that is the seam which keeps connections, authentication and PTY handling testable without a cache,
/// a keyring or a server, and those tests still need somewhere to put a pin.
/// </para>
/// <para>
/// Trust is lost when the process exits, so a user of this store would be asked about every host on every
/// launch. Nothing in the application composes it.
/// </para>
/// </remarks>
public sealed class InMemoryKnownHostStore : IKnownHostStore
{
private readonly Dictionary<string, HostKeyPresentation> pins = new(KnownHostIdentity.Comparer);
private readonly Lock gate = new();
/// <inheritdoc />
public ValueTask<string?> FindAsync(
string host,
int port,
string algorithm,
CancellationToken cancellationToken)
{
lock (gate)
{
var key = KnownHostIdentity.For(host, port, algorithm);
return ValueTask.FromResult(pins.GetValueOrDefault(key)?.Fingerprint);
}
}
/// <inheritdoc />
public ValueTask<IReadOnlyList<HostKeyPresentation>> FindForEndpointAsync(
string host,
int port,
CancellationToken cancellationToken)
{
lock (gate)
{
IReadOnlyList<HostKeyPresentation> found = pins.Values
.Where(pin => KnownHostIdentity.Comparer.Equals(pin.Host, host) && pin.Port == port)
.ToArray();
return ValueTask.FromResult(found);
}
}
/// <inheritdoc />
public ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(presentation);
lock (gate)
{
var key = KnownHostIdentity.For(
presentation.Host, presentation.Port, presentation.Algorithm);
pins[key] = presentation;
}
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public ValueTask<int> ForgetAsync(string host, int port, CancellationToken cancellationToken)
{
lock (gate)
{
// Matched on the stored values rather than by picking apart the composite key. The key exists to
// be looked up whole; taking it back apart to find an endpoint would be one parser too many, and
// a host that is an IPv6 literal is full of colons.
var doomed = pins
.Where(pin =>
KnownHostIdentity.Comparer.Equals(pin.Value.Host, host) && pin.Value.Port == port)
.Select(pin => pin.Key)
.ToArray();
foreach (var key in doomed)
{
pins.Remove(key);
}
return ValueTask.FromResult(doomed.Length);
}
}
}