custom_components/govee_bluetooth/cipher.py

"""The two wire ciphers newer Govee lights wrap the 20-byte frames in.

Nothing in the Home Assistant device path uses this module yet. It exists so
the handshakes are implemented once, against known-answer vectors from
hardware captures, before anything is pointed at a light:

* **V1, AES-ECB + RC4** (``com.govee.encryp.ble.Safe`` and ``Controller4Aes``
  in the decompiled app). Each 20-byte frame is AES-128-ECB on bytes 0-15 and
  RC4 on bytes 16-19 under one 16-byte key. The handshake runs under a fixed
  pre-shared key: ``E7 01`` request, ``E7 01 <16-byte session key>`` reply,
  ``E7 02`` confirm, ``E7 02`` ack. Everything after that uses the session
  key. Sources: Brady-Woods/govee-ble-local ``crypto.py`` / ``handshake.py``
  (live on an H60A6), egold555/Govee-Reverse-Engineering ``Products/H600B``
  (a phone capture that decrypts with this exact routine; those bytes are
  the test vector in tests/test_cipher.py).
* **V2, AES-GCM** (``Controller4AesGcm``). ``E7 11 01 <12-byte IV> 10`` +
  GCM(handshake key, IV, our 8-byte tx IV key, AAD = header); reply
  ``E7 11 00 <IV>`` + GCM(rx IV key || 11-byte device info). The session key
  is AES-ECB(device key, device info padded to 16). Frames are
  ``counter(4, BE) || GCM(session key, IV key || counter, frame, AAD = counter)``
  with a 16-byte tag. Source: lasswellt/govee-homeassistant ``ble_crypto.py``,
  verified on an H1270; its device-info / key pair is the vector here.

The keys below are constants compiled into the vendor app and shipped in
every device; they are not user secrets. The 8-byte pairing secret (``AA B1``
/ ``33 B2``) is a different mechanism and never passes through here.

``cryptography`` is a Home Assistant core dependency, so it is imported
lazily and only the AES primitives come from it; RC4 is ten lines.
"""

from __future__ import annotations

import os
from dataclasses import dataclass, field

from . import protocol

FRAME_LEN = protocol.FRAME_LENGTH
HANDSHAKE = 0xE7

# V1 (AES-ECB + RC4): the handshake PSK, decoded from the app's resources by
# govee-ble-local and confirmed as a literal in the H5083 firmware image.
V1_PSK = b"MakingLifeSmarte"
V1_OP_SESSION_KEY = 0x01
V1_OP_CONFIRM = 0x02

# V2 (AES-GCM): static keys from the app (lasswellt/govee-homeassistant).
V2_HANDSHAKE_KEY = bytes.fromhex("fc03783c7c42cb83e202a1643648aff6")
V2_DEVICE_KEY = bytes.fromhex("ae028b630bae6ecc4bff1b249e22f955")
V2_REQUEST_HEADER = bytes((HANDSHAKE, 0x11, 0x01))
V2_RESPONSE_HEADER = bytes((HANDSHAKE, 0x11, 0x00))
V2_IV_LEN = 12
V2_IV_KEY_LEN = 8
V2_COUNTER_LEN = 4
V2_TAG_LEN = 16

# The BGC-info characteristic (2b12). The decompiled app reads encryptVersion
# from byte 0; lasswellt found the version at byte 1 on an H1270 (byte 0 read
# 0x01 there). Record the raw bytes and let the classifier look at both.
BGC_CHARACTERISTIC_UUID = "00010203-0405-0607-0809-0a0b0c0d2b12"


class CipherError(ValueError):
    """A handshake frame did not decode as expected."""


# ---------------------------------------------------------------------------
# V1: AES-ECB on the first block, RC4 on the 4-byte remainder
# ---------------------------------------------------------------------------


def _aes_ecb(key: bytes, block: bytes, *, encrypt: bool) -> bytes:
    if len(key) != 16 or len(block) != 16:
        raise CipherError("AES-ECB needs a 16-byte key and a 16-byte block")
    from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

    cipher = Cipher(algorithms.AES(key), modes.ECB())
    ctx = cipher.encryptor() if encrypt else cipher.decryptor()
    return ctx.update(block) + ctx.finalize()


def rc4(key: bytes, data: bytes) -> bytes:
    """Plain RC4 (``Safe.f`` key schedule + ``Safe.g`` keystream), restarted per call."""
    if not key:
        raise CipherError("RC4 key must not be empty")
    s = list(range(256))
    j = 0
    for i in range(256):
        j = (j + s[i] + key[i % len(key)]) & 0xFF
        s[i], s[j] = s[j], s[i]
    out = bytearray(len(data))
    i = j = 0
    for k, byte in enumerate(data):
        i = (i + 1) & 0xFF
        j = (j + s[i]) & 0xFF
        s[i], s[j] = s[j], s[i]
        out[k] = byte ^ s[(s[i] + s[j]) & 0xFF]
    return bytes(out)


def v1_encrypt(frame: bytes, key: bytes) -> bytes:
    """``Safe.d``: AES-ECB on every whole 16-byte block, RC4 on the tail."""
    blocks, rem = divmod(len(frame), 16)
    out = b"".join(_aes_ecb(key, frame[i * 16 : i * 16 + 16], encrypt=True) for i in range(blocks))
    if rem:
        out += rc4(key, frame[blocks * 16 :])
    return out


def v1_decrypt(data: bytes, key: bytes) -> bytes:
    """``Safe.b``: the inverse of :func:`v1_encrypt`."""
    blocks, rem = divmod(len(data), 16)
    out = b"".join(_aes_ecb(key, data[i * 16 : i * 16 + 16], encrypt=False) for i in range(blocks))
    if rem:
        out += rc4(key, data[blocks * 16 :])
    return out


def v1_handshake_frame(op: int, fill: bytes | None = None) -> bytes:
    """Plaintext ``E7 <op> <17 fill bytes> <xor>``; ``Controller4Aes.a`` fills with random bytes."""
    body = fill if fill is not None else os.urandom(FRAME_LEN - 3)
    if len(body) != FRAME_LEN - 3:
        raise CipherError("handshake fill must be 17 bytes")
    return protocol.build_frame(op, body, frame_type=HANDSHAKE)


def v1_build_step1(psk: bytes = V1_PSK, fill: bytes | None = None) -> bytes:
    """PSK-encrypted ``E7 01`` request (``Controller4Aes.e``)."""
    return v1_encrypt(v1_handshake_frame(V1_OP_SESSION_KEY, fill), psk)


def v1_build_step2(psk: bytes = V1_PSK, fill: bytes | None = None) -> bytes:
    """PSK-encrypted ``E7 02`` confirm (``Controller4Aes.f``)."""
    return v1_encrypt(v1_handshake_frame(V1_OP_CONFIRM, fill), psk)


def v1_parse_session_key(reply: bytes, psk: bytes = V1_PSK) -> bytes | None:
    """The 16-byte session key from a PSK-encrypted ``E7 01`` reply, else None.

    ``Controller4Aes.g`` checks only bytes 0 and 1 and takes bytes 2..17; the
    reply's checksum is not validated by the app and is not here either.
    """
    if len(reply) < 18:
        return None
    plain = v1_decrypt(reply[:FRAME_LEN], psk)
    if plain[0] != HANDSHAKE or plain[1] != V1_OP_SESSION_KEY:
        return None
    return plain[2:18]


def v1_is_confirm_ack(reply: bytes, psk: bytes = V1_PSK) -> bool:
    if len(reply) < 2:
        return False
    plain = v1_decrypt(reply[:FRAME_LEN], psk)
    return plain[0] == HANDSHAKE and plain[1] == V1_OP_CONFIRM


@dataclass(slots=True)
class V1Session:
    """A negotiated V1 session: wrap writes, unwrap notifications."""

    key: bytes

    def wrap(self, frame: bytes) -> bytes:
        if len(frame) != FRAME_LEN:
            raise CipherError("V1 wraps 20-byte frames")
        return v1_encrypt(frame, self.key)

    def unwrap(self, data: bytes) -> bytes:
        return v1_decrypt(bytes(data)[:FRAME_LEN], self.key)


# ---------------------------------------------------------------------------
# V2: AES-GCM with a per-connection IV key pair
# ---------------------------------------------------------------------------


def _aesgcm(key: bytes):  # type: ignore[no-untyped-def]
    from cryptography.hazmat.primitives.ciphers.aead import AESGCM

    return AESGCM(key)


def v2_derive_device_key(device_info: bytes) -> bytes:
    """AES-ECB(device key, device_info zero-padded to 16): the session data key."""
    if not 1 <= len(device_info) <= 16:
        raise CipherError("device info must be 1-16 bytes")
    return _aes_ecb(V2_DEVICE_KEY, device_info.ljust(16, b"\x00"), encrypt=True)


def v2_build_request(iv: bytes, tx_iv_key: bytes) -> bytes:
    """``E7 11 01 <iv> 10`` + GCM(handshake key, iv, tx_iv_key, AAD = that header)."""
    if len(iv) != V2_IV_LEN or len(tx_iv_key) != V2_IV_KEY_LEN:
        raise CipherError("V2 request needs a 12-byte IV and an 8-byte IV key")
    header = V2_REQUEST_HEADER + iv + bytes((16,))
    return header + _aesgcm(V2_HANDSHAKE_KEY).encrypt(iv, tx_iv_key, header)


def v2_parse_response(response: bytes) -> tuple[bytes, bytes]:
    """``(rx_iv_key, device_info)`` from an ``E7 11 00`` reply; raises on a bad tag."""
    response = bytes(response)
    if not response.startswith(V2_RESPONSE_HEADER):
        raise CipherError("not a V2 handshake reply")
    aad = response[: 3 + V2_IV_LEN]
    iv = response[3 : 3 + V2_IV_LEN]
    from cryptography.exceptions import InvalidTag

    try:
        plain = _aesgcm(V2_HANDSHAKE_KEY).decrypt(iv, response[3 + V2_IV_LEN :], aad)
    except InvalidTag as err:
        raise CipherError("V2 handshake reply failed authentication") from err
    return plain[:V2_IV_KEY_LEN], plain[V2_IV_KEY_LEN:]


def v2_identity_matches(device_info: bytes, address: str) -> bool | None:
    """The reply carries the SKU and the MAC reversed; None when the address is not a MAC."""
    if len(device_info) < 11:
        return False
    try:
        mac = bytes.fromhex(address.replace(":", ""))
    except ValueError:
        return None
    return device_info[5:11] == bytes(reversed(mac))


@dataclass(slots=True)
class V2Session:
    """A negotiated V2 session. Counters restart at 1 per connection."""

    device_key: bytes
    tx_iv_key: bytes
    rx_iv_key: bytes
    _tx_counter: int = field(default=0, repr=False)

    def wrap(self, frame: bytes) -> bytes:
        self._tx_counter += 1
        counter = self._tx_counter.to_bytes(V2_COUNTER_LEN, "big")
        return counter + _aesgcm(self.device_key).encrypt(self.tx_iv_key + counter, frame, counter)

    def unwrap(self, packet: bytes) -> bytes:
        packet = bytes(packet)
        counter = packet[:V2_COUNTER_LEN]
        return _aesgcm(self.device_key).decrypt(
            self.rx_iv_key + counter, packet[V2_COUNTER_LEN:], counter
        )


def bgc_encrypt_version(bgc: bytes) -> tuple[int | None, str]:
    """Read a qualified BGC format discriminator followed by encryption version.

    Format 2 also carries broadcast flags and pact. Unknown/short formats
    remain unknown; missing BGC is not evidence of plaintext or authentication.
    """
    bgc = bytes(bgc)
    if not bgc:
        return None, "no bgc bytes"
    if bgc[0] not in (1, 2):
        return None, "unknown BGC format"
    minimum = 2 if bgc[0] == 1 else 6
    if len(bgc) < minimum:
        return None, "incomplete BGC format"
    return bgc[1], f"BGC format {bgc[0]}, encryption version at byte 1"