Keep host key trust in the vault, and make it withdrawable
ci / build and test (ubuntu) (push) Canceled after 0s
ci / build (windows) (push) Canceled after 0s

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:
2026-07-30 11:00:39 +02:00
parent d10a38d8e6
commit 211eba0666
38 changed files with 4363 additions and 95 deletions
+24 -13
View File
@@ -132,10 +132,16 @@ private key, then edit a host and pick that key from its **key** dropdown. From
authenticates with it — on every machine, since the choice travels inside the host's encrypted payload —
and its password box disappears.
Three of M1's known gaps are visible immediately, so they are worth expecting rather than diagnosing:
password authentication asks for the password every time, because credentials are not a synced entity type
yet (keys are — passwords are not); host key trust lasts one session, because known hosts do not live in the
vault yet; and unlock asks for the passphrase on every launch, because no device key is registered.
The first time you connect to a host you are asked to check its key fingerprint. That decision is stored in
the vault, so it is asked once per host rather than once per launch and it reaches your other machines with
the next sync. If a server is legitimately rebuilt and offers a new key, the connection is refused outright
with no way to continue from the warning — edit the host and choose **Forget host key**, which is deliberately
somewhere you have to go on purpose.
Two of M1's known gaps are visible immediately, so they are worth expecting rather than diagnosing: password
authentication asks for the password every time, because nothing in the interface can create a vault
credential yet (they do sync — there is just no editor for one); and unlock asks for the passphrase on every
launch, because no device key is registered.
### End-to-end verification
@@ -148,9 +154,9 @@ dotnet test tests/DodoSSH.SystemTests
It brings up PostgreSQL, Keycloak and an OpenSSH server in containers, applies the committed migrations,
starts the API as a child process out of its own build output, and then drives the real client: sign in
through Keycloak, enroll, unlock, create a host, sync it, read it back on a second simulated machine,
unlock again with no network, and open a shell on the `sshd`. Roughly 25 seconds once the images are
pulled.
through Keycloak, enroll, unlock, create an SSH key and a host bound to it, sync them, open a shell on the
`sshd` and approve its host key at the real first-contact refusal, then read all three back on a second
simulated machine and unlock again with no network. Roughly 25 seconds once the images are pulled.
What makes it worth its weight is that it consumes the artefacts that ship — the realm file from
`deploy/keycloak`, the EF migrations, the API's own `appsettings` — rather than a fixture written to match
@@ -198,14 +204,19 @@ off-Windows.
they are unverified.
*Verified end to end:* `tests/DodoSSH.SystemTests` drives the whole slice against a real Keycloak, a
real API, a real PostgreSQL and a real `sshd` — sign-in, the identity-provider key binding, enrollment,
offline unlock, a host through the vault to a second machine, and an interactive shell. See
offline unlock, a host and an SSH key through the vault to a second machine, an interactive shell, and the
host key approved at that shell's prompt reaching the second machine as well. See
[End-to-end verification](#end-to-end-verification).
Known gaps in the client, stated rather than implied by the interface: credentials are not a synced
entity type yet, so password authentication still asks for the password each time — SSH keys *are*
synced, and binding one to a host is the way to connect without typing anything; known host keys live in
memory for one session instead of in the vault; and no device key is registered, so the passphrase is
needed on every launch until the OS keystore is wired.
Known gaps in the client, stated rather than implied by the interface: nothing in the interface can create
a vault credential yet, so password authentication still asks for the password each time — SSH keys *are*
editable, and binding one to a host is the way to connect without typing anything; and no device key is
registered, so the passphrase is needed on every launch until the OS keystore is wired.
Host key trust *is* in the vault, which is what makes trust-on-first-use worth having: a fingerprint
approved on one machine is approved on all of them and survives a restart, and the server cannot drop a
pin to force a fresh first-use decision without the item visibly going missing. A changed host key stays a
hard refusal with no way past it; withdrawing a pin is a separate, deliberate act in the host's editor.
Binding a key introduced the first payload schema version bump, and it is worth knowing how it behaves:
a host is written at the *lowest* schema version that can represent it, so only hosts that actually bind
+105 -5
View File
@@ -68,8 +68,10 @@ internal interface IItemKind
internal static class ItemKinds
{
private static readonly Dictionary<SyncEntityType, IItemKind> Supported =
new[] { (IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind() }
.ToDictionary(kind => kind.WireType);
new[]
{
(IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(),
}.ToDictionary(kind => kind.WireType);
/// <summary>The kind for a wire type, or null when this server does not synchronise it yet.</summary>
/// <remarks>
@@ -295,9 +297,9 @@ internal sealed class SshKeyKind : IItemKind
/// <summary>Credentials: an envelope and nothing else.</summary>
/// <remarks>
/// The strictest of the three kinds about plaintext, and the reason is not symmetry. A key at least has a
/// fingerprint that is public by nature; a password has no part that is safe to expose, so this kind accepts
/// no plaintext fields at all and hydrates none.
/// As strict as a kind gets about plaintext — <see cref="KnownHostKeyKind"/> is the other one — and the
/// reason is not symmetry. A key at least has a fingerprint that is public by nature; a password has no part
/// that is safe to expose, so this kind accepts no plaintext fields at all and hydrates none.
/// </remarks>
internal sealed class CredentialKind : IItemKind
{
@@ -387,3 +389,101 @@ internal sealed class CredentialKind : IItemKind
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// <summary>Known host keys: an envelope and nothing else.</summary>
/// <remarks>
/// As strict as <see cref="CredentialKind"/> about plaintext, for a reason that is about aggregation rather
/// than secrecy. A fingerprint is published so it can be compared and an address may already sit in a
/// relay-enabled host's columns — but the set of endpoints one user has approved is a map of their estate,
/// and this server has nothing to do with it.
/// </remarks>
internal sealed class KnownHostKeyKind : IItemKind
{
/// <inheritdoc />
public SyncEntityType WireType => SyncEntityType.KnownHostKey;
/// <inheritdoc />
public ChangeEntityType ChangeType => ChangeEntityType.KnownHostKey;
/// <inheritdoc />
public async Task<IVaultItem?> FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.KnownHostKeys.SingleOrDefaultAsync(k => k.Id == id, cancellationToken)
.ConfigureAwait(false);
/// <inheritdoc />
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.KnownHostKeys
.Where(k => k.VaultId == vaultId && ids.Contains(k.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
/// <inheritdoc />
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var knownHost = new VaultKnownHostKey { Id = id, VaultId = vaultId };
database.KnownHostKeys.Add(knownHost);
return knownHost;
}
/// <summary>
/// Refuses every plaintext field there is.
/// </summary>
/// <remarks>
/// The relay fields are refused although this type is the one that <em>does</em> hold an address, and
/// that is the point: the address belongs in the ciphertext. A client sending it here is either confused
/// or trying to get the server to keep a list it has no business keeping, and either way it should be
/// told rather than have the value quietly dropped.
/// </remarks>
/// <inheritdoc />
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A known host key is not something the server dials; its address stays encrypted.";
return false;
}
if (fields.PublicKeyFingerprint is not null)
{
error = "A known host key's fingerprint stays inside its payload.";
return false;
}
return true;
}
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
/// <inheritdoc />
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
/// <inheritdoc />
public void ClearFieldsOnDelete(IVaultItem item)
{
}
/// <remarks>
/// Always null, as for a credential: this type has no plaintext columns, so there is nothing a pull could
/// hydrate even in principle.
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
+5 -4
View File
@@ -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;
}
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));
+18 -5
View File
@@ -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 &quot;Forget host key&quot; 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, )"
}
@@ -0,0 +1,106 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
namespace DodoSSH.Client.Domain;
/// <summary>
/// One host key the user has decided to trust, decrypted.
/// </summary>
/// <remarks>
/// <para>
/// Nothing in here is a secret — a public key fingerprint is published by operators on purpose — and it is
/// still a vault item rather than a local file, for two reasons that have nothing to do with confidentiality.
/// Trust follows the user to every device, so approving a host once means approving it once. And the server
/// cannot tamper with it: a server able to drop a pin could silently downgrade every connection to
/// first-use, which is the only interesting attack on a trust-on-first-use scheme. The <em>set</em> of hosts
/// a person reaches is worth keeping to themselves as well, and encrypting the item does that for free.
/// </para>
/// <para>
/// <b>One item per host, port and algorithm.</b> A server legitimately offers several host keys and which one
/// gets negotiated can change between connections, so a pin has to name the algorithm it is about — pinning
/// one and refusing the others would make an ordinary server look hostile. Three scalars and a fingerprint
/// also means the merge is four independent fields with no collection in sight, which is why adding this type
/// needed no new reconciliation logic.
/// </para>
/// <para>
/// <b>Keyed on the address as dialled, not on the host item.</b> The SSH layer knows only what it connected
/// to, and <c>IKnownHostStore</c> is asked in those terms. It also means changing a host's address makes its
/// old pin stop applying and the new address a first contact, which is correct rather than unfortunate: as
/// far as trust goes that is a different machine until somebody says otherwise.
/// </para>
/// </remarks>
public sealed record KnownHostSecret : IVaultSecret
{
/// <summary>The host as it was dialled — a name or an address.</summary>
/// <remarks>
/// Stored verbatim, and compared case-insensitively by the store that reads it rather than normalised
/// here. DNS is case-insensitive so the comparison has to be, but lower-casing what gets stored would
/// mean rewriting a value this type does not fully understand — an internationalised name, an IPv6
/// literal with a zone id — which is the same argument <see cref="SshKeySecret.PrivateKeyPem"/> makes for
/// keeping key armour untouched.
/// </remarks>
public required string Host { get; init; }
/// <summary>The port as it was dialled.</summary>
/// <remarks>
/// Required, with no default, unlike <see cref="HostSecret.Port"/>. A pin is a statement about one
/// endpoint, and a defaulted 22 would quietly pin the wrong one for a host reached anywhere else.
/// </remarks>
public required int Port { get; init; }
/// <summary>The host key algorithm, e.g. <c>ssh-ed25519</c>.</summary>
public required string Algorithm { get; init; }
/// <summary>The fingerprint that was approved, as <c>SHA256:base64</c>.</summary>
public required string Fingerprint { get; init; }
/// <summary>
/// What this pin is called, derived from what it pins.
/// </summary>
/// <remarks>
/// Computed rather than stored, which is the one place this type departs from the other three. A user
/// never names a pin — they approve a fingerprint for a host — so a stored label would be a field that
/// can disagree with the three fields it describes, and a merge taking the label from one side and the
/// address from the other would produce exactly that. Being get-only also keeps it out of the record's
/// equality, so two pins are equal when what they pin is equal.
/// </remarks>
public string Label =>
string.Create(CultureInfo.InvariantCulture, $"{Host}:{Port} ({Algorithm})");
/// <summary>Whether this is storable, and why not if it is not.</summary>
/// <remarks>
/// The whitespace checks are not pedantry. Neither of these values is typed by a person — the store
/// builds them from what the handshake presented — but a payload written by another client is untrusted
/// input, and a fingerprint carrying a stray space or a trailing comment would compare unequal to the
/// same key for ever, which reads to the user as a permanently changed host key.
/// </remarks>
public bool TryValidate([NotNullWhen(false)] out string? reason)
{
if (string.IsNullOrWhiteSpace(Host))
{
reason = "A known host key needs the host it belongs to.";
return false;
}
if (Port is < 1 or > 65535)
{
reason = $"Port must be between 1 and 65535, not {Port}.";
return false;
}
if (string.IsNullOrWhiteSpace(Algorithm) || Algorithm.Any(char.IsWhiteSpace))
{
reason = "A known host key needs the key algorithm it was negotiated with.";
return false;
}
if (string.IsNullOrWhiteSpace(Fingerprint) || Fingerprint.Any(char.IsWhiteSpace))
{
reason = "A known host key needs a fingerprint, with no spaces in it.";
return false;
}
reason = null;
return true;
}
}
@@ -0,0 +1,121 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Domain;
/// <summary>A decoded known-host payload, together with the schema version it was written at.</summary>
/// <param name="KnownHost">The pin.</param>
/// <param name="SchemaVersion">The version the writing client used.</param>
public sealed record KnownHostSecretDocument(KnownHostSecret KnownHost, int SchemaVersion)
{
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
public bool IsReadOnly => SchemaVersion > KnownHostSecretCodec.CurrentSchemaVersion;
}
/// <summary>
/// Encodes and decodes the plaintext inside a known-host item's encrypted payload.
/// </summary>
/// <remarks>
/// Mirrors <see cref="CredentialSecretCodec"/>, for the same reasons and with the same guarantees: JSON so a
/// field can be added without a migration, deterministic property order so an unchanged pin does not look
/// like a change to the sync engine, and a separate mutable document type so a decode failure cannot produce
/// a half-built pin that looks valid downstream.
/// <para>
/// The label is not in here, and that is not an omission — <see cref="KnownHostSecret.Label"/> is derived
/// from the three fields that are. Writing it would put a value on the wire that a reader could disagree
/// with.
/// </para>
/// </remarks>
public static class KnownHostSecretCodec
{
/// <summary>The schema version this build writes.</summary>
public const int CurrentSchemaVersion = 1;
/// <summary>Serialises a pin to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The pin is not valid for storage.</exception>
public static byte[] Encode(KnownHostSecret knownHost)
{
ArgumentNullException.ThrowIfNull(knownHost);
if (!knownHost.TryValidate(out var reason))
{
throw new ArgumentException(reason, nameof(knownHost));
}
var document = new KnownHostPayloadDocument
{
SchemaVersion = CurrentSchemaVersion,
Host = knownHost.Host,
Port = knownHost.Port,
Algorithm = knownHost.Algorithm,
Fingerprint = knownHost.Fingerprint,
};
return JsonSerializer.SerializeToUtf8Bytes(
document, KnownHostPayloadJsonContext.Default.KnownHostPayloadDocument);
}
/// <summary>Parses a decrypted payload.</summary>
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
public static bool TryDecode(
ReadOnlySpan<byte> payload,
[NotNullWhen(true)] out KnownHostSecretDocument? document)
{
document = null;
KnownHostPayloadDocument? parsed;
try
{
parsed = JsonSerializer.Deserialize(
payload, KnownHostPayloadJsonContext.Default.KnownHostPayloadDocument);
}
catch (JsonException)
{
return false;
}
if (parsed is null || parsed.SchemaVersion < 1)
{
return false;
}
var candidate = new KnownHostSecret
{
Host = parsed.Host ?? string.Empty,
Port = parsed.Port,
Algorithm = parsed.Algorithm ?? string.Empty,
Fingerprint = parsed.Fingerprint ?? string.Empty,
};
if (!candidate.TryValidate(out _))
{
return false;
}
document = new KnownHostSecretDocument(candidate, parsed.SchemaVersion);
return true;
}
}
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
internal sealed class KnownHostPayloadDocument
{
public int SchemaVersion { get; set; }
public string? Host { get; set; }
public int Port { get; set; }
public string? Algorithm { get; set; }
public string? Fingerprint { get; set; }
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(KnownHostPayloadDocument))]
internal sealed partial class KnownHostPayloadJsonContext : JsonSerializerContext;
@@ -0,0 +1,121 @@
using System.Globalization;
namespace DodoSSH.Client.Domain;
/// <summary>The merged pin, and everything that had to be overridden to produce it.</summary>
/// <param name="Merged">The pin to store and push.</param>
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
public sealed record KnownHostMergeResult(
KnownHostSecret Merged,
IReadOnlyList<HostFieldConflict> Conflicts)
{
/// <summary>Whether anything had to be overridden.</summary>
public bool HasConflicts => Conflicts.Count > 0;
}
/// <summary>
/// Merges two divergent versions of a known host key against the version they both started from.
/// </summary>
/// <remarks>
/// <para>
/// Four scalars, so this is the same shape as <see cref="CredentialSecretMerge"/> and reuses
/// <see cref="HostFieldConflict"/> for the same reason: the conflict log, the storage behind it and the
/// interface that shows it are shared.
/// </para>
/// <para>
/// <b>Nothing here is redacted, and the fingerprint least of all.</b> The other two item types hide the
/// value that lost, because a discarded password or a discarded private key is still live somewhere. A
/// fingerprint is published by operators precisely so that it can be compared, and which of two fingerprints
/// the merge dropped is the entire content of the notice — a user told only that "the fingerprint differed"
/// has been told nothing they can check.
/// </para>
/// <para>
/// In practice only <see cref="KnownHostSecret.Fingerprint"/> can genuinely clash, and it is worth saying why
/// the other three are merged rather than asserted: they are what the pin is <em>about</em>, so two sides
/// holding different values for them are not two versions of one pin at all. Nothing in the client can
/// produce that — the store rewrites a fingerprint or creates a new item, never re-addresses an existing one
/// — but a payload arriving from elsewhere is untrusted input, and merging it field by field keeps the
/// outcome a recorded conflict rather than a silent adoption.
/// </para>
/// </remarks>
public static class KnownHostSecretMerge
{
/// <summary>Produces the merged pin.</summary>
/// <param name="ancestor">The version both sides branched from.</param>
/// <param name="local">The pending local version.</param>
/// <param name="remote">The server's current version.</param>
public static KnownHostMergeResult Merge(
KnownHostSecret ancestor,
KnownHostSecret local,
KnownHostSecret remote)
{
ArgumentNullException.ThrowIfNull(ancestor);
ArgumentNullException.ThrowIfNull(local);
ArgumentNullException.ThrowIfNull(remote);
var conflicts = new List<HostFieldConflict>();
var merged = new KnownHostSecret
{
Host = Field(
nameof(KnownHostSecret.Host),
ancestor.Host,
local.Host,
remote.Host,
conflicts,
static host => host,
StringComparer.Ordinal),
Port = Field(
nameof(KnownHostSecret.Port),
ancestor.Port,
local.Port,
remote.Port,
conflicts,
static port => port.ToString(CultureInfo.InvariantCulture)),
Algorithm = Field(
nameof(KnownHostSecret.Algorithm),
ancestor.Algorithm,
local.Algorithm,
remote.Algorithm,
conflicts,
static algorithm => algorithm,
StringComparer.Ordinal),
Fingerprint = Field(
nameof(KnownHostSecret.Fingerprint),
ancestor.Fingerprint,
local.Fingerprint,
remote.Fingerprint,
conflicts,
static fingerprint => fingerprint,
StringComparer.Ordinal),
};
return new KnownHostMergeResult(merged, conflicts);
}
private static T Field<T>(
string name,
T ancestor,
T local,
T remote,
List<HostFieldConflict> conflicts,
Func<T, string?> format,
IEqualityComparer<T>? comparer = null)
{
var merge = ThreeWayMerge.Scalar(ancestor, local, remote, comparer);
if (merge.IsConflicted)
{
// The local side always loses a scalar clash — see ThreeWayMerge — so the discarded side is
// fixed here rather than derived from the outcome.
conflicts.Add(new HostFieldConflict(
name,
MergeSide.Local,
format(merge.Value),
format(merge.Discarded!),
DiscardedWasRemoval: false));
}
return merge.Value;
}
}
@@ -14,6 +14,14 @@
<ProjectReference Include="../DodoSSH.Client.Api/DodoSSH.Client.Api.csproj" />
<ProjectReference Include="../DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" />
<ProjectReference Include="../DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj" />
<!--
The SSH layer, for one type: VaultKnownHostStore, which is the IKnownHostStore the application
actually composes. It has to live on this side of the seam, because it is the only thing that needs
both the interface the handshake asks and the vault the answer comes from — and it must not live in
Client.Ssh, which has no project references at all so that connections, authentication and PTY
handling stay testable with no cache, no keyring and no server.
-->
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
<ProjectReference Include="../DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" />
<ProjectReference Include="../DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" />
</ItemGroup>
@@ -0,0 +1,364 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Sync;
namespace DodoSSH.Client.Session;
/// <summary>
/// Host key trust kept in the vault, answered from memory.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why a snapshot and not a lookup.</b> <see cref="FindAsync"/> is called from inside SSH.NET's
/// synchronous host key event, which the connection factory has no choice but to block on — see
/// <c>SshNetConnectionFactory</c>, 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.
/// </para>
/// <para>
/// <b>What that costs, stated rather than hidden.</b> 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.
/// </para>
/// <para>
/// <b>Process-lifetime object, session-scoped contents.</b> The connection factory is composed once, at
/// startup, and outlives every unlock; the vault behind this store does not. So the lifecycle is explicit:
/// <see cref="OpenAsync"/> when a vault unlocks, <see cref="RefreshAsync"/> after a pass,
/// <see cref="Close"/> 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.
/// </para>
/// </remarks>
public sealed class VaultKnownHostStore : IKnownHostStore
{
private readonly Lock gate = new();
private Dictionary<string, PinnedHostKey> pins = new(KnownHostIdentity.Comparer);
private Binding? binding;
/// <summary>
/// Bumped whenever the snapshot changes underneath a read, so a read in flight cannot install a stale
/// answer over a newer one.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private int generation;
/// <summary>Whether a vault is open behind this store.</summary>
public bool IsOpen
{
get
{
lock (gate)
{
return binding is not null;
}
}
}
/// <summary>Reads an unlocked vault's pins, and starts writing new ones to it.</summary>
/// <param name="session">The unlocked session. Its active vault is the one used.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <remarks>
/// 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.
/// </remarks>
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<string, PinnedHostKey>(KnownHostIdentity.Comparer);
stamp = ++generation;
}
var snapshot = await ReadAsync(opened, cancellationToken).ConfigureAwait(false);
Install(stamp, snapshot);
}
/// <summary>
/// Re-reads the vault, picking up trust recorded on another machine.
/// </summary>
/// <remarks>
/// Does nothing while closed, so a synchronisation pass that finishes after the vault was locked cannot
/// bring its contents back.
/// </remarks>
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);
}
/// <summary>Forgets the vault and everything read from it. What locking means here.</summary>
public void Close()
{
lock (gate)
{
binding = null;
pins = new Dictionary<string, PinnedHostKey>(KnownHostIdentity.Comparer);
generation++;
}
}
/// <inheritdoc />
public ValueTask<string?> 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);
}
}
/// <inheritdoc />
/// <exception cref="InvalidOperationException">
/// The vault is not open, so there is nowhere to record trust.
/// </exception>
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++;
}
}
/// <inheritdoc />
/// <exception cref="InvalidOperationException">
/// The vault is not open, so there is nothing to forget.
/// </exception>
public async ValueTask<int> 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;
}
/// <summary>The vault this store writes to, and which vault inside it.</summary>
private sealed record Binding(KnownHostRepository KnownHosts, Guid VaultId);
/// <summary>A pin, and the vault item it came from, so re-trusting updates rather than duplicates.</summary>
/// <param name="EntityId">The item holding this pin.</param>
/// <param name="Secret">The pin.</param>
private sealed record PinnedHostKey(Guid EntityId, KnownHostSecret Secret)
{
/// <summary>Whether this build may re-encode the item, or a newer one wrote it.</summary>
internal bool IsWritable { get; init; } = true;
}
/// <summary>
/// Records a pin, updating the item that already held one for this endpoint where there is one.
/// </summary>
/// <remarks>
/// 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 <see cref="ReadAsync"/>, and a client
/// that understands both can reconcile them.
/// </remarks>
private static async Task<Guid> 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);
}
/// <summary>
/// Reads every readable pin in the vault into a lookup.
/// </summary>
/// <remarks>
/// <para>
/// 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 <see cref="ForgetAsync"/> makes it.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
private static async Task<Dictionary<string, PinnedHostKey>> ReadAsync(
Binding bound,
CancellationToken cancellationToken)
{
var listing = await bound.KnownHosts
.ListAsync(bound.VaultId, cancellationToken)
.ConfigureAwait(false);
var snapshot = new Dictionary<string, PinnedHostKey>(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;
}
/// <remarks>
/// 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.
/// </remarks>
private void Install(int stamp, Dictionary<string, PinnedHostKey> 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.");
}
}
}
@@ -77,6 +77,7 @@ public sealed class VaultSession : IAsyncDisposable
Hosts = new HostRepository(Items, Outbox, keyring);
SshKeys = new SshKeyRepository(Items, Outbox, keyring);
Credentials = new CredentialRepository(Items, Outbox, keyring);
KnownHosts = new KnownHostRepository(Items, Outbox, keyring);
}
/// <summary>Who this session belongs to, and the material that unlocked it.</summary>
@@ -101,6 +102,14 @@ public sealed class VaultSession : IAsyncDisposable
/// <summary>Usernames and passwords, decrypted, with unpushed local changes laid over them.</summary>
public CredentialRepository Credentials { get; }
/// <summary>The host keys this vault trusts, decrypted, with unpushed local changes laid over them.</summary>
/// <remarks>
/// Read through <see cref="VaultKnownHostStore"/> rather than directly by anything that connects. The
/// handshake asks about host key trust from inside a synchronous SSH.NET event, and listing decrypts every
/// pin in the vault — see that type for why the two must not meet.
/// </remarks>
public KnownHostRepository KnownHosts { get; }
/// <summary>Vaults whose grant could not be opened, so their items cannot be read.</summary>
public IReadOnlyList<Guid> UnreadableVaults => keyring.Unopened;
@@ -138,6 +138,12 @@
"dodossh.client.domain": {
"type": "Project"
},
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"SSH.NET": "[2025.1.0, )"
}
},
"dodossh.client.storage": {
"type": "Project",
"dependencies": {
@@ -166,6 +172,12 @@
"NSec.Cryptography": "[26.4.0, )"
}
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"EFCore.NamingConventions": {
"type": "CentralTransitive",
"requested": "[10.0.1, )",
@@ -261,6 +273,16 @@
"dependencies": {
"SQLitePCLRaw.core": "2.1.12"
}
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
}
}
+94 -11
View File
@@ -1,3 +1,5 @@
using System.Globalization;
namespace DodoSSH.Client.Ssh;
/// <summary>A host key as the server presented it during the handshake.</summary>
@@ -7,6 +9,35 @@ namespace DodoSSH.Client.Ssh;
/// <param name="Fingerprint">OpenSSH-style fingerprint, from <see cref="SshHostKeyFingerprint"/>.</param>
public sealed record HostKeyPresentation(string Host, int Port, string Algorithm, string Fingerprint);
/// <summary>
/// What makes two pins the same pin.
/// </summary>
/// <remarks>
/// <para>
/// Shared by every <see cref="IKnownHostStore"/> rather than written per implementation, because the two that
/// exist have to agree: one is what ships and the other is what this project's own SSH tests run against, and
/// an identity rule that differed between them would mean the tested behaviour was not the shipped behaviour.
/// </para>
/// <para>
/// <b>Case-insensitive, and the host is stored verbatim anyway.</b> DNS names are case-insensitive, so
/// <c>DB.internal</c> and <c>db.internal</c> are one machine and must be one pin — a store that treated them
/// as two would ask the user to approve the same server twice. Comparing case-insensitively rather than
/// lower-casing what gets stored keeps the stored value the one that was actually dialled, which matters for
/// an internationalised name or an IPv6 literal with a zone id: rewriting those means rewriting a value this
/// layer does not fully understand. Algorithm names are compared the same way for the same reason, one step
/// weaker — they are ASCII tokens, and no server has been seen to vary their case.
/// </para>
/// </remarks>
public static class KnownHostIdentity
{
/// <summary>The comparer an identity must be compared with.</summary>
public static StringComparer Comparer => StringComparer.OrdinalIgnoreCase;
/// <summary>The identity of one pin, as a single comparable value.</summary>
public static string For(string host, int port, string algorithm) =>
string.Create(CultureInfo.InvariantCulture, $"{host}:{port}/{algorithm}");
}
/// <summary>
/// The trusted host keys a user has accumulated.
/// </summary>
@@ -19,14 +50,34 @@ public interface IKnownHostStore
{
/// <summary>Returns the pinned fingerprint for a host and key algorithm, if there is one.</summary>
/// <remarks>
/// <para>
/// Keyed on algorithm as well as host, because a server legitimately offers several host keys and
/// which one is negotiated can change between connections. Pinning only one and rejecting the
/// others would make a normal server look hostile.
/// </para>
/// <para>
/// <b>Implementations must answer this without I/O.</b> It is called from inside SSH.NET's synchronous
/// host key event — see <c>SshNetConnectionFactory</c> — so the caller has no choice but to block the
/// thread completing the key exchange on it. Asynchronous in signature because a store may need to be
/// asynchronous to <em>fill</em> itself; not because this call may go and look something up.
/// </para>
/// </remarks>
ValueTask<string?> FindAsync(string host, int port, string algorithm, CancellationToken cancellationToken);
/// <summary>Records a host key as trusted.</summary>
ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken);
/// <summary>
/// Withdraws trust from every key pinned for one endpoint, whatever the algorithm.
/// </summary>
/// <remarks>
/// The counterpart to a pin that now outlives the process, and not an optional extra: a server that is
/// legitimately rebuilt gets a new host key, <see cref="SshHostKeyMismatchException"/> is a hard refusal
/// with no way past it, and without this the host would be unreachable for ever. Every algorithm goes at
/// once because the user's decision is about the machine, not about one of the keys it happens to offer.
/// </remarks>
/// <returns>How many pins were removed, so a caller can say whether there was anything to forget.</returns>
ValueTask<int> ForgetAsync(string host, int port, CancellationToken cancellationToken);
}
/// <summary>
@@ -53,9 +104,9 @@ public sealed class SshHostKeyUnknownException(HostKeyPresentation presentation)
/// <remarks>
/// This must stay a hard block with no "continue anyway" in the connect path. A dialog offering to
/// proceed is how users are trained to click through the one warning that actually indicates an
/// interception. A legitimate key change — a rebuilt server — is handled by explicitly removing the
/// interception. A legitimate key change — a rebuilt server — is handled by explicitly forgetting the
/// pin in the host's settings, which is a deliberate act performed away from the moment of
/// connecting.
/// connecting. See <see cref="IKnownHostStore.ForgetAsync"/>.
/// </remarks>
public sealed class SshHostKeyMismatchException(HostKeyPresentation presentation, string pinnedFingerprint)
: Exception(
@@ -73,13 +124,21 @@ public sealed class SshHostKeyMismatchException(HostKeyPresentation presentation
/// A known-host store held in memory.
/// </summary>
/// <remarks>
/// Stands in until the encrypted local cache lands. Trust is lost when the process exits, so a user
/// is asked about every host on every launch — noisy, but the noise is the correct failure mode for a
/// placeholder: it cannot be mistaken for working persistence.
/// <para>
/// <b>Not the one that ships.</b> <c>VaultKnownHostStore</c> is: it keeps trust in the vault, so a
/// fingerprint approved once is approved on every device and survives a restart. This is what a store looks
/// like with nothing behind it, and it exists because this project deliberately has no project references at
/// all — that is the seam which keeps connections, authentication and PTY handling testable without a cache,
/// a keyring or a server, and those tests still need somewhere to put a pin.
/// </para>
/// <para>
/// Trust is lost when the process exits, so a user of this store would be asked about every host on every
/// launch. Nothing in the application composes it.
/// </para>
/// </remarks>
public sealed class InMemoryKnownHostStore : IKnownHostStore
{
private readonly Dictionary<string, string> pins = new(StringComparer.Ordinal);
private readonly Dictionary<string, HostKeyPresentation> pins = new(KnownHostIdentity.Comparer);
private readonly Lock gate = new();
/// <inheritdoc />
@@ -91,7 +150,9 @@ public sealed class InMemoryKnownHostStore : IKnownHostStore
{
lock (gate)
{
return ValueTask.FromResult(pins.GetValueOrDefault(Key(host, port, algorithm)));
var key = KnownHostIdentity.For(host, port, algorithm);
return ValueTask.FromResult(pins.GetValueOrDefault(key)?.Fingerprint);
}
}
@@ -102,13 +163,35 @@ public sealed class InMemoryKnownHostStore : IKnownHostStore
lock (gate)
{
pins[Key(presentation.Host, presentation.Port, presentation.Algorithm)] =
presentation.Fingerprint;
var key = KnownHostIdentity.For(
presentation.Host, presentation.Port, presentation.Algorithm);
pins[key] = presentation;
}
return ValueTask.CompletedTask;
}
private static string Key(string host, int port, string algorithm) =>
$"{host}:{port}/{algorithm}";
/// <inheritdoc />
public ValueTask<int> ForgetAsync(string host, int port, CancellationToken cancellationToken)
{
lock (gate)
{
// Matched on the stored values rather than by picking apart the composite key. The key exists to
// be looked up whole; taking it back apart to find an endpoint would be one parser too many, and
// a host that is an IPv6 literal is full of colons.
var doomed = pins
.Where(pin =>
KnownHostIdentity.Comparer.Equals(pin.Value.Host, host) && pin.Value.Port == port)
.Select(pin => pin.Key)
.ToArray();
foreach (var key in doomed)
{
pins.Remove(key);
}
return ValueTask.FromResult(doomed.Length);
}
}
}
+90 -1
View File
@@ -27,7 +27,8 @@ internal sealed record MergedItem<TSecret>(
/// <remarks>
/// <para>
/// The reconciler holds the six answers a collision can have — merge, adopt, resurrect, abandon, park,
/// refuse — and every one of them is identical for a host, an SSH key and a credential. Only the encoding,
/// refuse — and every one of them is identical for a host, an SSH key, a credential and a pinned host key.
/// Only the encoding,
/// the merge and the plaintext columns differ, and those arrive through here. A second copy of the
/// reconciler per item type is the alternative, and it is not a real one: the file's whole premise is
/// that the pull and push paths must answer the same situation the same way, and two copies would drift
@@ -116,6 +117,9 @@ internal static class ItemKinds
(SyncEntityType.Credential, static (outbox, conflicts, keyring) =>
new ItemReconciler<CredentialSecret>(CredentialKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.KnownHostKey, static (outbox, conflicts, keyring) =>
new ItemReconciler<KnownHostSecret>(KnownHostKeyKind.Instance, outbox, conflicts, keyring)),
];
/// <summary>The types to ask the server for, in a fixed order.</summary>
@@ -316,3 +320,88 @@ internal sealed class CredentialKind : IItemKind<CredentialSecret>
return secret with { Label = label };
}
}
/// <summary>Known host keys.</summary>
internal sealed class KnownHostKeyKind : IItemKind<KnownHostSecret>
{
internal static KnownHostKeyKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.KnownHostKey;
/// <summary>
/// What to call one of these to a person.
/// </summary>
/// <remarks>
/// "Known host key" rather than "host key", because a user who reads "this host key could not be
/// decrypted" would go looking at the host they are connecting to. The item is the record of a decision
/// they made about that host, and the noun has to say so.
/// </remarks>
/// <inheritdoc />
public string Noun => "known host key";
/// <inheritdoc />
public OpenedItem<KnownHostSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = KnownHostKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<KnownHostSecret>(document.KnownHost, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
KnownHostSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
KnownHostKeyCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing, and here the temptation is the strongest of the four.
/// </summary>
/// <remarks>
/// This item holds an address and a fingerprint, and neither is confidential in itself: the operator
/// published the fingerprint and the address is one the server may already hold for a relay-enabled host.
/// Handing them over anyway would tell the operator which machines each user actually connects to and
/// when they first did — a map of the estate assembled out of individually harmless facts, for columns
/// that do not exist and nothing that would read them. See ADR 0004.
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Fields(KnownHostSecret secret) => null;
/// <inheritdoc />
public MergedItem<KnownHostSecret> Merge(
KnownHostSecret ancestor,
KnownHostSecret local,
KnownHostSecret remote)
{
var merged = KnownHostSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<KnownHostSecret>(merged.Merged, merged.Conflicts);
}
/// <summary>
/// The pin unchanged, because a pin has no name of its own to change.
/// </summary>
/// <remarks>
/// The only caller is the reconciler's resurrection path, which renames rescued content so a user can see
/// what happened to it. <see cref="KnownHostSecret.Label"/> is derived from the host, port and algorithm
/// the pin is about, so there is nothing here that a rename could move: renaming it would mean claiming
/// the pin is about a different host. The resurrected item still gets its own id and still produces a
/// conflict notice, so the event is visible — the notice simply names the pin the same way twice.
/// </remarks>
/// <inheritdoc />
public KnownHostSecret Relabel(KnownHostSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret;
}
}
@@ -0,0 +1,131 @@
using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync;
/// <summary>
/// Turns a known host key into an item payload and back.
/// </summary>
/// <remarks>
/// <para>
/// Mirrors <see cref="HostCipher"/> exactly, including the rule that a payload is sealed at the version the
/// server <em>will</em> assign rather than the one it replaces — see <see cref="SyncVersions"/>.
/// </para>
/// <para>
/// <b>The resource type is the one thing not to copy.</b> As with <see cref="CredentialCipher"/>, the AAD
/// binds it and the two enums that name item types do not agree: <c>SyncEntityType.KnownHostKey</c> is 10
/// while <c>CryptoSpec.AadResourceType.KnownHostKey</c> is 11, because the crypto enum carries None, User,
/// Device and Vault ahead of the item types. Casting one to the other would seal a pin under the resource
/// type for a <em>port forward</em> — which encrypts perfectly, decrypts perfectly on the machine that wrote
/// it, and is a specification violation nothing would notice until an interoperating client refused the item.
/// </para>
/// </remarks>
public static class KnownHostKeyCipher
{
private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.KnownHostKey;
/// <summary>Encrypts a pin.</summary>
/// <param name="knownHost">The pin. Must be valid for storage.</param>
/// <param name="vaultKey">The vault key, which the data key is wrapped under.</param>
/// <param name="entityId">The item id, which the AAD binds.</param>
/// <param name="keyGeneration">The vault's current key generation.</param>
/// <param name="itemVersion">The version this payload will hold once the server accepts it.</param>
public static EncryptedPayload Seal(
KnownHostSecret knownHost,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(knownHost);
ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
var plaintext = KnownHostSecretCodec.Encode(knownHost);
var dataKey = ItemKeys.CreateDataKey();
try
{
var dataKeyId = Guid.CreateVersion7();
var wrappedDataKey = ItemKeys.WrapDataKey(
dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);
var envelope = ItemKeys.SealPayload(
dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);
return new EncryptedPayload(
envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
// Wiped like every other payload in this folder, and for a smaller reason than the others: a
// fingerprint is published on purpose, so nothing in here is a secret in the sense a password is.
// What the buffer does hold is the address of a machine this user reaches, and leaving that in a
// pooled buffer for nothing would be a gratuitous difference from the neighbouring ciphers.
CryptographicOperations.ZeroMemory(plaintext);
}
}
/// <summary>Decrypts a pin.</summary>
/// <inheritdoc cref="HostCipher.TryOpen" path="/returns" />
public static KnownHostSecretDocument? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(payload);
if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
{
return null;
}
var dataKey = ItemKeys.TryUnwrapDataKey(
vaultKey,
payload.WrappedDataKey,
Resource,
entityId,
payload.KeyGeneration,
(uint)itemVersion);
if (dataKey is null)
{
return null;
}
try
{
var plaintext = ItemKeys.TryOpenPayload(
dataKey,
payload.Envelope,
Resource,
entityId,
payload.DataKeyId,
payload.KeyGeneration,
(uint)itemVersion);
if (plaintext is null)
{
return null;
}
try
{
return KnownHostSecretCodec.TryDecode(plaintext, out var document) ? document : null;
}
finally
{
CryptographicOperations.ZeroMemory(plaintext);
}
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
}
}
}
@@ -0,0 +1,51 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
namespace DodoSSH.Client.Sync;
/// <summary>
/// The host keys this vault trusts, decrypted, with unpushed local changes laid over them.
/// </summary>
/// <remarks>
/// <para>
/// The fourth facade over the same generic repository, and it needed no new sync logic at all — which was the
/// point of the item-kind seam.
/// </para>
/// <para>
/// <b>Nothing here is on the SSH handshake path.</b> Listing decrypts every pin in the vault, and the
/// handshake asks about host key trust from inside a synchronous SSH.NET event where a decryption per lookup
/// would be I/O and AEAD work on the thread completing the key exchange. <c>VaultKnownHostStore</c> exists to
/// keep those apart: it reads through here when a vault opens and after each synchronisation pass, and answers
/// the handshake from an in-memory snapshot.
/// </para>
/// </remarks>
public sealed class KnownHostRepository(ItemStore items, OutboxStore outbox, VaultKeyring keyring)
{
private readonly VaultItemRepository<KnownHostSecret> knownHosts =
new(KnownHostKeyKind.Instance, items, outbox, keyring);
/// <inheritdoc cref="VaultItemRepository{TSecret}.ListAsync" />
public Task<ItemListing<KnownHostSecret>> ListAsync(
Guid vaultId,
CancellationToken cancellationToken) =>
knownHosts.ListAsync(vaultId, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.CreateAsync" />
public Task<Guid> CreateAsync(
Guid vaultId,
KnownHostSecret knownHost,
CancellationToken cancellationToken) =>
knownHosts.CreateAsync(vaultId, knownHost, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.UpdateAsync" />
public Task UpdateAsync(
Guid vaultId,
Guid entityId,
KnownHostSecret knownHost,
CancellationToken cancellationToken) =>
knownHosts.UpdateAsync(vaultId, entityId, knownHost, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
knownHosts.DeleteAsync(vaultId, entityId, cancellationToken);
}
+82
View File
@@ -233,3 +233,85 @@ public sealed class VaultCredential : IVaultItem
/// <summary>Who last modified it.</summary>
public Guid UpdatedByUserId { get; set; }
}
/// <summary>
/// One SSH host key a user has decided to trust, as ciphertext.
/// </summary>
/// <remarks>
/// <para>
/// The host, the port, the key algorithm and the fingerprint are all inside <see cref="Payload"/>, and this
/// is the item type where that is least obvious and most deliberate. None of those four values is
/// confidential on its own — an operator publishes the fingerprint so it can be checked, and a
/// relay-enabled host's address is already in a plaintext column next door. Together and across a user's
/// whole vault they are something else: which machines that person reaches, and when they first reached
/// them. The server has no use for any of it, so it gets none of it, and there is no column here to put it
/// in even by mistake.
/// </para>
/// <para>
/// <b>Why the server holds these at all, given it cannot read them.</b> Trust-on-first-use is only as good
/// as its memory. Kept locally, a pin dies with the machine and the user is asked to approve the same host
/// on every device — training them to approve without looking, which is the one habit that makes the
/// warning worthless. Kept here, trust follows the user, and because the payload is sealed the server
/// cannot drop a pin to force a fresh first-use decision without the client noticing the item is gone.
/// </para>
/// <para>
/// Otherwise this is <see cref="VaultCredential"/>'s shape exactly: an opaque envelope and the bookkeeping
/// the write path needs to order changes. Its own table for the same reason — the columns a host needs are
/// columns this must never have.
/// </para>
/// </remarks>
public sealed class VaultKnownHostKey : IVaultItem
{
/// <summary>Primary key. UUIDv7, generated by the client so a pin can be recorded offline.</summary>
public Guid Id { get; set; }
/// <summary>Owning vault.</summary>
public Guid VaultId { get; set; }
/// <summary>Owning vault.</summary>
public Vault? Vault { get; set; }
/// <summary>The encrypted pin: a DSH1 envelope. Opaque to the server.</summary>
public byte[] Payload { get; set; } = [];
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
public byte[]? DataKeyWrap { get; set; }
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
public Guid? ContentKeyId { get; set; }
/// <summary>Vault key generation this payload was encrypted under.</summary>
public int KeyGeneration { get; set; }
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
public short PayloadAadVersion { get; set; }
/// <summary>Client-visible, monotonic item version, used for <c>expectedVersion</c> checks.</summary>
public int Version { get; set; }
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
public long ChangeSequence { get; set; }
/// <summary>Creation timestamp.</summary>
public DateTimeOffset CreatedAtUtc { get; set; }
/// <summary>Last modification timestamp.</summary>
public DateTimeOffset UpdatedAtUtc { get; set; }
/// <summary>
/// Soft-delete marker; a tombstone, so an offline client learns the pin was withdrawn.
/// </summary>
/// <remarks>
/// A tombstone matters more here than for the other types. Withdrawing trust is the deliberate act a
/// user performs when a server has legitimately been rebuilt, and a pin that simply vanished from one
/// machine's view would come back on the next pull — leaving the rebuilt host permanently unreachable
/// from the machine that had the old key.
/// </remarks>
public DateTimeOffset? DeletedAtUtc { get; set; }
/// <summary>Who created it.</summary>
public Guid CreatedByUserId { get; set; }
/// <summary>Who last modified it.</summary>
public Guid UpdatedByUserId { get; set; }
}
@@ -90,9 +90,10 @@ public sealed class SshKeyConfiguration : IEntityTypeConfiguration<VaultSshKey>
/// Maps <see cref="VaultCredential"/>.
/// </summary>
/// <remarks>
/// The narrowest of the three item tables, and deliberately so: no relay CHECK, and unlike
/// <see cref="SshKeyConfiguration"/> not even a fingerprint column. There is nothing about a password that
/// is safe to hold in the clear, so there is nothing here but the envelope and its bookkeeping.
/// The narrowest shape any item table takes — shared now with <see cref="KnownHostKeyConfiguration"/> — and
/// deliberately so: no relay CHECK, and unlike <see cref="SshKeyConfiguration"/> not even a fingerprint
/// column. There is nothing about a password that is safe to hold in the clear, so there is nothing here but
/// the envelope and its bookkeeping.
/// </remarks>
public sealed class CredentialConfiguration : IEntityTypeConfiguration<VaultCredential>
{
@@ -122,6 +123,43 @@ public sealed class CredentialConfiguration : IEntityTypeConfiguration<VaultCred
}
}
/// <summary>
/// Maps <see cref="VaultKnownHostKey"/>.
/// </summary>
/// <remarks>
/// Identical to <see cref="CredentialConfiguration"/>, and the sameness is the statement: a pin has no
/// plaintext column either. It would have been easy to add a <c>host</c> column here for a "known hosts"
/// screen to list without decrypting, and that column would have handed the operator the map of every user's
/// estate. The client decrypts its own vault; it does not need the server's help to sort a list.
/// </remarks>
public sealed class KnownHostKeyConfiguration : IEntityTypeConfiguration<VaultKnownHostKey>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<VaultKnownHostKey> builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.ToTable("known_host_key");
builder.HasKey(k => k.Id);
// Client-generated UUIDv7: a pin must be recordable offline, with its id.
builder.Property(k => k.Id).ValueGeneratedNever();
builder.UseXminConcurrencyToken();
builder.Property(k => k.Payload).IsRequired();
builder.HasIndex(k => new { k.VaultId, k.ChangeSequence });
builder.HasIndex(k => k.VaultId)
.HasFilter("deleted_at_utc IS NULL")
.HasDatabaseName("ix_known_host_key_vault_live");
builder.ToTable(t => t.HasCheckConstraint(
"ck_known_host_key_version",
"version >= 1"));
}
}
/// <summary>Maps <see cref="VaultChange"/>.</summary>
public sealed class SyncChangeConfiguration : IEntityTypeConfiguration<VaultChange>
{
@@ -60,6 +60,9 @@ public class DodoDbContext(DbContextOptions<DodoDbContext> options) : DbContext(
/// <summary>Usernames and passwords, held as ciphertext.</summary>
public DbSet<VaultCredential> Credentials => Set<VaultCredential>();
/// <summary>Trusted SSH host keys, held as ciphertext.</summary>
public DbSet<VaultKnownHostKey> KnownHostKeys => Set<VaultKnownHostKey>();
/// <summary>The per-vault change log that delta sync reads.</summary>
public DbSet<VaultChange> VaultChanges => Set<VaultChange>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DodoSSH.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddKnownHostKeyItem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "known_host_key",
schema: "dodo",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
vault_id = table.Column<Guid>(type: "uuid", nullable: false),
payload = table.Column<byte[]>(type: "bytea", nullable: false),
data_key_wrap = table.Column<byte[]>(type: "bytea", nullable: true),
content_key_id = table.Column<Guid>(type: "uuid", nullable: true),
key_generation = table.Column<int>(type: "integer", nullable: false),
payload_aad_version = table.Column<short>(type: "smallint", nullable: false),
version = table.Column<int>(type: "integer", nullable: false),
change_sequence = table.Column<long>(type: "bigint", nullable: false),
created_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
updated_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
deleted_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
created_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
updated_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_known_host_key", x => x.id);
table.CheckConstraint("ck_known_host_key_version", "version >= 1");
table.ForeignKey(
name: "fk_known_host_key_vaults_vault_id",
column: x => x.vault_id,
principalSchema: "dodo",
principalTable: "vault",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_known_host_key_vault_id_change_sequence",
schema: "dodo",
table: "known_host_key",
columns: new[] { "vault_id", "change_sequence" });
migrationBuilder.CreateIndex(
name: "ix_known_host_key_vault_live",
schema: "dodo",
table: "known_host_key",
column: "vault_id",
filter: "deleted_at_utc IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "known_host_key",
schema: "dodo");
}
}
}
@@ -907,6 +907,87 @@ namespace DodoSSH.Infrastructure.Migrations
});
});
modelBuilder.Entity("DodoSSH.Domain.VaultKnownHostKey", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<long>("ChangeSequence")
.HasColumnType("bigint")
.HasColumnName("change_sequence");
b.Property<Guid?>("ContentKeyId")
.HasColumnType("uuid")
.HasColumnName("content_key_id");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at_utc");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("uuid")
.HasColumnName("created_by_user_id");
b.Property<byte[]>("DataKeyWrap")
.HasColumnType("bytea")
.HasColumnName("data_key_wrap");
b.Property<DateTimeOffset?>("DeletedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at_utc");
b.Property<int>("KeyGeneration")
.HasColumnType("integer")
.HasColumnName("key_generation");
b.Property<byte[]>("Payload")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("payload");
b.Property<short>("PayloadAadVersion")
.HasColumnType("smallint")
.HasColumnName("payload_aad_version");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at_utc");
b.Property<Guid>("UpdatedByUserId")
.HasColumnType("uuid")
.HasColumnName("updated_by_user_id");
b.Property<Guid>("VaultId")
.HasColumnType("uuid")
.HasColumnName("vault_id");
b.Property<int>("Version")
.HasColumnType("integer")
.HasColumnName("version");
b.Property<uint>("xmin")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.HasKey("Id")
.HasName("pk_known_host_key");
b.HasIndex("VaultId")
.HasDatabaseName("ix_known_host_key_vault_live")
.HasFilter("deleted_at_utc IS NULL");
b.HasIndex("VaultId", "ChangeSequence")
.HasDatabaseName("ix_known_host_key_vault_id_change_sequence");
b.ToTable("known_host_key", "dodo", t =>
{
t.HasCheckConstraint("ck_known_host_key_version", "version >= 1");
});
});
modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b =>
{
b.Property<Guid>("Id")
@@ -1121,6 +1202,18 @@ namespace DodoSSH.Infrastructure.Migrations
b.Navigation("Vault");
});
modelBuilder.Entity("DodoSSH.Domain.VaultKnownHostKey", b =>
{
b.HasOne("DodoSSH.Domain.Vault", "Vault")
.WithMany()
.HasForeignKey("VaultId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_known_host_key_vaults_vault_id");
b.Navigation("Vault");
});
modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b =>
{
b.HasOne("DodoSSH.Domain.Vault", "Vault")
@@ -761,6 +761,81 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
result.Detail.ShouldNotBeNull().ShouldContain("no public key");
}
[Fact]
public async Task AKnownHostKey_RoundTripsAsCiphertextWithNoPlaintextAtAll()
{
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var pinId = Guid.CreateVersion7();
var pushed = await client.PostContractAsync(
PushUrl(vaultId),
new SyncPushRequest(
[KnownHostOperation(pinId, expectedVersion: null, envelope: [4, 2])]));
var results = await pushed.Content.ReadContractAsync<SyncPushResponse>();
results!.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied);
var pulled = await client.PostContractAsync(
PullUrl(vaultId),
new SyncPullRequest(null, null, [SyncEntityType.KnownHostKey]));
var page = await pulled.Content.ReadContractAsync<SyncPullResponse>();
var change = page!.Changes.ShouldHaveSingleItem();
change.EntityType.ShouldBe(SyncEntityType.KnownHostKey);
change.EntityId.ShouldBe(pinId);
change.Payload.ShouldNotBeNull().Envelope.ShouldBe([4, 2]);
change.PlaintextFields.ShouldBeNull(
"which endpoints a user has approved is not something this server keeps");
}
[Fact]
public async Task AKnownHostKeyCarryingItsAddressInTheClear_IsRejected()
{
// The refusal that matters most of the four types, because this is the one item that genuinely holds
// an address: a client that put it in the relay columns would be handing the operator a list of the
// endpoints every user connects to, and it would look like an ordinary field while doing it.
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var operation = KnownHostOperation(Guid.CreateVersion7(), null, [1])
with
{ PlaintextFields = new SyncPlaintextFields(RelayEnabled: true, Hostname: "db.internal", Port: 22) };
var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));
var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
.ShouldHaveSingleItem();
result.Status.ShouldBe(SyncOperationStatus.Invalid);
result.Detail.ShouldNotBeNull().ShouldContain("stays encrypted");
}
[Fact]
public async Task AKnownHostKeyWithAFingerprintColumn_IsRejected()
{
// A pin is nothing but a fingerprint, so this is the field a client would most plausibly think it
// should send. The column exists for SSH keys, this client leaves even that one null, and a
// fingerprint here would identify the server rather than the user's own key.
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var operation = KnownHostOperation(Guid.CreateVersion7(), null, [1])
with
{ PlaintextFields = new SyncPlaintextFields(PublicKeyFingerprint: "SHA256:whatever") };
var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));
var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
.ShouldHaveSingleItem();
result.Status.ShouldBe(SyncOperationStatus.Invalid);
result.Detail.ShouldNotBeNull().ShouldContain("inside its payload");
}
[Fact]
public async Task ThreeItemTypesWithOneId_AreThreeSeparateItems()
{
@@ -935,6 +1010,23 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
Payload(envelope),
PlaintextFields: null);
/// <remarks>
/// As narrow as <see cref="CredentialOperation"/>, and worth stating why for a type that is nothing but an
/// address and a fingerprint: both stay inside the envelope, so there is no field here either.
/// </remarks>
private static SyncPushOperation KnownHostOperation(
Guid entityId,
int? expectedVersion,
byte[] envelope) =>
new(
Guid.CreateVersion7(),
SyncEntityType.KnownHostKey,
entityId,
SyncOperation.Upsert,
expectedVersion,
Payload(envelope),
PlaintextFields: null);
private static SyncPushRequest NewCreateBatch() =>
new([NewOperation(Guid.CreateVersion7(), expectedVersion: null, envelope: [1, 2, 3, 4])]);
@@ -42,6 +42,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
private ClientPaths paths = null!;
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private VaultKnownHostStore knownHosts = null!;
private MainWindowViewModel shell = null!;
/// <inheritdoc />
@@ -53,7 +54,10 @@ public sealed class ShellFlowTests : IAsyncLifetime
paths = new ClientPaths(directory);
caches = ClientCacheFactory.ForFile(paths.CacheFile);
var knownHosts = new InMemoryKnownHostStore();
// The real store, not a stand-in. It is the one the application composes, its lifecycle is this
// shell's business — opened on unlock, closed on lock — and the trust it records goes into the vault
// this suite already has, so substituting one would only stop the wiring being tested.
knownHosts = new VaultKnownHostStore();
// In-memory assets rather than the application's Avalonia-resource provider, which reads the
// resource system at construction and needs an initialised toolkit. This is what
@@ -271,7 +275,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
paths,
caches,
workspace,
new InMemoryKnownHostStore(),
new VaultKnownHostStore(),
(_, _) => throw new InvalidOperationException("The shell went to the network to unlock."),
TimeProvider.System,
CheapProfile);
@@ -459,6 +463,138 @@ public sealed class ShellFlowTests : IAsyncLifetime
requests.ShouldBe(0);
}
[Fact]
public async Task TrustingAHostKey_PinsItInTheVaultAndConnects()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Failure = new SshHostKeyUnknownException(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:first-contact"));
await vault.ConnectCommand.ExecuteAsync(null);
vault.HasPendingHostKey.ShouldBeTrue();
// The second connection is the one that succeeds, which is what a trust-and-retry actually is: the
// handshake is refused, the user decides, and a fresh connection is made with the pin in place.
ssh.Failure = null;
await vault.TrustHostKeyCommand.ExecuteAsync(null);
vault.HasPendingHostKey.ShouldBeFalse();
// Two connection attempts: the one that was refused and the one the pin allowed. Asserted on the
// factory rather than on the status line, which the push that follows a trust legitimately repaints.
ssh.Requests.Count.ShouldBe(2);
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token))
.ShouldBe("SHA256:first-contact");
// Pushed as part of trusting, so the next machine to sync is not asked the same question.
vault.PendingChanges.ShouldBe(0);
}
[Fact]
public async Task APinnedHostKey_SurvivesLockingAndUnlocking()
{
// The gap this whole item closes, from the shell's point of view: the store is opened on unlock and
// its contents come out of the vault, so approving a fingerprint is a decision that lasts.
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Failure = new SshHostKeyUnknownException(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:approved"));
await vault.ConnectCommand.ExecuteAsync(null);
ssh.Failure = null;
await vault.TrustHostKeyCommand.ExecuteAsync(null);
await shell.LockCommand.ExecuteAsync(null);
// Locked means locked: the pins go with the vault keys, so nothing can answer a host key question
// while the window is showing an unlock screen.
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
}
[Fact]
public async Task ForgettingAHostKey_ClearsThePinAndTheRefusal()
{
// The way back from a rebuilt server, and the reason a mismatch can stay a hard refusal: the user
// withdraws trust deliberately, from the host's own editor, rather than clicking past a warning.
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Failure = new SshHostKeyUnknownException(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-old-key"));
await vault.ConnectCommand.ExecuteAsync(null);
ssh.Failure = null;
await vault.TrustHostKeyCommand.ExecuteAsync(null);
// The server is rebuilt and offers something else.
ssh.Failure = new SshHostKeyMismatchException(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-new-key"),
"SHA256:the-old-key");
await vault.ConnectCommand.ExecuteAsync(null);
vault.HasHostKeyMismatch.ShouldBeTrue();
vault.EditSelectedHostCommand.Execute(null);
vault.CanForgetHostKey.ShouldBeTrue();
await vault.ForgetHostKeyCommand.ExecuteAsync(null);
// The refusal that sent the user here is about a pin that no longer exists, so it goes too.
vault.HasHostKeyMismatch.ShouldBeFalse();
(await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
// And the withdrawal was pushed rather than left for the timer: the other machines are the ones
// still refusing to connect to a server that has been rebuilt. The wording of the message is
// asserted in ForgettingAHostKeyThatWasNeverPinned_SaysSo, where no pass overwrites the status.
vault.PendingChanges.ShouldBe(0);
}
[Fact]
public async Task ForgettingAHostKeyThatWasNeverPinned_SaysSo()
{
var vault = await ReadyToConnectAsync();
vault.EditSelectedHostCommand.Execute(null);
await vault.ForgetHostKeyCommand.ExecuteAsync(null);
vault.Status.ShouldContain("Nothing was pinned");
}
[Fact]
public async Task ThereIsNothingToForgetOnAHostThatDoesNotExistYet()
{
// The button is hidden while a host is being created, because the pin belongs to an address that has
// not been saved anywhere yet.
var vault = await ReadyToConnectAsync();
vault.NewHostCommand.Execute(null);
vault.CanForgetHostKey.ShouldBeFalse();
vault.CancelEditCommand.Execute(null);
vault.CanForgetHostKey.ShouldBeFalse();
vault.EditSelectedHostCommand.Execute(null);
vault.CanForgetHostKey.ShouldBeTrue();
}
/// <remarks>
/// The shell stops forwarding once the vault is gone. Dropping the detach half of that would compile
/// and pass every other test, while leaving a discarded vault able to move focus in a locked window.
@@ -583,7 +719,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
paths,
caches,
workspace,
new InMemoryKnownHostStore(),
new VaultKnownHostStore(),
(_, _) => throw new InvalidOperationException("unreachable"),
TimeProvider.System,
CheapProfile);
@@ -491,6 +491,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, )"
}
@@ -0,0 +1,208 @@
namespace DodoSSH.Client.Domain.Tests;
/// <summary>
/// The known-host record, its codec and its merge.
/// </summary>
/// <remarks>
/// Shorter again than <see cref="CredentialSecretTests"/>, because three of the four fields are what the item
/// is <em>about</em> rather than content that gets edited. What is specific to this type and worth pinning:
/// that its name is derived rather than stored, that the derived name stays out of the payload and out of
/// equality, that a mangled fingerprint is refused rather than stored to fail comparisons for ever, and that a
/// fingerprint clash is reported with both values — the opposite of what the password merge does.
/// </remarks>
public sealed class KnownHostSecretTests
{
[Fact]
public void APinIsNamedAfterWhatItPins()
{
// Read by the conflict log, which is the only place a person meets one of these. "db.internal:22" is
// something they can match against a host in their list; an item id is not.
Pin().Label.ShouldBe("db.internal:22 (ssh-ed25519)");
Pin(port: 2222).Label.ShouldBe("db.internal:2222 (ssh-ed25519)");
}
[Fact]
public void TwoPinsOfTheSameKey_AreEqual()
{
// The derived label is get-only, so it stays out of the record's equality — which is what makes
// "these are the same pin" a question about the host, port, algorithm and fingerprint alone. The
// reconciler compares secrets this way to recognise its own create coming back.
Pin().ShouldBe(Pin());
Pin(fingerprint: "SHA256:something-else").ShouldNotBe(Pin());
}
[Theory]
[InlineData("", 22, "ssh-ed25519", "SHA256:aaa", "needs the host")]
[InlineData(" ", 22, "ssh-ed25519", "SHA256:aaa", "needs the host")]
[InlineData("db.internal", 0, "ssh-ed25519", "SHA256:aaa", "Port must be between")]
[InlineData("db.internal", 65536, "ssh-ed25519", "SHA256:aaa", "Port must be between")]
[InlineData("db.internal", 22, "", "SHA256:aaa", "needs the key algorithm")]
[InlineData("db.internal", 22, "ssh ed25519", "SHA256:aaa", "needs the key algorithm")]
[InlineData("db.internal", 22, "ssh-ed25519", "", "needs a fingerprint")]
[InlineData("db.internal", 22, "ssh-ed25519", "SHA256:aaa bbb", "needs a fingerprint")]
public void AnInvalidPin_SaysWhatIsWrongWithIt(
string host,
int port,
string algorithm,
string fingerprint,
string expected)
{
var pin = new KnownHostSecret
{
Host = host,
Port = port,
Algorithm = algorithm,
Fingerprint = fingerprint,
};
pin.TryValidate(out var reason).ShouldBeFalse();
reason.ShouldNotBeNull().ShouldContain(expected);
}
[Fact]
public void APinWithSpaceInItsFingerprint_IsRefusedRatherThanStored()
{
// A pasted "SHA256:… comment@host" or a stray newline would compare unequal to the same key on every
// future connection, which the user would read as a permanently changed host key. Refusing it at the
// codec means the bad value never becomes a stored pin.
var mangled = Pin(fingerprint: "SHA256:aaa bbb");
Should.Throw<ArgumentException>(() => KnownHostSecretCodec.Encode(mangled));
}
[Fact]
public void APin_SurvivesARoundTrip()
{
var pin = Pin(host: "bastion.internal", port: 2222, algorithm: "rsa-sha2-512");
var encoded = KnownHostSecretCodec.Encode(pin);
KnownHostSecretCodec.TryDecode(encoded, out var document).ShouldBeTrue();
document.ShouldNotBeNull();
document.KnownHost.ShouldBe(pin);
document.SchemaVersion.ShouldBe(KnownHostSecretCodec.CurrentSchemaVersion);
document.IsReadOnly.ShouldBeFalse();
}
[Fact]
public void EncodingIsDeterministic()
{
// An unchanged pin must not look like a change to the sync engine, or every pass would push every
// host the user has ever approved.
KnownHostSecretCodec.Encode(Pin()).ShouldBe(KnownHostSecretCodec.Encode(Pin()));
}
[Fact]
public void TheDerivedLabel_IsNotInThePayload()
{
// It is a function of the three fields that are, so writing it would put a value on the wire that a
// reader could disagree with — and a merge could then take the label from one side and the address
// from the other.
var json = System.Text.Encoding.UTF8.GetString(KnownHostSecretCodec.Encode(Pin()));
json.ShouldNotContain("label");
json.ShouldNotContain("(ssh-ed25519)");
}
[Theory]
[InlineData("not json")]
[InlineData("{}")]
[InlineData("""{"schemaVersion":1,"host":"db.internal","port":22,"algorithm":"ssh-ed25519"}""")]
[InlineData("""{"schemaVersion":1,"host":"db.internal","port":22,"fingerprint":"SHA256:aaa"}""")]
[InlineData("""{"schemaVersion":1,"port":22,"algorithm":"ssh-ed25519","fingerprint":"SHA256:aaa"}""")]
[InlineData(
"""{"schemaVersion":1,"host":"db.internal","algorithm":"ssh-ed25519","fingerprint":"SHA256:aaa"}""")]
[InlineData(
"""{"schemaVersion":0,"host":"db.internal","port":22,"algorithm":"ssh-ed25519","fingerprint":"SHA256:a"}""")]
public void APayloadThatIsNotAPin_DoesNotDecode(string json)
{
KnownHostSecretCodec
.TryDecode(System.Text.Encoding.UTF8.GetBytes(json), out var document)
.ShouldBeFalse();
document.ShouldBeNull();
}
[Fact]
public void APinFromANewerClient_IsReadableButNotWritable()
{
// Readable matters here more than for the other types: an unreadable pin means a host looks unvisited
// and the user is asked again. The four fields a pin needs are all present, so a newer schema is
// usable for comparison even though this build must not re-encode it.
var payload = System.Text.Encoding.UTF8.GetBytes(
"""
{"schemaVersion":99,"host":"db.internal","port":22,"algorithm":"ssh-ed25519",
"fingerprint":"SHA256:aaa","approvedBy":"someone using a later build"}
""");
KnownHostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document.ShouldNotBeNull();
document.IsReadOnly.ShouldBeTrue();
document.KnownHost.Fingerprint.ShouldBe("SHA256:aaa");
}
[Fact]
public void OnlyOneSideReApproving_TakesThatSide()
{
var ancestor = Pin();
var local = ancestor with { Fingerprint = "SHA256:the-rebuilt-server" };
var merged = KnownHostSecretMerge.Merge(ancestor, local, ancestor);
merged.HasConflicts.ShouldBeFalse();
merged.Merged.Fingerprint.ShouldBe("SHA256:the-rebuilt-server");
}
[Fact]
public void BothSidesApprovingADifferentKey_ReportsBothFingerprints()
{
// Deliberately the opposite of the password merge. An operator publishes a fingerprint so that it can
// be compared, and a notice that withheld the value it dropped would leave the user nothing to check.
var ancestor = Pin();
var local = ancestor with { Fingerprint = "SHA256:seen-from-the-laptop" };
var remote = ancestor with { Fingerprint = "SHA256:seen-from-the-desktop" };
var merged = KnownHostSecretMerge.Merge(ancestor, local, remote);
var conflict = merged.Conflicts.ShouldHaveSingleItem();
conflict.Field.ShouldBe(nameof(KnownHostSecret.Fingerprint));
conflict.Kept.ShouldBe("SHA256:seen-from-the-desktop");
conflict.Discarded.ShouldBe("SHA256:seen-from-the-laptop");
// The server's value wins, as it must for every replica to converge on the same answer.
merged.Merged.Fingerprint.ShouldBe("SHA256:seen-from-the-desktop");
}
[Fact]
public void APortClash_IsReportedAsANumberRatherThanAsNothing()
{
// Not reachable from this client — the store never re-addresses a pin — but a payload from elsewhere
// is untrusted input, and a conflict entry with an empty value in it would be a notice about nothing.
var ancestor = Pin();
var local = ancestor with { Port = 2222 };
var remote = ancestor with { Port = 2022 };
var merged = KnownHostSecretMerge.Merge(ancestor, local, remote);
var conflict = merged.Conflicts.ShouldHaveSingleItem();
conflict.Field.ShouldBe(nameof(KnownHostSecret.Port));
conflict.Kept.ShouldBe("2022");
conflict.Discarded.ShouldBe("2222");
}
private static KnownHostSecret Pin(
string host = "db.internal",
int port = 22,
string algorithm = "ssh-ed25519",
string fingerprint = "SHA256:aaa") =>
new()
{
Host = host,
Port = port,
Algorithm = algorithm,
Fingerprint = fingerprint,
};
}
@@ -0,0 +1,351 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Session.Tests;
/// <summary>
/// Host key trust that outlives the process, and the snapshot the SSH handshake reads it from.
/// </summary>
/// <remarks>
/// <para>
/// The first test is the whole point of the feature: approve a fingerprint, lock the vault, unlock it again,
/// and the host is still trusted. It runs with no server at all — the pin is in the outbox and the local
/// cache, which is exactly the situation a user is in on a laptop that has not been online since.
/// </para>
/// <para>
/// The rest are the properties that would each be a security bug rather than an inconvenience: that a pin
/// answers for the one algorithm it was recorded for, that a locked vault answers "not pinned" rather than
/// something optimistic, and that withdrawing trust reaches every pin for the endpoint.
/// </para>
/// </remarks>
public sealed class VaultKnownHostStoreTests : IAsyncLifetime
{
private const string Passphrase = "correct horse battery staple";
private const string ServerUrl = "https://dodossh.example";
/// <remarks>
/// Far below the shipped profile, as in <see cref="SessionLifecycleTests"/>: nothing here attacks a wrap,
/// and every test in this suite pays for at least one unlock.
/// </remarks>
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly FakeAccountServer server = new();
private readonly StubKeyBinding keyBinding = new();
private ClientCacheFactory caches = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"known-hosts-{Guid.CreateVersion7():N}");
await caches.MigrateAsync(TestContext.Current.CancellationToken);
await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
caches.Dispose();
return ValueTask.CompletedTask;
}
[Fact]
public async Task AHostTrustedOnceIsStillTrustedAfterALockAndUnlock()
{
// The gap this closes. With trust in memory the user is asked to check the same fingerprint on every
// launch, which is how people learn to approve host keys without reading them.
var store = new VaultKnownHostStore();
await using (var first = await UnlockAsync())
{
await store.OpenAsync(first, Token);
await store.TrustAsync(Presented(), Token);
}
store.Close();
await using var second = await UnlockAsync();
await store.OpenAsync(second, Token);
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token))
.ShouldBe("SHA256:approved");
}
[Fact]
public async Task AnUnvisitedHost_HasNoPin()
{
await using var session = await UnlockAsync();
var store = new VaultKnownHostStore();
await store.OpenAsync(session, Token);
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
}
[Fact]
public async Task APinAnswersForOneAlgorithmOnly()
{
// A server legitimately offers several host keys, and which one is negotiated can change between
// connections. A pin that answered for all of them would either accept a key nobody approved or
// report a mismatch for an ordinary server.
await using var session = await UnlockAsync();
var store = new VaultKnownHostStore();
await store.OpenAsync(session, Token);
await store.TrustAsync(Presented(algorithm: "ssh-ed25519"), Token);
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
(await store.FindAsync("db.internal", 22, "rsa-sha2-512", Token)).ShouldBeNull();
(await store.FindAsync("db.internal", 2222, "ssh-ed25519", Token)).ShouldBeNull();
(await store.FindAsync("other.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
}
[Fact]
public async Task AHostNameIsMatchedWithoutRegardToCase()
{
// DNS is case-insensitive, so DB.internal and db.internal are one machine. Treating them as two would
// ask the user to approve the same server twice, and leave two pins where a withdrawal has to find
// both.
await using var session = await UnlockAsync();
var store = new VaultKnownHostStore();
await store.OpenAsync(session, Token);
await store.TrustAsync(Presented(host: "DB.internal"), Token);
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
// And the value stored is the one that was dialled, not a lower-cased rewrite of it.
var listing = await session.KnownHosts.ListAsync(session.ActiveVaultId, Token);
listing.Items.ShouldHaveSingleItem().Secret.Host.ShouldBe("DB.internal");
}
[Fact]
public async Task ReApprovingTheSameKey_QueuesNothingFurther()
{
// Re-trusting an identical fingerprint used to be a dictionary write that cost nothing. It is now an
// outbox operation, and queueing one would push a modification to every other machine for a decision
// that had not changed.
await using var session = await UnlockAsync();
var store = new VaultKnownHostStore();
await store.OpenAsync(session, Token);
await store.TrustAsync(Presented(), Token);
var listing = await session.KnownHosts.ListAsync(session.ActiveVaultId, Token);
var entityId = listing.Items.ShouldHaveSingleItem().EntityId;
var before = await session.Outbox
.FindAsync(session.ActiveVaultId, SyncEntityType.KnownHostKey, entityId, Token);
var operationId = before.ShouldNotBeNull().OperationId;
await store.TrustAsync(Presented(), Token);
// Asserted on the operation rather than on a count of pending changes, and the difference is the
// whole test: queueing an identical write coalesces onto the same outbox row, so a count stays at one
// either way. What would move is the operation id, which is re-minted whenever the payload is
// rewritten — so this is what tells a redundant write from no write at all.
var after = await session.Outbox
.FindAsync(session.ActiveVaultId, SyncEntityType.KnownHostKey, entityId, Token);
after.ShouldNotBeNull().OperationId.ShouldBe(operationId);
(await session.KnownHosts.ListAsync(session.ActiveVaultId, Token)).Items.ShouldHaveSingleItem();
}
[Fact]
public async Task ApprovingANewKeyForAKnownHost_ReplacesThePinRatherThanAddingOne()
{
// What happens after a server is rebuilt and its old pin has been forgotten. Two items for one
// endpoint would leave the endpoint's trust depending on which of them a lookup happened to see.
await using var session = await UnlockAsync();
var store = new VaultKnownHostStore();
await store.OpenAsync(session, Token);
await store.TrustAsync(Presented(), Token);
await store.TrustAsync(Presented(fingerprint: "SHA256:rebuilt"), Token);
var listing = await session.KnownHosts.ListAsync(session.ActiveVaultId, Token);
listing.Items.ShouldHaveSingleItem().Secret.Fingerprint.ShouldBe("SHA256:rebuilt");
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:rebuilt");
}
[Fact]
public async Task ForgettingAHost_TakesEveryAlgorithmWithIt()
{
// The user's decision is about the machine, not about one of the keys it offers. Leaving one behind
// would mean a rebuilt server that still refuses to connect for a reason they believe they have
// already dealt with.
await using var session = await UnlockAsync();
var store = new VaultKnownHostStore();
await store.OpenAsync(session, Token);
await store.TrustAsync(Presented(algorithm: "ssh-ed25519"), Token);
await store.TrustAsync(Presented(algorithm: "rsa-sha2-512"), Token);
await store.TrustAsync(Presented(host: "other.internal"), Token);
(await store.ForgetAsync("db.internal", 22, Token)).ShouldBe(2);
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
(await store.FindAsync("db.internal", 22, "rsa-sha2-512", Token)).ShouldBeNull();
// And nothing else was touched.
(await store.FindAsync("other.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
}
[Fact]
public async Task ForgettingAHostThatWasNeverApproved_SaysSoRatherThanFailing()
{
await using var session = await UnlockAsync();
var store = new VaultKnownHostStore();
await store.OpenAsync(session, Token);
(await store.ForgetAsync("db.internal", 22, Token)).ShouldBe(0);
}
[Fact]
public async Task AForgottenPinStaysForgottenAcrossALockAndUnlock()
{
// The refresh that follows a withdrawal replaces the snapshot rather than merging into it. Merging
// would have made a forgotten pin reappear, which is the failure that matters here: the user would be
// told the host key had changed after explicitly saying it had.
var store = new VaultKnownHostStore();
await using (var first = await UnlockAsync())
{
await store.OpenAsync(first, Token);
await store.TrustAsync(Presented(), Token);
await store.ForgetAsync("db.internal", 22, Token);
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
}
store.Close();
await using var second = await UnlockAsync();
await store.OpenAsync(second, Token);
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
}
[Fact]
public async Task ALockedVaultAnswersNotPinnedRatherThanSomethingOptimistic()
{
// Reachable when a vault is locked while a handshake is in flight. Refusing the connection is the safe
// direction; the alternative would be answering a host key question out of a vault that is closed.
var store = new VaultKnownHostStore();
await using var session = await UnlockAsync();
await store.OpenAsync(session, Token);
await store.TrustAsync(Presented(), Token);
store.IsOpen.ShouldBeTrue();
store.Close();
store.IsOpen.ShouldBeFalse();
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
}
[Fact]
public async Task ALockedVaultRefusesToRecordOrWithdrawTrust()
{
// Loudly rather than silently. A pin accepted into nothing would leave the user believing they had
// approved a host, and a withdrawal accepted into nothing would leave one they believe is gone.
var store = new VaultKnownHostStore();
await Should.ThrowAsync<InvalidOperationException>(
async () => await store.TrustAsync(Presented(), Token));
await Should.ThrowAsync<InvalidOperationException>(
async () => await store.ForgetAsync("db.internal", 22, Token));
// And a refresh with nothing behind it is a no-op rather than a throw: a synchronisation pass may
// finish after the vault was locked, and that is ordinary rather than exceptional.
await store.RefreshAsync(Token);
}
[Fact]
public async Task ARefreshPicksUpAPinRecordedElsewhere()
{
// Stands in for a pin arriving from another machine: something reached the vault that this store's
// snapshot predates. Written through the repository directly, which is what a pull followed by a
// listing amounts to.
await using var session = await UnlockAsync();
var store = new VaultKnownHostStore();
await store.OpenAsync(session, Token);
await session.KnownHosts.CreateAsync(
session.ActiveVaultId,
new KnownHostSecret
{
Host = "bastion.internal",
Port = 22,
Algorithm = "ssh-ed25519",
Fingerprint = "SHA256:approved-on-the-desktop",
},
Token);
(await store.FindAsync("bastion.internal", 22, "ssh-ed25519", Token))
.ShouldBeNull("the snapshot is only re-read when it is told to be");
await store.RefreshAsync(Token);
(await store.FindAsync("bastion.internal", 22, "ssh-ed25519", Token))
.ShouldBe("SHA256:approved-on-the-desktop");
}
[Fact]
public async Task ARefreshKeepsWhatWasApprovedHere()
{
// The mistake that would matter. A pass that re-read the vault must not drop a pin recorded on this
// machine, or the user would be asked again about a host they had just approved.
await using var session = await UnlockAsync();
var store = new VaultKnownHostStore();
await store.OpenAsync(session, Token);
await store.TrustAsync(Presented(), Token);
await store.RefreshAsync(Token);
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
}
// ---- Helpers ----
private static HostKeyPresentation Presented(
string host = "db.internal",
int port = 22,
string algorithm = "ssh-ed25519",
string fingerprint = "SHA256:approved") =>
new(host, port, algorithm, fingerprint);
private SessionOpener Opener() => new(caches, TimeProvider.System);
private AccountProvisioner Provisioner() =>
new(server, keyBinding, caches, TimeProvider.System, CheapProfile);
private async Task<VaultSession> UnlockAsync()
{
var outcome = await Opener().UnlockAsync(Passphrase, Token);
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
return outcome.Session!;
}
}
@@ -324,10 +324,17 @@
"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, )"
}
},
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"SSH.NET": "[2025.1.0, )"
}
},
"dodossh.client.storage": {
"type": "Project",
"dependencies": {
@@ -356,6 +363,12 @@
"NSec.Cryptography": "[26.4.0, )"
}
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"EFCore.NamingConventions": {
"type": "CentralTransitive",
"requested": "[10.0.1, )",
@@ -451,6 +464,16 @@
"dependencies": {
"SQLitePCLRaw.core": "2.1.12"
}
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
}
}
@@ -0,0 +1,139 @@
namespace DodoSSH.Client.Ssh.Tests;
/// <summary>
/// What makes two pins the same pin, and the in-memory store that answers on those terms.
/// </summary>
/// <remarks>
/// <para>
/// No container and no vault. <see cref="KnownHostIdentity"/> is shared by every
/// <see cref="IKnownHostStore"/>, so the identity rules are asserted here once, at the layer that defines
/// them — and <see cref="InMemoryKnownHostStore"/> is what the rest of this suite runs against, so its own
/// behaviour has to be right or every test above it is testing something the application does not do.
/// </para>
/// <para>
/// The vault-backed store that actually ships has the same rules asserted against a real cache in
/// <c>DodoSSH.Client.Session.Tests</c>. The duplication is deliberate: two implementations of one interface,
/// and a store that quietly disagreed with the other about which host a pin belongs to would make this
/// suite's coverage of the connect path meaningless.
/// </para>
/// </remarks>
public sealed class KnownHostStoreTests
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
public async Task AnUnvisitedHost_HasNoPin()
{
var store = new InMemoryKnownHostStore();
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
}
[Fact]
public async Task APinAnswersForOneHostPortAndAlgorithm()
{
// A server legitimately offers several host keys and may negotiate a different one next time, so the
// algorithm is part of what was approved. A pin that answered for all of them would accept a key
// nobody checked.
var store = new InMemoryKnownHostStore();
await store.TrustAsync(Presented(), Token);
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
(await store.FindAsync("db.internal", 22, "rsa-sha2-512", Token)).ShouldBeNull();
(await store.FindAsync("db.internal", 2222, "ssh-ed25519", Token)).ShouldBeNull();
(await store.FindAsync("other.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
}
[Fact]
public async Task AHostNameIsMatchedWithoutRegardToCase()
{
// DNS is case-insensitive, so these are one machine and must be one pin.
var store = new InMemoryKnownHostStore();
await store.TrustAsync(Presented(host: "DB.internal"), Token);
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
(await store.FindAsync("db.INTERNAL", 22, "SSH-ED25519", Token)).ShouldBe("SHA256:approved");
}
[Fact]
public async Task ReApproving_ReplacesTheFingerprint()
{
var store = new InMemoryKnownHostStore();
await store.TrustAsync(Presented(), Token);
await store.TrustAsync(Presented(fingerprint: "SHA256:rebuilt"), Token);
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:rebuilt");
}
[Fact]
public async Task ForgettingAHost_TakesEveryAlgorithmAndNothingElse()
{
// The decision being withdrawn is about the machine, not about one of the keys it offers — and a pin
// left behind would keep refusing a connection the user believes they have already fixed.
var store = new InMemoryKnownHostStore();
await store.TrustAsync(Presented(algorithm: "ssh-ed25519"), Token);
await store.TrustAsync(Presented(algorithm: "rsa-sha2-512"), Token);
await store.TrustAsync(Presented(port: 2222), Token);
await store.TrustAsync(Presented(host: "other.internal"), Token);
(await store.ForgetAsync("db.internal", 22, Token)).ShouldBe(2);
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
(await store.FindAsync("db.internal", 22, "rsa-sha2-512", Token)).ShouldBeNull();
// A different port is a different endpoint, and a different host is obviously untouched.
(await store.FindAsync("db.internal", 2222, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
(await store.FindAsync("other.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
}
[Fact]
public async Task ForgettingAHostThatWasNeverApproved_SaysNothingWasRemoved()
{
var store = new InMemoryKnownHostStore();
(await store.ForgetAsync("db.internal", 22, Token)).ShouldBe(0);
}
[Fact]
public async Task ForgettingIsCaseInsensitiveToo()
{
// The lookup and the withdrawal have to agree about identity, or a pin could be found and not
// forgotten — which is the worst of the two, because the user would be told the trust was gone.
var store = new InMemoryKnownHostStore();
await store.TrustAsync(Presented(host: "DB.internal"), Token);
(await store.ForgetAsync("db.internal", 22, Token)).ShouldBe(1);
(await store.FindAsync("DB.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
}
[Fact]
public void AnIdentityIsMadeOfAllThreeParts()
{
// Stated directly, because every store keys on this string and a format that dropped the port or the
// algorithm would silently merge pins that are not the same pin.
KnownHostIdentity.For("db.internal", 22, "ssh-ed25519").ShouldBe("db.internal:22/ssh-ed25519");
// Asserted through the comparer rather than with ShouldNotBe, because the comparer is what every
// store actually keys on — a difference the default string comparison sees but this one does not
// would still collapse two pins into one.
KnownHostIdentity.Comparer.Equals(
KnownHostIdentity.For("db.internal", 22, "ssh-ed25519"),
KnownHostIdentity.For("db.internal", 2222, "ssh-ed25519")).ShouldBeFalse();
KnownHostIdentity.Comparer.Equals(
KnownHostIdentity.For("DB.internal", 22, "ssh-ed25519"),
KnownHostIdentity.For("db.internal", 22, "ssh-ed25519")).ShouldBeTrue();
}
private static HostKeyPresentation Presented(
string host = "db.internal",
int port = 22,
string algorithm = "ssh-ed25519",
string fingerprint = "SHA256:approved") =>
new(host, port, algorithm, fingerprint);
}
@@ -66,6 +66,7 @@ public sealed class AadResourceTypeTests
(SyncEntityType.Host, CryptoSpec.AadResourceType.Host),
(SyncEntityType.SshKey, CryptoSpec.AadResourceType.SshKey),
(SyncEntityType.Credential, CryptoSpec.AadResourceType.Credential),
(SyncEntityType.KnownHostKey, CryptoSpec.AadResourceType.KnownHostKey),
];
public static TheoryData<SyncEntityType, CryptoSpec.AadResourceType> Pinned
@@ -161,6 +162,9 @@ public sealed class AadResourceTypeTests
SyncEntityType.Credential => CredentialCipher.Seal(
NewCredential(), vaultKey, entityId, generation, version),
SyncEntityType.KnownHostKey => KnownHostKeyCipher.Seal(
NewKnownHost(), vaultKey, entityId, generation, version),
_ => throw new ArgumentOutOfRangeException(
nameof(wire),
wire,
@@ -256,6 +260,46 @@ public sealed class AadResourceTypeTests
SshKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion: 3).ShouldBeNull();
}
[Fact]
public void AKnownHostPayload_OpensAsNothingElse()
{
// The pairing table above is the load-bearing check; this is the cross-type refusal a reader expects
// to see spelled out, and it is the one that would notice a second cipher being pointed at
// AadResourceType.KnownHostKey by mistake.
var vaultKey = RandomNumberGenerator.GetBytes(32);
var entityId = Guid.CreateVersion7();
var sealed_ = KnownHostKeyCipher.Seal(
NewKnownHost(), vaultKey, entityId, keyGeneration: 1, itemVersion: 1);
HostCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
SshKeyCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
CredentialCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
KnownHostKeyCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldNotBeNull();
}
[Fact]
public void AKnownHostSealedAtOneVersion_DoesNotOpenAtAnother()
{
var vaultKey = RandomNumberGenerator.GetBytes(32);
var entityId = Guid.CreateVersion7();
var payload = KnownHostKeyCipher.Seal(
NewKnownHost(), vaultKey, entityId, keyGeneration: 1, itemVersion: 2);
KnownHostKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion: 3).ShouldBeNull();
}
private static KnownHostSecret NewKnownHost() => new()
{
Host = "db.internal",
Port = 22,
Algorithm = "ssh-ed25519",
Fingerprint = "SHA256:5cWZ1Zc2ZmEXAMPLEfingerprintvalue0123456789a",
};
private static CredentialSecret NewCredential() => new()
{
Label = "db-login",
@@ -31,7 +31,12 @@ internal sealed class FakeVaultServer : ISyncApi
{
/// <summary>The item types this fake knows, mirroring the server's own registry.</summary>
private static readonly SyncEntityType[] Supported =
[SyncEntityType.Host, SyncEntityType.SshKey, SyncEntityType.Credential];
[
SyncEntityType.Host,
SyncEntityType.SshKey,
SyncEntityType.Credential,
SyncEntityType.KnownHostKey,
];
private readonly Dictionary<(SyncEntityType Type, Guid EntityId), Row> rows = [];
private readonly List<LogEntry> log = [];
@@ -257,19 +262,26 @@ internal sealed class FakeVaultServer : ISyncApi
/// <summary>The per-type rules about which plaintext columns an item may carry.</summary>
/// <remarks>
/// A key's are stricter than a host's rather than merely different, and that asymmetry is the point:
/// the relay concession belongs to hosts alone, so a key arriving with an address is a client bug and
/// is refused with a reason instead of being quietly dropped.
/// One method per type, as the server has one class per type, because the differences are the interesting
/// part. Everything except a host is stricter rather than merely different: the relay concession belongs
/// to hosts alone, so anything else arriving with an address is a client bug and is refused with a reason
/// instead of being quietly dropped.
/// </remarks>
private static bool ValidateFields(
SyncEntityType entityType,
SyncPlaintextFields fields,
out string error)
out string error) => entityType switch
{
SyncEntityType.SshKey => ValidateKeyFields(fields, out error),
SyncEntityType.Credential => ValidateCredentialFields(fields, out error),
SyncEntityType.KnownHostKey => ValidateKnownHostFields(fields, out error),
_ => ValidateHostFields(fields, out error),
};
private static bool ValidateKeyFields(SyncPlaintextFields fields, out string error)
{
error = string.Empty;
if (entityType == SyncEntityType.SshKey)
{
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "An SSH key has no relay target; relay fields may only be set on a host.";
@@ -279,8 +291,10 @@ internal sealed class FakeVaultServer : ISyncApi
return true;
}
if (entityType == SyncEntityType.Credential)
private static bool ValidateCredentialFields(SyncPlaintextFields fields, out string error)
{
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A credential has no relay target; relay fields may only be set on a host.";
@@ -296,6 +310,33 @@ internal sealed class FakeVaultServer : ISyncApi
return true;
}
/// <remarks>
/// The type that does hold an address, and holds it inside the ciphertext. A pin arriving with one in the
/// clear would be the server being handed the list of endpoints a user reaches.
/// </remarks>
private static bool ValidateKnownHostFields(SyncPlaintextFields fields, out string error)
{
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A known host key is not something the server dials; its address stays encrypted.";
return false;
}
if (fields.PublicKeyFingerprint is not null)
{
error = "A known host key's fingerprint stays inside its payload.";
return false;
}
return true;
}
private static bool ValidateHostFields(SyncPlaintextFields fields, out string error)
{
error = string.Empty;
if (!fields.RelayEnabled && (fields.Hostname is not null || fields.Port is not null))
{
error = "An address may only be supplied when relay is enabled.";
@@ -18,7 +18,12 @@ public sealed class ItemKindsTests
public void ThePullFilterNamesEveryTypeThisBuildSynchronises()
{
ItemKinds.SyncedTypes.ShouldBe(
[SyncEntityType.Host, SyncEntityType.SshKey, SyncEntityType.Credential]);
[
SyncEntityType.Host,
SyncEntityType.SshKey,
SyncEntityType.Credential,
SyncEntityType.KnownHostKey,
]);
}
[Fact]
@@ -0,0 +1,201 @@
using DodoSSH.Contracts;
using static DodoSSH.Client.Sync.Tests.SyncHarness;
namespace DodoSSH.Client.Sync.Tests;
/// <summary>
/// Known host keys through the two-machine harness.
/// </summary>
/// <remarks>
/// The first test here is the whole reason this item type exists: a host approved on the laptop is approved on
/// the desktop. Everything else is what the credential and key suites check per type — the cipher, what the
/// server is told, that the items cannot be confused with another type's — plus the two properties that are
/// specific to trust: that withdrawing it propagates, and that a clash between two fingerprints is reported
/// with both of them, unlike a clash between two passwords.
/// </remarks>
public sealed class KnownHostSyncTests : IAsyncLifetime
{
private SyncHarness harness = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public async ValueTask InitializeAsync() => harness = await CreateAsync();
/// <inheritdoc />
public ValueTask DisposeAsync()
{
harness.Dispose();
return ValueTask.CompletedTask;
}
[Fact]
public async Task AHostApprovedOnOneMachine_IsApprovedOnTheOther()
{
// The point of the item type. Without it a user is asked to check the same fingerprint on every
// device, which is how people learn to approve host keys without reading them.
var entityId = await harness.First.CreateKnownHostAsync(
KnownHost("bastion.internal", port: 2222, fingerprint: "SHA256:approved-on-the-laptop"));
await harness.SettleAsync();
var seen = await harness.Second.FindKnownHostAsync(entityId);
seen.Secret.Host.ShouldBe("bastion.internal");
seen.Secret.Port.ShouldBe(2222);
seen.Secret.Algorithm.ShouldBe("ssh-ed25519");
seen.Secret.Fingerprint.ShouldBe("SHA256:approved-on-the-laptop");
seen.HasUnsyncedChanges.ShouldBeFalse();
}
[Fact]
public async Task TrustWithdrawnOnOneMachine_IsWithdrawnOnTheOther()
{
// The other half, and not a symmetry argument: a changed host key is refused with no way to continue,
// so a withdrawal that did not travel would leave a rebuilt server unreachable from every machine
// except the one that forgot its old key.
var entityId = await harness.First.CreateKnownHostAsync(KnownHost());
await harness.SettleAsync();
(await harness.Second.ListKnownHostsAsync()).Items.ShouldHaveSingleItem();
await harness.First.DeleteKnownHostAsync(entityId);
await harness.SettleAsync();
(await harness.Second.ListKnownHostsAsync()).Items.ShouldBeEmpty();
}
[Fact]
public async Task ThePull_AsksForKnownHostKeys()
{
// Derived from the registry rather than listed, so this cannot be forgotten — but a pin that
// reconciles perfectly and is never requested would work on one machine and exist nowhere else,
// which is exactly the failure the first test would then be unable to see.
await harness.First.SyncAsync();
harness.Server.LastPullTypes.ShouldNotBeNull().ShouldContain(SyncEntityType.KnownHostKey);
}
[Fact]
public async Task APinHandsTheServerNothingInPlaintext()
{
// The address especially. It is the one field here that the server is allowed to hold for a
// relay-enabled host, and putting it on this item as well would hand the operator the list of
// endpoints every user actually reaches — assembled out of values that are each harmless.
var entityId = await harness.First.CreateKnownHostAsync(KnownHost("bastion.internal"));
var queued = await harness.First.Outbox
.FindAsync(VaultId, SyncEntityType.KnownHostKey, entityId, Token);
queued.ShouldNotBeNull();
queued.Fields.ShouldBeNull("a pin tells the server nothing but its ciphertext");
await harness.SettleAsync();
var row = harness.Server.Find(entityId, SyncEntityType.KnownHostKey).ShouldNotBeNull();
row.Fields.RelayEnabled.ShouldBeFalse();
row.Fields.Hostname.ShouldBeNull();
row.Fields.Port.ShouldBeNull();
row.Fields.PublicKeyFingerprint.ShouldBeNull();
}
[Fact]
public async Task APinAndAHostSharingAnId_AreTwoItems()
{
// The cache keys on the type as well as the id, and each payload's AAD binds a different resource
// type. Arranged on the server because the repositories mint UUIDv7s and would never collide.
var sharedId = Guid.CreateVersion7();
harness.First.Keyring.TryGet(VaultId, out var vaultKey, out var generation).ShouldBeTrue();
harness.Server.ExternalUpsert(
sharedId,
HostCipher.Seal(Host("prod-db"), vaultKey.Span, sharedId, generation, itemVersion: 1),
new SyncPlaintextFields(),
SyncEntityType.Host);
harness.Server.ExternalUpsert(
sharedId,
KnownHostKeyCipher.Seal(
KnownHost(), vaultKey.Span, sharedId, generation, itemVersion: 1),
null,
SyncEntityType.KnownHostKey);
await harness.Second.SyncAsync();
(await harness.Second.ListAsync()).Items.ShouldHaveSingleItem()
.Secret.Label.ShouldBe("prod-db");
var pins = await harness.Second.ListKnownHostsAsync();
pins.Items.ShouldHaveSingleItem().Secret.Host.ShouldBe("db.internal");
pins.Unreadable.ShouldBe(0);
}
[Fact]
public async Task BothMachinesApprovedADifferentKey_TheDiscardedFingerprintIsReported()
{
// The deliberate contrast with the password merge, which reports that something differed and nothing
// more. A fingerprint is published by the operator so that it can be compared; a notice that withheld
// the value it dropped would leave the user with nothing to check it against.
var entityId = await harness.First.CreateKnownHostAsync(KnownHost());
await harness.SettleAsync();
await harness.First.UpdateKnownHostAsync(
entityId, KnownHost(fingerprint: "SHA256:seen-from-the-laptop"));
await harness.Second.UpdateKnownHostAsync(
entityId, KnownHost(fingerprint: "SHA256:seen-from-the-desktop"));
await harness.SettleAsync();
var first = (await harness.First.FindKnownHostAsync(entityId)).Secret;
// Converged, and on the value that reached the server first: every replica has to resolve a clash the
// same way or the two would push against each other for ever.
first.ShouldBe((await harness.Second.FindKnownHostAsync(entityId)).Secret);
first.Fingerprint.ShouldBe("SHA256:seen-from-the-laptop");
var details = await ConflictDetailsAsync();
details.ShouldContain(detail => detail.Contains("SHA256:seen-from-the-desktop", StringComparison.Ordinal));
details.ShouldContain(detail => detail.Contains("Fingerprint", StringComparison.Ordinal));
}
[Fact]
public async Task APinEditedElsewhereAfterBeingDeletedHere_IsCalledAKnownHostKey()
{
// The noun reaches a person. "This host key was edited elsewhere" would send them to look at the
// server they are connecting to, rather than at a decision they made about it.
var entityId = await harness.First.CreateKnownHostAsync(KnownHost());
await harness.SettleAsync();
await harness.First.UpdateKnownHostAsync(
entityId, KnownHost(fingerprint: "SHA256:still-the-one-i-approved"));
await harness.Second.DeleteKnownHostAsync(entityId);
await harness.SettleAsync();
(await harness.First.FindKnownHostAsync(entityId)).Secret.Fingerprint
.ShouldBe("SHA256:still-the-one-i-approved");
var details = await ConflictDetailsAsync();
details.ShouldContain(
detail => detail.Contains("This known host key was edited", StringComparison.Ordinal));
}
private async Task<IReadOnlyList<string>> ConflictDetailsAsync()
{
var first = await harness.First.ConflictsAsync();
var second = await harness.Second.ConflictsAsync();
return
[
.. first.Concat(second)
.Select(conflict => System.Text.Encoding.UTF8.GetString(conflict.Detail)),
];
}
}
@@ -39,6 +39,7 @@ internal sealed class SyncDevice : IDisposable
Hosts = new HostRepository(Items, Outbox, keyring);
SshKeys = new SshKeyRepository(Items, Outbox, keyring);
Credentials = new CredentialRepository(Items, Outbox, keyring);
KnownHosts = new KnownHostRepository(Items, Outbox, keyring);
Engine = new SyncEngine(
server, Items, Outbox, SyncState, Conflicts, keyring, TimeProvider.System, options);
@@ -62,6 +63,8 @@ internal sealed class SyncDevice : IDisposable
internal CredentialRepository Credentials { get; }
internal KnownHostRepository KnownHosts { get; }
internal SyncEngine Engine { get; }
internal static async Task<SyncDevice> CreateAsync(
@@ -165,6 +168,29 @@ internal sealed class SyncDevice : IDisposable
Credentials.UpdateAsync(
SyncHarness.VaultId, entityId, credential, TestContext.Current.CancellationToken);
// ---- And again on known host keys ----
internal Task<ItemListing<KnownHostSecret>> ListKnownHostsAsync() =>
KnownHosts.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
internal async Task<VaultItem<KnownHostSecret>> FindKnownHostAsync(Guid entityId)
{
var listing = await ListKnownHostsAsync();
return listing.Items.SingleOrDefault(pin => pin.EntityId == entityId)
?? throw new InvalidOperationException($"{Name} cannot see known host key {entityId}.");
}
internal Task<Guid> CreateKnownHostAsync(KnownHostSecret knownHost) =>
KnownHosts.CreateAsync(SyncHarness.VaultId, knownHost, TestContext.Current.CancellationToken);
internal Task UpdateKnownHostAsync(Guid entityId, KnownHostSecret knownHost) =>
KnownHosts.UpdateAsync(
SyncHarness.VaultId, entityId, knownHost, TestContext.Current.CancellationToken);
internal Task DeleteKnownHostAsync(Guid entityId) =>
KnownHosts.DeleteAsync(SyncHarness.VaultId, entityId, TestContext.Current.CancellationToken);
internal Task<IReadOnlyList<StoredConflict>> ConflictsAsync() =>
Conflicts.ListAsync(SyncHarness.VaultId, false, TestContext.Current.CancellationToken);
@@ -315,6 +341,25 @@ internal sealed class SyncHarness : IDisposable
string? notes = null) =>
new() { Label = label, Password = password, Username = username, Notes = notes };
/// <summary>A pinned host key, varying only what a test is about.</summary>
/// <remarks>
/// The fingerprint is a plausible shape rather than a real digest. Nothing in the sync path hashes
/// anything or checks the encoding — <c>SshHostKeyFingerprint</c> does that, one layer down and in its own
/// suite — so a value that reads as one is worth more here than a genuine one.
/// </remarks>
internal static KnownHostSecret KnownHost(
string host = "db.internal",
int port = 22,
string algorithm = "ssh-ed25519",
string fingerprint = "SHA256:AAAAtestfingerprint0123456789abcdefghijklmno") =>
new()
{
Host = host,
Port = port,
Algorithm = algorithm,
Fingerprint = fingerprint,
};
internal static SshKeySecret Key(
string label,
string material = "deploy-key-material",
@@ -91,11 +91,20 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
await AssertTheServerCannotSeeTheAddressAsync(connection, entityId);
await AssertTheServerLearnsNothingAboutTheKeyAsync(connection, keyId);
var seen = await ReadOnASecondMachineAsync(connection, host, entityId, key, keyId);
// The shell, and the trust decision it produces. Before the second machine reads the vault, so that
// what the second machine pulls includes the host key this one approved — which is the claim the whole
// item type exists to make and the only place it is proved through a real server.
var pin = await OpenAShellAsync(laptop, host);
var trusted = await laptop.SyncAsync(connection.Sync, Token);
trusted.Pushed.ShouldBe(1, "the host key the user approved at the prompt");
trusted.NeedsAttention.ShouldBeFalse();
await AssertTheServerLearnsNothingAboutTheTrustedHostAsync(connection);
await ReadOnASecondMachineAsync(connection, host, entityId, key, keyId, pin);
await AssertUnlocksOfflineAsync(laptopCache);
await OpenAShellAsync(seen);
}
// ---- Steps ----
@@ -200,12 +209,18 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
change.Payload.DataKeyId.ShouldNotBe(Guid.Empty);
}
private async Task<HostSecret> ReadOnASecondMachineAsync(
/// <remarks>
/// Takes the host key presentation the shell step produced, because the point of pinning trust in the
/// vault is that this machine — which has never spoken to that <c>sshd</c> — already knows the fingerprint
/// the other one approved.
/// </remarks>
private async Task ReadOnASecondMachineAsync(
ServerConnection connection,
HostSecret expected,
Guid entityId,
SshKeySecret expectedKey,
Guid keyId)
Guid keyId,
HostKeyPresentation pin)
{
using var desktopCache = await OpenCacheAsync();
@@ -220,7 +235,7 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
await using var session = desktop;
var pulled = await desktop.SyncAsync(connection.Sync, Token);
pulled.Pulled.ShouldBe(2, "the host and the key, in one pass");
pulled.Pulled.ShouldBe(3, "the host, the key and the approved host key, in one pass");
var listing = await desktop.Hosts.ListAsync(desktop.ActiveVaultId, Token);
var seen = listing.Items.ShouldHaveSingleItem();
@@ -243,7 +258,44 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
// the key ever having been readable to the thing that carried it.
seenKey.Secret.ShouldBe(expectedKey);
return seen.Secret;
// And the host key trust, which is what stops this machine asking the user to check a fingerprint
// somebody has already checked. Read through the store the SSH handshake actually asks, so what is
// proved here is the answer a connection would get and not merely that a row arrived.
var knownHosts = new VaultKnownHostStore();
await knownHosts.OpenAsync(desktop, Token);
(await knownHosts.FindAsync(pin.Host, pin.Port, pin.Algorithm, Token))
.ShouldBe(pin.Fingerprint, "trust recorded on one machine has to reach the other");
// The algorithm is part of the identity, so a pin must not answer for a key the user never saw.
(await knownHosts.FindAsync(pin.Host, pin.Port, "ssh-rsa-that-was-never-offered", Token))
.ShouldBeNull();
}
/// <remarks>
/// A pin is the item type most likely to be given a plaintext column by mistake — it holds an address the
/// server may already know for a relay-enabled host, and a fingerprint that is public by nature. Together,
/// across a vault, they are the list of machines a user reaches. Asserted against the real endpoint's
/// answer, as the host and the key are.
/// </remarks>
private static async Task AssertTheServerLearnsNothingAboutTheTrustedHostAsync(
ServerConnection connection)
{
var vaultId = (await connection.Account.GetMeAsync(Token)).Vaults.Single().VaultId;
var page = await connection.Sync.SyncPullAsync(
vaultId, new SyncPullRequest(null, 100, [SyncEntityType.KnownHostKey]), Token);
page.Changes.ShouldAllBe(change => change.EntityType == SyncEntityType.KnownHostKey);
var change = page.Changes.ShouldHaveSingleItem();
change.PlaintextFields.ShouldBeNull(
"which endpoints a user has approved is not something the server is told");
change.Payload.ShouldNotBeNull();
change.Payload.WrappedDataKey.ShouldNotBeEmpty();
change.Payload.DataKeyId.ShouldNotBe(Guid.Empty);
}
private static async Task AssertUnlocksOfflineAsync(ClientCacheFactory caches)
@@ -256,18 +308,30 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
}
/// <remarks>
/// <para>
/// Goes through the real trust-on-first-use path rather than around it. An unknown host key throws, the
/// caller pins it and retries — which is what the interface does, and the only way to prove the
/// fingerprint a user would be shown is the one the server actually presented.
/// </para>
/// <para>
/// Through the store that ships, so the pin is sealed under the vault key and queued for the server rather
/// than kept in a dictionary. That also means the answer the second handshake gets has been through a
/// real encrypt and decrypt, which is the property an in-memory store cannot exercise.
/// </para>
/// </remarks>
private static async Task OpenAShellAsync(HostSecret host)
/// <returns>The host key that was approved, so a second machine can be asked whether it knows it.</returns>
private static async Task<HostKeyPresentation> OpenAShellAsync(VaultSession laptop, HostSecret host)
{
var knownHosts = new InMemoryKnownHostStore();
var knownHosts = new VaultKnownHostStore();
await knownHosts.OpenAsync(laptop, Token);
var factory = new SshNetConnectionFactory(knownHosts);
var request = new SshConnectionRequest(
host.Hostname, host.Port, host.Username!, new SshPasswordCredential(DevStack.SshPassword));
HostKeyPresentation? pin = null;
try
{
await using var first = await factory.ConnectAsync(request, Token);
@@ -275,8 +339,10 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
}
catch (SshHostKeyUnknownException exception)
{
exception.Presentation.Fingerprint.ShouldStartWith("SHA256:");
await knownHosts.TrustAsync(exception.Presentation, Token);
pin = exception.Presentation;
pin.Fingerprint.ShouldStartWith("SHA256:");
await knownHosts.TrustAsync(pin, Token);
}
await using var connection = await factory.ConnectAsync(request, Token);
@@ -287,6 +353,8 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
var output = await ReadUntilEchoedAsync(shell, "dodossh-e2e-ok");
output.ShouldContain("dodossh-e2e-ok");
return pin.ShouldNotBeNull();
}
// ---- Helpers ----
@@ -420,6 +420,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, )"
}