using System.Buffers.Binary;
using System.Text;
namespace DodoSSH.Client.Import.Tests;
///
/// Reading the private keys an ssh_config names, and telling the protected ones apart.
///
///
///
/// 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.
///
///
/// 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.
///
///
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();
}
///
/// A missing .pub costs the public half and nothing else. It is optional on
/// SshKeySecret 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.
///
[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();
}
///
/// A relative IdentityFile resolves against ~/.ssh, which is OpenSSH's own rule and is why
/// this call takes the directory at all. SshConfigResolver expands a tilde and leaves everything
/// else alone, so a bare id_rsa arrives here exactly as it was written.
///
[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"));
}
///
/// ◆ 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
/// ssh-keygen has written by default for years, so it is the case that actually turns up.
///
[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);
}
///
/// The two formats that do say so in the armour. Classic PEM carries a Proc-Type line above the
/// base64; PKCS#8 puts it in the header outright.
///
[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();
}
///
/// ssh tolerates an IdentityFile naming the .pub — 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 SshKeySecret.TryValidate exists to catch.
///
[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");
}
///
/// 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.
///
[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");
}
///
/// An armoured OpenSSH v1 private key whose cipher field says what is asked for.
///
///
/// 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 ssh-keygen
/// produces, and treating one as unencrypted is the safe direction anyway.
///
private static string OpenSshKey(string cipher)
{
var body = new List();
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";
}
/// A directory standing in for ~/.ssh, removed when the test finishes.
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.
}
}
}
}