Public Access
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.
This commit is contained in:
@@ -52,10 +52,11 @@ internal sealed partial class DodoSshApp : Application
|
||||
var paths = ClientPaths.Default;
|
||||
var caches = ClientCacheFactory.ForFile(paths.CacheFile);
|
||||
|
||||
// Known hosts are still in memory. The plan puts them in the vault as a synced entity so trust
|
||||
// follows the user to every device, and SyncEntityType.KnownHostKey is reserved for it — but that
|
||||
// entity type is not synced yet, so trust currently lasts one session.
|
||||
var knownHosts = new InMemoryKnownHostStore();
|
||||
// Known hosts live in the vault, so trust survives a restart and follows the user to every device.
|
||||
// Composed here, once, because the connection factory below needs it now and outlives every unlock;
|
||||
// the vault behind it is attached and detached as one is opened and locked. See VaultKnownHostStore
|
||||
// for why the handshake is answered from a snapshot rather than by reading the vault per lookup.
|
||||
var knownHosts = new VaultKnownHostStore();
|
||||
|
||||
var workspace = new TerminalWorkspace(
|
||||
new AvaloniaTerminalAssetProvider(),
|
||||
|
||||
@@ -3,7 +3,6 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Crypto;
|
||||
@@ -60,7 +59,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private readonly ClientPaths paths;
|
||||
private readonly ClientCacheFactory caches;
|
||||
private readonly TerminalWorkspace workspace;
|
||||
private readonly IKnownHostStore knownHosts;
|
||||
|
||||
/// <remarks>
|
||||
/// The concrete store rather than <c>IKnownHostStore</c>, because this is where its lifecycle belongs:
|
||||
/// the interface is what the handshake asks, and opening a vault behind it, refreshing it and forgetting
|
||||
/// it are this state machine's business. The same instance was handed to the connection factory when the
|
||||
/// application was composed.
|
||||
/// </remarks>
|
||||
private readonly VaultKnownHostStore knownHosts;
|
||||
|
||||
private readonly SignInHandler signIn;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly Argon2Profile? passphraseProfile;
|
||||
@@ -83,7 +90,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
ClientPaths paths,
|
||||
ClientCacheFactory caches,
|
||||
TerminalWorkspace workspace,
|
||||
IKnownHostStore knownHosts,
|
||||
VaultKnownHostStore knownHosts,
|
||||
SignInHandler signIn,
|
||||
TimeProvider clock,
|
||||
Argon2Profile? passphraseProfile = null)
|
||||
@@ -355,6 +362,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
Passphrase = string.Empty;
|
||||
|
||||
// Before the vault view model, so the first connection after an unlock already knows which
|
||||
// host keys this user has approved. Reading them is one listing; doing it here rather than
|
||||
// lazily is what keeps it off the SSH handshake thread.
|
||||
try
|
||||
{
|
||||
await knownHosts.OpenAsync(outcome.Session!, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Nothing owns the session yet, so nothing else would ever dispose it — and an
|
||||
// undisposed session is vault keys left in memory for the life of the process, which is
|
||||
// precisely what unlocking must be able to undo.
|
||||
await outcome.Session!.DisposeAsync().ConfigureAwait(true);
|
||||
throw;
|
||||
}
|
||||
|
||||
Vault = new VaultViewModel(outcome.Session!, workspace, knownHosts, () => connection);
|
||||
State = ShellState.Unlocked;
|
||||
|
||||
@@ -398,6 +421,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[RelayCommand]
|
||||
private async Task LockAsync()
|
||||
{
|
||||
// First, and before the session it read from goes: a synchronisation pass may be in flight, and it
|
||||
// ends by refreshing this store. Detaching now makes that refresh a no-op instead of a set of pins
|
||||
// reappearing behind a lock screen.
|
||||
knownHosts.Close();
|
||||
|
||||
if (Vault is { } open)
|
||||
{
|
||||
Vault = null;
|
||||
@@ -420,6 +448,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
disposed = true;
|
||||
|
||||
knownHosts.Close();
|
||||
|
||||
if (Vault is { } open)
|
||||
{
|
||||
await open.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -173,10 +173,12 @@ internal sealed class ConflictRowViewModel(ConflictNotice notice)
|
||||
/// A background pass is deliberately quieter than the button: see <see cref="AutoSyncAsync" />.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Keys are in the vault; passwords are not.</b> An SSH key is a synced item, so it is stored once and
|
||||
/// available on every machine. <c>SyncEntityType.Credential</c> exists in the contract and is still not
|
||||
/// synced, so password authentication asks for the password 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.
|
||||
/// <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
|
||||
@@ -188,7 +190,7 @@ internal sealed class ConflictRowViewModel(ConflictNotice notice)
|
||||
internal sealed partial class VaultViewModel(
|
||||
VaultSession session,
|
||||
TerminalWorkspace workspace,
|
||||
IKnownHostStore knownHosts,
|
||||
VaultKnownHostStore knownHosts,
|
||||
Func<IVaultServer?> connection) : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
/// <remarks>
|
||||
@@ -276,6 +278,17 @@ internal sealed partial class VaultViewModel(
|
||||
/// <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.
|
||||
@@ -310,7 +323,8 @@ internal sealed partial class VaultViewModel(
|
||||
// ---- Connecting ----
|
||||
|
||||
/// <remarks>
|
||||
/// Typed per connection because credentials are not a synced entity type yet. Never persisted.
|
||||
/// 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;
|
||||
@@ -531,6 +545,11 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
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
|
||||
@@ -854,7 +873,15 @@ internal sealed partial class VaultViewModel(
|
||||
() => OpenSessionAsync(row, credential, cancellationToken)).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Pins the offered host key and retries.</summary>
|
||||
/// <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)
|
||||
{
|
||||
@@ -863,11 +890,28 @@ internal sealed partial class VaultViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
|
||||
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>
|
||||
@@ -878,6 +922,65 @@ internal sealed partial class VaultViewModel(
|
||||
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>
|
||||
@@ -1219,6 +1322,14 @@ internal sealed partial class VaultViewModel(
|
||||
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));
|
||||
|
||||
|
||||
@@ -163,6 +163,19 @@
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Save" Command="{Binding Vault.SaveHostCommand}" />
|
||||
<Button Content="Cancel" Command="{Binding Vault.CancelEditCommand}" />
|
||||
<!--
|
||||
Withdrawing host key trust lives here, in the host's own settings, because a changed host key
|
||||
is refused outright with no way to continue past it — so a legitimately rebuilt server needs
|
||||
somewhere deliberate to be re-approved from, and that somewhere must not be the warning
|
||||
itself. It takes effect when clicked rather than on Save, and the status line says so; it is
|
||||
not a field of the host.
|
||||
|
||||
Added to this row rather than as a row of its own on purpose: this column's editors already
|
||||
only just fit at the window's minimum height, which is why only one may be open at a time.
|
||||
-->
|
||||
<Button Content="Forget host key" Command="{Binding Vault.ForgetHostKeyCommand}"
|
||||
IsVisible="{Binding Vault.CanForgetHostKey}"
|
||||
ToolTip.Tip="Removes the pinned key for this host's address, so the next connection asks you to check its fingerprint again." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
@@ -253,10 +266,10 @@
|
||||
<Border Grid.Row="0" Padding="10,8" Background="#171b24" IsVisible="{Binding IsUnlocked}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<!--
|
||||
Typed per connection. SyncEntityType.Credential exists in the contract but is not synced
|
||||
yet, so the vault genuinely does not hold this — saying so beats a password box that looks
|
||||
like it should have been remembered. Disabled rather than hidden when a key is being used, so
|
||||
it stays visible that a password is what the other choice means.
|
||||
Typed per connection. Credentials do sync, but nothing in this interface can create one, so
|
||||
the vault genuinely does not hold this — saying so beats a password box that looks like it
|
||||
should have been remembered. Disabled rather than hidden when a key is being used, so it
|
||||
stays visible that a password is what the other choice means.
|
||||
-->
|
||||
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored yet)"
|
||||
PasswordChar="•" Width="220" VerticalAlignment="Center"
|
||||
@@ -305,7 +318,7 @@
|
||||
Foreground="#f3c9cd" FontWeight="SemiBold" />
|
||||
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
|
||||
Foreground="#f3c9cd" TextWrapping="Wrap" />
|
||||
<TextBlock Text="If the server was legitimately rebuilt, remove its pinned key in the host's settings first. There is deliberately no way to continue from here."
|
||||
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose "Forget host key" first. There is deliberately no way to continue from here."
|
||||
Foreground="#d59aa1" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -355,6 +355,7 @@
|
||||
"DodoSSH.Client.Api": "[1.0.0, )",
|
||||
"DodoSSH.Client.Auth": "[1.0.0, )",
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Storage": "[1.0.0, )",
|
||||
"DodoSSH.Client.Sync": "[1.0.0, )"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user