using System.Text; using Renci.SshNet; namespace DodoSSH.Client.Ssh.Tests; /// /// Whether a key this client generates is a key anything else recognises. /// /// /// /// The container is hand-written — see for why there was no alternative — /// so the only question that matters is whether a real parser accepts it. SSH.NET's /// PrivateKeyFile is the parser this application actually hands the key to, which makes it the right /// oracle: a file that satisfies a test of my own encoding and then fails there would be a test agreeing /// with the bug. /// /// /// The stronger proof is in KeyAuthenticationTests, which installs a generated public line on a real /// sshd in a container and connects with it. This class is what fails first and reads clearly when the /// encoding is wrong. /// /// public sealed class SshKeyGeneratorTests { [Theory] [InlineData(SshKeyAlgorithm.Ed25519)] [InlineData(SshKeyAlgorithm.Rsa4096)] public void AGeneratedKey_IsOneSshNetCanLoad(SshKeyAlgorithm algorithm) { var generated = SshKeyGenerator.Generate(algorithm, "dodossh@test"); using var armour = new MemoryStream(Encoding.ASCII.GetBytes(generated.PrivateKeyArmour)); // No passphrase, which is this writer's whole stated limitation. A throw here is the encoding being // wrong, not the key. var parsed = new PrivateKeyFile(armour); parsed.HostKeyAlgorithms.ShouldNotBeEmpty(); } /// /// SshKeySecret.TryValidate refuses anything that does not begin -----BEGIN, and refuses /// anything beginning ssh- outright because that is a pasted .pub file — the mistake /// people actually make. Asserting it here in those terms rather than by constructing the secret: this /// project deliberately has no reference to DodoSSH.Client.Domain, which is what keeps the SSH /// layer testable without a cache or a keychain, and one convenience is not worth spending it. /// [Theory] [InlineData(SshKeyAlgorithm.Ed25519)] [InlineData(SshKeyAlgorithm.Rsa4096)] public void AGeneratedKey_IsOneTheKeychainWillStore(SshKeyAlgorithm algorithm) { var generated = SshKeyGenerator.Generate(algorithm, "dodossh@test"); generated.PrivateKeyArmour.TrimStart().ShouldStartWith("-----BEGIN"); generated.PrivateKeyArmour.TrimStart().ShouldNotStartWith("ssh-"); generated.PublicKeyLine.ShouldStartWith("ssh-", Case.Sensitive); } [Fact] public void TheEd25519PublicLine_IsWhatOpenSshWrites() { var generated = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, "dodossh@test"); var parts = generated.PublicKeyLine.Split(' '); parts.Length.ShouldBe(3); parts[0].ShouldBe("ssh-ed25519"); parts[2].ShouldBe("dodossh@test"); // 4-byte length + "ssh-ed25519" + 4-byte length + 32-byte point. Convert.FromBase64String(parts[1]).Length.ShouldBe(4 + 11 + 4 + 32); } [Fact] public void TheFingerprint_IsTheOneSshKeygenWouldPrint() { var generated = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, "dodossh@test"); generated.Fingerprint.ShouldStartWith("SHA256:"); generated.Fingerprint.ShouldNotEndWith("=", Case.Sensitive); // Taken over the same blob the public line carries, which is the whole definition of an OpenSSH // fingerprint. Computing it over anything else — the armour, the file, the raw point — produces a // string that looks right and never matches what a server reports. var blob = Convert.FromBase64String(generated.PublicKeyLine.Split(' ')[1]); generated.Fingerprint.ShouldBe(SshHostKeyFingerprint.Format(blob)); } /// /// The classic openssh-key-v1 bug, and the reason it is worth twenty cases rather than one: the /// private section is padded to a multiple of eight, and the comment is the last thing in it. So whether /// the padding is right depends on how long the comment happens to be — a name that lands on a boundary /// produces a file every parser loads, and one character more produces one that none of them do. /// [Fact] public void TheContainerIsPaddedCorrectlyForACommentOfAnyLength() { for (var length = 0; length < 20; length++) { var comment = new string('c', length); var generated = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, comment); using var armour = new MemoryStream(Encoding.ASCII.GetBytes(generated.PrivateKeyArmour)); Should.NotThrow( () => new PrivateKeyFile(armour), $"a comment of {length} characters must not change whether the key parses"); } } [Fact] public void TwoGeneratedKeys_AreDifferent() { var first = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, "dodossh@test"); var second = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, "dodossh@test"); string.Equals(first.Fingerprint, second.Fingerprint, StringComparison.Ordinal) .ShouldBeFalse("two generated keys must not share a fingerprint"); string.Equals(first.PrivateKeyArmour, second.PrivateKeyArmour, StringComparison.Ordinal) .ShouldBeFalse("nor any of their material"); } }