Files
DodoSSH/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs
T
jaap-jan 211eba0666
ci / build and test (ubuntu) (push) Canceled after 0s
ci / build (windows) (push) Canceled after 0s
Keep host key trust in the vault, and make it withdrawable
A fingerprint approved once is now approved on every machine and survives a
restart, because host key trust is a vault item type rather than a dictionary
that dies with the process. InMemoryKnownHostStore was what shipped, so the user
was asked to verify a fingerprint on every single connection — which is the gap
most likely to train somebody to click through the one warning that actually
matters. A warning that appears when nothing is wrong teaches that nothing is
ever wrong.

The fourth item type, and like the third it cost no sync logic: a row, an EF
configuration, a migration, a server kind; a secret, a codec, a merge, a cipher,
a repository facade and a session property. One row in the client registry. The
reconciler, the mirror, the repository, the outbox and the pull filter were not
touched. SyncEntityType.KnownHostKey and AadResourceType.KnownHostKey were
already reserved, so neither the contract nor docs/crypto.md changed.

One item per (host, port, algorithm), because a server legitimately offers
several host keys and which one gets negotiated is not ours to predict. Pinning
per endpoint would make an algorithm change indistinguishable from an attack.

The label is derived rather than stored, which is the one place this type
departs from the other three. A user never names a pin — there is nothing to
name it after but the three fields it already has — and a stored label is a
second copy of data that can disagree with the first after a merge. Relabel
returns the secret unchanged, and says why.

The store answers the handshake without touching the disk. SshNetConnectionFactory
calls FindAsync from inside SSH.NET's synchronous HostKeyReceived event, over
.GetAwaiter().GetResult(), which cannot be avoided; doing SQLite I/O plus an AEAD
open per lookup there would put the handshake behind the cache. So decryption
happens in OpenAsync and RefreshAsync — on unlock and after each sync pass,
exactly where the host and key lists already reload — and FindAsync is a
dictionary read under a lock with no await inside it.

That snapshot is where the one real bug in this change lived. Install originally
merged the live pins over the freshly loaded snapshot, to protect a TrustAsync
that had landed while the read was in flight. It would also have resurrected
every pin the user had just forgotten, and stopped a withdrawal made on another
machine from ever taking effect — the store would have healed the deletion back
into existence on every refresh. Replacing wholesale and discarding the read
instead is correct because writes are the rare case: every write bumps a
generation counter, and a refresh whose stamp is stale throws itself away rather
than winning. Nothing found this but reading the method again; it is the kind of
mistake that passes every test written before it, because the test that catches
it is the one the bug tells you to write.

Forgetting is new, and persistence is what made it mandatory rather than
convenient. A mismatch is a hard refusal with no way to continue — deliberately,
and that stays — so pinning a key permanently is also a way to make a
legitimately rebuilt server permanently unreachable. Before this change the pin
died at exit and the problem solved itself; now it does not.

ForgetAsync drops every algorithm for an endpoint, and it is reachable from the
host editor rather than from the warning. Putting it on the mismatch banner would
have made it two clicks from "this may be an attack" to "connect anyway", which
is the affordance the hard refusal exists to deny. The banner already promised
the key could be removed in the host's settings; that promise is now true and
points at the button.

Trust recorded on another machine becomes visible at the next sync pass, not
immediately, and that is a decision rather than an oversight. The failure it
produces is a first-contact prompt for a host a colleague approved a minute ago:
answerable, and self-correcting on the next pass. The opposite trade — polling
the vault on the handshake thread to close a one-minute window — buys nothing
and costs the property above. The dangerous direction is not reachable at all: a
pin recorded here enters the snapshot as part of recording it, so a refresh can
never discard a local trust decision.

The server learns nothing, and this is the item type where the temptation was
real. A plaintext host column would let a known-hosts screen sort and page
without decrypting anything, and it would hand the operator the map of every
user's estate — assembled, as these things are, out of facts that are each
individually harmless. A host row concedes an address only when relay is
switched on and the database refuses to store one otherwise (ADR 0004); there is
no equivalent excuse here. The table has no column to put one in, and the EF
configuration says so where somebody adding it would be standing.

Two things about the migration in this commit are worth knowing, because both
came out of getting it wrong.

It was hand-written first, including its .Designer.cs, and that version is not
what is here. Verifying it turned up something that had been quietly assumed:
Migration_AppliedCleanly_WithNoPendingModelChanges does not check the model
snapshot. It asserts that migrations applied and that none are pending, which a
wrong snapshot satisfies perfectly — the snapshot only matters as the diff base
for the *next* migrations add, so an incorrect one passes the whole suite and
corrupts the following migration instead. The real check is to generate a
throwaway migration and confirm its Up and Down come out empty. They did, and
the generated designer was byte-identical to the transcribed one across all 1255
lines, so the hand-written work was in fact correct.

Then dotnet ef migrations remove --no-build deleted the wrong migration. With
--no-build the tool reads the previously compiled assembly rather than the files
on disk, and the probe had just changed which migration was last, so it removed
AddKnownHostKeyItem and reverted the snapshot. That turned out to leave exactly
the right diff base, so the migration here is EF's own output rather than a
transcription — a better outcome than the one that was interrupted, arrived at
by accident. Never pass --no-build to migrations remove.

Mutation tested, all three sabotages detected: dropping the algorithm from
KnownHostIdentity.For, merging instead of replacing in Install, and pointing
KnownHostKeyCipher at PortForward — which is what a cast from the wire enum's 10
would silently produce. Each is caught both by an assertion about the mechanism
and by a behavioural test that never mentions it; the resource-type sabotage is
caught by the table from d10a38d and nothing else, which is what that table is
for.

The end-to-end slice now approves the real sshd's host key through the vault,
pushes it, and reads it back on the second simulated machine — including a check
that the server learned no address, and that the second machine answers null for
an algorithm never offered.

845 tests green. Zero warnings, dotnet format clean.

Three things are deliberately not fixed. A tombstone queued over a create that
was never pushed is refused by the server as Invalid and parked; that is
pre-existing for all four item types, and the fix belongs in
VaultItemRepository.DeleteAsync rather than here. Deleting a host, or changing
its address, orphans its pins — both are correct as trust decisions, since a pin
describes an endpoint and not a bookmark, but nothing surfaces the leftovers.
And there is no interface listing pins at all: trust is created at the connect
prompt and withdrawn in the host editor. A known-hosts list is where the orphans
would become visible, and it wants the vault column rework first, for the same
reason the credential editor does.
2026-07-30 11:00:39 +02:00

1339 lines
51 KiB
C#

using System.Collections.ObjectModel;
using System.Globalization;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Api;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Sync;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.App.ViewModels;
/// <summary>One host, as a row in the list.</summary>
/// <remarks>
/// Carries the decrypted <see cref="HostSecret"/> so opening the editor needs no second decryption, and
/// the flags the list has to show: an edit this machine has not pushed, a change the server refused, and
/// an item a newer client wrote that must not be re-encoded here.
/// </remarks>
internal sealed class HostRowViewModel(VaultItem<HostSecret> host)
{
internal Guid EntityId => host.EntityId;
internal HostSecret Host => host.Secret;
internal string Label => host.Secret.Label;
internal string Address => string.Create(
CultureInfo.InvariantCulture,
$"{host.Secret.Username ?? ""}@{host.Secret.Hostname}:{host.Secret.Port}");
internal bool HasUnsyncedChanges => host.HasUnsyncedChanges;
internal bool IsBlocked => host.IsBlocked;
internal bool IsReadOnly => host.IsReadOnly;
/// <summary>How this host authenticates, in one word.</summary>
/// <remarks>
/// Worth a word in the list because the two behave differently at the moment of connecting: one needs
/// the password box filled in and the other does not, and a user staring at an empty password box on a
/// key-authenticated host has no other way to know it is not needed.
/// </remarks>
internal string Authentication => host.Secret.SshKeyId is null ? "password" : "key";
/// <summary>A short marker for the row, so the list says what it knows without a tooltip.</summary>
internal string Badge => ItemBadge.For(host.IsBlocked, host.IsReadOnly, host.HasUnsyncedChanges);
}
/// <summary>An entry in the host editor's key picker.</summary>
/// <param name="EntityId">The key's item id, or null for password authentication.</param>
/// <param name="Label">What to show.</param>
/// <remarks>
/// A sentinel entry rather than a nullable selection, because a ComboBox with nothing selected and a
/// ComboBox meaning "no key" look identical and are not the same thing — the first is a host whose binding
/// has not been decided, the second is a decision.
/// </remarks>
internal sealed record SshKeyChoice(Guid? EntityId, string Label)
{
/// <summary>The "use a password" entry, always first.</summary>
internal static SshKeyChoice None { get; } = new(null, "Password (no key)");
/// <summary>
/// A stand-in for a key the host names and the vault no longer has.
/// </summary>
/// <remarks>
/// Kept in the list, and kept selected, so that opening a host to change its port does not silently
/// convert it to password authentication on save. The id is preserved; only the label admits the
/// problem.
/// </remarks>
internal static SshKeyChoice Missing(Guid entityId) => new(entityId, "(a key that is no longer here)");
}
/// <summary>One SSH key, as a row in the list.</summary>
/// <remarks>
/// <para>
/// Carries the decrypted <see cref="SshKeySecret"/>, as the host row carries its host, so opening the
/// editor or connecting with the key needs no second decryption.
/// </para>
/// <para>
/// <b>Nothing here exposes the private key to the view.</b> <see cref="Key"/> is what the editor and the
/// connect path read, and the members the XAML binds are the label, a description and a badge. That is not
/// a security boundary — the same object holds the material either way — but it does mean no template,
/// tooltip or accessibility surface can end up rendering a private key by being pointed at the obvious
/// property.
/// </para>
/// </remarks>
internal sealed class SshKeyRowViewModel(VaultItem<SshKeySecret> key)
{
internal Guid EntityId => key.EntityId;
internal SshKeySecret Key => key.Secret;
internal string Label => key.Secret.Label;
/// <summary>What the list shows under the name: what is known about the key, never the key.</summary>
internal string Description => key.Secret switch
{
{ Passphrase: not null, PublicKey: not null } => "passphrase · public half stored",
{ Passphrase: not null } => "passphrase · no public half",
{ PublicKey: not null } => "no passphrase · public half stored",
_ => "no passphrase · no public half",
};
internal bool HasUnsyncedChanges => key.HasUnsyncedChanges;
internal bool IsBlocked => key.IsBlocked;
internal bool IsReadOnly => key.IsReadOnly;
internal string Badge => ItemBadge.For(key.IsBlocked, key.IsReadOnly, key.HasUnsyncedChanges);
}
/// <summary>The one-word marker a row shows for its sync state.</summary>
/// <remarks>
/// Shared by both row types rather than written twice, because the three states mean the same thing for
/// every item type and a list where one kind said "not synced" and the other "unsynced" would read as two
/// different conditions.
/// </remarks>
internal static class ItemBadge
{
internal static string For(bool isBlocked, bool isReadOnly, bool hasUnsyncedChanges) =>
(isBlocked, isReadOnly, hasUnsyncedChanges) switch
{
(true, _, _) => "rejected",
(_, true, _) => "newer version",
(_, _, true) => "not synced",
_ => string.Empty,
};
}
/// <summary>A conflict, as a row.</summary>
internal sealed class ConflictRowViewModel(ConflictNotice notice)
{
internal Guid Id => notice.Id;
internal string Summary => notice.Summary;
/// <summary>
/// The overridden values, one line each.
/// </summary>
/// <remarks>
/// This is the whole justification for resolving a conflict automatically. If these were not shown,
/// the merge would be last-writer-wins with a longer explanation.
/// </remarks>
// The lambda parameter is 'entry' rather than the obvious 'field': C# 14 made that a contextual
// keyword inside a property accessor, and this whole expression is one.
internal string Detail => notice.Fields.Count == 0
? string.Empty
: string.Join(
Environment.NewLine,
notice.Fields.Select(entry => entry.DiscardedWasRemoval
? $"{entry.Field}: a removal was overridden; '{entry.Kept}' was kept"
: $"{entry.Field}: kept '{entry.Kept}', discarded '{entry.Discarded}'"));
internal bool HasDetail => notice.Fields.Count > 0;
}
/// <summary>
/// An open vault: the host list, the editor, syncing, and connecting a terminal.
/// </summary>
/// <remarks>
/// <para>
/// The list is the local mirror with unpushed changes laid over it, so an edit appears immediately and a
/// delete disappears immediately whether or not the network is there. Nothing here waits on a server to
/// show a change.
/// </para>
/// <para>
/// Syncing then happens on its own: once when the vault opens, straight after any local change, and on a
/// timer while it stays open. The Sync button remains, because a person who has just been handed a
/// credential wants to know now rather than within the minute — but nothing depends on it being pressed.
/// A background pass is deliberately quieter than the button: see <see cref="AutoSyncAsync" />.
/// </para>
/// <para>
/// <b>Keys and host key trust are in the vault; passwords are not yet.</b> An SSH key is a synced item, so
/// it is stored once and available on every machine, and so is a known host key — approving a fingerprint
/// here approves it on every device and survives a restart. Credentials are synced as well, but nothing in
/// this interface can create one, so password authentication still asks each time. That is a real M1
/// limitation rather than a design choice, and the interface says so rather than implying the vault holds
/// more than it does.
/// </para>
/// <para>
/// <b>A key belongs to a host.</b> Each host names the key it authenticates with, or none, and that choice
/// is a field in its encrypted payload — so it follows the host to every machine rather than being made
/// again per connection. The cost is a payload schema version, paid only by hosts that actually bind a key:
/// see <c>HostSecretCodec.CurrentSchemaVersion</c>.
/// </para>
/// </remarks>
internal sealed partial class VaultViewModel(
VaultSession session,
TerminalWorkspace workspace,
VaultKnownHostStore knownHosts,
Func<IVaultServer?> connection) : ObservableObject, IAsyncDisposable
{
/// <remarks>
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
/// server almost nothing; the number that matters is how stale a teammate's change may look, and a
/// minute is short enough not to be noticed. Anything much shorter would be polling for its own sake,
/// and a change made on this machine does not wait for the timer anyway — saving pushes immediately.
/// </remarks>
private static readonly TimeSpan AutoSyncInterval = TimeSpan.FromMinutes(1);
/// <summary>Serialises every synchronisation pass, whether a button pressed it or a timer did.</summary>
private readonly SemaphoreSlim syncGate = new(1, 1);
private CancellationTokenSource? autoSync;
private Task? autoSyncLoop;
private bool disposed;
/// <summary>The hosts to show, unpushed local state included.</summary>
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
/// <summary>The SSH keys to show, unpushed local state included.</summary>
internal ObservableCollection<SshKeyRowViewModel> Keys { get; } = [];
/// <summary>Whatever the merge had to override and the user has not acknowledged.</summary>
internal ObservableCollection<ConflictRowViewModel> Conflicts { get; } = [];
internal string VaultName =>
session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Vault";
[ObservableProperty]
private HostRowViewModel? selectedHost;
[ObservableProperty]
private SshKeyRowViewModel? selectedKey;
[ObservableProperty]
private string status = string.Empty;
[ObservableProperty]
private int pendingChanges;
[ObservableProperty]
private int unreadableItems;
[ObservableProperty]
private bool isBusy;
// ---- The editor ----
[ObservableProperty]
private bool isEditing;
[ObservableProperty]
private string editorLabel = string.Empty;
[ObservableProperty]
private string editorHostname = string.Empty;
[ObservableProperty]
private int editorPort = HostSecret.DefaultPort;
[ObservableProperty]
private string editorUsername = string.Empty;
[ObservableProperty]
private string editorNotes = string.Empty;
[ObservableProperty]
private bool editorRelayEnabled;
/// <summary>
/// What the key picker offers: password, then every key in the vault.
/// </summary>
/// <remarks>
/// Rebuilt when the editor opens rather than kept in step with the key list. A background sync could
/// pull a new key while a host is being edited, and having the picker's contents change under the user
/// mid-edit is worse than the list being a minute stale — the two editors cannot be open at once, so
/// the only way to add a key is to close this one anyway.
/// </remarks>
internal ObservableCollection<SshKeyChoice> EditorKeyChoices { get; } = [];
[ObservableProperty]
private SshKeyChoice? editorSelectedKey;
/// <summary>The item being edited, or null when creating.</summary>
private Guid? editingEntityId;
/// <summary>
/// Whether the editor is showing a host that could have a pinned key to forget.
/// </summary>
/// <remarks>
/// Read by the editor to hide the button while a host is being created, where there is nothing to
/// withdraw yet. It does not claim a pin exists — answering that would mean a second question to the
/// known-host store for a button's visibility, and the command already says plainly when there was
/// nothing to forget.
/// </remarks>
internal bool CanForgetHostKey => IsEditing && editingEntityId is not null;
// ---- The key editor ----
// A second set of editor state rather than a shared one. The two editors hold unrelated fields, and
// sharing them would mean a half-typed host reappearing inside a key editor.
[ObservableProperty]
private bool isEditingKey;
[ObservableProperty]
private string keyEditorLabel = string.Empty;
/// <remarks>
/// Bound to a text box the user pastes a private key into, so this holds key material for as long as
/// the editor is open, and <see cref="CancelKeyEdit" /> clears it. Neither that nor anything else here
/// can wipe it — see <c>SshKeySecret</c>, which explains why a .NET string is the honest choice for
/// this and what it does not buy.
/// </remarks>
[ObservableProperty]
private string keyEditorPrivateKey = string.Empty;
[ObservableProperty]
private string keyEditorPassphrase = string.Empty;
[ObservableProperty]
private string keyEditorPublicKey = string.Empty;
[ObservableProperty]
private string keyEditorNotes = string.Empty;
/// <summary>The key being edited, or null when creating.</summary>
private Guid? editingKeyId;
// ---- Connecting ----
/// <remarks>
/// Typed per connection because nothing in this interface can create a vault credential yet — not because
/// the vault cannot hold one. Never persisted.
/// </remarks>
[ObservableProperty]
private string connectPassword = string.Empty;
/// <summary>Whether the selected host authenticates with a key, so the password box can say so.</summary>
internal bool SelectedHostUsesAKey => SelectedHost?.Host.SshKeyId is not null;
[ObservableProperty]
private HostKeyPresentation? pendingHostKey;
[ObservableProperty]
private string? hostKeyMismatch;
/// <summary>
/// Raised once a terminal session is open and its renderer has it.
/// </summary>
/// <remarks>
/// An event rather than a property because handing the terminal the keyboard is something that
/// happens, not something that is true: connecting a second host while one is already open has to
/// move focus again, and no state change describes that. Raised on the UI thread — every await on
/// the path from the command to here uses <c>ConfigureAwait(true)</c> — so a handler may touch
/// controls directly.
/// </remarks>
internal event EventHandler? SessionOpened;
internal bool HasPendingHostKey => PendingHostKey is not null;
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
internal bool HasConflicts => Conflicts.Count > 0;
/// <summary>Reads the vault into the list and says what is in it.</summary>
internal async Task LoadAsync(CancellationToken cancellationToken)
{
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = (Hosts.Count, Keys.Count) switch
{
(0, 0) => "No hosts yet. Add one.",
(0, var keys) => $"No hosts yet, and {keys} key(s) in {VaultName}.",
(var hosts, 0) => $"{hosts} host(s) in {VaultName}.",
var (hosts, keys) => $"{hosts} host(s) and {keys} key(s) in {VaultName}.",
};
}
/// <summary>
/// Reads the vault into the list, silently.
/// </summary>
/// <remarks>
/// Separate from <see cref="LoadAsync" /> because every caller except the first load has something
/// better to say afterwards — a save, a deletion, or a sync report — and a background pass has nothing
/// to say at all. Rebuilding the list used to repaint the status line unconditionally, which made
/// "the background pass is quiet" false on the one path that mattered.
/// </remarks>
private async Task ReloadAsync(CancellationToken cancellationToken)
{
var unreadable = await ReloadHostsAsync(cancellationToken).ConfigureAwait(true);
unreadable += await ReloadKeysAsync(cancellationToken).ConfigureAwait(true);
UnreadableItems = unreadable;
PendingChanges = await session.PendingChangeCountAsync(cancellationToken).ConfigureAwait(true);
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
}
/// <returns>How many hosts would not decrypt.</returns>
private async Task<int> ReloadHostsAsync(CancellationToken cancellationToken)
{
var listing = await session.Hosts
.ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(true);
var selectedId = SelectedHost?.EntityId;
Hosts.Clear();
foreach (var host in listing.Items.OrderBy(host => host.Secret.Label, StringComparer.CurrentCulture))
{
Hosts.Add(new HostRowViewModel(host));
}
// Selection survives a reload. Losing it on every sync would move the terminal's target out from
// under the user.
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == selectedId) ?? Hosts.FirstOrDefault();
return listing.Unreadable;
}
/// <returns>How many keys would not decrypt.</returns>
/// <remarks>
/// Unlike the host list, the selection is <em>not</em> defaulted to the first row. A key selection is
/// what <see cref="UseKeyAuthentication" /> authenticates with, and quietly selecting one on load would
/// mean a connection made with a key the user never chose.
/// </remarks>
private async Task<int> ReloadKeysAsync(CancellationToken cancellationToken)
{
var listing = await session.SshKeys
.ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(true);
var selectedId = SelectedKey?.EntityId;
Keys.Clear();
foreach (var key in listing.Items.OrderBy(key => key.Secret.Label, StringComparer.CurrentCulture))
{
Keys.Add(new SshKeyRowViewModel(key));
}
SelectedKey = Keys.FirstOrDefault(row => row.EntityId == selectedId);
return listing.Unreadable;
}
/// <summary>Runs a synchronisation pass, if there is a server to talk to.</summary>
[RelayCommand]
private async Task SyncAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server)
{
Status = "Offline. Changes are queued and will be sent after you sign in.";
return;
}
await RunAsync(
"Synchronising…",
async () =>
{
var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true);
// Null means a background pass held the gate. Saying so beats reporting a sync that this
// press did not perform.
Status = report is null
? "A synchronisation is already running."
: Describe(report);
}).ConfigureAwait(true);
}
/// <summary>
/// Starts syncing in the background until the vault is disposed.
/// </summary>
/// <remarks>
/// Explicit rather than started from the constructor, so that a test can drive
/// <see cref="AutoSyncAsync" /> a pass at a time instead of racing a timer.
/// </remarks>
internal void StartAutoSync()
{
if (autoSync is not null)
{
return;
}
autoSync = new CancellationTokenSource();
autoSyncLoop = RunAutoSyncLoopAsync(autoSync.Token);
}
/// <summary>
/// One background synchronisation pass, which stays out of the way.
/// </summary>
/// <remarks>
/// <para>
/// Deliberately not routed through <see cref="RunAsync" />. That would raise the busy flag every
/// interval — disabling Connect and Save for the duration — and repaint the status line with
/// "Synchronising…" while the user was reading something else. A background pass that makes the
/// application feel intermittently broken is worse than a Sync button.
/// </para>
/// <para>
/// So it is silent unless it has something to say: the status line changes only when the pass actually
/// moved an item or produced something needing attention. It also yields to the user — a pass is
/// skipped outright while a command is running, rather than queueing behind it.
/// </para>
/// </remarks>
internal async Task AutoSyncAsync(CancellationToken cancellationToken)
{
if (IsBusy || connection() is not { } server)
{
return;
}
try
{
var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true);
if (report is not null && (report.Pulled > 0 || report.Pushed > 0 || report.NeedsAttention))
{
Status = Describe(report);
}
}
catch (OperationCanceledException)
{
// Locking, or closing.
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Swallowed on purpose, and this is the one place in the view model where that is right: a
// laptop that has been closed all afternoon would otherwise replace whatever the user was
// reading with a socket error once a minute. The failure is not hidden — the account bar
// already shows when there is no connection, and pressing Sync reports the real reason.
}
}
/// <remarks>
/// The gate is shared with the manual command, so a press and a tick can never overlap. Taken with a
/// zero timeout rather than awaited: a pass that arrives while another is running has nothing to add by
/// waiting for it, and queueing them would turn a slow server into a backlog of identical work.
/// </remarks>
private async Task<SyncReport?> SyncOnceAsync(ISyncApi api, CancellationToken cancellationToken)
{
if (!await syncGate.WaitAsync(0, cancellationToken).ConfigureAwait(true))
{
return null;
}
try
{
var report = await session.SyncAsync(api, cancellationToken).ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
// Host key trust arrives with the rest of the vault, and the store the SSH handshake asks holds a
// snapshot rather than reading per lookup — so a pass that pulled a pin has to hand it over here,
// or a host a colleague approved stays a first-contact prompt until the next unlock.
await knownHosts.RefreshAsync(cancellationToken).ConfigureAwait(true);
return report;
}
finally
{
syncGate.Release();
}
}
/// <remarks>
/// <c>ConfigureAwait(true)</c> throughout, and that is load-bearing rather than habit: the loop is
/// started from the UI thread, so every continuation returns to it and the observable collections
/// <see cref="LoadAsync" /> rebuilds are still only ever touched from one thread. A
/// <c>ConfigureAwait(false)</c> here would mutate them from a timer thread, which Avalonia will
/// eventually notice in a way that looks like a rendering bug.
/// </remarks>
private async Task RunAutoSyncLoopAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(AutoSyncInterval);
try
{
// A pass on open, before the first tick. A vault edited on another machine should be current by
// the time the user has finished reading the list, not a minute afterwards.
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
{
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
}
catch (OperationCanceledException)
{
// Locking, or closing.
}
}
/// <summary>Starts a new host.</summary>
[RelayCommand]
private void NewHost()
{
if (KeyEditorIsInTheWay())
{
return;
}
editingEntityId = null;
EditorLabel = string.Empty;
EditorHostname = string.Empty;
EditorPort = HostSecret.DefaultPort;
EditorUsername = string.Empty;
EditorNotes = string.Empty;
EditorRelayEnabled = false;
BuildKeyChoices(boundKeyId: null);
IsEditing = true;
Status = "Adding a host.";
}
/// <summary>Opens the selected host for editing.</summary>
[RelayCommand]
private void EditSelectedHost()
{
if (SelectedHost is not { } row || KeyEditorIsInTheWay())
{
return;
}
if (row.IsReadOnly)
{
// Re-encoding would drop fields this build has no concept of, so the honest answer is to
// refuse rather than to silently lose a colleague's data.
Status = "This host was written by a newer version of DodoSSH. Update before editing it.";
return;
}
editingEntityId = row.EntityId;
EditorLabel = row.Host.Label;
EditorHostname = row.Host.Hostname;
EditorPort = row.Host.Port;
EditorUsername = row.Host.Username ?? string.Empty;
EditorNotes = row.Host.Notes ?? string.Empty;
EditorRelayEnabled = row.Host.RelayEnabled;
BuildKeyChoices(row.Host.SshKeyId);
IsEditing = true;
Status = $"Editing {row.Label}.";
}
/// <summary>Abandons the editor.</summary>
[RelayCommand]
private void CancelEdit()
{
IsEditing = false;
editingEntityId = null;
Status = string.Empty;
}
/// <summary>Stores the editor's contents, encrypted, and queues it for the server.</summary>
[RelayCommand]
private async Task SaveHostAsync(CancellationToken cancellationToken)
{
var host = BuildHost();
if (!host.TryValidate(out var error))
{
Status = error;
return;
}
await RunAsync(
"Saving…",
async () =>
{
if (editingEntityId is { } entityId)
{
await session.Hosts
.UpdateAsync(session.ActiveVaultId, entityId, host, cancellationToken)
.ConfigureAwait(true);
}
else
{
editingEntityId = await session.Hosts
.CreateAsync(session.ActiveVaultId, host, cancellationToken)
.ConfigureAwait(true);
}
IsEditing = false;
await ReloadAsync(cancellationToken).ConfigureAwait(true);
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == editingEntityId);
editingEntityId = null;
Status = connection() is null
? $"Saved '{host.Label}'. It will sync when you are online."
: $"Saved '{host.Label}'.";
}).ConfigureAwait(true);
// Pushed now rather than at the next tick. A change the user just made is the one they are most
// likely to be about to look for on another machine.
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>Queues a tombstone for the selected host.</summary>
[RelayCommand]
private async Task DeleteHostAsync(CancellationToken cancellationToken)
{
if (SelectedHost is not { } row)
{
return;
}
await RunAsync(
"Deleting…",
async () =>
{
await session.Hosts
.DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = $"Deleted '{row.Label}'.";
}).ConfigureAwait(true);
// As with saving: a tombstone is worth pushing straight away, so the item does not reappear on
// another machine that syncs before the next tick.
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>Starts a new SSH key.</summary>
[RelayCommand]
private void NewKey()
{
if (HostEditorIsInTheWay())
{
return;
}
editingKeyId = null;
ClearKeyEditor();
IsEditingKey = true;
Status = "Adding an SSH key.";
}
/// <summary>Opens the selected key for editing.</summary>
/// <remarks>
/// The private key is loaded into the editor, which is the only way an edit can preserve it: the
/// codec has no notion of a partial update, so saving re-encodes every field.
/// </remarks>
[RelayCommand]
private void EditSelectedKey()
{
if (SelectedKey is not { } row || HostEditorIsInTheWay())
{
return;
}
if (row.IsReadOnly)
{
Status = "This key was written by a newer version of DodoSSH. Update before editing it.";
return;
}
editingKeyId = row.EntityId;
KeyEditorLabel = row.Key.Label;
KeyEditorPrivateKey = row.Key.PrivateKeyPem;
KeyEditorPassphrase = row.Key.Passphrase ?? string.Empty;
KeyEditorPublicKey = row.Key.PublicKey ?? string.Empty;
KeyEditorNotes = row.Key.Notes ?? string.Empty;
IsEditingKey = true;
Status = $"Editing {row.Label}.";
}
/// <summary>Abandons the key editor, clearing the material out of it.</summary>
[RelayCommand]
private void CancelKeyEdit()
{
IsEditingKey = false;
editingKeyId = null;
ClearKeyEditor();
Status = string.Empty;
}
/// <summary>Stores the key editor's contents, encrypted, and queues it for the server.</summary>
[RelayCommand]
private async Task SaveKeyAsync(CancellationToken cancellationToken)
{
var key = BuildKey();
if (!key.TryValidate(out var reason))
{
Status = reason;
return;
}
await RunAsync(
"Saving…",
async () =>
{
if (editingKeyId is { } entityId)
{
await session.SshKeys
.UpdateAsync(session.ActiveVaultId, entityId, key, cancellationToken)
.ConfigureAwait(true);
}
else
{
editingKeyId = await session.SshKeys
.CreateAsync(session.ActiveVaultId, key, cancellationToken)
.ConfigureAwait(true);
}
IsEditingKey = false;
ClearKeyEditor();
await ReloadAsync(cancellationToken).ConfigureAwait(true);
SelectedKey = Keys.FirstOrDefault(row => row.EntityId == editingKeyId);
editingKeyId = null;
Status = connection() is null
? $"Saved '{key.Label}'. It will sync when you are online."
: $"Saved '{key.Label}'.";
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>Queues a tombstone for the selected key.</summary>
[RelayCommand]
private async Task DeleteKeyAsync(CancellationToken cancellationToken)
{
if (SelectedKey is not { } row)
{
return;
}
await RunAsync(
"Deleting…",
async () =>
{
await session.SshKeys
.DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = $"Deleted '{row.Label}'.";
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>Opens a terminal on the selected host.</summary>
[RelayCommand]
private async Task ConnectAsync(CancellationToken cancellationToken)
{
if (SelectedHost is not { } row)
{
Status = "Choose a host first.";
return;
}
if (string.IsNullOrEmpty(row.Host.Username))
{
Status = "This host has no username. Edit it and add one.";
return;
}
if (TryBuildCredential(row.Host) is not { } credential)
{
// Refused rather than quietly falling back to the password box. A host set up for key-only
// access that silently starts offering a password is the failure worth ruling out — the user
// asked for one thing and got another, and the host is the last place that would say so.
Status = $"'{row.Label}' authenticates with an SSH key that is not in this vault any more. "
+ "Edit the host to choose another key, or set it back to a password.";
return;
}
PendingHostKey = null;
HostKeyMismatch = null;
await RunAsync(
$"Connecting to {row.Label}…",
() => OpenSessionAsync(row, credential, cancellationToken)).ConfigureAwait(true);
}
/// <summary>
/// Pins the offered host key and retries.
/// </summary>
/// <remarks>
/// The pin goes into the vault, so this writes to the local cache and queues a change for every other
/// machine — which is why the write is guarded and the connection is only retried once it has landed.
/// It used to be a dictionary insert that could not fail; reporting a failed write as a failed
/// connection would send the user looking at the host.
/// </remarks>
[RelayCommand]
private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
{
if (PendingHostKey is not { } presentation)
{
return;
}
try
{
await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
}
catch (OperationCanceledException)
{
Status = "Cancelled.";
return;
}
catch (Exception exception)
{
Status = $"The host key could not be stored, so nothing was connected: {exception.Message}";
return;
}
PendingHostKey = null;
await ConnectAsync(cancellationToken).ConfigureAwait(true);
// After connecting, not before. A pin is worth pushing straight away — the same host on another
// machine should not ask again — but not at the cost of delaying the connection the user asked for.
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>Dismisses the trust prompt without pinning anything.</summary>
[RelayCommand]
private void RejectHostKey()
{
PendingHostKey = null;
Status = "The host key was not trusted, so nothing was connected.";
}
/// <summary>
/// Withdraws trust from every key pinned for the host being edited.
/// </summary>
/// <remarks>
/// <para>
/// The counterpart to trust that now outlives the process, and the reason it exists at all: a mismatch is
/// a hard refusal with no way past it, so a server that is legitimately rebuilt would be unreachable for
/// ever without this. It is deliberately <em>here</em> — in the host's editor, reached by choosing to edit
/// a host — and not on the refusal itself. A "forget this key" button next to the warning is the same
/// button as "continue anyway" with two clicks instead of one.
/// </para>
/// <para>
/// Applies to the host's <em>saved</em> address rather than whatever the editor's boxes currently hold.
/// The pin belongs to the endpoint that was actually dialled, and someone halfway through retyping a
/// hostname has not moved it yet.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ForgetHostKeyAsync(CancellationToken cancellationToken)
{
if (editingEntityId is not { } entityId
|| Hosts.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
{
return;
}
var address = row.Host.Hostname;
var port = row.Host.Port;
await RunAsync(
$"Forgetting the pinned host key for {address}…",
async () =>
{
var forgotten = await knownHosts
.ForgetAsync(address, port, cancellationToken)
.ConfigureAwait(true);
// The refusal that sent the user here is about a pin that no longer exists.
HostKeyMismatch = null;
PendingChanges = await session
.PendingChangeCountAsync(cancellationToken)
.ConfigureAwait(true);
if (forgotten == 0)
{
Status = $"Nothing was pinned for {address}:{port}.";
return;
}
Status = $"Forgot the pinned host key for {address}:{port}. The next connection will "
+ "ask you to check its fingerprint again.";
}).ConfigureAwait(true);
// Pushed straight away, as a save or a deletion is: a withdrawal that stayed on this machine would
// leave the other ones refusing to connect to a server that has been rebuilt.
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Marks every shown conflict as seen.
/// </summary>
/// <remarks>
/// Acknowledged rather than deleted, so the discarded values stay retrievable afterwards. Someone who
/// dismisses this and realises a minute later that they wanted the other value should still be able to
/// get it.
/// </remarks>
[RelayCommand]
private async Task AcknowledgeAllConflictsAsync(CancellationToken cancellationToken)
{
foreach (var conflict in Conflicts.ToArray())
{
await session.AcknowledgeConflictAsync(conflict.Id, cancellationToken).ConfigureAwait(true);
}
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
// The loop is stopped and awaited before the session goes, not merely signalled. A pass in flight
// holds the vault keys and the cache; letting it run on into a disposed session is how locking
// turns into an ObjectDisposedException on a background thread that nobody sees.
if (autoSync is not null)
{
await autoSync.CancelAsync().ConfigureAwait(false);
}
if (autoSyncLoop is not null)
{
await autoSyncLoop.ConfigureAwait(false);
}
autoSync?.Dispose();
syncGate.Dispose();
await session.DisposeAsync().ConfigureAwait(false);
}
/// <remarks>
/// The renderer has to be attached before a session opens: the transport drops frames when nothing is
/// connected, so a session opened earlier would lose its <c>SessionOpened</c> frame and then stream
/// output at a terminal that was never created. That wait is bounded and takes this command's token, so
/// a renderer that never arrives ends as a message rather than as a window stuck on "Connecting…".
/// </remarks>
private async Task OpenSessionAsync(
HostRowViewModel row,
SshCredential credential,
CancellationToken cancellationToken)
{
try
{
await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true);
var request = new SshConnectionRequest(
row.Host.Hostname,
row.Host.Port,
row.Host.Username!,
credential);
await workspace
.OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
.ConfigureAwait(true);
Status = $"Connected to {row.Label}.";
// Only now, and only on success. The page's own term.focus() focuses the textarea inside
// the document, which does nothing while the window's keyboard focus is still on the
// Connect button — so without this the first keystrokes of the session go to the shell's
// UI instead of the remote shell.
SessionOpened?.Invoke(this, EventArgs.Empty);
}
catch (TimeoutException)
{
// The renderer never attached, so nothing was connected. Reported here rather than left to
// RunAsync's generic handler because TimeoutException says only "The operation has timed out",
// and the one thing worth saying is where to look: a runtime this application does not install.
Status = "The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 "
+ "runtime is probably missing or blocked; install it and try again.";
}
catch (SshHostKeyUnknownException exception)
{
// First contact. The user has to decide, and they need the fingerprint to do it.
PendingHostKey = exception.Presentation;
Status = "This host has not been seen before.";
}
catch (SshHostKeyMismatchException exception)
{
HostKeyMismatch = exception.Message;
Status = "The host key has changed. The connection was refused.";
}
}
/// <summary>
/// How this host authenticates, or null when it names a key the vault does not have.
/// </summary>
/// <remarks>
/// <para>
/// The key material is handed over as UTF-8 bytes, which is what <c>PrivateKeyFile</c> reads from a
/// <c>MemoryStream</c> — so the key reaches SSH.NET without ever becoming a file on disk. The
/// passphrase goes with it: a key stored in the vault together with its passphrase is the whole point
/// of a vault, and <c>SshKeySecret</c> says why. It is passed straight through with no empty-to-null
/// check, because <c>SshKeySecret.Passphrase</c> cannot hold an empty string.
/// </para>
/// <para>
/// Null is a refusal, not a fallback, and the caller must treat it as one. A dangling reference means a
/// key was deleted on another machine — plausible, and no reason to start sending a password to a host
/// somebody deliberately set up not to accept one.
/// </para>
/// </remarks>
private SshCredential? TryBuildCredential(HostSecret host)
{
if (host.SshKeyId is not { } keyId)
{
return new SshPasswordCredential(ConnectPassword);
}
if (Keys.FirstOrDefault(row => row.EntityId == keyId) is not { } key)
{
return null;
}
return new SshPrivateKeyCredential(
Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase);
}
private HostSecret BuildHost() =>
new()
{
Label = EditorLabel.Trim(),
Hostname = EditorHostname.Trim(),
Port = EditorPort,
Username = string.IsNullOrWhiteSpace(EditorUsername) ? null : EditorUsername.Trim(),
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
RelayEnabled = EditorRelayEnabled,
// Whatever the picker holds, including the id of a key that has gone missing. Reading it from
// the picker rather than carrying the original through is what lets a binding be removed at all,
// and preserving a missing id is what stops an unrelated edit removing one by accident.
SshKeyId = EditorSelectedKey?.EntityId,
};
/// <summary>
/// Fills the key picker, keeping whatever the host is currently bound to selectable.
/// </summary>
/// <param name="boundKeyId">The key the host names, or null for password authentication.</param>
/// <remarks>
/// A bound key that is no longer in the vault gets a placeholder entry rather than being dropped. Without
/// one the picker would open on "Password (no key)", and someone editing the host's port would convert it
/// to password authentication by saving — which is the quiet version of the failure the connect path
/// refuses outright.
/// </remarks>
private void BuildKeyChoices(Guid? boundKeyId)
{
EditorKeyChoices.Clear();
EditorKeyChoices.Add(SshKeyChoice.None);
foreach (var key in Keys)
{
EditorKeyChoices.Add(new SshKeyChoice(key.EntityId, key.Label));
}
if (boundKeyId is { } bound && EditorKeyChoices.All(choice => choice.EntityId != bound))
{
EditorKeyChoices.Add(SshKeyChoice.Missing(bound));
}
EditorSelectedKey = EditorKeyChoices.FirstOrDefault(choice => choice.EntityId == boundKeyId)
?? SshKeyChoice.None;
}
/// <remarks>
/// The private key is not trimmed. Its armour is whitespace-significant and a client that tidied it up
/// would eventually tidy a format it did not fully understand — the same reason
/// <c>SshKeySecret.PrivateKeyPem</c> stores it verbatim. Everything else is trimmed, because a label
/// with a trailing space sorts oddly and reads as a different name.
/// </remarks>
private SshKeySecret BuildKey() =>
new()
{
Label = KeyEditorLabel.Trim(),
PrivateKeyPem = KeyEditorPrivateKey,
// Not trimmed and not emptied: leading or trailing spaces are legitimate in a passphrase, and
// the record turns an empty one into null on its own.
Passphrase = KeyEditorPassphrase,
PublicKey = string.IsNullOrWhiteSpace(KeyEditorPublicKey) ? null : KeyEditorPublicKey.Trim(),
Notes = string.IsNullOrWhiteSpace(KeyEditorNotes) ? null : KeyEditorNotes,
};
/// <summary>
/// Whether the key editor has to be dealt with before another one can open.
/// </summary>
/// <remarks>
/// <para>
/// Only one editor open at a time, and this is a layout constraint rather than a style rule. Both
/// editors sit in the same 340-pixel column as <c>Auto</c> rows, and their desired heights together
/// exceed the column at the window's minimum height — so opening both pushes the lower one's Save and
/// Cancel past the bottom edge, where they cannot be clicked. That is the same failure this window has
/// already shipped once, when the setup screens rendered sliced with their buttons unreachable, and it is
/// the failure that nothing in this repository can catch: no test loads a <c>.axaml</c>. Making it a
/// state rule instead of a sizing hope is what makes it testable at all.
/// </para>
/// <para>
/// Refused rather than resolved by closing the other editor, because closing it would silently discard
/// what was typed there — and in the key editor that is a pasted private key the user may have nowhere
/// else. One sentence and one click is the cheaper of the two.
/// </para>
/// </remarks>
private bool KeyEditorIsInTheWay()
{
if (!IsEditingKey)
{
return false;
}
Status = "Finish or cancel the SSH key you are editing first.";
return true;
}
/// <inheritdoc cref="KeyEditorIsInTheWay" />
private bool HostEditorIsInTheWay()
{
if (!IsEditing)
{
return false;
}
Status = "Finish or cancel the host you are editing first.";
return true;
}
private void ClearKeyEditor()
{
KeyEditorLabel = string.Empty;
KeyEditorPrivateKey = string.Empty;
KeyEditorPassphrase = string.Empty;
KeyEditorPublicKey = string.Empty;
KeyEditorNotes = string.Empty;
}
private async Task LoadConflictsAsync(CancellationToken cancellationToken)
{
var notices = await session.ReadConflictsAsync(cancellationToken).ConfigureAwait(true);
Conflicts.Clear();
foreach (var notice in notices)
{
Conflicts.Add(new ConflictRowViewModel(notice));
}
OnPropertyChanged(nameof(HasConflicts));
}
/// <remarks>
/// Deliberately reports the things a user has to act on rather than a count of successes. A pass that
/// resurrected a host or parked a change looks identical to a quiet one otherwise, and the whole point
/// of recording those is that somebody sees them.
/// </remarks>
private static string Describe(SyncReport report)
{
if (!report.NeedsAttention)
{
return report.Pulled == 0 && report.Pushed == 0
? "Already up to date."
: $"Synchronised: {report.Pulled} in, {report.Pushed} out.";
}
var notes = new List<string>();
// "item(s)", not "host(s)": a vault now holds keys as well, and a report that named the wrong kind
// would send someone looking through the wrong list for something that was not there.
if (report.Resurrected > 0)
{
notes.Add($"{report.Resurrected} item(s) deleted elsewhere were kept under a new name");
}
if (report.DeletesAbandoned > 0)
{
notes.Add($"{report.DeletesAbandoned} deletion(s) were not applied because of a newer edit");
}
if (report.Parked > 0)
{
notes.Add($"{report.Parked} change(s) were refused and need attention");
}
if (report.Unreadable > 0)
{
notes.Add($"{report.Unreadable} item(s) could not be decrypted");
}
if (report.RekeyRequired)
{
notes.Add("this vault was rekeyed and your access needs re-issuing");
}
return "Synchronised, but: " + string.Join("; ", notes) + ".";
}
private async Task RunAsync(string busyMessage, Func<Task> work)
{
if (IsBusy)
{
return;
}
IsBusy = true;
Status = busyMessage;
try
{
await work().ConfigureAwait(true);
}
catch (OperationCanceledException)
{
Status = "Cancelled.";
}
catch (Exception exception)
{
Status = exception.Message;
}
finally
{
IsBusy = false;
}
}
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
OnPropertyChanged(nameof(SelectedHostUsesAKey));
/// <remarks>
/// The editing id is set before this flips in every path that opens the editor, and cleared after it
/// flips back in every path that closes one, so this notification always observes the pair in a
/// consistent state.
/// </remarks>
partial void OnIsEditingChanged(bool value) =>
OnPropertyChanged(nameof(CanForgetHostKey));
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
OnPropertyChanged(nameof(HasPendingHostKey));
partial void OnHostKeyMismatchChanged(string? value) =>
OnPropertyChanged(nameof(HasHostKeyMismatch));
}