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.
230 lines
11 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|