Skip to content

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

CMD = 0x5B, INDEX = 0x00, DATA = [] (empty)
Target: BLE board (0x04)

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_PWD
  • INDEX != 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

crypto.set_key(bt_name.encode(), auth_param)  # key1 = device name, key2 = auth

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

CMD = 0x5C, INDEX = 0x00, LENGTH = 16, DATA = password[16]
Target: BLE board (0x04)

Response

  • INDEX = 1 — Password accepted, proceed to Phase 3
  • INDEX = 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

crypto.set_key(password, auth_param)  # key1 = password, key2 = auth

Request

CMD = 0x5D, INDEX = 0x00, LENGTH = 14, DATA = serial_number[14]
Target: BLE board (0x04)

Response

  • INDEX = 1Authentication 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:

  1. PRE_COMM is sent as normal (non-SN mode, counter = 0)
  2. If INDEX != 0 in the response, the app checks for a stored password
  3. If found, SET_PWD is skipped entirely — no re-keying to SHA-1(bt_name, auth) occurs
  4. AUTH key is set directly: SHA-1(stored_password, auth_param)
  5. 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)
  6. 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.