using DodoSSH.Client.Domain; using DodoSSH.Client.Ssh; using DodoSSH.Client.Sync; namespace DodoSSH.Client.Session; /// /// Host key trust kept in the vault, answered from memory. /// /// /// /// Why a snapshot and not a lookup. is called from inside SSH.NET's /// synchronous host key event, which the connection factory has no choice but to block on — see /// SshNetConnectionFactory, 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. /// /// /// What that costs, stated rather than hidden. 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. /// /// /// Process-lifetime object, session-scoped contents. The connection factory is composed once, at /// startup, and outlives every unlock; the vault behind this store does not. So the lifecycle is explicit: /// when a vault unlocks, after a pass, /// 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. /// /// public sealed class VaultKnownHostStore : IKnownHostStore { private readonly Lock gate = new(); private Dictionary pins = new(KnownHostIdentity.Comparer); private Binding? binding; /// /// Bumped whenever the snapshot changes underneath a read, so a read in flight cannot install a stale /// answer over a newer one. /// /// /// 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. /// private int generation; /// Whether a vault is open behind this store. public bool IsOpen { get { lock (gate) { return binding is not null; } } } /// Reads an unlocked vault's pins, and starts writing new ones to it. /// The unlocked session. Its active vault is the one used. /// Cancellation token. /// /// 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. /// 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(KnownHostIdentity.Comparer); stamp = ++generation; } var snapshot = await ReadAsync(opened, cancellationToken).ConfigureAwait(false); Install(stamp, snapshot); } /// /// Re-reads the vault, picking up trust recorded on another machine. /// /// /// Does nothing while closed, so a synchronisation pass that finishes after the vault was locked cannot /// bring its contents back. /// 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); } /// Forgets the vault and everything read from it. What locking means here. public void Close() { lock (gate) { binding = null; pins = new Dictionary(KnownHostIdentity.Comparer); generation++; } } /// public ValueTask 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); } } /// /// /// The vault is not open, so there is nowhere to record trust. /// 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++; } } /// /// /// The vault is not open, so there is nothing to forget. /// public async ValueTask 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; } /// The vault this store writes to, and which vault inside it. private sealed record Binding(KnownHostRepository KnownHosts, Guid VaultId); /// A pin, and the vault item it came from, so re-trusting updates rather than duplicates. /// The item holding this pin. /// The pin. private sealed record PinnedHostKey(Guid EntityId, KnownHostSecret Secret) { /// Whether this build may re-encode the item, or a newer one wrote it. internal bool IsWritable { get; init; } = true; } /// /// Records a pin, updating the item that already held one for this endpoint where there is one. /// /// /// 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 , and a client /// that understands both can reconcile them. /// private static async Task 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); } /// /// Reads every readable pin in the vault into a lookup. /// /// /// /// 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 makes it. /// /// /// 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. /// /// private static async Task> ReadAsync( Binding bound, CancellationToken cancellationToken) { var listing = await bound.KnownHosts .ListAsync(bound.VaultId, cancellationToken) .ConfigureAwait(false); var snapshot = new Dictionary(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; } /// /// 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. /// private void Install(int stamp, Dictionary 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."); } } }