using System.Globalization;
namespace DodoSSH.Client.Ssh;
/// A host key as the server presented it during the handshake.
/// Host as dialled.
/// Port as dialled.
/// Key algorithm, e.g. ssh-ed25519.
/// OpenSSH-style fingerprint, from .
public sealed record HostKeyPresentation(string Host, int Port, string Algorithm, string Fingerprint);
///
/// What makes two pins the same pin.
///
///
///
/// Shared by every 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.
///
///
/// Case-insensitive, and the host is stored verbatim anyway. DNS names are case-insensitive, so
/// DB.internal and db.internal 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.
///
///
public static class KnownHostIdentity
{
/// The comparer an identity must be compared with.
public static StringComparer Comparer => StringComparer.OrdinalIgnoreCase;
/// The identity of one pin, as a single comparable value.
public static string For(string host, int port, string algorithm) =>
string.Create(CultureInfo.InvariantCulture, $"{host}:{port}/{algorithm}");
}
///
/// The trusted host keys a user has accumulated.
///
///
/// 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.
///
public interface IKnownHostStore
{
/// Returns the pinned fingerprint for a host and key algorithm, if there is one.
///
///
/// 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.
///
///
/// Implementations must answer this without I/O. It is called from inside SSH.NET's synchronous
/// host key event — see SshNetConnectionFactory — 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 fill itself; not because this call may go and look something up.
///
///
ValueTask FindAsync(string host, int port, string algorithm, CancellationToken cancellationToken);
/// Records a host key as trusted.
ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken);
///
/// Withdraws trust from every key pinned for one endpoint, whatever the algorithm.
///
///
/// 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, 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.
///
/// How many pins were removed, so a caller can say whether there was anything to forget.
ValueTask ForgetAsync(string host, int port, CancellationToken cancellationToken);
}
///
/// The host has never been seen, so there is nothing to compare against.
///
///
/// 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.
///
public sealed class SshHostKeyUnknownException(HostKeyPresentation presentation)
: Exception($"The host key for {presentation.Host}:{presentation.Port} is not trusted yet.")
{
/// The key the server offered, to show the user before they trust it.
public HostKeyPresentation Presentation { get; } = presentation;
}
///
/// The host presented a different key from the one pinned for it.
///
///
/// 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 .
///
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}.")
{
/// The key the server offered.
public HostKeyPresentation Presentation { get; } = presentation;
/// The key previously trusted for this host.
public string PinnedFingerprint { get; } = pinnedFingerprint;
}
///
/// A known-host store held in memory.
///
///
///
/// Not the one that ships. VaultKnownHostStore 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.
///
///
/// 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.
///
///
public sealed class InMemoryKnownHostStore : IKnownHostStore
{
private readonly Dictionary pins = new(KnownHostIdentity.Comparer);
private readonly Lock gate = new();
///
public ValueTask 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);
}
}
///
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;
}
///
public ValueTask 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);
}
}
}