Authentication Handshake¶
The Encryption2 protocol uses a 3-phase handshake to establish an encrypted session between the app and the vehicle. This page documents the complete authentication flow.
State machine¶
stateDiagram-v2
[*] --> INITIAL
INITIAL --> PRE_COMM: Start connection
PRE_COMM --> SET_PWD: Got auth + SN
PRE_COMM --> AUTH: Has stored password
SET_PWD --> AUTH: Password accepted
SET_PWD --> SET_PWD: Waiting for button
AUTH --> COMM: Authenticated
AUTH --> SET_PWD: Auth failed (retry)
COMM --> [*]
INITIAL ──► PRE_COMM ──► SET_PWD ──► AUTH ──► COMM
│ │ │ │ │
│ │ │ │ └── Normal operation (encrypted)
│ │ │ └── Verify password + SN
│ │ └── Exchange session password
│ └── Get auth params + serial number
└── Not started
Key evolution¶
The encryption keys change at each phase of the handshake:
| Phase | key1 | key2 | Counter mode |
|---|---|---|---|
| PRE_COMM | BLE device name | null (zeros) |
Non-SN (counter = 0) |
| SET_PWD | BLE device name | auth parameter | SN (counter > 0) |
| AUTH | session password | auth parameter | SN (counter > 0) |
| COMM | session password | auth parameter | SN (counter > 0) |
At each phase, the AES key is derived as SHA-1(key1_pad16 ‖ key2_pad16)[0:16].
Phase 1: PRE_COMM (cmd = 0x5B / 91)¶
Purpose: Retrieve authentication parameters and device serial number.
Setup¶
crypto.reset_sn() # counter = 0 (non-SN mode)
crypto.set_key(bt_name.encode(), None) # key1 = device name, key2 = null
Request¶
Frame: [5A A5 00 3E 04 5B 00] (before encryption)
Response¶
The device responds with 30+ bytes of payload:
Offset Size Field
0 16 auth_param Authentication challenge (random)
16 14 serial_number Device serial number (ASCII)
The INDEX byte in the response indicates whether the device has a stored password:
INDEX = 0— No stored password, must do SET_PWDINDEX != 0— Has stored password, can skip to AUTH
Processing¶
auth_param = response.data[0:16] # 16-byte random challenge
serial_number = response.data[16:30] # 14-byte ASCII serial number
crypto.set_auth_param(auth_param) # Store for nonce construction
crypto.start_sn() # Enable SN mode (counter = 1)
Phase 2: SET_PWD (cmd = 0x5C / 92)¶
Purpose: Set a session password on the device.
Setup¶
Password generation¶
The session password is generated from the auth parameter and current time:
import hashlib, time
def generate_password(auth_param: bytes, time_ms: int = None) -> bytes:
if time_ms is None:
time_ms = int(time.time() * 1000)
# Derive seed from auth_param
seed_value = 0
for i, b in enumerate(auth_param):
signed_byte = b if b < 128 else b - 256
shift = (i % 8) * 8
val = signed_byte << (shift & 31) # Java int shift wraps at 32
seed_value += val
seed = time_ms + seed_value
# Java LCG PRNG
rng = JavaRandom(seed)
random_bytes = rng.next_bytes(16)
# SHA-256 hash, take first 16 bytes
return hashlib.sha256(random_bytes).digest()[:16]
Java LCG
The PRNG is java.util.Random, a 48-bit Linear Congruential Generator. The seed combines currentTimeMillis() with a function of the auth parameter. Java uses 32-bit int semantics for shift operations (shift amount masked with & 31).
Request¶
Response¶
INDEX = 1— Password accepted, proceed to Phase 3INDEX = 0— Password pending, device may require user interaction (e.g., pressing a button on the vehicle)
Waiting for user interaction¶
Some devices require the user to press a physical button to confirm pairing. The app retries every 2 seconds until the device accepts or timeout is reached.
Default timeout: 60 seconds (COMM_WAIT_USER_TIMEOUT)
Phase 3: AUTH (cmd = 0x5D / 93)¶
Purpose: Authenticate using the password and serial number.
Setup¶
Request¶
Response¶
INDEX = 1— Authentication successful. State transitions to COMM.INDEX = 0— Authentication failed.
On success¶
The password is stored for future reconnection. On the next connection, if the PRE_COMM response indicates a stored password (INDEX != 0), the app can skip SET_PWD and go directly to AUTH with the saved password.
On failure¶
The app retries from SET_PWD (up to 5 times with a new password each attempt).
Retry logic¶
| Phase | Max retries | Timeout per attempt |
|---|---|---|
| PRE_COMM | 10 | 2,000 ms |
| SET_PWD | timeout / 2000 | 2,000 ms |
| AUTH | 3 per attempt, 5 total restarts from SET_PWD | 2,000 ms |
Password persistence¶
After a successful AUTH, the password is persisted (typically in app local storage, keyed by device MAC address). On subsequent connections:
- PRE_COMM is sent as normal (non-SN mode, counter = 0)
- If
INDEX != 0in the response, the app checks for a stored password - If found, SET_PWD is skipped entirely — no re-keying to
SHA-1(bt_name, auth)occurs - AUTH key is set directly:
SHA-1(stored_password, auth_param) - AUTH frame is sent with counter = 2 (start_sn sets counter to 1, first encrypt increments to 2 — same as the normal flow where SET_PWD would have used counter 2)
- If AUTH fails, the stored password is cleared and SET_PWD is retried
Counter value on reconnect
The first SN-mode frame always uses counter = 2, regardless of whether SET_PWD was performed. In the normal flow, SET_PWD uses counter 2 and AUTH uses counter 3. In the reconnect flow, AUTH uses counter 2. The device expects this — it tracks whether SET_PWD was skipped based on the stored password flag.
Recovering a stored password¶
If the official app has already paired with the device, a stored password exists and can be recovered from app data. This avoids needing to re-pair (which requires physical button press on the vehicle).
Own devices only
This section is intended for recovering access to your own vehicle using your own device backup. The stored password is specific to a device you have previously paired with through the official app.
iOS (from an unencrypted iTunes/Finder backup):
The password is stored in the app's UserDefaults plist:
File: Library/Preferences/com.ninebot.segway.plist
Key: {SerialNumber}_decrypt
Value: Base64-encoded 16 bytes
For example, for a device with serial S1DXXXXXXXXX01:
Key: S1DXXXXXXXXX01_decrypt
Value: dGhpcyBpcyBhIGRlbW8hIQ== (base64)
→ 74 68 69 73 20 69 73 20 61 20 64 65 6d 6f 21 21 (16 bytes)
To extract from a backup:
# Find the plist in the backup's Manifest.db
sqlite3 Manifest.db "SELECT fileID, relativePath FROM Files
WHERE domain = 'AppDomain-com.ninebot.segway'
AND relativePath LIKE '%Preferences%plist'"
# Read the plist (copy fileID-named file, rename to .plist)
plutil -p com.ninebot.segway.plist | grep _decrypt
Android: The password is stored in the app's SharedPreferences or local database. Root access or an ADB backup is required to extract it.
Once recovered, the CLI reads it from its session file on subsequent runs. See the Python BLE Client page for the session-file schema and how to inject credentials for an already-paired device. The Flutter reference app uses the same values, delivered via a setup QR (see Credential QR Setup).
Complete handshake example¶
sequenceDiagram
participant App
participant Vehicle
Note over App: key = SHA-1(bt_name ‖ zeros)
Note over App: counter = 0 (non-SN)
App->>Vehicle: PRE_COMM [5A A5 00 enc(3E 04 5B 00)]
Vehicle->>App: [5A A5 1E enc(board 3E 5B idx auth[16] sn[14])]
Note over App: Extract auth_param, serial_number
Note over App: key = SHA-1(bt_name ‖ auth_param)
Note over App: counter = 1 (SN mode)
Note over App: Generate password
App->>Vehicle: SET_PWD [5A A5 10 enc(3E 04 5C 00 pwd[16])]
Vehicle->>App: [5A A5 00 enc(board 3E 5C 01)] (accepted)
Note over App: key = SHA-1(password ‖ auth_param)
App->>Vehicle: AUTH [5A A5 0E enc(3E 04 5D 00 sn[14])]
Vehicle->>App: [5A A5 00 enc(board 3E 5D 01)] (authenticated)
Note over App,Vehicle: Session established — all further communication uses password-derived key
Security notes¶
-
The BLE device name is used as key material in the first two handshake phases. An attacker who knows the device name (visible during BLE scanning) and can observe the PRE_COMM exchange has partial key material for Phase 1.
-
The session password's PRNG seed includes
currentTimeMillis(), which has millisecond resolution. Combined with the known auth parameter, the password space is approximately 2^13 per second of time uncertainty. However, the password is transmitted encrypted, so an attacker would need to break the Phase 2 encryption first. -
Replay protection via the monotonically increasing counter prevents replay of captured packets within a session.
Authorization beyond the handshake¶
Completing all three phases only gives you an encrypted channel — it does not grant permission to actuate the vehicle. Commands that turn on the motor or open the seat compartment carry a separate, per-device authorization secret in their payload (the $ident token). See Vehicle actions (cmd 0x64) for the wire format, and Credential QR setup for how the open-source Flutter client transfers that secret from an iOS backup.