diff --git a/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs index 947ccf9..c7ca69c 100644 --- a/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs +++ b/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs @@ -212,6 +212,56 @@ internal sealed class CredentialRowViewModel(VaultItem credent ItemBadge.For(credential.IsBlocked, credential.IsReadOnly, credential.HasUnsyncedChanges); } +/// One pinned host key, as a row in the list. +/// +/// +/// The only list of the four whose rows nobody created on purpose. A pin appears because somebody approved a +/// fingerprint at the moment of connecting, and it outlives whatever they approved it for — deleting a host +/// leaves its pin, and so does changing a host's address. Both are correct as trust decisions: the +/// address may still be reached by another host, and a pin is about the endpoint rather than the bookmark. +/// What was wrong was that nothing ever showed them. +/// +/// +/// Nothing here is secret. A host key fingerprint is published by operators on purpose, and the whole point +/// of pinning one is to compare it with what they published. +/// +/// +internal sealed class KnownHostRowViewModel(VaultItem pin, bool isDialledByAHost) +{ + internal Guid EntityId => pin.EntityId; + + internal KnownHostSecret Pin => pin.Secret; + + internal string Host => pin.Secret.Host; + + internal int Port => pin.Secret.Port; + + /// The endpoint and algorithm, which is what a pin actually identifies. + internal string Label => pin.Secret.Label; + + /// The fingerprint, in full. + /// + /// Not truncated. The only thing anybody does with a fingerprint is compare it against one an operator + /// published, and a shortened one cannot be compared — it can only be glanced at, which is the habit + /// this whole mechanism exists to replace. + /// + internal string Fingerprint => pin.Secret.Fingerprint; + + /// + /// Whether any host in this vault actually dials the endpoint this pin is for. + /// + /// + /// The reason this list exists rather than a plain enumeration. It is a hint and not a verdict: reaching + /// a machine without a bookmark for it is ordinary, so an unmatched pin is worth pointing at and not + /// worth deleting on the user's behalf. + /// + internal bool IsDialledByAHost { get; } = isDialledByAHost; + + internal string Badge => IsDialledByAHost + ? ItemBadge.For(pin.IsBlocked, pin.IsReadOnly, pin.HasUnsyncedChanges) + : "no host uses this"; +} + /// The one-word marker a row shows for its sync state. /// /// Shared by both row types rather than written twice, because the three states mean the same thing for @@ -278,6 +328,9 @@ internal enum VaultSection /// The usernames and passwords they authenticate with instead. Credentials, + + /// The host keys this user has approved. + KnownHosts, } /// @@ -350,6 +403,9 @@ internal sealed partial class VaultViewModel( /// The stored credentials to show, unpushed local state included. internal ObservableCollection Credentials { get; } = []; + /// The host keys this user has approved. + internal ObservableCollection KnownHostPins { get; } = []; + /// Whatever the merge had to override and the user has not acknowledged. internal ObservableCollection Conflicts { get; } = []; @@ -365,6 +421,9 @@ internal sealed partial class VaultViewModel( [ObservableProperty] private CredentialRowViewModel? selectedCredential; + [ObservableProperty] + private KnownHostRowViewModel? selectedKnownHost; + [ObservableProperty] private string status = string.Empty; @@ -398,6 +457,9 @@ internal sealed partial class VaultViewModel( /// internal bool ShowsCredentials => Section is VaultSection.Credentials; + /// + internal bool ShowsKnownHosts => Section is VaultSection.KnownHosts; + // ---- The editor ---- [ObservableProperty] @@ -627,6 +689,9 @@ internal sealed partial class VaultViewModel( unreadable += await ReloadKeysAsync(cancellationToken).ConfigureAwait(true); unreadable += await ReloadCredentialsAsync(cancellationToken).ConfigureAwait(true); + // Last, because it reads the host list to work out which pins nothing dials any more. + unreadable += await ReloadKnownHostsAsync(cancellationToken).ConfigureAwait(true); + UnreadableItems = unreadable; PendingChanges = await session.PendingChangeCountAsync(cancellationToken).ConfigureAwait(true); @@ -710,6 +775,50 @@ internal sealed partial class VaultViewModel( return listing.Unreadable; } + /// How many pins would not decrypt. + /// + /// Read through the repository rather than through VaultKnownHostStore, which holds a snapshot + /// shaped for the SSH handshake — one pin per endpoint, deduplicated, and with no entity ids. This list + /// has to show duplicates, because a duplicate is one of the things worth seeing. + /// + private async Task ReloadKnownHostsAsync(CancellationToken cancellationToken) + { + var listing = await session.KnownHosts + .ListAsync(session.ActiveVaultId, cancellationToken) + .ConfigureAwait(true); + + var selectedId = SelectedKnownHost?.EntityId; + + // Built once rather than searched per pin. A vault with a hundred of each would otherwise be a + // hundred scans of the host list on every background sync. + var dialled = Hosts + .Select(host => Endpoint(host.Host.Hostname, host.Host.Port)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + KnownHostPins.Clear(); + + foreach (var pin in listing.Items + .OrderBy(pin => pin.Secret.Host, StringComparer.CurrentCulture) + .ThenBy(pin => pin.Secret.Port) + .ThenBy(pin => pin.Secret.Algorithm, StringComparer.Ordinal)) + { + KnownHostPins.Add(new KnownHostRowViewModel( + pin, dialled.Contains(Endpoint(pin.Secret.Host, pin.Secret.Port)))); + } + + SelectedKnownHost = KnownHostPins.FirstOrDefault(row => row.EntityId == selectedId); + + return listing.Unreadable; + } + + /// + /// Case-insensitively, because a host name is, and KnownHostIdentity keys the store the same way. + /// A pin written as DB.internal and a host saved as db.internal are the same machine, and a + /// list that called one of them unused would be inviting somebody to delete trust they rely on. + /// + private static string Endpoint(string host, int port) => + string.Create(CultureInfo.InvariantCulture, $"{host}:{port}"); + /// Runs a synchronisation pass, if there is a server to talk to. [RelayCommand] private async Task SyncAsync(CancellationToken cancellationToken) @@ -1266,6 +1375,54 @@ internal sealed partial class VaultViewModel( await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } + /// + /// Withdraws trust from the selected pin's endpoint. + /// + /// + /// + /// Goes through the same ForgetAsync as the host editor's button, which withdraws every pin for + /// the endpoint rather than the one row that was selected. That is deliberate and not a shortcut: trust + /// is about an address, a second pin for the same address under another algorithm would go on being + /// offered at the next handshake, and a user who has decided to stop trusting a machine has not decided + /// to stop trusting one of its keys. The status line says how many went. + /// + /// + /// No confirmation. Withdrawing trust costs one fingerprint check on the next connection, and it is the + /// safe direction to be wrong in — the dangerous button is the one that adds trust, and that one is the + /// prompt at connect time. + /// + /// + [RelayCommand] + private async Task ForgetPinAsync(CancellationToken cancellationToken) + { + if (SelectedKnownHost is not { } row) + { + return; + } + + await RunAsync( + $"Forgetting the pinned host key for {row.Host}…", + async () => + { + var forgotten = await knownHosts + .ForgetAsync(row.Host, row.Port, cancellationToken) + .ConfigureAwait(true); + + // A mismatch the user was staring at is about a pin that may have just gone. + HostKeyMismatch = null; + + await ReloadAsync(cancellationToken).ConfigureAwait(true); + + Status = forgotten == 1 + ? $"Forgot the pinned key for {row.Host}:{row.Port}." + : $"Forgot {forgotten} pinned key(s) for {row.Host}:{row.Port}."; + }).ConfigureAwait(true); + + // Pushed straight away, as trusting is: the other machines are the ones still refusing to connect to + // a server that has been rebuilt. + await AutoSyncAsync(cancellationToken).ConfigureAwait(true); + } + /// Opens a terminal on the selected host. [RelayCommand] private async Task ConnectAsync(CancellationToken cancellationToken) @@ -1910,6 +2067,7 @@ internal sealed partial class VaultViewModel( OnPropertyChanged(nameof(ShowsHosts)); OnPropertyChanged(nameof(ShowsKeys)); OnPropertyChanged(nameof(ShowsCredentials)); + OnPropertyChanged(nameof(ShowsKnownHosts)); } /// diff --git a/src/DodoSSH.Client.App/Views/VaultColumn.axaml b/src/DodoSSH.Client.App/Views/VaultColumn.axaml index d1693c5..d115079 100644 --- a/src/DodoSSH.Client.App/Views/VaultColumn.axaml +++ b/src/DodoSSH.Client.App/Views/VaultColumn.axaml @@ -72,6 +72,10 @@ Classes.active="{Binding ShowsCredentials}" Command="{Binding ShowSectionCommand}" CommandParameter="{x:Static vm:VaultSection.Credentials}" /> +