import binascii import gzip import hashlib import logging from collections.abc import Callable from urllib.parse import urlparse from construct import ( # type: ignore Bytes, Checksum, ChecksumError, Construct, Container, GreedyBytes, GreedyRange, Int16ub, Int32ub, Optional, Peek, RawCopy, Struct, bytestringtype, stream_seek, stream_tell, ) from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad from roborock.data import RRiot from roborock.exceptions import RoborockException from roborock.mqtt.session import MqttParams from roborock.roborock_message import RoborockMessage _LOGGER = logging.getLogger(__name__) SALT = b"TXdfu$jyZ#TZHsg4" A01_HASH = "726f626f726f636b2d67a6d6da" B01_HASH = "5wwh9ikChRjASpMU8cxg7o1d2E" AP_CONFIG = 1 SOCK_DISCOVERY = 2 def md5hex(message: str) -> str: md5 = hashlib.md5() md5.update(message.encode()) return md5.hexdigest() class Utils: """Util class for protocol manipulation.""" @staticmethod def verify_token(token: bytes): """Checks if the given token is of correct type and length.""" if not isinstance(token, bytes): raise TypeError("Token must be bytes") if len(token) != 16: raise ValueError("Wrong token length") @staticmethod def ensure_bytes(msg: bytes | str) -> bytes: if isinstance(msg, str): return msg.encode() return msg @staticmethod def encode_timestamp(_timestamp: int) -> bytes: hex_value = f"{_timestamp:x}".zfill(8) return "".join(list(map(lambda idx: hex_value[idx], [5, 6, 3, 7, 1, 2, 0, 4]))).encode() @staticmethod def md5(data: bytes) -> bytes: """Calculates a md5 hashsum for the given bytes object.""" checksum = hashlib.md5() # nosec checksum.update(data) return checksum.digest() @staticmethod def encrypt_ecb(plaintext: bytes, token: bytes) -> bytes: """Encrypt plaintext with a given token using ecb mode. :param bytes plaintext: Plaintext (json) to encrypt :param bytes token: Token to use :return: Encrypted bytes """ if not isinstance(plaintext, bytes): raise TypeError("plaintext requires bytes") Utils.verify_token(token) cipher = AES.new(token, AES.MODE_ECB) if plaintext: plaintext = pad(plaintext, AES.block_size) return cipher.encrypt(plaintext) return plaintext @staticmethod def decrypt_ecb(ciphertext: bytes, token: bytes) -> bytes: """Decrypt ciphertext with a given token using ecb mode. :param bytes ciphertext: Ciphertext to decrypt :param bytes token: Token to use :return: Decrypted bytes object """ if not isinstance(ciphertext, bytes): raise TypeError("ciphertext requires bytes") if ciphertext: Utils.verify_token(token) aes_key = token decipher = AES.new(aes_key, AES.MODE_ECB) return unpad(decipher.decrypt(ciphertext), AES.block_size) return ciphertext @staticmethod def encrypt_cbc(plaintext: bytes, token: bytes) -> bytes: """Encrypt plaintext with a given token using cbc mode. This is currently used for testing purposes only. :param bytes plaintext: Plaintext (json) to encrypt :param bytes token: Token to use :return: Encrypted bytes """ if not isinstance(plaintext, bytes): raise TypeError("plaintext requires bytes") Utils.verify_token(token) iv = bytes(AES.block_size) cipher = AES.new(token, AES.MODE_CBC, iv) if plaintext: plaintext = pad(plaintext, AES.block_size) return cipher.encrypt(plaintext) return plaintext @staticmethod def decrypt_cbc(ciphertext: bytes, token: bytes) -> bytes: """Decrypt ciphertext with a given token using cbc mode. :param bytes ciphertext: Ciphertext to decrypt :param bytes token: Token to use :return: Decrypted bytes object """ if not isinstance(ciphertext, bytes): raise TypeError("ciphertext requires bytes") if ciphertext: Utils.verify_token(token) iv = bytes(AES.block_size) decipher = AES.new(token, AES.MODE_CBC, iv) return unpad(decipher.decrypt(ciphertext), AES.block_size) return ciphertext @staticmethod def _l01_key(local_key: str, timestamp: int) -> bytes: """Derive key for L01 protocol.""" hash_input = Utils.encode_timestamp(timestamp) + Utils.ensure_bytes(local_key) + SALT return hashlib.sha256(hash_input).digest() @staticmethod def _l01_iv(timestamp: int, nonce: int, sequence: int) -> bytes: """Derive IV for L01 protocol.""" digest_input = sequence.to_bytes(4, "big") + nonce.to_bytes(4, "big") + timestamp.to_bytes(4, "big") digest = hashlib.sha256(digest_input).digest() return digest[:12] @staticmethod def _l01_aad(timestamp: int, nonce: int, sequence: int, connect_nonce: int, ack_nonce: int | None = None) -> bytes: """Derive AAD for L01 protocol.""" return ( sequence.to_bytes(4, "big") + connect_nonce.to_bytes(4, "big") + (ack_nonce.to_bytes(4, "big") if ack_nonce is not None else b"") + nonce.to_bytes(4, "big") + timestamp.to_bytes(4, "big") ) @staticmethod def encrypt_gcm_l01( plaintext: bytes, local_key: str, timestamp: int, sequence: int, nonce: int, connect_nonce: int, ack_nonce: int | None = None, ) -> bytes: """Encrypt plaintext for L01 protocol using AES-256-GCM.""" if not isinstance(plaintext, bytes): raise TypeError("plaintext requires bytes") key = Utils._l01_key(local_key, timestamp) iv = Utils._l01_iv(timestamp, nonce, sequence) aad = Utils._l01_aad(timestamp, nonce, sequence, connect_nonce, ack_nonce) cipher = AES.new(key, AES.MODE_GCM, nonce=iv) cipher.update(aad) ciphertext, tag = cipher.encrypt_and_digest(plaintext) return ciphertext + tag @staticmethod def decrypt_gcm_l01( payload: bytes, local_key: str, timestamp: int, sequence: int, nonce: int, connect_nonce: int, ack_nonce: int, ) -> bytes: """Decrypt payload for L01 protocol using AES-256-GCM.""" if not isinstance(payload, bytes): raise TypeError("payload requires bytes") key = Utils._l01_key(local_key, timestamp) iv = Utils._l01_iv(timestamp, nonce, sequence) aad = Utils._l01_aad(timestamp, nonce, sequence, connect_nonce, ack_nonce) if len(payload) < 16: raise ValueError("Invalid payload length for GCM decryption") tag = payload[-16:] ciphertext = payload[:-16] cipher = AES.new(key, AES.MODE_GCM, nonce=iv) cipher.update(aad) try: return cipher.decrypt_and_verify(ciphertext, tag) except ValueError as e: raise RoborockException("GCM tag verification failed") from e @staticmethod def crc(data: bytes) -> int: """Gather bytes for checksum calculation.""" return binascii.crc32(data) @staticmethod def decompress(compressed_data: bytes): """Decompress data using gzip.""" return gzip.decompress(compressed_data) class EncryptionAdapter(Construct): """Adapter to handle communication encryption.""" def __init__(self, token_func: Callable): super().__init__() self.token_func = token_func def _parse(self, stream, context, path): subcon1 = Optional(Int16ub) length = subcon1.parse_stream(stream, **context) if not length: if length == 0: subcon1.parse_stream(stream, **context) # seek 2 return None subcon2 = Bytes(length) obj = subcon2.parse_stream(stream, **context) return self._decode(obj, context, path) def _build(self, obj, stream, context, path): if obj is not None: obj2 = self._encode(obj, context, path) subcon1 = Int16ub length = len(obj2) subcon1.build_stream(length, stream, **context) subcon2 = Bytes(length) subcon2.build_stream(obj2, stream, **context) return obj def _encode(self, obj, context, _): """Encrypt the given payload with the token stored in the context. :param obj: JSON object to encrypt """ if context.version == b"A01": iv = md5hex(format(context.random, "08x") + A01_HASH)[8:24] decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8")) return decipher.encrypt(obj) elif context.version == b"B01": iv = md5hex(f"{context.random:08x}" + B01_HASH)[9:25] decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8")) return decipher.encrypt(pad(obj, AES.block_size)) elif context.version == b"L01": return Utils.encrypt_gcm_l01( plaintext=obj, local_key=context.search("local_key"), timestamp=context.timestamp, sequence=context.seq, nonce=context.random, connect_nonce=context.search("connect_nonce"), ack_nonce=context.search("ack_nonce"), ) token = self.token_func(context) encrypted = Utils.encrypt_ecb(obj, token) return encrypted def _decode(self, obj, context, _): """Decrypts the given payload with the token stored in the context.""" if context.version == b"A01": iv = md5hex(format(context.random, "08x") + A01_HASH)[8:24] decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8")) return decipher.decrypt(obj) elif context.version == b"B01": iv = md5hex(f"{context.random:08x}" + B01_HASH)[9:25] decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8")) return unpad(decipher.decrypt(obj), AES.block_size) elif context.version == b"L01": return Utils.decrypt_gcm_l01( payload=obj, local_key=context.search("local_key"), timestamp=context.timestamp, sequence=context.seq, nonce=context.random, connect_nonce=context.search("connect_nonce"), ack_nonce=context.search("ack_nonce"), ) token = self.token_func(context) decrypted = Utils.decrypt_ecb(obj, token) return decrypted class OptionalChecksum(Checksum): def _parse(self, stream, context, path): if not context.message.value.payload: return hash1 = self.checksumfield.parse_stream(stream, **context) hash2 = self.hashfunc(self.bytesfunc(context)) if hash1 != hash2: raise ChecksumError( f"wrong checksum, read {hash1 if not isinstance(hash1, bytestringtype) else binascii.hexlify(hash1)}, " f"computed {hash2 if not isinstance(hash2, bytestringtype) else binascii.hexlify(hash2)}", path=path, ) return hash1 class PrefixedStruct(Struct): def _parse(self, stream, context, path): subcon1 = Peek(Optional(Bytes(3))) peek_version = subcon1.parse_stream(stream, **context) valid_versions = (b"1.0", b"A01", b"B01", b"L01") if peek_version not in valid_versions: # Current stream position does not start with a valid version. # Scan forward to find one. current_pos = stream_tell(stream, path) # Read remaining data to find a valid header data = stream.read() if not data: # EOF reached, let the parser fail naturally without logging stream_seek(stream, current_pos, 0, path) return super()._parse(stream, context, path) start_index = -1 # Find the earliest occurrence of any valid version in a single pass for i in range(len(data) - 2): if data[i : i + 3] in valid_versions: start_index = i break if start_index != -1: # Found a valid version header at `start_index`. # Seek to that position (original_pos + index). if start_index != 4: # 4 is the typical/expected amount we prune off, # therefore, we only want a debug if we have a different length. _LOGGER.debug("Stripping %d bytes of invalid data from stream", start_index) stream_seek(stream, current_pos + start_index, 0, path) else: _LOGGER.debug("No valid version header found in stream, continuing anyways...") # Seek back to the original position to avoid parsing at EOF stream_seek(stream, current_pos, 0, path) return super()._parse(stream, context, path) def _build(self, obj, stream, context, path): prefixed = context.search("prefixed") if not prefixed: return super()._build(obj, stream, context, path) offset = stream_tell(stream, path) stream_seek(stream, offset + 4, 0, path) super()._build(obj, stream, context, path) new_offset = stream_tell(stream, path) subcon1 = Bytes(4) stream_seek(stream, offset, 0, path) subcon1.build_stream(new_offset - offset - subcon1.sizeof(**context), stream, **context) stream_seek(stream, new_offset + 4, 0, path) return obj _Message = RawCopy( Struct( "version" / Bytes(3), "seq" / Int32ub, "random" / Int32ub, "timestamp" / Int32ub, "protocol" / Int16ub, "payload" / EncryptionAdapter( lambda ctx: Utils.md5( Utils.encode_timestamp(ctx.timestamp) + Utils.ensure_bytes(ctx.search("local_key")) + SALT ), ), ) ) _Messages = Struct( "messages" / GreedyRange( PrefixedStruct( "message" / _Message, "checksum" / OptionalChecksum(Optional(Int32ub), Utils.crc, lambda ctx: ctx.message.data), ) ), "remaining" / Optional(GreedyBytes), ) class _Parser: def __init__(self, con: Construct, required_local_key: bool): self.con = con self.required_local_key = required_local_key def parse( self, data: bytes, local_key: str | None = None, connect_nonce: int | None = None, ack_nonce: int | None = None ) -> tuple[list[RoborockMessage], bytes]: if self.required_local_key and local_key is None: raise RoborockException("Local key is required") parsed = self.con.parse(data, local_key=local_key, connect_nonce=connect_nonce, ack_nonce=ack_nonce) parsed_messages = [Container({"message": parsed.message})] if parsed.get("message") else parsed.messages messages = [] for message in parsed_messages: messages.append( RoborockMessage( version=message.message.value.version, seq=message.message.value.get("seq"), random=message.message.value.get("random"), timestamp=message.message.value.get("timestamp"), protocol=message.message.value.get("protocol"), payload=message.message.value.payload, ) ) remaining = parsed.get("remaining") or b"" return messages, remaining def build( self, roborock_messages: list[RoborockMessage] | RoborockMessage, local_key: str, prefixed: bool = True, connect_nonce: int | None = None, ack_nonce: int | None = None, ) -> bytes: if isinstance(roborock_messages, RoborockMessage): roborock_messages = [roborock_messages] messages = [] for roborock_message in roborock_messages: messages.append( { "message": { "value": { "version": roborock_message.version, "seq": roborock_message.seq, "random": roborock_message.random, "timestamp": roborock_message.timestamp, "protocol": roborock_message.protocol, "payload": roborock_message.payload, } }, } ) return self.con.build( {"messages": [message for message in messages], "remaining": b""}, local_key=local_key, prefixed=prefixed, connect_nonce=connect_nonce, ack_nonce=ack_nonce, ) MessageParser: _Parser = _Parser(_Messages, True) def create_mqtt_params(rriot: RRiot) -> MqttParams: """Return the MQTT parameters for this user.""" url = urlparse(rriot.r.m) if not isinstance(url.hostname, str): raise RoborockException(f"Url parsing '{rriot.r.m}' returned an invalid hostname") if not url.port: raise RoborockException(f"Url parsing '{rriot.r.m}' returned an invalid port") hashed_user = md5hex(rriot.u + ":" + rriot.k)[2:10] hashed_password = md5hex(rriot.s + ":" + rriot.k)[16:] return MqttParams( host=str(url.hostname), port=url.port, tls=(url.scheme == "ssl"), username=hashed_user, password=hashed_password, ) Decoder = Callable[[bytes], list[RoborockMessage]] Encoder = Callable[[RoborockMessage], bytes] def create_mqtt_decoder(local_key: str) -> Decoder: """Create a decoder for MQTT messages.""" def decode(data: bytes) -> list[RoborockMessage]: """Parse the given data into Roborock messages.""" messages, _ = MessageParser.parse(data, local_key) return messages return decode def create_mqtt_encoder(local_key: str) -> Encoder: """Create an encoder for MQTT messages.""" def encode(messages: RoborockMessage) -> bytes: """Build the given Roborock messages into a byte string.""" return MessageParser.build(messages, local_key, prefixed=False) return encode def create_local_decoder(local_key: str, connect_nonce: int | None = None, ack_nonce: int | None = None) -> Decoder: """Create a decoder for local API messages.""" # This buffer is used to accumulate bytes until a complete message can be parsed. # It is defined outside the decode function to maintain state across calls. buffer: bytes = b"" def decode(bytes_data: bytes) -> list[RoborockMessage]: """Parse the given data into Roborock messages.""" nonlocal buffer buffer += bytes_data parsed_messages, remaining = MessageParser.parse( buffer, local_key=local_key, connect_nonce=connect_nonce, ack_nonce=ack_nonce ) if remaining: _LOGGER.debug("Found %d extra bytes: %s", len(remaining), remaining) buffer = remaining return parsed_messages return decode def create_local_encoder(local_key: str, connect_nonce: int | None = None, ack_nonce: int | None = None) -> Encoder: """Create an encoder for local API messages.""" def encode(message: RoborockMessage) -> bytes: """Called when data is sent to the transport.""" return MessageParser.build(message, local_key=local_key, connect_nonce=connect_nonce, ack_nonce=ack_nonce) return encode