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