Skip to content

Encryption Algorithm

The Encryption2 protocol uses AES-128 in a custom CTR-like mode with CBC-MAC authentication. This encryption scheme must be implemented by any software that communicates with Segway-Ninebot vehicles — understanding it is essential for interoperability. This page documents the complete encryption and decryption algorithms, enabling vehicle owners to establish authenticated sessions with their own devices using independent software.


Overview

Property Value
Block cipher AES-128-ECB
Mode Custom CTR with CBC-MAC (similar to CCM)
Key size 128 bits (16 bytes)
Key derivation SHA-1 of concatenated key pair
Tag size 4 bytes (truncated)
Counter 16-bit, big-endian, monotonically increasing
Replay protection Yes — counter must strictly increase

Non-standard CTR

The CTR mode is not standard NIST SP 800-38A CTR. It uses a custom nonce format built from the counter and device serial number. The CBC-MAC construction is similar to, but not identical to, NIST CCM.


Key derivation

Two 16-byte keys are combined and hashed to produce the AES key:

Input:  key1[16], key2[16]
Step 1: combined = key1_padded ‖ key2_padded          (32 bytes)
Step 2: hash = SHA-1(combined)                         (20 bytes)
Step 3: aes_key = hash[0:16]                           (first 16 bytes)

If key2 is null, 16 zero bytes are used in its place.

The key pair changes during the authentication handshake:

Phase key1 key2
PRE_COMM BLE device name null (zeros)
SET_PWD BLE device name auth parameter (from device)
AUTH / COMM session password auth parameter

See Authentication for the full key exchange sequence.

import hashlib

def derive_key(key1: bytes, key2: bytes | None) -> bytes:
    """Derive AES-128 key from key pair."""
    k1 = (key1 + b"\x00" * 16)[:16]
    k2 = (key2 + b"\x00" * 16)[:16] if key2 else b"\x00" * 16
    return hashlib.sha1(k1 + k2).digest()[:16]

Nonce construction

The 13-byte nonce is built from the packet counter and the authentication parameter (derived from the device serial number):

nonce[13] = counter_BE[4] ‖ auth[0:8] ‖ 0x00

Where:

  • counter_BE[4] — 4-byte big-endian representation of the packet counter
  • auth[0:8] — first 8 bytes of the 16-byte auth parameter
  • 0x00 — constant padding byte
import struct

def build_nonce(counter: int, auth: bytes) -> bytes:
    return struct.pack(">I", counter) + auth[:8] + b"\x00"

Block formats

Two block formats are used, derived from the 13-byte nonce:

CTR block (A_i)

Used for keystream generation:

A_i[16] = [0x01] ‖ nonce[13] ‖ [0x00, i]

Where i is the block index: 0 for encrypting the tag, 1+ for encrypting data.

MAC block (B_0)

Used as the initial value for CBC-MAC:

B_0[16] = [0x59] ‖ nonce[13] ‖ [0x00, payload_len]

Where payload_len is the number of bytes after the 3-byte header.


SN-mode encryption (counter > 0)

This is the primary mode used after authentication completes.

Algorithm

encrypt(ctx, plaintext) → ciphertext:

    1. aes_key = SHA-1(key1 ‖ key2)[0:16]

    2. Copy header (NOT encrypted):
       ciphertext[0:3] = plaintext[0:3]     // 0x5A, 0xA5, LEN

    3. counter = ++ctx.counter

    4. nonce = build_nonce(counter, ctx.auth)

    5. Compute CBC-MAC tag over plaintext:
       raw_tag = cbc_mac(aes_key, plaintext, nonce)

    6. CTR-encrypt payload (plaintext[3:]):
       for each 16-byte block i (i = 1, 2, ...):
           A_i = [0x01 ‖ nonce ‖ 0x00 ‖ i]
           keystream = AES_ECB(aes_key, A_i)
           ciphertext_block = plaintext_block XOR keystream

    7. Encrypt tag with A_0 keystream:
       A_0 = [0x01 ‖ nonce ‖ 0x00 ‖ 0x00]
       enc_tag = raw_tag[0:4] XOR AES_ECB(aes_key, A_0)[0:4]

    8. Append tail:
       ciphertext += enc_tag[4]               // 4-byte encrypted MAC
       ciphertext += counter_BE[2]            // 2-byte counter (big-endian)

    Return: ciphertext (plaintext_len + 6 bytes)

CBC-MAC computation

cbc_mac(aes_key, plaintext, nonce) → tag[4]:

    1. B_0 = [0x59 ‖ nonce ‖ 0x00 ‖ payload_len]
       X = AES_ECB(aes_key, B_0)

    2. Associated data (3-byte header, zero-padded to 16):
       aad = plaintext[0:3] ‖ zeros[13]
       X = AES_ECB(aes_key, X XOR aad)

    3. For each 16-byte block of plaintext[3:]:
       block = payload_chunk (zero-padded to 16 if last block)
       X = AES_ECB(aes_key, X XOR block)

    4. tag = X[0:4]

Non-SN-mode encryption (counter == 0)

A simpler fallback mode used before authentication or with legacy devices.

encrypt(ctx, plaintext) → ciphertext:

    1. aes_key = SHA-1(key1 ‖ key2)[0:16]

    2. Copy header verbatim:
       ciphertext[0:3] = plaintext[0:3]

    3. checksum = (~sum(plaintext[3:])) & 0xFFFF

    4. Generate static keystream:
       keystream = AES_ECB(aes_key, zeros[16])

    5. XOR each 16-byte block with the SAME keystream:
       for each block:
           ciphertext_block = plaintext_block XOR keystream

    6. Append tail:
       ciphertext += [0x00, 0x00, checksum_lo, checksum_hi, 0x00, 0x00]

Weak encryption in non-SN mode

Non-SN mode uses the same keystream for every block (static ECB). This means identical plaintext blocks produce identical ciphertext blocks. SN mode should always be used for production communication.


Decryption

Decryption is the inverse of encryption:

decrypt(ctx, ciphertext) → (plaintext, return_code):

    1. aes_key = SHA-1(key1 ‖ key2)[0:16]

    2. Copy header verbatim:
       plaintext[0:3] = ciphertext[0:3]

    3. Read tail (last 6 bytes):
       counter = ciphertext[-2:] as big-endian uint16
       mac = ciphertext[-6:-2]                               // 4 bytes

    4. If SN mode (counter > 0):
       a. Replay check: if counter <= ctx.last_counter → return error -3
       b. nonce = build_nonce(counter, ctx.auth)
       c. CTR-decrypt payload blocks (same XOR as encrypt)
       d. Decrypt received tag with A_0 keystream
       e. Recompute CBC-MAC and compare → if mismatch return error -2
       f. Update ctx.last_counter = counter

    5. If non-SN mode (counter == 0):
       a. Decrypt with static keystream
       b. Verify checksum

    Return: 0 (success), -2 (MAC mismatch), -3 (replay)

Error codes

Code Constant Meaning
0 NB_ENCRYPT_SUCCESS Success
-1 NB_ENCRYPT_FAILED General failure
-2 NB_ENCRYPT_FAILED_AUTH MAC verification failed
-3 NB_ENCRYPT_FAILED_ONCE_SN Counter replay detected

Implementation notes

  1. Counter must strictly increase — The device maintains a last-seen counter and rejects any packet with counter <= last_counter.

  2. Counter wraps at 16 bits — The on-wire counter is 2 bytes (0–65535). The internal counter may be larger, but only the lower 16 bits are transmitted.

  3. Tag IS encrypted — The 4-byte MAC tag is XOR'd with the A_0 keystream before transmission. It is not sent in plaintext.

  4. Software AES — The native library uses a pure software AES implementation (standard S-box), not hardware acceleration.

  5. SHA-1 for key derivation — SHA-1 is used for key derivation (via mbed TLS). While SHA-1 has known weaknesses for collision resistance, it remains adequate for key derivation purposes.


Python reference implementation

from Crypto.Cipher import AES
import hashlib, struct

def aes_ecb_one(key: bytes, block: bytes) -> bytes:
    """Single-block AES-128-ECB encrypt."""
    return AES.new(key, AES.MODE_ECB).encrypt(block)

def derive_key(key1: bytes, key2: bytes | None) -> bytes:
    k1 = (key1 + b"\x00" * 16)[:16]
    k2 = (key2 + b"\x00" * 16)[:16] if key2 else b"\x00" * 16
    return hashlib.sha1(k1 + k2).digest()[:16]

def build_nonce(counter: int, auth: bytes) -> bytes:
    return struct.pack(">I", counter) + auth[:8] + b"\x00"

def encrypt_sn(aes_key, plaintext, counter, auth):
    nonce = build_nonce(counter, auth)
    header = plaintext[:3]

    # CBC-MAC
    payload_len = len(plaintext) - 3
    b0 = b"\x59" + nonce + bytes([0x00, payload_len & 0xFF])
    x = aes_ecb_one(aes_key, b0)
    aad = plaintext[:3] + b"\x00" * 13
    x = aes_ecb_one(aes_key, bytes(a ^ b for a, b in zip(x, aad)))
    payload = plaintext[3:]
    for off in range(0, len(payload), 16):
        chunk = payload[off:off+16].ljust(16, b"\x00")
        x = aes_ecb_one(aes_key, bytes(a ^ b for a, b in zip(x, chunk)))
    raw_tag = x[:4]

    # CTR encrypt
    ct = bytearray()
    for i in range(1, (len(payload) + 15) // 16 + 1):
        a_i = b"\x01" + nonce + bytes([0x00, i & 0xFF])
        ks = aes_ecb_one(aes_key, a_i)
        for j in range(min(16, len(payload) - (i-1)*16)):
            ct.append(payload[(i-1)*16 + j] ^ ks[j])

    # Encrypt tag
    a0 = b"\x01" + nonce + b"\x00\x00"
    a0_ks = aes_ecb_one(aes_key, a0)
    enc_tag = bytes(a ^ b for a, b in zip(raw_tag, a0_ks[:4]))

    return header + bytes(ct) + enc_tag + struct.pack(">H", counter & 0xFFFF)

The complete implementation is available in the Python BLE Client.