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):
Where:
counter_BE[4]— 4-byte big-endian representation of the packet counterauth[0:8]— first 8 bytes of the 16-byte auth parameter0x00— 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:
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:
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¶
-
Counter must strictly increase — The device maintains a last-seen counter and rejects any packet with
counter <= last_counter. -
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.
-
Tag IS encrypted — The 4-byte MAC tag is XOR'd with the A_0 keystream before transmission. It is not sent in plaintext.
-
Software AES — The native library uses a pure software AES implementation (standard S-box), not hardware acceleration.
-
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.