From: Matthew Endsley Date: Tue, 12 Jan 2021 15:10:12 +0000 (-0800) Subject: Implement server-side DTLS handshake X-Git-Tag: 1.0.0~20^2~19^2 X-Git-Url: https://git.deb.at/?a=commitdiff_plain;h=59a78241465e58de10f29ea674f2d9d99ce0fa2f;p=rhonda%2Fimpostor.hazel.git Implement server-side DTLS handshake --- diff --git a/Hazel/Crypto/SpanCryptoExtensions.cs b/Hazel/Crypto/SpanCryptoExtensions.cs new file mode 100644 index 0000000..b734961 --- /dev/null +++ b/Hazel/Crypto/SpanCryptoExtensions.cs @@ -0,0 +1,33 @@ +using System; +using System.Security.Cryptography; + +namespace Hazel.Crypto +{ + public static class SpanCryptoExtensions + { + /// + /// Clear a span's contents to zero + /// + public static void SecureClear(this ByteSpan span) + { + Array.Clear(span.GetUnderlyingArray(), span.Offset, span.Length); + } + + /// + /// Fill a byte span with random data + /// + /// Entropy source + public static void FillWithRandom(this ByteSpan span, RandomNumberGenerator random) + { + if (span.Offset == 0 && span.Length == span.GetUnderlyingArray().Length) + { + random.GetBytes(span.GetUnderlyingArray()); + return; + } + + byte[] temp = new byte[span.Length]; + random.GetBytes(temp); + new ByteSpan(temp).CopyTo(span); + } + } +} diff --git a/Hazel/Dtls/DtlsConnectionListener.cs b/Hazel/Dtls/DtlsConnectionListener.cs index dddc9ca..72eabc8 100644 --- a/Hazel/Dtls/DtlsConnectionListener.cs +++ b/Hazel/Dtls/DtlsConnectionListener.cs @@ -1,5 +1,14 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading; using Hazel.Udp.FewerThreads; +using Hazel.Crypto; namespace Hazel.Dtls { @@ -9,6 +18,139 @@ namespace Hazel.Dtls /// public class DtlsConnectionListener : ThreadLimitedUdpConnectionListener { + const int MaxDatagramSize = 1200; + + /// + /// Current state of handshake sequence + /// + enum HandshakeState + { + ExpectingHello, + ExpectingClientKeyExchange, + ExpectingChangeCipherSpec, + ExpectingFinish, + } + + /// + /// State to manage the current epoch `N` + /// + struct CurrentEpoch + { + public ulong NextOutgoingSequence; + + public ulong NextExpectedSequence; + public ulong PreviousSequenceWindowBitmask; + + public IRecordProtection RecordProtection; + + // Need to keep these around so we can re-transmit our + // last handshake record flight + public ByteSpan ExpectedClientFinishedVerification; + public ByteSpan ServerFinishedVerification; + public ulong NextOutgoingSequenceForPreviousEpoch; + } + + /// + /// State to manage the transition from the current + /// epoch `N` to epoch `N+1` + /// + struct NextEpoch + { + public ushort Epoch; + + public HandshakeState State; + public CipherSuite SelectedCipherSuite; + + public ulong NextOutgoingSequence; + + public IHandshakeCipherSuite Handshake; + public IRecordProtection RecordProtection; + + public ByteSpan ClientRandom; + public ByteSpan ServerRandom; + + public MemoryStream VerificationStream; + + public ByteSpan ClientVerification; + public ByteSpan ServerVerification; + + } + + /// + /// Per-peer state + /// + sealed class PeerData : IDisposable + { + public ushort Epoch; + public bool CanHandleApplicationData; + + public CurrentEpoch CurrentEpoch; + public NextEpoch NextEpoch; + + public ConnectionId ConnectionId; + + public readonly List QueuedApplicationDataMessage = new List(); + + public PeerData() + { + ByteSpan block = new byte[2 * Finished.Size]; + this.CurrentEpoch.ServerFinishedVerification = block.Slice(0, Finished.Size); + this.CurrentEpoch.ExpectedClientFinishedVerification = block.Slice(Finished.Size, Finished.Size); + + ResetPeer(ConnectionId.Create(0), 1); + } + + public void ResetPeer(ConnectionId connectionId, ulong nextExpectedSequenceNumber) + { + Dispose(); + + this.Epoch = 0; + this.CanHandleApplicationData = false; + this.QueuedApplicationDataMessage.Clear(); + + this.CurrentEpoch = new CurrentEpoch(); + this.CurrentEpoch.NextOutgoingSequence = 2; // Account for our ClientHelloVerify + this.CurrentEpoch.NextExpectedSequence = nextExpectedSequenceNumber; + this.CurrentEpoch.PreviousSequenceWindowBitmask = 0; + this.CurrentEpoch.RecordProtection = NullRecordProtection.Instance; + this.CurrentEpoch.ServerFinishedVerification.SecureClear(); + this.CurrentEpoch.ExpectedClientFinishedVerification.SecureClear(); + + this.NextEpoch = new NextEpoch(); + this.NextEpoch.State = HandshakeState.ExpectingHello; + this.NextEpoch.RecordProtection = null; + this.NextEpoch.ClientRandom = new byte[Random.Size]; + this.NextEpoch.ServerRandom = new byte[Random.Size]; + this.NextEpoch.VerificationStream = new MemoryStream(); + this.NextEpoch.ClientVerification = new byte[Finished.Size]; + this.NextEpoch.ServerVerification = new byte[Finished.Size]; + + this.ConnectionId = connectionId; + } + + public void Dispose() + { + this.CurrentEpoch.RecordProtection?.Dispose(); + this.NextEpoch.RecordProtection?.Dispose(); + this.NextEpoch.Handshake?.Dispose(); + this.NextEpoch.VerificationStream?.Dispose(); + } + } + + private RandomNumberGenerator random; + + // Private key component of certificate's public key + private readonly List encodedCertificates = new List(); + private uint encodedCertificatesTotalSize; + private RSA certificatePrivateKey; + + // HMAC key to validate ClientHello cookie + private HMAC currentCookieHmac; + + private readonly ConcurrentDictionary existingPeers = new ConcurrentDictionary(); + + private long connectionId_unsafe = 0; + /// /// Create a new instance of the DTLS listener /// @@ -19,18 +161,1101 @@ namespace Hazel.Dtls public DtlsConnectionListener(int numWorkers, IPEndPoint endPoint, ILogger logger, IPMode ipMode = IPMode.IPv4) : base(numWorkers, endPoint, logger, ipMode) { + this.random = RandomNumberGenerator.Create(); + + ///TODO(mendsley): The HMAC key should be cycled periodically + const string HMACProvider = "System.Security.Cryptography.HMACSHA1"; + this.currentCookieHmac = HMAC.Create(HMACProvider); } /// - protected override void ProcessIncomingMessageFromOtherThread(MessageReader message, EndPoint peerAddress, ConnectionId connectionId) + protected override void Dispose(bool disposing) { - base.ProcessIncomingMessageFromOtherThread(message, peerAddress, connectionId); + base.Dispose(disposing); + + this.random?.Dispose(); + this.random = null; + + this.currentCookieHmac?.Dispose(); + this.currentCookieHmac = null; + + foreach (var pair in this.existingPeers) + { + pair.Value.Dispose(); + } + this.existingPeers.Clear(); } - /// + /// + /// Set the certificate key pair for the listener + /// + /// Certificate for the server + public void SetCertificate(X509Certificate2 certificate) + { + if (!certificate.HasPrivateKey) + { + throw new ArgumentException("Certificate must have a private key attached", nameof(certificate)); + } + + RSA privateKey = certificate.PrivateKey as RSA; + if (privateKey == null) + { + throw new ArgumentException("Certificate must be signed by an RSA key", nameof(certificate)); + } + + this.certificatePrivateKey?.Dispose(); + this.certificatePrivateKey = privateKey; + + // Pre-fragment the certificate data + ByteSpan certificateData = Certificate.Encode(certificate); + this.encodedCertificatesTotalSize = (uint)certificateData.Length; + + // The first certificate data needs to leave room for + // * Record header + // * ServerHello header + // * ServerHello payload + // * Certificate header + int padding = Record.Size + Handshake.Size + ServerHello.Size + Handshake.Size; + this.encodedCertificates.Add(certificateData.Slice(0, Math.Min(certificateData.Length, MaxDatagramSize - padding))); + certificateData = certificateData.Slice(Math.Min(certificateData.Length, MaxDatagramSize - padding)); + + // Subsequent certificate data needs to leave room for + // * Record header + // * Certificate header + padding = Record.Size + Handshake.Size; + while (certificateData.Length > 0) + { + this.encodedCertificates.Add(certificateData.Slice(0, Math.Min(certificateData.Length, MaxDatagramSize - padding))); + certificateData = certificateData.Slice(Math.Min(certificateData.Length, MaxDatagramSize - padding)); + } + } + + /// + /// Handle an incoming datagram from the network. + /// + /// This is primarily a wrapper around ProcessIncomingMessage + /// to ensure `reader.Recycle()` is always called + /// + protected override void ProcessIncomingMessageFromOtherThread(MessageReader reader, EndPoint peerAddress, ConnectionId connectionId) + { + ByteSpan message = new ByteSpan(reader.Buffer, reader.Offset + reader.Position, reader.BytesRemaining); + this.ProcessIncomingMessage(message, peerAddress); + reader.Recycle(); + } + + /// + /// Handle an incoming datagram from the network + /// + private void ProcessIncomingMessage(ByteSpan message, EndPoint peerAddress) + { + PeerData peer = null; + if (!this.existingPeers.TryGetValue(peerAddress, out peer)) + { + HandleNonPeerRecord(message, peerAddress); + return; + } + + lock (peer) + { + // Each incoming packet may contain multiple DTLS + // records + while (message.Length > 0) + { + Record record; + if (!Record.Parse(out record, message)) + { + this.Logger.WriteError($"Dropping malformed record from `{peerAddress}`"); + return; + } + message = message.Slice(Record.Size); + + if (message.Length < record.Length) + { + this.Logger.WriteError($"Dropping malformed record from `{peerAddress}` Length({record.Length}) AvailableBytes({message.Length})"); + return; + } + + ByteSpan recordPayload = message.Slice(0, record.Length); + message = message.Slice(record.Length); + + // Early-out and drop ApplicationData records + if (record.ContentType == ContentType.ApplicationData && !peer.CanHandleApplicationData) + { + this.Logger.WriteInfo($"Dropping ApplicationData record from `{peerAddress}` Cannot process yet"); + continue; + } + + // Drop records from a different epoch + if (record.Epoch != peer.Epoch) + { + // Handle existing client negotiating a new connection + if (record.Epoch == 0 && record.ContentType == ContentType.Handshake) + { + ByteSpan handshakePayload = recordPayload; + + Handshake handshake; + if (!Handshake.Parse(out handshake, recordPayload)) + { + this.Logger.WriteError($"Dropping malformed re-negotiation Handshake from `{peerAddress}`"); + continue; + } + handshakePayload = handshakePayload.Slice(Handshake.Size); + + if (handshake.FragmentOffset != 0 || handshake.Length != handshake.FragmentLength) + { + this.Logger.WriteError($"Dropping fragmented re-negotiation Handshake from `{peerAddress}`"); + continue; + } + else if (handshake.MessageType != HandshakeType.ClientHello) + { + this.Logger.WriteError($"Dropping non-ClientHello re-negotiation Handshake from `{peerAddress}`"); + continue; + } + else if (handshakePayload.Length < handshake.Length) + { + this.Logger.WriteError($"Dropping malformed re-negotiation Handshake from `{peerAddress}`: Length({handshake.Length}) AvailableBytes({handshakePayload.Length})"); + } + + if (!this.HandleClientHello(peer, peerAddress, ref record, ref handshake, recordPayload, handshakePayload)) + { + return; + } + continue; + } + + this.Logger.WriteError($"Dropping bad-epoch record from `{peerAddress}` RecordEpoch({record.Epoch}) CurrentEpoch({peer.Epoch})"); + continue; + } + + // Prevent replay attacks by dropping records + // we've already processed + int windowIndex = (int)(peer.CurrentEpoch.NextExpectedSequence - record.SequenceNumber - 1); + ulong windowMask = 1ul << windowIndex; + if (record.SequenceNumber < peer.CurrentEpoch.NextExpectedSequence) + { + if (windowIndex >= 64) + { + this.Logger.WriteInfo($"Dropping too-old record from `{peerAddress}` Sequence({record.SequenceNumber}) Expected({peer.CurrentEpoch.NextExpectedSequence})"); + continue; + } + + if ((peer.CurrentEpoch.PreviousSequenceWindowBitmask & windowMask) != 0) + { + this.Logger.WriteInfo($"Dropping duplicate record from `{peerAddress}`"); + continue; + } + } + + // Validate record authenticity + int decryptedSize = peer.CurrentEpoch.RecordProtection.GetDecryptedSize(recordPayload.Length); + ByteSpan decryptedPayload = ReuseSpanIfPossible(recordPayload, decryptedSize); + + if (!peer.CurrentEpoch.RecordProtection.DecryptCiphertextFromClient(decryptedPayload, recordPayload, ref record)) + { + this.Logger.WriteError($"Dropping non-authentic record from `{peerAddress}`"); + return; + } + + recordPayload = decryptedPayload; + + // Update our squence number bookeeping + if (record.SequenceNumber >= peer.CurrentEpoch.NextExpectedSequence) + { + int windowShift = (int)(record.SequenceNumber + 1 - peer.CurrentEpoch.NextExpectedSequence); + peer.CurrentEpoch.PreviousSequenceWindowBitmask <<= windowShift; + peer.CurrentEpoch.NextExpectedSequence = record.SequenceNumber + 1; + } + else + { + peer.CurrentEpoch.PreviousSequenceWindowBitmask |= windowMask; + } + + switch (record.ContentType) + { + case ContentType.ChangeCipherSpec: + if (peer.NextEpoch.State != HandshakeState.ExpectingChangeCipherSpec) + { + this.Logger.WriteError($"Dropping unexpected ChangeChiperSpec record from `{peerAddress}` State({peer.NextEpoch.State})"); + break; + } + else if (peer.NextEpoch.RecordProtection == null) + { + ///NOTE(mendsley): This _should_ not + /// happen on a well-formed server. + Debug.Assert(false, "How did we receive a ChangeCipherSpec message without a pending record protection instance?"); + + this.Logger.WriteError($"Dropping ChangeCipherSpec message from `{peerAddress}`: No pending record protection"); + break; + } + + // Migrate to the next epoch + peer.Epoch = peer.NextEpoch.Epoch; + peer.CanHandleApplicationData = false; // Need a Finished message + peer.CurrentEpoch.NextOutgoingSequenceForPreviousEpoch = peer.CurrentEpoch.NextOutgoingSequence; + peer.CurrentEpoch.RecordProtection = peer.NextEpoch.RecordProtection; + peer.CurrentEpoch.NextOutgoingSequence = 1; + peer.CurrentEpoch.NextExpectedSequence = 1; + peer.CurrentEpoch.PreviousSequenceWindowBitmask = 0; + peer.NextEpoch.ClientVerification.CopyTo(peer.CurrentEpoch.ExpectedClientFinishedVerification); + peer.NextEpoch.ServerVerification.CopyTo(peer.CurrentEpoch.ServerFinishedVerification); + + peer.NextEpoch.State = HandshakeState.ExpectingHello; + peer.NextEpoch.Handshake?.Dispose(); + peer.NextEpoch.Handshake = null; + peer.NextEpoch.NextOutgoingSequence = 1; + peer.NextEpoch.RecordProtection = null; + peer.NextEpoch.VerificationStream.SetLength(0); + peer.NextEpoch.ClientVerification.SecureClear(); + peer.NextEpoch.ServerVerification.SecureClear(); + break; + + case ContentType.Alert: + this.Logger.WriteError($"Dropping unsupported Alert record from `{peerAddress}`"); + break; + + case ContentType.Handshake: + if (!ProcessHandshake(peer, peerAddress, ref record, recordPayload)) + { + return; + } + break; + + case ContentType.ApplicationData: + // Forward data to the application + MessageReader reader = MessageReader.GetSized(recordPayload.Length); + reader.Length = recordPayload.Length; + recordPayload.CopyTo(reader.Buffer); + + base.ProcessIncomingMessageFromOtherThread(reader, peerAddress, peer.ConnectionId); + break; + } + } + } + } + + /// + /// Reuse an existing span if there is enough space, + /// otherwise allocate new storage + /// + /// + /// Source span we should attempt to reuse + /// + /// Required size (bytes) + private static ByteSpan ReuseSpanIfPossible(ByteSpan source, int requiredSize) + { + if (source.Length >= requiredSize) + { + return source.Slice(0, requiredSize); + } + + return new byte[requiredSize]; + } + + /// + /// Process an incoming Handshake protocol message + /// + /// Originating peer + /// Peer's network address + /// Parent record + /// Record payload + /// + /// True if further processing of the underlying datagram + /// should be continues. Otherwise, false. + /// + private bool ProcessHandshake(PeerData peer, EndPoint peerAddress, ref Record record, ByteSpan message) + { + // Each record may have multiple handshake payloads + while (message.Length > 0) + { + ByteSpan originalMessage = message; + + Handshake handshake; + if (!Handshake.Parse(out handshake, message)) + { + this.Logger.WriteError($"Dropping malformed Handshake message from `{peerAddress}`"); + return false; + } + message = message.Slice(Handshake.Size); + + if (message.Length < handshake.Length) + { + this.Logger.WriteError($"Dropping malformed Handshake message from `{peerAddress}`"); + return false; + } + + ByteSpan payload = message.Slice(0, (int)message.Length); + message = message.Slice((int)handshake.Length); + originalMessage = originalMessage.Slice(0, Handshake.Size + (int)handshake.Length); + + // We do not support fragmented handshake messages + // from the client + if (handshake.FragmentOffset != 0 || handshake.FragmentLength != handshake.Length) + { + this.Logger.WriteError($"Dropping fragmented Handshake message from `{peerAddress}` Offset({handshake.FragmentOffset}) FragmentLength({handshake.FragmentLength}) Length({handshake.Length})"); + continue; + } + + ByteSpan packet; + ByteSpan writer; + + switch (handshake.MessageType) + { + case HandshakeType.ClientHello: + if (!this.HandleClientHello(peer, peerAddress, ref record, ref handshake, originalMessage, payload)) + { + return false; + } + break; + + case HandshakeType.ClientKeyExchange: + if (peer.NextEpoch.State != HandshakeState.ExpectingClientKeyExchange) + { + this.Logger.WriteError($"Dropping unexpected ClientKeyExchange message form `{peerAddress}` State({peer.NextEpoch.State})"); + continue; + } + else if (handshake.MessageSequence != 5) + { + this.Logger.WriteError($"Dropping bad-sequence ClientKeyExchange message from `{peerAddress}` MessageSequence({handshake.MessageSequence})"); + continue; + } + + ByteSpan sharedSecret = new byte[peer.NextEpoch.Handshake.SharedKeySize()]; + if (!peer.NextEpoch.Handshake.VerifyClientMessageAndGenerateSharedKey(sharedSecret, payload)) + { + this.Logger.WriteError($"Dropping malformed ClientKeyExchange message from `{peerAddress}`"); + return false; + } + + // Record incoming ClientKeyExchange message + // to verification stream + peer.NextEpoch.VerificationStream.Write( + originalMessage.GetUnderlyingArray() + , originalMessage.Offset + , originalMessage.Length + ); + + ByteSpan randomSeed = new byte[2 * Random.Size]; + peer.NextEpoch.ClientRandom.CopyTo(randomSeed); + peer.NextEpoch.ServerRandom.CopyTo(randomSeed.Slice(Random.Size)); + + const int MasterSecretSize = 48; + ByteSpan masterSecret = new byte[MasterSecretSize]; + PrfSha256.ExpandSecret( + masterSecret + , sharedSecret + , PrfLabel.MASTER_SECRET + , randomSeed + ); + + // Create the record protection for the upcoming epoch + switch (peer.NextEpoch.SelectedCipherSuite) + { + case CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: + peer.NextEpoch.RecordProtection = new Aes128GcmRecordProtection( + masterSecret + , peer.NextEpoch.ServerRandom + , peer.NextEpoch.ClientRandom); + break; + + default: + Debug.Assert(false, $"How did we agree to a cipher suite {peer.NextEpoch.SelectedCipherSuite} we can't create?"); + this.Logger.WriteError($"Dropping ClientKeyExchange message from `{peerAddress}` Unsuppored cipher suite"); + return false; + } + + // Generate verification signatures + ByteSpan handshakeStreamHash; + using (SHA256 sha256 = SHA256.Create()) + { + peer.NextEpoch.VerificationStream.Position = 0; + handshakeStreamHash = sha256.ComputeHash(peer.NextEpoch.VerificationStream); + } + + PrfSha256.ExpandSecret( + peer.NextEpoch.ClientVerification + , masterSecret + , PrfLabel.CLIENT_FINISHED + , handshakeStreamHash + ); + PrfSha256.ExpandSecret( + peer.NextEpoch.ServerVerification + , masterSecret + , PrfLabel.SERVER_FINISHED + , handshakeStreamHash + ); + + + // Update handshake state + masterSecret.SecureClear(); + peer.NextEpoch.State = HandshakeState.ExpectingChangeCipherSpec; + break; + + case HandshakeType.Finished: + // Unlike other handshake messages, this is + // for the current epoch - not the next epoch + + // Cannot process a Finished message for + // epoch 0 + if (peer.Epoch == 0) + { + this.Logger.WriteError($"Dropping Finished message for 0-epoch from `{peerAddress}`"); + continue; + } + // Cannot process a Finished message when we + // are negotiating the next epoch + else if (peer.NextEpoch.State != HandshakeState.ExpectingHello) + { + this.Logger.WriteError($"Dropping Finished message while negotiating new epoch from `{peerAddress}`"); + continue; + } + // Cannot process a Finished message without + // verify data + else if (peer.CurrentEpoch.ExpectedClientFinishedVerification.Length != Finished.Size || peer.CurrentEpoch.ServerFinishedVerification.Length != Finished.Size) + { + ///NOTE(mendsley): This _should_ not + /// happen on a well-formed server. + Debug.Assert(false, "How do we have an established non-zero epoch without verify data"); + + this.Logger.WriteError($"Dropping Finished message (no verify data) from `{peerAddress}`"); + return false; + } + + // Verify message sequence + if (handshake.MessageSequence != 6) + { + this.Logger.WriteError($"Dropping bad-sequence Finished message from `{peerAddress}` MessageSequence({handshake.MessageSequence})"); + continue; + } + + // Verify the client has the correct + // handshake sequence + if (payload.Length != Finished.Size) + { + this.Logger.WriteError($"Dropping malformed Finished message from `{peerAddress}`"); + return false; + } + else if (1 != Crypto.Const.ConstantCompareSpans(payload, peer.CurrentEpoch.ExpectedClientFinishedVerification)) + { + this.Logger.WriteError($"Dropping non-verified Finished Handshake from `{peerAddress}`"); + + // Abort the connection here + // + // The client is either broken, or + // doen not agree on our epoch settings. + // + // Either way, there is not a feasible + // way to progress the connection. + base.MarkConnectionAsStale(peer.ConnectionId); + this.existingPeers.TryRemove(peerAddress, out peer); + return false; + } + + // Describe our ChangeCipherSpec+Finished + Handshake outgoingHandshake = new Handshake(); + outgoingHandshake.MessageType = HandshakeType.Finished; + outgoingHandshake.Length = Finished.Size; + outgoingHandshake.MessageSequence = 7; + outgoingHandshake.FragmentOffset = 0; + outgoingHandshake.FragmentLength = outgoingHandshake.Length; + + Record changeCipherSpecRecord = new Record(); + changeCipherSpecRecord.ContentType = ContentType.ChangeCipherSpec; + changeCipherSpecRecord.Epoch = (ushort)(peer.Epoch - 1); + changeCipherSpecRecord.SequenceNumber = peer.CurrentEpoch.NextOutgoingSequenceForPreviousEpoch; + changeCipherSpecRecord.Length = 0; + ++peer.CurrentEpoch.NextOutgoingSequenceForPreviousEpoch; + + int plaintextFinishedPayloadSize = Handshake.Size + (int)outgoingHandshake.Length; + Record finishedRecord = new Record(); + finishedRecord.ContentType = ContentType.Handshake; + finishedRecord.Epoch = peer.Epoch; + finishedRecord.SequenceNumber = peer.CurrentEpoch.NextOutgoingSequence; + finishedRecord.Length = (ushort)peer.CurrentEpoch.RecordProtection.GetEncryptedSize(plaintextFinishedPayloadSize); + ++peer.CurrentEpoch.NextOutgoingSequence; + + // Encode the flight into wire format + packet = new byte[Record.Size + Record.Size + finishedRecord.Length]; + writer = packet; + changeCipherSpecRecord.Encode(writer); + writer = writer.Slice(Record.Size); + finishedRecord.Encode(writer); + writer = writer.Slice(Record.Size); + outgoingHandshake.Encode(writer); + writer = writer.Slice(Handshake.Size); + peer.CurrentEpoch.ServerFinishedVerification.CopyTo(writer); + + // Protect the Finished Handshake record + peer.CurrentEpoch.RecordProtection.EncryptServerPlaintext( + packet.Slice(Record.Size + Record.Size, finishedRecord.Length) + , packet.Slice(Record.Size + Record.Size, plaintextFinishedPayloadSize) + , ref finishedRecord + ); + + // Current epoch can now handle application data + peer.CanHandleApplicationData = true; + + base.QueueRawData(packet, peerAddress); + break; + + // Drop messages that we do not support + case HandshakeType.CertificateVerify: + this.Logger.WriteError($"Dropping unsupported Handshake message from `{peerAddress}` MessageType({handshake.MessageType})"); + continue; + + // Drop messages that originate from the server + case HandshakeType.HelloRequest: + case HandshakeType.ServerHello: + case HandshakeType.HelloVerifyRequest: + case HandshakeType.Certificate: + case HandshakeType.ServerKeyExchange: + case HandshakeType.CertificateRequest: + case HandshakeType.ServerHelloDone: + this.Logger.WriteError($"Dropping server Handshake message from `{peerAddress}` MessageType({handshake.MessageType})"); + continue; + } + } + + return true; + } + + /// + /// Handle a ClientHello message for a peer + /// + /// Originating peer + /// Peer address + /// Parent record + /// Parent Handshake header + /// Handshake payload + private bool HandleClientHello(PeerData peer, EndPoint peerAddress, ref Record record, ref Handshake handshake, ByteSpan originalMessage, ByteSpan payload) + { + // Verify message sequence + if (handshake.MessageSequence != 0) + { + this.Logger.WriteError($"Dropping bad-sequence ClientHello from `{peerAddress}` MessageSequence({handshake.MessageSequence})`"); + return true; + } + + // Make sure we can handle a ClientHello message + if (peer.NextEpoch.State != HandshakeState.ExpectingHello && peer.NextEpoch.State != HandshakeState.ExpectingClientKeyExchange) + { + // Always handle ClientHello for epoch 0 + if (record.Epoch != 0) + { + this.Logger.WriteError($"Dropping ClientHello from `{peer}` Not expecting ClientHello"); + return true; + } + } + + ClientHello clientHello; + if (!ClientHello.Parse(out clientHello, payload)) + { + this.Logger.WriteError($"Dropping malformed ClientHello Handshake message from `{peerAddress}`"); + return false; + } + + // Find an acceptable cipher suite we can use + CipherSuite selectedCipherSuite = CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256; + if (!clientHello.ContainsCipherSuite(CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256) || !clientHello.ContainsCurve(NamedCurve.x25519)) + { + this.Logger.WriteError($"Dropping ClientHello from `{peerAddress}` No compatible cipher suite"); + return false; + } + + // If this message was not signed by us, + // request a signed message before doing anything else + if (!HelloVerifyRequest.VerifyCookie(clientHello.Cookie, peerAddress, this.currentCookieHmac)) + { + ulong outgoingSequence = 1; + IRecordProtection recordProtection = NullRecordProtection.Instance; + if (record.Epoch != 0) + { + outgoingSequence = peer.CurrentEpoch.NextExpectedSequence; + ++peer.CurrentEpoch.NextOutgoingSequenceForPreviousEpoch; + + recordProtection = peer.CurrentEpoch.RecordProtection; + } + + this.SendHelloVerifyRequest(peerAddress, outgoingSequence, record.Epoch, recordProtection); + return true; + } + + // Client is initiating a brand new connection. We need + // to destroy the existing connection and establish a + // new session. + if (record.Epoch == 0 && peer.Epoch != 0) + { + ConnectionId oldConnectionId = peer.ConnectionId; + peer.ResetPeer(this.AllocateConnectionId(), record.SequenceNumber + 1); + + // Inform the parent layer that the existing + // connection should be abandoned. + base.MarkConnectionAsStale(oldConnectionId); + } + + // Determine if this is an original message, or a retransmission + bool recordMessagesForVerifyData = false; + if (peer.NextEpoch.State == HandshakeState.ExpectingHello) + { + // Create our handhake cipher suite + IHandshakeCipherSuite handshakeCipherSuite = null; + switch (selectedCipherSuite) + { + case CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: + if (clientHello.ContainsCurve(NamedCurve.x25519)) + { + handshakeCipherSuite = new X25519EcdheRsaSha256(this.random); + } + else + { + this.Logger.WriteError($"Dropping ClientHello from `{peerAddress}` Could not create TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 cipher suite"); + return false; + } + + break; + + default: + this.Logger.WriteError($"Dropping ClientHello from `{peerAddress}` Could not create handshake cipher suite"); + return false; + } + + // Update the state of our epoch transition + peer.NextEpoch.Epoch = (ushort)(record.Epoch + 1); + peer.NextEpoch.State = HandshakeState.ExpectingClientKeyExchange; + peer.NextEpoch.SelectedCipherSuite = selectedCipherSuite; + peer.NextEpoch.Handshake = handshakeCipherSuite; + clientHello.Random.CopyTo(peer.NextEpoch.ClientRandom); + peer.NextEpoch.ServerRandom.FillWithRandom(this.random); + recordMessagesForVerifyData = true; + + // Copy the original ClientHello + // handshake to our verification stream + peer.NextEpoch.VerificationStream.Write( + originalMessage.GetUnderlyingArray() + , originalMessage.Offset + , Handshake.Size + (int)handshake.Length + ); + } + + // The initial record flight from the server + // contains the following Handshake messages: + // * ServerHello + // * Certificate + // * ServerKeyExchange + // * ServerHelloDone + // + // The Certificate message is almost always + // too large to fit into a single datagram, + // so it is pre-fragmented + // (see `SetCertificates`). Therefore, we + // need to send multiple record packets for + // this flight. + // + // The first record contains the ServerHello + // handshake message, as well as the first + // portion of the Certificate message. + // + // We then send a record packet until the + // entire Certificate message has been sent + // to the client. + // + // The final record packet contains the + // ServerKeyExchange and the ServerHelloDone + // messages. + + // Describe first record of the flight + ServerHello serverHello = new ServerHello(); + serverHello.Random = peer.NextEpoch.ServerRandom; + serverHello.CipherSuite = selectedCipherSuite; + + Handshake serverHelloHandshake = new Handshake(); + serverHelloHandshake.MessageType = HandshakeType.ServerHello; + serverHelloHandshake.Length = ServerHello.Size; + serverHelloHandshake.MessageSequence = 1; + serverHelloHandshake.FragmentOffset = 0; + serverHelloHandshake.FragmentLength = serverHelloHandshake.Length; + + Handshake certificateHandshake = new Handshake(); + certificateHandshake.MessageType = HandshakeType.Certificate; + certificateHandshake.Length = this.encodedCertificatesTotalSize; + certificateHandshake.MessageSequence = 2; + certificateHandshake.FragmentOffset = 0; + certificateHandshake.FragmentLength = (uint)this.encodedCertificates[0].Length; + + int initialRecordPayloadSize = 0 + + Handshake.Size + ServerHello.Size + + Handshake.Size + (int)certificateHandshake.FragmentLength + ; + Record initialRecord = new Record(); + initialRecord.ContentType = ContentType.Handshake; + initialRecord.Epoch = peer.Epoch; + initialRecord.SequenceNumber = peer.CurrentEpoch.NextOutgoingSequence; + initialRecord.Length = (ushort)peer.CurrentEpoch.RecordProtection.GetEncryptedSize(initialRecordPayloadSize); + ++peer.CurrentEpoch.NextOutgoingSequence; + + // Convert initial record of the flight to + // wire format + ByteSpan packet = new byte[Record.Size + initialRecord.Length]; + ByteSpan writer = packet; + initialRecord.Encode(writer); + writer = writer.Slice(Record.Size); + serverHelloHandshake.Encode(writer); + writer = writer.Slice(Handshake.Size); + serverHello.Encode(writer); + writer = writer.Slice(ServerHello.Size); + certificateHandshake.Encode(writer); + writer = writer.Slice(Handshake.Size); + this.encodedCertificates[0].CopyTo(writer); + + // Protect initial record of the flight + peer.CurrentEpoch.RecordProtection.EncryptServerPlaintext( + packet.Slice(Record.Size, initialRecord.Length) + , packet.Slice(Record.Size, initialRecordPayloadSize) + , ref initialRecord + ); + + base.QueueRawData(packet, peerAddress); + + // Record record payload for verification + if (recordMessagesForVerifyData) + { + Handshake fullCeritficateHandshake = certificateHandshake; + fullCeritficateHandshake.FragmentLength = fullCeritficateHandshake.Length; + + packet = new byte[Handshake.Size + ServerHello.Size + Handshake.Size]; + writer = packet; + serverHelloHandshake.Encode(writer); + writer = writer.Slice(Handshake.Size); + serverHello.Encode(writer); + writer = writer.Slice(ServerHello.Size); + certificateHandshake.Encode(writer); + writer = writer.Slice(Handshake.Size); + + peer.NextEpoch.VerificationStream.Write( + packet.GetUnderlyingArray() + , packet.Offset + , packet.Length + ); + foreach (ByteSpan span in this.encodedCertificates) + { + peer.NextEpoch.VerificationStream.Write( + span.GetUnderlyingArray() + , span.Offset + , span.Length + ); + } + } + + // Process additional certificate records + for (int ii = 1, nn = this.encodedCertificates.Count; ii != nn; ++ii) + { + certificateHandshake.FragmentOffset += certificateHandshake.FragmentLength; + certificateHandshake.FragmentLength = (uint)this.encodedCertificates[ii].Length; + + int additionalRecordPayloadSize = Handshake.Size + (int)certificateHandshake.FragmentLength; + Record additionalRecord = new Record(); + additionalRecord.ContentType = ContentType.Handshake; + additionalRecord.Epoch = peer.Epoch; + additionalRecord.SequenceNumber = peer.CurrentEpoch.NextOutgoingSequence; + additionalRecord.Length = (ushort)peer.CurrentEpoch.RecordProtection.GetEncryptedSize(additionalRecordPayloadSize); + ++peer.CurrentEpoch.NextOutgoingSequence; + + // Convert record to wire format + packet = new byte[Record.Size + additionalRecord.Length]; + writer = packet; + additionalRecord.Encode(writer); + writer = writer.Slice(Record.Size); + certificateHandshake.Encode(writer); + writer = writer.Slice(Handshake.Size); + this.encodedCertificates[ii].CopyTo(writer); + + // Protect record + peer.CurrentEpoch.RecordProtection.EncryptServerPlaintext( + packet.Slice(Record.Size, additionalRecord.Length) + , packet.Slice(Record.Size, additionalRecordPayloadSize) + , ref additionalRecord + ); + + base.QueueRawData(packet, peerAddress); + } + + // Describe final record of the flight + Handshake serverKeyExchangeHandshake = new Handshake(); + serverKeyExchangeHandshake.MessageType = HandshakeType.ServerKeyExchange; + serverKeyExchangeHandshake.Length = (uint)peer.NextEpoch.Handshake.CalculateServerMessageSize(this.certificatePrivateKey); + serverKeyExchangeHandshake.MessageSequence = 3; + serverKeyExchangeHandshake.FragmentOffset = 0; + serverKeyExchangeHandshake.FragmentLength = serverKeyExchangeHandshake.Length; + + Handshake serverHelloDoneHandshake = new Handshake(); + serverHelloDoneHandshake.MessageType = HandshakeType.ServerHelloDone; + serverHelloDoneHandshake.Length = 0; + serverHelloDoneHandshake.MessageSequence = 4; + serverHelloDoneHandshake.FragmentOffset = 0; + serverHelloDoneHandshake.FragmentLength = 0; + + int finalRecordPayloadSize = 0 + + Handshake.Size + (int)serverKeyExchangeHandshake.Length + + Handshake.Size + (int)serverHelloDoneHandshake.Length + ; + Record finalRecord = new Record(); + finalRecord.ContentType = ContentType.Handshake; + finalRecord.Epoch = peer.Epoch; + finalRecord.SequenceNumber = peer.CurrentEpoch.NextOutgoingSequence; + finalRecord.Length = (ushort)peer.CurrentEpoch.RecordProtection.GetEncryptedSize(finalRecordPayloadSize); + ++peer.CurrentEpoch.NextOutgoingSequence; + + // Convert final record of the flight to wire + // format + packet = new byte[Record.Size + finalRecord.Length]; + writer = packet; + finalRecord.Encode(writer); + writer = writer.Slice(Record.Size); + serverKeyExchangeHandshake.Encode(writer); + writer = writer.Slice(Handshake.Size); + peer.NextEpoch.Handshake.EncodeServerKeyExchangeMessage(writer, this.certificatePrivateKey); + writer = writer.Slice((int)serverKeyExchangeHandshake.Length); + serverHelloDoneHandshake.Encode(writer); + + // Record record payload for verification + if (recordMessagesForVerifyData) + { + peer.NextEpoch.VerificationStream.Write( + packet.GetUnderlyingArray() + , packet.Offset + Record.Size + , finalRecordPayloadSize + ); + } + + // Protect final record of the flight + peer.CurrentEpoch.RecordProtection.EncryptServerPlaintext( + packet.Slice(Record.Size, finalRecord.Length) + , packet.Slice(Record.Size, finalRecordPayloadSize) + , ref finalRecord + ); + + base.QueueRawData(packet, peerAddress); + + return true; + } + + /// + /// Handle an incoming packet that is not tied to an existing peer + /// + /// Incoming datagram + /// Originating address + private void HandleNonPeerRecord(ByteSpan message, EndPoint peerAddress) + { + Record record; + if (!Record.Parse(out record, message)) + { + this.Logger.WriteError($"Dropping malformed record from non-peer `{peerAddress}`"); + return; + } + message = message.Slice(Record.Size); + + // The protocol only supports receiving a single record + // from a non-peer. + if (record.Length != message.Length) + { + ///NOTE(mendsley): This isn't always fatal. + /// However, this is an indication that something + /// fishy is going on. In the best case, there's a + /// bug on the client or in the UDP stack (some + /// stacks don't both to verify the checksum). In the + /// worst case we're dealing with a malicious actor. + /// In the malicious case, we'll end up dropping the + /// connection later in the process. + this.Logger.WriteInfo($"Received multiple record from non-peer `{peerAddress}`. Dropping all but first"); + if (message.Length < record.Length) + { + return; + } + } + + // We only accept zero-epoch records from non-peers + if (record.Epoch != 0) + { + ///NOTE(mendsley): Not logging anything here, as + /// this could easily be latent data arriving from a + /// recently disconnected peer. + return; + } + + // We only accept Handshake protocol messages from non-peers + if (record.ContentType != ContentType.Handshake) + { + this.Logger.WriteError($"Dropping non-handhsake message from non-peer `{peerAddress}`"); + return; + } + + ByteSpan originalMessage = message; + + Handshake handshake; + if (!Handshake.Parse(out handshake, message)) + { + this.Logger.WriteError($"Dropping malformed handshake message from non-peer `{peerAddress}`"); + return; + } + + // We only accept ClientHello messages from non-peers + if (handshake.MessageType != HandshakeType.ClientHello) + { + this.Logger.WriteError($"Dropping non-ClientHello ({handshake.MessageType}) message from non-peer `{peerAddress}`"); + return; + } + message = message.Slice(Handshake.Size); + + ClientHello clientHello; + if (!ClientHello.Parse(out clientHello, message)) + { + this.Logger.WriteError($"Dropping malformed ClientHello message from non-peer `{peerAddress}`"); + return; + } + + // If this ClientHello is not signed by us, request the + // client send us a signed message + if (!HelloVerifyRequest.VerifyCookie(clientHello.Cookie, peerAddress, this.currentCookieHmac)) + { + this.SendHelloVerifyRequest(peerAddress, 1, 0, NullRecordProtection.Instance); + return; + } + + // Allocate state for the new peer and register it + PeerData peer = new PeerData(); + peer.ResetPeer(this.AllocateConnectionId(), record.SequenceNumber + 1); + + this.existingPeers[peerAddress] = peer; + + lock (peer) + { + this.ProcessHandshake(peer, peerAddress, ref record, originalMessage); + } + } + + //Send a HelloVerifyRequest handshake message to a peer + private void SendHelloVerifyRequest(EndPoint peerAddress, ulong recordSequence, ushort epoch, IRecordProtection recordProtection) + { + Handshake handshake = new Handshake(); + handshake.MessageType = HandshakeType.HelloVerifyRequest; + handshake.Length = HelloVerifyRequest.Size; + handshake.MessageSequence = 0; + handshake.FragmentOffset = 0; + handshake.FragmentLength = handshake.Length; + + int plaintextPayloadSize = Handshake.Size + (int)handshake.Length; + + Record record = new Record(); + record.ContentType = ContentType.Handshake; + record.Epoch = epoch; + record.SequenceNumber = recordSequence; + record.Length = (ushort)recordProtection.GetEncryptedSize(plaintextPayloadSize); + + // Encode record to wire format + ByteSpan packet = new byte[Record.Size + record.Length]; + ByteSpan writer = packet; + record.Encode(writer); + writer = writer.Slice(Record.Size); + handshake.Encode(writer); + writer = writer.Slice(Handshake.Size); + HelloVerifyRequest.Encode(writer, peerAddress, this.currentCookieHmac); + + // Protect record payload + recordProtection.EncryptServerPlaintext( + packet.Slice(Record.Size, record.Length) + , packet.Slice(Record.Size, plaintextPayloadSize) + , ref record + ); + + base.QueueRawData(packet, peerAddress); + } + + /// + /// Handle a requrest to send a datagram to the network + /// protected override void QueueRawData(ByteSpan span, EndPoint remoteEndPoint) { - base.QueueRawData(span, remoteEndPoint); + PeerData peer; + if (!this.existingPeers.TryGetValue(remoteEndPoint, out peer)) + { + // Drop messages if we don't know how to send them + return; + } + + lock (peer) + { + // If we're negotiating a new epoch, queue data + if (peer.Epoch == 0 || peer.NextEpoch.State != HandshakeState.ExpectingHello) + { + ByteSpan copyOfSpan = new byte[span.Length]; + span.CopyTo(copyOfSpan); + + peer.QueuedApplicationDataMessage.Add(copyOfSpan); + return; + } + + // Send any queued application data now + for (int ii = 0, nn = peer.QueuedApplicationDataMessage.Count; ii != nn; ++ii) + { + ByteSpan queuedSpan = peer.QueuedApplicationDataMessage[ii]; + + Record outgoingRecord = new Record(); + outgoingRecord.ContentType = ContentType.ApplicationData; + outgoingRecord.Epoch = peer.Epoch; + outgoingRecord.SequenceNumber = peer.CurrentEpoch.NextOutgoingSequence; + outgoingRecord.Length = (ushort)peer.CurrentEpoch.RecordProtection.GetEncryptedSize(queuedSpan.Length); + ++peer.CurrentEpoch.NextOutgoingSequence; + + // Encode the record to wire format + ByteSpan packet = new byte[Record.Size + outgoingRecord.Length]; + ByteSpan writer = packet; + outgoingRecord.Encode(writer); + writer = writer.Slice(Record.Size); + queuedSpan.CopyTo(writer); + + // Protect the record + peer.CurrentEpoch.RecordProtection.EncryptServerPlaintext( + packet.Slice(Record.Size, outgoingRecord.Length) + , packet.Slice(Record.Size, queuedSpan.Length) + , ref outgoingRecord); + + base.QueueRawData(packet, remoteEndPoint); + } + peer.QueuedApplicationDataMessage.Clear(); + + { + Record outgoingRecord = new Record(); + outgoingRecord.ContentType = ContentType.ApplicationData; + outgoingRecord.Epoch = peer.Epoch; + outgoingRecord.SequenceNumber = peer.CurrentEpoch.NextExpectedSequence; + outgoingRecord.Length = (ushort)peer.CurrentEpoch.RecordProtection.GetEncryptedSize(span.Length); + ++peer.CurrentEpoch.NextOutgoingSequence; + + // Encode the record to wire format + ByteSpan packet = new byte[Record.Size + outgoingRecord.Length]; + ByteSpan writer = packet; + outgoingRecord.Encode(writer); + writer = writer.Slice(Record.Size); + span.CopyTo(writer); + + // Protect the record + peer.CurrentEpoch.RecordProtection.EncryptServerPlaintext( + packet.Slice(Record.Size, outgoingRecord.Length) + , packet.Slice(Record.Size, span.Length) + , ref outgoingRecord + ); + + base.QueueRawData(packet, remoteEndPoint); + } + } + } + + /// + /// Allocate a new connection id + /// + private ConnectionId AllocateConnectionId() + { + ulong rawConnectionId = (ulong)Interlocked.Increment(ref this.connectionId_unsafe); + return ConnectionId.Create(rawConnectionId); } } } diff --git a/Hazel/Dtls/Handshake.cs b/Hazel/Dtls/Handshake.cs index d9d8d0b..7ce3419 100644 --- a/Hazel/Dtls/Handshake.cs +++ b/Hazel/Dtls/Handshake.cs @@ -1,5 +1,54 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + namespace Hazel.Dtls { + /// + /// Handshake message type + /// + public enum HandshakeType : byte + { + HelloRequest = 0, + ClientHello = 1, + ServerHello = 2, + HelloVerifyRequest = 3, + Certificate = 11, + ServerKeyExchange = 12, + CertificateRequest = 13, + ServerHelloDone = 14, + CertificateVerify = 15, + ClientKeyExchange = 16, + Finished = 20, + } + + /// + /// List of cipher suites + /// + public enum CipherSuite + { + TLS_NULL_WITH_NULL_NULL = 0x0000, + TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 = 0xC02F, + } + + /// + /// List of compression methods + /// + public enum CompressionMethod : byte + { + Null = 0, + } + + /// + /// Extension type + /// + public enum ExtensionType : ushort + { + EllipticCurves = 10, + } + /// /// Named curves /// @@ -17,4 +66,434 @@ namespace Hazel.Dtls { NamedCurve = 3, } + + /// + /// Random state for entropy + /// + public struct Random + { + public const int Size = 0 + + 4 // gmt_unix_time + + 28 // random_bytes + ; + } + + /// + /// Encode/decode handshake protocol header + /// + public struct Handshake + { + public HandshakeType MessageType; + public uint Length; + public ushort MessageSequence; + public uint FragmentOffset; + public uint FragmentLength; + + public const int Size = 12; + + /// + /// Parse a Handshake protocol header from wire format + /// + /// True if we successfully decode a handshake header. Otherwise false + public static bool Parse(out Handshake header, ByteSpan span) + { + header = new Handshake(); + + if (span.Length < Size) + { + return false; + } + + header.MessageType = (HandshakeType)span[0]; + header.Length = span.ReadBigEndian24(1); + header.MessageSequence = span.ReadBigEndian16(4); + header.FragmentOffset = span.ReadBigEndian24(6); + header.FragmentLength = span.ReadBigEndian24(9); + return true; + } + + /// + /// Encode the Handshake protocol header to wire format + /// + /// + public void Encode(ByteSpan span) + { + span[0] = (byte)this.MessageType; + span.WriteBigEndian24(this.Length, 1); + span.WriteBigEndian16(this.MessageSequence, 4); + span.WriteBigEndian24(this.FragmentOffset, 6); + span.WriteBigEndian24(this.FragmentLength, 9); + } + } + + /// + /// Encode/decode ClientHello Handshake message + /// + public struct ClientHello + { + public ByteSpan Random; + public ByteSpan Cookie; + public ByteSpan CipherSuites; + public ByteSpan SupportedCurves; + + public const int MinSize = 0 + + 2 // client_version + + Dtls.Random.Size // random + + 1 // session_id (size) + + 1 // cookie (size) + + 2 // cipher_suites (size) + + 1 // compression_methods (size) + + 1 // compression_method[0] (NULL) + + + 0 // NamedCurveList extensions[0] + + 2 // extensions[0].extension_type + + 2 // extensions[0].extension_data (length) + + 2 // extensions[0].named_curve_list (size) + ; + + /// + /// Calculate the size in bytes required for the ClientHello payload + /// + /// + public int CalculateSize() + { + return MinSize + + this.Cookie.Length + + this.CipherSuites.Length + + this.SupportedCurves.Length + ; + } + + /// + /// Parse a Handshake ClientHello payload from wire format + /// + /// True if we successfully decode the ClientHello message. Otherwise false + public static bool Parse(out ClientHello result, ByteSpan span) + { + result = new ClientHello(); + if (span.Length < MinSize) + { + return false; + } + + ProtocolVersion clientVersion = (ProtocolVersion)span.ReadBigEndian16(); + if (clientVersion != ProtocolVersion.DTLS1_2) + { + return false; + } + span = span.Slice(2); + + result.Random = span.Slice(0, Dtls.Random.Size); + span = span.Slice(Dtls.Random.Size); + + ///NOTE(mendsley): We ignore session id + byte sessionIdSize = span[0]; + if (span.Length < 1 + sessionIdSize) + { + return false; + } + span = span.Slice(1 + sessionIdSize); + + byte cookieSize = span[0]; + if (span.Length < 1 + cookieSize) + { + return false; + } + result.Cookie = span.Slice(1, cookieSize); + span = span.Slice(1 + cookieSize); + + ushort cipherSuiteSize = span.ReadBigEndian16(); + if (span.Length < 2 + cipherSuiteSize) + { + return false; + } + else if (cipherSuiteSize % 2 != 0) + { + return false; + } + result.CipherSuites = span.Slice(2, cipherSuiteSize); + span = span.Slice(2 + cipherSuiteSize); + + int compressionMethodsSize = span[0]; + bool foundNullCompressionMethod = false; + for (int ii = 0; ii != compressionMethodsSize; ++ii) + { + if (span[1+ii] == (byte)CompressionMethod.Null) + { + foundNullCompressionMethod = true; + break; + } + } + span = span.Slice(1 + compressionMethodsSize); + + if (!foundNullCompressionMethod) + { + return false; + } + + // Parse extensions + while (span.Length > 0) + { + // Parse extension header + if (span.Length < 4) + { + return false; + } + + ExtensionType extensionType = (ExtensionType)span.ReadBigEndian16(0); + ushort extensionLength = span.ReadBigEndian16(2); + ByteSpan extensionData = span.Slice(4, extensionLength); + if (extensionData.Length < extensionLength) + { + return false; + } + + span = span.Slice(4 + extensionLength); + result.ParseExtension(extensionType, extensionData); + } + + return true; + } + + /// + /// Decode a ClientHello extension + /// + /// Extension type + /// Extension data + private void ParseExtension(ExtensionType extensionType, ByteSpan extensionData) + { + switch (extensionType) + { + case ExtensionType.EllipticCurves: + if (extensionData.Length % 2 != 0) + { + break; + } + else if (extensionData.Length < 2) + { + break; + } + + ushort namedCurveSize = extensionData.ReadBigEndian16(0); + if (namedCurveSize % 2 != 0) + { + break; + } + + this.SupportedCurves = extensionData.Slice(2, namedCurveSize); + break; + } + } + + /// + /// Determines if the ClientHello message advertises support + /// for the specified cipher suite + /// + public bool ContainsCipherSuite(CipherSuite cipherSuite) + { + ByteSpan iterator = this.CipherSuites; + while (iterator.Length >= 2) + { + if (iterator.ReadBigEndian16() == (ushort)cipherSuite) + { + return true; + } + + iterator = iterator.Slice(2); + } + + return false; + } + + /// + /// Determines if the ClientHello message advertises support + /// for the specified curve + /// + public bool ContainsCurve(NamedCurve curve) + { + ByteSpan iterator = this.SupportedCurves; + while (iterator.Length >= 2) + { + if (iterator.ReadBigEndian16() == (ushort)curve) + { + return true; + } + + iterator = iterator.Slice(2); + } + + return false; + } + + /// + /// Encode Handshake ClientHello payload to wire format + /// + public void Encode(ByteSpan span) + { + span.WriteBigEndian16((ushort)ProtocolVersion.DTLS1_2); + span = span.Slice(2); + + Debug.Assert(this.Random.Length == Dtls.Random.Size); + this.Random.CopyTo(span); + span = span.Slice(Dtls.Random.Size); + + // Do not encode session ids + span[0] = (byte)0; + span = span.Slice(1); + + span[0] = (byte)this.Cookie.Length; + this.Cookie.CopyTo(span.Slice(1)); + span = span.Slice(1 + this.Cookie.Length); + + span.WriteBigEndian16((ushort)this.CipherSuites.Length); + this.CipherSuites.CopyTo(span.Slice(2)); + span = span.Slice(2 + this.CipherSuites.Length); + + span[0] = 1; + span[1] = (byte)CompressionMethod.Null; + span = span.Slice(2); + + // Supported curves extension + span.WriteBigEndian16((ushort)ExtensionType.EllipticCurves); + span.WriteBigEndian16((ushort)(2 + this.SupportedCurves.Length), 2); + span.WriteBigEndian16((ushort)this.SupportedCurves.Length, 4); + this.SupportedCurves.CopyTo(span.Slice(6)); + } + } + + /// + /// Encode/decode Handshake HelloVerifyRequest message + /// + public struct HelloVerifyRequest + { + public const int CookieSize = 20; + public const int Size = 0 + + 2 // server_version + + 1 // cookie (size) + + CookieSize // cookie (data) + ; + + /// + /// Encode a HelloVerifyRequest payload to wire format + /// + /// Address of the remote peer + /// Listener HMAC signature provider + public static void Encode(ByteSpan span, EndPoint peerAddress, HMAC hmac) + { + ByteSpan cookie = ComputeAddressMac(peerAddress, hmac); + + span.WriteBigEndian16((ushort)ProtocolVersion.DTLS1_2); + span[2] = (byte)CookieSize; + cookie.CopyTo(span.Slice(3)); + } + + /// + /// Generate an HMAC for a peer address + /// + /// Address of the remote peer + /// Listener HMAC signature provider + public static ByteSpan ComputeAddressMac(EndPoint peerAddress, HMAC hmac) + { + SocketAddress address = peerAddress.Serialize(); + byte[] data = new byte[address.Size]; + for (int ii = 0, nn = data.Length; ii != nn; ++ii) + { + data[ii] = address[ii]; + } + + ///NOTE(mendsley): Lame that we need to allocate+copy here + ByteSpan signature = hmac.ComputeHash(data); + return signature.Slice(0, CookieSize); + } + + /// + /// Verify a client's cookie was signed by our listener + /// + /// Wire format cookie + /// Address of the remote peer + /// Listener HMAC signature provider + /// True if the cookie is valid. Otherwise false + public static bool VerifyCookie(ByteSpan cookie, EndPoint peerAddress, HMAC hmac) + { + ByteSpan expectedHash = ComputeAddressMac(peerAddress, hmac); + if (expectedHash.Length != cookie.Length) + { + return false; + } + + return (1 == Crypto.Const.ConstantCompareSpans(cookie, expectedHash)); + } + } + + /// + /// Encode/decode Handshake ServerHello message + /// + public struct ServerHello + { + //public ProtocolVersion ServerVersion; + public ByteSpan Random; + public CipherSuite CipherSuite; + + public const int Size = 0 + + 2 // server_version + + Dtls.Random.Size // random + + 1 // session_id (size) + + 2 // cipher_suite + + 1 // compression_method + ; + + /// + /// Encode Handshake ServerHello to wire format + /// + public void Encode(ByteSpan span) + { + Debug.Assert(this.Random.Length == Dtls.Random.Size); + + span.WriteBigEndian16((ushort)ProtocolVersion.DTLS1_2, 0); + span = span.Slice(2); + + this.Random.CopyTo(span); + span = span.Slice(Dtls.Random.Size); + + span[0] = 0; + span = span.Slice(1); + + span.WriteBigEndian16((ushort)this.CipherSuite); + span = span.Slice(2); + + span[0] = (byte)CompressionMethod.Null; + } + } + + /// + /// Encode/decode Handshake Certificate message + /// + public struct Certificate + { + /// + /// Encode a certificate to wire formate + /// + public static ByteSpan Encode(X509Certificate2 certificate) + { + ByteSpan certData = certificate.GetRawCertData(); + int totalSize = certData.Length + 3; + + ByteSpan result = new byte[totalSize]; + + ByteSpan writer = result; + writer.WriteBigEndian24((uint)certData.Length); + writer = writer.Slice(3); + + certData.CopyTo(writer); + return result; + } + } + + /// + /// Encode/decode Handshake Finished message + /// + public struct Finished + { + public const int Size = 12; + } } diff --git a/Hazel/Dtls/IRecordProtection.cs b/Hazel/Dtls/IRecordProtection.cs index 1d70c6a..cbee1b0 100644 --- a/Hazel/Dtls/IRecordProtection.cs +++ b/Hazel/Dtls/IRecordProtection.cs @@ -63,4 +63,22 @@ namespace Hazel.Dtls /// True if the input was authenticated and decrypted. Otherwise false bool DecryptCiphertextFromClient(ByteSpan output, ByteSpan input, ref Record record); } + + /// + /// Factory to create record protection from cipher suite identifiers + /// + public sealed class RecordProtectionFactory + { + public static IRecordProtection Create(CipherSuite cipherSuite, ByteSpan masterSecret, ByteSpan serverRandom, ByteSpan clientRandom) + { + switch (cipherSuite) + { + case CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: + return new Aes128GcmRecordProtection(masterSecret, serverRandom, clientRandom); + + default: + return null; + } + } + } } diff --git a/Hazel/Dtls/X25519EcdheRsaSha256.cs b/Hazel/Dtls/X25519EcdheRsaSha256.cs index 505f2d2..3e4fb59 100644 --- a/Hazel/Dtls/X25519EcdheRsaSha256.cs +++ b/Hazel/Dtls/X25519EcdheRsaSha256.cs @@ -160,7 +160,7 @@ namespace Hazel.Dtls public void EncodeClientKeyExchangeMessage(ByteSpan output) { output[0] = (byte)X25519.KeySize; - X25519.Func(output.Slice(1), this.privateAgreementKey); + X25519.Func(output.Slice(1, X25519.KeySize), this.privateAgreementKey); } /// diff --git a/Hazel/Hazel.csproj b/Hazel/Hazel.csproj index 5f1d51e..e091add 100644 --- a/Hazel/Hazel.csproj +++ b/Hazel/Hazel.csproj @@ -75,6 +75,7 @@ +