I recently bought a Wi-fi connected smart plug from Cleverio, sold by the Swedish company Kjell & Co.

If you follow the official instructions, you end up with an app on your phone, that you use to pair and control the plug with.

After some curious investigation, I found that the products from Cleverio are in practise just rebranded Tuya devices. This made me happy, since it significantly increases the number of resources available on the web from others who have investigated the products.

I had two goals with this blog post:

  1. Be able to control the plug without the app. Preferably through some kind of web-based REST API.
  2. Block the plug from accessing the Internet, while still allowing it to work on my local network.

(1) is mostly because I want to be able to do whatever I want to with my home automation setup, and (2) is mostly for security reasons. Internet-connected IoT devices are not particularly well-known for their security.

Prior Work

There are several interesting projects related to Tuya devices. The following are those that I’ve looked most into.

  • TinyTuya: A Python library to communicate with Tuya devices.
  • rust-tuyapi: A Rust library to communicate with Tuya devices.
  • rust-async-tuyapi: An async Rust library, based on rust-tuyapi above.

Find the ID and Key of the Plug

The first step to take control over the plug is to extract it’s ID and secret key. These are needed to be able to communicate with the plug directly on the same LAN.

There are multiple ways to do this, you only need to use one.

What I Did

Since I already had an unused rooted Android phone at home, I was inspired by this guide, and did the following:

  1. Installed an old version of the Smart Life app, namely version 3.6.1. You can’t use the newest version of the app, I tried. After downloading the APK, I installed it withadb install xxxxxx.apk.
  2. Logged in with my Tuya account
  3. Paired my plug with my account
  4. Fetched the /data/data/com.tuya.smartlife/shared_prefs/preference_global_keyeuXXXXXXXX.xml file from the phone to my computer, and renamed it to codes.xml. (I used adb shell and cat to get the file content)
  5. Ran transform.py from the guide above.
  6. id and key are the things needed to control your plug
  1. Follow the instructions from TinyTuya. Briefly: register as a Tuya developer and download the information from their developer portal.
  2. Follow the instructions from tuya_code_extract. Briefly: install the (old) Smart Life app in an online Android emulator, and extract the keys from there.

REST API

Since I wanted to control the plug in any way I wanted, I built a small REST API written in Rust using axum. To communicate with the device, I based it on rust-async-tuyapi.

The API supports multiple plugs - numbered from 0 and up - and requires the ID, key, and IP-address for each plug.

The source code for the project can be found on GitHub: https://github.com/zozs/tuya-web.

Briefly, it exposes the following endpoints:

  • POST /outlet/:outlet_id: Toggles the plug between on and offs
  • PUT /outlet/:outlet_id/true: Turn on the plug
  • PUT /outlet/:outlet_id/false: Turn off the plug
  • GET /outlet/:outlet_id: Returns current state of the plug

This allows me to connect the POST route to a regular button, in my case a Flic button. At the same time, I can have cronjobs that turns a plug on or off at certain times during the day. In my case, I control a growing light using the following Kubernetes CronJob.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: tuya-growing-light-on
spec:
  schedule: "0 9 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: curl
            image: quay.io/curl/curl
            command: ["curl", "-v", "-X", "PUT", "http://tuya-web.default.svc.cluster.local/outlet/0/true"]
          restartPolicy: OnFailure
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: tuya-growing-light-off
spec:
  schedule: "0 17 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: curl
            image: quay.io/curl/curl
            command: ["curl", "-v", "-X", "PUT", "http://tuya-web.default.svc.cluster.local/outlet/0/false"]
          restartPolicy: OnFailure

Investigate and Block Internet Access

After finishing my API, I blocked the Internet access for the plug’s IP address in my router. For me, that was through the use of a firewall rule in PF.

#/etc/pf.conf

# Block smart plug from accessing internet
block in quick on guestnet from { 10.0.5.50 } to !(guestnet:network)

At the same time, I turned on logging of DNS requests in unbound.

#/etc/unbound.conf

log-queries: yes
verbosity: 2

The logs are posted below, but to summarise:

  • There are a lot of broadcasts on port 6667/UDP. It seems to be connected to auto discovery for the app to find the plug on the same network. It doesn’t seem to matter that I block these.
  • The plug makes a new DHCP request every five minutes. I suspect this is due to it being unable to access the Internet. However, it doesn’t seem to cause any harm.
  • It performs DNS requests to m2.tuyaeu.com, and then tries to connect to that IP. Presumably, this is the actual cloud service that is normally used to control the plug with the app.

Logs

Mar 28 10:36:51 zone dhcpd[24647]: DHCPOFFER on 10.0.5.50 to 1c:90:ff:0a:72:88 via vlan2
Mar 28 10:36:51 zone dhcpd[24647]: DHCPREQUEST for 10.0.5.50 from 1c:90:ff:0a:72:88 via vlan2
Mar 28 10:36:51 zone dhcpd[24647]: DHCPACK on 10.0.5.50 to 1c:90:ff:0a:72:88 via vlan2
Mar 28 10:37:03 rule 0/(match) block in on vlan2: 10.0.5.50.59727 > 255.255.255.255.6667: udp 172
Mar 28 10:37:08 rule 0/(match) block in on vlan2: 10.0.5.50.59727 > 255.255.255.255.6667: udp 172
Mar 28 10:37:13 rule 0/(match) block in on vlan2: 10.0.5.50.59727 > 255.255.255.255.6667: udp 172
Mar 28 10:37:18 rule 0/(match) block in on vlan2: 10.0.5.50.59727 > 255.255.255.255.6667: udp 172
Mar 28 10:37:23 rule 0/(match) block in on vlan2: 10.0.5.50.59727 > 255.255.255.255.6667: udp 172
Mar 28 10:37:28 rule 0/(match) block in on vlan2: 10.0.5.50.59727 > 255.255.255.255.6667: udp 172
Mar 28 10:37:33 rule 0/(match) block in on vlan2: 10.0.5.50.59727 > 255.255.255.255.6667: udp 172
Mar 28 10:37:38 rule 0/(match) block in on vlan2: 10.0.5.50.59727 > 255.255.255.255.6667: udp 172
Mar 28 10:37:43 rule 0/(match) block in on vlan2: 10.0.5.50.59727 > 255.255.255.255.6667: udp 172
Mar 28 10:37:44 zone unbound: [85358:0] info: 10.0.5.50 m2.tuyaeu.com. A IN
Mar 28 10:37:44 rule 0/(match) block in on vlan2: 10.0.5.50.40188 > 35.156.42.116.8886: S 357465146:357465146(0) win 4380 <mss 1460>
Mar 28 10:37:46 rule 0/(match) block in on vlan2: 10.0.5.50.40188 > 35.156.42.116.8886: S 357465146:357465146(0) win 4380 <mss 1460>
Mar 28 10:37:48 rule 0/(match) block in on vlan2: 10.0.5.50.40188 > 35.156.42.116.8886: S 357465146:357465146(0) win 4380 <mss 1460>
Mar 28 10:37:48 rule 0/(match) block in on vlan2: 10.0.5.50.59727 > 255.255.255.255.6667: udp 172
Mar 28 10:37:50 rule 0/(match) block in on vlan2: 10.0.5.50.40188 > 35.156.42.116.8886: S 357465146:357465146(0) win 4380 <mss 1460>
Mar 28 10:37:52 rule 0/(match) block in on vlan2: 10.0.5.50.40188 > 35.156.42.116.8886: S 357465146:357465146(0) win 4380 <mss 1460>
Mar 28 10:37:53 rule 0/(match) block in on vlan2: 10.0.5.50.59727 > 255.255.255.255.6667: udp 172
Mar 28 10:37:54 rule 0/(match) block in on vlan2: 10.0.5.50.40188 > 35.156.42.116.8886: S 357465146:357465146(0) win 4380 <mss 1460>
Mar 28 10:37:56 rule 0/(match) block in on vlan2: 10.0.5.50.40188 > 35.156.42.116.8886: S 357465146:357465146(0) win 4380 <mss 1460>
Mar 28 10:37:58 rule 0/(match) block in on vlan2: 10.0.5.50.40188 > 35.156.42.116.8886: S 357465146:357465146(0) win 4380 <mss 1460>
Mar 28 10:37:58 rule 0/(match) block in on vlan2: 10.0.5.50.59727 > 255.255.255.255.6667: udp 172
Mar 28 10:38:00 rule 0/(match) block in on vlan2: 10.0.5.50.40188 > 35.156.42.116.8886: S 357465146:357465146(0) win 4380 <mss 1460>
Mar 28 10:38:03 rule 0/(match) block in on vlan2: 10.0.5.50.59727 > 255.255.255.255.6667: udp 172