The door lock Bluetooth module encrypts serial communication with the MCU to meet national standard GB21556.2 lock-board link encryption requirements.
The module and the MCU complete the encryption process together:
This service applies to products that use Tuya’s standard door lock solution and need encryption on the MCU–module serial link. If the MCU does not support encryption, do not report the encryption capability. The module does not enable encryption, and existing product behavior stays unchanged.
The serial encryption service uses the following commands:
| Command | Description |
|---|---|
| 0x01 | Declares MCU serial encryption capability in the Option Config field of the product information response |
| 0xD8 | Runs serial encryption negotiation in three steps: query the IndexList, notify the Seed, and run an encryption probe |
Serial frames follow the Tuya Bluetooth module serial communication protocol (55 AA header + version + command + length + data + checksum). This section covers encryption-related differences only. Byte 3 of the frame has different meanings in the two frame types:
| Frame type | byte3 | Data field |
|---|---|---|
| Plaintext frame | CMD | Plaintext data |
| Encrypted business frame | Encryption type (EncType, 0x01 = AES, 0x02 = Simple) |
Encrypted [CMD + length (2B) + data] |
An encrypted business frame encrypts the whole inner plaintext (command + two-byte length + data) and places the ciphertext in the data field. byte3 stores the encryption type. The negotiation command 0xD8 is always sent and received in plaintext.
Overlapping byte3 values (distinguish them):
Value Plaintext CMD EncType 0x01Product information query/response AES 0x02Request working mode Simple Key rule: Treat
CMD=0x01 && len=0(55 AA 00 01 00 00) as a plaintext0x01query for MCU information and clearhandshake_done. This occurs when the module restarts. In other cases, decrypt when the handshake is done andbyte3 == EncType.
The module sends CMD 0x01 in plaintext to query MCU product information. In the response, the MCU appends a Type-Length-Data (TLD) [08 01 01] in the Option Config field (the TLD list starting at byte 13) to declare serial encryption support.
| MCU response | Module behavior |
|---|---|
No TLD or D=0x00 |
Encryption is disabled. Keep plaintext for the whole session. |
08 01 01 |
The module starts the 0xD8 encryption negotiation flow. |
After serial encryption is enabled (enable=1), communication cannot be downgraded to plaintext unless a factory reset clears the encryption state. Even if the MCU removes this TLD, the module does not restore plaintext business traffic.
Negotiation uses plaintext frames with cmd = 0xD8 and completes in three steps:
After negotiation succeeds, both sides encrypt business frames.
Negotiation sequence:
Data field structure: The 0xD8 frame uses the following data structure:
| Scope | OpType | Data |
|---|---|---|
| 0x00 | See below | Operation-specific data |
Scope: Fixed 0x00 (full encryption scope).OpType:
0x00: Query the IndexList.0x01: Notify the Seed.0x03: Run an encryption probe.The module starts this step only when Flash has no valid IndexList (index_valid=0), for example, on the first power-on or after a module reset.
Module → MCU data
| Scope | OpType | EncType list |
|---|---|---|
| 0x00 | 0x00 | 0x01 0x02 |
EncType list (two bytes): Encryption types supported by the module. Current supported values:
0x01: AES0x02: Simple encryptionMCU → Module data
| Scope | OpType | EncType | IndexList |
|---|---|---|---|
| 0x00 | 0x00 | 0x01/0x02 | xx xx … xx (16 bytes) |
EncType (1 byte):
[0x01, 0x02] provided by the module.IndexList (16 bytes):
0x00 to 0x1F.After the MCU generates the IndexList for the first time, write it to Flash and keep it unchanged until a factory reset. A module restart does not trigger another index query. The module only sends the Op 0x00 request when the IndexList is lost or invalid. The MCU and the module must store the same IndexList so both sides derive the same key.
Example: Checksum bytes are samples only; use the calculated value.
55 AA 00 D8 00 04 00 00 01 02 DE55 AA 00 D8 00 13 00 00 01 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10 72The module generates a new Seed and sends it to the MCU during each encryption negotiation.
Module → MCU data
| Scope | OpType | EncType | Seed |
|---|---|---|---|
| 0x00 | 0x01 | 0x01/0x02 | xx xx xx xx |
EncType (1 byte): Same as the value returned by the MCU when querying the index.Seed (4 bytes): Random seed for this session.MCU → Module data
| Scope | OpType | EncType |
|---|---|---|
| 0x00 | 0x01 | 0x01/0x02 |
EncType to confirm the encryption type.Each encryption negotiation updates Seed, but the module does not query IndexList again. After receiving Op 0x01, the MCU must recalculate the session_key using the new Seed.
Example
55 AA 00 D8 00 07 00 01 01 65 AA 89 D2 4955 AA 00 D8 00 03 00 01 01 DCIn the last negotiation step, the module sends 16 bytes of random plaintext. The MCU encrypts the data and returns it. The module verifies whether the returned ciphertext matches the expected result.
Module → MCU data
| Scope | OpType | PlainRandom |
|---|---|---|
| 0x00 | 0x03 | xx xx … xx (16 bytes) |
PlainRandom (16 bytes): Random plaintext generated by the module.MCU → Module data
| Scope | OpType | Cipher |
|---|---|---|
| 0x00 | 0x03 | xx xx … xx (16 bytes) |
Cipher (16 bytes): Ciphertext of PlainRandom encrypted with the current session_key and EncType.The module encrypts locally generated PlainRandom and compares it with MCU’s returned Cipher. A match indicates successful negotiation; the module sends encrypted 0x02, and the MCU sets enable=1 and saves it to Flash. The MCU can then send encrypted business commands proactively. Negotiation retries are capped at 3.
Maintain the following fields on the MCU (you can map them to a Flash structure):
| Field | Store in Flash | Description |
|---|---|---|
enable |
Yes |
|
enc_type |
Yes |
|
index_list[16] |
Yes | IndexList for key derivation |
index_valid |
Yes | Whether the index has been generated |
seed[4] |
No | Currently active Seed |
session_key[16] |
No | Runtime session key. Recalculated after power-on or receiving a new Seed. |
handshake_done |
No | Runtime flag. Set to 1 after the negotiation is completed. |
When to write Flash
| Timing | Content |
|---|---|
After Op 0x00 response |
index_list, index_valid, enc_type |
After Op 0x01 receives a new Seed |
Update only the in-memory seed and recalculate session_key. |
After Op 0x03 probe succeeds |
enable=1 (first successful negotiation) |
MCU cold start (enable=1)
index_valid, index_list, enc_type, and enable from Flash.handshake_done only after the module completes another successful 0xD8 negotiation.handshake_done=1.The session key depends on the Seed delivered by the module and the IndexList generated and stored by the MCU. The flow is:
Seed[4]
→ MD5(Seed, 4) Get a 16-byte binary digest
→ Convert to 32-byte lowercase hex ASCII (candidate_key)
→ Select bytes from candidate_key by IndexList[16]
→ session_key[16] (session key)
IndexList length is fixed at 16 bytes. Each element is an index into candidate_key, in the range 0x00–0x1F.The MCU and the module must derive the same session_key with this flow. Implement MD5 and AES with the platform hardware engine or a mature software library. For complete platform API declarations and derivation pseudocode, see Appendix A.
In negotiation Op 0x00, the module sends the supported type list [0x01, 0x02]. The MCU returns the configured EncType, which must be included in the list provided by the module. Both sides use that type for the entire session.
AES-128-ECB (EncType = 0x01)
session_key[16].0x00 to the end of the plaintext until the length is a multiple of 16.Len field to take the valid Payload and ignore the zero-padding area.Simple encryption (EncType = 0x02)
Simple encryption uses byte-level operations. Encryption and decryption use the same operation:
data[i] = ~(data[i] ^ key[i % 16]) // Keep the low 8 bits.
Rules:
[CMD(1) + Len(2) + Payload(N)].Encryption process:
Decryption is the reverse process:
Len field. Extract the valid Payload. Ignore trailing the zero-padding area.0x03) also follow this encrypt and decrypt logic.For reference pseudocode that wraps and unwraps business frames, see Appendix B.
After encryption is enabled, encrypt all non-allowlist business commands (for example, DP 0x06/0x07).
| Command | Description |
|---|---|
0x00 |
Heartbeat |
0x01 |
Product information query/response (including the encryption capability TLD) |
0xD8 |
Encryption negotiation |
0xEA–0xEE |
MCU OTA |
0x04/0x05 |
Reset commands (conditional pass-through) |
Conditional pass-through (not a fixed allowlist): When the module is unbound, the module accepts plaintext reset commands 0x04/0x05 so you can clear the module encryption state while unbound. When the module is bound, reset commands must still be encrypted.
Behavior in other cases:
| Condition | Behavior |
|---|---|
enable=0 (Encryption has never been enabled) |
All communication uses plaintext. Compatible with devices without encryption. |
enable=1 and negotiation incomplete (not ACTIVE) |
Discard non-allowlist plaintext. Do not send non-allowlist business traffic. |
enable=1 and negotiation complete (ACTIVE) |
Send and receive non-allowlist traffic as ciphertext with EncType. |
enable=1 is stored, the module does not fall back to full plaintext, even if the MCU does not report encryption capability in this round or negotiation fails. Clear enable only through a factory reset.0x02 (request working mode) arrives. Use ciphertext when encryption is supported and plaintext when it is not.0xE8 → 0x02.0x02 handler then sends CMD 0x03 to sync working status.Identify a module restart: After each restart, the module sends the following plaintext product information query frame: 55 AA 00 01 00 00 (CMD: 0x01, len: 0). When the MCU receives this frame:
handshake_done = 0 (pause non-allowlist ciphertext to the module).enable, index_list, and enc_type (do not downgrade on restart).0x01 in plaintext (include the encryption capability TLD).0xE8/0x02, send the initialization configuration.0x00, then a plaintext product information query 0x01.0x01 with a TLD:
enable=0: The module sends plaintext 0xE8 and 0x02.enable=1: Downgrade is forbidden. Do not send supplemental plaintext startup commands.0xD8 encryption negotiation commands.0x02 (in the encryption case, the decrypted inner CMD is 0x02).0x02, send business commands as needed.| Integration item | Description |
|---|---|
Pack CMD 0x01 |
Add TLD 08 01 01 in the Option Config field to declare encryption support. |
| UART receive preprocessing | Pass through allowlist plaintext. Identify 0x01 with len=0 as module restart and clear handshake_done. Decrypt frames when the handshake is completed and byte3 == EncType. Discard invalid frames. |
| UART send hook | Send allowlist commands in plaintext. Encrypt other commands when enable=1 and handshake_done=1. |
Handle CMD 0xD8 |
Respond to Op 0x00/0x01/0x03. |
| Initialization configuration | Send only after 0x02 arrives (with or without encryption; in the encryption case, 0x02 is ciphertext). Receiving 0x02 ensures the module can decrypt encrypted commands from the MCU. |
| Factory reset | While the encryption handshake is still valid, do not clear the encryption state first and then send a plaintext reset. When Bluetooth is already unbound, plaintext 0x04/0x05 is allowed. |
When serial encryption is enabled and negotiation is complete (enable=1 && handshake_done=1), the MCU local reset must follow this sequence. Otherwise, the module will not receive the reset command and cannot clear its own encryption state, causing:
enable status between the MCU and module.0x05, or the equivalent factory reset notification used by the product) through the encrypted communication path.session_key. Keep handshake_done.enable.enable, seed, index_list, enc_type, handshake_done, and so on).0x05 causes the module to discard the frame and keep encryption enabled. This causes inconsistent encryption states between the MCU and module.0x04/0x05 commands are accepted. Still prefer the main path: Encrypted reset first, then clear the local side.enable=0) or the negotiation is incomplete, you can still use the plaintext factory reset flow.No, if the module-side encryption status is enable=0. When the MCU does not report the encryption TLD (no T=0x08) or reports D=0x00, the module starts in plaintext (sends supplemental 0xE8/0x02), same as a product without encryption.
If the module side has enable=1 (it previously negotiated successfully with an encryption-capable MCU) and the MCU firmware no longer supports encryption, this is a disallowed encryption downgrade. The module does not respond to non-allowlist plaintext. Perform a factory reset, then pair again.
The MCU must complete the following steps:
08 01 01 in the Option Config field of the CMD 0x01 response to declare encryption support.< 0x20). Keep it unchanged until a factory reset.0xD8 handling:
0x00: Return IndexList.0x01: Store Seed and recalculate the key.0x03: Return probe ciphertext.0x02 (ciphertext after encryption is enabled).0x05), then clear local encryption state and Flash. For details, see MCU factory reset sequence.After the module receives D=0x01, it automatically starts 0xD8 negotiation. After success, it sends supplemental ciphertext 0xE8 and 0x02.
Both types are in the module support list [0x01, 0x02].
Comparison:
| Encryption type | Advantages | Limitations |
|---|---|---|
| AES-128-ECB | Higher security | Requires AES implementation. Ciphertext length must be a multiple of 16 bytes. |
| Simple encryption | Lightweight. Ciphertext length equals plaintext length. | Lower security. |
Configure one EncType in the MCU firmware and return it in the Op 0x00 response (it must be in the module list). Use that type for the entire session. Do not switch mid-session.
Yes. See Identify a module restart. After a restart, the module first sends the plaintext query frame 55 AA 00 01 00 00. The MCU sets handshake_done=0 and keeps enable and IndexList, then:
0xD8 Op 0x01 delivers a new Seed. The MCU recalculates session_key in memory.0xD8 Op 0x03 completes the encryption probe. After success, the module sends supplemental ciphertext 0xE8/0x02.A module restart does not query IndexList again (unless the module is reset and loses the index). The MCU must persist IndexList so key derivation stays consistent.
If Flash has enable=1, restore index_list and enc_type after power-on. After the module detects an MCU restart through the 0x00 handshake, it starts the encryption negotiation flow again. The MCU must wait until D8 completes again and set handshake_done. Still wait for ciphertext 0x02 before sending initialization configuration. Do not send non-allowlist plaintext to a module with enable=1 before negotiation completes.
The MCU generates IndexList on the first Op 0x00 and writes it to Flash. After that, as long as the module index is valid (index_valid=1), the module does not query again. Only when the module is reset and Flash loses the index does it start Op 0x00 again. In that case, the MCU should return the same stored IndexList (or regenerate it and keep both sides consistent).
Fixed allowlist: 0x00, 0x01, 0xD8, OTA 0xEA–0xEE. In addition, when module Bluetooth is unbound, plaintext reset 0x04/0x05 is conditionally allowed. Other commands such as 0xE8, 0x02, and 0x03 use ciphertext after enable=1 and negotiation succeeds. When enable=0, the full link uses plaintext.
No. Encryption downgrade is forbidden. After enable=1, the module does not restore plaintext business traffic when the MCU removes the capability declaration. To return to a plaintext product, perform a factory reset to clear the encryption state, then pair again.
For security: Once enable=1, both the module and the MCU reject non-allowlist plaintext business frames. Even if the link is interfered with or MCU firmware is replaced with a version that does not support encryption, plaintext commands cannot bypass encryption. Only a factory reset can clear the encryption state.
Send the encrypted reset command first (for example, 0x05), then clear local enable/IndexList and related Flash. If you clear first and then send plaintext while still bound, the module discards the frame.
If module Bluetooth is already unbound, the module can accept plaintext 0x04/0x05. For details, see MCU factory reset sequence.
0x01/0x02 be confused with plaintext commands 0x01/0x02?The numeric values overlap. Distinguish them by the send and receive rules. See Frame format. Key points:
55 AA 00 01 00 00 (0x01 with len=0) to identify a module restart.EncType=0x02) and plaintext working mode 0x02: 0x02 is not on the allowlist. After negotiation succeeds it should be ciphertext, so a double-allowlist conflict is unlikely.The following pseudocode shows the full key-derivation and business-frame encrypt and decrypt flow. MD5 and AES are standard algorithms. This document does not provide their implementations. Call the chip platform hardware AES engine, or port a mature software library (for example, mbedTLS, tinyAES, or micro-ecc), as long as the interface semantics below are met.
Platform APIs (replace with your implementation)
/* MD5: Digest the first in_len bytes of in and output 16 binary bytes. */
extern int platform_md5(const uint8_t *in, uint16_t in_len, uint8_t out[16]);
/* AES-128-ECB single block (16 bytes) encrypt and decrypt. */
extern int platform_aes128_ecb_encrypt(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]);
extern int platform_aes128_ecb_decrypt(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]);
Derivation flow (Seed + IndexList → session_key)
#define SEED_LEN 4
#define INDEX_LIST_LEN 16
#define CANDIDATE_KEY_LEN 32 /* Length after MD5 is converted to hex. */
#define SESSION_KEY_LEN 16
/* 16 binary bytes → 32 lowercase hex ASCII bytes */
static void bin_to_hex_lower(const uint8_t in[16], uint8_t hex[CANDIDATE_KEY_LEN])
{
static const char tab[] = "0123456789abcdef";
uint8_t i;
for (i = 0; i < 16; i++) {
hex[i * 2] = (uint8_t)tab[(in[i] >> 4) & 0x0F];
hex[i * 2 + 1] = (uint8_t)tab[in[i] & 0x0F];
}
}
/* Seed[4] + IndexList[16] → session_key[16]
* Flow: Seed → MD5 → 16B → 32B lowercase hex(candidate_key)
* → Index into candidate_key by each IndexList byte → session_key. */
int derive_session_key(const uint8_t seed[SEED_LEN],
const uint8_t index_list[INDEX_LIST_LEN],
uint8_t session_key[SESSION_KEY_LEN])
{
uint8_t md5_out[16];
uint8_t candidate_key[CANDIDATE_KEY_LEN];
uint8_t i;
if (platform_md5(seed, SEED_LEN, md5_out) != 0) {
return -1; /* MD5 failed. */
}
bin_to_hex_lower(md5_out, candidate_key);
for (i = 0; i < SESSION_KEY_LEN; i++) {
if (index_list[i] >= CANDIDATE_KEY_LEN) {
return -1; /* Invalid index; must be 0x00–0x1F. */
}
session_key[i] = candidate_key[index_list[i]];
}
return 0;
}
#define AES_BLOCK_LEN 16
#define ENC_AES 0x01
#define ENC_SIMPLE 0x02
#define UART_CRYPTO_MAX_INNER_LEN 64 /* Depends on max business frame; must be ≥ padded length. */
/* Inner [CMD|Len|Payload] → ciphertext */
static int crypt_inner(uint8_t enc_type, const uint8_t key[16],
const uint8_t *inner, uint16_t inner_len,
uint8_t *out, uint16_t *out_len, int encrypt)
{
uint16_t padded = ((inner_len + 15) / 16) * 16;
uint16_t off;
if (enc_type == ENC_AES) {
uint8_t buf[UART_CRYPTO_MAX_INNER_LEN];
if (padded > sizeof(buf)) {
return -1;
}
memcpy(buf, inner, inner_len);
memset(buf + inner_len, 0, padded - inner_len);
for (off = 0; off < padded; off += AES_BLOCK_LEN) {
if (encrypt) {
platform_aes128_ecb_encrypt(key, &buf[off], &out[off]);
} else {
platform_aes128_ecb_decrypt(key, &buf[off], &out[off]);
}
}
*out_len = padded;
} else {
for (off = 0; off < inner_len; off++) {
out[off] = (uint8_t)(~(inner[off] ^ key[off % 16]));
}
*out_len = inner_len;
}
return 0;
}
/* Send: Encrypt the inner payload and build outer frame 55 AA 00 EncType EncLen Cipher Sum. */
int wrap_encrypt_send(uint8_t enc_type, const uint8_t session_key[16],
uint8_t cmd, const uint8_t *payload, uint16_t len,
uint8_t *frame_out, uint16_t *frame_len)
{
uint8_t inner[UART_CRYPTO_MAX_INNER_LEN];
uint8_t cipher[UART_CRYPTO_MAX_INNER_LEN];
uint16_t inner_len = (uint16_t)(3 + len);
uint16_t cipher_len = 0;
uint16_t pos = 0;
inner[0] = cmd;
inner[1] = (uint8_t)(len >> 8);
inner[2] = (uint8_t)(len);
memcpy(&inner[3], payload, len);
if (crypt_inner(enc_type, session_key, inner, inner_len,
cipher, &cipher_len, 1) != 0) {
return -1;
}
frame_out[pos++] = 0x55;
frame_out[pos++] = 0xAA;
frame_out[pos++] = 0x00;
frame_out[pos++] = enc_type;
frame_out[pos++] = (uint8_t)(cipher_len >> 8);
frame_out[pos++] = (uint8_t)(cipher_len);
memcpy(&frame_out[pos], cipher, cipher_len);
pos = (uint16_t)(pos + cipher_len);
frame_out[pos++] = checksum8(frame_out, pos); /* Implement with the platform checksum algorithm. */
*frame_len = pos;
return 0;
}
/* Receive: When byte3==EncType, decrypt and restore plaintext frame 55 AA 00 CMD Len Payload Sum. */
int unwrap_decrypt_recv(uint8_t enc_type, const uint8_t session_key[16],
const uint8_t *frame_in, uint16_t frame_len,
uint8_t *frame_out, uint16_t *frame_out_len)
{
uint16_t enc_len;
uint8_t plain[UART_CRYPTO_MAX_INNER_LEN];
uint16_t plain_len = 0;
uint8_t inner_cmd;
uint16_t payload_len;
uint16_t pos = 0;
if (frame_in[3] != enc_type) {
return -1; /* Not a ciphertext frame; handle with allowlist or discard logic. */
}
enc_len = (uint16_t)((frame_in[4] << 8) | frame_in[5]);
if (crypt_inner(enc_type, session_key, &frame_in[6], enc_len,
plain, &plain_len, 0) != 0) {
return -1;
}
inner_cmd = plain[0];
payload_len = (uint16_t)((plain[1] << 8) | plain[2]);
if ((uint16_t)(3 + payload_len) > plain_len) {
return -1;
}
frame_out[pos++] = 0x55;
frame_out[pos++] = 0xAA;
frame_out[pos++] = 0x00;
frame_out[pos++] = inner_cmd;
frame_out[pos++] = plain[1];
frame_out[pos++] = plain[2];
memcpy(&frame_out[pos], &plain[3], payload_len);
pos = (uint16_t)(pos + payload_len);
frame_out[pos++] = checksum8(frame_out, pos);
*frame_out_len = pos;
return 0;
}
Is this page helpful?
YesFeedbackIs this page helpful?
YesFeedback