Skip to content

Lock: remote unlock is possible without sniffing the cloud key (remote_no_pd_setkey + remote_no_dp_key) #5649

Description

@nihatds100

Summary

lock.py currently assumes the 8 character remote unlock key can only be obtained
by eavesdropping on cloud unlock messages:

# Remote Code unlocking protocol: 8 digit code set when paired by
# remote_no_pd_setkey (typically dp 60), user can obtain by
# eavesdropping cloud unlock messages.

That turns out not to be necessary. The device accepts a key registration over the
LAN with no authentication beyond the local key
, so Home Assistant can simply
register its own key and immediately unlock with it. This is not a workaround - it is
what the Tuya cloud itself does: watching the dp traffic during an app unlock shows the
cloud registering a fresh key on remote_no_pd_setkey microseconds before sending the
unlock on remote_no_dp_key.

The result is fully local remote unlocking on a device family where lock.unlock
currently raises NotImplementedError.

Device

  • Config: sboardiii_weigand_lock.yaml (Secukey Sboard III mini / BSTUOKEY, product id 5k8h97qska6pf5cm)
  • Wi-Fi access controller with RFID keypad, protocol 3.3
  • remote_no_pd_setkey = dp 8, remote_no_dp_key = dp 9
  • The device answers DP_QUERY with json obj data unvalid, so it never reports any
    state on poll - everything below was learned from push messages.

Protocol, as observed

Both dps are raw/base64. Big endian.

remote_no_pd_setkey (dp 8)

request [valid(1)][member(2)][valid_from(4)][valid_to(4)][uses(2)][key(8 ASCII)]
reply [status(1)][member(2)], status 0x00 = success

remote_no_dp_key (dp 9)

request [action(1)][member(2)][key(8 ASCII)][source(2)]
reply [status(1)][member(2)][...], 0x00 = success, 0x05 = wrong key

This matches the format already documented in lock.py, and build_code_unlock_msg()
produces the correct unlock request as is.

Cloud unlock captured from the LAN side

A remote unlock triggered from the Smart Life app, seen locally:

remote_no_pd_setkey = AAAB      -> 00 00 01        status 0x00, member 1  (cloud registered its key)
remote_no_dp_key    = AAABAAA=  -> 00 00 01 00 00  status 0x00, member 1  (unlock succeeded)
closed_open_state   = AQAB                          door opened

Note the device only ever publishes the reply on these dps, never the key itself, so
eavesdropping the key locally is in fact impossible - which makes the "obtain by
eavesdropping" note in the source unactionable. Registering our own key is.

Registering our own key and unlocking

-> {"8": "<valid=1, member=2, 0, 0x7FFFFFFF, 0xFFFF, key>", "9": "<action=1, member=2, key, source=1>"}
<- {"8": "AAAC"}       00 00 02        key accepted, member 2
<- {"9": "AAACr2w="}   00 00 02 ...    unlock succeeded
<- {"35": "AQAB"}                      door opened

Round trip is about 600 ms.

Two things that matter for the implementation

1. There is a single key slot. Registering a key replaces whatever the cloud
registered last, and vice versa. This is harmless in both directions, because both
sides register their key immediately before unlocking. But it does mean a key
registered once and used later will eventually fail with 0x05 - on a shared door
every unlock by another resident invalidates it.

2. The registration and the unlock must be sent in one message. If they are sent as
two separate messages, the cloud can slip its own registration in between (observed
consistently, roughly 400-500 ms after ours) and the unlock then fails with 0x05:

18:16:05.489  unlock                 -> 0x05
18:16:06.198  <- dp 8 = AAAB          cloud re-registered its key (member 1)
18:16:07.063  -> dp 8 = our key       accepted (AAAC)
18:16:07.509  <- dp 8 = AAAB          cloud overwrote it again, 0.45 s later
18:16:07.763  unlock                 -> 0x05

Passing both dps to async_set_properties() in one call fixes this: pending updates are
sorted by dp id, so the key registration (dp 8) is processed before the unlock (dp 9)
inside a single payload, and there is no window for the cloud to interleave. Verified
repeatedly, including from a deliberately invalidated key state - it always succeeds on
the first attempt.

Ruled out

For completeness, these were tested on the same device and do not work, in case
someone else goes down the same path:

  • The user's keypad PIN as the dp 9 key (unpadded, NUL padded, zero padded) - 0x05.
    remote_no_pd_* is the "remote unlock without password" channel and has its own
    secret; keypad credentials live in a separate table (unlock_password and the
    unlock_method_create/delete/modify dps).
  • The device's fixed 4 digit master code, same paddings - 0x05.
  • Writing dp 104 (mapped as a writable lock in bstuokey_access_keypad.yaml, same
    product id) - device does not respond at all.

Proposed change

Patch below. It:

  • picks up remote_no_pd_setkey and remote_no_dp_key in TuyaLocalLock.__init__
  • reports code_format of .{8} when both are present, so the user supplies the key as
    the lock code (HA's default code setting works nicely for this - the key is arbitrary,
    we are the ones registering it)
  • adds build_remote_key_msg() alongside the existing build_code_unlock_msg()
  • adds an async_unlock branch that sends both dps in a single async_set_properties()
    call
  • updates the stale comment at the top of the file

No device config change is needed - sboardiii_weigand_lock.yaml already names both dps
correctly.

The generated messages are byte identical to the ones verified against real hardware.

Open questions for you

  • REMOTE_MEMBER_ID is hardcoded to 2 (1 belongs to the cloud). Would you prefer this
    configurable per device config, e.g. as an optional dp attribute?
  • Should async_lock get the same treatment? action = CODE_LOCK is accepted by the
    device but produces no reply and no visible effect on this hardware (it is a strike
    controller with auto relock), so I left it alone.
  • The reply status on dp 9 is currently only visible as an entity attribute. Surfacing
    it (e.g. logging a warning on a non-zero status) would make failures much easier to
    diagnose, but needs a push subscription, so I kept it out of this patch.

Happy to add tests and iterate if you would like this upstream.

Patch against custom_components/tuya_local/lock.py
--- a/custom_components/tuya_local/lock.py
+++ b/custom_components/tuya_local/lock.py
@@ -14,13 +14,16 @@
 
 _LOGGER = logging.getLogger(__name__)
 
-# Remote Code unlocking protocol: 8 digit code set when paired by
-# remote_no_pd_setkey (typically dp 60), user can obtain by
-# eavesdropping cloud unlock messages.
-# Format in case this command can be supported outside of pairing process:
+# Remote Code unlocking protocol: 8 digit code set by remote_no_pd_setkey
+# (typically dp 8 or dp 60).  The key does not have to be obtained from the
+# cloud: the device accepts a key registration over the LAN, so we can simply
+# register our own key and then use it.  This is exactly what the Tuya cloud
+# itself does - it registers a fresh key immediately before every remote
+# unlock (verified by watching the dp traffic during an app unlock).
 # Request: Validity (1 byte 0 or 1), Member ID (2 bytes),
 #  Start time (4 byte unixtime), End time (4 byte unixtime),
 #  Usable times (2 bytes, 0=infinite), Key (8 bytes ASCII)
+# Reply: status (1 byte), Member ID (2 bytes)
 
 # Same 8 digit ASCII code used along with binary member ID to generate
 # unlock command with remote_no_dp_key (typically dp 61)
@@ -45,6 +48,22 @@
 CODE_REPLY_WRONGCODE = 0x05
 CODE_REPLY_DOUBLELOCKED = 0x06
 
+# Member id used for keys that Home Assistant registers itself.  Member 1 is
+# what the Tuya cloud uses for its own remote unlock key.  Devices seen so far
+# keep a single key slot, so registering a key replaces whatever the cloud
+# registered last - that is harmless, because the cloud re-registers its key
+# before each of its own unlocks.  Using a distinct id keeps the unlock reports
+# (unlock_app) attributable to Home Assistant.
+REMOTE_MEMBER_ID = 0x0002
+
+# Validity window for keys we register.  The end time has to stay positive if
+# the device treats it as a signed 32 bit value, so 0x7FFFFFFF is used rather
+# than 0xFFFFFFFF, and the start time is 0 so that the key stays valid even if
+# the device loses its clock.
+REMOTE_KEY_VALID_FROM = 0
+REMOTE_KEY_VALID_TO = 0x7FFFFFFF
+REMOTE_KEY_USES = 0xFFFF
+
 
 async def async_setup_entry(hass, config_entry, async_add_entities):
     config = {**config_entry.data, **config_entry.options}
@@ -88,6 +107,8 @@
         self._req_unlock_dp = dps_map.pop("request_unlock", None)
         self._approve_unlock_dp = dps_map.pop("approve_unlock", None)
         self._code_unlock_dp = dps_map.pop("code_unlock", None)
+        self._remote_key_dp = dps_map.pop("remote_no_pd_setkey", None)
+        self._remote_unlock_dp = dps_map.pop("remote_no_dp_key", None)
         self._req_intercom_dp = dps_map.pop("request_intercom", None)
         self._approve_intercom_dp = dps_map.pop("approve_intercom", None)
         self._jam_dp = dps_map.pop("jammed", None)
@@ -139,10 +160,15 @@
     @property
     def code_format(self):
         """Return the code format of the lock."""
-        if self._code_unlock_dp:
+        if self._code_unlock_dp or self._remote_unlock_supported:
             return r".{8}"
         return None
 
+    @property
+    def _remote_unlock_supported(self):
+        """Can we register our own key and unlock with it?"""
+        return bool(self._remote_key_dp and self._remote_unlock_dp)
+
     def unlocker_id(self, dp, how):
         if dp:
             unlock = dp.get_value(self._device)
@@ -206,6 +232,29 @@
             )
             _LOGGER.info("%s unlocking with code", self._config.config_id)
             await self._code_unlock_dp.async_set_value(self._device, msg)
+        elif self._remote_unlock_supported:
+            code = kwargs.get("code")
+            if not code:
+                raise ValueError("Code required to unlock")
+            _LOGGER.info("%s unlocking with remote key", self._config.config_id)
+            # The key registration and the unlock command are sent in a single
+            # message, in dp order.  Sending them separately leaves a window in
+            # which the cloud can register its own key over ours, which makes
+            # the unlock fail with CODE_REPLY_WRONGCODE.
+            await self._device.async_set_properties(
+                {
+                    self._remote_key_dp.id: self.build_remote_key_msg(
+                        REMOTE_MEMBER_ID,
+                        code,
+                    ),
+                    self._remote_unlock_dp.id: self.build_code_unlock_msg(
+                        CODE_UNLOCK,
+                        member_id=REMOTE_MEMBER_ID,
+                        code=code,
+                        source=CODE_SRC_APP,
+                    ),
+                }
+            )
         elif self._lock_dp and not self._lock_dp.readonly:
             _LOGGER.info("%s unlocking", self._config.config_id)
             await self._lock_dp.async_set_value(self._device, False)
@@ -230,6 +279,26 @@
             _LOGGER.info("%s opening", self._config.config_id)
             await self._open_dp.async_set_value(self._device, True)
 
+    def build_remote_key_msg(
+        self,
+        member_id,
+        code,
+        valid_from=REMOTE_KEY_VALID_FROM,
+        valid_to=REMOTE_KEY_VALID_TO,
+        uses=REMOTE_KEY_USES,
+    ):
+        """Generate the message that registers a remote unlock key."""
+        if len(code) != 8 or not code.isascii():
+            raise ValueError("Code must be 8 ASCII characters")
+        msg = bytearray()
+        msg.append(0x01)  # key is valid
+        msg += member_id.to_bytes(2, "big")
+        msg += valid_from.to_bytes(4, "big")
+        msg += valid_to.to_bytes(4, "big")
+        msg += uses.to_bytes(2, "big")
+        msg += code.encode("ascii")
+        return b64encode(msg).decode("utf-8")
+
     def build_code_unlock_msg(self, action, member_id, code, source=CODE_SRC_UNKNOWN):
         """Generate the unlock code message."""
         if len(code) != 8 or not code.isascii():

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    Status
    🔖 Ready

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions