63 lines
2.8 KiB
Python

"""AES-128-ECB framing used by the Shining Mask BLE protocol.
Every command written to the command characteristic is a 16-byte block
encrypted with a fixed AES-128 key in ECB mode, and notifications coming back
on the notify characteristic are encrypted the same way.
The key is baked into the mask firmware / the official "Shining Mask" app --
it is *not* a secret and there is no key exchange. It was recovered by the
reverse-engineering community (see the reddit post referenced in the project
README) and is identical across every vendor variant of this mask.
This module prefers ``cryptography`` (which is what the rest of the robotics
stack already ships) and transparently falls back to ``pycryptodome`` if only
that is installed.
"""
from __future__ import annotations
# Fixed device key -- hardcoded in firmware, not a credential. Do not "rotate".
MASK_AES_KEY = bytes.fromhex("32672f7974ad43451d9c6c894a0e8764")
BLOCK_SIZE = 16
try: # Preferred backend: cryptography (pyca)
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
def _cipher() -> "Cipher":
return Cipher(algorithms.AES(MASK_AES_KEY), modes.ECB()) # noqa: S305 - ECB is what the device uses
def encrypt(block: bytes) -> bytes:
"""Encrypt one or more 16-byte blocks with AES-128-ECB."""
if len(block) == 0 or len(block) % BLOCK_SIZE != 0:
raise ValueError(f"data length must be a positive multiple of {BLOCK_SIZE}, got {len(block)}")
enc = _cipher().encryptor()
return enc.update(bytes(block)) + enc.finalize()
def decrypt(data: bytes) -> bytes:
"""Decrypt one or more 16-byte blocks with AES-128-ECB."""
if len(data) == 0 or len(data) % BLOCK_SIZE != 0:
raise ValueError(f"data length must be a positive multiple of {BLOCK_SIZE}, got {len(data)}")
dec = _cipher().decryptor()
return dec.update(bytes(data)) + dec.finalize()
BACKEND = "cryptography"
except ImportError: # pragma: no cover - exercised only when pyca is absent
from Crypto.Cipher import AES # type: ignore
def encrypt(block: bytes) -> bytes:
"""Encrypt one or more 16-byte blocks with AES-128-ECB."""
if len(block) == 0 or len(block) % BLOCK_SIZE != 0:
raise ValueError(f"data length must be a positive multiple of {BLOCK_SIZE}, got {len(block)}")
return AES.new(MASK_AES_KEY, AES.MODE_ECB).encrypt(bytes(block))
def decrypt(data: bytes) -> bytes:
"""Decrypt one or more 16-byte blocks with AES-128-ECB."""
if len(data) == 0 or len(data) % BLOCK_SIZE != 0:
raise ValueError(f"data length must be a positive multiple of {BLOCK_SIZE}, got {len(data)}")
return AES.new(MASK_AES_KEY, AES.MODE_ECB).decrypt(bytes(data))
BACKEND = "pycryptodome"