Bind an SSH key to a host instead of picking one per connection

A host now names the key it authenticates with, or none, as a field in its
encrypted payload — so the choice follows the host to every machine rather than
being made again each time somebody connects. The per-connection "Use key"
switch it replaces was a stopgap for not having this, and keeping both would
have left two mechanisms answering one question.

This is the first payload schema version bump, and it does not work the obvious
way. A host is written at the *lowest* schema version that can represent it: one
that binds a key is written at 2, one that does not is still written at 1, byte
for byte as it was before the field existed. The version is what makes an older
client refuse to edit an item, so stamping 2 unconditionally would mean
upgrading a single machine and renaming a single host made that host uneditable
on every machine that had not upgraded yet. Confining the cost to the hosts that
actually use the field is the difference between a team noticing a bump and a
team being blocked by one. HostSecretCodec states the rule so the next field
added follows it, and a test pins the version-1 bytes against a literal rather
than against the codec, because the claim is about history: every host already in
every vault has to re-encode to what it encoded before, or the first sync after
an upgrade would push the whole vault as changed.

A binding is an item id, not a copy of the key — a second copy of a private key
is one that goes stale — which means the reference can dangle when the key is
deleted on another machine. Both places that meets are handled the same way, by
refusing rather than falling back:

- Connecting to a host whose key is gone is refused outright. A host somebody
  deliberately set up for key-only access must not quietly start offering a
  password.
- Opening such a host in the editor keeps the binding, selected, labelled as
  missing. The quieter version of the same failure is someone editing the port
  and saving, silently converting the host to password authentication with
  nothing ever having said so.

Two things this found by being falsified:

- The merge was untested for the new field, and "just take the server's value"
  passed the entire suite — a local binding change would have been discarded with
  no conflict recorded. HostSecretMergeTests already had a test written for
  exactly this class of omission; it simply had not been extended.

- Adding a nullable field exposed a defect in HostSecretMerge.Field: it
  short-circuited when the discarded value was null, so the formatter never ran
  for the one case where null is a value rather than an absence, and a field
  whose absence has a name could not report it. Now the formatter always runs,
  and "no key" appears in the conflict log where an empty string used to.

Also fixes eight nullable warnings in SyncEndpointTests left by the server-side
SSH key commit, which had omitted the null-forgiving operator the rest of that
file uses. They were invisible until an unrelated change forced the project to
recompile.

The end-to-end slice now binds its host to its key, so a schema-version-2
payload goes through the real API, the real PostgreSQL and back out on a second
machine.

745 tests green. Zero warnings, dotnet format clean.
This commit is contained in:
2026-07-29 20:42:51 +02:00
parent e3fd3e1728
commit 70b3290a77
12 changed files with 523 additions and 105 deletions
@@ -36,10 +36,42 @@ internal sealed class HostRowViewModel(VaultItem<HostSecret> host)
internal bool IsReadOnly => host.IsReadOnly;
/// <summary>How this host authenticates, in one word.</summary>
/// <remarks>
/// Worth a word in the list because the two behave differently at the moment of connecting: one needs
/// the password box filled in and the other does not, and a user staring at an empty password box on a
/// key-authenticated host has no other way to know it is not needed.
/// </remarks>
internal string Authentication => host.Secret.SshKeyId is null ? "password" : "key";
/// <summary>A short marker for the row, so the list says what it knows without a tooltip.</summary>
internal string Badge => ItemBadge.For(host.IsBlocked, host.IsReadOnly, host.HasUnsyncedChanges);
}
/// <summary>An entry in the host editor's key picker.</summary>
/// <param name="EntityId">The key's item id, or null for password authentication.</param>
/// <param name="Label">What to show.</param>
/// <remarks>
/// A sentinel entry rather than a nullable selection, because a ComboBox with nothing selected and a
/// ComboBox meaning "no key" look identical and are not the same thing — the first is a host whose binding
/// has not been decided, the second is a decision.
/// </remarks>
internal sealed record SshKeyChoice(Guid? EntityId, string Label)
{
/// <summary>The "use a password" entry, always first.</summary>
internal static SshKeyChoice None { get; } = new(null, "Password (no key)");
/// <summary>
/// A stand-in for a key the host names and the vault no longer has.
/// </summary>
/// <remarks>
/// Kept in the list, and kept selected, so that opening a host to change its port does not silently
/// convert it to password authentication on save. The id is preserved; only the label admits the
/// problem.
/// </remarks>
internal static SshKeyChoice Missing(Guid entityId) => new(entityId, "(a key that is no longer here)");
}
/// <summary>One SSH key, as a row in the list.</summary>
/// <remarks>
/// <para>
@@ -147,10 +179,10 @@ internal sealed class ConflictRowViewModel(ConflictNotice notice)
/// than a design choice, and the interface says so rather than implying the vault holds more than it does.
/// </para>
/// <para>
/// <b>A key is chosen per connection, not per host.</b> Binding a key to a host is the better answer and it
/// is not free: it means a new field on <c>HostSecret</c>, which means bumping the payload schema version,
/// which makes every host written afterwards read-only on an older build. Worth doing deliberately rather
/// than as a side effect of adding keys, so for now this works the way <c>ssh -i</c> does.
/// <b>A key belongs to a host.</b> Each host names the key it authenticates with, or none, and that choice
/// is a field in its encrypted payload — so it follows the host to every machine rather than being made
/// again per connection. The cost is a payload schema version, paid only by hosts that actually bind a key:
/// see <c>HostSecretCodec.CurrentSchemaVersion</c>.
/// </para>
/// </remarks>
internal sealed partial class VaultViewModel(
@@ -227,6 +259,20 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
private bool editorRelayEnabled;
/// <summary>
/// What the key picker offers: password, then every key in the vault.
/// </summary>
/// <remarks>
/// Rebuilt when the editor opens rather than kept in step with the key list. A background sync could
/// pull a new key while a host is being edited, and having the picker's contents change under the user
/// mid-edit is worse than the list being a minute stale — the two editors cannot be open at once, so
/// the only way to add a key is to close this one anyway.
/// </remarks>
internal ObservableCollection<SshKeyChoice> EditorKeyChoices { get; } = [];
[ObservableProperty]
private SshKeyChoice? editorSelectedKey;
/// <summary>The item being edited, or null when creating.</summary>
private Guid? editingEntityId;
@@ -269,16 +315,8 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
private string connectPassword = string.Empty;
/// <summary>
/// Whether to authenticate with the selected key rather than a password.
/// </summary>
/// <remarks>
/// An explicit switch rather than "use the key if one happens to be selected". The key list's selection
/// exists to edit and delete keys, and letting it silently change how the next connection authenticates
/// would make clicking a row to rename it alter what Connect does.
/// </remarks>
[ObservableProperty]
private bool useKeyAuthentication;
/// <summary>Whether the selected host authenticates with a key, so the password box can say so.</summary>
internal bool SelectedHostUsesAKey => SelectedHost?.Host.SshKeyId is not null;
[ObservableProperty]
private HostKeyPresentation? pendingHostKey;
@@ -545,6 +583,7 @@ internal sealed partial class VaultViewModel(
EditorUsername = string.Empty;
EditorNotes = string.Empty;
EditorRelayEnabled = false;
BuildKeyChoices(boundKeyId: null);
IsEditing = true;
Status = "Adding a host.";
}
@@ -573,6 +612,7 @@ internal sealed partial class VaultViewModel(
EditorUsername = row.Host.Username ?? string.Empty;
EditorNotes = row.Host.Notes ?? string.Empty;
EditorRelayEnabled = row.Host.RelayEnabled;
BuildKeyChoices(row.Host.SshKeyId);
IsEditing = true;
Status = $"Editing {row.Label}.";
}
@@ -796,12 +836,13 @@ internal sealed partial class VaultViewModel(
return;
}
if (UseKeyAuthentication && SelectedKey is null)
if (TryBuildCredential(row.Host) is not { } credential)
{
// Refused rather than quietly falling back to the password box. Silently authenticating a
// different way than the user asked for is how a password reaches a host that was meant to
// only ever see a key.
Status = "Choose a key to authenticate with, or turn key authentication off.";
// Refused rather than quietly falling back to the password box. A host set up for key-only
// access that silently starts offering a password is the failure worth ruling out — the user
// asked for one thing and got another, and the host is the last place that would say so.
Status = $"'{row.Label}' authenticates with an SSH key that is not in this vault any more. "
+ "Edit the host to choose another key, or set it back to a password.";
return;
}
@@ -810,7 +851,7 @@ internal sealed partial class VaultViewModel(
await RunAsync(
$"Connecting to {row.Label}…",
() => OpenSessionAsync(row, cancellationToken)).ConfigureAwait(true);
() => OpenSessionAsync(row, credential, cancellationToken)).ConfigureAwait(true);
}
/// <summary>Pins the offered host key and retries.</summary>
@@ -891,7 +932,10 @@ internal sealed partial class VaultViewModel(
/// output at a terminal that was never created. That wait is bounded and takes this command's token, so
/// a renderer that never arrives ends as a message rather than as a window stuck on "Connecting…".
/// </remarks>
private async Task OpenSessionAsync(HostRowViewModel row, CancellationToken cancellationToken)
private async Task OpenSessionAsync(
HostRowViewModel row,
SshCredential credential,
CancellationToken cancellationToken)
{
try
{
@@ -901,7 +945,7 @@ internal sealed partial class VaultViewModel(
row.Host.Hostname,
row.Host.Port,
row.Host.Username!,
BuildCredential());
credential);
await workspace
.OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
@@ -937,27 +981,36 @@ internal sealed partial class VaultViewModel(
}
/// <summary>
/// How the next connection authenticates.
/// How this host authenticates, or null when it names a key the vault does not have.
/// </summary>
/// <remarks>
/// <para>
/// The key material is handed over as UTF-8 bytes, which is what <c>PrivateKeyFile</c> reads from a
/// <c>MemoryStream</c> — so the key reaches SSH.NET without ever becoming a file on disk. The
/// passphrase goes with it: a key stored in the vault together with its passphrase is the whole point
/// of a vault, and <c>SshKeySecret</c> says why.
/// of a vault, and <c>SshKeySecret</c> says why. It is passed straight through with no empty-to-null
/// check, because <c>SshKeySecret.Passphrase</c> cannot hold an empty string.
/// </para>
/// <para>
/// The passphrase is passed straight through, with no empty-to-null check, because
/// <c>SshKeySecret.Passphrase</c> cannot hold an empty string — it normalises one to null on the way in.
/// Null is a refusal, not a fallback, and the caller must treat it as one. A dangling reference means a
/// key was deleted on another machine — plausible, and no reason to start sending a password to a host
/// somebody deliberately set up not to accept one.
/// </para>
/// </remarks>
private SshCredential BuildCredential()
private SshCredential? TryBuildCredential(HostSecret host)
{
if (!UseKeyAuthentication || SelectedKey is not { } row)
if (host.SshKeyId is not { } keyId)
{
return new SshPasswordCredential(ConnectPassword);
}
if (Keys.FirstOrDefault(row => row.EntityId == keyId) is not { } key)
{
return null;
}
return new SshPrivateKeyCredential(
Encoding.UTF8.GetBytes(row.Key.PrivateKeyPem), row.Key.Passphrase);
Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase);
}
private HostSecret BuildHost() =>
@@ -969,8 +1022,42 @@ internal sealed partial class VaultViewModel(
Username = string.IsNullOrWhiteSpace(EditorUsername) ? null : EditorUsername.Trim(),
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
RelayEnabled = EditorRelayEnabled,
// Whatever the picker holds, including the id of a key that has gone missing. Reading it from
// the picker rather than carrying the original through is what lets a binding be removed at all,
// and preserving a missing id is what stops an unrelated edit removing one by accident.
SshKeyId = EditorSelectedKey?.EntityId,
};
/// <summary>
/// Fills the key picker, keeping whatever the host is currently bound to selectable.
/// </summary>
/// <param name="boundKeyId">The key the host names, or null for password authentication.</param>
/// <remarks>
/// A bound key that is no longer in the vault gets a placeholder entry rather than being dropped. Without
/// one the picker would open on "Password (no key)", and someone editing the host's port would convert it
/// to password authentication by saving — which is the quiet version of the failure the connect path
/// refuses outright.
/// </remarks>
private void BuildKeyChoices(Guid? boundKeyId)
{
EditorKeyChoices.Clear();
EditorKeyChoices.Add(SshKeyChoice.None);
foreach (var key in Keys)
{
EditorKeyChoices.Add(new SshKeyChoice(key.EntityId, key.Label));
}
if (boundKeyId is { } bound && EditorKeyChoices.All(choice => choice.EntityId != bound))
{
EditorKeyChoices.Add(SshKeyChoice.Missing(bound));
}
EditorSelectedKey = EditorKeyChoices.FirstOrDefault(choice => choice.EntityId == boundKeyId)
?? SshKeyChoice.None;
}
/// <remarks>
/// The private key is not trimmed. Its armour is whitespace-significant and a client that tidied it up
/// would eventually tidy a format it did not fully understand — the same reason
@@ -1129,6 +1216,9 @@ internal sealed partial class VaultViewModel(
}
}
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
OnPropertyChanged(nameof(SelectedHostUsesAKey));
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
OnPropertyChanged(nameof(HasPendingHostKey));