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.
385 lines
14 KiB
C#
385 lines
14 KiB
C#
using DodoSSH.Client.Domain;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Sync;
|
|
|
|
namespace DodoSSH.Client.Session;
|
|
|
|
/// <summary>
|
|
/// Host key trust kept in the vault, answered from memory.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Why a snapshot and not a lookup.</b> <see cref="FindAsync"/> is called from inside SSH.NET's
|
|
/// synchronous host key event, which the connection factory has no choice but to block on — see
|
|
/// <c>SshNetConnectionFactory</c>, where the comment explains why that cannot be avoided. A store that read
|
|
/// SQLite and ran an AEAD open per lookup would put a disk round trip and a decryption on the thread
|
|
/// completing the key exchange, once per host key offered, on every connection. So the vault is read when it
|
|
/// unlocks and after each synchronisation pass, and the handshake gets a dictionary lookup.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>What that costs, stated rather than hidden.</b> Trust recorded on another machine is not visible until
|
|
/// the next pass brings it down, which is within the minute the shell already syncs on. The failure that
|
|
/// causes is a first-contact prompt for a host somebody else approved seconds ago — a prompt the user can
|
|
/// answer correctly, since the fingerprint is on screen — and it resolves itself. The reverse mistake would
|
|
/// be the serious one, and it cannot happen here: a pin recorded on this machine goes into the snapshot as
|
|
/// part of recording it, and invalidates any read that was already in flight.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Process-lifetime object, session-scoped contents.</b> The connection factory is composed once, at
|
|
/// startup, and outlives every unlock; the vault behind this store does not. So the lifecycle is explicit:
|
|
/// <see cref="OpenAsync"/> when a vault unlocks, <see cref="RefreshAsync"/> after a pass,
|
|
/// <see cref="Close"/> when it locks. While closed every lookup answers "not pinned", which refuses
|
|
/// connections rather than allowing them — the safe direction for the one case that can reach it, a vault
|
|
/// locked while a handshake was in flight.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class VaultKnownHostStore : IKnownHostStore
|
|
{
|
|
private readonly Lock gate = new();
|
|
|
|
private Dictionary<string, PinnedHostKey> pins = new(KnownHostIdentity.Comparer);
|
|
private Binding? binding;
|
|
|
|
/// <summary>
|
|
/// Bumped whenever the snapshot changes underneath a read, so a read in flight cannot install a stale
|
|
/// answer over a newer one.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The races are ordinary rather than theoretical. A background pass ends with a refresh, and between
|
|
/// that refresh's listing and its assignment the user may lock the vault, approve a new host key, or
|
|
/// withdraw trust from one — and every one of those would otherwise be undone a moment later by the
|
|
/// arriving snapshot.
|
|
/// </remarks>
|
|
private int generation;
|
|
|
|
/// <summary>Whether a vault is open behind this store.</summary>
|
|
public bool IsOpen
|
|
{
|
|
get
|
|
{
|
|
lock (gate)
|
|
{
|
|
return binding is not null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Reads an unlocked vault's pins, and starts writing new ones to it.</summary>
|
|
/// <param name="session">The unlocked session. Its active vault is the one used.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <remarks>
|
|
/// The previous session's pins are dropped before the new vault is read, not after. Between the two
|
|
/// every host looks unvisited, which is one listing long and errs towards asking; keeping them would
|
|
/// mean one account's trust decisions briefly answering for another's.
|
|
/// </remarks>
|
|
public async Task OpenAsync(VaultSession session, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(session);
|
|
|
|
var opened = new Binding(session.KnownHosts, session.ActiveVaultId);
|
|
int stamp;
|
|
|
|
lock (gate)
|
|
{
|
|
binding = opened;
|
|
pins = new Dictionary<string, PinnedHostKey>(KnownHostIdentity.Comparer);
|
|
stamp = ++generation;
|
|
}
|
|
|
|
var snapshot = await ReadAsync(opened, cancellationToken).ConfigureAwait(false);
|
|
|
|
Install(stamp, snapshot);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-reads the vault, picking up trust recorded on another machine.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Does nothing while closed, so a synchronisation pass that finishes after the vault was locked cannot
|
|
/// bring its contents back.
|
|
/// </remarks>
|
|
public async Task RefreshAsync(CancellationToken cancellationToken)
|
|
{
|
|
Binding? current;
|
|
int stamp;
|
|
|
|
lock (gate)
|
|
{
|
|
current = binding;
|
|
stamp = generation;
|
|
}
|
|
|
|
if (current is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var snapshot = await ReadAsync(current, cancellationToken).ConfigureAwait(false);
|
|
|
|
Install(stamp, snapshot);
|
|
}
|
|
|
|
/// <summary>Forgets the vault and everything read from it. What locking means here.</summary>
|
|
public void Close()
|
|
{
|
|
lock (gate)
|
|
{
|
|
binding = null;
|
|
pins = new Dictionary<string, PinnedHostKey>(KnownHostIdentity.Comparer);
|
|
generation++;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask<string?> FindAsync(
|
|
string host,
|
|
int port,
|
|
string algorithm,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
lock (gate)
|
|
{
|
|
var identity = KnownHostIdentity.For(host, port, algorithm);
|
|
|
|
return ValueTask.FromResult(pins.GetValueOrDefault(identity)?.Secret.Fingerprint);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>From the snapshot, like <see cref="FindAsync"/>, and for the same reason.</remarks>
|
|
public ValueTask<IReadOnlyList<HostKeyPresentation>> FindForEndpointAsync(
|
|
string host,
|
|
int port,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
lock (gate)
|
|
{
|
|
IReadOnlyList<HostKeyPresentation> found = pins.Values
|
|
.Where(pin =>
|
|
KnownHostIdentity.Comparer.Equals(pin.Secret.Host, host) && pin.Secret.Port == port)
|
|
.Select(pin => new HostKeyPresentation(
|
|
pin.Secret.Host, pin.Secret.Port, pin.Secret.Algorithm, pin.Secret.Fingerprint))
|
|
.ToArray();
|
|
|
|
return ValueTask.FromResult(found);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <exception cref="InvalidOperationException">
|
|
/// The vault is not open, so there is nowhere to record trust.
|
|
/// </exception>
|
|
public async ValueTask TrustAsync(
|
|
HostKeyPresentation presentation,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(presentation);
|
|
|
|
var bound = Require();
|
|
|
|
var identity = KnownHostIdentity.For(
|
|
presentation.Host, presentation.Port, presentation.Algorithm);
|
|
|
|
var existing = Pinned(identity);
|
|
|
|
if (existing is not null
|
|
&& SshHostKeyFingerprint.Equal(existing.Secret.Fingerprint, presentation.Fingerprint))
|
|
{
|
|
// Already trusted, to the byte. Writing it again would queue an outbox operation that changes
|
|
// nothing and push it to every other machine as a modification.
|
|
return;
|
|
}
|
|
|
|
var pin = new KnownHostSecret
|
|
{
|
|
Host = presentation.Host,
|
|
Port = presentation.Port,
|
|
Algorithm = presentation.Algorithm,
|
|
Fingerprint = presentation.Fingerprint,
|
|
};
|
|
|
|
var entityId = await StoreAsync(bound, existing, pin, cancellationToken).ConfigureAwait(false);
|
|
|
|
lock (gate)
|
|
{
|
|
if (!ReferenceEquals(binding, bound))
|
|
{
|
|
// The vault was locked, or another one was opened, while this was being written. The item is
|
|
// in that vault's outbox and will be there when it is next opened; it must not answer for
|
|
// whatever is open now.
|
|
return;
|
|
}
|
|
|
|
pins[identity] = new PinnedHostKey(entityId, pin);
|
|
generation++;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <exception cref="InvalidOperationException">
|
|
/// The vault is not open, so there is nothing to forget.
|
|
/// </exception>
|
|
public async ValueTask<int> ForgetAsync(
|
|
string host,
|
|
int port,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var bound = Require();
|
|
|
|
// Read from the vault rather than from the snapshot, and this is the one operation that must:
|
|
// withdrawing trust has to reach every pin for the endpoint, including a duplicate the snapshot
|
|
// shadowed. A pin left behind is a host that keeps refusing to connect for a reason the user
|
|
// believes they have already dealt with.
|
|
var listing = await bound.KnownHosts
|
|
.ListAsync(bound.VaultId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var doomed = listing.Items
|
|
.Where(item =>
|
|
KnownHostIdentity.Comparer.Equals(item.Secret.Host, host) && item.Secret.Port == port)
|
|
.ToArray();
|
|
|
|
foreach (var item in doomed)
|
|
{
|
|
await bound.KnownHosts
|
|
.DeleteAsync(bound.VaultId, item.EntityId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
Invalidate();
|
|
|
|
// Re-read rather than patched. The next listing is what these deletions mean, and reproducing that
|
|
// arithmetic against the snapshot is how the two would come to disagree.
|
|
await RefreshAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
return doomed.Length;
|
|
}
|
|
|
|
/// <summary>The vault this store writes to, and which vault inside it.</summary>
|
|
private sealed record Binding(KnownHostRepository KnownHosts, Guid VaultId);
|
|
|
|
/// <summary>A pin, and the vault item it came from, so re-trusting updates rather than duplicates.</summary>
|
|
/// <param name="EntityId">The item holding this pin.</param>
|
|
/// <param name="Secret">The pin.</param>
|
|
private sealed record PinnedHostKey(Guid EntityId, KnownHostSecret Secret)
|
|
{
|
|
/// <summary>Whether this build may re-encode the item, or a newer one wrote it.</summary>
|
|
internal bool IsWritable { get; init; } = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records a pin, updating the item that already held one for this endpoint where there is one.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// An item a newer client wrote is left alone and a fresh one is created beside it. Re-encoding it would
|
|
/// drop fields this build has no concept of, which is the rule the whole client follows for read-only
|
|
/// items — and refusing outright, as the editors do, would leave the user unable to connect to a rebuilt
|
|
/// server at all. The new item wins the lookup by the tie-break in <see cref="ReadAsync"/>, and a client
|
|
/// that understands both can reconcile them.
|
|
/// </remarks>
|
|
private static async Task<Guid> StoreAsync(
|
|
Binding bound,
|
|
PinnedHostKey? existing,
|
|
KnownHostSecret pin,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (existing is { IsWritable: true } writable)
|
|
{
|
|
await bound.KnownHosts
|
|
.UpdateAsync(bound.VaultId, writable.EntityId, pin, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return writable.EntityId;
|
|
}
|
|
|
|
return await bound.KnownHosts
|
|
.CreateAsync(bound.VaultId, pin, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads every readable pin in the vault into a lookup.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Two items can name the same endpoint and algorithm: two machines that first met a host while unable
|
|
/// to reach each other each minted one. Where they agree — the ordinary case, since they saw the same
|
|
/// server — the duplicate is invisible. Where they do not, the later item wins, ordered by an id that is
|
|
/// a UUIDv7 and therefore by when the trust was recorded. Any total order would do for correctness; what
|
|
/// matters is that every machine picks the same one, and that the wrong choice is recoverable rather than
|
|
/// permanent, which <see cref="ForgetAsync"/> makes it.
|
|
/// </para>
|
|
/// <para>
|
|
/// A pin that will not decrypt is skipped, and its endpoint then looks unvisited. That is the safe
|
|
/// reading: an unreadable pin cannot be compared against anything, so the only honest answers are "ask
|
|
/// the user" and "refuse", and asking is the one that leaves them a way forward. The count is not lost —
|
|
/// the vault view reports undecryptable items of every kind.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static async Task<Dictionary<string, PinnedHostKey>> ReadAsync(
|
|
Binding bound,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var listing = await bound.KnownHosts
|
|
.ListAsync(bound.VaultId, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var snapshot = new Dictionary<string, PinnedHostKey>(KnownHostIdentity.Comparer);
|
|
|
|
foreach (var item in listing.Items.OrderBy(item => item.EntityId))
|
|
{
|
|
var identity = KnownHostIdentity.For(
|
|
item.Secret.Host, item.Secret.Port, item.Secret.Algorithm);
|
|
|
|
snapshot[identity] = new PinnedHostKey(item.EntityId, item.Secret)
|
|
{
|
|
IsWritable = !item.IsReadOnly,
|
|
};
|
|
}
|
|
|
|
return snapshot;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Replaces the snapshot wholesale rather than merging into it, which is what makes a pin withdrawn on
|
|
/// another machine actually disappear here. Anything recorded on this machine while the read was in
|
|
/// flight has already bumped the generation, so it is this snapshot that gets dropped and not that pin.
|
|
/// </remarks>
|
|
private void Install(int stamp, Dictionary<string, PinnedHostKey> snapshot)
|
|
{
|
|
lock (gate)
|
|
{
|
|
if (stamp == generation)
|
|
{
|
|
pins = snapshot;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void Invalidate()
|
|
{
|
|
lock (gate)
|
|
{
|
|
generation++;
|
|
}
|
|
}
|
|
|
|
private PinnedHostKey? Pinned(string identity)
|
|
{
|
|
lock (gate)
|
|
{
|
|
return pins.GetValueOrDefault(identity);
|
|
}
|
|
}
|
|
|
|
private Binding Require()
|
|
{
|
|
lock (gate)
|
|
{
|
|
return binding
|
|
?? throw new InvalidOperationException(
|
|
"The vault is locked, so host key trust cannot be changed.");
|
|
}
|
|
}
|
|
}
|