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); } } }