Offer to bring the keys an ssh_config points at
ci / build and test (push) Successful in 2m0s
ci / android head (push) Successful in 6m5s
ci / api image (push) Successful in 42s

An import that recorded a key path and left every host asking for a password was
an import whose result did not connect. The answer to that was a manual paste per
key, which is the sort of thing people do once and then stop importing.

So there is a tick, and it starts off. With it off nothing changes: an
IdentityFile becomes a note and the host asks for a password. With it on, IMPORT
reads each host's first IdentityFile out of ~/.ssh, stores it in the vault
encrypted like any other key, and binds the host to it.

Three things about how it is drawn are load-bearing rather than tidy. It is a
default nobody arrives at by accident. The sentence beside it names the directory
rather than saying "your keys", because that is what somebody is agreeing to. And
nothing is read during SCAN — tick it, read what it says, untick it, and no
private key has been opened. This is the only place the application opens key
material out of a directory the user did not point at file by file, and the whole
of what makes that acceptable is that it took a deliberate press.

One vault key per file, however many entries named it: an ssh_config pointing
twelve hosts at one id_ed25519 is the ordinary shape, and twelve copies would be
twelve things to rotate and eleven to forget. A file whose material is already in
the keychain is bound to rather than stored again, which is what makes running
the import twice harmless.

What cannot be read off a disk is a passphrase, so a protected key arrives
without one — and the report under the button names those files rather than
leaving a host to fail at connect time with a message about a malformed key.
Telling them apart means decoding for OpenSSH's own container, whose cipher name
is the first field inside the base64 rather than anything in the armour, and that
is the format ssh-keygen has written by default for years. The 88 base64
characters it decodes need 66 bytes, not 64: with the smaller span every
protected key came back unprotected, which the tests now pin.

A path that is not on this machine leaves its host imported and unbound, exactly
as it would have been with the tick off, and is named in the same report. A
config carried from another machine is the ordinary case, not an error.
This commit is contained in:
2026-08-05 08:58:28 +02:00
parent 746711da9d
commit ca07d63585
11 changed files with 1220 additions and 62 deletions
+19 -1
View File
@@ -69,7 +69,7 @@ src/
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing
DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP
DodoSSH.Client.Import reading ~/.ssh/config, with no I/O of its own
DodoSSH.Client.Import reading ~/.ssh/config, and the keys it names when asked
DodoSSH.Client.Shell the view models both heads drive, the renderer's files, the palette
DodoSSH.Client.App the desktop head: its views, and its Windows integration
DodoSSH.Client.Android the phone head: its views, and its Android integration
@@ -231,6 +231,24 @@ Windows — is offered neither button and keeps asking for the passphrase, which
rather than leaving you to notice. The passphrase never stops working either way: a declined confirmation
leaves the box exactly where it was.
**Importing an `~/.ssh/config` can bring the keys with it, and only if you ask.** Preferences → IMPORT
HOSTS scans the file and shows what it found; nothing is stored until you press the button. Below the list
there is one tick — *Also import the private keys these hosts point at* — and it starts off.
With it off, an `IdentityFile` becomes a note recording the path, and the host asks for a password until you
bind it to a key yourself. With it on, IMPORT reads each host's first `IdentityFile` out of `~/.ssh`, stores
it in the vault encrypted like any other key, and binds the host to it. One key is stored per **file**
however many entries name it, and a file already in your keychain is bound to rather than stored twice, so
running the import again does not fill the keychain with copies.
**This is the only place DodoSSH opens a private key you did not point it at file by file**, which is why
the tick is off, why the sentence beside it names the directory, and why nothing is read during SCAN — you
can tick it, read what it says and untick it, and no key will have been opened. A key protected by a
passphrase comes in without one, because nothing on disk says what the passphrase is; the report under the
button names those files, and the keychain's editor is where the passphrase goes. A file that is not on this
machine — the ordinary case for a config carried from another one — leaves its host imported and unbound,
and is named in the same report.
### Moving files
**SFTP** and **S3** in the tab strip are a two-pane browser: this machine on the left, the remote on the
+4 -2
View File
@@ -210,8 +210,10 @@ saved, and there is no frame on the terminal data plane that would carry a chang
**Three things the design did not ask for and this build now has.** A key can be generated in the client
rather than pasted in (`SshKeyGenerator`, and the `openssh-key-v1` container is written by hand — see
`OpenSshKeyWriter` for why there was no alternative and why it is written unencrypted). Hosts can be
imported from `~/.ssh/config` (`DodoSSH.Client.Import`; it reads no key material, and `ProxyJump` is
recorded as intent because the SSH layer still has no jump hosts). And the file-transfer screen takes drag
imported from `~/.ssh/config` (`DodoSSH.Client.Import`; `ProxyJump` is recorded as intent because the SSH
layer still has no jump hosts, and the private keys the config names come in **only behind a tick that
starts off** — the one control in this application that opens key material out of a directory the user did
not point at file by file, which is why nothing is read until IMPORT is pressed). And the file-transfer screen takes drag
and drop in four directions — remote to Explorer is the one that does not ship, because it needs a virtual
file the platform layer cannot supply; see `docs/manual-checks.md`.
+58 -13
View File
@@ -218,12 +218,57 @@ Scan, then navigate away without importing.
### 2.9 Imported hosts are correct
Import a couple, then open one on the Hosts screen.
Import a couple with the key tick **off**, then open one on the Hosts screen.
**Pass:** the address, port and username match the config, and the notes record any `IdentityFile` path and
any `ProxyJump` — with `ProxyJump` clearly stated as not routing. Connecting should ask for a password even
where the config named a key, because **no key material is read**; binding it to a key in the keychain is a
separate act.
any `ProxyJump` — with `ProxyJump` clearly stated as not routing. Connecting asks for a password even where
the config named a key, because with the tick off **no key material is read**; binding it to a key in the
keychain is a separate act.
### 2.10 The key tick reads `~/.ssh`, and only when it is ticked · **the one worth the most care**
Scan a config that names at least one `IdentityFile`.
**Pass:** below the list there is a tick reading *Also import the private keys these hosts point at*, and it
is **off**. On a config that names no `IdentityFile` anywhere there is no tick at all.
Turn it on without pressing anything else.
**Pass:** a warning appears naming what IMPORT will read, and the rows whose config named a key change from
*a key on disk* to *a key, imported*. Nothing has been read yet — turn it off again and navigate away, and
the point is that no private key was ever opened.
Turn it on and press IMPORT.
**Pass:** the keychain holds one key per **file**, not per host — a config pointing twelve entries at one
`~/.ssh/id_ed25519` produces one key, and all twelve hosts show `key` as their authentication. The key's
notes name the file it came from. A report appears under the button with one line per file.
**Pass, and this is the half that is easiest to lose:** for a key protected by a passphrase, the report says
so and names the file. The key is still imported; what it does not have is the passphrase, which is nowhere
on disk to be read. Add it on the keychain screen and the host connects.
Now import the same config again.
**Pass:** the hosts duplicate — a second bookmark for one machine is allowed and takes a click — and the
**key does not**. The report should be identical and the keychain should still hold one.
**Failure means:** a tick that is on by default, or one that reads keys during SCAN, is the failure that
matters here — this is the only control in the application that opens private key material out of a
directory the user did not point at file by file, and the whole of what makes it acceptable is that it took
a deliberate press. A second copy of a key on a re-import means the material comparison in
`VaultViewModel.ImportHostsAsync` is not finding what is already there, which turns "import my config"
into a way to fill a keychain with duplicates of one file.
### 2.11 A key file that is not on this machine leaves the host alone
Point an `IdentityFile` at a path that does not exist, tick the box and import.
**Pass:** the host is imported, unbound, exactly as it would have been with the tick off — and the report
names the file and says there is no such file. One bad path must not stop the other entries.
**Failure means:** an import that stopped, or a host that vanished, on a config carried from another
machine. That is the ordinary case rather than an error.
---
@@ -237,27 +282,27 @@ covered by nothing at all.
Connect the SFTP page to a host first. All four of these should queue transfers.
### 2.10 Explorer → remote pane
### 2.12 Explorer → remote pane
Drag one file, then several, from Explorer onto the right-hand pane.
**Pass:** the pane outlines in accent colour while the pointer is over it, and the drop queues one transfer
per file into the directory showing.
### 2.11 Local pane → remote pane
### 2.13 Local pane → remote pane
**Pass:** as above. This uses the same platform file format as the Explorer drag, so a failure here with
2.10 passing points at the drag *source*, not the drop target.
2.12 passing points at the drag *source*, not the drop target.
### 2.12 Remote pane → local pane
### 2.14 Remote pane → local pane
**Pass:** the left pane outlines and the drop queues a download.
### 2.13 Local pane → Explorer
### 2.15 Local pane → Explorer
**Pass:** the file copies out.
### 2.14 The highlight clears · **the one most likely to be wrong**
### 2.16 The highlight clears · **the one most likely to be wrong**
Drag something over a pane and then out of it again without dropping.
@@ -268,13 +313,13 @@ why the layout test cannot catch it — but once visible it swallows the `DragOv
the pointer appears to leave immediately, the highlight sticks, and the drop lands nowhere. The fix is
`IsHitTestVisible="False"` on the highlight `Border` in `TransfersScreen.axaml`.
### 2.15 Dropping while disconnected
### 2.17 Dropping while disconnected
Disconnect, then drag a file over the remote pane.
**Pass:** the pane outlines in red and says "Connect to a host first." Nothing is queued on drop.
### 2.16 A click still selects a row
### 2.18 A click still selects a row
Click rows in both panes, and drag a row a few pixels without releasing.
@@ -794,7 +839,7 @@ around the cards.
space around the cards connects to nothing.
**Failure means:** the 5-pixel threshold in `HostsScreen.axaml.cs` is not doing its job — the same failure
as 2.16 on the other screen, and here it would make the grid unusable. Empty space that connects means the
as 2.18 on the other screen, and here it would make the grid unusable. Empty space that connects means the
double-tap handler has lost its check that the pointer was over a card.
**And the same two gestures on the group cards above**, where they mean select and open rather than select
@@ -15,6 +15,17 @@
Reachable from the preferences screen and not from the nav rail. It is a task rather than a
destination — done once, or once a year — and a seventh rail entry would cost every screen a slot for
something almost nobody is looking at.
── ◆ THE ONE TICK THAT READS PRIVATE KEYS ─────────────────────────────────────────────────────────────
Below the list, off, and drawn only where the scan actually found an IdentityFile. It is the only control
in this application that opens private key material out of a directory the user did not point at file by
file, so three things about how it is drawn are load-bearing rather than tidy: it is a default nobody
arrives at by accident, the sentence beside it names the directory rather than saying "keys", and nothing
is read until IMPORT is pressed — so ticking it, reading the sentence and unticking it costs nothing.
What comes back afterwards is the report under the list: one line per key file, saying which were stored,
which are protected by a passphrase this cannot know, and which were not there at all. That is reported
rather than previewed for the same reason — previewing would mean reading them.
-->
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
@@ -100,17 +111,57 @@
IsVisible="{Binding HasRows}">
<StackPanel Spacing="8">
<!--
Said before the button, not after. A key path is recorded and the key itself is not read: that is
the difference between a bookmark that connects and one that asks for a password, and somebody
who is not told will conclude the import was broken.
Said before the button, not after. Whether the key material comes with the host is the difference
between a bookmark that connects and one that asks for a password, and somebody who is not told
will conclude the import was broken.
-->
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="Key files are not read. Where ssh_config names an IdentityFile the path is recorded as a note, and the host asks for a password until you bind it to a key in your keychain. Nothing here reaches into ~/.ssh for private key material." />
IsVisible="{Binding !ImportsKeys}"
Text="Key files are not read. Where ssh_config names an IdentityFile the path is recorded as a note, and the host asks for a password until you bind it to a key in your keychain." />
<!--
◆ THE TICK. Hidden entirely where the scan found no IdentityFile anywhere — an offer to read ~/.ssh
on a screen where it would read nothing is a control that teaches people to ignore it.
The warning sentence appears only when it is on, and it is the one place this application says out
loud that it is about to open private keys. It names the directory rather than saying "your keys",
because what somebody is agreeing to is a read of that directory.
-->
<StackPanel Spacing="6" IsVisible="{Binding HasKeyFiles}">
<CheckBox IsChecked="{Binding ImportsKeys}">
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Text="Also import the private keys these hosts point at" />
</CheckBox>
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
IsVisible="{Binding ImportsKeys}" Foreground="{StaticResource WarnText}"
Text="Pressing IMPORT will read each host's first IdentityFile out of ~/.ssh, store it in this vault encrypted, and bind the host to it. One key is stored per file however many hosts name it, and a file already in your keychain is bound to rather than stored twice. A key protected by a passphrase comes in without one — nothing on disk says what it is — and the report below will name it." />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Content="{Binding ImportLabel}" Command="{Binding ImportCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="TICK ALL / NONE" Command="{Binding ToggleAllCommand}" />
</StackPanel>
<!--
◆ What became of each key file, after the fact. Below the button because it is the answer rather
than the offer, and capped with a scroll viewer because a config with thirty keyed hosts would
otherwise push IMPORT off the window — the one control this screen must never lose.
-->
<Border IsVisible="{Binding HasKeyReport}" Padding="10,8" CornerRadius="4"
Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
BorderThickness="1">
<ScrollViewer MaxHeight="120">
<ItemsControl ItemsSource="{Binding KeyReport}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Text="{Binding}" Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Margin="0,2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</StackPanel>
</Border>
+11 -4
View File
@@ -42,10 +42,17 @@ public sealed record ImportedHost(
/// <summary>Turns this into the host that would be stored.</summary>
/// <remarks>
/// <para>
/// <b>The identity file becomes a note and a directive, not a key.</b> Reading somebody's
/// <c>~/.ssh/id_ed25519</c> into a keychain is exactly the act this product exists to make deliberate,
/// and doing it as a side effect of "import my config" is the wrong default. The path is recorded so it
/// is not lost; importing the material is a separate, per-row choice.
/// <b>The identity file becomes a note and a directive, not a key — here.</b> Reading somebody's
/// <c>~/.ssh/id_ed25519</c> into a keychain is exactly the act this product exists to make deliberate, so
/// nothing on this path does it: the value is recorded so it is not lost, and this record has no way to
/// reach a disk in any case.
/// <para>
/// ◆ The material <em>can</em> be imported, and the tick that does it is on the import screen, off by
/// default and beside a sentence saying what it will read. It runs through <see cref="SshIdentityFiles"/>
/// and binds the stored key to the host afterwards; what it does not do is change what this method
/// produces, so a host imported without it and a host imported with it differ by a binding rather than
/// by their contents.
/// </para>
/// </para>
/// <para>
/// <b>ProxyJump records intent and changes nothing about connecting.</b> The SSH layer has no jump
@@ -26,6 +26,17 @@ public sealed class SshConfigLocator
/// <summary>Whether there is anything to read.</summary>
public bool Exists => File.Exists(ConfigPath);
/// <summary>
/// Reads the private key an <c>IdentityFile</c> named.
/// </summary>
/// <remarks>
/// Here rather than on <see cref="SshIdentityFiles"/> directly because this type owns the one fact that
/// call needs and nothing else has: where <c>~/.ssh</c> is for this run. That is what a relative
/// <c>IdentityFile</c> resolves against, and it is what the tests override to point at a directory they
/// made — see the class remark on why the disk lives in exactly one type here.
/// </remarks>
public SshIdentityFile ReadIdentity(string path) => SshIdentityFiles.Read(path, sshDirectory);
/// <summary>Reads and resolves the configuration.</summary>
/// <exception cref="FileNotFoundException">There is no configuration file.</exception>
public async Task<SshConfigImport> ReadAsync(CancellationToken cancellationToken)
@@ -0,0 +1,279 @@
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Import;
/// <summary>
/// A private key file an <c>ssh_config</c> named, as the import screen has to describe it.
/// </summary>
/// <remarks>
/// A record rather than a thrown exception per failure, because every outcome here is a row on a screen
/// rather than an error: a path that does not exist is the ordinary shape of a config carried between
/// machines, and a config with forty entries will have several. The import goes ahead without them and says
/// which.
/// </remarks>
/// <param name="Path">The file this is about, resolved. What a message names, so it is never null.</param>
/// <param name="PrivateKeyPem">The armour, verbatim, or null when it could not be read.</param>
/// <param name="PublicKey">
/// The <c>.pub</c> beside it, if there is one. Optional for the reason <c>SshKeySecret.PublicKey</c> is
/// optional — it cannot be derived without parsing every format this stores verbatim — and worth taking
/// while it is right there, because installing a key on a host needs exactly that line.
/// </param>
/// <param name="IsEncrypted">
/// Whether the file is passphrase-protected. Not a failure: the key is worth importing either way, and the
/// passphrase is the one thing on disk that cannot be read off it. The import says so and the keychain's
/// editor is where it gets added.
/// </param>
/// <param name="Failure">Why there is no key, or null when there is one.</param>
public sealed record SshIdentityFile(
string Path,
string? PrivateKeyPem,
string? PublicKey,
bool IsEncrypted,
string? Failure)
{
/// <summary>Whether there is key material to store.</summary>
[MemberNotNullWhen(true, nameof(PrivateKeyPem))]
public bool WasRead => PrivateKeyPem is { Length: > 0 };
}
/// <summary>
/// Reading the private keys an <c>ssh_config</c> points at, and telling encrypted ones apart.
/// </summary>
/// <remarks>
/// <para>
/// <b>Nothing in here runs unless somebody ticked a box.</b> Pulling a private key out of a home directory
/// is the act this product exists to make deliberate, so it is opt-in on the import screen, off by default,
/// and said out loud beside the tick. What this type is responsible for is doing it honestly once asked:
/// reading exactly the files the config named, saying which it could not, and never guessing at a path the
/// config did not give.
/// </para>
/// <para>
/// It does not parse key material. <c>SshKeySecret</c> stores whatever armour it is handed and declines to
/// re-encode it, for reasons written there, and this keeps to the same rule — the one thing it looks
/// <em>into</em> a key for is whether it is encrypted, because that decides what the screen has to say next.
/// </para>
/// </remarks>
public static class SshIdentityFiles
{
/// <summary>The OpenSSH v1 container's magic, which is what an armoured OPENSSH PRIVATE KEY holds.</summary>
private static readonly byte[] OpenSshMagic = "openssh-key-v1\0"u8.ToArray();
/// <summary>
/// Reads the key at a path an <c>ssh_config</c> gave.
/// </summary>
/// <param name="path">
/// The <c>IdentityFile</c> value. Already tilde-expanded by <see cref="SshConfigResolver"/>; a relative
/// one is resolved against <paramref name="sshDirectory"/>, which is OpenSSH's own rule.
/// </param>
/// <param name="sshDirectory">Where <c>~/.ssh</c> is for this run.</param>
/// <remarks>
/// <para>
/// The <c>.pub</c> is read as a side dish and never as a requirement: a missing one costs the public
/// half and nothing else, so it is silently absent rather than a failure. A <em>present</em> one is
/// worth having, and the mistake it heads off is the one <c>SshKeySecret.TryValidate</c> exists to
/// catch — somebody later pasting the wrong one of two files whose names differ by four characters.
/// </para>
/// <para>
/// Every failure is caught and returned rather than thrown. A config that names keys on a machine it
/// was copied from is the ordinary case, and one unreadable path must not stop the other thirty-nine
/// entries being imported.
/// </para>
/// </remarks>
public static SshIdentityFile Read(string path, string sshDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
ArgumentNullException.ThrowIfNull(sshDirectory);
var resolved = System.IO.Path.IsPathRooted(path)
? path
: System.IO.Path.Combine(sshDirectory, path);
try
{
if (!File.Exists(resolved))
{
return Refused(resolved, "there is no such file on this machine");
}
var material = File.ReadAllText(resolved);
if (material.TrimStart().StartsWith("ssh-", StringComparison.Ordinal)
|| material.TrimStart().StartsWith("ecdsa-", StringComparison.Ordinal))
{
// The .pub, named as the IdentityFile. ssh itself tolerates this — it looks for the private
// half beside it — and importing the public one would produce a keychain entry that looks
// fine and fails at connect time saying nothing about which file was chosen.
return Refused(resolved, "that is the public half; the private key is the file without .pub");
}
if (!material.TrimStart().StartsWith("-----BEGIN", StringComparison.Ordinal))
{
return Refused(resolved, "it does not look like a private key");
}
return new SshIdentityFile(
resolved, material, ReadPublicHalf(resolved), IsEncrypted(material), Failure: null);
}
catch (IOException failure)
{
return Refused(resolved, failure.Message);
}
catch (UnauthorizedAccessException failure)
{
return Refused(resolved, failure.Message);
}
}
/// <summary>
/// Whether the armour is passphrase-protected.
/// </summary>
/// <remarks>
/// <para>
/// Three formats and three tells. PKCS#8 says so in the header outright. Classic PEM carries a
/// <c>Proc-Type: 4,ENCRYPTED</c> line above the base64. OpenSSH's own container says nothing in the
/// armour at all — the cipher name is the first field <em>inside</em> the base64 — which is why this
/// decodes rather than reading headers, and it is the format <c>ssh-keygen</c> has written by default
/// for years, so it is the case that actually turns up.
/// </para>
/// <para>
/// Wrong in the safe direction when it cannot tell: anything it fails to decode is reported as
/// unencrypted, which means the key is imported with no warning and fails at connect time with SSH.NET's
/// own message. The opposite default would put a warning on every key, and a warning everybody sees is
/// one nobody reads.
/// </para>
/// </remarks>
internal static bool IsEncrypted(string material)
{
if (material.Contains("BEGIN ENCRYPTED PRIVATE KEY", StringComparison.Ordinal))
{
return true;
}
if (material.Contains("Proc-Type:", StringComparison.Ordinal)
&& material.Contains("ENCRYPTED", StringComparison.Ordinal))
{
return true;
}
return TryReadOpenSshCipher(material, out var cipher)
&& !string.Equals(cipher, "none", StringComparison.Ordinal);
}
/// <summary>
/// Reads the cipher name out of an OpenSSH v1 private key container.
/// </summary>
/// <remarks>
/// The container is <c>openssh-key-v1\0</c> followed by SSH string fields, each a big-endian length and
/// then that many bytes. The first of them is the cipher, and <c>none</c> is what an unprotected key
/// carries. Only that first field is read: everything after it is key material, and this type does not
/// look at key material.
/// </remarks>
private static bool TryReadOpenSshCipher(string material, [NotNullWhen(true)] out string? cipher)
{
cipher = null;
const string header = "-----BEGIN OPENSSH PRIVATE KEY-----";
const string footer = "-----END OPENSSH PRIVATE KEY-----";
var start = material.IndexOf(header, StringComparison.Ordinal);
var end = material.IndexOf(footer, StringComparison.Ordinal);
if (start < 0 || end <= start)
{
return false;
}
var body = material[(start + header.Length)..end];
// 88 base64 characters decode to 66 bytes, so the destination is 72 rather than 64 — a span too
// small makes Convert.TryFromBase64Chars answer false, which this would read as "not an OpenSSH
// container" and report as unencrypted. Measured: every protected key came back unprotected.
Span<byte> decoded = stackalloc byte[72];
// Only the head of the container is needed and the whole of it may be megabytes, so just enough of
// the base64 is decoded for the magic and the first field.
var bytes = TryDecodeHead(body, decoded, out var written) ? decoded[..written] : default;
if (bytes.Length < OpenSshMagic.Length + 4)
{
return false;
}
if (!bytes[..OpenSshMagic.Length].SequenceEqual(OpenSshMagic))
{
return false;
}
var rest = bytes[OpenSshMagic.Length..];
var length = (rest[0] << 24) | (rest[1] << 16) | (rest[2] << 8) | rest[3];
if (length is < 1 or > 64 || rest.Length < 4 + length)
{
return false;
}
cipher = System.Text.Encoding.ASCII.GetString(rest.Slice(4, length));
return true;
}
/// <summary>Decodes the first few base64 characters of an armoured body.</summary>
/// <remarks>
/// The armour is wrapped at 70 characters and the destination is 64 bytes, so this takes the first
/// non-whitespace 88 characters — a multiple of four, decoding to 66 — and stops. Trailing padding never
/// enters into it because the slice never reaches the end of a real key.
/// </remarks>
private static bool TryDecodeHead(ReadOnlySpan<char> body, Span<byte> destination, out int written)
{
Span<char> head = stackalloc char[88];
var taken = 0;
foreach (var character in body)
{
if (char.IsWhiteSpace(character))
{
continue;
}
head[taken++] = character;
if (taken == head.Length)
{
break;
}
}
if (taken < head.Length)
{
written = 0;
return false;
}
return Convert.TryFromBase64Chars(head, destination, out written);
}
/// <remarks>
/// Absent is not a failure, so this answers null rather than a reason. Read with the same swallow the
/// private half uses: a <c>.pub</c> that cannot be read costs the public line and nothing else.
/// </remarks>
private static string? ReadPublicHalf(string privatePath)
{
try
{
var pub = privatePath + ".pub";
return File.Exists(pub) ? File.ReadAllText(pub).Trim() : null;
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
private static SshIdentityFile Refused(string path, string reason) =>
new(path, PrivateKeyPem: null, PublicKey: null, IsEncrypted: false, Failure: reason);
}
@@ -6,6 +6,26 @@ using DodoSSH.Client.Import;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>One host an import is about to store, with the key file it named if that is coming too.</summary>
/// <remarks>
/// The key travels beside the host rather than inside it because the two are separate vault items: a host
/// carries an <c>SshKeyId</c>, and the id does not exist until the key has been written. So the screen says
/// which file a host named and the vault decides what id that file ends up with — see
/// <c>VaultViewModel.ImportHostsAsync</c>, which is where one key per file is enforced.
/// </remarks>
/// <param name="Host">The host as it would be stored, with no binding on it yet.</param>
/// <param name="Key">The key read off the disk, or null when none was asked for or none could be read.</param>
/// <param name="KeyPath">
/// The file <paramref name="Key"/> came out of. It is what two hosts naming one key are compared on, so it
/// travels even though nothing stores it.
/// </param>
internal sealed record ImportedHostRequest(HostSecret Host, SshKeySecret? Key, string? KeyPath);
/// <summary>What an import stored.</summary>
/// <param name="Hosts">Hosts written.</param>
/// <param name="Keys">Private keys written. Counts files read, not hosts bound to one.</param>
internal sealed record ImportOutcome(int Hosts, int Keys);
/// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary>
/// <remarks>
/// The checkbox is the whole point of this type. Nothing is written until somebody has looked at the list
@@ -16,10 +36,11 @@ internal sealed partial class ImportRowViewModel : ObservableObject
{
private readonly ImportedHost host;
internal ImportRowViewModel(ImportedHost host, bool alreadyPresent)
internal ImportRowViewModel(ImportedHost host, bool alreadyPresent, bool importsItsKey)
{
this.host = host;
AlreadyPresent = alreadyPresent;
this.importsItsKey = importsItsKey && HasKeyFile;
// A host already in the keychain starts unticked. Importing it again is allowed — a second bookmark
// for one machine is a thing people genuinely want — but it should take a click rather than be the
@@ -40,17 +61,36 @@ internal sealed partial class ImportRowViewModel : ObservableObject
internal bool HasBadge => AlreadyPresent;
/// <summary>Whether this row's <c>ssh_config</c> entry named a key at all.</summary>
/// <remarks>
/// Most do not, and the tick above the list is about the ones that do. Kept as a property rather than
/// asked of <see cref="ImportedHost"/> at each call site so that "this row has a key file" is one
/// sentence in one place.
/// </remarks>
internal bool HasKeyFile => host.IdentityFiles.Count > 0;
/// <summary>What this row's <c>IdentityFile</c> named, or empty where it named none.</summary>
internal string KeyPath => HasKeyFile ? host.IdentityFiles[0] : string.Empty;
/// <summary>How this would authenticate, in the terms the preview can honestly offer.</summary>
/// <remarks>
/// "a key on disk" rather than "a key", because nothing is imported: the path is recorded and the host
/// will ask for a password until somebody binds it to a keychain key. Saying "key" here would promise a
/// connection that does not work.
/// <para>
/// "a key on disk" rather than "a key" when the material is staying where it is, because nothing is
/// imported then: the path is recorded and the host will ask for a password until somebody binds it to a
/// keychain key. Saying "key" there would promise a connection that does not work.
/// </para>
/// <para>
/// ◆ With the tick on it says the opposite, and has to: the same row now means a key that will be read,
/// stored and bound, and leaving it reading "on disk" would understate the one thing on this screen
/// somebody explicitly agreed to.
/// </para>
/// </remarks>
internal string Authentication => host.IdentityFiles.Count switch
internal string Authentication => (host.IdentityFiles.Count, ImportsItsKey) switch
{
0 => "password",
1 => $"a key on disk · {host.IdentityFiles[0]}",
var count => $"{count} keys on disk · {host.IdentityFiles[0]}",
(0, _) => "password",
(_, true) => $"a key, imported · {host.IdentityFiles[0]}",
(1, _) => $"a key on disk · {host.IdentityFiles[0]}",
var (count, _) => $"{count} keys on disk · {host.IdentityFiles[0]}",
};
internal bool HasWarnings => host.Warnings.Count > 0;
@@ -59,6 +99,17 @@ internal sealed partial class ImportRowViewModel : ObservableObject
[ObservableProperty]
private bool isSelected;
/// <summary>Whether the import will read this row's key file as well as its host.</summary>
/// <remarks>
/// Set from the one tick above the list rather than owned per row. A key file is very often shared by a
/// dozen entries, so a per-row choice would be a dozen ticks deciding one thing — and the decision being
/// made is about reading <c>~/.ssh</c> at all, which is a decision about the directory rather than about
/// any host in it.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Authentication))]
private bool importsItsKey;
}
/// <summary>
@@ -72,10 +123,19 @@ internal sealed partial class ImportRowViewModel : ObservableObject
/// interesting question is not "can it be parsed" but "which of these did you actually want".
/// </para>
/// <para>
/// <b>Nothing reads a private key.</b> An <c>IdentityFile</c> becomes a directive and a note recording the
/// path. Pulling someone's <c>~/.ssh/id_ed25519</c> into a keychain as a side effect of importing a config
/// is the one thing this screen must not do quietly; there is a GENERATE KEY button on the keychain screen
/// for making one deliberately, and pasting an existing one is a deliberate act too.
/// <b>Private keys are read only if somebody ticks the box, and it starts unticked.</b> Pulling a
/// <c>~/.ssh/id_ed25519</c> into a keychain is exactly the act this product exists to make deliberate, and
/// doing it as a side effect of "import my config" would be the wrong default — so it stays a default nobody
/// gets by accident, and the sentence beside the tick names the directory it will read. What changed is that
/// it is now <em>possible</em>: an import that recorded a path and left every host asking for a password was
/// an import whose result did not connect, and the answer to that was a manual paste per key.
/// </para>
/// <para>
/// With the tick on, each host's first <c>IdentityFile</c> is read, stored as a keychain key and bound to
/// the host. One key per file however many hosts named it, and a file already in the keychain is bound to
/// rather than stored twice; both of those live in <c>VaultViewModel.ImportHostsAsync</c>. What cannot be
/// read off a disk is a passphrase, so a protected key arrives without one and the screen says which — see
/// <see cref="KeyReport"/>.
/// </para>
/// </remarks>
internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLocator locator) : ObservableObject
@@ -97,10 +157,47 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
[ObservableProperty]
private bool isBusy;
/// <summary>
/// Whether the import should also read the key files the config names.
/// </summary>
/// <remarks>
/// <para>
/// Off, and it has to be the sort of off that survives somebody not reading the screen. This is the only
/// control in the application that reads private key material out of a directory the user did not point
/// at file by file, and the whole of what makes that acceptable is that it took a deliberate press.
/// </para>
/// <para>
/// Not persisted in <c>ClientSettings</c>, deliberately. A remembered "yes" would make the next import —
/// possibly on another machine, possibly of a colleague's config — read <c>~/.ssh</c> without asking
/// again, which is the answer to a question that was never put twice.
/// </para>
/// </remarks>
[ObservableProperty]
private bool importsKeys;
/// <summary>What happened to the key files, one line each. Empty until an import has run.</summary>
/// <remarks>
/// Reported after the fact rather than previewed, because previewing means reading them — and a screen
/// that read every key in order to tell you it had not imported them would be doing the thing the tick
/// exists to gate. So the list is what the import found: which were stored, which are protected by a
/// passphrase this cannot know, and which were not there at all.
/// </remarks>
internal ObservableCollection<string> KeyReport { get; } = [];
internal bool HasRows => Rows.Count > 0;
internal bool HasWarnings => Warnings.Count > 0;
internal bool HasKeyReport => KeyReport.Count > 0;
/// <summary>Whether anything in the scan named a key file at all.</summary>
/// <remarks>
/// The tick is drawn only when it would do something. A config of forty password hosts has no keys to
/// offer, and a permanently visible offer to read <c>~/.ssh</c> on a screen where it would read nothing
/// is a control that teaches people to ignore it.
/// </remarks>
internal bool HasKeyFiles => Rows.Any(row => row.HasKeyFile);
internal int SelectedCount => Rows.Count(row => row.IsSelected);
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 HOST" : $"IMPORT {SelectedCount} HOSTS";
@@ -111,6 +208,7 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
{
Rows.Clear();
Warnings.Clear();
KeyReport.Clear();
HasScanned = false;
if (!locator.Exists)
@@ -128,7 +226,7 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
foreach (var host in import.Hosts)
{
Rows.Add(new ImportRowViewModel(host, IsAlreadyPresent(host)));
Rows.Add(new ImportRowViewModel(host, IsAlreadyPresent(host), ImportsKeys));
}
foreach (var warning in import.Warnings)
@@ -157,7 +255,12 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
}
}
/// <summary>Stores the ticked hosts.</summary>
/// <summary>Stores the ticked hosts, and the keys they name if that was asked for.</summary>
/// <remarks>
/// The key files are read here rather than during the scan, and that ordering is the tick's whole
/// meaning: until this press nothing has opened a private key, so changing your mind after scanning
/// costs nothing and leaves nothing read.
/// </remarks>
[RelayCommand]
private async Task ImportAsync(CancellationToken cancellationToken)
{
@@ -173,18 +276,24 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
try
{
var imported = await vault
.ImportHostsAsync([.. chosen.Select(row => row.Host.ToSecret())], cancellationToken)
.ConfigureAwait(true);
KeyReport.Clear();
var requests = chosen.Select(BuildRequest).ToList();
var outcome = await vault.ImportHostsAsync(requests, cancellationToken).ConfigureAwait(true);
// Rebuilt rather than cleared, so the rows that were imported now say so — which is what makes
// pressing the button twice harmless and visible rather than harmless and confusing.
foreach (var row in Rows.ToList())
{
Rows[Rows.IndexOf(row)] = new ImportRowViewModel(row.Host, IsAlreadyPresent(row.Host));
Rows[Rows.IndexOf(row)] =
new ImportRowViewModel(row.Host, IsAlreadyPresent(row.Host), ImportsKeys);
}
Status = $"Imported {imported} host(s). They are on the Hosts screen.";
// This screen's own sentence rather than the vault's. The vault writes one too and then pushes,
// and the push overwrites it with a sync report — which is right on a screen somebody is about
// to leave, and wrong on the one they are still standing on.
Status = Describe(outcome);
}
finally
{
@@ -193,6 +302,77 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
}
}
/// <summary>What the import did, in this screen's own words.</summary>
/// <remarks>
/// The keys are counted separately and named only when there are any, because reading key material is
/// the half of this somebody agreed to rather than the half they asked for. The count is files, not
/// hosts: a dozen entries sharing one key is one key, and saying twelve would be describing the config
/// rather than what was stored.
/// </remarks>
private static string Describe(ImportOutcome outcome)
{
var material = outcome.Keys switch
{
0 => string.Empty,
1 => " 1 private key came with them, and every host that named it is bound to it.",
_ => $" {outcome.Keys} private keys came with them, and every host that named one is bound to it.",
};
return $"Imported {outcome.Hosts} host(s). They are on the Hosts screen.{material}";
}
/// <summary>
/// Turns a ticked row into what the vault stores, reading its key file where that was agreed to.
/// </summary>
/// <remarks>
/// <para>
/// The first <c>IdentityFile</c> only, which is the same one <see cref="ImportedHost.ToSecret"/> records
/// as a directive. A host may list several and ssh tries each in turn; a keychain host binds one key, so
/// importing all of them would be storing keys nothing is bound to and calling it authentication.
/// </para>
/// <para>
/// Every outcome is a line in <see cref="KeyReport"/> rather than a failure, including the good one. A
/// key that could not be read leaves the host importable and unbound, which is exactly what the import
/// did for every host before the tick existed.
/// </para>
/// </remarks>
private ImportedHostRequest BuildRequest(ImportRowViewModel row)
{
var host = row.Host.ToSecret();
if (!ImportsKeys || !row.HasKeyFile)
{
return new ImportedHostRequest(host, Key: null, KeyPath: null);
}
var identity = locator.ReadIdentity(row.KeyPath);
if (!identity.WasRead)
{
KeyReport.Add($"{row.Alias}: {identity.Path} was not imported — {identity.Failure}.");
return new ImportedHostRequest(host, Key: null, KeyPath: null);
}
// Named for the file rather than for the host, because one file is very often a dozen hosts and a
// key called "web-01" that four other machines also use is a name that misleads on five screens.
var key = new SshKeySecret
{
Label = Path.GetFileName(identity.Path),
PrivateKeyPem = identity.PrivateKeyPem,
PublicKey = identity.PublicKey,
Notes = $"Imported from {identity.Path} with {locator.ConfigPath}.",
};
KeyReport.Add(identity.IsEncrypted
? $"{row.Alias}: {identity.Path} was imported and is protected by a passphrase. Nothing on "
+ "disk says what it is, so add it to the key on the keychain screen or the host will not "
+ "connect."
: $"{row.Alias}: {identity.Path} was imported.");
return new ImportedHostRequest(host, key, identity.Path);
}
/// <summary>Ticks or unticks everything at once.</summary>
[RelayCommand]
private void ToggleAll()
@@ -209,6 +389,20 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
internal void NoteSelectionChanged() => RaiseListState();
/// <remarks>
/// The rows carry the answer as well as the view model, because each one says what it will authenticate
/// with and that sentence changes with the tick. Pushed rather than bound per row: a row cannot see a
/// property of the screen it is in without a parent binding, and that is markup for something the list
/// already has to be walked for.
/// </remarks>
partial void OnImportsKeysChanged(bool value)
{
foreach (var row in Rows)
{
row.ImportsItsKey = value && row.HasKeyFile;
}
}
/// <remarks>
/// Matched on where a host points rather than on what it is called. Two entries with different aliases
/// for one machine are the ordinary shape of an <c>ssh_config</c>, and matching on the name would offer
@@ -243,6 +437,8 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
{
OnPropertyChanged(nameof(HasRows));
OnPropertyChanged(nameof(HasWarnings));
OnPropertyChanged(nameof(HasKeyFiles));
OnPropertyChanged(nameof(HasKeyReport));
OnPropertyChanged(nameof(SelectedCount));
OnPropertyChanged(nameof(ImportLabel));
}
@@ -5782,14 +5782,15 @@ internal sealed partial class VaultViewModel(
}
/// <summary>
/// Stores several hosts at once, the way one save does.
/// Stores several hosts at once, the way one save does, with the keys they named where those are coming
/// too.
/// </summary>
/// <remarks>
/// <para>
/// For the <c>ssh_config</c> import, which is the only thing that produces hosts in bulk. It goes
/// through the same repository, the same outbox and the same automatic push as saving one — the import
/// screen decides <em>which</em> hosts and nothing else, so there is no second way for a host to be
/// written and no second place for the sync wiring to be forgotten.
/// through the same repositories, the same outbox and the same automatic push as saving one — the import
/// screen decides <em>which</em> hosts and nothing else, so there is no second way for a host or a key to
/// be written and no second place for the sync wiring to be forgotten.
/// </para>
/// <para>
/// One reload and one push for the whole batch, rather than per host: thirty saves would otherwise be
@@ -5800,47 +5801,142 @@ internal sealed partial class VaultViewModel(
/// A host that fails validation is skipped and counted rather than aborting the batch. Twenty-nine good
/// hosts thrown away because the thirtieth had no hostname is not what anybody wants from an import.
/// </para>
/// <para>
/// ◆ <b>One vault key per file, however many hosts named it.</b> An <c>ssh_config</c> pointing twelve
/// entries at <c>~/.ssh/id_ed25519</c> is the ordinary shape, and twelve copies of one private key would
/// be twelve things to rotate and eleven to forget — which is the argument <c>HostSecret.SshKeyId</c>
/// already makes for referencing a key rather than embedding it. The path is what identifies a file here,
/// because it is the only thing the config gave that two entries can be compared on.
/// </para>
/// <para>
/// <b>A key whose material is already in the keychain is bound to rather than stored again</b>, which is
/// what makes running the import twice harmless. Compared on the armour verbatim, as the codec stores it:
/// a re-import of the same file is byte-identical, and anything that is not is a different key whatever
/// it is called.
/// </para>
/// </remarks>
internal async Task<int> ImportHostsAsync(
IReadOnlyList<HostSecret> hosts,
internal async Task<ImportOutcome> ImportHostsAsync(
IReadOnlyList<ImportedHostRequest> requests,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(hosts);
ArgumentNullException.ThrowIfNull(requests);
var imported = 0;
var hosts = 0;
var keys = 0;
var refused = 0;
await RunAsync(
hosts.Count == 1 ? "Importing 1 host…" : $"Importing {hosts.Count} hosts…",
requests.Count == 1 ? "Importing 1 host…" : $"Importing {requests.Count} hosts…",
async () =>
{
foreach (var host in hosts)
// Keyed on the path rather than on the material, and filled as the batch runs rather than up
// front: the second host naming a file has to land on the id the first one produced, and
// nothing is reloaded until the batch finishes.
var bound = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
foreach (var request in requests)
{
if (!host.TryValidate(out _))
if (!request.Host.TryValidate(out _))
{
refused++;
continue;
}
var (keyId, created) = await BindImportedKeyAsync(request, bound, cancellationToken)
.ConfigureAwait(true);
if (created)
{
keys++;
}
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
await session.Hosts
.CreateAsync(session.ActiveVaultId, host, cancellationToken)
.ConfigureAwait(true);
imported++;
hosts++;
}
await ReloadAsync(cancellationToken).ConfigureAwait(true);
var refusals = refused == 0 ? string.Empty : $" {refused} could not be stored and were skipped.";
Status = connection() is null
? $"Imported {imported} host(s). They will sync when you are online.{refusals}"
: $"Imported {imported} host(s).{refusals}";
Status = DescribeImport(hosts, keys, refused);
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
return imported;
return new ImportOutcome(hosts, keys);
}
/// <summary>Finds or stores the key an imported host should be bound to.</summary>
/// <returns>
/// The key to bind to, and whether storing it was what put it there. The second half is what the count
/// reported afterwards is about: binding to a key the keychain already held reads nothing off a disk and
/// must not be reported as having.
/// </returns>
/// <remarks>
/// The keychain is searched as well as this batch's own map, and both are needed. The map answers within
/// one run, where nothing has been reloaded yet; the keychain answers for a run an hour ago, which is
/// what makes importing the same config twice leave one key rather than two.
/// </remarks>
private async Task<(Guid? Id, bool Created)> BindImportedKeyAsync(
ImportedHostRequest request,
Dictionary<string, Guid> bound,
CancellationToken cancellationToken)
{
if (request.Key is not { } key || request.KeyPath is not { } path)
{
return (null, false);
}
if (bound.TryGetValue(path, out var already))
{
return (already, false);
}
if (Keys.FirstOrDefault(row => string.Equals(
row.Key.PrivateKeyPem, key.PrivateKeyPem, StringComparison.Ordinal)) is { } existing)
{
bound[path] = existing.EntityId;
return (existing.EntityId, false);
}
if (!key.TryValidate(out _))
{
return (null, false);
}
var id = await session.SshKeys
.CreateAsync(session.ActiveVaultId, key, cancellationToken)
.ConfigureAwait(true);
bound[path] = id;
return (id, true);
}
/// <summary>What the import did, in one sentence.</summary>
/// <remarks>
/// The keys are named separately from the hosts and only when there are any, because importing key
/// material is the half of this somebody agreed to rather than the half they asked for — folded into
/// "imported 12 hosts" it would be the one number worth saying out loud, said quietly.
/// </remarks>
private string DescribeImport(int hosts, int keys, int refused)
{
var refusals = refused == 0 ? string.Empty : $" {refused} could not be stored and were skipped.";
var material = keys switch
{
0 => string.Empty,
1 => " 1 private key came with them.",
_ => $" {keys} private keys came with them.",
};
return connection() is null
? $"Imported {hosts} host(s). They will sync when you are online.{material}{refusals}"
: $"Imported {hosts} host(s).{material}{refusals}";
}
/// <summary>
@@ -3480,6 +3480,230 @@ public sealed class ShellFlowTests : IAsyncLifetime
server.LiveRowCount.ShouldBe(2);
}
/// <remarks>
/// ◆ <b>The tick, and the half of it that matters is what happens with it off.</b> Reading a private key
/// out of a home directory is the act this product exists to make deliberate, so the default has to be a
/// default nobody arrives at by accident — and a scan that read keys in order to describe them would
/// have already done the thing the tick gates. So this asserts twice over: nothing is bound, and the
/// report that names every file it opened is empty.
/// </remarks>
[Fact]
public async Task ImportingWithoutTheKeyTick_RecordsThePathAndReadsNothing()
{
await UnlockedAsync();
var vault = shell.Vault!;
var sshDirectory = KeyedConfigDirectory();
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
await import.ScanCommand.ExecuteAsync(null);
import.HasKeyFiles.ShouldBeTrue("the tick is drawn only where there is a key to read");
import.ImportsKeys.ShouldBeFalse("and it starts off");
await import.ImportCommand.ExecuteAsync(null);
vault.Keys.ShouldBeEmpty("nothing reached into ~/.ssh");
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull();
import.KeyReport.ShouldBeEmpty("and no file was opened to report on");
// The path is still recorded, which is what the import did before the tick existed.
vault.Hosts[0].Host.Notes.ShouldContain("id_ed25519");
}
/// <remarks>
/// The other side of it: with the tick on, the key is read, stored encrypted in the vault and bound to
/// the host — which is the difference between an import whose result connects and one whose every host
/// asks for a password.
/// </remarks>
[Fact]
public async Task ImportingWithTheKeyTick_StoresTheKeyAndBindsTheHostToIt()
{
await UnlockedAsync();
var vault = shell.Vault!;
var sshDirectory = KeyedConfigDirectory();
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
await import.ScanCommand.ExecuteAsync(null);
import.ImportsKeys = true;
// The row says what it will do, which is the only place a person sees the tick's effect per host.
import.Rows.ShouldHaveSingleItem().Authentication.ShouldContain("imported");
await import.ImportCommand.ExecuteAsync(null);
var key = vault.Keys.ShouldHaveSingleItem();
key.Key.PrivateKeyPem.ShouldContain("PRIVATE KEY");
key.Key.PublicKey.ShouldBe("ssh-ed25519 AAAAC3Nz nobody@example");
key.Key.Notes.ShouldContain("id_ed25519");
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(key.EntityId);
vault.Hosts[0].Authentication.ShouldBe("key");
import.KeyReport.ShouldHaveSingleItem().ShouldContain("imported");
import.Status.ShouldContain("1 private key");
}
/// <remarks>
/// <para>
/// One vault key per file, however many entries named it — the shape a real <c>ssh_config</c> actually
/// has. Twelve copies of one private key would be twelve things to rotate and eleven to forget, which is
/// the argument <c>HostSecret.SshKeyId</c> already makes for referencing a key rather than embedding it.
/// </para>
/// <para>
/// The second half is the one that would go unnoticed: importing the same config twice must bind to the
/// key that is already there rather than storing a second copy of it. Asserted by running the whole
/// import again over a keychain that now holds one.
/// </para>
/// </remarks>
[Fact]
public async Task TwoHostsNamingOneKeyFile_ShareOneStoredKeyAndARepeatAddsNone()
{
await UnlockedAsync();
var vault = shell.Vault!;
var sshDirectory = KeyedConfigDirectory("""
Host web-01
HostName web-01.internal
User deploy
IdentityFile ~KEY~
Host web-02
HostName web-02.internal
User deploy
IdentityFile ~KEY~
""");
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
await import.ScanCommand.ExecuteAsync(null);
import.ImportsKeys = true;
await import.ImportCommand.ExecuteAsync(null);
var key = vault.Keys.ShouldHaveSingleItem("one file is one key");
vault.Hosts.Count.ShouldBe(2);
vault.Hosts.ShouldAllBe(row => row.Host.SshKeyId == key.EntityId);
import.Status.ShouldContain("1 private key");
// Again, over a keychain that already holds it. The hosts duplicate — a second bookmark for one
// machine is allowed and takes a click — and the key must not.
foreach (var row in import.Rows)
{
row.IsSelected = true;
}
await import.ImportCommand.ExecuteAsync(null);
vault.Keys.ShouldHaveSingleItem("the material was already here, so it was bound to rather than stored");
import.Status.ShouldNotContain("private key", Case.Insensitive);
}
/// <remarks>
/// A passphrase is the one thing about a key file that is not in the key file, so an encrypted one comes
/// in without it and would fail at connect time with SSH.NET's own message. The import says so instead —
/// naming the file, because a config with a dozen keys has a dozen candidates for which one it was.
/// </remarks>
[Fact]
public async Task AnEncryptedKeyIsImportedAndSaidToBeEncrypted()
{
await UnlockedAsync();
var vault = shell.Vault!;
var sshDirectory = KeyedConfigDirectory();
await File.WriteAllTextAsync(
Path.Combine(sshDirectory, "id_ed25519"),
"-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-128-CBC,00\n\nQUJD\n"
+ "-----END RSA PRIVATE KEY-----\n",
Token);
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
await import.ScanCommand.ExecuteAsync(null);
import.ImportsKeys = true;
await import.ImportCommand.ExecuteAsync(null);
vault.Keys.ShouldHaveSingleItem().Key.Passphrase.ShouldBeNull("nothing on disk says what it is");
var reported = import.KeyReport.ShouldHaveSingleItem();
reported.ShouldContain("passphrase");
reported.ShouldContain("id_ed25519");
}
/// <remarks>
/// A config carried from another machine names keys that are not on this one, and that is the ordinary
/// case rather than an error. The host still imports — unbound, exactly as it would have without the
/// tick — and the report says which file was missing, because otherwise the only symptom is a host that
/// asks for a password on a screen that said it would not.
/// </remarks>
[Fact]
public async Task AKeyFileThatIsNotThere_LeavesTheHostImportedAndUnbound()
{
await UnlockedAsync();
var vault = shell.Vault!;
var sshDirectory = KeyedConfigDirectory();
File.Delete(Path.Combine(sshDirectory, "id_ed25519"));
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
await import.ScanCommand.ExecuteAsync(null);
import.ImportsKeys = true;
await import.ImportCommand.ExecuteAsync(null);
vault.Keys.ShouldBeEmpty();
vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull();
import.KeyReport.ShouldHaveSingleItem().ShouldContain("no such file");
}
/// <summary>
/// A <c>~/.ssh</c> holding a config that names a key, and the key beside it.
/// </summary>
/// <remarks>
/// The key path is written into the config as an absolute one, which is what
/// <c>SshConfigResolver.ExpandHome</c> produces for a real <c>~/.ssh/id_ed25519</c> — the tests cannot
/// use a tilde, since that would resolve against the profile of whoever is running them.
/// </remarks>
private string KeyedConfigDirectory(string? config = null)
{
var sshDirectory = Path.Combine(directory, $"ssh-{Guid.CreateVersion7():N}");
Directory.CreateDirectory(sshDirectory);
var keyPath = Path.Combine(sshDirectory, "id_ed25519");
File.WriteAllText(keyPath, PrivateKey("QUJDRA"));
File.WriteAllText(keyPath + ".pub", "ssh-ed25519 AAAAC3Nz nobody@example\n");
var text = config ?? """
Host web-01
HostName web-01.internal
User deploy
IdentityFile ~KEY~
""";
File.WriteAllText(
Path.Combine(sshDirectory, "config"),
text.Replace("~KEY~", keyPath, StringComparison.Ordinal));
return sshDirectory;
}
[Fact]
public async Task ImportingWithNoConfigFile_SaysSoRatherThanFailing()
{
@@ -4047,7 +4271,12 @@ public sealed class ShellFlowTests : IAsyncLifetime
var groupId = Guid.CreateVersion7();
await vault.ImportHostsAsync(
[new HostSecret { Label = "prod-db", Hostname = "db.internal", GroupId = groupId }],
[
new ImportedHostRequest(
new HostSecret { Label = "prod-db", Hostname = "db.internal", GroupId = groupId },
Key: null,
KeyPath: null),
],
Token);
vault.Groups.ShouldBeEmpty("nothing in this keychain answers to that id");
@@ -0,0 +1,224 @@
using System.Buffers.Binary;
using System.Text;
namespace DodoSSH.Client.Import.Tests;
/// <summary>
/// Reading the private keys an <c>ssh_config</c> names, and telling the protected ones apart.
/// </summary>
/// <remarks>
/// <para>
/// The encryption check is what most of this is about, and it earns the attention: a key imported without a
/// passphrase it needs is a host that fails at connect time with a message about a malformed key, and
/// nothing in the interface would say which of the fifteen files it was. So the three formats are each
/// pinned, and so is the one that answers by decoding rather than by reading a header.
/// </para>
/// <para>
/// The rest is refusals. Every one of them is a row on a screen rather than an exception — a config carried
/// from another machine names keys that are not on this one, which is the ordinary case, and one bad path
/// must not stop the other thirty-nine entries being imported.
/// </para>
/// </remarks>
public sealed class SshIdentityFileTests
{
[Fact]
public void AKeyBesideItsPublicHalf_ComesBackWithBoth()
{
using var home = new TemporaryDirectory();
var path = home.Write("id_ed25519", OpenSshKey("none"));
home.Write("id_ed25519.pub", "ssh-ed25519 AAAAC3Nz nobody@example\n");
var read = SshIdentityFiles.Read(path, home.Path);
read.WasRead.ShouldBeTrue(read.Failure);
read.PublicKey.ShouldBe("ssh-ed25519 AAAAC3Nz nobody@example");
read.IsEncrypted.ShouldBeFalse();
read.Failure.ShouldBeNull();
}
/// <remarks>
/// A missing <c>.pub</c> costs the public half and nothing else. It is optional on
/// <c>SshKeySecret</c> for the reason written there — it cannot be derived without parsing every format
/// the key is stored verbatim in — so its absence is not a failure and must not read as one.
/// </remarks>
[Fact]
public void AKeyWithNoPublicHalf_IsStillRead()
{
using var home = new TemporaryDirectory();
var read = SshIdentityFiles.Read(home.Write("id_ed25519", OpenSshKey("none")), home.Path);
read.WasRead.ShouldBeTrue(read.Failure);
read.PublicKey.ShouldBeNull();
}
/// <remarks>
/// A relative <c>IdentityFile</c> resolves against <c>~/.ssh</c>, which is OpenSSH's own rule and is why
/// this call takes the directory at all. <c>SshConfigResolver</c> expands a tilde and leaves everything
/// else alone, so a bare <c>id_rsa</c> arrives here exactly as it was written.
/// </remarks>
[Fact]
public void ARelativePath_ResolvesAgainstTheSshDirectory()
{
using var home = new TemporaryDirectory();
home.Write("id_rsa", OpenSshKey("none"));
var read = SshIdentityFiles.Read("id_rsa", home.Path);
read.WasRead.ShouldBeTrue(read.Failure);
read.Path.ShouldBe(Path.Combine(home.Path, "id_rsa"));
}
/// <remarks>
/// ◆ The one that decodes rather than reading a header. OpenSSH's own container says nothing about
/// encryption in its armour — the cipher name is the first field inside the base64 — and it is what
/// <c>ssh-keygen</c> has written by default for years, so it is the case that actually turns up.
/// </remarks>
[Theory]
[InlineData("none", false)]
[InlineData("aes256-ctr", true)]
[InlineData("aes256-gcm@openssh.com", true)]
public void AnOpenSshKey_IsJudgedByTheCipherInsideIt(string cipher, bool encrypted)
{
using var home = new TemporaryDirectory();
var read = SshIdentityFiles.Read(home.Write("id_ed25519", OpenSshKey(cipher)), home.Path);
read.WasRead.ShouldBeTrue(read.Failure);
read.IsEncrypted.ShouldBe(encrypted);
}
/// <remarks>
/// The two formats that do say so in the armour. Classic PEM carries a <c>Proc-Type</c> line above the
/// base64; PKCS#8 puts it in the header outright.
/// </remarks>
[Theory]
[InlineData("-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-128-CBC,00\n\nQUJD\n-----END RSA PRIVATE KEY-----\n")]
[InlineData("-----BEGIN ENCRYPTED PRIVATE KEY-----\nQUJD\n-----END ENCRYPTED PRIVATE KEY-----\n")]
public void AnArmourThatSaysItIsEncrypted_IsTakenAtItsWord(string material)
{
using var home = new TemporaryDirectory();
var read = SshIdentityFiles.Read(home.Write("id_rsa", material), home.Path);
read.WasRead.ShouldBeTrue(read.Failure);
read.IsEncrypted.ShouldBeTrue();
}
/// <remarks>
/// <c>ssh</c> tolerates an <c>IdentityFile</c> naming the <c>.pub</c> — it looks for the private half
/// beside it — so people write it. Importing the public one would produce a keychain entry that looks
/// fine and fails at connect time saying nothing about which of two files whose names differ by four
/// characters was chosen, which is the mistake <c>SshKeySecret.TryValidate</c> exists to catch.
/// </remarks>
[Fact]
public void ThePublicHalfNamedAsTheIdentityFile_IsRefusedByName()
{
using var home = new TemporaryDirectory();
var path = home.Write("id_ed25519.pub", "ssh-ed25519 AAAAC3Nz nobody@example\n");
var read = SshIdentityFiles.Read(path, home.Path);
read.WasRead.ShouldBeFalse();
read.Failure.ShouldContain("public half");
}
[Fact]
public void AFileThatIsNotAKeyAtAll_IsRefused()
{
using var home = new TemporaryDirectory();
var read = SshIdentityFiles.Read(home.Write("notes.txt", "remember to rotate this"), home.Path);
read.WasRead.ShouldBeFalse();
read.Failure.ShouldContain("private key");
}
/// <remarks>
/// The ordinary case for a config carried between machines, and the reason none of this throws: the
/// path is still named, so the import screen can say which entry lost its key rather than reporting
/// that something somewhere went wrong.
/// </remarks>
[Fact]
public void APathThatIsNotThere_NamesItselfInTheRefusal()
{
using var home = new TemporaryDirectory();
var read = SshIdentityFiles.Read("id_absent", home.Path);
read.WasRead.ShouldBeFalse();
read.Failure.ShouldContain("no such file");
read.Path.ShouldContain("id_absent");
}
/// <summary>
/// An armoured OpenSSH v1 private key whose cipher field says what is asked for.
/// </summary>
/// <remarks>
/// Padded past 66 bytes because the reader decodes the first 88 base64 characters and needs the magic
/// and the first field inside them — a container shorter than that is not a shape <c>ssh-keygen</c>
/// produces, and treating one as unencrypted is the safe direction anyway.
/// </remarks>
private static string OpenSshKey(string cipher)
{
var body = new List<byte>();
body.AddRange("openssh-key-v1\0"u8);
foreach (var field in new[] { cipher, "none", string.Empty })
{
var length = new byte[4];
BinaryPrimitives.WriteUInt32BigEndian(length, (uint)field.Length);
body.AddRange(length);
body.AddRange(Encoding.ASCII.GetBytes(field));
}
while (body.Count < 96)
{
body.Add(0x41);
}
var armour = Convert.ToBase64String(body.ToArray());
return $"-----BEGIN OPENSSH PRIVATE KEY-----\n{armour}\n-----END OPENSSH PRIVATE KEY-----\n";
}
/// <summary>A directory standing in for <c>~/.ssh</c>, removed when the test finishes.</summary>
private sealed class TemporaryDirectory : IDisposable
{
internal TemporaryDirectory()
{
Path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"dodossh-identity-{Guid.CreateVersion7():N}");
Directory.CreateDirectory(Path);
}
internal string Path { get; }
internal string Write(string name, string content)
{
var path = System.IO.Path.Combine(Path, name);
File.WriteAllText(path, content);
return path;
}
public void Dispose()
{
try
{
Directory.Delete(Path, recursive: true);
}
catch (IOException)
{
// A test's leftovers in the system temp directory are not worth failing a run over.
}
}
}
}