diff --git a/src/ZWave.CommandClasses.Tests/S0CryptoTests.cs b/src/ZWave.CommandClasses.Tests/S0CryptoTests.cs new file mode 100644 index 0000000..86d8119 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/S0CryptoTests.cs @@ -0,0 +1,145 @@ +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public class S0CryptoTests +{ + private static byte[] Hex(string value) => Convert.FromHexString(value); + private static string Hex(byte[] value) => Convert.ToHexString(value).ToLowerInvariant(); + + [TestMethod] + public void EcbEncrypt_Fips197_C1_MatchesKnownVector() + { + byte[] key = Hex("000102030405060708090a0b0c0d0e0f"); + byte[] plaintext = Hex("00112233445566778899aabbccddeeff"); + byte[] ciphertext = S0Crypto.Aes128EcbEncrypt(plaintext, key); + + Assert.AreEqual("69c4e0d86a7b0430d8cdb78070b4c55a", Hex(ciphertext)); + } + + [TestMethod] + public void EcbEncrypt_WrongBlockSize_Throws() + { + byte[] key = Hex("000102030405060708090a0b0c0d0e0f"); + + Assert.Throws(() => S0Crypto.Aes128EcbEncrypt(new byte[15], key)); + Assert.Throws(() => S0Crypto.Aes128EcbEncrypt(new byte[17], key)); + } + + [TestMethod] + public void EncryptOfb_SingleBlock_MatchesKnownVector() + { + byte[] key = Hex("2b7e151628aed2a6abf7158809cf4f3c"); + byte[] iv = Hex("000102030405060708090a0b0c0d0e0f"); + byte[] plaintext = Hex("6bc1bee22e409f96e93d7e117393172a"); + + byte[] ciphertext = S0Crypto.EncryptOfb(plaintext, key, iv); + + Assert.AreEqual("3b3fd92eb72dad20333449f8e83cfb4a", Hex(ciphertext)); + } + + [TestMethod] + public void EncryptOfb_MultiBlock_MatchesReferenceAndRoundTrips() + { + byte[] key = Hex("2b7e151628aed2a6abf7158809cf4f3c"); + byte[] iv = Hex("000102030405060708090a0b0c0d0e0f"); + byte[] plaintext = Hex( + "6bc1bee22e409f96e93d7e117393172a" + + "ae2d8a571e03ac9c9eb76fac45af8e51" + + "30c81c46a35ce411e5fbc1191a0a52ef" + + "f69f2445df4f9b17ad2b417be66c3710"); + + byte[] ciphertext = S0Crypto.EncryptOfb(plaintext, key, iv); + + Assert.AreEqual( + "3b3fd92eb72dad20333449f8e83cfb4a" + + "7789508d16918f03f53c52dac54ed825" + + "9740051e9c5fecf64344f7a82260edcc" + + "304c6528f659c77866a510d9c1d6ae5e", + Hex(ciphertext)); + Assert.AreEqual(Hex(plaintext), Hex(S0Crypto.DecryptOfb(ciphertext, key, iv))); + } + + [TestMethod] + public void EncryptOfb_NonBlockAligned_LengthPreservedAndRoundTrips() + { + byte[] key = Hex("2b7e151628aed2a6abf7158809cf4f3c"); + byte[] iv = Hex("000102030405060708090a0b0c0d0e0f"); + byte[] plaintext = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 }; + + byte[] ciphertext = S0Crypto.EncryptOfb(plaintext, key, iv); + + Assert.HasCount(plaintext.Length, ciphertext); + Assert.AreNotEqual(Hex(plaintext), Hex(ciphertext)); + Assert.AreEqual(Hex(plaintext), Hex(S0Crypto.DecryptOfb(ciphertext, key, iv))); + } + + [TestMethod] + public void CbcEncrypt_BlockAligned_MatchesReference() + { + byte[] key = Hex("2b7e151628aed2a6abf7158809cf4f3c"); + byte[] iv = Hex("000102030405060708090a0b0c0d0e0f"); + byte[] plaintext = Hex( + "6bc1bee22e409f96e93d7e117393172a" + + "ae2d8a571e03ac9c9eb76fac45af8e51" + + "30c81c46a35ce411e5fbc1191a0a52ef" + + "f69f2445df4f9b17ad2b417be66c3710"); + + byte[] ciphertext = S0Crypto.Aes128CbcEncrypt(plaintext, key, iv); + + Assert.AreEqual( + "7649abac8119b246cee98e9b12e9197d" + + "5086cb9b507219ee95db113a917678b2" + + "73bed6b8e3c1743b7116e69e22229516" + + "3ff1caa1681fac09120eca307586e1a7", + Hex(ciphertext)); + } + + [TestMethod] + public void CbcEncrypt_ZeroPadsToBlockBoundary() + { + byte[] key = Hex("2b7e151628aed2a6abf7158809cf4f3c"); + byte[] iv = Hex("000102030405060708090a0b0c0d0e0f"); + byte[] plaintext = new byte[23]; + plaintext[0] = 0xAB; + plaintext[22] = 0xCD; + + byte[] ciphertext = S0Crypto.Aes128CbcEncrypt(plaintext, key, iv); + + Assert.HasCount(32, ciphertext); + } + + [TestMethod] + public void ComputeMac_ZeroPaddedAuthData_MatchesReference() + { + byte[] authKey = Hex("01010101010101010101010101010101"); + byte[] authData = Hex("000102030405060700010203040506078101020daabbcc"); + + ReadOnlySpan mac = S0Crypto.ComputeMac(authData, authKey); + + Assert.AreEqual("143f7d8906c5246b", Convert.ToHexString(mac).ToLowerInvariant()); + } + + [TestMethod] + public void DeriveAuthKey_MatchesReference() + { + byte[] networkKey = Hex("2b7e151628aed2a6abf7158809cf4f3c"); + + Assert.AreEqual("c985043655121fdf1f87fbce7c7ca451", Hex(S0Crypto.DeriveAuthKey(networkKey))); + } + + [TestMethod] + public void DeriveEncryptionKey_MatchesReference() + { + byte[] networkKey = Hex("2b7e151628aed2a6abf7158809cf4f3c"); + + Assert.AreEqual("b53da9ee4283df0d733138f64594d676", Hex(S0Crypto.DeriveEncryptionKey(networkKey))); + } + + [TestMethod] + public void DerivedKeys_AreDifferent() + { + byte[] networkKey = Hex("2b7e151628aed2a6abf7158809cf4f3c"); + + Assert.AreNotEqual(Hex(S0Crypto.DeriveAuthKey(networkKey)), Hex(S0Crypto.DeriveEncryptionKey(networkKey))); + } +} diff --git a/src/ZWave.CommandClasses.Tests/Security0CommandClassTests.CommandsSupported.cs b/src/ZWave.CommandClasses.Tests/Security0CommandClassTests.CommandsSupported.cs new file mode 100644 index 0000000..9ee2031 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/Security0CommandClassTests.CommandsSupported.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class Security0CommandClassTests +{ + [TestMethod] + public void CommandsSupportedReport_CreateParseRoundTrip() + { + CommandClassId[] supported = [CommandClassId.BinarySwitch, CommandClassId.Version]; + CommandClassId[] controlled = [CommandClassId.Meter]; + Security0CommandClass.CommandsSupportedReportCommand report = + Security0CommandClass.CommandsSupportedReportCommand.Create(supported, controlled); + + (Security0CommandsSupportedReport parsed, byte reportsToFollow) = + Security0CommandClass.CommandsSupportedReportCommand.Parse(report.Frame, NullLogger.Instance); + + Assert.AreEqual(0, reportsToFollow); + Assert.HasCount(3, parsed.CommandClasses); + Assert.AreEqual(new CommandClassInfo(CommandClassId.BinarySwitch, IsSupported: true, IsControlled: false), parsed.CommandClasses[0]); + Assert.AreEqual(new CommandClassInfo(CommandClassId.Version, IsSupported: true, IsControlled: false), parsed.CommandClasses[1]); + Assert.AreEqual(new CommandClassInfo(CommandClassId.Meter, IsSupported: false, IsControlled: true), parsed.CommandClasses[2]); + } + + [TestMethod] + public void CommandsSupportedReport_Parse_ReportsToFollowZero_ParsesClasses() + { + // reports-to-follow = 0x00 (single-frame report), then the supported classes, the + // SupportControlMark (0xEF), then the controlled classes. + byte[] parameters = [0x00, (byte)CommandClassId.BinarySwitch, (byte)CommandClassId.Version, (byte)CommandClassId.SupportControlMark, (byte)CommandClassId.Meter]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId.Security0, (byte)Security0Command.CommandsSupportedReport, parameters); + + (Security0CommandsSupportedReport parsed, byte reportsToFollow) = + Security0CommandClass.CommandsSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(0, reportsToFollow); + Assert.HasCount(3, parsed.CommandClasses); + Assert.AreEqual(new CommandClassInfo(CommandClassId.BinarySwitch, IsSupported: true, IsControlled: false), parsed.CommandClasses[0]); + Assert.AreEqual(new CommandClassInfo(CommandClassId.Version, IsSupported: true, IsControlled: false), parsed.CommandClasses[1]); + Assert.AreEqual(new CommandClassInfo(CommandClassId.Meter, IsSupported: false, IsControlled: true), parsed.CommandClasses[2]); + } + + [TestMethod] + public void CommandsSupportedReport_Parse_NoMark_AllSupported() + { + // No SupportControlMark present: every class is supported. + byte[] parameters = [0x00, (byte)CommandClassId.BinarySwitch, (byte)CommandClassId.Version]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId.Security0, (byte)Security0Command.CommandsSupportedReport, parameters); + + (Security0CommandsSupportedReport parsed, byte reportsToFollow) = + Security0CommandClass.CommandsSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(0, reportsToFollow); + Assert.HasCount(2, parsed.CommandClasses); + Assert.AreEqual(new CommandClassInfo(CommandClassId.BinarySwitch, IsSupported: true, IsControlled: false), parsed.CommandClasses[0]); + Assert.AreEqual(new CommandClassInfo(CommandClassId.Version, IsSupported: true, IsControlled: false), parsed.CommandClasses[1]); + } + + [TestMethod] + public void CommandsSupportedReport_Parse_ReportsToFollowNonZero_SurfacesCount() + { + // reports-to-follow = 0x02 (two more frames are coming), then this frame's classes. + byte[] parameters = [0x02, (byte)CommandClassId.BinarySwitch, (byte)CommandClassId.Version]; + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId.Security0, (byte)Security0Command.CommandsSupportedReport, parameters); + + (Security0CommandsSupportedReport parsed, byte reportsToFollow) = + Security0CommandClass.CommandsSupportedReportCommand.Parse(frame, NullLogger.Instance); + + Assert.AreEqual(0x02, reportsToFollow); + Assert.HasCount(2, parsed.CommandClasses); + Assert.AreEqual(new CommandClassInfo(CommandClassId.BinarySwitch, IsSupported: true, IsControlled: false), parsed.CommandClasses[0]); + Assert.AreEqual(new CommandClassInfo(CommandClassId.Version, IsSupported: true, IsControlled: false), parsed.CommandClasses[1]); + } + + [TestMethod] + public void CommandsSupportedReport_Parse_TruncatedExtended_Throws() + { + // reports-to-follow (0x00) then a 0xF1 extended MSB with no following LSB byte. + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId.Security0, (byte)Security0Command.CommandsSupportedReport, [0x00, 0xF1]); + + Assert.Throws(() => + Security0CommandClass.CommandsSupportedReportCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void CommandsSupportedReport_Parse_Empty_Throws() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId.Security0, (byte)Security0Command.CommandsSupportedReport); + + Assert.Throws(() => + Security0CommandClass.CommandsSupportedReportCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/Security0CommandClassTests.Encapsulation.cs b/src/ZWave.CommandClasses.Tests/Security0CommandClassTests.Encapsulation.cs new file mode 100644 index 0000000..c10fa87 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/Security0CommandClassTests.Encapsulation.cs @@ -0,0 +1,302 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class Security0CommandClassTests +{ + [TestMethod] + public void Encapsulation_TwoPartyARoundTripsB() + { + (S0SecurityManager sender, S0SecurityManager receiver) = CreateManagers(); + + // The receiver generates its nonce; the sender stores it as if received via a Nonce Report. + byte[] receiverNonce = receiver.GenerateNonce(receiver: 1); + sender.StoreReceivedNonce(issuer: 2, receiverNonce); + + CommandClassFrame inner = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x01, [0x01]); + Security0CommandClass.CommandEncapsulationCommand command = + Security0CommandClass.CommandEncapsulationCommand.Create(sender, srcNodeId: 1, dstNodeId: 2, inner, 0x00); + + Security0Encapsulation? result = + Security0CommandClass.CommandEncapsulationCommand.Parse(receiver, srcNodeId: 1, dstNodeId: 2, command.Frame, NullLogger.Instance); + + Assert.IsNotNull(result); + Assert.AreEqual((byte)0x00, result.Value.FrameControl); + Assert.AreEqual(Hex(inner.Data.Span.ToArray()), Hex(result.Value.EncapsulatedFrame.Data.Span.ToArray())); + } + + [TestMethod] + public void Encapsulation_CiphertextDiffersFromPlaintext() + { + (S0SecurityManager sender, S0SecurityManager receiver) = CreateManagers(); + + byte[] receiverNonce = receiver.GenerateNonce(receiver: 1); + sender.StoreReceivedNonce(issuer: 2, receiverNonce); + + CommandClassFrame inner = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x01, [0x01, 0x02, 0x03]); + Security0CommandClass.CommandEncapsulationCommand command = + Security0CommandClass.CommandEncapsulationCommand.Create(sender, srcNodeId: 1, dstNodeId: 2, inner, 0x00); + + byte[] plaintext = new byte[1 + inner.Data.Length]; + plaintext[0] = 0x00; + inner.Data.Span.CopyTo(plaintext.AsSpan(1)); + byte[] parameters = command.Frame.CommandParameters.Span.ToArray(); + byte[] ciphertext = parameters.AsSpan()[8..(8 + plaintext.Length)].ToArray(); + + Assert.IsFalse(ciphertext.SequenceEqual(plaintext)); + } + + [TestMethod] + public void Encapsulation_TamperedCiphertext_MacMismatch_ReturnsNull() + { + (S0SecurityManager sender, S0SecurityManager receiver) = CreateManagers(); + + byte[] receiverNonce = receiver.GenerateNonce(receiver: 1); + sender.StoreReceivedNonce(issuer: 2, receiverNonce); + + CommandClassFrame inner = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x01, [0x01]); + Security0CommandClass.CommandEncapsulationCommand command = + Security0CommandClass.CommandEncapsulationCommand.Create(sender, srcNodeId: 1, dstNodeId: 2, inner, 0x00); + + byte[] data = command.Frame.Data.Span.ToArray(); + data[10] ^= 0xFF; // first ciphertext byte (offset 2 for CC+cmd, 8 for sender nonce) + CommandClassFrame tampered = new(data); + + Security0Encapsulation? result = + Security0CommandClass.CommandEncapsulationCommand.Parse(receiver, srcNodeId: 1, dstNodeId: 2, tampered, NullLogger.Instance); + + Assert.IsNull(result); + } + + [TestMethod] + public void Encapsulation_WrongNetworkKey_ReturnsNull() + { + S0SecurityManager sender = new(ownNodeId: 1); + S0SecurityManager receiver = new(ownNodeId: 2); + sender.SetNetworkKey(NetworkKey); + receiver.SetNetworkKey(Convert.FromHexString("00112233445566778899aabbccddeeff")); + + byte[] receiverNonce = receiver.GenerateNonce(receiver: 1); + sender.StoreReceivedNonce(issuer: 2, receiverNonce); + + CommandClassFrame inner = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x01, [0x01]); + Security0CommandClass.CommandEncapsulationCommand command = + Security0CommandClass.CommandEncapsulationCommand.Create(sender, srcNodeId: 1, dstNodeId: 2, inner, 0x00); + + Security0Encapsulation? result = + Security0CommandClass.CommandEncapsulationCommand.Parse(receiver, srcNodeId: 1, dstNodeId: 2, command.Frame, NullLogger.Instance); + + Assert.IsNull(result); + } + + [TestMethod] + public void Encapsulation_UnknownNonce_ReturnsNull() + { + S0SecurityManager sender = new(ownNodeId: 1); + S0SecurityManager receiver = new(ownNodeId: 2); + S0SecurityManager nonceProvider = new(ownNodeId: 2); + sender.SetNetworkKey(NetworkKey); + receiver.SetNetworkKey(NetworkKey); + nonceProvider.SetNetworkKey(NetworkKey); + + // The nonce is generated by a different instance, so the receiver does not know it. + byte[] receiverNonce = nonceProvider.GenerateNonce(receiver: 1); + sender.StoreReceivedNonce(issuer: 2, receiverNonce); + + CommandClassFrame inner = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x01, [0x01]); + Security0CommandClass.CommandEncapsulationCommand command = + Security0CommandClass.CommandEncapsulationCommand.Create(sender, srcNodeId: 1, dstNodeId: 2, inner, 0x00); + + Security0Encapsulation? result = + Security0CommandClass.CommandEncapsulationCommand.Parse(receiver, srcNodeId: 1, dstNodeId: 2, command.Frame, NullLogger.Instance); + + Assert.IsNull(result); + } + + [TestMethod] + public void Encapsulation_TooShortFrame_ReturnsNull() + { + S0SecurityManager receiver = new(ownNodeId: 2); + receiver.SetNetworkKey(NetworkKey); + CommandClassFrame frame = new(new byte[] { 0x98, 0x81, 0x00, 0x00, 0x00 }); + + Security0Encapsulation? result = + Security0CommandClass.CommandEncapsulationCommand.Parse(receiver, srcNodeId: 1, dstNodeId: 2, frame, NullLogger.Instance); + + Assert.IsNull(result); + } + + [TestMethod] + public void Encapsulation_Create_NoUsableNonce_Throws() + { + S0SecurityManager sender = new(ownNodeId: 1); + sender.SetNetworkKey(NetworkKey); + CommandClassFrame inner = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x01, [0x01]); + + Assert.Throws(() => + Security0CommandClass.CommandEncapsulationCommand.Create(sender, srcNodeId: 1, dstNodeId: 2, inner, 0x00)); + } + + [TestMethod] + public void Encapsulation_Create_NoNetworkKey_Throws() + { + S0SecurityManager sender = new(ownNodeId: 1); + CommandClassFrame inner = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x01, [0x01]); + + Assert.Throws(() => + Security0CommandClass.CommandEncapsulationCommand.Create(sender, srcNodeId: 1, dstNodeId: 2, inner, 0x00)); + } + + [TestMethod] + public void Encapsulation_Create_UnsupportedNodeId_Throws() + { + S0SecurityManager sender = new(ownNodeId: 1); + sender.SetNetworkKey(NetworkKey); + CommandClassFrame inner = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x01, [0x01]); + + Assert.Throws(() => + Security0CommandClass.CommandEncapsulationCommand.Create(sender, srcNodeId: 300, dstNodeId: 2, inner, 0x00)); + } + + [TestMethod] + public void Encapsulation_NonceGet_RoundTripsWithRequestedNonce() + { + (S0SecurityManager sender, S0SecurityManager receiver) = CreateManagers(); + + byte[] receiverNonce = receiver.GenerateNonce(receiver: 1); + sender.StoreReceivedNonce(issuer: 2, receiverNonce); + + CommandClassFrame inner = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x01, [0x01]); + Security0CommandClass.CommandEncapsulationCommand command = + Security0CommandClass.CommandEncapsulationCommand.Create(sender, srcNodeId: 1, dstNodeId: 2, inner, 0x00, requestNonce: true); + + Assert.AreEqual((byte)Security0Command.CommandEncapsulationNonceGet, command.Frame.CommandId); + + Security0Encapsulation? result = + Security0CommandClass.CommandEncapsulationCommand.Parse(receiver, srcNodeId: 1, dstNodeId: 2, command.Frame, NullLogger.Instance); + + Assert.IsNotNull(result); + Assert.IsTrue(result.Value.RequestedNonce); + Assert.AreEqual(Hex(inner.Data.Span.ToArray()), Hex(result.Value.EncapsulatedFrame.Data.Span.ToArray())); + } + + [TestMethod] + public void Encapsulation_Default_RoundTripsWithoutRequestedNonce() + { + (S0SecurityManager sender, S0SecurityManager receiver) = CreateManagers(); + + byte[] receiverNonce = receiver.GenerateNonce(receiver: 1); + sender.StoreReceivedNonce(issuer: 2, receiverNonce); + + CommandClassFrame inner = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x01, [0x01]); + Security0CommandClass.CommandEncapsulationCommand command = + Security0CommandClass.CommandEncapsulationCommand.Create(sender, srcNodeId: 1, dstNodeId: 2, inner, 0x00); + + Assert.AreEqual((byte)Security0Command.CommandEncapsulation, command.Frame.CommandId); + + Security0Encapsulation? result = + Security0CommandClass.CommandEncapsulationCommand.Parse(receiver, srcNodeId: 1, dstNodeId: 2, command.Frame, NullLogger.Instance); + + Assert.IsNotNull(result); + Assert.IsFalse(result.Value.RequestedNonce); + } + + [TestMethod] + public void Encapsulation_UnsupportedCommandId_ReturnsNull() + { + (S0SecurityManager sender, S0SecurityManager receiver) = CreateManagers(); + + byte[] receiverNonce = receiver.GenerateNonce(receiver: 1); + sender.StoreReceivedNonce(issuer: 2, receiverNonce); + + CommandClassFrame inner = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x01, [0x01]); + Security0CommandClass.CommandEncapsulationCommand command = + Security0CommandClass.CommandEncapsulationCommand.Create(sender, srcNodeId: 1, dstNodeId: 2, inner, 0x00); + + // Overwrite the command ID (byte 1) with a value that is neither 0x81 nor 0xC1. + byte[] data = command.Frame.Data.Span.ToArray(); + data[1] = 0x98; + CommandClassFrame badCommandId = new(data); + + Security0Encapsulation? result = + Security0CommandClass.CommandEncapsulationCommand.Parse(receiver, srcNodeId: 1, dstNodeId: 2, badCommandId, NullLogger.Instance); + + Assert.IsNull(result); + } + + [TestMethod] + public void Encapsulation_SenderNonce_NotTrackedInOwnPool() + { + (S0SecurityManager sender, S0SecurityManager receiver) = CreateManagers(); + + byte[] receiverNonce = receiver.GenerateNonce(receiver: 1); + sender.StoreReceivedNonce(issuer: 2, receiverNonce); + + CommandClassFrame inner = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x01, [0x01]); + Security0CommandClass.CommandEncapsulationCommand command = + Security0CommandClass.CommandEncapsulationCommand.Create(sender, srcNodeId: 1, dstNodeId: 2, inner, 0x00); + + // The sender nonce is the first 8 bytes of the command parameters. It must not be + // retrievable from the sender's own pool: it was generated for this frame, not issued + // to a peer, so Parse must never accept an inbound command that references it. + byte[] senderNonce = command.Frame.CommandParameters.Span[..8].ToArray(); + ReadOnlySpan ownNonce = sender.GetOwnNonce(senderNonce[0], sourceNodeId: 2); + + Assert.IsTrue(ownNonce.IsEmpty); + } + + [TestMethod] + public void Encapsulation_NonceIssuedToOtherPeer_RejectsFrameFromDifferentNode() + { + // Node 1 issues a nonce to peer 2. A different node (3) that has learned the nonce value + // (e.g. by eavesdropping the Nonce Report) must not be able to consume node 1's nonce + // slot, even though it can compute a valid MAC (all S0 nodes share the network key). + S0SecurityManager receiver = new(ownNodeId: 1); + receiver.SetNetworkKey(NetworkKey); + byte[] issuedNonce = receiver.GenerateNonce(receiver: 2); + + S0SecurityManager attacker = new(ownNodeId: 3); + attacker.SetNetworkKey(NetworkKey); + attacker.StoreReceivedNonce(issuer: 1, issuedNonce); + + CommandClassFrame inner = CommandClassFrame.Create(CommandClassId.BinarySwitch, 0x01, [0x01]); + Security0CommandClass.CommandEncapsulationCommand command = + Security0CommandClass.CommandEncapsulationCommand.Create(attacker, srcNodeId: 3, dstNodeId: 1, inner, 0x00); + + Security0Encapsulation? result = + Security0CommandClass.CommandEncapsulationCommand.Parse(receiver, srcNodeId: 3, dstNodeId: 1, command.Frame, NullLogger.Instance); + + Assert.IsNull(result); + } + + [TestMethod] + public void ConsumeOwnNonce_ConcurrentCalls_OnlyOneConsumes() + { + // A nonce is single-use, so of many concurrent attempts to consume the same nonce ID, + // exactly one must succeed. This is the invariant the atomic lookup-and-remove in + // ConsumeOwnNonce enforces (a split check-then-delete would let two callers through). + S0SecurityManager manager = new(ownNodeId: 1); + byte[] nonce = manager.GenerateNonce(receiver: 2); + byte nonceId = nonce[0]; + + const int Callers = 32; + Task[] tasks = new Task[Callers]; + for (int i = 0; i < Callers; i++) + { + tasks[i] = Task.Run(() => manager.ConsumeOwnNonce(nonceId, sourceNodeId: 2)); + } + + Task.WaitAll(tasks); + + int consumed = 0; + foreach (Task task in tasks) + { + if (task.Result is not null) + { + consumed++; + } + } + + Assert.AreEqual(1, consumed); + } +} diff --git a/src/ZWave.CommandClasses.Tests/Security0CommandClassTests.NetworkKey.cs b/src/ZWave.CommandClasses.Tests/Security0CommandClassTests.NetworkKey.cs new file mode 100644 index 0000000..89a6209 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/Security0CommandClassTests.NetworkKey.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class Security0CommandClassTests +{ + [TestMethod] + public void NetworkKeySet_CreateParseRoundTrip() + { + byte[] key = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]; + Security0CommandClass.NetworkKeySetCommand command = Security0CommandClass.NetworkKeySetCommand.Create(key); + + byte[] parsed = Security0CommandClass.NetworkKeySetCommand.Parse(command.Frame, NullLogger.Instance); + + Assert.AreEqual(Hex(key), Hex(parsed)); + } + + [TestMethod] + public void NetworkKeySet_Create_WrongLength_Throws() + { + Assert.Throws(() => Security0CommandClass.NetworkKeySetCommand.Create(new byte[15])); + } + + [TestMethod] + public void NetworkKeySet_Parse_Malformed_Throws() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId.Security0, (byte)Security0Command.NetworkKeySet, new byte[15]); + + Assert.Throws(() => Security0CommandClass.NetworkKeySetCommand.Parse(frame, NullLogger.Instance)); + } + + [TestMethod] + public void NetworkKeyVerify_HasCorrectFormat() + { + // Spec §3.5.3.5: Network Key Verify carries no parameters. + Security0CommandClass.NetworkKeyVerifyCommand command = Security0CommandClass.NetworkKeyVerifyCommand.Create(); + + Assert.AreEqual((byte)Security0Command.NetworkKeyVerify, Security0CommandClass.NetworkKeyVerifyCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + } + + [TestMethod] + public void SchemeInherit_HasCorrectFormat() + { + Security0CommandClass.SchemeInheritCommand command = Security0CommandClass.SchemeInheritCommand.Create(); + + Assert.AreEqual((byte)Security0Command.SchemeInherit, Security0CommandClass.SchemeInheritCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[0]); + } +} diff --git a/src/ZWave.CommandClasses.Tests/Security0CommandClassTests.Nonce.cs b/src/ZWave.CommandClasses.Tests/Security0CommandClassTests.Nonce.cs new file mode 100644 index 0000000..7be7cb9 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/Security0CommandClassTests.Nonce.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class Security0CommandClassTests +{ + [TestMethod] + public void NonceGet_HasCorrectFormat() + { + Security0CommandClass.NonceGetCommand command = Security0CommandClass.NonceGetCommand.Create(); + + Assert.AreEqual(CommandClassId.Security0, Security0CommandClass.NonceGetCommand.CommandClassId); + Assert.AreEqual((byte)Security0Command.NonceGet, Security0CommandClass.NonceGetCommand.CommandId); + Assert.AreEqual(2, command.Frame.Data.Length); + Assert.AreEqual(0, command.Frame.CommandParameters.Length); + } + + [TestMethod] + public void NonceReport_CreateParseRoundTrip() + { + byte[] nonce = [1, 2, 3, 4, 5, 6, 7, 8]; + Security0CommandClass.NonceReportCommand report = Security0CommandClass.NonceReportCommand.Create(nonce); + + Security0Nonce parsed = Security0CommandClass.NonceReportCommand.Parse(report.Frame, NullLogger.Instance); + + Assert.AreEqual(Hex(nonce), Hex(parsed.Nonce.ToArray())); + Assert.AreEqual((byte)1, parsed.NonceId); + } + + [TestMethod] + public void NonceReport_Create_WrongLength_Throws() + { + Assert.Throws(() => Security0CommandClass.NonceReportCommand.Create(new byte[7])); + } + + [TestMethod] + public void NonceReport_Parse_Malformed_Throws() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId.Security0, (byte)Security0Command.NonceReport, [1, 2, 3]); + + Assert.Throws(() => Security0CommandClass.NonceReportCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/Security0CommandClassTests.Scheme.cs b/src/ZWave.CommandClasses.Tests/Security0CommandClassTests.Scheme.cs new file mode 100644 index 0000000..f831de9 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/Security0CommandClassTests.Scheme.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace ZWave.CommandClasses.Tests; + +public partial class Security0CommandClassTests +{ + [TestMethod] + public void SchemeGet_HasCorrectFormat() + { + Security0CommandClass.SchemeGetCommand command = Security0CommandClass.SchemeGetCommand.Create(); + + Assert.AreEqual((byte)Security0Command.SchemeGet, Security0CommandClass.SchemeGetCommand.CommandId); + Assert.AreEqual(3, command.Frame.Data.Length); + Assert.AreEqual(0x00, command.Frame.CommandParameters.Span[0]); + } + + [TestMethod] + public void SchemeReport_CreateParseRoundTrip() + { + // Spec §3.5.3.3: the report carries the Supported Security Schemes byte; S0 is 0x00. + Security0CommandClass.SchemeReportCommand report = Security0CommandClass.SchemeReportCommand.Create(); + + Security0SchemeReport parsed = Security0CommandClass.SchemeReportCommand.Parse(report.Frame, NullLogger.Instance); + + Assert.AreEqual((byte)0x00, parsed.SupportedSecuritySchemes); + } + + [TestMethod] + public void SchemeReport_Parse_Malformed_Throws() + { + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId.Security0, (byte)Security0Command.SchemeReport, [1, 2]); + + Assert.Throws(() => Security0CommandClass.SchemeReportCommand.Parse(frame, NullLogger.Instance)); + } +} diff --git a/src/ZWave.CommandClasses.Tests/Security0CommandClassTests.cs b/src/ZWave.CommandClasses.Tests/Security0CommandClassTests.cs new file mode 100644 index 0000000..2bb0999 --- /dev/null +++ b/src/ZWave.CommandClasses.Tests/Security0CommandClassTests.cs @@ -0,0 +1,18 @@ +namespace ZWave.CommandClasses.Tests; + +[TestClass] +public partial class Security0CommandClassTests +{ + private static byte[] NetworkKey => Convert.FromHexString("2b7e151628aed2a6abf7158809cf4f3c"); + + private static string Hex(byte[] value) => Convert.ToHexString(value).ToLowerInvariant(); + + private static (S0SecurityManager Sender, S0SecurityManager Receiver) CreateManagers() + { + S0SecurityManager sender = new(ownNodeId: 1); + S0SecurityManager receiver = new(ownNodeId: 2); + sender.SetNetworkKey(NetworkKey); + receiver.SetNetworkKey(NetworkKey); + return (sender, receiver); + } +} diff --git a/src/ZWave.CommandClasses/S0Crypto.cs b/src/ZWave.CommandClasses/S0Crypto.cs new file mode 100644 index 0000000..64a4f00 --- /dev/null +++ b/src/ZWave.CommandClasses/S0Crypto.cs @@ -0,0 +1,144 @@ +using System.Security.Cryptography; + +namespace ZWave.CommandClasses; + +/// +/// AES-128 primitives for the Security 0 Command Class (spec SDS13783 §3.5): key derivation (ECB), +/// payload encryption (OFB), and the message authentication code (CBC). +/// +/// +/// The S0 cipher suite is defined in the Z-Wave spec and in Silicon Labs' "Z-Wave AES-128" +/// primitives. Unlike PKCS#7 padding, S0 uses zero-byte padding: input is padded with 0x00 +/// (not with a length count) to reach a 16-byte boundary, and for OFB the output length equals +/// the input length. The ECB and CBC primitives use the span-based one-shot Aes methods; OFB has +/// no one-shot equivalent on this runtime (CipherMode.OFB is rejected), so it is built on ECB. +/// +internal static class S0Crypto +{ + private const int BlockSize = 16; + private const byte AuthKeyFill = 0x55; + private const byte EncryptionKeyFill = 0xAA; + + private static readonly byte[] ZeroIv = new byte[BlockSize]; + private static readonly byte[] AuthKeyBlock = Fill16(AuthKeyFill); + private static readonly byte[] EncryptionKeyBlock = Fill16(EncryptionKeyFill); + + /// + /// Derives the S0 authentication key: AES-128-ECB of a 16-byte 0x55 block under the network key. + /// + public static byte[] DeriveAuthKey(byte[] networkKey) + => Aes128EcbEncrypt(AuthKeyBlock, networkKey); + + /// + /// Derives the S0 encryption key: AES-128-ECB of a 16-byte 0xAA block under the network key. + /// + public static byte[] DeriveEncryptionKey(byte[] networkKey) + => Aes128EcbEncrypt(EncryptionKeyBlock, networkKey); + + /// + /// Encrypts a single 16-byte block with AES-128-ECB. + /// + /// Exactly one 16-byte block. + /// A 16-byte key. + public static byte[] Aes128EcbEncrypt(ReadOnlySpan plaintext, byte[] key) + { + if (plaintext.Length != BlockSize) + { + ZWaveException.Throw(ZWaveErrorCode.CommandInvalidArgument, "AES-128-ECB input must be exactly one 16-byte block"); + } + + using Aes aes = Aes.Create(); + aes.Key = key; + return aes.EncryptEcb(plaintext, PaddingMode.None); + } + + /// + /// Encrypts arbitrary-length data with AES-128-OFB. The output length equals the input length. + /// + public static byte[] EncryptOfb(ReadOnlySpan plaintext, byte[] key, byte[] iv) + => Ofb(plaintext, key, iv); + + /// + /// Decrypts arbitrary-length data with AES-128-OFB. The output length equals the input length. + /// + public static byte[] DecryptOfb(ReadOnlySpan ciphertext, byte[] key, byte[] iv) + => Ofb(ciphertext, key, iv); + + private static byte[] Ofb(ReadOnlySpan data, byte[] key, byte[] iv) + { + if (data.IsEmpty) + { + return []; + } + + // OFB is self-inverse (encryption and decryption are the same operation), and this runtime + // rejects CipherMode.OFB with no one-shot alternative, so it is built on the ECB primitive. + byte[] output = new byte[data.Length]; + + using Aes aes = Aes.Create(); + aes.Key = key; + + // A single 32-byte buffer holds the two OFB feedback/keystream blocks, ping-ponged between + // the ECB input and output, so the loop allocates no per-block arrays. The IV seeds the first + // block; it is copied in because the feedback block is overwritten as the chain advances. + byte[] scratch = new byte[BlockSize * 2]; + iv.CopyTo(scratch); + Span feedback = scratch.AsSpan(0, BlockSize); + Span keystream = scratch.AsSpan(BlockSize, BlockSize); + int offset = 0; + while (offset < data.Length) + { + _ = aes.EncryptEcb(feedback, keystream, PaddingMode.None); + int blockLength = Math.Min(BlockSize, data.Length - offset); + for (int i = 0; i < blockLength; i++) + { + output[offset + i] = (byte)(data[offset + i] ^ keystream[i]); + } + + Span temp = feedback; + feedback = keystream; + keystream = temp; + offset += blockLength; + } + + return output; + } + + /// + /// Encrypts data with AES-128-CBC. The input is zero-padded to a 16-byte multiple and the full + /// (untrimmed) output is returned, matching the S0 MAC construction. + /// + public static byte[] Aes128CbcEncrypt(ReadOnlySpan plaintext, byte[] key, byte[] iv) + { + byte[] padded = ZeroPadToBlock(plaintext); + using Aes aes = Aes.Create(); + aes.Key = key; + return aes.EncryptCbc(padded, iv, PaddingMode.None); + } + + /// + /// Computes the S0 MAC: AES-128-CBC of the auth data under the auth key (zero IV), taking the + /// first 8 bytes of the final 16-byte block. + /// + public static ReadOnlySpan ComputeMac(ReadOnlySpan authData, byte[] authKey) + { + byte[] cbc = Aes128CbcEncrypt(authData, authKey, ZeroIv); + return cbc.AsSpan()[^BlockSize..^(BlockSize / 2)]; + } + + private static byte[] Fill16(byte value) + { + byte[] block = new byte[BlockSize]; + block.AsSpan().Fill(value); + return block; + } + + private static byte[] ZeroPadToBlock(ReadOnlySpan data) + { + int remainder = data.Length % BlockSize; + int paddedLength = remainder == 0 ? data.Length : data.Length + (BlockSize - remainder); + byte[] padded = new byte[paddedLength]; + data.CopyTo(padded); + return padded; + } +} diff --git a/src/ZWave.CommandClasses/S0SecurityManager.cs b/src/ZWave.CommandClasses/S0SecurityManager.cs new file mode 100644 index 0000000..747566f --- /dev/null +++ b/src/ZWave.CommandClasses/S0SecurityManager.cs @@ -0,0 +1,272 @@ +using System.Security.Cryptography; + +namespace ZWave.CommandClasses; + +/// +/// Tracks the Security 0 state for a single node: the (derived) network keys and the pool of +/// nonces issued to and received from peer nodes. +/// +/// +/// Each nonce is 8 random bytes; its first byte (the "nonce ID") is the pool key. Nonces that +/// this node generated have an "issuer" equal to and must be kept +/// (used to decrypt/replay-check inbound commands); nonces received from a peer are stored as +/// "free" and consumed when this node sends to that peer. Nonces expire after . +/// +internal sealed class S0SecurityManager +{ + private readonly record struct NonceEntry(ReadOnlyMemory Nonce, ushort Receiver, bool Free, DateTimeOffset ExpiresAt); + + private readonly Lock _lock = new(); + private readonly ushort _ownNodeId; + private readonly TimeSpan _nonceTimeout; + private readonly Dictionary<(ushort Issuer, byte NonceId), NonceEntry> _nonces = new(); + private byte[]? _networkKey; + private byte[]? _authKey; + private byte[]? _encryptionKey; + + public S0SecurityManager(ushort ownNodeId, TimeSpan? nonceTimeout = null) + { + _ownNodeId = ownNodeId; + _nonceTimeout = nonceTimeout ?? TimeSpan.FromSeconds(20); + } + + /// + /// Gets whether a network key has been set for this node. + /// + public bool HasNetworkKey + { + get + { + lock (_lock) + { + return _networkKey is not null; + } + } + } + + /// + /// Sets (or replaces) the 16-byte network key and invalidates the cached derived keys. + /// + public void SetNetworkKey(ReadOnlySpan networkKey) + { + if (networkKey.Length != 16) + { + ZWaveException.Throw(ZWaveErrorCode.CommandInvalidArgument, "The S0 network key must be 16 bytes long"); + } + + lock (_lock) + { + _networkKey = networkKey.ToArray(); + _authKey = null; + _encryptionKey = null; + } + } + + /// + /// Gets the derived authentication key (deriving and caching it on first use). + /// + public byte[] GetAuthKey() + { + lock (_lock) + { + if (_authKey is null) + { + _authKey = S0Crypto.DeriveAuthKey(EnsureNetworkKey()); + } + + return _authKey; + } + } + + /// + /// Gets the derived encryption key (deriving and caching it on first use). + /// + public byte[] GetEncryptionKey() + { + lock (_lock) + { + if (_encryptionKey is null) + { + _encryptionKey = S0Crypto.DeriveEncryptionKey(EnsureNetworkKey()); + } + + return _encryptionKey; + } + } + + /// + /// Generates a fresh nonce issued by this node, for the specified receiver. + /// + public byte[] GenerateNonce(ushort receiver) + { + lock (_lock) + { + ExpireStaleNonces(); + + byte[] nonce = new byte[8]; + do + { + RandomNumberGenerator.Fill(nonce); + } + while (_nonces.ContainsKey((_ownNodeId, nonce[0]))); + + _nonces[(_ownNodeId, nonce[0])] = new NonceEntry(nonce, receiver, Free: false, DateTimeOffset.UtcNow + _nonceTimeout); + return nonce; + } + } + + /// + /// Generates a fresh 8-byte sender nonce for attachment to an outbound encapsulated command. + /// + /// + /// Unlike , the nonce is not tracked in the pool. A sender + /// nonce is used only for the current frame's IV and MAC and is carried in the frame itself; + /// a peer never references it back by ID, so storing it would only let + /// later return a nonce that was never issued to any peer. + /// + public byte[] GenerateSenderNonce() + { + byte[] nonce = new byte[8]; + RandomNumberGenerator.Fill(nonce); + return nonce; + } + + /// + /// Stores an 8-byte nonce received from the specified issuer. + /// + public void StoreReceivedNonce(ushort issuer, ReadOnlySpan nonce) + { + if (nonce.Length != 8) + { + ZWaveException.Throw(ZWaveErrorCode.CommandInvalidArgument, "The S0 nonce must be 8 bytes long"); + } + + lock (_lock) + { + _nonces[(issuer, nonce[0])] = new NonceEntry(nonce.ToArray(), _ownNodeId, Free: true, DateTimeOffset.UtcNow + _nonceTimeout); + } + } + + /// + /// Removes and returns a usable (free, non-expired) nonce issued by the specified issuer, or + /// null if none is available. + /// + public ReadOnlySpan GetUsableNonce(ushort issuer) + { + lock (_lock) + { + ExpireStaleNonces(); + + foreach (KeyValuePair<(ushort Issuer, byte NonceId), NonceEntry> pair in _nonces) + { + if (pair.Key.Issuer == issuer && pair.Value.Free) + { + NonceEntry entry = pair.Value; + _nonces.Remove(pair.Key); + return entry.Nonce.Span; + } + } + + return []; + } + } + + /// + /// Gets this node's own nonce for the given nonce ID (used to decrypt an inbound command), + /// or null if unknown, expired, or not issued to . + /// + /// + /// The nonce is only returned when it was issued to : a nonce + /// issued to one peer must not be consumable by another, even though all S0 nodes share the + /// network key (and hence can compute a valid MAC once they learn the nonce value). + /// + public ReadOnlySpan GetOwnNonce(byte nonceId, ushort sourceNodeId) + { + lock (_lock) + { + ExpireStaleNonces(); + + if (_nonces.TryGetValue((_ownNodeId, nonceId), out NonceEntry entry) + && entry.Receiver == sourceNodeId) + { + return entry.Nonce.Span; + } + + return []; + } + } + + /// + /// Atomically retrieves and consumes this node's own nonce for the given nonce ID, so that a + /// nonce can be consumed by at most one caller. + /// + /// + /// Under a single lock this expires stale entries, finds the nonce by ID, and verifies it was + /// issued to . On success it removes the nonce (and any other + /// nonce issued to the same peer, per the single-use replay rules) and returns an owned copy + /// of the nonce bytes. Returns null if the nonce is unknown, expired, or not issued to + /// . Batching the lookup and the removal in one critical + /// section is what makes nonce consumption race-free. + /// + public byte[]? ConsumeOwnNonce(byte nonceId, ushort sourceNodeId) + { + lock (_lock) + { + ExpireStaleNonces(); + + if (_nonces.TryGetValue((_ownNodeId, nonceId), out NonceEntry entry) + && entry.Receiver == sourceNodeId) + { + _nonces.Remove((_ownNodeId, nonceId)); + DeleteAllNoncesForReceiver(entry.Receiver); + return entry.Nonce.ToArray(); + } + + return null; + } + } + + /// + /// Removes all nonces for this node. + /// + public void Clear() + { + lock (_lock) + { + _nonces.Clear(); + } + } + + private void DeleteAllNoncesForReceiver(ushort receiver) + { + foreach (KeyValuePair<(ushort Issuer, byte NonceId), NonceEntry> pair in _nonces) + { + if (pair.Value.Receiver == receiver) + { + _nonces.Remove(pair.Key); + } + } + } + + private void ExpireStaleNonces() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + foreach (KeyValuePair<(ushort Issuer, byte NonceId), NonceEntry> pair in _nonces) + { + if (pair.Value.ExpiresAt <= now) + { + _nonces.Remove(pair.Key); + } + } + } + + private byte[] EnsureNetworkKey() + { + if (_networkKey is null) + { + ZWaveException.Throw(ZWaveErrorCode.CommandNotReady, "No S0 network key has been set for this node"); + } + + return _networkKey; + } +} diff --git a/src/ZWave.CommandClasses/Security0CommandClass.CommandsSupported.cs b/src/ZWave.CommandClasses/Security0CommandClass.CommandsSupported.cs new file mode 100644 index 0000000..ba734d8 --- /dev/null +++ b/src/ZWave.CommandClasses/Security0CommandClass.CommandsSupported.cs @@ -0,0 +1,125 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents a Security 0 Commands Supported Report (spec §3.5.4.3). +/// +public readonly record struct Security0CommandsSupportedReport( + /// + /// The command classes this node supports and/or controls securely. + /// + IReadOnlyList CommandClasses); + +public sealed partial class Security0CommandClass +{ + /// + /// Event raised when a Commands Supported Report is received (solicited or unsolicited). + /// + public event Action? OnCommandsSupportedReportReceived; + + /// + /// Queries the command classes supported by this node. + /// + /// + /// Per spec §3.5.4, this command is only valid in the secure (encapsulated) context. The + /// Driver's Security 0 pipeline (a follow-up) inserts the nonce exchange and encapsulation; + /// this method issues the plain command and awaits the plain report. A node may respond with + /// several frames; each frame's "reports to follow" count says how many further frames will + /// follow, and this method accumulates them into a single report. + /// + public async Task GetCommandsSupportedAsync(CancellationToken cancellationToken) + { + CommandsSupportedGetCommand command = CommandsSupportedGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + (Security0CommandsSupportedReport report, byte reportsToFollow) = CommandsSupportedReportCommand.Parse(reportFrame, Logger); + List classes = new(report.CommandClasses); + while (reportsToFollow > 0) + { + reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + (report, reportsToFollow) = CommandsSupportedReportCommand.Parse(reportFrame, Logger); + classes.AddRange(report.CommandClasses); + } + + Security0CommandsSupportedReport result = new(classes); + OnCommandsSupportedReportReceived?.Invoke(result); + return result; + } + + /// + /// Commands Supported Get command (spec §3.5.5.1). No parameters. + /// + internal readonly struct CommandsSupportedGetCommand : ICommand + { + public CommandsSupportedGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Security0; + + public static byte CommandId => (byte)Security0Command.CommandsSupportedGet; + + public CommandClassFrame Frame { get; } + + public static CommandsSupportedGetCommand Create() + => new(CommandClassFrame.Create(CommandClassId, CommandId)); + } + + /// + /// Commands Supported Report command (spec §3.5.4.3): a reports-to-follow byte, the supported + /// command classes, the COMMAND_CLASS_MARK, then the controlled command classes. + /// + internal readonly struct CommandsSupportedReportCommand : ICommand + { + public CommandsSupportedReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Security0; + + public static byte CommandId => (byte)Security0Command.CommandsSupportedReport; + + public CommandClassFrame Frame { get; } + + public static CommandsSupportedReportCommand Create(IReadOnlyList supported, IReadOnlyList controlled) + { + List parameters = new(); + parameters.Add(0x00); // reports to follow (this is a single-frame report) + foreach (CommandClassId commandClass in supported) + { + parameters.Add((byte)commandClass); + } + + parameters.Add((byte)CommandClassId.SupportControlMark); + foreach (CommandClassId commandClass in controlled) + { + parameters.Add((byte)commandClass); + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, parameters.ToArray()); + return new CommandsSupportedReportCommand(frame); + } + + public static (Security0CommandsSupportedReport Report, byte ReportsToFollow) Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length < 1) + { + logger.LogWarning("Commands Supported Report frame is malformed ({Length} bytes)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Commands Supported Report frame is malformed"); + } + + // parameters[0] is "reports to follow" (a continuation count), not a class count. A + // single-frame report is 0; the remaining bytes are the supported/controlled class + // list, which ParseList splits on the SupportControlMark. The count is returned + // alongside the report (a chaining detail) rather than stored in it. + byte reportsToFollow = frame.CommandParameters.Span[0]; + IReadOnlyList commandClasses = CommandClassInfo.ParseList(frame.CommandParameters.Span[1..]); + + return (new Security0CommandsSupportedReport(commandClasses), reportsToFollow); + } + } +} diff --git a/src/ZWave.CommandClasses/Security0CommandClass.Encapsulation.cs b/src/ZWave.CommandClasses/Security0CommandClass.Encapsulation.cs new file mode 100644 index 0000000..208f65a --- /dev/null +++ b/src/ZWave.CommandClasses/Security0CommandClass.Encapsulation.cs @@ -0,0 +1,251 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents a parsed Security 0 Encapsulation frame. +/// +public readonly record struct Security0Encapsulation( + /// + /// The de-encapsulated (decrypted) command class frame. + /// + CommandClassFrame EncapsulatedFrame, + + /// + /// The frame control byte (sequence counter / sequenced / second-frame flags). + /// + byte FrameControl, + + /// + /// Whether the frame is a Security Message Encapsulation Nonce Get (0xC1), meaning the sender + /// is requesting a fresh nonce from this node (reply with a Nonce Report). + /// + bool RequestedNonce); + +public sealed partial class Security0CommandClass +{ + /// + /// Creates a Security 0 Encapsulation frame wrapping the specified command for delivery to + /// . + /// + /// + /// Consumes a nonce previously received from the destination (via a Nonce Report) and generates + /// this node's own nonce. Throws with + /// CommandNotReady if no usable nonce is available. When + /// is set, the frame is a Security Message Encapsulation Nonce Get (0xC1), asking the + /// destination to send a fresh nonce in reply. + /// + public CommandClassFrame CreateEncapsulation(CommandClassFrame innerFrame, ushort destNodeId, bool requestNonce = false) + => CommandEncapsulationCommand.Create(_manager, Endpoint.NodeId, destNodeId, innerFrame, 0x00, requestNonce).Frame; + + /// + /// Parses a Security 0 Encapsulation frame received from . + /// + /// The de-encapsulated frame, or null if the frame is malformed, the referenced + /// nonce is unknown/expired, or the MAC does not match. + public Security0Encapsulation? ParseEncapsulation(CommandClassFrame frame, ushort sourceNodeId) + => CommandEncapsulationCommand.Parse(_manager, sourceNodeId, Endpoint.NodeId, frame, Logger); + + /// + /// Security 0 Command Encapsulation command (spec §3.5.2). + /// + /// + /// The command ID is 0x81 (Security Message Encapsulation) or 0xC1 (Security Message + /// Encapsulation Nonce Get). The frame is addressed to the Security CC (0x98); the command ID + /// is a separate field and is NOT 0x98. + /// Wire format (command parameters): + /// bytes 0..7: Sender nonce (8 bytes, generated by this node) + /// bytes 8..N-9: Ciphertext = AES-128-OFB([frame control][inner command]) + /// byte N-9: Receiver nonce ID (1 byte, first byte of the destination's nonce) + /// bytes N-8..N: MAC (8 bytes) + /// The IV for OFB is [sender nonce][receiver nonce] (16 bytes). Per SDS10865-11 §5.4.1, the MAC + /// is the first 8 bytes of the final block of AES-128-CBC(authData, auth key, zero IV), where + /// authData = [sender nonce][receiver nonce][security header][src node][dst node][ciphertext length][ciphertext]. + /// The security header is the actual outer command ID (0x81 or 0xC1), not the CC ID (0x98); both + /// sender and receiver must MAC with the byte that appears in the frame. + /// + internal readonly struct CommandEncapsulationCommand : ICommand + { + private const int MinParamsLength = 18; + private const int NonceLength = 8; + private const int MacLength = 8; + + public CommandEncapsulationCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Security0; + + public static byte CommandId => (byte)Security0Command.CommandEncapsulation; + + public CommandClassFrame Frame { get; } + + public static CommandEncapsulationCommand Create( + S0SecurityManager manager, + ushort srcNodeId, + ushort dstNodeId, + CommandClassFrame innerFrame, + byte frameControl, + bool requestNonce = false) + { + if (srcNodeId > 255 || dstNodeId > 255) + { + ZWaveException.Throw(ZWaveErrorCode.CommandInvalidArgument, "Security 0 only supports 8-bit node IDs"); + } + + if (!manager.HasNetworkKey) + { + ZWaveException.Throw(ZWaveErrorCode.CommandNotReady, "No S0 network key has been set for this node"); + } + + ReadOnlySpan receiverNonce = manager.GetUsableNonce(dstNodeId); + if (receiverNonce.IsEmpty) + { + ZWaveException.Throw(ZWaveErrorCode.CommandNotReady, $"No usable nonce is available for node {dstNodeId}"); + } + + // The sender nonce is used only for this frame's IV and MAC and is carried in the + // frame itself, so it is generated fresh rather than tracked in the manager's pool. + byte[] senderNonce = manager.GenerateSenderNonce(); + + byte[] plaintext = new byte[1 + innerFrame.Data.Length]; + plaintext[0] = frameControl; + innerFrame.Data.Span.CopyTo(plaintext.AsSpan(1)); + + byte[] encryptionKey = manager.GetEncryptionKey(); + byte[] iv = BuildIv(senderNonce, receiverNonce); + byte[] ciphertext = S0Crypto.EncryptOfb(plaintext, encryptionKey, iv); + if (ciphertext.Length > 255) + { + ZWaveException.Throw(ZWaveErrorCode.CommandInvalidArgument, "The S0 ciphertext exceeds the 1-byte length limit"); + } + + byte securityHeader = requestNonce + ? (byte)Security0Command.CommandEncapsulationNonceGet + : (byte)Security0Command.CommandEncapsulation; + + byte[] authData = BuildAuthData(senderNonce, receiverNonce, securityHeader, srcNodeId, dstNodeId, ciphertext); + byte[] authKey = manager.GetAuthKey(); + ReadOnlySpan mac = S0Crypto.ComputeMac(authData, authKey); + + byte[] parameters = new byte[NonceLength + ciphertext.Length + 1 + MacLength]; + int offset = 0; + senderNonce.CopyTo(parameters.AsSpan(offset)); + offset += NonceLength; + ciphertext.CopyTo(parameters.AsSpan(offset)); + offset += ciphertext.Length; + parameters[offset] = receiverNonce[0]; + offset += 1; + mac.CopyTo(parameters.AsSpan(offset)); + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, securityHeader, parameters); + return new CommandEncapsulationCommand(frame); + } + + public static Security0Encapsulation? Parse( + S0SecurityManager manager, + ushort srcNodeId, + ushort dstNodeId, + CommandClassFrame frame, + ILogger logger) + { + if (srcNodeId > 255 || dstNodeId > 255) + { + logger.LogWarning("Security 0 Encapsulation frame has an unsupported node ID (S0 is 8-bit only)"); + return null; + } + + if (!manager.HasNetworkKey) + { + logger.LogWarning("Security 0 Encapsulation frame received but no network key is set for this node"); + return null; + } + + byte securityHeader = frame.CommandId; + if (securityHeader != (byte)Security0Command.CommandEncapsulation + && securityHeader != (byte)Security0Command.CommandEncapsulationNonceGet) + { + logger.LogWarning("Security 0 Encapsulation frame has an unsupported command ID (0x{CommandId:X2})", securityHeader); + return null; + } + + ReadOnlySpan parameters = frame.CommandParameters.Span; + if (parameters.Length < MinParamsLength) + { + logger.LogWarning("Security 0 Encapsulation frame is too short ({Length} bytes)", parameters.Length); + return null; + } + + ReadOnlySpan senderNonce = parameters[..NonceLength]; + ReadOnlySpan ciphertext = parameters[NonceLength..^9]; + byte nonceId = parameters[^9]; + ReadOnlySpan mac = parameters[^MacLength..]; + + // The nonce must have been issued to the frame's source node; a mismatch (as well as an + // unknown or expired nonce) is rejected. The lookup and the removal are one atomic + // operation, and the nonce is consumed before the MAC is validated (spec §3.5.2). + byte[]? ownNonce = manager.ConsumeOwnNonce(nonceId, srcNodeId); + if (ownNonce is null) + { + logger.LogWarning("Security 0 Encapsulation frame from node {SrcNodeId} references an unknown, expired, or foreign nonce (0x{NonceId:X2})", srcNodeId, nonceId); + return null; + } + + byte[] authData = BuildAuthData(senderNonce, ownNonce, securityHeader, srcNodeId, dstNodeId, ciphertext); + ReadOnlySpan expectedMac = S0Crypto.ComputeMac(authData, manager.GetAuthKey()); + if (!mac.SequenceEqual(expectedMac)) + { + logger.LogWarning("Security 0 Encapsulation frame MAC mismatch"); + return null; + } + + byte[] iv = BuildIv(senderNonce, ownNonce); + byte[] plaintext = S0Crypto.DecryptOfb(ciphertext, manager.GetEncryptionKey(), iv); + if (plaintext.Length < 3) + { + logger.LogWarning("Security 0 Encapsulation frame decrypted to too short a payload ({Length} bytes)", plaintext.Length); + return null; + } + + byte frameControl = plaintext[0]; + CommandClassFrame encapsulatedFrame = new(plaintext[1..]); + bool requestedNonce = securityHeader == (byte)Security0Command.CommandEncapsulationNonceGet; + return new Security0Encapsulation(encapsulatedFrame, frameControl, requestedNonce); + } + + private static byte[] BuildIv(ReadOnlySpan senderNonce, ReadOnlySpan receiverNonce) + { + byte[] iv = new byte[NonceLength * 2]; + senderNonce.CopyTo(iv); + receiverNonce.CopyTo(iv.AsSpan(NonceLength)); + return iv; + } + + private static byte[] BuildAuthData( + ReadOnlySpan senderNonce, + ReadOnlySpan receiverNonce, + byte securityHeader, + ushort srcNodeId, + ushort dstNodeId, + ReadOnlySpan ciphertext) + { + byte[] authData = new byte[NonceLength * 2 + 4 + ciphertext.Length]; + int offset = 0; + senderNonce.CopyTo(authData.AsSpan(offset)); + offset += NonceLength; + receiverNonce.CopyTo(authData.AsSpan(offset)); + offset += NonceLength; + authData[offset] = securityHeader; + offset += 1; + authData[offset] = (byte)srcNodeId; + offset += 1; + authData[offset] = (byte)dstNodeId; + offset += 1; + authData[offset] = (byte)ciphertext.Length; + offset += 1; + ciphertext.CopyTo(authData.AsSpan(offset)); + return authData; + } + } +} diff --git a/src/ZWave.CommandClasses/Security0CommandClass.NetworkKey.cs b/src/ZWave.CommandClasses/Security0CommandClass.NetworkKey.cs new file mode 100644 index 0000000..186b198 --- /dev/null +++ b/src/ZWave.CommandClasses/Security0CommandClass.NetworkKey.cs @@ -0,0 +1,102 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +public sealed partial class Security0CommandClass +{ + /// + /// Sets the 16-byte S0 network key locally for this node. + /// + /// + /// This is used for nodes whose network key is already known (e.g. restored from persistence). + /// During inclusion the key is instead received from the node via the Network Key Set command + /// (spec §3.5.4), which is a follow-up. + /// + public void SetNetworkKey(ReadOnlySpan networkKey) + => _manager.SetNetworkKey(networkKey); + + /// + /// Gets whether a network key has been set for this node. + /// + public bool HasNetworkKey => _manager.HasNetworkKey; + + /// + /// Network Key Set command (spec §3.5.4.1). Carries the 16-byte network key. + /// + internal readonly struct NetworkKeySetCommand : ICommand + { + public NetworkKeySetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Security0; + + public static byte CommandId => (byte)Security0Command.NetworkKeySet; + + public CommandClassFrame Frame { get; } + + public static NetworkKeySetCommand Create(ReadOnlySpan networkKey) + { + if (networkKey.Length != 16) + { + ZWaveException.Throw(ZWaveErrorCode.CommandInvalidArgument, "The S0 network key must be 16 bytes long"); + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, networkKey); + return new NetworkKeySetCommand(frame); + } + + public static byte[] Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length != 16) + { + logger.LogWarning("Network Key Set frame is malformed ({Length} bytes, expected 16)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Network Key Set frame is malformed"); + } + + return frame.CommandParameters.ToArray(); + } + } + + /// + /// Network Key Verify command (spec §3.5.3.5). No parameters; sent by the node after it + /// successfully decrypts the Network Key Set. + /// + internal readonly struct NetworkKeyVerifyCommand : ICommand + { + public NetworkKeyVerifyCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Security0; + + public static byte CommandId => (byte)Security0Command.NetworkKeyVerify; + + public CommandClassFrame Frame { get; } + + public static NetworkKeyVerifyCommand Create() + => new(CommandClassFrame.Create(CommandClassId, CommandId)); + } + + /// + /// Scheme Inherit command (spec §3.5.3.6). Carries the Supported Security Schemes byte. + /// + internal readonly struct SchemeInheritCommand : ICommand + { + public SchemeInheritCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Security0; + + public static byte CommandId => (byte)Security0Command.SchemeInherit; + + public CommandClassFrame Frame { get; } + + public static SchemeInheritCommand Create() + => new(CommandClassFrame.Create(CommandClassId, CommandId, [SupportedSecuritySchemesS0])); + } +} diff --git a/src/ZWave.CommandClasses/Security0CommandClass.Nonce.cs b/src/ZWave.CommandClasses/Security0CommandClass.Nonce.cs new file mode 100644 index 0000000..9c2a609 --- /dev/null +++ b/src/ZWave.CommandClasses/Security0CommandClass.Nonce.cs @@ -0,0 +1,97 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents a Security 0 nonce (8 random bytes). +/// +public readonly record struct Security0Nonce(ReadOnlyMemory Nonce) +{ + /// + /// Gets the nonce ID, which is the first byte of the nonce. + /// + public byte NonceId => Nonce.Span[0]; +} + +public sealed partial class Security0CommandClass +{ + /// + /// Event raised when a Nonce Report is received (solicited or unsolicited). + /// + public event Action? OnNonceReportReceived; + + /// + /// Requests a nonce from this node. + /// + public async Task GetNonceAsync(CancellationToken cancellationToken) + { + NonceGetCommand command = NonceGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + Security0Nonce nonce = NonceReportCommand.Parse(reportFrame, Logger); + OnNonceReportReceived?.Invoke(nonce); + return nonce; + } + + /// + /// Nonce Get command (spec §3.5.2.1). Requests a fresh nonce; no parameters. + /// + internal readonly struct NonceGetCommand : ICommand + { + public NonceGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Security0; + + public static byte CommandId => (byte)Security0Command.NonceGet; + + public CommandClassFrame Frame { get; } + + public static NonceGetCommand Create() + => new(CommandClassFrame.Create(CommandClassId, CommandId)); + } + + /// + /// Nonce Report command (spec §3.5.2.2). Carries the 8-byte nonce. + /// + internal readonly struct NonceReportCommand : ICommand + { + public NonceReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Security0; + + public static byte CommandId => (byte)Security0Command.NonceReport; + + public CommandClassFrame Frame { get; } + + public static NonceReportCommand Create(ReadOnlySpan nonce) + { + if (nonce.Length != 8) + { + ZWaveException.Throw(ZWaveErrorCode.CommandInvalidArgument, "The S0 nonce must be 8 bytes long"); + } + + CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, nonce); + return new NonceReportCommand(frame); + } + + public static Security0Nonce Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length != 8) + { + logger.LogWarning("Nonce Report frame is malformed ({Length} bytes, expected 8)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Nonce Report frame is malformed"); + } + + // No copy: the frame is backed by a dedicated per-frame array (see FrameParser), so it is + // safe to hold a slice of it for the nonce's lifetime. + return new Security0Nonce(frame.CommandParameters); + } + } +} diff --git a/src/ZWave.CommandClasses/Security0CommandClass.Scheme.cs b/src/ZWave.CommandClasses/Security0CommandClass.Scheme.cs new file mode 100644 index 0000000..42e5aab --- /dev/null +++ b/src/ZWave.CommandClasses/Security0CommandClass.Scheme.cs @@ -0,0 +1,83 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Represents a Security Scheme Report (spec §3.5.3.3). Carries the Supported Security Schemes +/// byte, the same field as Scheme Get (S0 is represented by 0x00). +/// +public readonly record struct Security0SchemeReport(byte SupportedSecuritySchemes); + +public sealed partial class Security0CommandClass +{ + /// + /// Event raised when a Scheme Report is received (solicited or unsolicited). + /// + public event Action? OnSchemeReportReceived; + + /// + /// Queries the security schemes supported by this node. + /// + public async Task GetSchemeAsync(CancellationToken cancellationToken) + { + SchemeGetCommand command = SchemeGetCommand.Create(); + await SendCommandAsync(command, cancellationToken).ConfigureAwait(false); + + CommandClassFrame reportFrame = await AwaitNextReportAsync(cancellationToken).ConfigureAwait(false); + Security0SchemeReport report = SchemeReportCommand.Parse(reportFrame, Logger); + OnSchemeReportReceived?.Invoke(report); + return report; + } + + /// + /// Scheme Get command (spec §3.5.3.2). Carries the Supported Security Schemes byte. + /// + internal readonly struct SchemeGetCommand : ICommand + { + public SchemeGetCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Security0; + + public static byte CommandId => (byte)Security0Command.SchemeGet; + + public CommandClassFrame Frame { get; } + + public static SchemeGetCommand Create() + => new(CommandClassFrame.Create(CommandClassId, CommandId, [SupportedSecuritySchemesS0])); + } + + /// + /// Scheme Report command (spec §3.5.3.3). Carries the Supported Security Schemes byte, the + /// same field as Scheme Get; S0 is represented by 0x00. + /// + internal readonly struct SchemeReportCommand : ICommand + { + public SchemeReportCommand(CommandClassFrame frame) + { + Frame = frame; + } + + public static CommandClassId CommandClassId => CommandClassId.Security0; + + public static byte CommandId => (byte)Security0Command.SchemeReport; + + public CommandClassFrame Frame { get; } + + public static SchemeReportCommand Create() + => new(CommandClassFrame.Create(CommandClassId, CommandId, [SupportedSecuritySchemesS0])); + + public static Security0SchemeReport Parse(CommandClassFrame frame, ILogger logger) + { + if (frame.CommandParameters.Length != 1) + { + logger.LogWarning("Scheme Report frame is malformed ({Length} bytes, expected 1)", frame.CommandParameters.Length); + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Scheme Report frame is malformed"); + } + + return new Security0SchemeReport(frame.CommandParameters.Span[0]); + } + } +} diff --git a/src/ZWave.CommandClasses/Security0CommandClass.cs b/src/ZWave.CommandClasses/Security0CommandClass.cs new file mode 100644 index 0000000..a1b4bb2 --- /dev/null +++ b/src/ZWave.CommandClasses/Security0CommandClass.cs @@ -0,0 +1,99 @@ +using Microsoft.Extensions.Logging; + +namespace ZWave.CommandClasses; + +/// +/// Security 0 Command Class commands (version 1). +/// +public enum Security0Command : byte +{ + CommandsSupportedGet = 0x02, + CommandsSupportedReport = 0x03, + SchemeGet = 0x04, + SchemeReport = 0x05, + NetworkKeySet = 0x06, + NetworkKeyVerify = 0x07, + SchemeInherit = 0x08, + NonceGet = 0x40, + NonceReport = 0x80, + CommandEncapsulation = 0x81, + CommandEncapsulationNonceGet = 0xC1, +} + +/// +/// Implements the Security 0 (S0) Command Class (version 1). +/// +/// +/// Per the Transport-Encapsulation spec (SDS13783) §3.5, S0 is a Transport-Encapsulation CC that +/// provides command encapsulation using AES-128 (OFB) encryption and a CBC-based MAC. Phase 1 +/// provides the encryption/nonce/key infrastructure and the command structs; the Driver +/// integration that inserts this layer into the encapsulation pipeline (spec §4.1.3.5) is a +/// follow-up. +/// +[CommandClass(CommandClassId.Security0)] +public sealed partial class Security0CommandClass : CommandClass +{ + // Spec §3.5.3.2 Table 4: the Supported Security Schemes byte for an S0 node is 0x00 — + // bit 0 cleared indicates S0 support and all other bits are reserved (must be 0). + private const byte SupportedSecuritySchemesS0 = 0b0000_0000; + + private readonly S0SecurityManager _manager; + + internal Security0CommandClass( + CommandClassInfo info, + IDriver driver, + IEndpoint endpoint, + ILogger logger) + : base(info, driver, endpoint, logger) + { + _manager = new S0SecurityManager(endpoint.NodeId); + } + + /// + public override bool? IsCommandSupported(Security0Command command) + => command switch + { + Security0Command.CommandsSupportedGet => true, + Security0Command.SchemeGet => true, + Security0Command.NetworkKeySet => true, + Security0Command.NetworkKeyVerify => true, + Security0Command.SchemeInherit => true, + Security0Command.NonceGet => true, + _ => false, + }; + + /// + /// Per spec §3.5, Security 0 is a Transport-Encapsulation CC. + /// + internal override CommandClassCategory Category => CommandClassCategory.Transport; + + /// + /// Per spec §3.5, there is no mandatory node interview for this Command Class. + /// + internal override Task InterviewAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + protected override void ProcessUnsolicitedCommand(CommandClassFrame frame) + { + switch ((Security0Command)frame.CommandId) + { + case Security0Command.NonceReport: + { + Security0Nonce nonce = NonceReportCommand.Parse(frame, Logger); + OnNonceReportReceived?.Invoke(nonce); + break; + } + case Security0Command.SchemeReport: + { + Security0SchemeReport report = SchemeReportCommand.Parse(frame, Logger); + OnSchemeReportReceived?.Invoke(report); + break; + } + case Security0Command.CommandsSupportedReport: + { + (Security0CommandsSupportedReport report, _) = CommandsSupportedReportCommand.Parse(frame, Logger); + OnCommandsSupportedReportReceived?.Invoke(report); + break; + } + } + } +} diff --git a/src/ZWave.Protocol/CommandClassInfo.cs b/src/ZWave.Protocol/CommandClassInfo.cs index e517d7c..e1603c6 100644 --- a/src/ZWave.Protocol/CommandClassInfo.cs +++ b/src/ZWave.Protocol/CommandClassInfo.cs @@ -24,6 +24,9 @@ public record struct CommandClassInfo( /// The byte span may contain the (0xEF) separator. /// IDs before the mark are considered "supported"; IDs after it are "controlled". /// If no mark is present, all IDs are treated as supported. + /// Extended (2-byte) command classes (MSB 0xF1..0xFF) are skipped because they cannot be + /// represented by (a byte); a truncated extended class (a 0xF1..0xFF + /// byte with no following byte) throws . /// public static IReadOnlyList ParseList(ReadOnlySpan commandClassBytes) { @@ -32,7 +35,24 @@ public static IReadOnlyList ParseList(ReadOnlySpan comma bool isControlled = false; for (int i = 0; i < commandClassBytes.Length; i++) { - CommandClassId commandClassId = (CommandClassId)commandClassBytes[i]; + byte commandClassByte = commandClassBytes[i]; + + // Extended (2-byte) command classes (MSB 0xF1..0xFF) cannot be represented by + // CommandClassId (byte); skip the pair to keep the remaining list aligned. 0xF0 is a + // reserved value, not part of the extended range (spec §3.2 Command Class format). + // TODO: Handle extended (2-byte) command classes properly instead of skipping them. + if (commandClassByte >= 0xF1) + { + if (i + 1 >= commandClassBytes.Length) + { + ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "Command class list is truncated"); + } + + i++; + continue; + } + + CommandClassId commandClassId = (CommandClassId)commandClassByte; if (commandClassId == CommandClassId.SupportControlMark) { isSupported = false; diff --git a/src/ZWave.Serial.Tests/Commands/CommandDataParsingHelpersTests.cs b/src/ZWave.Serial.Tests/Commands/CommandDataParsingHelpersTests.cs index b937ea6..4d90884 100644 --- a/src/ZWave.Serial.Tests/Commands/CommandDataParsingHelpersTests.cs +++ b/src/ZWave.Serial.Tests/Commands/CommandDataParsingHelpersTests.cs @@ -79,6 +79,36 @@ public void ParseCommandClasses_MarkNotIncludedInResult() Assert.IsFalse(result.Any(cc => cc.CommandClass == CommandClassId.SupportControlMark)); } + [TestMethod] + public void ParseCommandClasses_ExtendedClassSkipped() + { + // A 2-byte extended command class (MSB 0xF1, LSB 0x22) is skipped to stay aligned. + byte[] data = + [ + (byte)CommandClassId.Basic, + 0xF1, + 0x22, + ]; + + IReadOnlyList result = CommandClassInfo.ParseList(data); + + Assert.HasCount(1, result); + Assert.AreEqual(new CommandClassInfo(CommandClassId.Basic, IsSupported: true, IsControlled: false), result[0]); + } + + [TestMethod] + public void ParseCommandClasses_TruncatedExtended_Throws() + { + // A 0xF1 MSB with no following LSB byte is a truncated extended class. + byte[] data = + [ + (byte)CommandClassId.Basic, + 0xF1, + ]; + + Assert.Throws(() => CommandClassInfo.ParseList(data)); + } + [TestMethod] public void ParseNodeBitmask_EmptyBitmask_ReturnsEmpty() { diff --git a/src/ZWave/CommandClassCollection.cs b/src/ZWave/CommandClassCollection.cs index 57c9cd2..34d6562 100644 --- a/src/ZWave/CommandClassCollection.cs +++ b/src/ZWave/CommandClassCollection.cs @@ -20,7 +20,7 @@ internal sealed class CommandClassCollection // Copy-on-write dictionary for lock-free reads. Writes are protected by _writeLock. private volatile Dictionary _commandClasses = new Dictionary(); - private readonly object _writeLock = new object(); + private readonly Lock _writeLock = new(); internal CommandClassCollection(IDriver driver, IEndpoint endpoint, ILogger logger) { diff --git a/src/ZWave/Driver.cs b/src/ZWave/Driver.cs index 741cab7..0a3a4d5 100644 --- a/src/ZWave/Driver.cs +++ b/src/ZWave/Driver.cs @@ -32,7 +32,7 @@ private record struct UnresolvedCallbackKey(CommandId CommandId, byte SessionId) private readonly Task _frameProcessingTask; // Lock anything related to session ids or callbacks - private readonly object _callbackLock = new object(); + private readonly Lock _callbackLock = new(); private readonly Dictionary> _unresolvedCallbacks = new Dictionary>(); @@ -49,7 +49,7 @@ private record struct UnresolvedCallbackKey(CommandId CommandId, byte SessionId) private CommandParsingContext CommandParsingContext => new CommandParsingContext(NodeIdType); // Lock access to _awaitedFrameResponse - private readonly object _requestResponseFrameFlowLock = new object(); + private readonly Lock _requestResponseFrameFlowLock = new(); private AwaitedFrameResponse? _awaitedFrameResponse; diff --git a/src/ZWave/Node.cs b/src/ZWave/Node.cs index acd6316..b9720d0 100644 --- a/src/ZWave/Node.cs +++ b/src/ZWave/Node.cs @@ -25,9 +25,9 @@ public sealed class Node : INode // Child endpoints (1–127). Writes protected by _endpointsWriteLock. private volatile Dictionary _endpoints = []; - private readonly object _endpointsWriteLock = new(); + private readonly Lock _endpointsWriteLock = new(); - private readonly object _interviewStateLock = new object(); + private readonly Lock _interviewStateLock = new(); private Task? _interviewTask;