Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,53 @@ async with OpenDisplayDevice(mac_address="BB:CC:DD:EE:FF:00") as device:
`import_config_json()` raises `ValueError` if required packets (`system`, `manufacturer`, `power`) or all display packets are missing.
JSON packet id `38` (`wifi_config` / TLV `0x26`) is supported for import/export.

### Encryption

Devices with firmware encryption enabled require authentication before accepting any commands (except `read_firmware_version`). Pass the 16-byte AES-128 master key to the constructor — authentication and session setup happen automatically before the first interrogation.

```python
from opendisplay import OpenDisplayDevice

key = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")

async with OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF", encryption_key=key) as device:
await device.upload_image(image)
```

All commands are transparently encrypted after authentication. Devices without encryption enabled work exactly as before — the `encryption_key` parameter is ignored if the device does not require it.

#### Getting the key

The encryption key is set when configuring the device via the [Open Display Config Builder](https://opendisplay.org/firmware/config/) web tool. It can be read from the device config once authenticated:

```python
async with OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF", encryption_key=key) as device:
sc = device.config.security_config
print(sc.encryption_key.hex()) # 32-char hex string
print(sc.encryption_enabled_flag) # True
print(sc.rewrite_allowed) # True if WRITE_CONFIG works without auth
print(sc.session_timeout_seconds) # How long before re-authentication is needed
```

#### Error handling

```python
from opendisplay import AuthenticationRequiredError, AuthenticationFailedError

try:
async with OpenDisplayDevice(mac_address="AA:BB:CC:DD:EE:FF") as device:
pass
except AuthenticationRequiredError:
# Device has encryption enabled but no key was provided
# (or the session expired and re-authentication is needed)
print("This device requires an encryption key")
except AuthenticationFailedError:
# A key was provided but the device rejected it (wrong key or rate-limited)
print("Wrong encryption key")
```

Both exceptions are subclasses of `AuthenticationError`, which can be used as a catch-all when the distinction doesn't matter.

### Rebooting the Device

Remotely reboot the device (useful after configuration changes or troubleshooting):
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ dependencies = [
"numpy>=1.24.0,!=2.4.0",
"bleak-retry-connector>=3.5.0",
"epaper-dithering==0.6.3",
"cryptography>=41.0.0",
]

[project.optional-dependencies]
Expand Down
8 changes: 8 additions & 0 deletions src/opendisplay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
from .device import OpenDisplayDevice, prepare_image
from .discovery import discover_devices
from .exceptions import (
AuthenticationError,
AuthenticationFailedError,
AuthenticationRequiredError,
BLEConnectionError,
BLETimeoutError,
ConfigParseError,
Expand All @@ -34,6 +37,7 @@
LedConfig,
ManufacturerData,
PowerOption,
SecurityConfig,
SensorData,
SystemConfig,
WifiConfig,
Expand Down Expand Up @@ -64,6 +68,9 @@
"prepare_image",
# Exceptions
"OpenDisplayError",
"AuthenticationError",
"AuthenticationFailedError",
"AuthenticationRequiredError",
"BLEConnectionError",
"BLETimeoutError",
"ProtocolError",
Expand All @@ -82,6 +89,7 @@
"SensorData",
"DataBus",
"BinaryInputs",
"SecurityConfig",
"WifiConfig",
# Models - Other
"DeviceCapabilities",
Expand Down
129 changes: 129 additions & 0 deletions src/opendisplay/crypto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""AES-128-CCM/CMAC crypto helpers for OpenDisplay BLE encryption.

Implements the application-layer encryption protocol used by firmware >= commit b04a22b.
All operations match the firmware's mbedtls/CryptoCell implementations exactly.
"""

from __future__ import annotations

import os

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.ciphers.aead import AESCCM
from cryptography.hazmat.primitives.cmac import CMAC

# Firmware placeholder device ID (hardcoded in firmware, never changes)
_DEVICE_ID = bytes([0x00, 0x00, 0x00, 0x01])

# CCM auth tag length used by firmware
_TAG_LEN = 12


def aes_cmac(key: bytes, data: bytes) -> bytes:
"""Compute AES-128-CMAC."""
c = CMAC(algorithms.AES(key))
c.update(data)
return c.finalize()


def aes_ecb_encrypt(key: bytes, block: bytes) -> bytes:
"""Encrypt a single 16-byte block with AES-ECB (used in KDF only)."""
cipher = Cipher(algorithms.AES(key), modes.ECB()) # noqa: S305 — intentional single-block KDF step
enc = cipher.encryptor()
return enc.update(block) + enc.finalize()


def derive_session_key(master_key: bytes, client_nonce: bytes, server_nonce: bytes) -> bytes:
"""Derive per-session AES-128 key from master key and nonces.

Matches firmware deriveSessionKey():
1. CMAC(master_key, label || 0x00 || device_id || client_nonce || server_nonce || 0x00 0x80)
2. AES-ECB(master_key, counter_be(1, 8 bytes) || intermediate[0:8])
"""
label = b"OpenDisplay session"
cmac_input = label + b"\x00" + _DEVICE_ID + client_nonce + server_nonce + bytes([0x00, 0x80])
intermediate = aes_cmac(master_key, cmac_input)

# counter = 1, big-endian 8 bytes
counter_be = (1).to_bytes(8, "big")
final_input = counter_be + intermediate[:8]
return aes_ecb_encrypt(master_key, final_input)


def derive_session_id(session_key: bytes, client_nonce: bytes, server_nonce: bytes) -> bytes:
"""Derive 8-byte session ID from session key and nonces.

Matches firmware deriveSessionId():
AES-CMAC(session_key, client_nonce || server_nonce)[0:8]
"""
return aes_cmac(session_key, client_nonce + server_nonce)[:8]


def compute_challenge_response(master_key: bytes, server_nonce: bytes, client_nonce: bytes) -> bytes:
"""Compute CMAC challenge proof sent to device in step 2 of auth.

CMAC(master_key, server_nonce || client_nonce || device_id)
"""
return aes_cmac(master_key, server_nonce + client_nonce + _DEVICE_ID)


def get_nonce(session_id: bytes, counter: int) -> bytes:
"""Build the 16-byte full nonce: session_id(8) || counter_be(8)."""
return session_id + counter.to_bytes(8, "big")


def encrypt_command(session_key: bytes, session_id: bytes, counter: int, cmd: bytes, payload: bytes) -> bytes:
"""Encrypt a command payload for sending to the device.

Returns full BLE write bytes: [cmd:2][nonce_full:16][ciphertext][tag:12]

The CCM nonce is nonce_full[3:16] (13 bytes).
AD = cmd bytes (2 bytes).
Plaintext = [len(payload):1][payload].
"""
nonce_full = get_nonce(session_id, counter)
ccm_nonce = nonce_full[3:] # 13 bytes
ad = cmd # 2-byte command code as AAD
plaintext = bytes([len(payload)]) + payload

aesccm = AESCCM(session_key, tag_length=_TAG_LEN)
ciphertext_and_tag = aesccm.encrypt(ccm_nonce, plaintext, ad)
ciphertext = ciphertext_and_tag[:-_TAG_LEN]
tag = ciphertext_and_tag[-_TAG_LEN:]

return cmd + nonce_full + ciphertext + tag


def decrypt_response(session_key: bytes, raw: bytes) -> tuple[int, bytes]:
"""Decrypt an encrypted response notification from the device.

Parses [cmd:2][nonce_full:16][ciphertext][tag:12].
Returns (cmd_code, plaintext_payload).

Raises:
ValueError: If data is too short or tag verification fails.
"""
min_len = 2 + 16 + 1 + _TAG_LEN # cmd + nonce + 1-byte payload + tag
if len(raw) < min_len:
raise ValueError(f"Encrypted response too short: {len(raw)} bytes")

cmd_code = int.from_bytes(raw[:2], "big")
nonce_full = raw[2:18]
ciphertext = raw[18:-_TAG_LEN]
tag = raw[-_TAG_LEN:]
ccm_nonce = nonce_full[3:] # 13 bytes
ad = raw[:2] # cmd bytes as AAD

aesccm = AESCCM(session_key, tag_length=_TAG_LEN)
# cryptography library expects ciphertext+tag concatenated
decrypted = aesccm.decrypt(ccm_nonce, ciphertext + tag, ad)

# First byte is payload length
payload_len = decrypted[0]
payload = decrypted[1 : 1 + payload_len]
return cmd_code, payload


def generate_client_nonce() -> bytes:
"""Generate 16 cryptographically random bytes for the client nonce."""
return os.urandom(16)
Loading
Loading