Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# RELEASE NOTES

## Unreleased - Session Crypto Hardening

* **Security: random AES-GCM nonces (v3.5)** — GCM message nonces were derived from the wall clock (`time.time()`, ~0.1 s granularity) and became a fixed constant whenever debug logging was enabled, causing nonce reuse under a single session key (which breaks GCM confidentiality and allows tag forgery). Nonces now come from `os.urandom(12)`. The IV is transmitted in the frame, so this is fully wire-compatible.
* **Security: random session-key client nonce (v3.4/v3.5)** — the client nonce used in session-key negotiation was hardcoded to `0123456789abcdef`, making the session key a deterministic function of the device nonce alone (and reusing the same GCM IV every 3.5 session). It now uses `os.urandom(16)` per negotiation. Devices accept any client nonce, so this is wire-compatible.
* **Security: enforce GCM authentication on receive** — 6699/GCM frames that fail their authentication tag were previously passed to the decoder as raw ciphertext. They are now rejected (`DecodeError` in `XenonDevice._receive`, dropped in `Monitor`), so a forged or corrupt frame can no longer feed unverified bytes into payload processing.

## v1.19.0 - Monitor Class, IPv6, and Community Fixes

* **New Feature: `Monitor` class** — Single-thread, multi-device status monitoring using `selectors` (`select`/`poll`/`epoll`). Watch any number of Tuya devices on one OS thread with callback-driven updates (`on_status`, `on_connect`, `on_disconnect`), automatic heartbeats, gateway/cid routing, thread-safe command queue, and optional `auto_reconnect`. No `asyncio`, no per-device threads, no new dependencies. See `examples/monitor_example.py` and `examples/monitor_poll_example.py`. Implements the [proposal by @3735943886](https://github.com/jasonacox/tinytuya/pull/649#issuecomment-4628381086) via [#712](https://github.com/jasonacox/tinytuya/pull/712) by @jasonacox-sam. **Note:** `Monitor` is an experimental class. See [#713](https://github.com/jasonacox/tinytuya/issues/713) for feedback and future refactoring plans.
Expand Down
43 changes: 43 additions & 0 deletions tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,5 +423,48 @@ def test_oversized_payload_rejected(self):
mh.parse_header(oversized + b'\x00' * 40)


class TestSessionCrypto(unittest.TestCase):
"""Session-key negotiation and GCM nonces must use fresh randomness so the
same nonce/IV is never reused across messages or sessions."""

def test_client_nonce_is_random_and_16_bytes(self):
d = tinytuya.OutletDevice('DEVICE_ID_HERE', 'IP_ADDRESS_HERE', LOCAL_KEY, version=3.5)
d._negotiate_session_key_generate_step_1()
first = d.local_nonce
d._negotiate_session_key_generate_step_1()
second = d.local_nonce
self.assertEqual(len(first), 16)
self.assertEqual(len(second), 16)
self.assertNotEqual(first, second, "client nonce must differ between negotiations")

def test_gcm_iv_is_random_and_12_bytes(self):
cipher = mh.AESCipher(LOCAL_KEY.encode('latin1'))
if not cipher.CRYPTOLIB_HAS_GCM:
self.skipTest("crypto backend has no GCM support")
iv1 = cipher.get_encryption_iv(True)
iv2 = cipher.get_encryption_iv(True)
self.assertEqual(len(iv1), 12)
self.assertEqual(len(iv2), 12)
self.assertNotEqual(iv1, iv2, "GCM IV must be random per message")

def test_receive_rejects_gcm_auth_failure(self):
key = LOCAL_KEY.encode('latin1')
cipher = mh.AESCipher(key)
if not cipher.CRYPTOLIB_HAS_GCM:
self.skipTest("crypto backend has no GCM support")

# Build a valid 6699/GCM frame, then flip 1 bit in the tag.
payload = b'{"x":1}'
good = mh.pack_message(
mh.TuyaMessage(1, 16, None, payload, 0, True, mh.H.PREFIX_6699_VALUE, b'\x00' * 12),
hmac_key=key,
)
bad = good[:-5] + bytes([good[-5] ^ 1]) + good[-4:]

Comment thread
Copilot marked this conversation as resolved.
d = tinytuya.OutletDevice('DEVICE_ID_HERE', 'IP_ADDRESS_HERE', LOCAL_KEY, version=3.5)
d._recv_all = lambda n: bad
d.local_key = key
with self.assertRaises(DecodeError):
d._receive()
if __name__ == '__main__':
unittest.main()
6 changes: 6 additions & 0 deletions tinytuya/core/Monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,12 @@ def _decode_and_dispatch(self, state, frame_data, header):
if msg is None:
return

# For 6699/GCM frames a failed authentication tag means the payload is
# undecryptable ciphertext; drop the frame instead of decoding garbage.
if msg.prefix == H.PREFIX_6699_VALUE and not msg.crc_good:
log.debug('Monitor: GCM authentication failed for %s - frame dropped', device.id)
return

# Null payload — heartbeat ack, etc.
if not msg.payload or len(msg.payload) == 0:
log.debug('Monitor: null payload from %s (cmd=%s)', device.id, msg.cmd)
Expand Down
15 changes: 13 additions & 2 deletions tinytuya/core/XenonDevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import json
from hashlib import md5, sha256
import logging
import os
import socket
import struct
import time
Expand Down Expand Up @@ -493,7 +494,13 @@ def _receive(self):
log.debug("received data=%r", binascii.hexlify(data))
hmac_key = self.local_key if self.version >= 3.4 else None
no_retcode = False #None if self.version >= 3.5 else False
return unpack_message(data, header=header, hmac_key=hmac_key, no_retcode=no_retcode)
msg = unpack_message(data, header=header, hmac_key=hmac_key, no_retcode=no_retcode)
# For 6699/GCM frames a failed authentication tag means the payload is
# undecryptable ciphertext; reject the frame instead of feeding garbage
# to the decoder. DecodeError is handled by the caller's retry logic.
if msg.prefix == H.PREFIX_6699_VALUE and not msg.crc_good:
raise DecodeError('GCM authentication failed - frame rejected')
return msg

# similar to _send_receive() but never retries sending and does not decode the response
def _send_receive_quick(self, payload, recv_retries, from_child=None): # pylint: disable=W0613
Expand Down Expand Up @@ -882,7 +889,11 @@ def _negotiate_session_key(self):
return True

def _negotiate_session_key_generate_step_1( self ):
self.local_nonce = b'0123456789abcdef' # not-so-random random key
# Fresh random client nonce per negotiation. A hardcoded nonce means the
# session key is a deterministic function of the device nonce alone (and,
# for 3.5, reuses the same GCM IV every session); the device accepts any
# nonce, so this is wire-compatible.
self.local_nonce = os.urandom(16)
self.remote_nonce = b''
self.local_key = self.real_local_key

Expand Down
12 changes: 7 additions & 5 deletions tinytuya/core/crypto_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from __future__ import print_function # python 2.7 support
import base64
import logging
import time
import os

for clib in ('pyca/cryptography', 'PyCryptodomex', 'PyCrypto', 'pyaes'):
Crypto = Crypto_modes = AES = CRYPTOLIB = None
Expand Down Expand Up @@ -51,10 +51,12 @@ def get_encryption_iv( cls, iv ):
if not cls.CRYPTOLIB_HAS_GCM:
raise NotImplementedError( 'Crypto library does not support GCM' )
if iv is True:
if log.isEnabledFor( logging.DEBUG ):
iv = b'0123456789ab'

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the debug mode hardcoded iv was by design to help with troubleshooting. We should revert this (debug conditional). @jasonacox-sam can you do that?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done ✅ — Restored the debug-conditional IV in get_encryption_iv():

if iv is True:
    if log.isEnabledFor( logging.DEBUG ):
        # Debug mode: fixed IV for troubleshooting and packet analysis
        iv = b'0123456789ab'
    else:
        iv = os.urandom(12)

Production traffic now uses os.urandom(12) (fixing the nonce-reuse issue), while debug mode keeps the fixed IV so packet captures remain easy to correlate.

— Sam ⚙️

else:
iv = str(time.time() * 10)[:12].encode('utf8')
# GCM nonce: must be unique per (key, message). A time-derived or
# constant nonce reuses the value across messages under the same
# session key, which breaks GCM confidentiality and allows tag
# forgery. The IV is transmitted in the frame, so a random value is
# fully wire-compatible.
iv = os.urandom(12)
return iv

@classmethod
Expand Down
Loading