"""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, device_id: bytes = _DEVICE_ID, ) -> 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, device_id: bytes = _DEVICE_ID, ) -> 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)