diff --git a/HomeKitDotNet/Connection.cs b/HomeKitDotNet/Connection.cs index b216059..8efa9d6 100644 --- a/HomeKitDotNet/Connection.cs +++ b/HomeKitDotNet/Connection.cs @@ -32,6 +32,8 @@ public class Connection : IDisposable Channel responses = Channel.CreateUnbounded(); public event EventHandler? EventReceived; CancellationTokenSource token = new CancellationTokenSource(); + private Task m_pReceiveTask = Task.CompletedTask; + private int m_nDisposed; public Connection() { @@ -46,27 +48,34 @@ public async Task ConnectAsync(IPEndPoint ep, CancellationToken cancellationToke await client.ConnectAsync(ep.Address, ep.Port, cancellationToken); writer = new EncryptedStreamWriter(client.GetStream()); reader = new EncryptedStreamReader(client.GetStream()); - await Task.Factory.StartNew(ReceiveLoop, token.Token); + m_pReceiveTask = Task.Run(ReceiveLoop); } public void Dispose() { - token.Cancel(); - token.Dispose(); - client.Dispose(); + // Close pending response waits as well as the socket, allowing repeated disposal. + if (Interlocked.Exchange(ref m_nDisposed, 1) == 0) + { + token.Cancel(); + client.Dispose(); + responses.Writer.TryComplete(new IOException("The HAP connection was closed.")); + } } public async Task EnableEncryption(byte[] writeKey, byte[] readKey) { AssertConnected(); + // Await the plaintext reader before enabling encrypted framing. token.Cancel(); + await m_pReceiveTask; + ObjectDisposedException.ThrowIf(Volatile.Read(ref m_nDisposed) != 0, this); token.Dispose(); writer.EnableEncryption(new ChaCha20Poly1305(writeKey)); reader.EnableDecryption(new ChaCha20Poly1305(readKey)); token = new CancellationTokenSource(); - await Task.Factory.StartNew(ReceiveLoop, token.Token); + m_pReceiveTask = Task.Run(ReceiveLoop); } public async Task Get(string path, CancellationToken token) @@ -98,11 +107,14 @@ public async Task PostTLVs(string path, CancellationToken t public async Task ReceiveLoop() { + // Keep the token belonging to this loop during an encryption transition. + CancellationToken cancellationToken = token.Token; + try { - while (!token.IsCancellationRequested) + while (!cancellationToken.IsCancellationRequested) { - string? line = await reader!.ReadLineAsync(token.Token); + string? line = await reader!.ReadLineAsync(cancellationToken); if (line == null) throw new EndOfStreamException(); string[] parts = line.Split(' ', 3); @@ -122,53 +134,103 @@ public async Task ReceiveLoop() if (parts.Length == 2 && parts[0].Equals("Content-Type", StringComparison.InvariantCultureIgnoreCase)) type = parts[1]; if (parts.Length == 2 && parts[0].Equals("Transfer-Encoding", StringComparison.InvariantCultureIgnoreCase)) - chunked = parts[1].Equals("chunked", StringComparison.InvariantCultureIgnoreCase); - line = await reader.ReadLineAsync(token.Token); + { + foreach (string encoding in parts[1].Split(',', StringSplitOptions.TrimEntries)) + { + if (encoding.Equals("chunked", StringComparison.OrdinalIgnoreCase)) + chunked = true; + } + + } + line = await reader.ReadLineAsync(cancellationToken); } if (line == null) throw new EndOfStreamException(); - if (contentLen != 0) + // Chunked framing includes extensions, a final terminator and optional trailers. + if (chunked) + { + response.Content = new ByteArrayContent(await ReadChunkedBodyAsync(cancellationToken)); + } + else if (contentLen != 0) { byte[] contentBytes = new byte[contentLen]; - await reader.ReadBytesAsync(contentBytes, contentLen, token.Token); + await reader.ReadBytesAsync(contentBytes, contentLen, cancellationToken); response.Content = new ByteArrayContent(contentBytes); - response.Content.Headers.ContentType = new MediaTypeHeaderValue(type); } - else if (chunked) + + if (MediaTypeHeaderValue.TryParse(type, out MediaTypeHeaderValue? contentType)) + response.Content.Headers.ContentType = contentType; + + if (protocol == "EVENT/1.0") { - byte[] chunk = new byte[1024]; - MemoryStream ms = new MemoryStream(); - if (!int.TryParse((await reader.ReadLineAsync(token.Token)).Split(' ')[0], NumberStyles.HexNumber, null, out int chunkSize)) - throw new EndOfStreamException("Truncated chunk read"); - - while (chunkSize > 0) + // Subscribers read from the beginning; the connection owns the response. + try { - if (chunkSize > chunk.Length) - chunk = new byte[chunkSize]; - await reader.ReadBytesAsync(chunk, chunkSize, token.Token); - ms.Write(chunk, 0, chunkSize); - await reader.Seek(2, token.Token); //CRLF after chunk - if (!int.TryParse((await reader.ReadLineAsync(token.Token)).Split(' ')[0], NumberStyles.HexNumber, null, out chunkSize)) - throw new EndOfStreamException("Truncated chunk read"); - } + Delegate[] arHandlers = EventReceived?.GetInvocationList() ?? []; + + foreach (Delegate pHandler in arHandlers) + { + Stream pContent = response.Content.ReadAsStream(); + pContent.Position = 0; + ((EventHandler)pHandler).Invoke(this, response); + } - await reader.Seek(2, token.Token); //CRLF after chunk - response.Content = new ByteArrayContent(ms.ToArray()); - response.Content.Headers.ContentType = new MediaTypeHeaderValue(type); + } + finally + { + response.Dispose(); + } } - if (protocol == "EVENT/1.0") - EventReceived?.Invoke(this, response); else + { responses.Writer.TryWrite(response); + } } } - catch (OperationCanceledException) { } // Ignore + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } // Ignore catch (Exception e) { - Console.WriteLine("Error: " + e.ToString()); + // Propagate reader failures instead of leaving requests waiting indefinitely. + responses.Writer.TryComplete(e); + Dispose(); } } + private async Task ReadChunkedBodyAsync(CancellationToken cancellationToken) + { + // Reassemble chunks and consume trailers before reading the next response. + const int nMaxBodyLength = 16 * 1024 * 1024; + using MemoryStream pBody = new MemoryStream(); + int nLength; + + do + { + string sLine = await reader!.ReadLineAsync(cancellationToken); + int nExtension = sLine.IndexOf(';'); + string sSize = nExtension >= 0 ? sLine.Substring(0, nExtension) : sLine; + + if (!int.TryParse(sSize, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out nLength) || nLength < 0 || nLength > nMaxBodyLength - pBody.Length) + throw new InvalidDataException("Invalid HAP HTTP chunk size."); + + if (nLength > 0) + { + byte[] arChunk = new byte[nLength]; + await reader.ReadBytesAsync(arChunk, nLength, cancellationToken); + pBody.Write(arChunk); + + if ((await reader.ReadLineAsync(cancellationToken)).Length != 0) + throw new InvalidDataException("Invalid HAP HTTP chunk terminator."); + } + } + while (nLength != 0); + + while ((await reader!.ReadLineAsync(cancellationToken)).Length != 0) + { + } + + return pBody.ToArray(); + } + protected async Task SendAsync(HttpMethod method, string path, ByteArrayContent? content, CancellationToken token) { AssertConnected(); @@ -187,13 +249,23 @@ protected async Task SendAsync(HttpMethod method, string pa msg.Append($"Content-Type: {content.Headers.ContentType}\r\n"); } msg.Append("\r\n"); - await semaphore.WaitAsync(token); + // Cancelled or timed-out requests must not leave a reusable, desynchronized session. + using CancellationTokenSource pLifetime = CancellationTokenSource.CreateLinkedTokenSource(token, this.token.Token); + await semaphore.WaitAsync(pLifetime.Token); + pLifetime.CancelAfter(TimeSpan.FromSeconds(15)); + using CancellationTokenRegistration pRegistration = pLifetime.Token.Register(Dispose); + try{ - await writer.WriteAsync(Encoding.UTF8.GetBytes(msg.ToString()), token); + await writer.WriteAsync(Encoding.UTF8.GetBytes(msg.ToString()), pLifetime.Token); if (contentStream != null) - await writer.WriteAsync(contentStream, token); - await writer.FlushAsync(token); - return await responses.Reader.ReadAsync(token); + await writer.WriteAsync(contentStream, pLifetime.Token); + await writer.FlushAsync(pLifetime.Token); + return await responses.Reader.ReadAsync(pLifetime.Token); + } + catch + { + Dispose(); + throw; } finally { diff --git a/HomeKitDotNet/Controller.cs b/HomeKitDotNet/Controller.cs index e06aab0..7787569 100644 --- a/HomeKitDotNet/Controller.cs +++ b/HomeKitDotNet/Controller.cs @@ -88,84 +88,111 @@ public async Task Connect(IPEndPoint destination, byte[] access var keyPair = session.GenerateKeyPair(); Connection con = new Connection(); - await con.ConnectAsync(destination, token); + // Release incomplete sessions on cancellation, authentication or catalog failure. + using CancellationTokenRegistration pRegistration = token.Register(con.Dispose); + bool bConnected = false; - //Step 1 - HttpResponseMessage msg = await con.PostTLVs("/pair-verify", token, new TLVInt(TLVType.kTLVType_State, (byte)PairingState.M1), new TLVBytes(TLVType.kTLVType_PublicKey, keyPair.Public)); - if (!msg.IsSuccessStatusCode) + try { - con.Dispose(); - throw new IOException($"Pair Step 1 Failed with {msg.StatusCode}: {msg.ReasonPhrase}"); - } + await con.ConnectAsync(destination, token); - List responseTLVs = TLVValue.CollectionFromStream(msg.Content.ReadAsStream()); - byte[] AccessorySessionPK = TLVValue.Concat(TLVType.kTLVType_PublicKey, responseTLVs); - ArraySegment encrypted = (responseTLVs.Find(t => t.Type == TLVType.kTLVType_EncryptedData) as TLVBytes)!.Value; - byte[] payload = new byte[encrypted.Count - 16]; - byte[] sharedSecret = session.GetSharedSecret(AccessorySessionPK); + //Step 1 + HttpResponseMessage msg = await con.PostTLVs("/pair-verify", token, new TLVInt(TLVType.kTLVType_State, (byte)PairingState.M1), new TLVBytes(TLVType.kTLVType_PublicKey, keyPair.Public)); + if (!msg.IsSuccessStatusCode) + { + con.Dispose(); + throw new IOException($"Pair Step 1 Failed with {msg.StatusCode}: {msg.ReasonPhrase}"); + } - byte[] sessionKey = HKDF.DeriveKey(HashAlgorithmName.SHA512, sharedSecret, 32, Encoding.UTF8.GetBytes("Pair-Verify-Encrypt-Salt"), Encoding.UTF8.GetBytes("Pair-Verify-Encrypt-Info")); + List responseTLVs = TLVValue.CollectionFromStream(msg.Content.ReadAsStream()); + byte[] AccessorySessionPK = TLVValue.Concat(TLVType.kTLVType_PublicKey, responseTLVs); + ArraySegment encrypted = (responseTLVs.Find(t => t.Type == TLVType.kTLVType_EncryptedData) as TLVBytes)!.Value; + byte[] payload = new byte[encrypted.Count - 16]; + byte[] sharedSecret = session.GetSharedSecret(AccessorySessionPK); - ChaCha20Poly1305 decrypter = new ChaCha20Poly1305(sessionKey); - decrypter.Decrypt(Encoding.ASCII.GetBytes("\0\0\0\0PV-Msg02"), encrypted.Slice(0, encrypted.Count - 16), encrypted.Slice(encrypted.Count - 16, 16), payload); - responseTLVs = TLVValue.CollectionFromStream(new MemoryStream(payload)); + byte[] sessionKey = HKDF.DeriveKey(HashAlgorithmName.SHA512, sharedSecret, 32, Encoding.UTF8.GetBytes("Pair-Verify-Encrypt-Salt"), Encoding.UTF8.GetBytes("Pair-Verify-Encrypt-Info")); - byte[] AccessoryInfo = BufferUtil.Concat(AccessorySessionPK, accessoryID, keyPair.Public); - byte[] accessorySignature = (responseTLVs.Find(t => t.Type == TLVType.kTLVType_Signature) as TLVBytes)!.Value; - if (!Ed25519.Verify(accessorySignature, AccessoryInfo, accessoryLTPK)) - { - con.Dispose(); - throw new IOException("Accessory returned invalid signature"); - } + ChaCha20Poly1305 decrypter = new ChaCha20Poly1305(sessionKey); + decrypter.Decrypt(Encoding.ASCII.GetBytes("\0\0\0\0PV-Msg02"), encrypted.Slice(0, encrypted.Count - 16), encrypted.Slice(encrypted.Count - 16, 16), payload); + responseTLVs = TLVValue.CollectionFromStream(new MemoryStream(payload)); - byte[] iOSDeviceInfo = BufferUtil.Concat(keyPair.Public, DeviceID, AccessorySessionPK); - byte[] iOSDeviceSignature = Ed25519.Sign(iOSDeviceInfo, LTSK); - byte[] encryptedPayload; - using (MemoryStream encryptionPayload = new MemoryStream()) - { - TLVValue.WriteCollection([ - new TLVBytes(TLVType.kTLVType_Signature, iOSDeviceSignature), - new TLVBytes(TLVType.kTLVType_Identifier, DeviceID) - ], encryptionPayload); - ChaCha20Poly1305 encrypter = new ChaCha20Poly1305(sessionKey); - encryptedPayload = new byte[encryptionPayload.Length + 16]; - encrypter.Encrypt(Encoding.ASCII.GetBytes("\0\0\0\0PV-Msg03"), encryptionPayload.ToArray(), encryptedPayload.AsSpan().Slice(0, (int)encryptionPayload.Length), encryptedPayload.AsSpan().Slice((int)encryptionPayload.Length, 16)); - } + byte[] AccessoryInfo = BufferUtil.Concat(AccessorySessionPK, accessoryID, keyPair.Public); + byte[] accessorySignature = (responseTLVs.Find(t => t.Type == TLVType.kTLVType_Signature) as TLVBytes)!.Value; + if (!Ed25519.Verify(accessorySignature, AccessoryInfo, accessoryLTPK)) + { + con.Dispose(); + throw new IOException("Accessory returned invalid signature"); + } - msg = await con.PostTLVs("/pair-verify", token, - new TLVInt(TLVType.kTLVType_State, (byte)PairingState.M3), - new TLVBytes(TLVType.kTLVType_EncryptedData, encryptedPayload) - ); - if (!msg.IsSuccessStatusCode) - { - con.Dispose(); - string content = new StreamReader(msg.Content.ReadAsStream()).ReadToEnd(); - throw new IOException($"Verify Step 3 Failed with {msg.StatusCode}: {msg.ReasonPhrase}: {content}"); - } - responseTLVs = TLVValue.CollectionFromStream(msg.Content.ReadAsStream()); + byte[] iOSDeviceInfo = BufferUtil.Concat(keyPair.Public, DeviceID, AccessorySessionPK); + byte[] iOSDeviceSignature = Ed25519.Sign(iOSDeviceInfo, LTSK); + byte[] encryptedPayload; + using (MemoryStream encryptionPayload = new MemoryStream()) + { + TLVValue.WriteCollection([ + new TLVBytes(TLVType.kTLVType_Signature, iOSDeviceSignature), + new TLVBytes(TLVType.kTLVType_Identifier, DeviceID) + ], encryptionPayload); + ChaCha20Poly1305 encrypter = new ChaCha20Poly1305(sessionKey); + encryptedPayload = new byte[encryptionPayload.Length + 16]; + encrypter.Encrypt(Encoding.ASCII.GetBytes("\0\0\0\0PV-Msg03"), encryptionPayload.ToArray(), encryptedPayload.AsSpan().Slice(0, (int)encryptionPayload.Length), encryptedPayload.AsSpan().Slice((int)encryptionPayload.Length, 16)); + } + + msg = await con.PostTLVs("/pair-verify", token, + new TLVInt(TLVType.kTLVType_State, (byte)PairingState.M3), + new TLVBytes(TLVType.kTLVType_EncryptedData, encryptedPayload) + ); + if (!msg.IsSuccessStatusCode) + { + con.Dispose(); + string content = new StreamReader(msg.Content.ReadAsStream()).ReadToEnd(); + throw new IOException($"Verify Step 3 Failed with {msg.StatusCode}: {msg.ReasonPhrase}: {content}"); + } + responseTLVs = TLVValue.CollectionFromStream(msg.Content.ReadAsStream()); - bool error = responseTLVs.Any(t => t.Type == TLVType.kTLVType_Error); + bool error = responseTLVs.Any(t => t.Type == TLVType.kTLVType_Error); - if (!error) - { - byte[] readKey = HKDF.DeriveKey(HashAlgorithmName.SHA512, sharedSecret, 32, Encoding.UTF8.GetBytes("Control-Salt"), Encoding.UTF8.GetBytes("Control-Read-Encryption-Key")); - byte[] writeKey = HKDF.DeriveKey(HashAlgorithmName.SHA512, sharedSecret, 32, Encoding.UTF8.GetBytes("Control-Salt"), Encoding.UTF8.GetBytes("Control-Write-Encryption-Key")); - await con.EnableEncryption(writeKey, readKey); - - HttpResponseMessage resp = await con.Get("/accessories", token); - AccessoriesJSON? accessories = JsonSerializer.Deserialize(resp.Content.ReadAsStream(token)); - HomeKitEndPoint ep; - if (accessories != null) + // Require M4 before enabling encrypted traffic. + bool bConfirmed = false; + + foreach (TLVValue pValue in responseTLVs) { - ep = new HomeKitEndPoint(con, accessories); - endpoints.Add(ep); - return ep; + if (pValue is TLVInt pState && pState.Type == TLVType.kTLVType_State && pState.Value == (byte)PairingState.M4) + bConfirmed = true; + } + + if (!error && bConfirmed) + { + byte[] readKey = HKDF.DeriveKey(HashAlgorithmName.SHA512, sharedSecret, 32, Encoding.UTF8.GetBytes("Control-Salt"), Encoding.UTF8.GetBytes("Control-Read-Encryption-Key")); + byte[] writeKey = HKDF.DeriveKey(HashAlgorithmName.SHA512, sharedSecret, 32, Encoding.UTF8.GetBytes("Control-Salt"), Encoding.UTF8.GetBytes("Control-Write-Encryption-Key")); + await con.EnableEncryption(writeKey, readKey); + + // Reject HTTP errors before deserializing the accessory catalog. + using HttpResponseMessage resp = await con.Get("/accessories", token); + + if (!resp.IsSuccessStatusCode) + throw new IOException($"GET /accessories: HTTP {(int)resp.StatusCode} {resp.ReasonPhrase}."); + + AccessoriesJSON? accessories = JsonSerializer.Deserialize(resp.Content.ReadAsStream(token)); + HomeKitEndPoint ep; + if (accessories != null) + { + ep = new HomeKitEndPoint(con, accessories); + endpoints.Add(ep); + bConnected = true; + return ep; + } + con.Dispose(); + throw new IOException("Empty Accessory List Received"); } con.Dispose(); - throw new IOException("Empty Accessory List Received"); + throw new UnauthorizedAccessException("Failed to Connect - Authentication Error"); + } + finally + { + if (!bConnected) + con.Dispose(); } - con.Dispose(); - throw new UnauthorizedAccessException("Failed to Connect - Authentication Error"); } /// @@ -178,7 +205,7 @@ public async Task Connect(IPEndPoint destination, byte[] access public async Task UnPair(HomeKitEndPoint endpoint, CancellationToken token = default) { ArgumentNullException.ThrowIfNull(endpoint, nameof(endpoint)); - HttpResponseMessage msg = await endpoint.Connection.PostTLVs("/pair-setup", token, + using HttpResponseMessage msg = await endpoint.Connection.PostTLVs("/pairings", token, new TLVInt(TLVType.kTLVType_State, (byte)PairingState.M1), new TLVInt(TLVType.kTLVType_Method, (byte)Method.RemovePairing), new TLVBytes(TLVType.kTLVType_Identifier, DeviceID)); @@ -188,9 +215,22 @@ public async Task UnPair(HomeKitEndPoint endpoint, CancellationToken token if (responseTLVs.Any(t => t.Type == TLVType.kTLVType_Error)) return false; - endpoints.Remove(endpoint); - endpoint.Connection.Dispose(); - return true; + // Remove the local endpoint only after an M2 acknowledgment. + bool bConfirmed = false; + + foreach (TLVValue pValue in responseTLVs) + { + if (pValue is TLVInt pState && pState.Type == TLVType.kTLVType_State && pState.Value == (byte)PairingState.M2) + bConfirmed = true; + } + + if (bConfirmed) + { + endpoints.Remove(endpoint); + endpoint.Connection.Dispose(); + } + + return bConfirmed; } /// @@ -246,7 +286,19 @@ public async Task Pair(long setupPin, IPEndPoint destination, boo throw new IOException($"Pair Step 3 Failed - Public key is missing"); BigInteger B = new BigInteger(accessoryPK, true, true); - byte[] s = (responseTLVs.Find(t => t.Type == TLVType.kTLVType_Salt) as TLVBytes)!.Value; + // Validate the salt before using the Pair Setup response. + TLVBytes? pSalt = null; + + foreach (TLVValue pValue in responseTLVs) + { + if (pValue is TLVBytes pBytes && pBytes.Type == TLVType.kTLVType_Salt) + pSalt = pBytes; + } + + if (pSalt == null) + throw new IOException("Pair Setup response does not contain a salt."); + + byte[] s = pSalt.Value; SRP srp = new SRP(); BigInteger A = srp.GenerateAValues(); BigInteger K = srp.ComputeSessionKey(I, P, s, B); diff --git a/HomeKitDotNet/Crypto/X25519.cs b/HomeKitDotNet/Crypto/X25519.cs index 4c5d55e..09c06f6 100644 --- a/HomeKitDotNet/Crypto/X25519.cs +++ b/HomeKitDotNet/Crypto/X25519.cs @@ -11,12 +11,14 @@ // along with this program. If not, see . using System.Security.Cryptography; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Security; namespace HomeKitDotNet.Crypto { public class X25519 { - ECDiffieHellman ecc = ECDiffieHellman.Create(Curve25519); + private X25519PrivateKeyParameters? m_pPrivateKey; internal static ECCurve Curve25519 = new ECCurve() { CurveType = ECCurve.ECCurveType.PrimeMontgomery, @@ -34,28 +36,42 @@ public class X25519 public (byte[] Public, byte[] Private) GenerateKeyPair() { - ecc.GenerateKey(Curve25519); - var p = ecc.ExportParameters(true); - return (p.Q.X!, p.D!); + // Generate keys independently of the operating-system cryptographic provider. + X25519PrivateKeyParameters pPrivateKey = new X25519PrivateKeyParameters(new SecureRandom()); + m_pPrivateKey = pPrivateKey; + return (pPrivateKey.GeneratePublicKey().GetEncoded(), pPrivateKey.GetEncoded()); } public void LoadKeys(byte[] @public, byte[] @private) { - ecc.ImportParameters(new ECParameters() - { - Curve = Curve25519, - D = @private, - Q = new ECPoint() - { - X = @public, - Y = new byte[32] - } - }); + ArgumentNullException.ThrowIfNull(@public); + ArgumentNullException.ThrowIfNull(@private); + + if (@private.Length != X25519PrivateKeyParameters.KeySize) + throw new ArgumentException("Invalid X25519 private key length.", nameof(@private)); + + // Reject inconsistent key pairs before using them for Pair Verify. + X25519PrivateKeyParameters pPrivateKey = new X25519PrivateKeyParameters(@private, 0); + + if (!@public.AsSpan().SequenceEqual(pPrivateKey.GeneratePublicKey().GetEncoded())) + throw new ArgumentException("The X25519 public key does not match the private key.", nameof(@public)); + + m_pPrivateKey = pPrivateKey; } public byte[] GetSharedSecret(byte[] otherPublic) { - return ecc.DeriveRawSecretAgreement(new ECCKey(otherPublic)); + ArgumentNullException.ThrowIfNull(otherPublic); + + if (otherPublic.Length != X25519PublicKeyParameters.KeySize) + throw new ArgumentException("Invalid X25519 public key length.", nameof(otherPublic)); + + // Derive the same X25519 shared secret on Windows and Linux. + X25519PrivateKeyParameters pPrivateKey = m_pPrivateKey ?? throw new InvalidOperationException("No X25519 private key is available."); + X25519PublicKeyParameters pPeer = new X25519PublicKeyParameters(otherPublic, 0); + byte[] arSecret = new byte[X25519PrivateKeyParameters.SecretSize]; + pPrivateKey.GenerateSecret(pPeer, arSecret, 0); + return arSecret; } } } diff --git a/HomeKitDotNet/EncryptedStreamReader.cs b/HomeKitDotNet/EncryptedStreamReader.cs index 3b40aaa..0643ff4 100644 --- a/HomeKitDotNet/EncryptedStreamReader.cs +++ b/HomeKitDotNet/EncryptedStreamReader.cs @@ -31,33 +31,45 @@ public EncryptedStreamReader(Stream stream) public async Task ReadLineAsync(CancellationToken token) { - int newLine = -1; - if (decrypter != null && readPos >= buffEnd) - await ReadEncrypted(token); - if (buffEnd > 0) - newLine = FindReturn(buffer.Slice(readPos, buffEnd - readPos).Span); - if (decrypter == null) + // Reassemble HTTP lines across TCP reads and encrypted frames. + using MemoryStream line = new MemoryStream(); + bool complete = false; + + while (!complete) { - while (newLine == -1) + if (readPos == buffEnd) { - if (readPos != 0) + readPos = 0; + + if (decrypter != null) { - if (readPos != buffEnd) - buffer.Slice(readPos, buffEnd - readPos).CopyTo(buffer); - buffEnd -= readPos; - readPos = 0; + await ReadEncrypted(token); + } + else + { + buffEnd = await stream.ReadAsync(buffer, token); + + if (buffEnd == 0) + throw new EndOfStreamException(); } - buffEnd += await stream.ReadAsync(buffer.Slice(buffEnd), token); - newLine = FindReturn(buffer.Slice(readPos, buffEnd - readPos).Span); } + + int newline = FindReturn(buffer.Slice(readPos, buffEnd - readPos).Span); + int length = newline >= 0 ? newline : buffEnd - readPos; + line.Write(buffer.Slice(readPos, length).Span); + readPos += length; + complete = newline >= 0; + + if (line.Length > 16384) + throw new InvalidDataException("HAP HTTP header line too long."); } - else if (newLine == -1) - { - throw new InvalidDataException("Unable to find newline in decoded data"); - } - string ret = Encoding.UTF8.GetString(buffer.Slice(readPos, newLine - 2).Span); - readPos += newLine; - return ret; + + byte[] bytes = line.ToArray(); + + if (bytes.Length < 2 || bytes[^2] != 13) + throw new InvalidDataException("Invalid HAP HTTP line ending."); + + return Encoding.UTF8.GetString(bytes, 0, bytes.Length - 2); } public async Task Seek(int offset, CancellationToken token) @@ -106,15 +118,17 @@ public async Task ReadBytesAsync(byte[] bytes, int contentLength, CancellationTo private async Task ReadUnencryptedStream(byte[] bytes, int contentLength, CancellationToken token) { int pos = 0; + if (readPos != buffEnd) { - buffer.Slice(readPos, buffEnd - readPos).CopyTo(bytes); - pos += (buffEnd - readPos); - buffEnd = 0; - readPos = 0; + // Consume only the requested bytes, preserving buffered data for subsequent reads. + pos = Math.Min(contentLength, buffEnd - readPos); + buffer.Slice(readPos, pos).CopyTo(bytes); + readPos += pos; } - while (pos < contentLength - 1) - pos += await stream.ReadAsync(bytes, pos, contentLength - pos, token); + + // Read the complete payload and throw if the stream closes prematurely. + await stream.ReadExactlyAsync(bytes.AsMemory(pos, contentLength - pos), token); } private async Task ReadEncrypted(CancellationToken token) //read line @@ -124,7 +138,12 @@ private async Task ReadEncrypted(CancellationToken token) //read line await stream.ReadExactlyAsync(buffer.Slice(2, len + 16), token); byte[] nonce = new byte[12]; BinaryPrimitives.WriteUInt64LittleEndian(nonce.AsSpan().Slice(4), counter++); - decrypter!.Decrypt(nonce.AsSpan(), buffer.Slice(2, len).Span, buffer.Slice(2 + len, 16).Span, buffer.Slice(0, len).Span, buffer.Slice(0, 2).Span); + + // Avoid overlapping input and output buffers, which OpenSSL does not support. + byte[] plaintext = new byte[len]; + decrypter!.Decrypt(nonce.AsSpan(), buffer.Slice(2, len).Span, buffer.Slice(2 + len, 16).Span, plaintext.AsSpan(), buffer.Slice(0, 2).Span); + plaintext.AsSpan().CopyTo(buffer.Slice(0, len).Span); + buffEnd = len; readPos = 0; } diff --git a/HomeKitDotNet/HomeKitDotNet.csproj b/HomeKitDotNet/HomeKitDotNet.csproj index e9ea5ba..5622b9b 100644 --- a/HomeKitDotNet/HomeKitDotNet.csproj +++ b/HomeKitDotNet/HomeKitDotNet.csproj @@ -49,4 +49,8 @@ + + + + diff --git a/README.md b/README.md index 7e7bca7..934e714 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ [![Version](https://img.shields.io/nuget/v/HomeKitDotNet.svg)](https://www.nuget.org/packages/HomeKitDotNet) # HomeKitDotNet * A 100% native implementation of a HomeKit Controller in dotnet -* Works on all architectures and operating systems without dependencies +* Works on all architectures and operating systems +* Uses BouncyCastle.Cryptography for cross-platform X25519 key agreement * Implements all 243 characteristics and 71 services #### Working: diff --git a/Test/ConnectionTest.cs b/Test/ConnectionTest.cs new file mode 100644 index 0000000..743e786 --- /dev/null +++ b/Test/ConnectionTest.cs @@ -0,0 +1,183 @@ +using HomeKitDotNet; +using HomeKitDotNet.JSON; +using HomeKitDotNet.Models; +using HomeKitDotNet.TLV.Enums; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Channels; + +namespace Test; + +[TestClass] +public sealed class ConnectionTest +{ + private readonly List m_lstEventPayloads = new List(); + + [TestMethod] + public async Task ChunkExtensionsAndTrailersPreserveTheNextResponse() + { + // A complete chunked response must not consume or corrupt the following response. + using CancellationTokenSource pTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using TcpListener pListener = new TcpListener(IPAddress.Loopback, 0); + pListener.Start(); + using Connection pConnection = new Connection(); + await pConnection.ConnectAsync((IPEndPoint)pListener.LocalEndpoint, pTimeout.Token); + using TcpClient pPeer = await pListener.AcceptTcpClientAsync(pTimeout.Token); + Task pPending = pConnection.Get("/first", pTimeout.Token); + await ReadRequestAsync(pPeer, pTimeout.Token); + string sResponse = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Type: text/plain\r\n\r\n3;extension=value\r\nabc\r\n0\r\nX-Trailer: value\r\n\r\nHTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\nZ"; + await pPeer.GetStream().WriteAsync(Encoding.ASCII.GetBytes(sResponse), pTimeout.Token); + using HttpResponseMessage pFirst = await pPending.WaitAsync(pTimeout.Token); + Assert.AreEqual("abc", await pFirst.Content.ReadAsStringAsync()); + using HttpResponseMessage pSecond = await pConnection.Get("/second", pTimeout.Token); + Assert.AreEqual("Z", await pSecond.Content.ReadAsStringAsync()); + await ReadRequestAsync(pPeer, pTimeout.Token); + } + + [TestMethod] + public async Task EncryptionTransitionUsesOnlyOneReceiveLoop() + { + // Finish a plaintext request, then exchange another response using HAP encryption. + using CancellationTokenSource pTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using TcpListener pListener = new TcpListener(IPAddress.Loopback, 0); + pListener.Start(); + using Connection pConnection = new Connection(); + await pConnection.ConnectAsync((IPEndPoint)pListener.LocalEndpoint, pTimeout.Token); + using TcpClient pPeer = await pListener.AcceptTcpClientAsync(pTimeout.Token); + Task pPlainPending = pConnection.Get("/plaintext", pTimeout.Token); + await ReadRequestAsync(pPeer, pTimeout.Token); + await pPeer.GetStream().WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 204 No Content\r\n\r\n"), pTimeout.Token); + using HttpResponseMessage pPlainResponse = await pPlainPending.WaitAsync(pTimeout.Token); + byte[] arWriteKey = new byte[32]; + byte[] arReadKey = new byte[32]; + arReadKey[0] = 1; + await pConnection.EnableEncryption(arWriteKey, arReadKey).WaitAsync(pTimeout.Token); + Task pEncryptedPending = pConnection.Get("/encrypted", pTimeout.Token); + + // Decrypt the outgoing request as well, so the complete round trip is exercised. + EncryptedStreamReader pServerReader = new EncryptedStreamReader(pPeer.GetStream()); + using ChaCha20Poly1305 pDecrypter = new ChaCha20Poly1305(arWriteKey); + System.Reflection.MethodInfo pEnable = typeof(EncryptedStreamReader).GetMethod("EnableDecryption", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + pEnable.Invoke(pServerReader, new object[] { pDecrypter }); + Assert.AreEqual("GET /encrypted HTTP/1.1", await pServerReader.ReadLineAsync(pTimeout.Token)); + + while ((await pServerReader.ReadLineAsync(pTimeout.Token)).Length != 0) + { + } + + EncryptedStreamWriter pServerWriter = new EncryptedStreamWriter(pPeer.GetStream()); + using ChaCha20Poly1305 pEncrypter = new ChaCha20Poly1305(arReadKey); + pServerWriter.EnableEncryption(pEncrypter); + await pServerWriter.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"), pTimeout.Token); + await pServerWriter.FlushAsync(pTimeout.Token); + using HttpResponseMessage pEncryptedResponse = await pEncryptedPending.WaitAsync(pTimeout.Token); + Assert.AreEqual("OK", await pEncryptedResponse.Content.ReadAsStringAsync()); + } + + [TestMethod] + public async Task ClosedSocketCompletesPendingRequests() + { + // Transport failure must wake the caller without waiting for its cancellation token. + using CancellationTokenSource pTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using TcpListener pListener = new TcpListener(IPAddress.Loopback, 0); + pListener.Start(); + using Connection pConnection = new Connection(); + await pConnection.ConnectAsync((IPEndPoint)pListener.LocalEndpoint, pTimeout.Token); + using TcpClient pPeer = await pListener.AcceptTcpClientAsync(pTimeout.Token); + Task pPending = pConnection.Get("/closed", pTimeout.Token); + await ReadRequestAsync(pPeer, pTimeout.Token); + pPeer.Dispose(); + bool bClosed = false; + + try + { + await pPending.WaitAsync(pTimeout.Token); + } + catch (ChannelClosedException) + { + bClosed = true; + } + + Assert.IsTrue(bClosed); + pConnection.Dispose(); + } + + [TestMethod] + public async Task EventSubscribersEachReadTheCompleteBody() + { + // Two subscribers consume the event stream before an ordinary response is delivered. + using CancellationTokenSource pTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using TcpListener pListener = new TcpListener(IPAddress.Loopback, 0); + pListener.Start(); + using Connection pConnection = new Connection(); + pConnection.EventReceived += ReadFirstEvent; + pConnection.EventReceived += ReadSecondEvent; + await pConnection.ConnectAsync((IPEndPoint)pListener.LocalEndpoint, pTimeout.Token); + using TcpClient pPeer = await pListener.AcceptTcpClientAsync(pTimeout.Token); + Task pPending = pConnection.Get("/events", pTimeout.Token); + await ReadRequestAsync(pPeer, pTimeout.Token); + string sResponse = "EVENT/1.0 200 OK\r\nContent-Length: 3\r\n\r\nabcHTTP/1.1 204 No Content\r\n\r\n"; + await pPeer.GetStream().WriteAsync(Encoding.ASCII.GetBytes(sResponse), pTimeout.Token); + using HttpResponseMessage pResponse = await pPending.WaitAsync(pTimeout.Token); + CollectionAssert.AreEqual(new string[] { "abc", "abc" }, m_lstEventPayloads); + Assert.AreEqual(HttpStatusCode.NoContent, pResponse.StatusCode); + } + + [TestMethod] + [DataRow((byte)PairingState.M1, false)] + [DataRow((byte)PairingState.M2, true)] + public async Task UnPairRequiresM2Acknowledgment(byte nState, bool bExpected) + { + // Use the real HTTP path and TLV parser, without a physical accessory. + using CancellationTokenSource pTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using TcpListener pListener = new TcpListener(IPAddress.Loopback, 0); + pListener.Start(); + using Connection pConnection = new Connection(); + await pConnection.ConnectAsync((IPEndPoint)pListener.LocalEndpoint, pTimeout.Token); + using TcpClient pPeer = await pListener.AcceptTcpClientAsync(pTimeout.Token); + using Controller pController = new Controller(out byte[] arPublic, out byte[] arPrivate); + HomeKitEndPoint pEndpoint = new HomeKitEndPoint(pConnection, new AccessoriesJSON { Accessories = [] }); + Task pPending = pController.UnPair(pEndpoint, pTimeout.Token); + string sRequest = await ReadRequestAsync(pPeer, pTimeout.Token); + Assert.AreEqual("POST /pairings HTTP/1.1", sRequest); + await pPeer.GetStream().WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\n"), pTimeout.Token); + await pPeer.GetStream().WriteAsync(new byte[] { (byte)TLVType.kTLVType_State, 1, nState }, pTimeout.Token); + Assert.AreEqual(bExpected, await pPending.WaitAsync(pTimeout.Token)); + } + + private static async Task ReadRequestAsync(TcpClient pPeer, CancellationToken ct) + { + // Consume the complete request so closing the peer does not discard unread bytes. + EncryptedStreamReader pReader = new EncryptedStreamReader(pPeer.GetStream()); + string sRequest = await pReader.ReadLineAsync(ct); + string sLine; + int nLength = 0; + + while ((sLine = await pReader.ReadLineAsync(ct)).Length != 0) + { + if (sLine.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) + nLength = int.Parse(sLine.Substring("Content-Length:".Length)); + } + + if (nLength > 0) + await pReader.ReadBytesAsync(new byte[nLength], nLength, ct); + + return sRequest; + } + + private void ReadFirstEvent(object? sender, HttpResponseMessage pResponse) + { + // Leave response ownership with the connection. + using StreamReader pReader = new StreamReader(pResponse.Content.ReadAsStream(), Encoding.UTF8, false, 1024, true); + m_lstEventPayloads.Add(pReader.ReadToEnd()); + } + + private void ReadSecondEvent(object? sender, HttpResponseMessage pResponse) + { + // The second subscriber must see the same content from the beginning. + using StreamReader pReader = new StreamReader(pResponse.Content.ReadAsStream(), Encoding.UTF8, false, 1024, true); + m_lstEventPayloads.Add(pReader.ReadToEnd()); + } +} diff --git a/Test/EncryptedStreamReaderTest.cs b/Test/EncryptedStreamReaderTest.cs new file mode 100644 index 0000000..1817e2d --- /dev/null +++ b/Test/EncryptedStreamReaderTest.cs @@ -0,0 +1,79 @@ +using HomeKitDotNet; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; + +namespace Test; + +[TestClass] +public sealed class EncryptedStreamReaderTest +{ + [TestMethod] + public async Task FragmentedHeaderAndSingleByteBodyAreReadCompletely() + { + // Exercise the final byte and headers split across transport reads. + using FragmentedReadStream pStream = new FragmentedReadStream(Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\n\r\nX"), 1); + EncryptedStreamReader pReader = new EncryptedStreamReader(pStream); + Assert.AreEqual("HTTP/1.1 200 OK", await pReader.ReadLineAsync(CancellationToken.None)); + Assert.AreEqual("", await pReader.ReadLineAsync(CancellationToken.None)); + byte[] arBody = new byte[1]; + await pReader.ReadBytesAsync(arBody, 1, CancellationToken.None); + Assert.AreEqual((byte)'X', arBody[0]); + } + + [TestMethod] + public async Task BufferedBodyPreservesTheNextResponse() + { + // Both responses arrive in one read, but consuming the body must leave the next header intact. + using MemoryStream pStream = new MemoryStream(Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\n\r\nabcHTTP/1.1 204 No Content\r\n")); + EncryptedStreamReader pReader = new EncryptedStreamReader(pStream); + await pReader.ReadLineAsync(CancellationToken.None); + await pReader.ReadLineAsync(CancellationToken.None); + byte[] arBody = new byte[8]; + await pReader.ReadBytesAsync(arBody, 3, CancellationToken.None); + Assert.AreEqual("abc", Encoding.ASCII.GetString(arBody, 0, 3)); + Assert.AreEqual("HTTP/1.1 204 No Content", await pReader.ReadLineAsync(CancellationToken.None)); + } + + [TestMethod] + public async Task TruncatedBodyThrowsInsteadOfWaitingForever() + { + // EOF must be reported even when only the last requested byte is missing. + using MemoryStream pStream = new MemoryStream(new byte[] { 1, 2 }); + EncryptedStreamReader pReader = new EncryptedStreamReader(pStream); + bool bRejected = false; + + try + { + await pReader.ReadBytesAsync(new byte[3], 3, CancellationToken.None); + } + catch (EndOfStreamException) + { + bRejected = true; + } + + Assert.IsTrue(bRejected); + } + + [TestMethod] + public async Task EncryptedLinesCanSpanFramesAndPreserveBufferedBytes() + { + // Split CRLF between encrypted frames and keep the next header in the same final frame. + byte[] arKey = new byte[32]; + using MemoryStream pStream = new MemoryStream(); + EncryptedStreamWriter pWriter = new EncryptedStreamWriter(pStream); + using ChaCha20Poly1305 pEncrypter = new ChaCha20Poly1305(arKey); + pWriter.EnableEncryption(pEncrypter); + await pWriter.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r"), CancellationToken.None); + await pWriter.FlushAsync(CancellationToken.None); + await pWriter.WriteAsync(Encoding.ASCII.GetBytes("\nNext: value\r\n"), CancellationToken.None); + await pWriter.FlushAsync(CancellationToken.None); + pStream.Position = 0; + EncryptedStreamReader pReader = new EncryptedStreamReader(pStream); + using ChaCha20Poly1305 pDecrypter = new ChaCha20Poly1305(arKey); + MethodInfo pEnable = typeof(EncryptedStreamReader).GetMethod("EnableDecryption", BindingFlags.Instance | BindingFlags.NonPublic)!; + pEnable.Invoke(pReader, new object[] { pDecrypter }); + Assert.AreEqual("HTTP/1.1 200 OK", await pReader.ReadLineAsync(CancellationToken.None)); + Assert.AreEqual("Next: value", await pReader.ReadLineAsync(CancellationToken.None)); + } +} diff --git a/Test/FragmentedReadStream.cs b/Test/FragmentedReadStream.cs new file mode 100644 index 0000000..9ce0818 --- /dev/null +++ b/Test/FragmentedReadStream.cs @@ -0,0 +1,17 @@ +namespace Test; + +internal sealed class FragmentedReadStream : MemoryStream +{ + private readonly int m_nMaxRead; + + public FragmentedReadStream(byte[] arBytes, int nMaxRead) : base(arBytes) + { + m_nMaxRead = nMaxRead; + } + + public override ValueTask ReadAsync(Memory pBuffer, CancellationToken ct) + { + // Force reads to span multiple transport fragments. + return base.ReadAsync(pBuffer.Slice(0, Math.Min(pBuffer.Length, m_nMaxRead)), ct); + } +} diff --git a/Test/X25519Test.cs b/Test/X25519Test.cs new file mode 100644 index 0000000..061d74b --- /dev/null +++ b/Test/X25519Test.cs @@ -0,0 +1,52 @@ +using HomeKitDotNet.Crypto; + +namespace Test; + +[TestClass] +public sealed class X25519Test +{ + [TestMethod] + public void SharedSecretMatchesRfc7748() + { + // RFC 7748 section 6.1, Alice's key pair and Bob's public key. + byte[] arPrivate = Convert.FromHexString("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"); + byte[] arPublic = Convert.FromHexString("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a"); + byte[] arPeer = Convert.FromHexString("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f"); + byte[] arExpected = Convert.FromHexString("4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742"); + X25519 pSession = new X25519(); + pSession.LoadKeys(arPublic, arPrivate); + CollectionAssert.AreEqual(arExpected, pSession.GetSharedSecret(arPeer)); + } + + [TestMethod] + public void GeneratedKeyPairsCanBeReloaded() + { + // Preserve the original tuple-returning public API and verify symmetric agreement. + X25519 pAlice = new X25519(); + X25519 pBob = new X25519(); + ValueTuple pAliceKeys = pAlice.GenerateKeyPair(); + ValueTuple pBobKeys = pBob.GenerateKeyPair(); + X25519 pReloaded = new X25519(); + pReloaded.LoadKeys(pAliceKeys.Item1, pAliceKeys.Item2); + CollectionAssert.AreEqual(pBob.GetSharedSecret(pAliceKeys.Item1), pReloaded.GetSharedSecret(pBobKeys.Item1)); + } + + [TestMethod] + public void InconsistentKeyPairIsRejected() + { + // Imported private and public keys must describe the same key pair. + X25519 pSession = new X25519(); + bool bRejected = false; + + try + { + pSession.LoadKeys(new byte[32], new byte[32]); + } + catch (ArgumentException) + { + bRejected = true; + } + + Assert.IsTrue(bRejected); + } +}