Skip to content

Commit a7c3e10

Browse files
authored
Merge pull request #337 from ParadoxAlarmInterface/serial_encryption
Serial encryption
2 parents bd25594 + aababe4 commit a7c3e10

14 files changed

Lines changed: 982 additions & 75 deletions

docs/serial_encryption.md

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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.

paradox/config.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class Config:
2929
# Serial Connection Details
3030
"SERIAL_PORT": "/dev/ttyS1", # Pathname of the Serial Port
3131
"SERIAL_BAUD": 9600, # Baud rate of the Serial Port. Use 38400(default setting) or 57600 for EVO
32+
"SERIAL_ENCRYPTED": False, # Set True for EVO panels with full serial encryption (firmware >= 7.50)
3233
# IP Connection Details
3334
"IP_CONNECTION_HOST": "127.0.0.1", # IP Module address when using direct IP Connection
3435
"IP_CONNECTION_PORT": (
@@ -57,7 +58,11 @@ class Config:
5758
"KEEP_ALIVE_INTERVAL": 10, # Interval between status updates
5859
"IO_TIMEOUT": 0.5, # Timeout for IO operations
5960
"LIMITS": {}, # By default all zones will be monitored
60-
"MODULE_PGM_ADDRESSES": ({}, dict, None), # Map of bus module address -> pgm count, e.g. {4: 4}
61+
"MODULE_PGM_ADDRESSES": (
62+
{},
63+
dict,
64+
None,
65+
), # Map of bus module address -> pgm count, e.g. {4: 4}
6166
"LABEL_ENCODING": "paradox-en", # Encoding to use when decoding labels. paradox-* or https://docs.python.org/3/library/codecs.html#standard-encodings
6267
"LABEL_REFRESH_INTERVAL": (
6368
15 * 60,

paradox/connections/protocols.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
IPMessageResponse,
1212
IPMessageType,
1313
)
14+
from paradox.connections.serial_encryption import (
15+
EncryptedSerialTransport,
16+
make_serial_key,
17+
)
18+
from paradox.lib.crypto import decrypt_serial_message
1419

1520
logger = logging.getLogger("PAI").getChild(__name__)
1621

@@ -102,6 +107,15 @@ def __del__(self):
102107

103108

104109
class SerialConnectionProtocol(ConnectionProtocol):
110+
def connection_made(self, transport):
111+
if cfg.SERIAL_ENCRYPTED and cfg.PASSWORD:
112+
self._serial_key = make_serial_key(cfg.PASSWORD)
113+
transport = EncryptedSerialTransport(transport, self._serial_key)
114+
logger.info("Serial encryption enabled (SERIAL_ENCRYPTED=True)")
115+
else:
116+
self._serial_key = None
117+
super().connection_made(transport)
118+
105119
def send_message(self, message):
106120
if cfg.LOGGING_DUMP_PACKETS:
107121
logger.debug(f"PAI -> SER {binascii.hexlify(message)}")
@@ -116,6 +130,7 @@ def data_received(self, recv_data):
116130
min_length = 4 if self.use_variable_message_length else 37
117131

118132
while len(self.buffer) >= min_length:
133+
is_encrypted_frame = False
119134
if self.use_variable_message_length:
120135
if self.buffer[0] >> 4 == 0:
121136
potential_packet_length = 37
@@ -128,7 +143,31 @@ def data_received(self, recv_data):
128143
elif self.buffer[0] >> 4 == 0xC:
129144
potential_packet_length = self.buffer[1] * 256 + self.buffer[2]
130145
elif self.buffer[0] >> 4 == 0xE:
131-
if self.buffer[1] < 37 or self.buffer[1] == 0xFF:
146+
if self.buffer[1] == 0xFE:
147+
if cfg.SERIAL_ENCRYPTED:
148+
# Full-AES E0 FE: frame is [E0|x][FE][n*16 AES bytes][checksum].
149+
# Scan AES block boundaries for the first valid checksum to
150+
# determine frame length without a timeout.
151+
found = False
152+
for n_blocks in range(1, 8):
153+
frame_len = 2 + n_blocks * 16 + 1
154+
if len(self.buffer) < frame_len:
155+
break
156+
candidate = self.buffer[:frame_len]
157+
if sum(candidate[:-1]) % 256 == candidate[-1]:
158+
potential_packet_length = frame_len
159+
is_encrypted_frame = True
160+
found = True
161+
break
162+
if not found:
163+
break # Wait for more data
164+
else:
165+
# BabyWare compact E0 FE: length byte at [2]
166+
if len(self.buffer) < 3:
167+
break
168+
potential_packet_length = self.buffer[2]
169+
is_encrypted_frame = True
170+
elif self.buffer[1] < 37 or self.buffer[1] == 0xFF:
132171
# MG/SP in 21st century and EVO Live Events. Probable values=0x13, 0x13, 0x00, 0xFF
133172
potential_packet_length = 37
134173
else:
@@ -144,12 +183,24 @@ def data_received(self, recv_data):
144183

145184
frame = self.buffer[:potential_packet_length]
146185

147-
if checksum(frame, min_length):
186+
if is_encrypted_frame or checksum(frame, min_length):
148187
self.buffer = self.buffer[len(frame) :] # Remove message
149188
if cfg.LOGGING_DUMP_PACKETS:
150189
logger.debug(f"SER -> PAI {binascii.hexlify(frame)}")
151190

152-
self.handler.on_message(frame)
191+
if cfg.SERIAL_ENCRYPTED and is_encrypted_frame:
192+
decrypted = decrypt_serial_message(frame, self._serial_key)
193+
if decrypted:
194+
logger.debug(
195+
f"SER DECRYPT: {len(frame)}b E0FE → {len(decrypted)}b"
196+
)
197+
self.handler.on_message(decrypted)
198+
else:
199+
logger.warning(
200+
f"SER: E0FE AES decrypt failed: {binascii.hexlify(frame)}"
201+
)
202+
else:
203+
self.handler.on_message(frame)
153204
else:
154205
self.buffer = self.buffer[1:]
155206

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Transparent AES-256 E0 FE encryption layer for EVO serial connections."""
2+
3+
import logging
4+
5+
from paradox.lib.crypto import encrypt_serial_message
6+
7+
logger = logging.getLogger("PAI").getChild(__name__)
8+
9+
10+
class EncryptedSerialTransport:
11+
"""Wraps a serial transport to transparently encrypt outgoing messages."""
12+
13+
def __init__(self, transport, key: bytes):
14+
self._transport = transport
15+
self._key = key
16+
17+
def write(self, data: bytes) -> None:
18+
encrypted = encrypt_serial_message(data, self._key)
19+
logger.debug(f"SER ENCRYPT: {len(data)}b → {len(encrypted)}b E0FE frame")
20+
self._transport.write(encrypted)
21+
22+
def __getattr__(self, name):
23+
return getattr(self._transport, name)
24+
25+
26+
def make_serial_key(password) -> bytes:
27+
"""Derive the 32-byte serial encryption key from the panel PC password."""
28+
if isinstance(password, bytes):
29+
raw = password
30+
elif isinstance(password, int):
31+
raw = str(password).zfill(4).encode("utf-8")
32+
else:
33+
raw = str(password).encode("utf-8")
34+
if len(raw) < 32:
35+
raw = raw + b"\xee" * (32 - len(raw))
36+
return raw[:32]

0 commit comments

Comments
 (0)