diff --git a/src/Renci.SshNet.Tests/Classes/KnownHostStoreTests.cs b/src/Renci.SshNet.Tests/Classes/KnownHostStoreTests.cs new file mode 100644 index 000000000..021acaec0 --- /dev/null +++ b/src/Renci.SshNet.Tests/Classes/KnownHostStoreTests.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Renci.SshNet.Common; + +namespace Renci.SshNet.Tests.Classes +{ + [TestClass] + public class KnownHostStoreTests + { + private static readonly byte[] ExampleKey = new byte[] + { + 0x00, 0x11, 0x22, 0x33, 0x44, + 0x55, 0x66, 0x77, 0x88, 0x99, + 0xaa, 0xbb, 0xcc, 0xdd, 0xee, + 0xff + }; + + private const string ExampleHost = "some.domain.com"; + private const string ExampleSubDomain = "*.domain.com"; + private const string ExampleKeyType = "rsa"; + + [TestMethod] + public void TestKnownHostStoreFindsHostIfMatch() + { + KnownHostStore hostStore = new KnownHostStore(); + hostStore.AddHost(ExampleHost, 22, ExampleKeyType, ExampleKey, false); + Assert.IsTrue(hostStore.Knows(ExampleHost, ExampleKeyType, ExampleKey, 22)); + } + + [TestMethod] + public void TestKnownHostStoreFindsHashedHostIfMatch() + { + KnownHostStore hostStore = new KnownHostStore(); + hostStore.AddHost(ExampleHost, 22, ExampleKeyType, ExampleKey, true); + Assert.IsTrue(hostStore.Knows(ExampleHost, ExampleKeyType, ExampleKey, 22)); + } + + [TestMethod] + [ExpectedException(typeof(RevokedKeyException), "Did not throw an exception upon finding a revoked key in the store")] + public void TestKnownHostStoreRejectsKeyIfRevoked() + { + KnownHostStore hostStore = new KnownHostStore(); + hostStore.AddHost(ExampleHost, 22, ExampleKeyType, ExampleKey, false); + hostStore.AddHost(ExampleSubDomain, 22, ExampleKeyType, ExampleKey, false, "@revoked"); + + hostStore.Knows(ExampleHost, ExampleKeyType, ExampleKey, 22); + } + } +} diff --git a/src/Renci.SshNet.Tests/Classes/KnownHostTest.cs b/src/Renci.SshNet.Tests/Classes/KnownHostTest.cs new file mode 100644 index 000000000..05572be5a --- /dev/null +++ b/src/Renci.SshNet.Tests/Classes/KnownHostTest.cs @@ -0,0 +1,428 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Renci.SshNet.Security.Cryptography; +using System; +using System.Text; + +namespace Renci.SshNet.Tests.Classes +{ + [TestClass] + public class KnownHostTest + { + private const string ExampleHost1 = "example.com"; + private const string ExampleHost2 = "test.web.net"; + private const string ExampleAlgo = "some-key-algorithm"; + + private static readonly byte[] ExampleKey = new byte[] + { + 0x00, 0x11, 0x22, 0x33, 0x44, + 0x55, 0x66, 0x77, 0x88, 0x99, + 0xaa, 0xbb, 0xcc, 0xdd, 0xee, + 0xff + }; + private static readonly string Base64ExampleKey = Convert.ToBase64String(ExampleKey); + + private static readonly byte[] ExampleSalt = new byte[] + { + 0x00, 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, 0x09, + 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13 + }; + private static readonly string Base64ExampleSalt = Convert.ToBase64String(ExampleSalt); + private const string ExampleHostHashed = "nnUK16ANsXd3hL31YfAkGOluSjU="; + + [TestMethod] + public void KnownHostCanParsePlaintextHostEntry() + { + string expectedPlaintextHostEntry = + string.Format("{0} {1} {2}", ExampleHost1, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + + Assert.IsTrue(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.Matches, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, ExampleKey, 22)); + Assert.AreEqual(expectedPlaintextHostEntry, parsedHost.ToString()); + } + + [TestMethod] + public void KnownHostCanParseHashedHostEntry() + { + string expectedHashedHostEntry = + string.Format("|1|{0}|{1} {2} {3}", Base64ExampleSalt, ExampleHostHashed, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + Assert.IsTrue(KnownHost.TryParse(expectedHashedHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.Matches, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, ExampleKey, 22)); + Assert.AreEqual(expectedHashedHostEntry, parsedHost.ToString()); + } + + [TestMethod] + public void KnownHostCanParsePlaintextHostEntryWithTrailingComments() + { + string expectedPlaintextHostEntry = + string.Format("{0} {1} {2} nonagon infinity opens the door", ExampleHost1, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + + Assert.IsTrue(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.Matches, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, ExampleKey, 22)); + } + + [TestMethod] + public void KnownHostCanParseHashedHostEntryWithTrailingComments() + { + string expectedHashedHostEntry = + string.Format("|1|{0}|{1} {2} {3} wait for the answer to open the door", Base64ExampleSalt, ExampleHostHashed, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + Assert.IsTrue(KnownHost.TryParse(expectedHashedHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.Matches, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, ExampleKey, 22)); + } + + [TestMethod] + public void KnownHostRejectsInvalidHost() + { + string expectedPlaintextHostEntry = + string.Format("{0} {1} {2}", ExampleHost1, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + + Assert.IsTrue(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.DoesNoMatch, parsedHost.MatchesPubKey("MaliciousHost.evil", ExampleAlgo, ExampleKey, 22)); + } + + [TestMethod] + public void KnownHostRejectsInvalidKeyAlgo() + { + string expectedPlaintextHostEntry = + string.Format("{0} {1} {2}", ExampleHost1, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + + Assert.IsTrue(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.DoesNoMatch, parsedHost.MatchesPubKey(ExampleHost1, "failingAlgo", ExampleKey, 22)); + } + + [TestMethod] + public void KnownHostRejectsInvalidKey() + { + string expectedPlaintextHostEntry = + string.Format("{0} {1} {2}", ExampleHost1, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + + Assert.IsTrue(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.DoesNoMatch, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, new byte[]{0x00}, 22)); + } + + [TestMethod] + public void KnownHostRejectsInvalidPort() + { + string expectedPlaintextHostEntry = + string.Format("{0} {1} {2}", ExampleHost1, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + + Assert.IsTrue(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.DoesNoMatch, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, ExampleKey, 9001)); + } + + [TestMethod] + public void KnownHostCanParsePlaintextHostEntryWithNonstandardPort() + { + string expectedPlaintextHostEntry = + string.Format("[{0}:9001] {1} {2}", ExampleHost1, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + + Assert.IsTrue(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.DoesNoMatch, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, ExampleKey, 22)); + Assert.AreEqual(KnownHost.HostValidationResponse.Matches, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, ExampleKey, 9001)); + Assert.AreEqual(expectedPlaintextHostEntry, parsedHost.ToString()); + } + + [TestMethod] + public void KnownHostCanParsePlaintextHostEntryWithBeginningGlob() + { + string expectedPlaintextHostEntry = + string.Format("*.com {0} {1}", ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + + Assert.IsTrue(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.Matches, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, ExampleKey, 22)); + Assert.AreEqual(expectedPlaintextHostEntry, parsedHost.ToString()); + } + + [TestMethod] + public void KnownHostCanParsePlaintextHostEntryWithEndingGlob() + { + string expectedPlaintextHostEntry = + string.Format("example.* {0} {1}", ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + + Assert.IsTrue(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.Matches, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, ExampleKey, 22)); + Assert.AreEqual(expectedPlaintextHostEntry, parsedHost.ToString()); + } + + [TestMethod] + public void KnownHostCanParsePlaintextHostEntryWithMultipleGlobs() + { + string expectedPlaintextHostEntry = + string.Format("ex*le.* {0} {1}", ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + + Assert.IsTrue(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.Matches, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, ExampleKey, 22)); + Assert.AreEqual(expectedPlaintextHostEntry, parsedHost.ToString()); + } + + [TestMethod] + public void KnownHostCanParseMultipleHostPatterns() + { + + string expectedPlaintextHostEntry = + string.Format("{0},{1} {2} {3}", ExampleHost1, ExampleHost2, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + + Assert.IsTrue(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.Matches, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, ExampleKey, 22)); + Assert.AreEqual(KnownHost.HostValidationResponse.Matches, parsedHost.MatchesPubKey(ExampleHost2, ExampleAlgo, ExampleKey, 22)); + Assert.AreEqual(expectedPlaintextHostEntry, parsedHost.ToString()); + } + + [TestMethod] + public void KnownHostSupportsExcludedPatterns() + { + + string expectedPlaintextHostEntry = + string.Format("example.*,!*.com {0} {1}", ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + + Assert.IsTrue(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.DoesNoMatch, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, ExampleKey, 22)); + Assert.AreEqual(KnownHost.HostValidationResponse.Matches, parsedHost.MatchesPubKey("example.net", ExampleAlgo, ExampleKey, 22)); + Assert.AreEqual(expectedPlaintextHostEntry, parsedHost.ToString()); + } + + [TestMethod] + public void KnownHostSupportsKeyRevocationForPlaintextHost() + { + string expectedPlaintextHostEntry = + string.Format("@revoked {0} {1} {2}", ExampleHost1, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + + Assert.IsTrue(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.KeyRevoked, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, ExampleKey, 22)); + Assert.AreEqual(expectedPlaintextHostEntry, parsedHost.ToString()); + } + + [TestMethod] + public void KnownHostSupportsKeyRevocationForHashedHost() + { + string expectedHashedHostEntry = + string.Format("@revoked |1|{0}|{1} {2} {3}", Base64ExampleSalt, ExampleHostHashed, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + Assert.IsTrue(KnownHost.TryParse(expectedHashedHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.KeyRevoked, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, ExampleKey, 22)); + Assert.AreEqual(expectedHashedHostEntry, parsedHost.ToString()); + } + + + [TestMethod] + public void KnownHostRecognizesCertAuthForPlaintextHost() + { + string expectedPlaintextHostEntry = + string.Format("@cert-authority {0} {1} {2}", ExampleHost1, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + + Assert.IsTrue(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.InvalidSignature, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, ExampleKey, 22)); + Assert.AreEqual(expectedPlaintextHostEntry, parsedHost.ToString()); + } + + + [TestMethod] + public void KnownHostRecognizesCertAuthForHashedHost() + { + string expectedHashedHostEntry = + string.Format("@cert-authority |1|{0}|{1} {2} {3}", Base64ExampleSalt, ExampleHostHashed, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + Assert.IsTrue(KnownHost.TryParse(expectedHashedHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.InvalidSignature, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, ExampleKey, 22)); + Assert.AreEqual(expectedHashedHostEntry, parsedHost.ToString()); + } + + [TestMethod] + public void KnownHostcanParseHasedHostEntryWithNonstandardPort() + { + string hostWithNonStandardPort = string.Format("[{0}:9001]", ExampleHost1); + HMACSHA1 hmac = new HMACSHA1(ExampleSalt); + byte[] hashedHost = hmac.ComputeHash(Encoding.ASCII.GetBytes(hostWithNonStandardPort)); + + string expectedHashedHostEntry = + string.Format("|1|{0}|{1} {2} {3}", Base64ExampleSalt, Convert.ToBase64String(hashedHost), ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + Assert.IsTrue(KnownHost.TryParse(expectedHashedHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.Matches, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, ExampleKey, 9001)); + Assert.AreEqual(expectedHashedHostEntry, parsedHost.ToString()); + } + + [TestMethod] + public void KnownHostcanParseHasedHostEntryWithRedundantPort() + { + string hostWithNonStandardPort = string.Format("[{0}:22]", ExampleHost1); + HMACSHA1 hmac = new HMACSHA1(ExampleSalt); + byte[] hashedHost = hmac.ComputeHash(Encoding.ASCII.GetBytes(hostWithNonStandardPort)); + + string expectedHashedHostEntry = + string.Format("|1|{0}|{1} {2} {3}", Base64ExampleSalt, Convert.ToBase64String(hashedHost), ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + Assert.IsTrue(KnownHost.TryParse(expectedHashedHostEntry, out parsedHost)); + Assert.AreEqual(KnownHost.HostValidationResponse.Matches, parsedHost.MatchesPubKey(ExampleHost1, ExampleAlgo, ExampleKey, 22)); + Assert.AreEqual(expectedHashedHostEntry, parsedHost.ToString()); + } + + [TestMethod] + public void KnownHostFailsToParseMalformedKey() + { + string expectedPlaintextHostEntry = + string.Format("{0} {1} {2}", ExampleHost1, ExampleAlgo, "This_Key_Not_Base_64"); + + KnownHost parsedHost; + + Assert.IsFalse(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + } + + [TestMethod] + public void KnownHostFailsIfSectionMissing() + { + string expectedPlaintextHostEntry = + string.Format("{0} {1}", ExampleHost1, ExampleAlgo); + + KnownHost parsedHost; + + Assert.IsFalse(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + } + + [TestMethod] + public void KnownHostFailsToParseEmptyLine() + { + string expectedPlaintextHostEntry = String.Empty; + + KnownHost parsedHost; + + Assert.IsFalse(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + } + + [TestMethod] + public void KnownHostFailsToParseCommentLine() + { + string expectedPlaintextHostEntry = + string.Format("#{0} {1} {2}", ExampleHost1, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + + Assert.IsFalse(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + } + + [TestMethod] + public void KnownHostFailsToParseLineWithOperandAndMissingSecion() + { + string expectedPlaintextHostEntry = + string.Format("@cert-authority {0} {1}", ExampleHost1, ExampleAlgo); + + KnownHost parsedHost; + + Assert.IsFalse(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + } + + [TestMethod] + public void KnownHostFailsToParseInvalidOperand() + { + string expectedPlaintextHostEntry = + string.Format("@fail-parsing {0} {1} {2}", ExampleHost1, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + + Assert.IsFalse(KnownHost.TryParse(expectedPlaintextHostEntry, out parsedHost)); + } + + [TestMethod] + public void KnownHostFailsToParseInvalidHashType() + { + string expectedHashedHostEntry = + string.Format("|2|{0}|{1} {2} {3}", Base64ExampleSalt, ExampleHostHashed, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + Assert.IsFalse(KnownHost.TryParse(expectedHashedHostEntry, out parsedHost)); + } + + [TestMethod] + public void KnownHostFailsToParseMalformedSalt() + { + string expectedHashedHostEntry = + string.Format("|1|{0}|{1} {2} {3}", "not_base_64", ExampleHostHashed, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + Assert.IsFalse(KnownHost.TryParse(expectedHashedHostEntry, out parsedHost)); + } + + [TestMethod] + public void KnownHostFailsToParseMalformedHash() + { + string expectedHashedHostEntry = + string.Format("|1|{0}|{1} {2} {3}", Base64ExampleSalt, "not_base_64", ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + Assert.IsFalse(KnownHost.TryParse(expectedHashedHostEntry, out parsedHost)); + } + + + [TestMethod] + public void KnownHostFailsToParseIfHashSectionMissing() + { + string expectedHashedHostEntry = + string.Format("|1|{0} {1} {2}", Base64ExampleSalt, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + Assert.IsFalse(KnownHost.TryParse(expectedHashedHostEntry, out parsedHost)); + } + + + [TestMethod] + public void KnownHostFailsToParseSaltTooShort() + { + byte[] shortSalt = new byte[19]; + + string expectedHashedHostEntry = + string.Format("|1|{0}|{1} {2} {3}", Convert.ToBase64String(shortSalt), ExampleHostHashed, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + Assert.IsFalse(KnownHost.TryParse(expectedHashedHostEntry, out parsedHost)); + } + + [TestMethod] + public void KnownHostFailsToParseSaltTooLong() + { + byte[] longSalt = new byte[21]; + + string expectedHashedHostEntry = + string.Format("|1|{0}|{1} {2} {3}", Convert.ToBase64String(longSalt), ExampleHostHashed, ExampleAlgo, Base64ExampleKey); + + KnownHost parsedHost; + Assert.IsFalse(KnownHost.TryParse(expectedHashedHostEntry, out parsedHost)); + } + } +} diff --git a/src/Renci.SshNet.Tests/Renci.SshNet.Tests.csproj b/src/Renci.SshNet.Tests/Renci.SshNet.Tests.csproj index aee9c21b4..230e8cbfc 100644 --- a/src/Renci.SshNet.Tests/Renci.SshNet.Tests.csproj +++ b/src/Renci.SshNet.Tests/Renci.SshNet.Tests.csproj @@ -224,6 +224,8 @@ + + diff --git a/src/Renci.SshNet/Common/RevokedKeyException.cs b/src/Renci.SshNet/Common/RevokedKeyException.cs new file mode 100644 index 000000000..c10312dcb --- /dev/null +++ b/src/Renci.SshNet/Common/RevokedKeyException.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +#if FEATURE_BINARY_SERIALIZATION +using System.Runtime.Serialization; +#endif // FEATURE_BINARY_SERIALIZATION + +namespace Renci.SshNet.Common +{ + /// + /// The exception that is thrown when SSH exception occurs. + /// +#if FEATURE_BINARY_SERIALIZATION + [Serializable] +#endif // FEATURE_BINARY_SERIALIZATION + public class RevokedKeyException : Exception + { + /// + /// Initializes a new instance of the class. + /// + public RevokedKeyException() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message. + public RevokedKeyException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message. + /// The inner. + public RevokedKeyException(string message, Exception inner) + : base(message, inner) + { + } + +#if FEATURE_BINARY_SERIALIZATION + /// + /// Initializes a new instance of the class. + /// + /// The that holds the serialized object data about the exception being thrown. + /// The that contains contextual information about the source or destination. + /// The parameter is null. + /// The class name is null or is zero (0). + protected RevokedKeyException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } +#endif // FEATURE_BINARY_SERIALIZATION + } +} diff --git a/src/Renci.SshNet/KnownHost.cs b/src/Renci.SshNet/KnownHost.cs new file mode 100644 index 000000000..65c3015df --- /dev/null +++ b/src/Renci.SshNet/KnownHost.cs @@ -0,0 +1,305 @@ +using Renci.SshNet.Security.Cryptography; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace Renci.SshNet +{ + /// + /// Represents a host with a known public key + /// + internal class KnownHost + { + //If present at the start of a known_hosts entry, signifies that the hostname is sha1 hashed + private const string HashedHostSignifier = "|1|"; + + private const UInt16 DefaultSshPortNumber = 22; + private const string DefaultSshPortString = "22"; + + private const string KeyRevokeMarker = "@revoked"; + private const string KeyCertAuthMarker = "@cert-authority"; + + private readonly bool _isHashed; + private readonly List> _plaintextHostPatterns; + private readonly byte[] _hashedHostName; + private readonly byte[] _hashSalt; + private readonly byte[] _pubKey; + private readonly string _keyType; + private readonly HostPubkeyMarker _hostMarker; + + public enum HostValidationResponse + { + Matches, + DoesNoMatch, + KeyRevoked, + InvalidSignature, + ValidSignature + } + + private enum HostPubkeyMarker + { + None, + Revoke, + CertAuthority, + Error + } + + private static HostPubkeyMarker ParseSpecialOperand(string toParse) + { + switch (toParse) + { + case KeyRevokeMarker: + return HostPubkeyMarker.Revoke; + case KeyCertAuthMarker: + return HostPubkeyMarker.CertAuthority; + default: + return HostPubkeyMarker.Error; + } + } + + private KnownHost(string hostName, string keyType, string base64Pubkey, HostPubkeyMarker marker) + { + _isHashed = hostName.StartsWith("|"); + + if (_isHashed) + { + if (!hostName.StartsWith(HashedHostSignifier)) + throw new FormatException("The hashed section was not properly composed"); + + string[] splitHashedSection = hostName.Split('|'); + if (splitHashedSection.Length != 4) + throw new FormatException("The hashed section was not properly composed"); + + _hashSalt = Convert.FromBase64String(splitHashedSection[2]); + + if (_hashSalt.Length != 20) + throw new ArgumentException("The salt must be exacly 20 bytes"); + + _hashedHostName = Convert.FromBase64String(splitHashedSection[3]); + } + else + { + _plaintextHostPatterns = GetPatternList(hostName); + if (_plaintextHostPatterns.Count == 0) + throw new FormatException("No hostname patterns given"); + } + + _keyType = keyType; + _pubKey = Convert.FromBase64String(base64Pubkey); + _hostMarker = marker; + } + + /// + /// Attempts to parse a line from an openssh known_hosts file + /// + /// + /// + /// true if the host line could be parsed. false if an error occurred + public static bool TryParse(string hostLine, out KnownHost host) + { + try + { + return UnsafeTryParse(hostLine, out host); + } + catch (FormatException) + { + host = null; + return false; + } + catch (ArgumentException) + { + host = null; + return false; + } + } + + private static bool UnsafeTryParse(string hostLine, out KnownHost host) + { + host = null; + if (string.IsNullOrEmpty(hostLine) || hostLine.StartsWith("#")) + return false; + + bool hasSpecialPrefix = hostLine.StartsWith("@"); + + string[] sectionedLine = hostLine.Split(' '); + if (hasSpecialPrefix && sectionedLine.Length < 4) + return false; + else if (sectionedLine.Length < 3) + return false; + + HostPubkeyMarker marker = + hasSpecialPrefix ? ParseSpecialOperand(sectionedLine[0]) : HostPubkeyMarker.None; + if (marker == HostPubkeyMarker.Error) + return false; + + int sectionOffset = hasSpecialPrefix ? 1 : 0; + string hostNameSecion = sectionedLine[sectionOffset]; + string keyTypeSection = sectionedLine[1 + sectionOffset]; + string pubKeySection = sectionedLine[2 + sectionOffset]; + + host = new KnownHost(hostNameSecion, keyTypeSection, pubKeySection, marker); + + return true; + } + + /// + /// Compare the given host information with this KnownHost + /// + /// + /// + /// + /// + /// Whether or not the given hostname-keytype-pubkey combination is a match to this KnownHost + public HostValidationResponse MatchesPubKey(string hostname, string keyType, byte[] pubKey, UInt16 portNumber) + { + if (!ValidateHostName(hostname, portNumber)) + return HostValidationResponse.DoesNoMatch; + + if (_hostMarker == HostPubkeyMarker.CertAuthority) + return ValidateKeySignature(hostname, keyType, pubKey); + + if (_keyType != keyType || !_pubKey.SequenceEqual(pubKey)) + return HostValidationResponse.DoesNoMatch; + + switch (_hostMarker) + { + case HostPubkeyMarker.Revoke: + return HostValidationResponse.KeyRevoked; + default: + return HostValidationResponse.Matches; + } + } + + private HostValidationResponse ValidateKeySignature(string hostname, string keyType, byte[] keyToCheck) + { + //TODO: Return HostValidationResponse.ValidSignature if the key is signed by this Certificate Authority + return HostValidationResponse.InvalidSignature; + } + + /// + /// Writes this KnownHost as an openssh known_hosts formatted line + /// + /// + public override string ToString() + { + string hostnameSection = _isHashed + ? string.Format("{0}{1}|{2}", HashedHostSignifier, Convert.ToBase64String(_hashSalt), Convert.ToBase64String(_hashedHostName)) + : string.Join("," , _plaintextHostPatterns.Select(x => x.Item1)); + + string specialPrefix = string.Empty; + switch (_hostMarker) + { + case HostPubkeyMarker.CertAuthority: + specialPrefix = KeyCertAuthMarker + " "; + break; + case HostPubkeyMarker.Revoke: + specialPrefix = KeyRevokeMarker + " "; + break; + } + + return string.Format("{0}{1} {2} {3}",specialPrefix, hostnameSection, _keyType, Convert.ToBase64String(_pubKey)); + } + + private bool ValidateHostName(string host, UInt16 port) + { + if (_isHashed) + { + //Corner case with hashed hosts: If the default port is specified, then the hash could either be just the hostname, + //or hostname with port + if (port == DefaultSshPortNumber && ValidateHashedHostName(host)) + return true; + return ValidateHashedHostName(string.Format("[{0}:{1}]", host, port)); + } + return ValidatePlaintextHostName(string.Format("[{0}:{1}]", host, port)); + } + + private bool ValidateHashedHostName(string hostAndPort) + { + HMACSHA1 hmac = new HMACSHA1(_hashSalt); + byte[] hashToCompare = hmac.ComputeHash(Encoding.ASCII.GetBytes(hostAndPort)); + + return _hashedHostName.SequenceEqual(hashToCompare); + } + + private bool ValidatePlaintextHostName(string hostAndPort) + { + bool foundAtLeastOneMatch = false; + foreach (Tuple possibleMatch in _plaintextHostPatterns) + { + bool negateMatch = possibleMatch.Item1.StartsWith("!"); + bool foundMatch = possibleMatch.Item2.IsMatch(hostAndPort); + if (foundMatch && negateMatch) + return false; + + foundAtLeastOneMatch = foundAtLeastOneMatch || foundMatch; + } + + return foundAtLeastOneMatch; + } + + private List> GetPatternList(string unsplitHostPatterns) + { + List> patternList = new List>(); + foreach (string s in unsplitHostPatterns.Split(',')) + { + Regex toAdd; + if(!TryGetRegexFromPlaintextHostPattern(s, out toAdd)) + continue; + patternList.Add(new Tuple(s, toAdd)); + } + + return patternList; + } + + private static bool TryGetRegexFromPlaintextHostPattern(string pattern, out Regex matchingExpression) + { + matchingExpression = null; + + StringBuilder regexBuilder = new StringBuilder(); + + string strippedHostPattern; + if(!TryValidateAndFormatHostPattern(pattern, out strippedHostPattern)) + return false; + + //split by *, regex escape the split sections, replace * with regex equivalent (.*) + string[] sectionedPattern = strippedHostPattern.Split('*'); + regexBuilder.Append(string.Join(".*", sectionedPattern.Select(Regex.Escape))); + + + matchingExpression = new Regex(regexBuilder.ToString()); + return true; + } + + private static bool TryValidateAndFormatHostPattern(string input, out string hostPattern) + { + hostPattern = null; + + bool squareBraceOpens = input.StartsWith("["); + bool squareBraceCloses = input.EndsWith("]"); + + //Opening/Closing braces must be matched + if (squareBraceOpens ^ squareBraceCloses) + return false; + + if(squareBraceOpens) + { + //Patterns surrounded by braces must specify port + if (!input.Contains(":")) + return false; + hostPattern = input; + } + else + { + hostPattern = string.Format("[{0}:{1}]", input, DefaultSshPortString); + } + + //Strip the negation operand if present + if (hostPattern.StartsWith("[!")) + hostPattern = hostPattern.Remove(1, 1); + + return true; + } + } +} \ No newline at end of file diff --git a/src/Renci.SshNet/KnownHostStore.cs b/src/Renci.SshNet/KnownHostStore.cs new file mode 100644 index 000000000..78fc995ad --- /dev/null +++ b/src/Renci.SshNet/KnownHostStore.cs @@ -0,0 +1,154 @@ +using System; +using Renci.SshNet.Abstractions; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Renci.SshNet.Common; +using Renci.SshNet.Security.Cryptography; + +namespace Renci.SshNet +{ + /// + /// A class to save and store the public keys of connected hosts. Capable + /// of reading and creating files compatible with openssh's known_hosts + /// format. For more information on known_hosts files, read the + /// man 8 sshd section on known_hosts + /// + public class KnownHostStore + { + private readonly List _knownHosts; + + /// + /// Construct an empty KnownHostStore + /// + public KnownHostStore() + { + _knownHosts = new List(); + } + + /// + /// Construct a KnownHostStore from an openssh known_hosts file + /// + /// The path of the file to read + public KnownHostStore(string knownHostsFile) + { + _knownHosts = new List(); + ImportKnownHostsFromFile(knownHostsFile); + } + + /// + /// Fill this store with elements from an openssh known_hosts file + /// + /// The path of the file to read + public void ImportKnownHostsFromFile(string filePath) + { + using (var fStream = File.OpenText(filePath)) + { + while (!fStream.EndOfStream) + { + KnownHost createdHost; + if (KnownHost.TryParse(fStream.ReadLine(), out createdHost)) + _knownHosts.Add(createdHost); + } + } + } + + /// + /// Add a host to this store. + /// + /// The name of the host to add + /// + /// The algorithm of the public key + /// The public key of the host to add + /// Whether the hostname should be stored as a SHA1 hash + /// An optional @ prefixed string that corresponds to a marker for this host. See the man 8 sshd section about known_hosts for valid markers + /// Thrown if the given arguments cannor be parsed into a valid host entry + public void AddHost(string hostname, UInt16 portNumber, string keyType, byte[] pubKey, bool storeHostnameHashed, string marker = "") + { + string hostNameWithPort = string.Format("[{0}:{1}]", hostname, portNumber); + string hostnameSection; + + if (!string.IsNullOrEmpty(marker)) + { + if(!marker.StartsWith("@")) + throw new FormatException("The given host marker must be prefixed with \'@\'. See the \'man 8 sshd\' section about known_hosts for more details on markers"); + marker = string.Format("{0} ", marker); + } + + if (storeHostnameHashed) + { + byte[] salt = new byte[20]; + CryptoAbstraction.GenerateRandom(salt); + + HMACSHA1 hmac = new HMACSHA1(salt); + byte[] hash = hmac.ComputeHash(Encoding.ASCII.GetBytes(hostNameWithPort)); + + hostnameSection = string.Format("|1|{0}|{1}", Convert.ToBase64String(salt), Convert.ToBase64String(hash)); + } + else + { + hostnameSection = hostNameWithPort; + } + + string hostToParse = string.Format("{3}{0} {1} {2}", hostnameSection, keyType, Convert.ToBase64String(pubKey), marker); + + KnownHost newHost; + if(!KnownHost.TryParse(hostToParse, out newHost)) + throw new FormatException("Malformed input: Failed to create entry. If you specified a marker, ensure it is valid (see the \'man 8 sshd\' section on known_hosts for more details on markers)"); + + _knownHosts.Add(newHost); + } + + /// + /// Removes all hosts from this store + /// + public void ClearHosts() + { + _knownHosts.Clear(); + } + + /// + /// Writes all hosts in this store to an openssh known_hosts formatted file + /// + /// The path of the file to be written + public void ExportKnownHostsToFile(string outFile) + { + using (var fStream = File.OpenWrite(outFile)) + { + foreach (KnownHost host in _knownHosts) + { + byte[] hostLine = Encoding.ASCII.GetBytes(host.ToString() + Environment.NewLine); + fStream.Write(hostLine, 0, hostLine.Length); + } + } + } + + /// + /// Look for a host in this store with the given hostname and public key + /// + /// The name of the host to search for + /// The algorithm of the public key + /// The public key of the host to search for + /// The port number + /// If the given key is marked as revoked + /// Whether a host corresponding to the given parameters exists in this store + public bool Knows(string hostname, string keyType, byte[] pubKey, UInt16 port) + { + bool foundMatch = false; + + foreach (KnownHost host in _knownHosts) + { + KnownHost.HostValidationResponse typeOfMatch = host.MatchesPubKey(hostname, keyType, pubKey, port); + + if(typeOfMatch == KnownHost.HostValidationResponse.KeyRevoked) + throw new RevokedKeyException("The given host-pubkey pair is marked as revoked"); + + foundMatch = foundMatch || (typeOfMatch == KnownHost.HostValidationResponse.Matches); + } + + return foundMatch; + } + } +} diff --git a/src/Renci.SshNet/Renci.SshNet.csproj b/src/Renci.SshNet/Renci.SshNet.csproj index 0598d18ce..40d2e8bb5 100644 --- a/src/Renci.SshNet/Renci.SshNet.csproj +++ b/src/Renci.SshNet/Renci.SshNet.csproj @@ -114,6 +114,7 @@ Code + Code @@ -159,6 +160,8 @@ + +