From 1dd78165908cd9b924800442e6fe73025d1456d1 Mon Sep 17 00:00:00 2001 From: kokarare1212 Date: Mon, 13 Sep 2021 21:04:50 +0900 Subject: [PATCH] Add Zeroconf Support * still not working --- librespot/__init__.py | 4 +- librespot/core.py | 43 +++++++ librespot/structure.py | 5 + librespot/zeroconf.py | 259 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 librespot/zeroconf.py diff --git a/librespot/__init__.py b/librespot/__init__.py index f942b23..b210b15 100644 --- a/librespot/__init__.py +++ b/librespot/__init__.py @@ -1,5 +1,7 @@ from __future__ import annotations +from librespot.crypto import DiffieHellman from librespot.proto.Keyexchange_pb2 import BuildInfo, Platform, Product, ProductFlags +from librespot.structure import Closeable, Runnable import platform @@ -29,4 +31,4 @@ class Version: return BuildInfo(product=Product.PRODUCT_CLIENT, product_flags=[ProductFlags.PRODUCT_FLAG_NONE], platform=Version.platform(), - version=112800721) \ No newline at end of file + version=112800721) diff --git a/librespot/core.py b/librespot/core.py index c86a7c3..4fd8afc 100644 --- a/librespot/core.py +++ b/librespot/core.py @@ -1,6 +1,8 @@ from __future__ import annotations from Cryptodome import Random +from Cryptodome.Cipher import AES from Cryptodome.Hash import HMAC, SHA1 +from Cryptodome.Protocol.KDF import PBKDF2 from Cryptodome.PublicKey import RSA from Cryptodome.Signature import PKCS1_v1_5 from librespot import util, Version @@ -1092,6 +1094,47 @@ class Session(Closeable, MessageListener, SubListener): class Builder(AbsBuilder): login_credentials: Authentication.LoginCredentials = None + def blob(self, username: str, blob: bytes) -> Session.Builder: + if self.device_id is None: + raise TypeError("You must specify the device ID first.") + self.login_credentials = self.decrypt_blob(self.device_id, username, blob) + return self + + def decrypt_blob(self, device_id: str, username: str, encrypted_blob: bytes) -> Authentication.LoginCredentials: + encrypted_blob = base64.b64decode(encrypted_blob) + sha1 = SHA1.new() + sha1.update(device_id.encode()) + secret = sha1.digest() + base_key = PBKDF2(secret.decode(), username.encode(), 20, 0x100) + aes = AES.new(base_key, AES.MODE_ECB) + decrypted_blob = aes.decrypt(encrypted_blob) + l = len(decrypted_blob) + for i in range(0, l - 0x10): + decrypted_blob[l - i - 1] ^= decrypted_blob[l - i - 0x11] + blob = io.BytesIO(decrypted_blob) + blob.read(1) + le = self.read_blob_int(blob) + blob.read(le) + blob.read(1) + type_int = self.read_blob_int(blob) + type_ = Authentication.AuthenticationType.Name(type_int) + if type_ is None: + raise IOError(TypeError("Unknown AuthenticationType: {}".format(type_int))) + le = self.read_blob_int(blob) + auth_data = blob.read(le) + return Authentication.LoginCredentials( + auth_data=auth_data, + typ=type_, + username=username, + ) + + def read_blob_int(self, buffer: io.BytesIO) -> int: + lo = buffer.read(1) + if (int(lo[0]) & 0x80) == 0: + return int(lo[0]) + hi = buffer.read(1) + return int(lo[0]) & 0x7f | int(hi[0]) << 7 + def stored(self): """ TODO: implement function diff --git a/librespot/structure.py b/librespot/structure.py index a6b2afb..0868c19 100644 --- a/librespot/structure.py +++ b/librespot/structure.py @@ -81,6 +81,11 @@ class RequestListener: raise NotImplementedError +class Runnable: + def run(self): + raise NotImplementedError + + class SubListener: def event(self, resp: MercuryClient.Response) -> None: raise NotImplementedError diff --git a/librespot/zeroconf.py b/librespot/zeroconf.py new file mode 100644 index 0000000..87042e4 --- /dev/null +++ b/librespot/zeroconf.py @@ -0,0 +1,259 @@ +from __future__ import annotations +from Cryptodome.Cipher import AES +from Cryptodome.Hash import HMAC, SHA1 +from librespot import util, Version +from librespot.core import Session +from librespot.crypto import DiffieHellman +from librespot.proto import Connect_pb2 as Connect +from librespot.structure import Closeable, Runnable +import base64 +import concurrent.futures +import copy +import io +import json +import logging +import random +import socket +import threading +import typing +import urllib.parse +import zeroconf + + +class ZeroconfServer(Closeable): + logger = logging.getLogger("Librespot:ZeroconfServer") + service = "_spotify-connect._tcp.local." + __connecting_username: typing.Union[str, None] = None + __connection_lock = threading.Condition() + __default_get_info_fields = { + "status": 101, + "statusString": "OK", + "spotifyError": 0, + "version": "2.7.1", + "libraryVersion": Version.version_name, + "accountReq": "PREMIUM", + "brandDisplayName": "kokarare1212", + "modelDisplayName": "librespot-python", + "voiceSupport": "NO", + "availability": "", + "productID": 0, + "tokenType": "default", + "groupStatus": "NONE", + "resolverVersion": "0", + "scope": "streaming,client-authorization-universal", + } + __default_successful_add_user = { + "status": 101, + "spotifyError": 0, + "statusString": "OK", + } + __eol = b"\r\n" + __max_port = 65536 + __min_port = 1024 + __runner: HttpRunner + __service_info: zeroconf.ServiceInfo + __session: typing.Union[Session, None] + __session_listeners = [] + __zeroconf: zeroconf.Zeroconf + + def __init__(self, inner: Inner, listen_port): + self.__inner = inner + self.__keys = DiffieHellman() + if listen_port == -1: + listen_port = random.randint(self.__min_port + 1, self.__max_port) + self.__runner = ZeroconfServer.HttpRunner(self, listen_port) + threading.Thread(target=self.__runner.run, name="zeroconf-http-server").start() + self.__zeroconf = zeroconf.Zeroconf() + self.__service_info = zeroconf.ServiceInfo( + ZeroconfServer.service, + inner.device_name + "." + ZeroconfServer.service, + listen_port, 0, 0, { + "CPath": "/", + "VERSION": "1.0", + "STACK": "SP", + }, + inner.device_name, + ) + self.__zeroconf.register_service(self.__service_info) + threading.Thread(target=self.__zeroconf.start, name="zeroconf-multicast-dns-server").start() + + def close(self) -> None: + self.__zeroconf.close() + self.__runner.close() + + def handle_add_user(self, __socket: socket.socket, params: dict[str, str], http_version: str) -> None: + username = params.get("userName") + if not username: + logging.error("Missing userName!") + return + blob_str = params.get("blob") + if not blob_str: + logging.error("Missing blob!") + return + client_key_str = params.get("clientKey") + if not client_key_str: + logging.error("Missing clientKey!") + with self.__connection_lock: + if username == self.__connecting_username: + logging.info("{} is already trying to connect.".format(username)) + __socket.send(http_version.encode()) + __socket.send(b" 403 Forbidden") + __socket.send(self.__eol) + __socket.send(self.__eol) + return + shared_key = util.int_to_bytes(self.__keys.compute_shared_key(base64.b64decode(client_key_str.encode()))) + blob_bytes = base64.b64decode(blob_str) + iv = blob_bytes[:16] + encrypted = blob_bytes[16:len(blob_bytes) - 20] + checksum = blob_bytes[len(blob_bytes) - 20:] + sha1 = SHA1.new() + sha1.update(shared_key) + base_key = sha1.digest()[:16] + hmac = HMAC.new(base_key, digestmod=SHA1) + hmac.update(b"checksum") + checksum_key = hmac.digest() + hmac = HMAC.new(base_key, digestmod=SHA1) + hmac.update(b"encryption") + encryption_key = hmac.digest() + hmac = HMAC.new(checksum_key, digestmod=SHA1) + hmac.update(encrypted) + mac = hmac.digest() + if mac != checksum: + logging.error("Mac and checksum don't match!") + __socket.send(http_version.encode()) + __socket.send(b" 400 Bad Request") + __socket.send(self.__eol) + __socket.send(self.__eol) + return + aes = AES.new(encryption_key[:16], AES.MODE_CTR, iv) + decrypted = aes.decrypt(encrypted) + with self.__connection_lock: + self.__connecting_username = username + logging.info("Accepted new user from {}. [deviceId: {}]".format(params.get("deviceName"), self.__inner.device_id)) + response = json.dumps(self.__default_successful_add_user) + __socket.send(http_version.encode()) + __socket.send(b" 200 OK") + __socket.send(self.__eol) + __socket.send(b"Content-Length: ") + __socket.send(str(len(response)).encode()) + __socket.send(self.__eol) + __socket.send(self.__eol) + __socket.send(response.encode()) + self.__session = Session.Builder(self.__inner.conf) \ + .set_device_id(self.__inner.device_id) \ + .set_device_name(self.__inner.device_name) \ + .set_device_type(self.__inner.device_type) \ + .set_preferred_locale(self.__inner.preferred_locale) \ + .blob(username, decrypted) \ + .create() + with self.__connection_lock: + self.__connecting_username = None + + def handle_get_info(self, __socket: socket.socket, http_version: str) -> None: + info = copy.deepcopy(self.__default_get_info_fields) + info["device_id"] = self.__inner.device_id + info["remoteName"] = self.__inner.device_name + info["publicKey"] = base64.b64encode(self.__keys.public_key_bytes()).decode() + info["deviceType"] = Connect.DeviceType.Name(self.__inner.device_type) + with self.__connection_lock: + info["activeUser"] = self.__connecting_username if self.__connecting_username is not None else self.__session.username() if self.has_valid_session() else "" + + def has_valid_session(self) -> bool: + valid = self.__session and self.__session.is_valid() + if not valid: + self.__session = None + return valid + + def parse_path(self, path: str) -> dict[str, str]: + url = "http://host" + path + parsed = {} + map = urllib.parse.parse_qs(urllib.parse.urlparse(url).query) + for key, values in map.items(): + for value in values: + parsed[key] = value + return parsed + + class HttpRunner(Closeable, Runnable): + __should_stop = False + __socket: socket.socket + __worker = concurrent.futures.ThreadPoolExecutor() + __zeroconf_server: ZeroconfServer + + def __init__(self, zeroconf_server: ZeroconfServer, port: int): + self.__socket = socket.socket() + self.__socket.bind((".".join(["0"] * 4), port)) + self.__socket.listen(5) + self.__zeroconf_server = zeroconf_server + + def close(self) -> None: + pass + + def run(self): + while not self.__should_stop: + __socket, address = self.__socket.accept() + + def anonymous(): + self.__handle(__socket) + __socket.close() + self.__worker.submit(anonymous) + + def __handle(self, __socket: socket.socket) -> None: + request = io.BytesIO(__socket.recv(1024 * 1024)) + request_line = request.readline().split(b" ") + if len(request_line) != 3: + logging.warning("Unexpected request line: {}".format(request_line)) + method = request_line[0].decode() + path = request_line[1].decode() + http_version = request_line[2].decode() + headers = {} + while True: + header = request.readline() + if not header: + break + split = header.split(b":") + headers[split[0].decode()] = split[1].strip().decode() + if not self.__zeroconf_server.has_valid_session(): + logging.debug("Handling request: {}, {}, {}, headers: {}".format(method, path, http_version, headers)) + params = {} + if method == "POST": + content_type = headers.get("Content-Type") + if content_type != "application/x-www-form-urlencoded": + logging.error("Bad Content-Type: {}".format(content_type)) + return + content_length_str = headers.get("Content-Length") + if content_length_str is None: + logging.error("Missing Content-Length header!") + return + content_length = int(content_length_str) + body = request.read(content_length).decode() + pairs = body.split("&") + for pair in pairs: + split = pair.split("=") + params[urllib.parse.unquote(split[0])] = urllib.parse.unquote(split[1]) + else: + params = self.__zeroconf_server.parse_path(path) + action = params.get("action") + if action is None: + logging.debug("Request is missing action.") + return + self.handle_request(__socket, http_version, action, params) + + def handle_request(self, __socket: socket.socket, http_version: str, action: str, params: dict[str, str]) -> None: + if action == "addUser": + if params is None: + raise RuntimeError + + + class Inner: + conf: typing.Final[Session.Configuration] + device_name: typing.Final[str] + device_id: typing.Final[str] + device_type: typing.Final[Connect.DeviceType] + preferred_locale: typing.Final[str] + + def __init__(self, device_type: Connect.DeviceType, device_name: str, device_id: str, preferred_locale: str, conf: Session.Configuration): + self.conf = conf + self.device_name = device_name + self.device_id = util.random_hex_string(40).lower() if device_id else device_id + self.device_type = device_type + self.preferred_locale = preferred_locale