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