|
| 1 | +# EVO Serial Encryption (E0 FE frames) |
| 2 | + |
| 3 | +EVO panels with firmware ≥ 7.50.000 encrypt the RS-232 serial bus using a |
| 4 | +Paradox-custom AES-256-ECB algorithm. The unencrypted serial port can be |
| 5 | +re-enabled with an unlock code from Paradox. |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## Two distinct E0 FE frame formats |
| 10 | + |
| 11 | +Both formats share the same two-byte header (`0xE0`, `0xFE`), but differ |
| 12 | +in everything that follows. |
| 13 | + |
| 14 | +### Format 1 — BabyWare compact (firmware < 7.50 / BabyWare software) |
| 15 | + |
| 16 | +``` |
| 17 | +[E0 | status_nibble] [FE] [length] [0x00] [request_nr] [data...] [checksum] [end] |
| 18 | +``` |
| 19 | + |
| 20 | +- `byte[2]` is the **total frame length** (including all header/trailer bytes). |
| 21 | +- Data payload is short (typically 4–8 bytes); the proprietary encryption |
| 22 | + algorithm is unknown. |
| 23 | +- PAI passes these frames to the handler unchanged. They cannot be decrypted. |
| 24 | + |
| 25 | +### Format 2 — Full-AES (ESP32 firmware / EVO ≥ 7.50) |
| 26 | + |
| 27 | +``` |
| 28 | +[0xE0 | (payload[0] & 0x0F)] [0xFE] [AES-256 ciphertext...] [checksum] |
| 29 | +``` |
| 30 | + |
| 31 | +- **No length byte** at position `[2]`. The low nibble of `byte[0]` echoes |
| 32 | + the low nibble of the first byte of the plaintext payload. |
| 33 | +- AES ciphertext occupies `ceil(len(payload) / 16) * 16` bytes |
| 34 | + (payload is padded to a 16-byte boundary with `0xEE`). |
| 35 | +- `byte[-1]` is `sum(all preceding bytes) % 256`. |
| 36 | +- Frame boundary is determined by scanning for a **valid checksum at AES block |
| 37 | + boundaries**: try `frame_len = 2 + n×16 + 1` for n = 1, 2, 3… and accept |
| 38 | + the first position where `sum(frame[:-1]) % 256 == frame[-1]`. No length |
| 39 | + field or inter-byte timeout is required. |
| 40 | + |
| 41 | +#### Example: 37-byte InitiateCommunication |
| 42 | + |
| 43 | +``` |
| 44 | +plaintext : 37 bytes |
| 45 | +padded : 48 bytes (3 × 16, trailing 0xEE fill) |
| 46 | +frame size : 2 (header) + 48 (AES) + 1 (checksum) = 51 bytes |
| 47 | +``` |
| 48 | + |
| 49 | +--- |
| 50 | + |
| 51 | +## Encryption algorithm |
| 52 | + |
| 53 | +Paradox uses a **custom pure-Python AES-256-ECB** implementation |
| 54 | +(`paradox/lib/crypto.py`). The same algorithm is used for IP150 TCP |
| 55 | +encryption; only the key derivation differs. |
| 56 | + |
| 57 | +### Key derivation for serial |
| 58 | + |
| 59 | +``` |
| 60 | +key = pc_password_bytes + b"\xee" * (32 - len(pc_password_bytes)) |
| 61 | +``` |
| 62 | + |
| 63 | +- `make_serial_key(password)` in `paradox/connections/serial_encryption.py` |
| 64 | + handles `str`, `bytes`, and `int` password types. |
| 65 | +- The PC password is the 4-hex-digit code configured on the panel |
| 66 | + (e.g. `"0000"` → `b"0000" + b"\xee" * 28`). |
| 67 | +- **Do not call `str()` on a `bytes` password** — `str(b"1234")` yields |
| 68 | + `"b'1234'"`, producing the wrong key bytes. |
| 69 | + |
| 70 | +### Padding |
| 71 | + |
| 72 | +Plaintext is padded with `0xEE` bytes to the next 16-byte boundary before |
| 73 | +encryption. After decryption, trailing `0xEE` bytes are stripped. |
| 74 | + |
| 75 | +--- |
| 76 | + |
| 77 | +## PAI implementation |
| 78 | + |
| 79 | +### Outgoing messages (`SerialConnectionProtocol.send_message`) |
| 80 | + |
| 81 | +When `SERIAL_ENCRYPTED = True`, `connection_made()` wraps the raw asyncio |
| 82 | +transport with `EncryptedSerialTransport`. Every call to `transport.write()` |
| 83 | +transparently passes through `encrypt_serial_message()` before hitting the |
| 84 | +wire. |
| 85 | + |
| 86 | +``` |
| 87 | +PAI message bytes |
| 88 | + → encrypt_serial_message(payload, key) |
| 89 | + → [E0|nibble][FE][AES...][cs] |
| 90 | + → serial port |
| 91 | +``` |
| 92 | + |
| 93 | +### Incoming messages (`SerialConnectionProtocol.data_received`) |
| 94 | + |
| 95 | +The framer distinguishes the two E0 FE modes by the `SERIAL_ENCRYPTED` config |
| 96 | +flag: |
| 97 | + |
| 98 | +| `SERIAL_ENCRYPTED` | `buffer[1] == 0xFE` action | |
| 99 | +|---|---| |
| 100 | +| `False` | Read `buffer[2]` as total frame length (BabyWare compact) | |
| 101 | +| `True` | Scan `frame_len = 2 + n×16 + 1` (n = 1…7); accept first valid checksum | |
| 102 | + |
| 103 | +For Format 2, the framer scans incrementally as bytes arrive — no timer is |
| 104 | +needed. The decrypted payload is forwarded to `handler.on_message()` |
| 105 | +immediately once a valid checksum boundary is found. |
| 106 | + |
| 107 | +### Configuration |
| 108 | + |
| 109 | +```python |
| 110 | +# pai.conf |
| 111 | +SERIAL_ENCRYPTED = True # default: False |
| 112 | +PASSWORD = "0000" # PC password (same as used for non-encrypted serial) |
| 113 | +``` |
| 114 | + |
| 115 | +`SERIAL_ENCRYPTED` applies to both `SERIAL_PORT` and `IP_CONNECTION_BARE` connections. |
| 116 | +Both use `SerialConnectionProtocol`, so the same framing and decryption logic is active |
| 117 | +for either transport. |
| 118 | + |
| 119 | +--- |
| 120 | + |
| 121 | +## Capture file decryption (`ip150_connection_decrypt --serial`) |
| 122 | + |
| 123 | +The `--serial` mode in `paradox/console_scripts/ip150_connection_decrypt.py` |
| 124 | +can decrypt capture files produced by the ESP32 or a serial sniffer. |
| 125 | + |
| 126 | +### Capture file format |
| 127 | + |
| 128 | +``` |
| 129 | +TX [51]: e0 fe 12 c5 ca 4a b7 dc b3 c5 92 06 f6 e9 eb 47 76 1e c9 28 bf 27 54 ee 41 dd d3 ab b4 d0 88 bb b3 ee 36 9b e2 17 50 fd 52 cc 91 19 ... |
| 130 | +RX [51]: e0 fe ... |
| 131 | +``` |
| 132 | + |
| 133 | +Each line is a complete framed packet. The tool calls |
| 134 | +`decrypt_serial_message(frame, key)` directly on each E0 FE line; no |
| 135 | +timeout-based framing is needed since packet boundaries are already known. |
| 136 | + |
| 137 | +### Usage |
| 138 | + |
| 139 | +```bash |
| 140 | +pai-decrypt capture.serial --serial --pc-password 0000 |
| 141 | +``` |
| 142 | + |
| 143 | +### Decryption behaviour |
| 144 | + |
| 145 | +| Frame type | Outcome | |
| 146 | +|---|---| |
| 147 | +| Full-AES E0 FE (≥ 16 bytes AES data) | Decrypted; inner message parsed by panel parsers | |
| 148 | +| BabyWare compact E0 FE (< 16 bytes AES data) | Structure displayed (`request_nr`, raw data); content not decryptable | |
| 149 | +| Regular Paradox frame | Parsed normally | |
| 150 | + |
| 151 | +--- |
| 152 | + |
| 153 | +## File locations |
| 154 | + |
| 155 | +| File | Role | |
| 156 | +|---|---| |
| 157 | +| `paradox/lib/crypto.py` | `encrypt()`, `decrypt()`, `encrypt_serial_message()`, `decrypt_serial_message()` | |
| 158 | +| `paradox/connections/serial_encryption.py` | `EncryptedSerialTransport`, `make_serial_key()` | |
| 159 | +| `paradox/connections/protocols.py` | `SerialConnectionProtocol` — framing and dispatch | |
| 160 | +| `paradox/config.py` | `SERIAL_ENCRYPTED` flag | |
| 161 | +| `paradox/console_scripts/ip150_connection_decrypt.py` | Offline capture decryption tool | |
| 162 | +| `tests/lib/test_serial_crypto.py` | Unit tests for encrypt/decrypt roundtrips | |
| 163 | +| `tests/connection/test_serial_protocol.py` | Integration tests for framing and decryption | |
| 164 | + |
| 165 | +--- |
| 166 | + |
| 167 | +## Known pitfalls |
| 168 | + |
| 169 | +- **No length byte in Format 2.** An earlier PAI implementation incorrectly |
| 170 | + read `buffer[2]` as the frame length. In Format 2, that byte is AES |
| 171 | + ciphertext — roughly half the time it exceeds the actual frame length, |
| 172 | + causing the framer to stall. |
| 173 | + |
| 174 | +- **AES data offset.** An earlier implementation read `frame[3:-1]` for the |
| 175 | + ciphertext, skipping three bytes instead of two, so decryption always |
| 176 | + produced garbage. |
| 177 | + |
| 178 | +- **`str(bytes)` key corruption.** Passing a `bytes` password through |
| 179 | + `str()` before encoding produces `"b'1234'"` instead of `"1234"`, |
| 180 | + generating the wrong key. `make_serial_key()` handles `bytes` directly to |
| 181 | + avoid this. |
| 182 | + |
| 183 | +- **BabyWare compact frames are not AES.** These 12–13 byte frames use a |
| 184 | + proprietary algorithm; brute-forcing the PC password across 0000–9999 yields |
| 185 | + no valid decryptions. |
0 commit comments