From b9fc33ce13d95540f2a7b98150150d27eb36ee98 Mon Sep 17 00:00:00 2001 From: J-N-K Date: Tue, 30 Jan 2024 17:15:03 +0100 Subject: [PATCH] [tuya] Improve Netty handlers (#564) * [tuya] Improve Netty handlers Signed-off-by: Jan N. Klug --- .../tuya/internal/local/TuyaDevice.java | 70 +++++++------------ .../internal/local/UdpDiscoveryListener.java | 12 +++- .../local/handlers/HeartbeatHandler.java | 26 ++++--- .../internal/local/handlers/TuyaDecoder.java | 42 ++++++----- .../internal/local/handlers/TuyaEncoder.java | 49 +++++++------ .../local/handlers/TuyaMessageHandler.java | 52 ++++++++++---- .../local/handlers/UserEventHandler.java | 21 ++++-- .../local/handlers/TuyaDecoderTest.java | 29 ++++++-- .../local/handlers/TuyaEncoderTest.java | 32 +++++++-- 9 files changed, 204 insertions(+), 129 deletions(-) diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/TuyaDevice.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/TuyaDevice.java index 8a30f86f1b..680df8babf 100644 --- a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/TuyaDevice.java +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/TuyaDevice.java @@ -49,6 +49,7 @@ import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.timeout.IdleStateHandler; +import io.netty.util.AttributeKey; /** * The {@link TuyaDevice} handles the device connection @@ -57,22 +58,27 @@ */ @NonNullByDefault public class TuyaDevice implements ChannelFutureListener { + public static final AttributeKey DEVICE_ID_ATTR = AttributeKey.valueOf("deviceId"); + public static final AttributeKey PROTOCOL_ATTR = AttributeKey.valueOf("protocol"); + public static final AttributeKey SESSION_RANDOM_ATTR = AttributeKey.valueOf("sessionRandom"); + public static final AttributeKey SESSION_KEY_ATTR = AttributeKey.valueOf("sessionKey"); + private final Logger logger = LoggerFactory.getLogger(TuyaDevice.class); private final Bootstrap bootstrap = new Bootstrap(); private final DeviceStatusListener deviceStatusListener; private final String deviceId; + private final byte[] deviceKey; private final String address; private final ProtocolVersion protocolVersion; - private final KeyStore keyStore; private @Nullable Channel channel; public TuyaDevice(Gson gson, DeviceStatusListener deviceStatusListener, EventLoopGroup eventLoopGroup, String deviceId, byte[] deviceKey, String address, String protocolVersion) { this.address = address; this.deviceId = deviceId; - this.keyStore = new KeyStore(deviceKey); + this.deviceKey = deviceKey; this.deviceStatusListener = deviceStatusListener; this.protocolVersion = ProtocolVersion.fromString(protocolVersion); bootstrap.group(eventLoopGroup).channel(NioSocketChannel.class); @@ -83,20 +89,17 @@ protected void initChannel(SocketChannel ch) throws Exception { ChannelPipeline pipeline = ch.pipeline(); pipeline.addLast("idleStateHandler", new IdleStateHandler(TCP_CONNECTION_TIMEOUT, TCP_CONNECTION_HEARTBEAT_INTERVAL, 0)); - pipeline.addLast("messageEncoder", - new TuyaEncoder(gson, deviceId, keyStore, TuyaDevice.this.protocolVersion)); - pipeline.addLast("messageDecoder", - new TuyaDecoder(gson, deviceId, keyStore, TuyaDevice.this.protocolVersion)); - pipeline.addLast("heartbeatHandler", new HeartbeatHandler(deviceId)); - pipeline.addLast("deviceHandler", new TuyaMessageHandler(deviceId, keyStore, deviceStatusListener)); - pipeline.addLast("userEventHandler", new UserEventHandler(deviceId)); + pipeline.addLast("messageEncoder", new TuyaEncoder(gson)); + pipeline.addLast("messageDecoder", new TuyaDecoder(gson)); + pipeline.addLast("heartbeatHandler", new HeartbeatHandler()); + pipeline.addLast("deviceHandler", new TuyaMessageHandler(deviceStatusListener)); + pipeline.addLast("userEventHandler", new UserEventHandler()); } }); connect(); } public void connect() { - keyStore.reset(); // reset session key bootstrap.connect(address, 6668).addListener(this); } @@ -147,12 +150,22 @@ public void dispose() { public void operationComplete(@NonNullByDefault({}) ChannelFuture channelFuture) throws Exception { if (channelFuture.isSuccess()) { Channel channel = channelFuture.channel(); - this.channel = channel; + channel.attr(DEVICE_ID_ATTR).set(deviceId); + channel.attr(PROTOCOL_ATTR).set(protocolVersion); + // session key is device key before negotiation + channel.attr(SESSION_KEY_ATTR).set(deviceKey); + if (protocolVersion == V3_4) { + byte[] sessionRandom = CryptoUtil.generateRandom(16); + channel.attr(SESSION_RANDOM_ATTR).set(sessionRandom); + this.channel = channel; + // handshake for session key required - MessageWrapper m = new MessageWrapper<>(SESS_KEY_NEG_START, keyStore.getRandom()); + MessageWrapper m = new MessageWrapper<>(SESS_KEY_NEG_START, sessionRandom); channel.writeAndFlush(m); } else { + this.channel = channel; + // no handshake for 3.1/3.3 requestStatus(); } @@ -164,37 +177,4 @@ public void operationComplete(@NonNullByDefault({}) ChannelFuture channelFuture) deviceStatusListener.connectionStatus(false); } } - - public static class KeyStore { - private final byte[] deviceKey; - private byte[] sessionKey; - private byte[] random; - - public KeyStore(byte[] deviceKey) { - this.deviceKey = deviceKey; - this.sessionKey = deviceKey; - this.random = CryptoUtil.generateRandom(16).clone(); - } - - public void reset() { - this.sessionKey = this.deviceKey; - this.random = CryptoUtil.generateRandom(16).clone(); - } - - public byte[] getDeviceKey() { - return sessionKey; - } - - public byte[] getSessionKey() { - return sessionKey; - } - - public void setSessionKey(byte[] sessionKey) { - this.sessionKey = sessionKey; - } - - public byte[] getRandom() { - return random; - } - } } diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/UdpDiscoveryListener.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/UdpDiscoveryListener.java index 844ad17fdf..9b8476fcc3 100644 --- a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/UdpDiscoveryListener.java +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/UdpDiscoveryListener.java @@ -74,19 +74,25 @@ private void activate() { protected void initChannel(DatagramChannel ch) throws Exception { ChannelPipeline pipeline = ch.pipeline(); pipeline.addLast("udpDecoder", new DatagramToByteBufDecoder()); - pipeline.addLast("messageDecoder", new TuyaDecoder(gson, "udpListener", - new TuyaDevice.KeyStore(TUYA_UDP_KEY), ProtocolVersion.V3_1)); + pipeline.addLast("messageDecoder", new TuyaDecoder(gson)); pipeline.addLast("discoveryHandler", new DiscoveryMessageHandler(deviceInfos, deviceListeners)); - pipeline.addLast("userEventHandler", new UserEventHandler("udpListener")); + pipeline.addLast("userEventHandler", new UserEventHandler()); } }); ChannelFuture futureEncrypted = b.bind(6667).addListener(this).sync(); encryptedChannel = futureEncrypted.channel(); + encryptedChannel.attr(TuyaDevice.DEVICE_ID_ATTR).set("udpListener"); + encryptedChannel.attr(TuyaDevice.PROTOCOL_ATTR).set(ProtocolVersion.V3_1); + encryptedChannel.attr(TuyaDevice.SESSION_KEY_ATTR).set(TUYA_UDP_KEY); ChannelFuture futureRaw = b.bind(6666).addListener(this).sync(); rawChannel = futureRaw.channel(); + rawChannel.attr(TuyaDevice.DEVICE_ID_ATTR).set("udpListener"); + rawChannel.attr(TuyaDevice.PROTOCOL_ATTR).set(ProtocolVersion.V3_1); + rawChannel.attr(TuyaDevice.SESSION_KEY_ATTR).set(TUYA_UDP_KEY); + } catch (InterruptedException e) { throw new IllegalStateException(e); } diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/HeartbeatHandler.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/HeartbeatHandler.java index 9060c043c1..a920a6ece3 100644 --- a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/HeartbeatHandler.java +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/HeartbeatHandler.java @@ -23,6 +23,7 @@ import org.slf4j.LoggerFactory; import org.smarthomej.binding.tuya.internal.local.CommandType; import org.smarthomej.binding.tuya.internal.local.MessageWrapper; +import org.smarthomej.binding.tuya.internal.local.TuyaDevice; import io.netty.channel.ChannelDuplexHandler; import io.netty.channel.ChannelHandlerContext; @@ -37,18 +38,19 @@ @NonNullByDefault public class HeartbeatHandler extends ChannelDuplexHandler { private final Logger logger = LoggerFactory.getLogger(HeartbeatHandler.class); - private final String deviceId; private int heartBeatMissed = 0; - public HeartbeatHandler(String deviceId) { - this.deviceId = deviceId; - } - @Override public void userEventTriggered(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDefault({}) Object evt) throws Exception { - if (evt instanceof IdleStateEvent) { - IdleStateEvent e = (IdleStateEvent) evt; + if (!ctx.channel().hasAttr(TuyaDevice.DEVICE_ID_ATTR)) { + logger.warn("{}: Failed to retrieve deviceId from ChannelHandlerContext. This is a bug.", + Objects.requireNonNullElse(ctx.channel().remoteAddress(), "")); + return; + } + String deviceId = ctx.channel().attr(TuyaDevice.DEVICE_ID_ATTR).get(); + + if (evt instanceof IdleStateEvent e) { if (IdleState.READER_IDLE.equals(e.state())) { logger.warn("{}{}: Did not receive a message from for {} seconds. Connection seems to be dead.", deviceId, Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""), @@ -75,8 +77,14 @@ public void userEventTriggered(@NonNullByDefault({}) ChannelHandlerContext ctx, @Override public void channelRead(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDefault({}) Object msg) throws Exception { - if (msg instanceof MessageWrapper) { - MessageWrapper m = (MessageWrapper) msg; + if (!ctx.channel().hasAttr(TuyaDevice.DEVICE_ID_ATTR)) { + logger.warn("{}: Failed to retrieve deviceId from ChannelHandlerContext. This is a bug.", + Objects.requireNonNullElse(ctx.channel().remoteAddress(), "")); + return; + } + String deviceId = ctx.channel().attr(TuyaDevice.DEVICE_ID_ATTR).get(); + + if (msg instanceof MessageWrapper m) { if (CommandType.HEART_BEAT.equals(m.commandType)) { logger.trace("{}{}: Received pong", deviceId, Objects.requireNonNullElse(ctx.channel().remoteAddress(), "")); diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaDecoder.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaDecoder.java index c3b2ab1235..50df4f4df2 100644 --- a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaDecoder.java +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaDecoder.java @@ -20,6 +20,7 @@ import static org.smarthomej.binding.tuya.internal.local.CommandType.UDP_NEW; import static org.smarthomej.binding.tuya.internal.local.ProtocolVersion.V3_3; import static org.smarthomej.binding.tuya.internal.local.ProtocolVersion.V3_4; +import static org.smarthomej.binding.tuya.internal.local.TuyaDevice.*; import java.nio.ByteBuffer; import java.util.Arrays; @@ -35,7 +36,6 @@ import org.smarthomej.binding.tuya.internal.local.CommandType; import org.smarthomej.binding.tuya.internal.local.MessageWrapper; import org.smarthomej.binding.tuya.internal.local.ProtocolVersion; -import org.smarthomej.binding.tuya.internal.local.TuyaDevice; import org.smarthomej.binding.tuya.internal.local.dto.DiscoveryMessage; import org.smarthomej.binding.tuya.internal.local.dto.TcpStatusPayload; import org.smarthomej.binding.tuya.internal.util.CryptoUtil; @@ -58,16 +58,10 @@ public class TuyaDecoder extends ByteToMessageDecoder { private final Logger logger = LoggerFactory.getLogger(TuyaDecoder.class); - private final TuyaDevice.KeyStore keyStore; - private final ProtocolVersion version; private final Gson gson; - private final String deviceId; - public TuyaDecoder(Gson gson, String deviceId, TuyaDevice.KeyStore keyStore, ProtocolVersion version) { + public TuyaDecoder(Gson gson) { this.gson = gson; - this.keyStore = keyStore; - this.version = version; - this.deviceId = deviceId; } @Override @@ -78,6 +72,17 @@ public void decode(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDe return; } + if (!ctx.channel().hasAttr(DEVICE_ID_ATTR) || !ctx.channel().hasAttr(PROTOCOL_ATTR) + || !ctx.channel().hasAttr(SESSION_KEY_ATTR)) { + logger.warn( + "{}: Failed to retrieve deviceId, protocol or sessionKey from ChannelHandlerContext. This is a bug.", + Objects.requireNonNullElse(ctx.channel().remoteAddress(), "")); + return; + } + String deviceId = ctx.channel().attr(DEVICE_ID_ATTR).get(); + ProtocolVersion protocol = ctx.channel().attr(PROTOCOL_ATTR).get(); + byte[] sessionKey = ctx.channel().attr(SESSION_KEY_ATTR).get(); + // we need to take a copy first so the buffer stays intact if we exit early ByteBuf inCopy = in.copy(); byte[] bytes = new byte[inCopy.readableBytes()]; @@ -111,20 +116,20 @@ public void decode(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDe if ((returnCode & 0xffffff00) != 0) { // rewind if no return code is present buffer.position(buffer.position() - 4); - payload = version == V3_4 ? new byte[payloadLength - 32] : new byte[payloadLength - 8]; + payload = protocol == V3_4 ? new byte[payloadLength - 32] : new byte[payloadLength - 8]; } else { - payload = version == V3_4 ? new byte[payloadLength - 32 - 8] : new byte[payloadLength - 8 - 4]; + payload = protocol == V3_4 ? new byte[payloadLength - 32 - 8] : new byte[payloadLength - 8 - 4]; } buffer.get(payload); - if (version == V3_4 && commandType != UDP && commandType != UDP_NEW) { + if (protocol == V3_4 && commandType != UDP && commandType != UDP_NEW) { byte[] fullMessage = new byte[buffer.position()]; buffer.position(0); buffer.get(fullMessage); byte[] expectedHmac = new byte[32]; buffer.get(expectedHmac); - byte[] calculatedHmac = CryptoUtil.hmac(fullMessage, keyStore.getSessionKey()); + byte[] calculatedHmac = CryptoUtil.hmac(fullMessage, sessionKey); if (!Arrays.equals(expectedHmac, calculatedHmac)) { logger.warn("{}{}: Checksum failed for message: calculated {}, found {}", deviceId, Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""), @@ -150,8 +155,8 @@ public void decode(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDe return; } - if (Arrays.equals(Arrays.copyOfRange(payload, 0, version.getBytes().length), version.getBytes())) { - if (version == V3_3) { + if (Arrays.equals(Arrays.copyOfRange(payload, 0, protocol.getBytes().length), protocol.getBytes())) { + if (protocol == V3_3) { // Remove 3.3 header payload = Arrays.copyOfRange(payload, 15, payload.length); } else { @@ -165,13 +170,14 @@ public void decode(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDe m = new MessageWrapper<>(commandType, Objects.requireNonNull(gson.fromJson(new String(payload), DiscoveryMessage.class))); } else { - byte[] decodedMessage = version == V3_4 ? CryptoUtil.decryptAesEcb(payload, keyStore.getSessionKey(), true) - : CryptoUtil.decryptAesEcb(payload, keyStore.getDeviceKey(), false); + byte[] decodedMessage = protocol == V3_4 ? CryptoUtil.decryptAesEcb(payload, sessionKey, true) + : CryptoUtil.decryptAesEcb(payload, sessionKey, false); if (decodedMessage == null) { return; } - if (Arrays.equals(Arrays.copyOfRange(decodedMessage, 0, version.getBytes().length), version.getBytes())) { - if (version == V3_4) { + + if (Arrays.equals(Arrays.copyOfRange(decodedMessage, 0, protocol.getBytes().length), protocol.getBytes())) { + if (protocol == V3_4) { // Remove 3.4 header decodedMessage = Arrays.copyOfRange(decodedMessage, 15, decodedMessage.length); } diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaEncoder.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaEncoder.java index f652547a2b..5ddf63b233 100644 --- a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaEncoder.java +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaEncoder.java @@ -20,6 +20,8 @@ import static org.smarthomej.binding.tuya.internal.local.CommandType.SESS_KEY_NEG_START; import static org.smarthomej.binding.tuya.internal.local.ProtocolVersion.V3_3; import static org.smarthomej.binding.tuya.internal.local.ProtocolVersion.V3_4; +import static org.smarthomej.binding.tuya.internal.local.TuyaDevice.*; +import static org.smarthomej.binding.tuya.internal.local.TuyaDevice.SESSION_KEY_ATTR; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; @@ -37,7 +39,6 @@ import org.smarthomej.binding.tuya.internal.local.CommandType; import org.smarthomej.binding.tuya.internal.local.MessageWrapper; import org.smarthomej.binding.tuya.internal.local.ProtocolVersion; -import org.smarthomej.binding.tuya.internal.local.TuyaDevice; import org.smarthomej.binding.tuya.internal.util.CryptoUtil; import com.google.gson.Gson; @@ -57,31 +58,36 @@ public class TuyaEncoder extends MessageToByteEncoder> { private final Logger logger = LoggerFactory.getLogger(TuyaEncoder.class); - private final TuyaDevice.KeyStore keyStore; - private final ProtocolVersion version; - private final String deviceId; private final Gson gson; private int sequenceNo = 0; - public TuyaEncoder(Gson gson, String deviceId, TuyaDevice.KeyStore keyStore, ProtocolVersion version) { + public TuyaEncoder(Gson gson) { this.gson = gson; - this.deviceId = deviceId; - this.keyStore = keyStore; - this.version = version; } @Override @SuppressWarnings("unchecked") public void encode(@NonNullByDefault({}) ChannelHandlerContext ctx, MessageWrapper msg, @NonNullByDefault({}) ByteBuf out) throws Exception { + if (!ctx.channel().hasAttr(DEVICE_ID_ATTR) || !ctx.channel().hasAttr(PROTOCOL_ATTR) + || !ctx.channel().hasAttr(SESSION_KEY_ATTR)) { + logger.warn( + "{}: Failed to retrieve deviceId, protocol or sessionKey from ChannelHandlerContext. This is a bug.", + Objects.requireNonNullElse(ctx.channel().remoteAddress(), "")); + return; + } + String deviceId = ctx.channel().attr(DEVICE_ID_ATTR).get(); + ProtocolVersion protocol = ctx.channel().attr(PROTOCOL_ATTR).get(); + byte[] sessionKey = ctx.channel().attr(SESSION_KEY_ATTR).get(); + byte[] payloadBytes; // prepare payload if (msg.content == null || msg.content instanceof Map) { Map content = (Map) msg.content; Map payload = new HashMap<>(); - if (version == V3_4) { + if (protocol == V3_4) { payload.put("protocol", 5); payload.put("t", System.currentTimeMillis() / 1000); Map data = new HashMap<>(); @@ -119,8 +125,8 @@ public void encode(@NonNullByDefault({}) ChannelHandlerContext ctx, MessageWrapp return; } - Optional bufferOptional = version == V3_4 ? encode34(msg.commandType, payloadBytes) - : encodePre34(msg.commandType, payloadBytes); + Optional bufferOptional = protocol == V3_4 ? encode34(msg.commandType, payloadBytes, sessionKey) + : encodePre34(msg.commandType, payloadBytes, sessionKey, protocol); bufferOptional.ifPresentOrElse(buffer -> { if (logger.isTraceEnabled()) { @@ -132,11 +138,12 @@ public void encode(@NonNullByDefault({}) ChannelHandlerContext ctx, MessageWrapp }, () -> logger.debug("{}{}: Encoding returned an empty buffer", deviceId, ctx.channel().remoteAddress())); } - private Optional encodePre34(CommandType commandType, byte[] payload) { + private Optional encodePre34(CommandType commandType, byte[] payload, byte[] deviceKey, + ProtocolVersion protocol) { byte[] payloadBytes = payload; - if (version == V3_3) { + if (protocol == V3_3) { // Always encrypted - payloadBytes = CryptoUtil.encryptAesEcb(payloadBytes, keyStore.getDeviceKey(), true); + payloadBytes = CryptoUtil.encryptAesEcb(payloadBytes, deviceKey, true); if (payloadBytes == null) { return Optional.empty(); } @@ -151,16 +158,16 @@ private Optional encodePre34(CommandType commandType, byte[] payload) { } } else if (CommandType.CONTROL.equals(commandType)) { // Protocol 3.1 and below, only encrypt data if necessary - byte[] encryptedPayload = CryptoUtil.encryptAesEcb(payloadBytes, keyStore.getDeviceKey(), true); + byte[] encryptedPayload = CryptoUtil.encryptAesEcb(payloadBytes, deviceKey, true); if (encryptedPayload == null) { return Optional.empty(); } String payloadStr = Base64.encode(encryptedPayload); - String hash = CryptoUtil.md5( - "data=" + payloadStr + "||lpv=" + version.getString() + "||" + new String(keyStore.getDeviceKey())); + String hash = CryptoUtil + .md5("data=" + payloadStr + "||lpv=" + protocol.getString() + "||" + new String(deviceKey)); // Create byte buffer from hex data - payloadBytes = (version + hash.substring(8, 24) + payloadStr).getBytes(StandardCharsets.UTF_8); + payloadBytes = (protocol + hash.substring(8, 24) + payloadStr).getBytes(StandardCharsets.UTF_8); } // Allocate buffer with room for payload + 24 bytes for @@ -186,7 +193,7 @@ private Optional encodePre34(CommandType commandType, byte[] payload) { return Optional.of(buffer.array()); } - private Optional encode34(CommandType commandType, byte[] payloadBytes) { + private Optional encode34(CommandType commandType, byte[] payloadBytes, byte[] sessionKey) { byte[] rawPayload = payloadBytes; if (commandType != DP_QUERY && commandType != HEART_BEAT && commandType != DP_QUERY_NEW @@ -202,7 +209,7 @@ private Optional encode34(CommandType commandType, byte[] payloadBytes) Arrays.fill(padded, padding); System.arraycopy(rawPayload, 0, padded, 0, rawPayload.length); - byte[] encryptedPayload = CryptoUtil.encryptAesEcb(padded, keyStore.getSessionKey(), false); + byte[] encryptedPayload = CryptoUtil.encryptAesEcb(padded, sessionKey, false); if (encryptedPayload == null) { return Optional.empty(); } @@ -221,7 +228,7 @@ private Optional encode34(CommandType commandType, byte[] payloadBytes) // Calculate and add checksum byte[] checksumContent = new byte[encryptedPayload.length + 16]; System.arraycopy(buffer.array(), 0, checksumContent, 0, encryptedPayload.length + 16); - byte[] checksum = CryptoUtil.hmac(checksumContent, this.keyStore.getSessionKey()); + byte[] checksum = CryptoUtil.hmac(checksumContent, sessionKey); if (checksum == null) { return Optional.empty(); } diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaMessageHandler.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaMessageHandler.java index 734d4288ac..aa7ad7eedc 100644 --- a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaMessageHandler.java +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaMessageHandler.java @@ -12,6 +12,8 @@ */ package org.smarthomej.binding.tuya.internal.local.handlers; +import static org.smarthomej.binding.tuya.internal.local.TuyaDevice.SESSION_KEY_ATTR; + import java.util.Arrays; import java.util.Map; import java.util.Objects; @@ -39,19 +41,21 @@ public class TuyaMessageHandler extends ChannelDuplexHandler { private final Logger logger = LoggerFactory.getLogger(TuyaMessageHandler.class); - private final String deviceId; private final DeviceStatusListener deviceStatusListener; - private final TuyaDevice.KeyStore keyStore; - public TuyaMessageHandler(String deviceId, TuyaDevice.KeyStore keyStore, - DeviceStatusListener deviceStatusListener) { - this.deviceId = deviceId; - this.keyStore = keyStore; + public TuyaMessageHandler(DeviceStatusListener deviceStatusListener) { this.deviceStatusListener = deviceStatusListener; } @Override public void channelActive(@NonNullByDefault({}) ChannelHandlerContext ctx) throws Exception { + if (!ctx.channel().hasAttr(TuyaDevice.DEVICE_ID_ATTR) || !ctx.channel().hasAttr(SESSION_KEY_ATTR)) { + logger.warn("{}: Failed to retrieve deviceId or sessionKey from ChannelHandlerContext. This is a bug.", + Objects.requireNonNullElse(ctx.channel().remoteAddress(), "")); + return; + } + String deviceId = ctx.channel().attr(TuyaDevice.DEVICE_ID_ATTR).get(); + logger.debug("{}{}: Connection established.", deviceId, Objects.requireNonNullElse(ctx.channel().remoteAddress(), "")); deviceStatusListener.connectionStatus(true); @@ -59,6 +63,13 @@ public void channelActive(@NonNullByDefault({}) ChannelHandlerContext ctx) throw @Override public void channelInactive(@NonNullByDefault({}) ChannelHandlerContext ctx) throws Exception { + if (!ctx.channel().hasAttr(TuyaDevice.DEVICE_ID_ATTR) || !ctx.channel().hasAttr(SESSION_KEY_ATTR)) { + logger.warn("{}: Failed to retrieve deviceId or sessionKey from ChannelHandlerContext. This is a bug.", + Objects.requireNonNullElse(ctx.channel().remoteAddress(), "")); + return; + } + String deviceId = ctx.channel().attr(TuyaDevice.DEVICE_ID_ATTR).get(); + logger.debug("{}{}: Connection terminated.", deviceId, Objects.requireNonNullElse(ctx.channel().remoteAddress(), "")); deviceStatusListener.connectionStatus(false); @@ -68,8 +79,14 @@ public void channelInactive(@NonNullByDefault({}) ChannelHandlerContext ctx) thr @SuppressWarnings("unchecked") public void channelRead(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDefault({}) Object msg) throws Exception { - if (msg instanceof MessageWrapper) { - MessageWrapper m = (MessageWrapper) msg; + if (!ctx.channel().hasAttr(TuyaDevice.DEVICE_ID_ATTR) || !ctx.channel().hasAttr(SESSION_KEY_ATTR)) { + logger.warn("{}: Failed to retrieve deviceId or sessionKey from ChannelHandlerContext. This is a bug.", + Objects.requireNonNullElse(ctx.channel().remoteAddress(), "")); + return; + } + String deviceId = ctx.channel().attr(TuyaDevice.DEVICE_ID_ATTR).get(); + + if (msg instanceof MessageWrapper m) { if (m.commandType == CommandType.DP_QUERY || m.commandType == CommandType.STATUS) { Map stateMap = null; if (m.content instanceof TcpStatusPayload) { @@ -83,7 +100,15 @@ public void channelRead(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNul } else if (m.commandType == CommandType.DP_QUERY_NOT_SUPPORTED) { deviceStatusListener.processDeviceStatus(Map.of()); } else if (m.commandType == CommandType.SESS_KEY_NEG_RESPONSE) { - byte[] localKeyHmac = CryptoUtil.hmac(keyStore.getRandom(), keyStore.getDeviceKey()); + if (!ctx.channel().hasAttr(TuyaDevice.SESSION_KEY_ATTR) + || !ctx.channel().hasAttr(TuyaDevice.SESSION_RANDOM_ATTR)) { + logger.warn("{}{}: Session key negotiation failed because device key or session random is not set.", + deviceId, Objects.requireNonNullElse(ctx.channel().remoteAddress(), "")); + return; + } + byte[] sessionKey = ctx.channel().attr(TuyaDevice.SESSION_KEY_ATTR).get(); + byte[] sessionRandom = ctx.channel().attr(TuyaDevice.SESSION_RANDOM_ATTR).get(); + byte[] localKeyHmac = CryptoUtil.hmac(sessionRandom, sessionKey); byte[] localKeyExpectedHmac = Arrays.copyOfRange((byte[]) m.content, 16, 16 + 32); if (!Arrays.equals(localKeyHmac, localKeyExpectedHmac)) { @@ -96,19 +121,18 @@ public void channelRead(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNul } byte[] remoteKey = Arrays.copyOf((byte[]) m.content, 16); - byte[] remoteKeyHmac = CryptoUtil.hmac(remoteKey, keyStore.getDeviceKey()); + byte[] remoteKeyHmac = CryptoUtil.hmac(remoteKey, sessionKey); MessageWrapper response = new MessageWrapper<>(CommandType.SESS_KEY_NEG_FINISH, remoteKeyHmac); ctx.channel().writeAndFlush(response); - byte[] sessionKey = CryptoUtil.generateSessionKey(keyStore.getRandom(), remoteKey, - keyStore.getDeviceKey()); - if (sessionKey == null) { + byte[] newSessionKey = CryptoUtil.generateSessionKey(sessionRandom, remoteKey, sessionKey); + if (newSessionKey == null) { logger.warn("{}{}: Session key negotiation failed because session key is null.", deviceId, Objects.requireNonNullElse(ctx.channel().remoteAddress(), "")); return; } - keyStore.setSessionKey(sessionKey); + ctx.channel().attr(TuyaDevice.SESSION_KEY_ATTR).set(newSessionKey); } } } diff --git a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/UserEventHandler.java b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/UserEventHandler.java index 2c487ebf04..09d6b65183 100644 --- a/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/UserEventHandler.java +++ b/bundles/org.smarthomej.binding.tuya/src/main/java/org/smarthomej/binding/tuya/internal/local/handlers/UserEventHandler.java @@ -18,6 +18,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.smarthomej.binding.tuya.internal.local.TuyaDevice; import io.netty.channel.ChannelDuplexHandler; import io.netty.channel.ChannelHandlerContext; @@ -31,14 +32,14 @@ public class UserEventHandler extends ChannelDuplexHandler { private final Logger logger = LoggerFactory.getLogger(UserEventHandler.class); - private final String deviceId; - - public UserEventHandler(String deviceId) { - this.deviceId = deviceId; - } - @Override public void userEventTriggered(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDefault({}) Object evt) { + if (!ctx.channel().hasAttr(TuyaDevice.DEVICE_ID_ATTR)) { + logger.warn("Failed to retrieve deviceId from ChannelHandlerContext. This is a bug."); + return; + } + String deviceId = ctx.channel().attr(TuyaDevice.DEVICE_ID_ATTR).get(); + if (evt instanceof DisposeEvent) { logger.debug("{}{}: Received DisposeEvent, closing channel", deviceId, Objects.requireNonNullElse(ctx.channel().remoteAddress(), "")); @@ -49,6 +50,14 @@ public void userEventTriggered(@NonNullByDefault({}) ChannelHandlerContext ctx, @Override public void exceptionCaught(@NonNullByDefault({}) ChannelHandlerContext ctx, @NonNullByDefault({}) Throwable cause) throws Exception { + if (!ctx.channel().hasAttr(TuyaDevice.DEVICE_ID_ATTR)) { + logger.warn("{}: Failed to retrieve deviceId from ChannelHandlerContext. This is a bug.", + Objects.requireNonNullElse(ctx.channel().remoteAddress(), "")); + ctx.close(); + return; + } + String deviceId = ctx.channel().attr(TuyaDevice.DEVICE_ID_ATTR).get(); + if (cause instanceof IOException) { logger.debug("{}{}: IOException caught, closing channel.", deviceId, Objects.requireNonNullElse(ctx.channel().remoteAddress(), ""), cause); diff --git a/bundles/org.smarthomej.binding.tuya/src/test/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaDecoderTest.java b/bundles/org.smarthomej.binding.tuya/src/test/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaDecoderTest.java index ec6ac77d06..67807aec55 100644 --- a/bundles/org.smarthomej.binding.tuya/src/test/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaDecoderTest.java +++ b/bundles/org.smarthomej.binding.tuya/src/test/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaDecoderTest.java @@ -16,7 +16,7 @@ import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.mockito.Mockito.when; -import static org.smarthomej.binding.tuya.internal.local.ProtocolVersion.V3_4; +import static org.smarthomej.binding.tuya.internal.local.TuyaDevice.*; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -29,13 +29,14 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.openhab.core.util.HexUtils; import org.smarthomej.binding.tuya.internal.local.MessageWrapper; -import org.smarthomej.binding.tuya.internal.local.TuyaDevice; +import org.smarthomej.binding.tuya.internal.local.ProtocolVersion; import com.google.gson.Gson; import io.netty.buffer.Unpooled; import io.netty.channel.Channel; import io.netty.channel.ChannelHandlerContext; +import io.netty.util.Attribute; /** * The {@link TuyaDecoderTest} is a @@ -47,14 +48,28 @@ public class TuyaDecoderTest { private final Gson gson = new Gson(); - private @Mock @NonNullByDefault({}) ChannelHandlerContext ctx; + private @Mock @NonNullByDefault({}) ChannelHandlerContext ctxMock; private @Mock @NonNullByDefault({}) Channel channelMock; + private @Mock @NonNullByDefault({}) Attribute deviceIdAttrMock; + private @Mock @NonNullByDefault({}) Attribute protocolAttrMock; + private @Mock @NonNullByDefault({}) Attribute sessionKeyAttrMock; @Test public void decode34Test() throws Exception { - when(ctx.channel()).thenReturn(channelMock); + when(ctxMock.channel()).thenReturn(channelMock); + + when(channelMock.hasAttr(DEVICE_ID_ATTR)).thenReturn(true); + when(channelMock.attr(DEVICE_ID_ATTR)).thenReturn(deviceIdAttrMock); + when(deviceIdAttrMock.get()).thenReturn(""); + + when(channelMock.hasAttr(PROTOCOL_ATTR)).thenReturn(true); + when(channelMock.attr(PROTOCOL_ATTR)).thenReturn(protocolAttrMock); + when(protocolAttrMock.get()).thenReturn(ProtocolVersion.V3_4); + + when(channelMock.hasAttr(SESSION_KEY_ATTR)).thenReturn(true); + when(channelMock.attr(SESSION_KEY_ATTR)).thenReturn(sessionKeyAttrMock); + when(sessionKeyAttrMock.get()).thenReturn("5c8c3ccc1f0fbdbb".getBytes(StandardCharsets.UTF_8)); - TuyaDevice.KeyStore keyStore = new TuyaDevice.KeyStore("5c8c3ccc1f0fbdbb".getBytes(StandardCharsets.UTF_8)); byte[] packet = HexUtils.hexToBytes( "000055aa0000fc6c0000000400000068000000004b578f442ec0802f26ca6794389ce4ebf57f94561e9367569b0ff90afebe08765460b35678102c0a96b666a6f6a3aabf9328e42ea1f29fd0eca40999ab964927c340dba68f847cb840b473c19572f8de9e222de2d5b1793dc7d4888a8b4f11b00000aa55"); byte[] expectedResult = HexUtils.hexToBytes( @@ -62,8 +77,8 @@ public void decode34Test() throws Exception { List out = new ArrayList<>(); - TuyaDecoder decoder = new TuyaDecoder(gson, "", keyStore, V3_4); - decoder.decode(ctx, Unpooled.copiedBuffer(packet), out); + TuyaDecoder decoder = new TuyaDecoder(gson); + decoder.decode(ctxMock, Unpooled.copiedBuffer(packet), out); assertThat(out, hasSize(1)); MessageWrapper result = (MessageWrapper) out.get(0); diff --git a/bundles/org.smarthomej.binding.tuya/src/test/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaEncoderTest.java b/bundles/org.smarthomej.binding.tuya/src/test/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaEncoderTest.java index 6e25ca605d..1d674fa3da 100644 --- a/bundles/org.smarthomej.binding.tuya/src/test/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaEncoderTest.java +++ b/bundles/org.smarthomej.binding.tuya/src/test/java/org/smarthomej/binding/tuya/internal/local/handlers/TuyaEncoderTest.java @@ -15,7 +15,8 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; import static org.mockito.Mockito.verify; -import static org.smarthomej.binding.tuya.internal.local.ProtocolVersion.V3_4; +import static org.mockito.Mockito.when; +import static org.smarthomej.binding.tuya.internal.local.TuyaDevice.*; import java.nio.charset.StandardCharsets; @@ -28,12 +29,14 @@ import org.openhab.core.util.HexUtils; import org.smarthomej.binding.tuya.internal.local.CommandType; import org.smarthomej.binding.tuya.internal.local.MessageWrapper; -import org.smarthomej.binding.tuya.internal.local.TuyaDevice; +import org.smarthomej.binding.tuya.internal.local.ProtocolVersion; import com.google.gson.Gson; import io.netty.buffer.ByteBuf; +import io.netty.channel.Channel; import io.netty.channel.ChannelHandlerContext; +import io.netty.util.Attribute; /** * The {@link TuyaEncoderTest} is a @@ -45,20 +48,37 @@ public class TuyaEncoderTest { private final Gson gson = new Gson(); - private @Mock @NonNullByDefault({}) ChannelHandlerContext ctx; + private @Mock @NonNullByDefault({}) ChannelHandlerContext ctxMock; + private @Mock @NonNullByDefault({}) Channel channelMock; + private @Mock @NonNullByDefault({}) Attribute deviceIdAttrMock; + private @Mock @NonNullByDefault({}) Attribute protocolAttrMock; + private @Mock @NonNullByDefault({}) Attribute sessionKeyAttrMock; private @Mock @NonNullByDefault({}) ByteBuf out; @Test public void testEncoding34() throws Exception { - TuyaDevice.KeyStore keyStore = new TuyaDevice.KeyStore("5c8c3ccc1f0fbdbb".getBytes(StandardCharsets.UTF_8)); + when(ctxMock.channel()).thenReturn(channelMock); + + when(channelMock.hasAttr(DEVICE_ID_ATTR)).thenReturn(true); + when(channelMock.attr(DEVICE_ID_ATTR)).thenReturn(deviceIdAttrMock); + when(deviceIdAttrMock.get()).thenReturn(""); + + when(channelMock.hasAttr(PROTOCOL_ATTR)).thenReturn(true); + when(channelMock.attr(PROTOCOL_ATTR)).thenReturn(protocolAttrMock); + when(protocolAttrMock.get()).thenReturn(ProtocolVersion.V3_4); + + when(channelMock.hasAttr(SESSION_KEY_ATTR)).thenReturn(true); + when(channelMock.attr(SESSION_KEY_ATTR)).thenReturn(sessionKeyAttrMock); + when(sessionKeyAttrMock.get()).thenReturn("5c8c3ccc1f0fbdbb".getBytes(StandardCharsets.UTF_8)); + byte[] payload = HexUtils.hexToBytes("47f877066f5983df0681e1f08be9f1a1"); byte[] expectedResult = HexUtils.hexToBytes( "000055aa000000010000000300000044af06484eb01c2272666a10953aaa23e89328e42ea1f29fd0eca40999ab964927c99646647abb2ab242062a7e911953195ae99b2ee79fa00a95da8cc67e0b42e20000aa55"); MessageWrapper msg = new MessageWrapper<>(CommandType.SESS_KEY_NEG_START, payload); - TuyaEncoder encoder = new TuyaEncoder(gson, "", keyStore, V3_4); - encoder.encode(ctx, msg, out); + TuyaEncoder encoder = new TuyaEncoder(gson); + encoder.encode(ctxMock, msg, out); ArgumentCaptor captor = ArgumentCaptor.forClass(Object.class);