Files
DodoSSH/tests/DodoSSH.Client.Import.Tests/SshIdentityFileTests.cs
T
jaap-jan ca07d63585
ci / build and test (push) Successful in 2m0s
ci / android head (push) Successful in 6m5s
ci / api image (push) Successful in 42s
Offer to bring the keys an ssh_config points at
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.
2026-08-05 08:58:28 +02:00

225 lines
8.3 KiB
C#

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.
}
}
}
}