Serial Port Encryption Service

Last Updated on : 2026-07-22 10:24:25Copy for LLMView as MarkdownDownload PDF

Overview

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:

  1. The MCU declares encryption support in the product information response.
  2. The module generates a random seed (Seed) and starts negotiation.
  3. The MCU generates and stores an IndexList, then derives a session key from the Seed.
  4. Both sides use that session key to encrypt and decrypt business frames.

Applicable modules

  • Module: BK3633
  • Firmware version: 0.0.12 or later

Scenarios

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.

Features

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 encryption protocol

Frame format

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.

  • Advanced Encryption Standard (AES) mode: Pad the inner plaintext with zeros to a multiple of 16 bytes.
  • Simple mode: No padding.

Overlapping byte3 values (distinguish them):

Value Plaintext CMD EncType
0x01 Product information query/response AES
0x02 Request working mode Simple

Key rule: Treat CMD=0x01 && len=0 (55 AA 00 01 00 00) as a plaintext 0x01 query for MCU information and clear handshake_done. This occurs when the module restarts. In other cases, decrypt when the handshake is done and byte3 == EncType.

Declare serial encryption capability (CMD-0x01)

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.

Serial encryption negotiation (CMD-0xD8)

Negotiation uses plaintext frames with cmd = 0xD8 and completes in three steps:

  1. Query the IndexList.
  2. Notify the Seed.
  3. Run an encryption probe.

After negotiation succeeds, both sides encrypt business frames.

Negotiation sequence:

ModuleMCU0x00 Heartbeat0x00 MCU receives the first heartbeat0x01 Query product information0x01 Response + TLD (08 01 01)0xD8 Op=0x00 Data=[0x01, 0x02]0xD8 Op=0x00 Data=[EncType, IndexList[16]]MCU stores IndexList and selects EncTypealt[Module has no IndexList]0xD8 Op=0x01 Data=[EncType, Seed[4]]MCU stores Seed and derives session_key0xD8 Op=0x01 Data=[EncType]0xD8 Op=0x03 Data=[PlainRandom[16]]MCU encrypts PlainRandom with session_key0xD8 Op=0x03 Data=[Cipher[16]]Probe succeeds, enable=1Ciphertext 0xE8 / 0x02 (CMD 0x03 syncs working status, sent with 0x02 handling)After receiving 0x02, MCU sends init config (module_ready), such as turn advertising on or off and sleep settingsModuleMCU

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.

Query the IndexList (OpType 0x00)

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: AES
    • 0x02: Simple encryption

MCU → Module data

Scope OpType EncType IndexList
0x00 0x00 0x01/0x02 xx xx … xx (16 bytes)
  • Field description:
    • EncType (1 byte):
      • The encryption type configured in the MCU firmware.
      • Must be included in the encryption type list [0x01, 0x02] provided by the module.
      • The selected type is used for subsequent encrypted communication.
    • IndexList (16 bytes):
      • Generated by the MCU or returned from existing storage.
      • Each byte ranges from 0x00 to 0x1F.
      • Used for session key derivation.

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.

  • Module → MCU: 55 AA 00 D8 00 04 00 00 01 02 DE
  • MCU → Module: 55 AA 00 D8 00 13 00 00 01 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10 72

Notify the Seed (OpType 0x01)

The 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
  • After receiving a new Seed:
    1. The MCU recalculates a new session key using the current IndexList.
    2. The MCU returns the same 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

  • Module → MCU: 55 AA 00 D8 00 07 00 01 01 65 AA 89 D2 49
  • MCU → Module: 55 AA 00 D8 00 03 00 01 01 DC

Encryption probe (OpType 0x03)

In 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.

MCU persistence and runtime state

Maintain the following fields on the MCU (you can map them to a Flash structure):

Field Store in Flash Description
enable Yes
  • 0: Encryption disabled.
  • 1: Encryption enabled. Cleared only after factory reset.
enc_type Yes
  • 0x01: AES
  • 0x02: Simple
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)

  1. Restore index_valid, index_list, enc_type, and enable from Flash.
  2. Set handshake_done only after the module completes another successful 0xD8 negotiation.
  3. Encrypt and exchange non-allowlist traffic only after handshake_done=1.
  4. When the module restarts and renegotiates, update Seed as described in Identify a module restart.

Key derivation algorithm

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 0x000x1F.
  • After the first generation, write it to MCU Flash and keep it unchanged until a factory reset.

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.

Encryption algorithms

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)

  • Key: The derived session_key[16].
  • Mode: Electronic Codebook (ECB).
  • Padding: Zero padding. Append 0x00 to the end of the plaintext until the length is a multiple of 16.
  • Encrypt and decrypt each 16-byte block independently. After decryption, use the inner 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:

  • No padding. Ciphertext length equals plaintext length.
  • Inner plaintext structure: [CMD(1) + Len(2) + Payload(N)].

Encryption process:

  • AES mode:
    • Add Zero padding to a multiple of 16 bytes.
    • Encrypt by block.
  • Simple mode:
    • Operate on the inner bytes directly.

Decryption is the reverse process:

  • AES mode: Decrypt data blocks. Parse the inner Len field. Extract the valid Payload. Ignore trailing the zero-padding area.
  • Simple encryption: Apply the same operation to restore the original plaintext. The 16-byte plaintext and ciphertext of the negotiation probe packet (Op 0x03) also follow this encrypt and decrypt logic.

For reference pseudocode that wraps and unwraps business frames, see Appendix B.

Allowlist commands and communication rules

After encryption is enabled, encrypt all non-allowlist business commands (for example, DP 0x06/0x07).

Allowlist

Command Description
0x00 Heartbeat
0x01 Product information query/response (including the encryption capability TLD)
0xD8 Encryption negotiation
0xEA0xEE 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.
  • No encryption downgrade: After 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.
  • Whether the MCU supports encryption, start sending business commands only after the module 0x02 (request working mode) arrives. Use ciphertext when encryption is supported and plaintext when it is not.
  • After a successful negotiation:
    • The module sends encrypted 0xE80x02.
    • The 0x02 handler then sends CMD 0x03 to sync working status.
  • If negotiation fails, retry up to three times, then give up. The module does not fall back to plaintext startup.

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:

  1. Clear handshake_done = 0 (pause non-allowlist ciphertext to the module).
  2. Keep enable, index_list, and enc_type (do not downgrade on restart).
  3. Reply to CMD 0x01 in plaintext (include the encryption capability TLD).
  4. Wait for D8 to update Seed and complete negotiation. After the module sends ciphertext 0xE8/0x02, send the initialization configuration.

Startup sequence (encryption macro enabled)

  1. The module sends plaintext heartbeat 0x00, then a plaintext product information query 0x01.
  2. The MCU responds to 0x01 with a TLD:
    • No encryption support and module enable=0: The module sends plaintext 0xE8 and 0x02.
    • No encryption support and module enable=1: Downgrade is forbidden. Do not send supplemental plaintext startup commands.
    • Encryption supported: The module sends 0xD8 encryption negotiation commands.
  3. After negotiation succeeds: The module sends ciphertext 0x02 (in the encryption case, the decrypted inner CMD is 0x02).
  4. After the MCU receives 0x02, send business commands as needed.

MCU integration checklist

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.

MCU factory reset sequence

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:

  • Inconsistent enable status between the MCU and module.
  • Different IndexList values.
  • Failure to complete future negotiations.
  1. Send the reset command (usually CMD 0x05, or the equivalent factory reset notification used by the product) through the encrypted communication path.
    Requirements:
    • Keep the current session_key. Keep handshake_done.
    • Do not switch back to plaintext after clearing enable.
  2. Confirm the reset frame is queued or sent (at least finish encrypted packing and submit the UART transmit), then clear local encryption-related memory and Flash (enable, seed, index_list, enc_type, handshake_done, and so on).
  3. Continue other factory reset operations and restart.
  • Notify the module with encryption first, then clear local state/Flash. If the session can still encrypt and Bluetooth is bound, clearing encryption data first and then sending plaintext 0x05 causes the module to discard the frame and keep encryption enabled. This causes inconsistent encryption states between the MCU and module.
  • Exception: When the Bluetooth module is unbound, plaintext 0x04/0x05 commands are accepted. Still prefer the main path: Encrypted reset first, then clear the local side.
  • If encryption is not enabled (enable=0) or the negotiation is incomplete, you can still use the plaintext factory reset flow.

FAQ

Does an MCU that does not support serial encryption get affected?

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.

How do I enable serial encryption?

The MCU must complete the following steps:

  1. Add TLD 08 01 01 in the Option Config field of the CMD 0x01 response to declare encryption support.
  2. Generate and persist a 16-byte IndexList (each byte < 0x20). Keep it unchanged until a factory reset.
  3. Implement CMD 0xD8 handling:
    • Op 0x00: Return IndexList.
    • Op 0x01: Store Seed and recalculate the key.
    • Op 0x03: Return probe ciphertext.
  4. Implement business-frame encrypt and decrypt wrapping per Key derivation algorithm and Encryption algorithms.
  5. Send initialization configuration only after the module sends 0x02 (ciphertext after encryption is enabled).
  6. Factory reset: If encryption is enabled and negotiation is complete, send the encrypted reset command first (for example, 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.

How do I choose between AES and Simple encryption?

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.

Do I need to renegotiate after a module restart?

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:

  1. 0xD8 Op 0x01 delivers a new Seed. The MCU recalculates session_key in memory.
  2. 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.

How does the MCU handle an already-enabled encryption state on cold start?

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.

When is IndexList generated? When is it queried again?

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).

After encryption is enabled, which commands still use plaintext?

Fixed allowlist: 0x00, 0x01, 0xD8, OTA 0xEA0xEE. 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.

Can I make the MCU fall back to no encryption after enable=1?

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.

Why is encryption downgrade to plaintext forbidden after enable=1?

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.

During MCU factory reset, can I clear Flash first and then notify the module?

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.

Can EncType 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:

  • On the MCU: Use 55 AA 00 01 00 00 (0x01 with len=0) to identify a module restart.
  • Simple (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.

Appendix

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.

Appendix A: Key derivation reference

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;
}

Appendix B: Business frame encrypt and decrypt reference

#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;
}