├── .github └── workflows │ └── build.yml ├── .gitignore ├── README.md ├── requirements.txt ├── setup.py ├── tests ├── conftest.py ├── data │ ├── .keep │ ├── appconfig_no_tokens.json │ └── rtp_connection_probing.bin ├── test_appconfig.py ├── test_crypto.py └── test_teredo.py └── xcloud ├── __init__.py ├── auth ├── __init__.py ├── constants.py ├── filetimes.py ├── models.py ├── request_signer.py ├── signed_session.py └── xal_auth.py ├── common.py ├── ice.py ├── protocol ├── __init__.py ├── ipv6.py ├── packets.py ├── srtp_crypto.py ├── teredo.py └── utils.py ├── scripts ├── __init__.py ├── client.py └── pcap_reader.py ├── smartglass_api.py ├── smartglass_models.py ├── streaming_models.py ├── xcloud_api.py ├── xcloud_models.py └── xhomestreaming_api.py /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: ['push', 'pull_request'] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ['3.7', '3.8', '3.9'] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | pip install -U pip wheel 22 | pip install -U -r requirements.txt 23 | pip install . 24 | - name: Lint with flake8 25 | run: | 26 | # stop the build if there are Python syntax errors or undefined names 27 | flake8 xcloud --count --select=E9,F63,F7,F82 --show-source --statistics 28 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 29 | flake8 xcloud --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 30 | - name: Test with pytest 31 | run: | 32 | pytest -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | *.tar.gz 4 | .DS_Store 5 | 6 | .vscode/ 7 | .idea/ 8 | venv/ 9 | .venv/ 10 | .env/ 11 | 12 | appconfig* 13 | 14 | # Python build artifacts 15 | .eggs/ 16 | *.egg-info/ 17 | build/ 18 | dist/ 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # XCloud, XHome and SmartGlass 2 | 3 | ## Status 4 | 5 | Work-in-progress research codebase 6 | 7 | ## Preparation 8 | 9 | As usual, create a python venv 10 | 11 | ```sh 12 | python -m venv venv 13 | source venv/bin/activate 14 | ``` 15 | 16 | And install the dependencies 17 | 18 | ```sh 19 | pip install -r requirements.txt 20 | pip install . 21 | ``` 22 | 23 | ## Usage 24 | 25 | You can invoke 3 different parts of the script 26 | 27 | ```sh 28 | xcloud-client smartglass 29 | xcloud-client xhome 30 | xcloud-client xcloud 31 | ``` 32 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ecdsa 2 | ms_cv 3 | pydantic 4 | httpx 5 | aiortc 6 | construct 7 | dpkt 8 | hexdump 9 | 10 | wheel 11 | flake8 12 | pytest-runner 13 | pytest -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | 5 | setup( 6 | name='xcloud', 7 | version='0.1.0', 8 | description='XCloud Gamestreaming library for python', 9 | author='tuxuser', 10 | author_email='noreply@openxbox.org', 11 | url='https://github.com/OpenXbox/xcloud-python', 12 | packages=find_packages(), 13 | entry_points={ 14 | 'console_scripts': [ 15 | 'xcloud-pcap-reader=xcloud.scripts.pcap_reader:main', 16 | 'xcloud-client=xcloud.scripts.client:main' 17 | ] 18 | }, 19 | install_requires=[ 20 | "ecdsa", 21 | "ms_cv", 22 | "pydantic", 23 | "httpx", 24 | "aiortc", 25 | "construct", 26 | "dpkt" 27 | ], 28 | setup_requires=[ 29 | "wheel", 30 | "pytest-runner" 31 | ], 32 | tests_require=[ 33 | "flake8", 34 | "pytest" 35 | ] 36 | ) 37 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Tuple 2 | import os 3 | import pytest 4 | from binascii import unhexlify 5 | 6 | from xcloud.protocol import srtp_crypto 7 | 8 | @pytest.fixture(scope='session') 9 | def test_data() -> Dict[str, bytes]: 10 | data = {} 11 | data_path = os.path.join(os.path.dirname(__file__), 'data') 12 | for f in os.listdir(data_path): 13 | with open(os.path.join(data_path, f), 'rb') as fh: 14 | data[f] = fh.read() 15 | 16 | return data 17 | 18 | @pytest.fixture(scope='session') 19 | def teredo_packet() -> bytes: 20 | """ 21 | Teredo IPv6 over UDP tunneling 22 | Internet Protocol Version 6, Src: 2001:0:338c:24f4:1c38:f3fd:d2f3:c93d, Dst: 2001:0:338c:24f4:43b:30e3:d2f3:c93d 23 | 0110 .... = Version: 6 24 | .... 0000 0000 .... .... .... .... .... = Traffic Class: 0x00 (DSCP: CS0, ECN: Not-ECT) 25 | .... 0000 00.. .... .... .... .... .... = Differentiated Services Codepoint: Default (0) 26 | .... .... ..00 .... .... .... .... .... = Explicit Congestion Notification: Not ECN-Capable Transport (0) 27 | .... .... .... 0000 0000 0000 0000 0000 = Flow Label: 0x00000 28 | Payload Length: 0 29 | Next Header: No Next Header for IPv6 (59) 30 | Hop Limit: 21 31 | Source Address: 2001:0:338c:24f4:1c38:f3fd:d2f3:c93d 32 | Destination Address: 2001:0:338c:24f4:43b:30e3:d2f3:c93d 33 | [Source Teredo Server IPv4: 51.140.36.244] 34 | [Source Teredo Port: 3074] 35 | [Source Teredo Client IPv4: 45.12.54.194] 36 | [Destination Teredo Server IPv4: 51.140.36.244] 37 | [Destination Teredo Port: 53020] 38 | [Destination Teredo Client IPv4: 45.12.54.194] 39 | """ 40 | return unhexlify( 41 | '6000000000003b1520010000338c24f41c38f3fdd2f3c93d20010000' 42 | '338c24f4043b30e3d2f3c93d01049eb8960803080000c0a889db0c02' 43 | ) 44 | 45 | @pytest.fixture(scope='session') 46 | def session_id() -> str: 47 | return 'ED309CA5-F87C-439D-A429-63F417B552FA' 48 | 49 | @pytest.fixture(scope='session') 50 | def ice_credentials_client() -> Tuple[str, str]: 51 | return ('m99KewV+44E=', 'AneALie0L4P2tpvbh76nremwgQrT12/R3UYTG5VmUJ8=') 52 | 53 | @pytest.fixture(scope='session') 54 | def ice_credentials_host() -> Tuple[str, str]: 55 | return ('5yUsZtOzQ+w=', 'bWpvx/cXTk3/IeadJHO4T19W/OZopsbn0MwTAZqZu8w=') 56 | 57 | @pytest.fixture(scope='session') 58 | def srtp_key() -> str: 59 | return 'RdHzuLLVGuO1aHILIEVJ1UzR7RWVioepmpy+9SRf' 60 | 61 | @pytest.fixture(scope='session') 62 | def crypto_context(srtp_key: str) -> srtp_crypto.SrtpContext: 63 | return srtp_crypto.SrtpContext.from_base64(srtp_key) -------------------------------------------------------------------------------- /tests/data/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xcloud-python/01122a3b9d0579739b0335311b8c25a40dbcb371/tests/data/.keep -------------------------------------------------------------------------------- /tests/data/appconfig_no_tokens.json: -------------------------------------------------------------------------------- 1 | { 2 | "ClientUUID": "78af29d1-7572-4861-9ce2-1cd99830b9e7", 3 | "XalParameters": { 4 | "UserAgent": "XAL iOS 2020.07.20200714.000", 5 | "AppId": "000000004415494b", 6 | "DeviceType": "iOS", 7 | "ClientVersion": "14.0.1", 8 | "TitleId": "177887386", 9 | "RedirectUri": "ms-xal-000000004415494b://auth", 10 | "QueryDisplay": "ios_phone" 11 | }, 12 | "WindowsLiveTokens": null, 13 | "Authorization": null, 14 | "SigningKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEICQAlg6DMIbN8tugevQ0s50BbDLzvQ9DVKOw3BUKrWi1oAoGCCqGSM49\nAwEHoUQDQgAEHTLC5OYTHLLRYAM0yCcacB6f79DlcXvhJjBLwKJYFPDQ/Ab+D9DY\nahRqpFyqUGDmMnQxImptfr6tI9NJ7g6hjg==\n-----END EC PRIVATE KEY-----\n" 15 | } -------------------------------------------------------------------------------- /tests/data/rtp_connection_probing.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xcloud-python/01122a3b9d0579739b0335311b8c25a40dbcb371/tests/data/rtp_connection_probing.bin -------------------------------------------------------------------------------- /tests/test_appconfig.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import json 3 | from xcloud.common import AppConfiguration 4 | 5 | def test_appconfig(test_data: dict): 6 | appconfig = test_data["appconfig_no_tokens.json"].decode('utf-8') 7 | appconfig = json.loads(appconfig) 8 | 9 | config = AppConfiguration(**appconfig) 10 | 11 | assert config.SigningKey.startswith("-----BEGIN EC PRIVATE KEY-----\nMH") 12 | assert config.WindowsLiveTokens is None 13 | assert config.XalParameters is not None 14 | assert config.ClientUUID == uuid.UUID("78af29d1-7572-4861-9ce2-1cd99830b9e7") 15 | assert config.XalParameters.AppId == "000000004415494b" -------------------------------------------------------------------------------- /tests/test_crypto.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import binascii 3 | from xcloud.protocol import srtp_crypto 4 | 5 | def test_decrypt(test_data: dict, crypto_context: srtp_crypto.SrtpContext): 6 | rtp_packet_raw = test_data['rtp_connection_probing.bin'] 7 | 8 | plaintext = crypto_context.decrypt_packet(rtp_packet_raw) 9 | with pytest.raises(Exception): 10 | # Skip 1 byte of "additional data" to ensure invalid data 11 | crypto_context.decrypt(rtp_packet_raw[:-1]) 12 | 13 | assert plaintext is not None 14 | 15 | def test_init_master_keys(srtp_key: str): 16 | from_base64 = srtp_crypto.SrtpMasterKeys.from_base64(srtp_key) 17 | null_keys = srtp_crypto.SrtpMasterKeys.null_keys() 18 | dummy_keys = srtp_crypto.SrtpMasterKeys.dummy_keys() 19 | 20 | assert len(from_base64.master_key) == 0x10 21 | assert len(from_base64.master_salt) == 0x0E 22 | 23 | assert null_keys is not None 24 | assert dummy_keys is not None 25 | 26 | def test_derive_session_keys(srtp_key: str): 27 | session_keys = srtp_crypto.SrtpContext.from_base64(srtp_key).session_keys 28 | 29 | assert binascii.hexlify(session_keys.crypt_key) == b'45eaf77f1262638cf5d3ad0db5838d1d' 30 | assert binascii.hexlify(session_keys.auth_key) == b'd03d6382e1fec9480feb65e603c81e48' 31 | assert binascii.hexlify(session_keys.salt_key) == b'dad2a3c84f32ff7dbca6802ea223' 32 | -------------------------------------------------------------------------------- /tests/test_teredo.py: -------------------------------------------------------------------------------- 1 | from binascii import unhexlify 2 | from xcloud.protocol import teredo 3 | 4 | def test_teredo_convert(teredo_packet: bytes): 5 | parsed = teredo.TeredoPacket.parse(teredo_packet) 6 | 7 | # Ipv6 8 | assert parsed.ipv6.version == 6 9 | assert parsed.ipv6.traffic_cls == 0x00 10 | assert parsed.ipv6.flow_label == 0x00 11 | assert parsed.ipv6.payload_len == 0x00 12 | assert parsed.ipv6.next_header == 59 # No next header 13 | assert parsed.ipv6.hop_limit == 21 14 | assert parsed.ipv6.src == unhexlify('20010000338c24f41c38f3fdd2f3c93d') 15 | assert parsed.ipv6.dst == unhexlify('20010000338c24f4043b30e3d2f3c93d') 16 | 17 | # Teredo source 18 | assert parsed.src_teredo.udp_port == 3074 19 | assert parsed.src_teredo.flags == 0x1c38 20 | assert str(parsed.src_teredo.teredo_server_ipv4) == '51.140.36.244' 21 | assert str(parsed.src_teredo.client_pub_ipv4) == '45.12.54.194' 22 | 23 | # Teredo destination 24 | assert parsed.dst_teredo.udp_port == 53020 25 | assert parsed.dst_teredo.flags == 0x43b 26 | assert str(parsed.dst_teredo.teredo_server_ipv4) == '51.140.36.244' 27 | assert str(parsed.dst_teredo.client_pub_ipv4) == '45.12.54.194' 28 | 29 | 30 | -------------------------------------------------------------------------------- /xcloud/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Init 3 | """ 4 | -------------------------------------------------------------------------------- /xcloud/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xcloud-python/01122a3b9d0579739b0335311b8c25a40dbcb371/xcloud/auth/__init__.py -------------------------------------------------------------------------------- /xcloud/auth/constants.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class XalUserAgents(str, Enum): 5 | Android_GamestreamingPreview = 'XAL Android 2019.07.20190801.001' 6 | Android_XboxBetaApp = 'XAL Android 2020.07.20200714.000' 7 | Android_GamePassBeta = 'XAL Android 2020.07.20200714.000' 8 | iOS_XboxBetaApp = 'XAL iOS 2020.07.20200714.000' 9 | 10 | 11 | class XalAppId(str, Enum): 12 | Android_GamestreamingPreview = '000000004825e41d' 13 | XboxGamePassBeta = '000000004c20a908' 14 | XboxBetaApp = '000000004415494b' 15 | 16 | 17 | class XalRedirectUri(str, Enum): 18 | OAuth20_Desktop = 'https://login.live.com/oauth20_desktop.srf' 19 | XboxGamePassBeta = 'ms-xal-public-beta-000000004c20a908://auth' 20 | XboxBetaApp = 'ms-xal-000000004415494b://auth' 21 | 22 | 23 | class XalTitleId(str, Enum): 24 | Android_GamestreamingPreview = '' 25 | XboxGamePassBeta = '1016898439' 26 | XboxBetaApp = '177887386' 27 | 28 | 29 | class XalDeviceType(str, Enum): 30 | iOS = 'iOS' 31 | Android = 'Android' 32 | Win32 = 'Win32' 33 | 34 | 35 | class XalQueryDisplay(str, Enum): 36 | Android = 'android_phone' 37 | iOS = 'ios_phone' 38 | 39 | 40 | IOS_XBOXBETA_APP_PARAMS = dict( 41 | UserAgent=XalUserAgents.iOS_XboxBetaApp, 42 | AppId=XalAppId.XboxBetaApp, 43 | DeviceType=XalDeviceType.iOS, 44 | ClientVersion='14.0.1', 45 | TitleId=XalTitleId.XboxBetaApp, 46 | RedirectUri=XalRedirectUri.XboxBetaApp, 47 | QueryDisplay=XalQueryDisplay.iOS 48 | ) 49 | 50 | ANDROID_XBOXBETA_APP_PARAMS = dict( 51 | UserAgent=XalUserAgents.Android_XboxBetaApp, 52 | AppId=XalAppId.XboxBetaApp, 53 | DeviceType=XalDeviceType.Android, 54 | ClientVersion='8.0.0', 55 | TitleId=XalTitleId.XboxBetaApp, 56 | RedirectUri=XalRedirectUri.XboxBetaApp, 57 | QueryDisplay=XalQueryDisplay.Android 58 | ) 59 | 60 | ANDROID_GAMEPASS_BETA_PARAMS = dict( 61 | UserAgent=XalUserAgents.Android_GamePassBeta, 62 | AppId=XalAppId.XboxGamePassBeta, 63 | DeviceType=XalDeviceType.Android, 64 | ClientVersion='8.0.0', 65 | TitleId=XalTitleId.XboxGamePassBeta, 66 | RedirectUri=XalRedirectUri.XboxGamePassBeta, 67 | QueryDisplay=XalQueryDisplay.Android 68 | ) 69 | -------------------------------------------------------------------------------- /xcloud/auth/filetimes.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2009, David Buxton 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are 6 | # met: 7 | # 8 | # * Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # * Redistributions in binary form must reproduce the above copyright 11 | # notice, this list of conditions and the following disclaimer in the 12 | # documentation and/or other materials provided with the distribution. 13 | # 14 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 15 | # IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 16 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 17 | # PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 18 | # HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 19 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 20 | # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 21 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 22 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 23 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | """Tools to convert between Python datetime instances and Microsoft times. 26 | """ 27 | from datetime import datetime, timedelta, tzinfo 28 | from calendar import timegm 29 | 30 | 31 | # http://support.microsoft.com/kb/167296 32 | # How To Convert a UNIX time_t to a Win32 FILETIME or SYSTEMTIME 33 | EPOCH_AS_FILETIME = 116444736000000000 # January 1, 1970 as MS file time 34 | HUNDREDS_OF_NANOSECONDS = 10000000 35 | 36 | 37 | ZERO = timedelta(0) 38 | HOUR = timedelta(hours=1) 39 | 40 | 41 | class UTC(tzinfo): 42 | """UTC""" 43 | def utcoffset(self, dt): 44 | return ZERO 45 | 46 | def tzname(self, dt): 47 | return "UTC" 48 | 49 | def dst(self, dt): 50 | return ZERO 51 | 52 | 53 | utc = UTC() 54 | 55 | 56 | def dt_to_filetime(dt): 57 | """Converts a datetime to Microsoft filetime format. If the object is 58 | time zone-naive, it is forced to UTC before conversion. 59 | 60 | >>> "%.0f" % dt_to_filetime(datetime(2009, 7, 25, 23, 0)) 61 | '128930364000000000' 62 | 63 | >>> "%.0f" % dt_to_filetime(datetime(1970, 1, 1, 0, 0, tzinfo=utc)) 64 | '116444736000000000' 65 | 66 | >>> "%.0f" % dt_to_filetime(datetime(1970, 1, 1, 0, 0)) 67 | '116444736000000000' 68 | 69 | >>> dt_to_filetime(datetime(2009, 7, 25, 23, 0, 0, 100)) 70 | 128930364000001000 71 | """ 72 | if (dt.tzinfo is None) or (dt.tzinfo.utcoffset(dt) is None): 73 | dt = dt.replace(tzinfo=utc) 74 | ft = EPOCH_AS_FILETIME + (timegm(dt.timetuple()) * HUNDREDS_OF_NANOSECONDS) 75 | return ft + (dt.microsecond * 10) 76 | 77 | 78 | def filetime_to_dt(ft): 79 | """Converts a Microsoft filetime number to a Python datetime. The new 80 | datetime object is time zone-naive but is equivalent to tzinfo=utc. 81 | 82 | >>> filetime_to_dt(116444736000000000) 83 | datetime.datetime(1970, 1, 1, 0, 0) 84 | 85 | >>> filetime_to_dt(128930364000000000) 86 | datetime.datetime(2009, 7, 25, 23, 0) 87 | 88 | >>> filetime_to_dt(128930364000001000) 89 | datetime.datetime(2009, 7, 25, 23, 0, 0, 100) 90 | """ 91 | # Get seconds and remainder in terms of Unix epoch 92 | (s, ns100) = divmod(ft - EPOCH_AS_FILETIME, HUNDREDS_OF_NANOSECONDS) 93 | # Convert to datetime object 94 | dt = datetime.utcfromtimestamp(s) 95 | # Add remainder in as microseconds. Python 3.2 requires an integer 96 | dt = dt.replace(microsecond=(ns100 // 10)) 97 | return dt 98 | -------------------------------------------------------------------------------- /xcloud/auth/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from typing import Dict, List, Optional 3 | 4 | 5 | class XADDisplayClaims(BaseModel): 6 | # {"xdi": {"did": "F.....", "dcs": "0"}} 7 | xdi: Dict[str, str] 8 | 9 | 10 | class XADResponse(BaseModel): 11 | IssueInstant: str 12 | NotAfter: str 13 | Token: str 14 | DisplayClaims: XADDisplayClaims 15 | 16 | 17 | class XATDisplayClaims(BaseModel): 18 | xti: Dict[str, str] 19 | 20 | 21 | class XATResponse(BaseModel): 22 | IssueInstant: str 23 | NotAfter: str 24 | Token: str 25 | DisplayClaims: XATDisplayClaims 26 | 27 | 28 | class XAUDisplayClaims(BaseModel): 29 | xui: List[Dict[str, str]] 30 | 31 | 32 | class XAUResponse(BaseModel): 33 | IssueInstant: str 34 | NotAfter: str 35 | Token: str 36 | DisplayClaims: XAUDisplayClaims 37 | 38 | 39 | class XSTSDisplayClaims(BaseModel): 40 | xui: List[Dict[str, str]] 41 | 42 | 43 | class XSTSResponse(BaseModel): 44 | IssueInstant: str 45 | NotAfter: str 46 | Token: str 47 | DisplayClaims: XSTSDisplayClaims 48 | 49 | @property 50 | def Userhash(self) -> str: 51 | return self.DisplayClaims.xui[0]["uhs"] 52 | 53 | @property 54 | def authorization_header_value(self) -> str: 55 | return f'XBL3.0 x={self.Userhash};{self.Token}' 56 | 57 | 58 | class SisuAuthenticationResponse(BaseModel): 59 | MsaOauthRedirect: str 60 | MsaRequestParameters: Dict[str, str] 61 | 62 | 63 | class SisuAuthorizationResponse(BaseModel): 64 | DeviceToken: str 65 | TitleToken: XATResponse 66 | UserToken: XAUResponse 67 | AuthorizationToken: XSTSResponse 68 | WebPage: str 69 | Sandbox: str 70 | UseModernGamertag: Optional[bool] 71 | 72 | 73 | class WindowsLiveTokenResponse(BaseModel): 74 | token_type: str 75 | expires_in: int 76 | scope: str 77 | access_token: str 78 | refresh_token: str 79 | user_id: str 80 | 81 | 82 | class XCloudTokenResponse(BaseModel): 83 | lpt: str 84 | refresh_token: str 85 | user_id: str 86 | 87 | 88 | class XalClientParameters(BaseModel): 89 | UserAgent: str 90 | AppId: str 91 | DeviceType: str 92 | ClientVersion: str 93 | TitleId: str 94 | RedirectUri: str 95 | QueryDisplay: str 96 | -------------------------------------------------------------------------------- /xcloud/auth/request_signer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Request Signer 3 | 4 | Employed for generating the "Signature" header in authentication requests. 5 | """ 6 | import base64 7 | import hashlib 8 | import struct 9 | from datetime import datetime 10 | from ecdsa import SigningKey, NIST256p 11 | 12 | from . import filetimes 13 | 14 | 15 | class RequestSigner: 16 | 17 | # Version 1 18 | SIGNATURE_VERSION = b'\x00\x00\x00\x01' 19 | 20 | def __init__(self, signing_key=None): 21 | self.signing_key = signing_key or SigningKey.generate(curve=NIST256p) 22 | 23 | pk_point = self.signing_key.verifying_key.pubkey.point 24 | self.proof_field = { 25 | 'use': 'sig', 26 | 'alg': 'ES256', 27 | 'kty': 'EC', 28 | 'crv': 'P-256', 29 | 'x': self.__encode_ec_coord(pk_point.x()), 30 | 'y': self.__encode_ec_coord(pk_point.y()) 31 | } 32 | 33 | def export_signing_key(self) -> str: 34 | return self.signing_key.to_pem().decode() 35 | 36 | @staticmethod 37 | def import_signing_key(signing_key: str) -> SigningKey: 38 | return SigningKey.from_pem(signing_key) 39 | 40 | @classmethod 41 | def from_pem(cls, pem_string: str): 42 | request_signer = RequestSigner.import_signing_key(pem_string) 43 | return cls(request_signer) 44 | 45 | def get_timestamp_buffer(self, dt: datetime) -> bytes: 46 | """ 47 | Get usable buffer from datetime 48 | 49 | dt: Input datetime 50 | 51 | Returns: 52 | bytes: FILETIME buffer (network order/big endian) 53 | """ 54 | filetime = filetimes.dt_to_filetime(dt) 55 | return struct.pack('!Q', filetime) 56 | 57 | def sign( 58 | self, 59 | method: str, 60 | path_and_query: str, 61 | body: bytes = b'', 62 | authorization: str = '', 63 | timestamp: datetime = None 64 | ) -> str: 65 | if timestamp is None: 66 | timestamp = datetime.utcnow() 67 | 68 | signature = self._sign_raw(method, path_and_query, body, authorization, timestamp) 69 | return base64.b64encode(signature).decode('ascii') 70 | 71 | def _sign_raw( 72 | self, 73 | method: str, 74 | path_and_query: str, 75 | body: bytes, authorization: str, 76 | timestamp: datetime 77 | ) -> bytes: 78 | # Calculate hash 79 | ts_bytes = self.get_timestamp_buffer(timestamp) 80 | hash = self._hash(method, path_and_query, body, authorization, ts_bytes) 81 | 82 | # Sign the hash 83 | signature = self.signing_key.sign_digest_deterministic(hash) 84 | 85 | # Return signature version + timestamp encoded + signature 86 | return self.SIGNATURE_VERSION + ts_bytes + signature 87 | 88 | @staticmethod 89 | def _hash( 90 | method: str, 91 | path_and_query: str, 92 | body: bytes, 93 | authorization: str, 94 | ts_bytes: bytes 95 | ) -> bytes: 96 | hash = hashlib.sha256() 97 | 98 | # Version + null 99 | hash.update(RequestSigner.SIGNATURE_VERSION) 100 | hash.update(b'\x00') 101 | 102 | # Timestamp + null 103 | hash.update(ts_bytes) 104 | hash.update(b'\x00') 105 | 106 | # Method (in uppercase) + null 107 | hash.update(method.upper().encode('ascii')) 108 | hash.update(b'\x00') 109 | 110 | # Path and query 111 | hash.update(path_and_query.encode('ascii')) 112 | hash.update(b'\x00') 113 | 114 | # Authorization (even if an empty string) 115 | hash.update(authorization.encode('ascii')) 116 | hash.update(b'\x00') 117 | 118 | # Body 119 | hash.update(body) 120 | hash.update(b'\x00') 121 | 122 | return hash.digest() 123 | 124 | @staticmethod 125 | def __base64_escaped(binary: bytes) -> str: 126 | encoded = base64.b64encode(binary).decode('ascii') 127 | encoded = encoded.rstrip('=') 128 | encoded = encoded.replace('+', '-') 129 | encoded = encoded.replace('/', '_') 130 | return encoded 131 | 132 | @staticmethod 133 | def __encode_ec_coord(coord) -> str: 134 | return RequestSigner.__base64_escaped(coord.to_bytes(32, 'big')) 135 | -------------------------------------------------------------------------------- /xcloud/auth/signed_session.py: -------------------------------------------------------------------------------- 1 | """ 2 | Signed Session 3 | 4 | A wrapper around httpx' AsyncClient which transparently calculates the "Signature" header. 5 | """ 6 | 7 | import httpx 8 | from .request_signer import RequestSigner 9 | 10 | 11 | class SignedSession(httpx.AsyncClient): 12 | def __init__(self, request_signer=None): 13 | super().__init__() 14 | self.request_signer = request_signer or RequestSigner() 15 | 16 | @classmethod 17 | def from_pem_signing_key(cls, pem_string: str): 18 | request_signer = RequestSigner.from_pem(pem_string) 19 | return cls(request_signer) 20 | 21 | def _prepare_signed_request( 22 | self, 23 | request: httpx.Request 24 | ) -> httpx.Request: 25 | path_and_query = request.url.raw_path.decode() 26 | authorization = request.headers.get('Authorization', '') 27 | 28 | body = b'' 29 | for byte in request.stream: 30 | body += byte 31 | 32 | signature = self.request_signer.sign( 33 | method=request.method, 34 | path_and_query=path_and_query, 35 | body=body, 36 | authorization=authorization 37 | ) 38 | 39 | request.headers['Signature'] = signature 40 | return request 41 | 42 | async def send_signed(self, request: httpx.Request) -> httpx.Response: 43 | """ 44 | Shorthand for prepare signed + send 45 | """ 46 | prepared = self._prepare_signed_request(request) 47 | return await self.send(prepared) 48 | -------------------------------------------------------------------------------- /xcloud/auth/xal_auth.py: -------------------------------------------------------------------------------- 1 | """" 2 | SISU Authentication 3 | """ 4 | import os 5 | import logging 6 | import uuid 7 | import base64 8 | import hashlib 9 | import json 10 | import httpx 11 | from urllib import parse 12 | from typing import Optional, Tuple 13 | 14 | import ms_cv 15 | from .signed_session import SignedSession 16 | from .request_signer import RequestSigner 17 | from .models import SisuAuthenticationResponse, SisuAuthorizationResponse, \ 18 | WindowsLiveTokenResponse, XADResponse, XalClientParameters, XSTSResponse, \ 19 | XCloudTokenResponse 20 | from .constants import XalDeviceType 21 | 22 | log = logging.getLogger('auth') 23 | 24 | 25 | class XalAuthenticator(object): 26 | def __init__( 27 | self, 28 | client_id: uuid.UUID, 29 | xal_client: XalClientParameters, 30 | request_signer: RequestSigner = None 31 | ): 32 | self.client_id = client_id 33 | self.client_data: XalClientParameters = xal_client 34 | 35 | self.session = SignedSession(request_signer) 36 | self.session.headers.update({ 37 | 'User-Agent': self.client_data.UserAgent 38 | }) 39 | 40 | self.cv = ms_cv.CorrelationVector() 41 | 42 | self.windows_live_tokens: Optional[WindowsLiveTokenResponse] = None 43 | self.sisu_authorization_tokens: Optional[SisuAuthorizationResponse] = None 44 | self._endpoints = None 45 | 46 | async def fetch_endpoints(self) -> dict: 47 | if not self._endpoints: 48 | self._endpoints = (await self._get_endpoints()).json() 49 | return self._endpoints 50 | 51 | @staticmethod 52 | def get_random_bytes(length) -> bytes: 53 | return os.urandom(length) 54 | 55 | @staticmethod 56 | def generate_code_verifier() -> str: 57 | # https://tools.ietf.org/html/rfc7636 58 | code_verifier = base64.urlsafe_b64encode( 59 | XalAuthenticator.get_random_bytes(32) 60 | ).decode().rstrip('=') 61 | assert len(code_verifier) >= 43 and len(code_verifier) <= 128 62 | 63 | return code_verifier 64 | 65 | @staticmethod 66 | def get_code_challenge_from_code_verifier(code_verifier: str) -> str: 67 | code_challenge = hashlib.sha256(code_verifier.encode()).digest() 68 | # Base64 urlsafe encoding WITH stripping trailing '=' 69 | code_challenge = base64.urlsafe_b64encode( 70 | code_challenge 71 | ).decode().rstrip('=') 72 | 73 | return code_challenge 74 | 75 | @staticmethod 76 | def generate_random_state() -> str: 77 | state = str(uuid.uuid4()).encode() 78 | # Base64 urlsafe encoding WITHOUT stripping trailing '=' 79 | return base64.b64encode(state).decode() 80 | 81 | async def _get_endpoints(self) -> httpx.Response: 82 | url = 'https://title.mgt.xboxlive.com/titles/default/endpoints' 83 | headers = { 84 | 'x-xbl-contract-version': '1' 85 | } 86 | params = { 87 | 'type': 1 88 | } 89 | return await self.session.get(url, headers=headers, params=params) 90 | 91 | async def _get_device_token(self) -> httpx.Response: 92 | # Proof of posession: https://tools.ietf.org/html/rfc7800 93 | 94 | client_uuid = str(self.client_id) 95 | 96 | if self.client_data.DeviceType == XalDeviceType.Android: 97 | # {decf45e4-945d-4379-b708-d4ee92c12d99} 98 | client_uuid = "{%s}" % client_uuid 99 | else: 100 | # iOS 101 | # DECF45E4-945D-4379-B708-D4EE92C12D99 102 | client_uuid = client_uuid.upper() 103 | 104 | url = 'https://device.auth.xboxlive.com/device/authenticate' 105 | headers = { 106 | 'x-xbl-contract-version': '1', 107 | 'MS-CV': self.cv.get_value() 108 | } 109 | post_body = { 110 | 'RelyingParty': 'http://auth.xboxlive.com', 111 | 'TokenType': 'JWT', 112 | 'Properties': { 113 | 'AuthMethod': 'ProofOfPossession', 114 | 'Id': client_uuid, 115 | 'DeviceType': self.client_data.DeviceType, 116 | 'Version': self.client_data.ClientVersion, 117 | 'ProofKey': self.session.request_signer.proof_field 118 | } 119 | } 120 | 121 | request = self.session.build_request('POST', url, headers=headers, json=post_body) 122 | return await self.session.send_signed(request) 123 | 124 | async def _get_title_token( 125 | self, device_token: str, access_token: str 126 | ) -> httpx.Response: 127 | url = "https://title.auth.xboxlive.com/title/authenticate" 128 | headers = {"x-xbl-contract-version": "1", "MS-CV": self.cv.increment()} 129 | post_body = { 130 | "RelyingParty": "http://auth.xboxlive.com", 131 | "TokenType": "JWT", 132 | "Properties": { 133 | "AuthMethod": "RPS", 134 | "DeviceToken": device_token, 135 | "RpsTicket": f"t={access_token}", 136 | "SiteName": "user.auth.xboxlive.com", 137 | }, 138 | } 139 | 140 | request = self.session.build_request( 141 | "POST", url, headers=headers, json=post_body 142 | ) 143 | return await self.session.send_signed(request) 144 | 145 | async def get_title_token2( 146 | self, 147 | device_token: str, 148 | title_id: str = "49312658", 149 | title_version: str = "10.0.10011.16384", 150 | title_build: str = "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", 151 | title_content_id: str = "73DE1908-F71B-4C5C-821E-ED00A426E221", 152 | ) -> httpx.Response: 153 | url = "https://title.auth.xboxlive.com/title/authenticate" 154 | headers = {"x-xbl-contract-version": "1", "MS-CV": self.cv.increment()} 155 | post_body = { 156 | "RelyingParty": "http://auth.xboxlive.com", 157 | "TokenType": "JWT", 158 | "Properties": { 159 | "DeviceToken": device_token, 160 | "TitleAttestation": json.dumps( 161 | { 162 | "Id": title_id, 163 | "Version": title_version, 164 | "Build": title_build, 165 | "ContentId": title_content_id, 166 | } 167 | ), 168 | }, 169 | } 170 | 171 | request = self.session.build_request( 172 | "POST", url, headers=headers, json=post_body 173 | ) 174 | return await self.session.send_signed(request) 175 | 176 | async def _do_sisu_authentication( 177 | self, 178 | device_token_jwt: str, 179 | code_challenge: str, 180 | state: str 181 | ) -> httpx.Response: 182 | url = 'https://sisu.xboxlive.com/authenticate' 183 | headers = { 184 | 'x-xbl-contract-version': '1', 185 | 'MS-CV': self.cv.increment() 186 | } 187 | post_body = { 188 | 'AppId': self.client_data.AppId, 189 | 'TitleId': self.client_data.TitleId, 190 | 'RedirectUri': self.client_data.RedirectUri, 191 | 'DeviceToken': device_token_jwt, 192 | 'Sandbox': 'RETAIL', 193 | 'TokenType': 'code', 194 | 'Offers': [ 195 | 'service::user.auth.xboxlive.com::MBI_SSL' 196 | ], 197 | 'Query': { 198 | 'display': self.client_data.QueryDisplay, 199 | 'code_challenge': code_challenge, 200 | 'code_challenge_method': 'S256', 201 | 'state': state 202 | } 203 | } 204 | 205 | request = self.session.build_request('POST', url, headers=headers, json=post_body) 206 | return await self.session.send_signed(request) 207 | 208 | async def __oauth20_token_endpoint( 209 | self, json_body: dict 210 | ) -> httpx.Response: 211 | url = 'https://login.live.com/oauth20_token.srf' 212 | headers = { 213 | 'MS-CV': self.cv.increment() 214 | } 215 | 216 | # NOTE: No signature necessary 217 | request = self.session.build_request( 218 | 'POST', url, headers=headers, data=json_body 219 | ) 220 | return await self.session.send(request) 221 | 222 | async def _exchange_code_for_token( 223 | self, 224 | authorization_code: str, 225 | code_verifier: str 226 | ) -> httpx.Response: 227 | post_body = { 228 | 'client_id': self.client_data.AppId, 229 | 'code': authorization_code, 230 | 'code_verifier': code_verifier, 231 | 'grant_type': 'authorization_code', 232 | 'redirect_uri': self.client_data.RedirectUri, 233 | 'scope': 'service::user.auth.xboxlive.com::MBI_SSL' 234 | } 235 | 236 | return await self.__oauth20_token_endpoint(post_body) 237 | 238 | async def exchange_refresh_token_for_xcloud_transfer_token( 239 | self, 240 | refresh_token_jwt: str 241 | ) -> XCloudTokenResponse: 242 | post_body = { 243 | 'client_id': self.client_data.AppId, 244 | 'refresh_token': refresh_token_jwt, 245 | 'grant_type': 'refresh_token', 246 | 'scope': 'service::http://Passport.NET/purpose::PURPOSE_XBOX_CLOUD_CONSOLE_TRANSFER_TOKEN' 247 | } 248 | 249 | resp = await self.__oauth20_token_endpoint(post_body) 250 | resp.raise_for_status() 251 | return XCloudTokenResponse.parse_obj(resp.json()) 252 | 253 | async def _refresh_token(self, refresh_token_jwt: str) -> httpx.Response: 254 | post_body = { 255 | 'client_id': self.client_data.AppId, 256 | 'refresh_token': refresh_token_jwt, 257 | 'grant_type': 'refresh_token', 258 | 'redirect_uri': self.client_data.RedirectUri, 259 | 'scope': 'service::user.auth.xboxlive.com::MBI_SSL' 260 | } 261 | 262 | return await self.__oauth20_token_endpoint(post_body) 263 | 264 | async def _do_sisu_authorization( 265 | self, 266 | sisu_session_id: str, 267 | access_token_jwt: str, 268 | device_token_jwt: str 269 | ) -> httpx.Response: 270 | url = 'https://sisu.xboxlive.com/authorize' 271 | headers = { 272 | 'MS-CV': self.cv.increment() 273 | } 274 | post_body = { 275 | 'AccessToken': f't={access_token_jwt}', 276 | 'AppId': self.client_data.AppId, 277 | 'DeviceToken': device_token_jwt, 278 | 'Sandbox': 'RETAIL', 279 | 'SiteName': 'user.auth.xboxlive.com', 280 | 'SessionId': sisu_session_id, 281 | 'ProofKey': self.session.request_signer.proof_field 282 | } 283 | 284 | request = self.session.build_request('POST', url, headers=headers, json=post_body) 285 | return await self.session.send_signed(request) 286 | 287 | async def xsts_authorization( 288 | self, 289 | device_token_jwt: str, 290 | title_token_jwt: str, 291 | user_token_jwt: str, 292 | relying_party: str 293 | ) -> XSTSResponse: 294 | url = 'https://xsts.auth.xboxlive.com/xsts/authorize' 295 | headers = { 296 | 'x-xbl-contract-version': '1', 297 | 'MS-CV': self.cv.increment() 298 | } 299 | post_body = { 300 | 'RelyingParty': relying_party, 301 | 'TokenType': 'JWT', 302 | 'Properties': { 303 | 'SandboxId': 'RETAIL', 304 | 'DeviceToken': device_token_jwt, 305 | 'TitleToken': title_token_jwt, 306 | 'UserTokens': [ 307 | user_token_jwt 308 | ] 309 | } 310 | } 311 | 312 | request = self.session.build_request('POST', url, headers=headers, json=post_body) 313 | resp = await self.session.send_signed(request) 314 | resp.raise_for_status() 315 | return XSTSResponse.parse_obj(resp.json()) 316 | 317 | async def device_auth(self) -> XADResponse: 318 | print('::: DEVICE TOKEN AUTHENTICATION :::') 319 | resp = await self._get_device_token() 320 | assert resp.status_code == 200,\ 321 | f'Invalid response for GET_DEVICE_TOKEN: {resp.status_code}' 322 | 323 | resp = XADResponse.parse_raw(resp.content) 324 | print(f'Device Token: {resp.Token}') 325 | return resp 326 | 327 | async def sisu_authentication( 328 | self, 329 | device_token: str, 330 | code_challenge: str, 331 | state: str 332 | ) -> Tuple[SisuAuthenticationResponse, str]: 333 | print('::: SISU AUTHENTICATION :::') 334 | resp = await self._do_sisu_authentication(device_token, code_challenge, state) 335 | assert resp.status_code == 200,\ 336 | f'Invalid response for DO_SISU_AUTHENTICATION: {resp.status_code}' 337 | 338 | session_id = resp.headers['X-SessionId'] 339 | print('SISU Session Id: {}'.format(session_id)) 340 | 341 | resp = SisuAuthenticationResponse.parse_raw(resp.content) 342 | return resp, session_id 343 | 344 | async def auth_flow(self): 345 | device_token_resp = await self.device_auth() 346 | 347 | code_verifier = self.generate_code_verifier() 348 | code_challenge = self.get_code_challenge_from_code_verifier(code_verifier) 349 | state = self.generate_random_state() 350 | 351 | sisu_authenticate_resp, sisu_session_id = \ 352 | await self.sisu_authentication(device_token_resp.Token, code_challenge, state) 353 | 354 | redirect_uri = input( 355 | (f'Continue auth with the following URL: ' 356 | f'{sisu_authenticate_resp.MsaOauthRedirect}.\n\n' 357 | f'Provide redirect URI:') 358 | ) 359 | 360 | if not redirect_uri.startswith(self.client_data.RedirectUri): 361 | print('Wrong data passed as redirect URI') 362 | return None 363 | 364 | query_params = dict( 365 | parse.parse_qsl(parse.urlsplit(redirect_uri).query) 366 | ) 367 | 368 | resp_authorization_code = query_params['code'] 369 | resp_state = query_params['state'] 370 | 371 | if resp_state != state: 372 | print('Response with non-matching state received') 373 | return None 374 | 375 | tokens = await self._exchange_code_for_token(resp_authorization_code, code_verifier) 376 | assert tokens.status_code == 200,\ 377 | f'Invalid response for EXCHANGE_CODE_FOR_TOKENS: {tokens.status_code}' 378 | tokens = WindowsLiveTokenResponse.parse_raw(tokens.content) 379 | 380 | print('::: SISU AUTHORIZATION :::') 381 | sisu_authorization = await self._do_sisu_authorization( 382 | sisu_session_id, 383 | tokens.access_token, 384 | device_token_resp.Token 385 | ) 386 | assert sisu_authorization.status_code == 200, 'Invalid response for DO_SISU_AUTHORIZATION' 387 | 388 | sisu_authorization_resp = SisuAuthorizationResponse.parse_raw( 389 | sisu_authorization.content 390 | ) 391 | 392 | print('Device Token: {}'.format(sisu_authorization_resp.DeviceToken)) 393 | print('User Token: {}'.format(sisu_authorization_resp.UserToken.Token)) 394 | print('Authorization Token: {}'.format(sisu_authorization_resp.AuthorizationToken.Token)) 395 | print('Userhash: {}'.format(sisu_authorization_resp.AuthorizationToken.Userhash)) 396 | 397 | self.windows_live_tokens = tokens 398 | self.sisu_authorization_tokens = sisu_authorization_resp 399 | -------------------------------------------------------------------------------- /xcloud/common.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel, UUID4 4 | from .auth.models import SisuAuthorizationResponse, WindowsLiveTokenResponse,\ 5 | XalClientParameters 6 | 7 | 8 | class AppConfiguration(BaseModel): 9 | ClientUUID: UUID4 10 | XalParameters: XalClientParameters 11 | WindowsLiveTokens: Optional[WindowsLiveTokenResponse] 12 | Authorization: Optional[SisuAuthorizationResponse] 13 | SigningKey: str 14 | -------------------------------------------------------------------------------- /xcloud/ice.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | 4 | from aiortc import ( 5 | RTCIceGatherer, 6 | RTCIceCandidate, 7 | RTCIceTransport, 8 | RTCIceParameters 9 | ) 10 | 11 | 12 | class ICEHandler: 13 | Full: int = 1 14 | PacingMs: int = 50 15 | Version: int = 1 16 | 17 | def __init__(self): 18 | self._gatherer = RTCIceGatherer() 19 | self._transport = RTCIceTransport(self._gatherer) 20 | 21 | @property 22 | def transport(self) -> RTCIceTransport: 23 | return self._transport 24 | 25 | @staticmethod 26 | def _candidate_to_dict(candidate: RTCIceCandidate) -> dict: 27 | return { 28 | "transportAddress": f"{candidate.ip}:{candidate.port}", 29 | "baseAddress": f"{candidate.ip}:{candidate.port}", 30 | "serverAddress": "", 31 | "ipv6": "0", 32 | "type": "0", 33 | "addressType": "3", # TODO: whats addressType ? 34 | "priority": str(candidate.priority), 35 | "foundation": candidate.foundation, 36 | "transport": candidate.protocol 37 | } 38 | 39 | @staticmethod 40 | def _dict_to_candidate(candidate_node: dict) -> RTCIceCandidate: 41 | host_port_combo: str = candidate_node.get('transportAddress') 42 | candidate_type: str = candidate_node.get('type') 43 | priority: int = int(candidate_node.get('priority')) 44 | foundation: str = candidate_node.get('foundation') 45 | protocol: str = candidate_node.get('transport') 46 | 47 | # TODO: whats component? 48 | component = 0 49 | 50 | host, port = host_port_combo.rsplit(':', maxsplit=1) 51 | 52 | return RTCIceCandidate( 53 | component, foundation, host, port, priority, protocol, 54 | candidate_type 55 | ) 56 | 57 | async def generate_local_config(self) -> dict: 58 | await self._gatherer.gather() 59 | local_candidates = self._gatherer.getLocalCandidates() 60 | local_params = self._gatherer.getLocalParameters() 61 | 62 | ice_config: dict = { 63 | "Full": str(ICEHandler.Full), 64 | "PacingMs": str(ICEHandler.PacingMs), 65 | "Version": str(ICEHandler.Version), 66 | "Username": local_params.usernameFragment, 67 | "Password": local_params.password, 68 | "Candidates": { 69 | "count": str(len(local_candidates)) 70 | } 71 | } 72 | for index, candidate in enumerate(local_candidates): 73 | candidate_node = ICEHandler._candidate_to_dict(candidate) 74 | ice_config['Candidates'].update({ 75 | str(index): candidate_node 76 | }) 77 | 78 | return ice_config 79 | 80 | @staticmethod 81 | def parse_remote_config( 82 | ice_config: dict 83 | ) -> Tuple[List[RTCIceCandidate], RTCIceParameters]: 84 | candidate_nodes: dict = ice_config.get('Candidates') 85 | if not candidate_nodes: 86 | raise Exception( 87 | 'parse_remote_config: Invalid input, no Candidates node found' 88 | ) 89 | 90 | remote_params = RTCIceParameters( 91 | ice_config.get('Username'), 92 | ice_config.get('Password') 93 | ) 94 | 95 | candidates: List[RTCIceCandidate] = [] 96 | candidate_count = int(candidate_nodes.get('count')) 97 | for i in range(candidate_count): 98 | candidate_node: dict = candidate_nodes.get(str(i)) 99 | candidate = ICEHandler._dict_to_candidate(candidate_node) 100 | candidates.append(candidate) 101 | 102 | return candidates, remote_params 103 | -------------------------------------------------------------------------------- /xcloud/protocol/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xcloud-python/01122a3b9d0579739b0335311b8c25a40dbcb371/xcloud/protocol/__init__.py -------------------------------------------------------------------------------- /xcloud/protocol/ipv6.py: -------------------------------------------------------------------------------- 1 | import dpkt 2 | from typing import Any 3 | from enum import Enum 4 | 5 | NO_NEXT_HEADER = 59 6 | 7 | class IPv6Packet: 8 | def __init__( 9 | self, 10 | ipv6_base: dpkt.ip6.IP6 11 | ) -> None: 12 | self.ipv6_base = ipv6_base 13 | 14 | @property 15 | def version(self) -> int: 16 | return self.ipv6_base.v 17 | 18 | @property 19 | def traffic_cls(self) -> int: 20 | return self.ipv6_base.fc 21 | 22 | @property 23 | def flow_label(self) -> int: 24 | return self.ipv6_base.flow 25 | 26 | @property 27 | def payload_len(self) -> int: 28 | return self.ipv6_base.plen 29 | 30 | @property 31 | def next_header(self) -> int: 32 | return self.ipv6_base.nxt 33 | 34 | @property 35 | def hop_limit(self) -> int: 36 | return self.ipv6_base.hlim 37 | 38 | @property 39 | def src(self) -> bytes: 40 | return self.ipv6_base.src 41 | 42 | @property 43 | def dst(self) -> bytes: 44 | return self.ipv6_base.dst 45 | 46 | @property 47 | def data(self) -> Any: 48 | return self.ipv6_base.data 49 | 50 | def __repr__(self): 51 | return ( 52 | f"IPv6Packet(V={self.version}, SRC={self.src}, DST={self.dst}, PLEN={self.payload_len} NEXT={self.next_header} HLIM={self.hop_limit})" 53 | ) 54 | 55 | @classmethod 56 | def parse(cls, data: bytes): 57 | ipv6_base = dpkt.ip6.IP6(data) 58 | if ipv6_base.v != 6: 59 | raise ValueError( 60 | f'Invalid IP version: Not 6' 61 | ) 62 | return cls(ipv6_base) -------------------------------------------------------------------------------- /xcloud/protocol/packets.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from construct import Struct, this, Int32ul, Int64ul, PrefixedArray, Bytes, Array 3 | 4 | """ 5 | Payload types 6 | """ 7 | 8 | 9 | class PayloadType(Enum): 10 | MuxDCTChannelRangeDefault = 0x23 11 | MuxDCTChannelRangeEnd = 0x3f 12 | BaseLinkControl = 0x60 13 | MuxDCTControl = 0x61 14 | FECControl = 0x62 15 | SecurityLayerCtrl = 0x63 16 | URCPControl = 0x64 17 | UDPKeepAlive = 0x65 18 | UDPConnectionProbing = 0x66 19 | URCPDummyPacket = 0x68 20 | MockUDPDctCtrl = 0x7f 21 | 22 | 23 | """ 24 | Video Channel 25 | """ 26 | 27 | 28 | class VideoControlFlags: 29 | LAST_DISPLAYED_FRAME = 0x01 30 | LOST_FRAMES = 0x02 31 | QUEUE_DEPTH = 0x04 32 | STOP_STREAM = 0x08 33 | START_STREAM = 0x10 34 | REQUEST_KEYFRAMES = 0x20 35 | LAST_DISPLAYED_FRAME_RENDERED = 0x80 36 | SMOOTH_RENDERING_SETTINGS_SENT = 0x1000 37 | 38 | 39 | video_format = Struct( 40 | ) 41 | 42 | 43 | video_server_handshake = Struct( 44 | 'protocol_version' / Int32ul, 45 | 'screen_width' / Int32ul, 46 | 'screen_height' / Int32ul, 47 | 'reference_timestamp' / Int64ul, 48 | 'formats' / PrefixedArray(Int32ul, video_format) 49 | ) 50 | 51 | 52 | video_client_handshake = Struct( 53 | 'initial_frame_id' / Int32ul, 54 | 'requested_format' / video_format 55 | ) 56 | 57 | 58 | video_control = Struct( 59 | 'flags' / Int32ul, # see VideoControlFlags 60 | 'last_displayed_frame' / Int32ul, # if(flags << 31) 61 | 'last_displayed_frame_rendered' / Int32ul, # if(flags & 0x80) 62 | 'lost_frames' / Array(2, Int32ul), # if (flags & 2) 63 | 'queue_depth' / Int32ul, # if(flags & 4) 64 | ) 65 | 66 | 67 | video_data = Struct( 68 | 'flags' / Int32ul, 69 | 'frame_id' / Int32ul, 70 | 'timestamp' / Int32ul, 71 | 'metadata_size' / Int32ul, 72 | 'data_size' / Int32ul, 73 | 'offset' / Int32ul, 74 | 'data' / Bytes(this.data_size) 75 | ) 76 | 77 | 78 | class QosControlFlags: 79 | REINITIALIZE = 0x1 80 | 81 | 82 | qos_server_policy = Struct( 83 | 'schema_version' / Int32ul, 84 | 'policy_length' / Int32ul, 85 | 'fragment_count' / Int32ul, 86 | 'offset' / Int32ul, 87 | 'fragment_size' / Int32ul 88 | ) 89 | 90 | 91 | qos_server_handshake = Struct( 92 | 'protocol_version' / Int32ul, 93 | 'min_supported_client_version' / Int32ul 94 | ) 95 | 96 | 97 | qos_client_policy = Struct( 98 | 'schema_version' / Int32ul 99 | ) 100 | 101 | 102 | qos_client_handshake = Struct( 103 | 'protocol_version' / Int32ul, 104 | 'initial_frame_id' / Int32ul 105 | ) 106 | 107 | 108 | qos_control = Struct( 109 | 'flags' / Int32ul 110 | ) 111 | 112 | 113 | qos_data = Struct( 114 | 'flags' / Int32ul, 115 | 'frame_id' / Int32ul, 116 | # TBD 117 | ) 118 | 119 | 120 | """ 121 | Control Protocol 122 | """ 123 | 124 | 125 | class ControlProtocolMessageOpCode(Enum): 126 | Auth = 0x1 127 | AuthComplete = 0x2 128 | Config = 0x3 129 | ControllerChange = 0x4 130 | Config2 = 0x6 131 | -------------------------------------------------------------------------------- /xcloud/protocol/srtp_crypto.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import struct 3 | from enum import Enum 4 | from typing import List, Optional 5 | from dataclasses import dataclass 6 | 7 | from aiortc.rtp import RtpPacket 8 | 9 | from cryptography.hazmat.backends import default_backend 10 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 11 | from cryptography.hazmat.primitives.ciphers.aead import AESGCM 12 | 13 | from . import utils 14 | 15 | class TransformDirection(Enum): 16 | Encrypt = 0 17 | Decrypt = 1 18 | 19 | class SrtpMasterKeys: 20 | MASTER_KEY_SIZE = 16 21 | MASTER_SALT_SIZE = 14 22 | DUMMY_KEY = ( 23 | b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F' 24 | b'\x10\x11\x12\x13' 25 | ) 26 | 27 | def __init__(self, master_key: bytes, master_salt: bytes): 28 | assert len(master_key) == SrtpMasterKeys.MASTER_KEY_SIZE 29 | assert len(master_salt) == SrtpMasterKeys.MASTER_SALT_SIZE 30 | 31 | self._master_key = master_key 32 | self._master_key_id = 0 33 | 34 | self._master_salt = master_salt 35 | self._master_salt_id = 0 36 | 37 | @classmethod 38 | def from_base64(cls, master_bytes_b64: str): 39 | decoded = base64.b64decode(master_bytes_b64) 40 | return cls( 41 | decoded[:SrtpMasterKeys.MASTER_KEY_SIZE], 42 | decoded[SrtpMasterKeys.MASTER_KEY_SIZE:] 43 | ) 44 | 45 | @classmethod 46 | def null_keys(cls): 47 | return cls( 48 | SrtpMasterKeys.MASTER_KEY_SIZE * b'\x00', 49 | SrtpMasterKeys.MASTER_SALT_SIZE * b'\x00', 50 | ) 51 | 52 | @classmethod 53 | def dummy_keys(cls): 54 | return cls( 55 | SrtpMasterKeys.DUMMY_KEY[:SrtpMasterKeys.MASTER_KEY_SIZE], 56 | SrtpMasterKeys.DUMMY_KEY[:SrtpMasterKeys.MASTER_SALT_SIZE] 57 | ) 58 | 59 | @property 60 | def master_key(self) -> bytes: 61 | return self._master_key 62 | 63 | @property 64 | def master_key_id(self) -> int: 65 | return self._master_key_id 66 | 67 | @property 68 | def master_salt(self) -> bytes: 69 | return self._master_salt 70 | 71 | @property 72 | def master_salt_id(self) -> int: 73 | return self._master_salt_id 74 | 75 | class SrtpSessionKeys: 76 | SRTP_CRYPT = 0 77 | SRTP_AUTH = 1 78 | SRTP_SALT = 2 79 | # Max count of keys 80 | SRTP_SESSION_KEYS_MAX = 3 81 | 82 | def __init__(self, crypt_key: bytes, auth_key: bytes, salt_key: bytes): 83 | self._crypt_key = crypt_key 84 | self._auth_key = auth_key 85 | self._salt_key = salt_key 86 | 87 | @classmethod 88 | def from_list(cls, session_keys: List[bytes]): 89 | assert len(session_keys) == SrtpSessionKeys.SRTP_SESSION_KEYS_MAX 90 | return cls( 91 | session_keys[SrtpSessionKeys.SRTP_CRYPT], 92 | session_keys[SrtpSessionKeys.SRTP_AUTH], 93 | session_keys[SrtpSessionKeys.SRTP_SALT] 94 | ) 95 | 96 | @property 97 | def crypt_key(self) -> bytes: 98 | return self._crypt_key 99 | 100 | @property 101 | def auth_key(self) -> bytes: 102 | return self._auth_key 103 | 104 | @property 105 | def salt_key(self) -> bytes: 106 | return self._salt_key 107 | 108 | class SrtpContext: 109 | _backend = default_backend() 110 | 111 | def __init__(self, master_keys: SrtpMasterKeys): 112 | """ 113 | MS-SRTP context 114 | """ 115 | self.roc = 0 116 | self.seq = 0 117 | 118 | self.master_keys = master_keys 119 | self.session_keys = SrtpContext._derive_session_keys( 120 | self.master_keys.master_key, self.master_keys.master_salt 121 | ) 122 | 123 | # Set-up GCM crypto instances 124 | self.crypto_ctx = SrtpContext._init_gcm_cryptor(self.session_keys.crypt_key) 125 | 126 | @classmethod 127 | def from_base64(cls, master_bytes_b64: str): 128 | return cls( 129 | SrtpMasterKeys.from_base64(master_bytes_b64) 130 | ) 131 | 132 | @classmethod 133 | def from_bytes(cls, master_key: bytes, master_salt: bytes): 134 | return cls( 135 | SrtpMasterKeys(master_key, master_salt) 136 | ) 137 | 138 | @staticmethod 139 | def _crypt_ctr_oneshot(key: bytes, iv: bytes, plaintext: bytes, max_bytes: Optional[int] = None): 140 | """ 141 | Encrypt data with AES-CTR (one-shot) 142 | """ 143 | cipher = Cipher(algorithms.AES(key), modes.CTR(iv)) 144 | encryptor = cipher.encryptor() 145 | cipher_out = encryptor.update(plaintext) + encryptor.finalize() 146 | if max_bytes: 147 | # Trim to desired output 148 | cipher_out = cipher_out[:max_bytes] 149 | return cipher_out 150 | 151 | @staticmethod 152 | def _derive_single_key(master_key, master_salt, key_index: int = 0, max_bytes: int = 16, pkt_i=0, key_derivation_rate=0): 153 | '''SRTP key derivation, https://tools.ietf.org/html/rfc3711#section-4.3''' 154 | 155 | assert len(master_key) == 128 // 8 156 | assert len(master_salt) == 112 // 8 157 | salt = utils.bytes_to_int(master_salt) 158 | 159 | DIV = lambda x, y: 0 if y == 0 else x // y 160 | prng = lambda iv: SrtpContext._crypt_ctr_oneshot( 161 | master_key, utils.int_to_bytes(iv, 16), b'\x00' * 16, max_bytes=max_bytes 162 | ) 163 | r = DIV(pkt_i, key_derivation_rate) # pkt_i is always 48 bits 164 | derive_key_from_label = lambda label: prng( 165 | (salt ^ ((label << 48) + r)) << 16) 166 | 167 | return derive_key_from_label(key_index) 168 | 169 | @staticmethod 170 | def _derive_session_keys(master_key: bytes, master_salt: bytes) -> SrtpSessionKeys: 171 | crypt_key = SrtpContext._derive_single_key(master_key, master_salt, SrtpSessionKeys.SRTP_CRYPT) 172 | auth_key = SrtpContext._derive_single_key(master_key, master_salt, SrtpSessionKeys.SRTP_AUTH) 173 | salt_key = SrtpContext._derive_single_key(master_key, master_salt, SrtpSessionKeys.SRTP_SALT, max_bytes=14) 174 | 175 | return SrtpSessionKeys(crypt_key, auth_key, salt_key) 176 | 177 | @staticmethod 178 | def _init_gcm_cryptor(key: bytes) -> AESGCM: 179 | return AESGCM(key) 180 | 181 | @staticmethod 182 | def _decrypt(ctx: AESGCM, nonce: bytes, data: bytes, aad: bytes) -> bytes: 183 | return ctx.decrypt(nonce, data, aad) 184 | 185 | @staticmethod 186 | def _encrypt(ctx: AESGCM, nonce: bytes, data: bytes, aad: bytes) -> bytes: 187 | return ctx.encrypt(nonce, data, aad) 188 | 189 | @staticmethod 190 | def packet_index(roc, seq): 191 | return seq + (roc << 16) 192 | 193 | @staticmethod 194 | def _calc_iv(salt, ssrc, pkt_i): 195 | salt = utils.bytes_to_int(salt) 196 | iv = ((ssrc << (48)) + pkt_i) ^ salt 197 | return utils.int_to_bytes(iv, 12) 198 | 199 | def _crypt_packet(self, rtp_packet: bytes, encrypt: bool) -> RtpPacket: 200 | rtp_header = rtp_packet[:12] 201 | parsed = RtpPacket.parse(rtp_packet) 202 | 203 | if parsed.sequence_number < self.seq: 204 | self.roc += 1 205 | self.seq = parsed.sequence_number 206 | pkt_i = SrtpContext.packet_index(self.roc, self.seq) 207 | iv = SrtpContext._calc_iv(self.session_keys.salt_key[2:], parsed.ssrc, pkt_i) 208 | 209 | if encrypt: 210 | transformed_payload = SrtpContext._encrypt(self.crypto_ctx, iv, parsed.payload, rtp_header) 211 | else: 212 | transformed_payload = SrtpContext._decrypt(self.crypto_ctx, iv, parsed.payload, rtp_header) 213 | 214 | parsed.payload = transformed_payload 215 | return parsed 216 | 217 | def encrypt_packet(self, rtp_packet: bytes) -> RtpPacket: 218 | return self._crypt_packet(rtp_packet, True) 219 | 220 | def decrypt_packet(self, rtp_packet: bytes) -> RtpPacket: 221 | return self._crypt_packet(rtp_packet, False) -------------------------------------------------------------------------------- /xcloud/protocol/teredo.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dataclasses import dataclass, field 3 | from struct import pack, unpack, unpack_from 4 | from typing import Any, List, Optional, Tuple, Union 5 | from ipaddress import IPv4Address, IPv6Address 6 | 7 | from . import ipv6 8 | 9 | TEREDO_PORT = 3544 10 | TEREDO_HEADER_LENGTH = 22 11 | 12 | 13 | @dataclass 14 | class TeredoEndpoint: 15 | teredo_server_ipv4: IPv4Address 16 | flags: int 17 | udp_port: int 18 | client_pub_ipv4: IPv4Address 19 | 20 | 21 | def convert_teredo_addr_to_endpoint( 22 | teredo_addr: bytes 23 | ) -> TeredoEndpoint: 24 | """ 25 | 0x00-0x04 Prefix // 32 bits 26 | 0x04-0x08 Teredo server IPv4 // 32 bits 27 | 0x08-0x0A Flags // 16 bits 28 | 0x0A-0x0C UDP Port // 16 bits 29 | 0x0C-0x10 Client public IPv4 // 32 bits 30 | 31 | Client IP address and UDP port are obfuscated / inverted 32 | """ 33 | addr = IPv6Address(teredo_addr) 34 | teredo_tuple = addr.teredo 35 | if not teredo_tuple: 36 | raise ValueError('Not a teredo address') 37 | 38 | _, server_ipv4, flags, udp_port, client_ipv4 = \ 39 | unpack('!IIHHI', teredo_addr) 40 | 41 | # Deobfuscate/invert client address and port 42 | client_ipv4 ^= 0xFFFFFFFF 43 | udp_port ^= 0xFFFF 44 | 45 | # Convert IP addresses to object 46 | server_ipv4 = IPv4Address(server_ipv4) 47 | client_ipv4 = IPv4Address(client_ipv4) 48 | 49 | assert server_ipv4 == teredo_tuple[0] 50 | assert client_ipv4 == teredo_tuple[1] 51 | 52 | return TeredoEndpoint( 53 | teredo_server_ipv4=server_ipv4, 54 | flags=flags, 55 | udp_port=udp_port, 56 | client_pub_ipv4=client_ipv4 57 | ) 58 | 59 | class TeredoPacket: 60 | def __init__( 61 | self, 62 | ipv6_base: ipv6.IPv6Packet, 63 | src_teredo: TeredoEndpoint, 64 | dst_teredo: TeredoEndpoint 65 | ) -> None: 66 | self.ipv6 = ipv6_base 67 | self.src_teredo = src_teredo 68 | self.dst_teredo = dst_teredo 69 | 70 | def __repr__(self) -> str: 71 | return ( 72 | f"TeredoPacket(IPv6={self.ipv6}, SRC={self.src_teredo}, DST={self.dst_teredo})" 73 | ) 74 | 75 | @classmethod 76 | def parse(cls, data: bytes): 77 | if len(data) < TEREDO_HEADER_LENGTH: 78 | raise ValueError( 79 | f"Teredo packet length is less than {TEREDO_HEADER_LENGTH} bytes" 80 | ) 81 | base = ipv6.IPv6Packet.parse(data) 82 | src_teredo = convert_teredo_addr_to_endpoint(base.src) 83 | dst_teredo = convert_teredo_addr_to_endpoint(base.dst) 84 | return cls(base, src_teredo, dst_teredo) 85 | 86 | -------------------------------------------------------------------------------- /xcloud/protocol/utils.py: -------------------------------------------------------------------------------- 1 | def bytes_to_int(b): 2 | return int.from_bytes(b, byteorder='big') 3 | 4 | def int_to_bytes(i, n_bytes): 5 | return i.to_bytes(n_bytes, byteorder='big') -------------------------------------------------------------------------------- /xcloud/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenXbox/xcloud-python/01122a3b9d0579739b0335311b8c25a40dbcb371/xcloud/scripts/__init__.py -------------------------------------------------------------------------------- /xcloud/scripts/client.py: -------------------------------------------------------------------------------- 1 | import io 2 | import sys 3 | import uuid 4 | import time 5 | import asyncio 6 | 7 | from ..common import AppConfiguration 8 | 9 | from ..auth.constants import IOS_XBOXBETA_APP_PARAMS, ANDROID_GAMEPASS_BETA_PARAMS 10 | from ..auth.models import XalClientParameters 11 | from ..auth.xal_auth import XalAuthenticator 12 | from ..auth.request_signer import RequestSigner 13 | 14 | from ..smartglass_api import SmartglassApi 15 | from ..xcloud_api import XCloudApi 16 | from ..xhomestreaming_api import XHomeStreamingApi 17 | 18 | APP_CONFIG_XBOXBETA_FILE = "appconfig.xboxbeta.json" 19 | APP_CONFIG_XBOXGAMEPASS_FILE = "appconfig.xboxgamepass.json" 20 | 21 | 22 | def choose_console(console_list): 23 | print('Please choose a console:') 24 | for index, c in enumerate(console_list.result): 25 | print(f'{index}) {c.id} - {c.name} - Type: {c.consoleType}') 26 | 27 | choice = int(input('Enter index of target console: ')) 28 | return console_list.result[choice] 29 | 30 | 31 | async def test_smartglass_api( 32 | smartglass: SmartglassApi, 33 | console_liveid: str 34 | ): 35 | console_status = await smartglass.get_console_status(console_liveid) 36 | print(console_status) 37 | 38 | poweron_resp = await smartglass.command_power_on(console_liveid) 39 | print(poweron_resp) 40 | 41 | print('Waiting 30 secs') 42 | time.sleep(30) 43 | 44 | poweroff_resp = await smartglass.command_power_off(console_liveid) 45 | print(poweroff_resp) 46 | await smartglass.session.aclose() 47 | 48 | 49 | async def test_xhome_streaming( 50 | config: AppConfiguration, 51 | console_liveid: str, 52 | ): 53 | xal = XalAuthenticator( 54 | config.ClientUUID, 55 | config.XalParameters, 56 | RequestSigner.from_pem(config.SigningKey) 57 | ) 58 | 59 | print(':: Requesting XSTS Token (RelyingParty: http://gssv.xboxlive.com)') 60 | gssv_token = await xal.xsts_authorization( 61 | config.Authorization.DeviceToken, 62 | config.Authorization.TitleToken.Token, 63 | config.Authorization.UserToken.Token, 64 | relying_party='http://gssv.xboxlive.com/' 65 | ) 66 | await xal.session.aclose() 67 | 68 | xhome_api = XHomeStreamingApi(gssv_token) 69 | await xhome_api.start_streaming(console_liveid) 70 | await xhome_api.session.aclose() 71 | 72 | 73 | async def test_xcloud_streaming( 74 | config: AppConfiguration 75 | ): 76 | xal = XalAuthenticator( 77 | config.ClientUUID, 78 | config.XalParameters, 79 | RequestSigner.from_pem(config.SigningKey) 80 | ) 81 | 82 | print(':: Requesting XSTS Token (RelyingParty: http://gssv.xboxlive.com)') 83 | gssv_token = await xal.xsts_authorization( 84 | config.Authorization.DeviceToken, 85 | config.Authorization.TitleToken.Token, 86 | config.Authorization.UserToken.Token, 87 | relying_party='http://gssv.xboxlive.com/' 88 | ) 89 | print(':: Exchanging refresh token for xcloud transfer token') 90 | xcloud_token = await xal.exchange_refresh_token_for_xcloud_transfer_token( 91 | config.WindowsLiveTokens.refresh_token 92 | ) 93 | await xal.session.aclose() 94 | 95 | xhome_api = XCloudApi(gssv_token, xcloud_token) 96 | await xhome_api.start_streaming() 97 | await xhome_api.session.aclose() 98 | 99 | 100 | async def async_main(command: str): 101 | """ 102 | Prepare needed values 103 | """ 104 | 105 | if command == 'xhome' or command == 'smartglass': 106 | app_config_file = APP_CONFIG_XBOXBETA_FILE 107 | xal_params = IOS_XBOXBETA_APP_PARAMS 108 | elif command == 'xcloud': 109 | app_config_file = APP_CONFIG_XBOXGAMEPASS_FILE 110 | xal_params = ANDROID_GAMEPASS_BETA_PARAMS 111 | else: 112 | print(':: Unexpected command...') 113 | return 114 | 115 | try: 116 | config = AppConfiguration.parse_file(app_config_file) 117 | except Exception as e: 118 | print(f'Failed to parse app configuration! Err: {e}') 119 | print('Initializing new config...') 120 | config = AppConfiguration( 121 | ClientUUID=uuid.uuid4(), 122 | SigningKey=RequestSigner().export_signing_key(), 123 | XalParameters=XalClientParameters.parse_obj( 124 | xal_params 125 | ) 126 | ) 127 | 128 | # Create request signer 129 | request_signer = RequestSigner.from_pem(config.SigningKey) 130 | 131 | """ 132 | Authenticate 133 | """ 134 | if not config.WindowsLiveTokens or not config.Authorization: 135 | xal = XalAuthenticator( 136 | config.ClientUUID, config.XalParameters, request_signer 137 | ) 138 | await xal.auth_flow() 139 | 140 | config.WindowsLiveTokens = xal.windows_live_tokens 141 | config.Authorization = xal.sisu_authorization_tokens 142 | 143 | """ 144 | Saving app config 145 | """ 146 | with io.open(app_config_file, 'wt') as f: 147 | f.write(config.json(indent=2)) 148 | 149 | if command == 'smartglass' or command == 'xhome': 150 | smartglass = SmartglassApi( 151 | request_signer, 152 | config.Authorization.AuthorizationToken 153 | ) 154 | 155 | print(':: Getting console list') 156 | console_list = await smartglass.get_console_list() 157 | await smartglass.session.aclose() 158 | 159 | console = choose_console(console_list) 160 | console_liveid = console.id 161 | 162 | if command == 'smartglass': 163 | await test_smartglass_api(smartglass, console_liveid) 164 | else: 165 | await test_xhome_streaming(config, console_liveid) 166 | elif command == 'xcloud': 167 | await test_xcloud_streaming(config) 168 | 169 | 170 | def main(): 171 | if len(sys.argv) < 2: 172 | print(':: Please provide a command! Choices: smartglass, xhome, xcloud') 173 | sys.exit(1) 174 | 175 | command = sys.argv[1] 176 | if command not in ['smartglass', 'xhome', 'xcloud']: 177 | print(':: You provided an invalid command!') 178 | sys.exit(2) 179 | 180 | asyncio.run(async_main(command)) 181 | 182 | 183 | if __name__ == '__main__': 184 | main() 185 | -------------------------------------------------------------------------------- /xcloud/scripts/pcap_reader.py: -------------------------------------------------------------------------------- 1 | """ 2 | PCAP Parser for XCloud network traffic 3 | """ 4 | import argparse 5 | import logging 6 | import struct 7 | from typing import Any, Optional 8 | 9 | import dpkt 10 | from hexdump import hexdump 11 | from aiortc import rtp 12 | from aioice import stun 13 | from construct.lib import containers 14 | 15 | from ..protocol import packets, teredo, ipv6, srtp_crypto 16 | 17 | 18 | logging.basicConfig(level=logging.DEBUG) 19 | containers.setGlobalPrintFullStrings(True) 20 | LOG = logging.getLogger(__name__) 21 | 22 | class XcloudPcapParser: 23 | def __init__(self, srtp_key: Optional[str]): 24 | self.crypto: Optional[srtp_crypto.SrtpContext] = None 25 | if srtp_key: 26 | self.outgoing_crypto = srtp_crypto.SrtpContext.from_base64(srtp_key) 27 | self.incoming_crypto = srtp_crypto.SrtpContext.from_base64(srtp_key) 28 | self.xbox_mac: Optional[bytes] = None 29 | 30 | @property 31 | def PACKET_TYPES(self): 32 | return [ 33 | (stun.parse_message, self.get_info_stun), 34 | (rtp.RtpPacket.parse, self.get_info_rtp), 35 | (teredo.TeredoPacket.parse, self.get_info_teredo) 36 | ] 37 | 38 | def get_info_stun(self, stun: stun.Message, is_client: bool) -> None: 39 | return f'STUN: {stun}' 40 | 41 | def get_info_rtp(self, rtp: rtp.RtpPacket, is_client: bool) -> None: 42 | try: 43 | payload_name = packets.PayloadType(rtp.payload_type) 44 | except: 45 | payload_name = '' 46 | 47 | direction = 'OUT -> ' if is_client else '<- IN ' 48 | info_str = f'{direction} RTP: {payload_name.name} {rtp} SSRC={rtp.ssrc}' 49 | if self.incoming_crypto and self.outgoing_crypto: 50 | rtp_packet = rtp.serialize() 51 | try: 52 | if isinstance(is_client, bool): 53 | if is_client: 54 | rtp_decrypted = self.outgoing_crypto.decrypt_packet(rtp_packet) 55 | else: 56 | rtp_decrypted = self.incoming_crypto.decrypt_packet(rtp_packet) 57 | info_str += "\n" + hexdump(rtp_decrypted.payload, result='return') + "\n" 58 | else: 59 | info_str += "\n UNKNOWN DIRECTION \n" 60 | except Exception: 61 | info_str += "\n DECRYPTION FAILED \n" 62 | return info_str 63 | 64 | def get_info_teredo(self, teredo: teredo.TeredoPacket, is_client: bool) -> None: 65 | info = f'TEREDO: {teredo}' 66 | if teredo.ipv6.next_header != ipv6.NO_NEXT_HEADER: 67 | data = teredo.ipv6.data 68 | if type(data) == bytes: 69 | raise ValueError(f'TEREDO contains unparsed-subpacket: {data}') 70 | subpacket_info = self.get_info_general(data) 71 | info += f'\n -> TEREDO-WRAPPED: {subpacket_info}' 72 | return info 73 | 74 | def get_info_general(self, packet: Any, is_client: bool) -> Optional[str]: 75 | if isinstance(packet, dpkt.udp.UDP): 76 | data = bytes(packet.data) 77 | for cls, info_func in self.PACKET_TYPES: 78 | try: 79 | instance = cls(data) 80 | info = info_func(instance, is_client) 81 | return info 82 | except: 83 | pass 84 | elif isinstance(packet, bytes): 85 | return '' 86 | else: 87 | return '' 88 | 89 | def packet_filter(self, filepath): 90 | with open(filepath, 'rb') as fh: 91 | for ts, buf in dpkt.pcap.Reader(fh): 92 | eth = dpkt.ethernet.Ethernet(buf) 93 | 94 | # Make sure the Ethernet data contains an IP packet 95 | if not isinstance(eth.data, dpkt.ip.IP): 96 | continue 97 | 98 | ip = eth.data 99 | if not isinstance(ip.data, dpkt.udp.UDP): 100 | continue 101 | 102 | # Check packet direction (client/host) 103 | if not self.xbox_mac and ip.data.sport == 3074: 104 | self.xbox_mac = eth.src 105 | 106 | if self.xbox_mac: 107 | is_client = (eth.src == self.xbox_mac) 108 | else: 109 | is_client = None 110 | 111 | yield(ip, ts, is_client) 112 | 113 | 114 | def parse_file(self, pcap_filepath: str) -> None: 115 | for packet, timestamp, is_client in self.packet_filter(pcap_filepath): 116 | info = self.get_info_general(packet.data, is_client) 117 | if info: 118 | print(info) 119 | 120 | def main(): 121 | parser = argparse.ArgumentParser( 122 | "XCloud PCAP parser", 123 | description="PCAP Parser for XCloud network traffic" 124 | ) 125 | parser.add_argument("filepath", help="Path to PCAP/NG file") 126 | parser.add_argument("--key", "-k", help="SRTP key") 127 | args = parser.parse_args() 128 | 129 | pcap_parser = XcloudPcapParser(args.key) 130 | pcap_parser.parse_file(args.filepath) 131 | 132 | 133 | if __name__ == "__main__": 134 | main() 135 | -------------------------------------------------------------------------------- /xcloud/smartglass_api.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import uuid 3 | from typing import Optional, List 4 | 5 | import ms_cv 6 | from .auth.models import XSTSResponse 7 | from .auth.signed_session import SignedSession 8 | from .auth.request_signer import RequestSigner 9 | 10 | from .smartglass_models import SmartglassConsoleList, \ 11 | SmartglassConsoleStatus, CommandResponse, VolumeDirection, InputKeyType,\ 12 | MediaCommand, InstalledPackagesList, StorageDevicesList,\ 13 | OperationStatusResponse 14 | 15 | 16 | class SmartglassApi: 17 | def __init__( 18 | self, 19 | request_signer: RequestSigner, 20 | xsts_token: XSTSResponse, 21 | user_agent: str = 'Xbox/2008.0915.0311 CFNetwork/1197 Darwin/20.0.0' 22 | ): 23 | self.cv = ms_cv.CorrelationVector() 24 | 25 | self.user_agent = user_agent 26 | self.session = SignedSession(request_signer) 27 | self.session.headers.update({ 28 | 'User-Agent': self.user_agent, 29 | 'Authorization': xsts_token.authorization_header_value, 30 | 'x-xbl-contract-version': '4', 31 | 'skillplatform': 'RemoteManagement' 32 | }) 33 | 34 | self.smartglass_session_id = str(uuid.uuid4()) 35 | 36 | async def fetch_operation_status( 37 | self, 38 | operation_id: str, 39 | device_id: str 40 | ) -> OperationStatusResponse: 41 | url = 'https://xccs.xboxlive.com/opStatus' 42 | headers = { 43 | 'MS-CV': self.cv.increment(), 44 | 'x-xbl-contract-version': '3', 45 | 'x-xbl-opId': operation_id, 46 | 'x-xbl-deviceId': device_id 47 | } 48 | request = self.session.build_request('GET', url, headers=headers) 49 | resp = await self.session.send_signed(request) 50 | resp.raise_for_status() 51 | return OperationStatusResponse.parse_obj(resp.json()) 52 | 53 | async def _fetchList(self, list_name: str, query_params: dict = None) -> httpx.Response: 54 | url = f'https://xccs.xboxlive.com/lists/{list_name}' 55 | headers = { 56 | 'MS-CV': self.cv.increment() 57 | } 58 | request = self.session.build_request('GET', url, headers=headers, params=query_params) 59 | resp = await self.session.send_signed(request) 60 | resp.raise_for_status() 61 | return resp 62 | 63 | async def get_console_list(self) -> SmartglassConsoleList: 64 | query_params = { 65 | 'queryCurrentDevice': 'false', 66 | 'includeStorageDevices': 'true' 67 | } 68 | resp = await self._fetchList("devices", query_params) 69 | return SmartglassConsoleList.parse_obj(resp.json()) 70 | 71 | async def get_storage_devices(self, device_id: str) -> StorageDevicesList: 72 | query_params = { 73 | 'deviceId': device_id 74 | } 75 | resp = await self._fetchList("storageDevices", query_params) 76 | return StorageDevicesList.parse_obj(resp.json()) 77 | 78 | async def get_installed_apps(self, device_id: str) -> InstalledPackagesList: 79 | query_params = { 80 | 'deviceId': device_id 81 | } 82 | resp = await self._fetchList("installedApps", query_params) 83 | return InstalledPackagesList.parse_obj(resp.json()) 84 | 85 | async def get_console_status(self, console_live_id: str) -> SmartglassConsoleStatus: 86 | url = f'https://xccs.xboxlive.com/consoles/{console_live_id}' 87 | headers = { 88 | 'MS-CV': self.cv.increment() 89 | } 90 | request = self.session.build_request('GET', url, headers=headers) 91 | resp = await self.session.send_signed(request) 92 | resp.raise_for_status() 93 | return SmartglassConsoleStatus.parse_obj(resp.json()) 94 | 95 | async def _send_command( 96 | self, 97 | console_liveid: str, 98 | command_type: str, 99 | command: str, 100 | parameters: Optional[list] = None 101 | ) -> CommandResponse: 102 | if not parameters: 103 | parameters = [{}] 104 | 105 | url = 'https://xccs.xboxlive.com/commands' 106 | headers = { 107 | 'MS-CV': self.cv.increment() 108 | } 109 | json_body = { 110 | "destination": "Xbox", 111 | "type": command_type, 112 | "command": command, 113 | "sessionId": self.smartglass_session_id, 114 | "sourceId": "com.microsoft.smartglass", 115 | "parameters": parameters, 116 | "linkedXboxId": console_liveid 117 | } 118 | request = self.session.build_request('POST', url, headers=headers, json=json_body) 119 | resp = await self.session.send_signed(request) 120 | resp.raise_for_status() 121 | return CommandResponse.parse_obj(resp.json()) 122 | 123 | async def command_power_on(self, console_live_id: str) -> CommandResponse: 124 | return await self._send_command(console_live_id, "Power", "WakeUp") 125 | 126 | async def command_power_off(self, console_live_id: str) -> CommandResponse: 127 | return await self._send_command(console_live_id, "Power", "TurnOff") 128 | 129 | async def command_power_reboot(self, console_live_id: str) -> CommandResponse: 130 | return await self._send_command(console_live_id, "Power", "Reboot") 131 | 132 | async def command_audio_mute(self, console_live_id: str) -> CommandResponse: 133 | return await self._send_command(console_live_id, "Audio", "Mute") 134 | 135 | async def command_audio_unmute(self, console_live_id: str) -> CommandResponse: 136 | return await self._send_command(console_live_id, "Audio", "Unmute") 137 | 138 | async def command_audio_volume( 139 | self, console_live_id: str, direction: VolumeDirection, amount: int = 1 140 | ) -> CommandResponse: 141 | params = [{"direction": direction.value, "amount": str(amount)}] 142 | return await self._send_command(console_live_id, "Audio", "Volume", params) 143 | 144 | async def command_config_digital_assistant_remote_control( 145 | self, 146 | console_live_id: str 147 | ) -> CommandResponse: 148 | return await self._send_command(console_live_id, "Config", "DigitalAssistantRemoteControl") 149 | 150 | async def command_config_remote_access( 151 | self, 152 | console_live_id: str, 153 | enable: bool 154 | ) -> CommandResponse: 155 | params = [{"enabled": str(enable)}] 156 | return await self._send_command(console_live_id, "Config", "RemoteAccess", params) 157 | 158 | async def command_config_allow_console_streaming( 159 | self, 160 | console_live_id: str, 161 | enable: bool 162 | ) -> CommandResponse: 163 | params = [{"enabled": str(enable)}] 164 | return await self._send_command(console_live_id, "Config", "AllowConsoleStreaming", params) 165 | 166 | async def command_game_capture_gameclip( 167 | self, 168 | console_live_id: str 169 | ) -> CommandResponse: 170 | return await self._send_command(console_live_id, "Game", "CaptureGameClip") 171 | 172 | async def command_game_capture_screenshot( 173 | self, 174 | console_live_id: str 175 | ) -> CommandResponse: 176 | return await self._send_command(console_live_id, "Game", "CaptureScreenshot") 177 | 178 | async def command_game_invite_party_to_game( 179 | self, 180 | console_live_id: str 181 | ) -> CommandResponse: 182 | return await self._send_command(console_live_id, "Game", "InvitePartyToGame") 183 | 184 | async def command_game_invite_to_party( 185 | self, 186 | console_live_id: str 187 | ) -> CommandResponse: 188 | return await self._send_command(console_live_id, "Game", "InviteToParty") 189 | 190 | async def command_game_kick_from_party( 191 | self, 192 | console_live_id: str 193 | ) -> CommandResponse: 194 | return await self._send_command(console_live_id, "Game", "KickFromParty") 195 | 196 | async def command_game_leave_party( 197 | self, 198 | console_live_id: str 199 | ) -> CommandResponse: 200 | return await self._send_command(console_live_id, "Game", "LeaveParty") 201 | 202 | async def command_game_set_online_status( 203 | self, 204 | console_live_id: str 205 | ) -> CommandResponse: 206 | return await self._send_command(console_live_id, "Game", "SetOnlineStatus") 207 | 208 | async def command_game_start_a_party( 209 | self, 210 | console_live_id: str 211 | ) -> CommandResponse: 212 | return await self._send_command(console_live_id, "Game", "StartAParty") 213 | 214 | async def command_game_start_broadcasting( 215 | self, 216 | console_live_id: str 217 | ) -> CommandResponse: 218 | return await self._send_command(console_live_id, "Game", "StartBroadcasting") 219 | 220 | async def command_game_stop_broadcasting( 221 | self, 222 | console_live_id: str 223 | ) -> CommandResponse: 224 | return await self._send_command(console_live_id, "Game", "StopBroadcasting") 225 | 226 | async def command_gamestreaming_start_management_service( 227 | self, 228 | console_live_id: str 229 | ) -> CommandResponse: 230 | return await self._send_command(console_live_id, "GameStreaming", "StartStreamingManagementService") 231 | 232 | async def command_gamestreaming_stop_streaming( 233 | self, 234 | console_live_id: str 235 | ) -> CommandResponse: 236 | return await self._send_command(console_live_id, "GameStreaming", "StopStreaming") 237 | 238 | async def command_marketplace_redeem_code( 239 | self, 240 | console_live_id: str 241 | ) -> CommandResponse: 242 | return await self._send_command(console_live_id, "Marketplace", "RedeemCode") 243 | 244 | async def command_marketplace_search( 245 | self, 246 | console_live_id: str 247 | ) -> CommandResponse: 248 | return await self._send_command(console_live_id, "Marketplace", "Search") 249 | 250 | async def command_marketplace_search_store( 251 | self, 252 | console_live_id: str 253 | ) -> CommandResponse: 254 | return await self._send_command(console_live_id, "Marketplace", "SearchTheStore") 255 | 256 | async def command_marketplace_show_title( 257 | self, 258 | console_live_id: str 259 | ) -> CommandResponse: 260 | return await self._send_command(console_live_id, "Marketplace", "ShowTitle") 261 | 262 | async def command_media( 263 | self, 264 | console_live_id: str, 265 | media_command: MediaCommand 266 | ) -> CommandResponse: 267 | return await self._send_command(console_live_id, "Media", media_command.value) 268 | 269 | async def command_shell_activate_app_with_uri( 270 | self, 271 | console_live_id: str 272 | ) -> CommandResponse: 273 | return await self._send_command(console_live_id, "Shell", "ActivateApplicationWithUri") 274 | 275 | async def command_shell_activate_app_with_aumid( 276 | self, 277 | console_live_id: str 278 | ) -> CommandResponse: 279 | return await self._send_command(console_live_id, "Shell", "ActivateApplicationWithAumid") 280 | 281 | async def command_shell_activate_app_with_onestore_product_id( 282 | self, 283 | console_live_id: str, 284 | onestore_product_id: str 285 | ) -> CommandResponse: 286 | params = [{"oneStoreProductId": onestore_product_id}] 287 | return await self._send_command(console_live_id, "Shell", "ActivationApplicationWithOneStoreProductId", params) 288 | 289 | async def command_shell_allow_remote_management( 290 | self, 291 | console_live_id: str 292 | ) -> CommandResponse: 293 | return await self._send_command(console_live_id, "Shell", "AllowRemoteManagement") 294 | 295 | async def command_shell_change_view( 296 | self, 297 | console_live_id: str 298 | ) -> CommandResponse: 299 | return await self._send_command(console_live_id, "Shell", "ChangeView") 300 | 301 | async def command_shell_check_for_package_updates( 302 | self, 303 | console_live_id: str 304 | ) -> CommandResponse: 305 | return await self._send_command(console_live_id, "Shell", "CheckForPackageUpdates") 306 | 307 | async def command_shell_copy_packages( 308 | self, 309 | console_live_id: str 310 | ) -> CommandResponse: 311 | return await self._send_command(console_live_id, "Shell", "CopyPackages") 312 | 313 | async def command_shell_move_packages( 314 | self, 315 | console_live_id: str 316 | ) -> CommandResponse: 317 | return await self._send_command(console_live_id, "Shell", "MovePackages") 318 | 319 | async def command_shell_install_packages( 320 | self, 321 | console_live_id: str, 322 | big_cat_ids: List[str] 323 | ) -> CommandResponse: 324 | params = [{"bigCatIdList": ','.join(big_cat_ids)}] 325 | return await self._send_command(console_live_id, "Shell", "InstallPackages", params) 326 | 327 | async def command_shell_uninstall_package( 328 | self, 329 | console_live_id: str, 330 | instance_id: str 331 | ) -> CommandResponse: 332 | params = [{"instanceId": instance_id}] 333 | return await self._send_command(console_live_id, "Shell", "UninstallPackage", params) 334 | 335 | async def command_shell_update_packages( 336 | self, 337 | console_live_id: str 338 | ) -> CommandResponse: 339 | return await self._send_command(console_live_id, "Shell", "UpdatePackages") 340 | 341 | async def command_shell_eject_disk( 342 | self, 343 | console_live_id: str 344 | ) -> CommandResponse: 345 | return await self._send_command(console_live_id, "Shell", "EjectDisk") 346 | 347 | async def command_shell_go_back( 348 | self, 349 | console_live_id: str 350 | ) -> CommandResponse: 351 | return await self._send_command(console_live_id, "Shell", "GoBack") 352 | 353 | async def command_shell_go_home( 354 | self, 355 | console_live_id: str 356 | ) -> CommandResponse: 357 | return await self._send_command(console_live_id, "Shell", "GoHome") 358 | 359 | async def command_shell_pair_controller( 360 | self, 361 | console_live_id: str 362 | ) -> CommandResponse: 363 | return await self._send_command(console_live_id, "Shell", "PairController") 364 | 365 | async def command_shell_send_text_message( 366 | self, 367 | console_live_id: str 368 | ) -> CommandResponse: 369 | return await self._send_command(console_live_id, "Shell", "SendTextMessage") 370 | 371 | async def command_shell_show_guide_tab( 372 | self, 373 | console_live_id: str 374 | ) -> CommandResponse: 375 | return await self._send_command(console_live_id, "Shell", "ShowGuideTab") 376 | 377 | async def command_shell_sign_in( 378 | self, 379 | console_live_id: str 380 | ) -> CommandResponse: 381 | return await self._send_command(console_live_id, "Shell", "SignIn") 382 | 383 | async def command_shell_sign_out( 384 | self, 385 | console_live_id: str 386 | ) -> CommandResponse: 387 | return await self._send_command(console_live_id, "Shell", "SignOut") 388 | 389 | async def command_shell_launch_game( 390 | self, 391 | console_live_id: str 392 | ) -> CommandResponse: 393 | return await self._send_command(console_live_id, "Shell", "LaunchGame") 394 | 395 | async def command_shell_terminate_application( 396 | self, 397 | console_live_id: str 398 | ) -> CommandResponse: 399 | return await self._send_command(console_live_id, "Shell", "TerminateApplication") 400 | 401 | async def command_shell_keyinput( 402 | self, console_live_id: str, key_type: InputKeyType 403 | ) -> CommandResponse: 404 | params = [{"keyType": key_type.value}] 405 | return await self._send_command(console_live_id, "Shell", "InjectKey", params) 406 | 407 | async def command_shell_textinput( 408 | self, console_live_id: str, text_input: str 409 | ) -> CommandResponse: 410 | params = [{"replacementString": text_input}] 411 | return await self._send_command(console_live_id, "Shell", "InjectString", params) 412 | 413 | async def command_tv_show_guide( 414 | self, 415 | console_live_id: str 416 | ) -> CommandResponse: 417 | return await self._send_command(console_live_id, "TV", "ShowGuide") 418 | 419 | async def command_tv_watch_channel( 420 | self, 421 | console_live_id: str 422 | ) -> CommandResponse: 423 | return await self._send_command(console_live_id, "TV", "WatchChannel") 424 | -------------------------------------------------------------------------------- /xcloud/smartglass_models.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | from pydantic import BaseModel 3 | from enum import Enum 4 | 5 | """ 6 | Responses 7 | """ 8 | 9 | 10 | class ConsoleType(str, Enum): 11 | XboxOne = 'XboxOne' 12 | XboxOneS = 'XboxOneS' 13 | XboxOneSDigital = 'XboxOneSDigital' 14 | XboxOneX = 'XboxOneX' 15 | XboxSeriesS = 'XboxSeriesS' 16 | XboxSeriesX = 'XboxSeriesX' 17 | 18 | 19 | class PowerState(str, Enum): 20 | Unknown = 'Unknown' 21 | On = 'On' 22 | Off = 'Off' 23 | ConnectedStandby = 'ConnectedStandby' 24 | SystemUpdate = 'SystemUpdate' 25 | 26 | 27 | class PlaybackState(str, Enum): 28 | Unknown = 'Unknown' 29 | Playing = 'Playing' 30 | Paused = 'Paused' 31 | Stopped = 'Stopped' 32 | 33 | 34 | class ErrorCode(str, Enum): 35 | OK = 'OK' 36 | CurrentConsoleNotFound = 'CurrentConsoleNotFound' 37 | RemoteManagementDisabled = 'RemoteManagementDisabled' 38 | XboxDataNotFound = 'XboxDataNotFound' 39 | XboxNotPaired = 'XboxNotPaired' 40 | 41 | 42 | class OpStatus(str, Enum): 43 | Paused = 'Paused' 44 | OffConsoleError = 'OffConsoleError' 45 | Pending = 'Pending' 46 | TimedOut = 'TimedOut' 47 | Error = 'Error' 48 | Succeeded = 'Succeeded' 49 | 50 | 51 | class SmartglassApiStatus(BaseModel): 52 | errorCode: str 53 | errorMessage: Optional[str] 54 | 55 | 56 | class StorageDevice(BaseModel): 57 | storageDeviceId: str 58 | storageDeviceName: str 59 | isDefault: bool 60 | totalSpaceBytes: float 61 | freeSpaceBytes: float 62 | 63 | 64 | class SmartglassConsole(BaseModel): 65 | id: str 66 | name: str 67 | consoleType: ConsoleType 68 | powerState: PowerState 69 | consoleStreamingEnabled: bool 70 | digitalAssistantRemoteControlEnabled: bool 71 | remoteManagementEnabled: bool 72 | storageDevices: Optional[List[StorageDevice]] 73 | 74 | 75 | class SmartglassConsoleList(BaseModel): 76 | agentUserId: Optional[str] 77 | result: List[SmartglassConsole] 78 | status: SmartglassApiStatus 79 | 80 | 81 | class SmartglassConsoleStatus(BaseModel): 82 | powerState: PowerState 83 | consoleStreamingEnabled: bool 84 | digitalAssistantRemoteControlEnabled: bool 85 | remoteManagementEnabled: bool 86 | 87 | focusAppAumid: str 88 | isTvConfigured: bool 89 | loginState: Optional[str] 90 | playbackState: PlaybackState 91 | powerState: PowerState 92 | 93 | storageDevices: Optional[List[StorageDevice]] 94 | status: SmartglassApiStatus 95 | 96 | 97 | class InstalledPackage(BaseModel): 98 | oneStoreProductId: Optional[str] 99 | titleId: int 100 | aumid: Optional[str] 101 | lastActiveTime: Optional[str] 102 | isGame: bool 103 | name: Optional[str] 104 | contentType: str 105 | instanceId: str 106 | storageDeviceId: str 107 | uniqueId: str 108 | legacyProductId: Optional[str] 109 | version: int 110 | sizeInBytes: int 111 | installTime: str 112 | updateTime: Optional[str] 113 | parentId: Optional[str] 114 | 115 | 116 | class InstalledPackagesList(BaseModel): 117 | result: List[InstalledPackage] 118 | status: SmartglassApiStatus 119 | agentUserId: Optional[str] 120 | 121 | 122 | class StorageDevicesList(BaseModel): 123 | deviceId: str 124 | result: List[StorageDevice] 125 | status: SmartglassApiStatus 126 | 127 | 128 | class OpStatusNode(BaseModel): 129 | operationStatus: OpStatus 130 | opId: str 131 | originatingSessionId: str 132 | command: str 133 | succeeded: bool 134 | consoleStatusCode: Optional[int] 135 | xccsErrorCode: Optional[ErrorCode] 136 | hResult: Optional[int] 137 | message: Optional[str] 138 | 139 | 140 | class OperationStatusResponse(BaseModel): 141 | opStatusList: List[OpStatusNode] 142 | status: SmartglassApiStatus 143 | 144 | 145 | class CommandDestination(BaseModel): 146 | id: str 147 | name: str 148 | powerState: PowerState 149 | remoteManagementEnabled: bool 150 | consoleStreamingEnabled: bool 151 | consoleType: ConsoleType 152 | wirelessWarning: Optional[str] 153 | outOfHomeWarning: Optional[str] 154 | 155 | 156 | class CommandResponse(BaseModel): 157 | result: Optional[str] 158 | uiText: Optional[str] 159 | destination: CommandDestination 160 | userInfo: Optional[str] 161 | opId: str 162 | status: SmartglassApiStatus 163 | 164 | 165 | """ 166 | Requests 167 | """ 168 | 169 | 170 | class VolumeDirection(str, Enum): 171 | Up = "Up" 172 | Down = "Down" 173 | 174 | 175 | class InputKeyType(str, Enum): 176 | Guide = "Guide" 177 | Menu = "Menu" 178 | View = "View" 179 | A = "A" 180 | B = "B" 181 | X = "X" 182 | Y = "Y" 183 | Up = "Up" 184 | Down = "Down" 185 | Left = "Left" 186 | Right = "Right" 187 | 188 | 189 | class MediaCommand(str, Enum): 190 | Pause = "Pause" 191 | Play = "Play" 192 | Previous = "Previous" 193 | Next = "Next" 194 | -------------------------------------------------------------------------------- /xcloud/streaming_models.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import List, Optional 3 | from pydantic import BaseModel 4 | 5 | 6 | class StreamSetupState(str, Enum): 7 | WaitingForResources = 'WaitingForResources' 8 | ReadyToConnect = 'ReadyToConnect' 9 | Provisioning = 'Provisioning' 10 | Provisioned = 'Provisioned' 11 | 12 | 13 | class RegionCloudServer(BaseModel): 14 | name: str 15 | baseUri: str 16 | networkTestHostname: Optional[str] 17 | isDefault: bool 18 | poolIds: Optional[str] 19 | systemUpdateGroups: Optional[str] 20 | fallbackPriority: int 21 | 22 | 23 | class CloudEnvironment(BaseModel): 24 | Name: str 25 | AuthBaseUri: Optional[str] 26 | 27 | 28 | class ClientCloudSettings(BaseModel): 29 | Environments: List[CloudEnvironment] 30 | 31 | 32 | class OfferingSettings(BaseModel): 33 | allowRegionSelection: bool 34 | regions: List[RegionCloudServer] 35 | clientCloudSettings: ClientCloudSettings 36 | 37 | 38 | class StreamLoginResponse(BaseModel): 39 | offeringSettings: OfferingSettings 40 | market: str 41 | gsToken: str 42 | tokenType: str 43 | durationInSeconds: int 44 | 45 | 46 | class StreamSessionResponse(BaseModel): 47 | sessionId: Optional[str] 48 | sessionPath: str 49 | state: Optional[StreamSetupState] 50 | 51 | 52 | class StreamErrorDetails(BaseModel): 53 | code: Optional[str] 54 | message: Optional[str] 55 | 56 | 57 | class StreamStateResponse(BaseModel): 58 | state: StreamSetupState 59 | detailedSessionState: Optional[int] 60 | errorDetails: Optional[StreamErrorDetails] 61 | transferUri: Optional[str] 62 | 63 | 64 | class StreamSRtpData(BaseModel): 65 | key: str 66 | 67 | 68 | class StreamServerDetails(BaseModel): 69 | ipAddress: str 70 | port: int 71 | ipV4Address: Optional[str] 72 | ipV4Port: int 73 | ipV6Address: Optional[str] 74 | ipV6Port: int 75 | iceExchangePath: Optional[str] 76 | stunServerAddress: Optional[str] 77 | srtp: StreamSRtpData 78 | 79 | 80 | class StreamConfig(BaseModel): 81 | keepAlivePulseInSeconds: int 82 | serverDetails: StreamServerDetails 83 | 84 | 85 | class StreamICEConfig(BaseModel): 86 | candidates: str 87 | -------------------------------------------------------------------------------- /xcloud/xcloud_api.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from urllib.parse import urljoin 3 | from typing import List 4 | 5 | import httpx 6 | import ms_cv 7 | 8 | from .auth.models import XSTSResponse, XCloudTokenResponse 9 | from .streaming_models import StreamLoginResponse, StreamSessionResponse, \ 10 | StreamStateResponse, StreamConfig, StreamSetupState 11 | from .xcloud_models import TitlesResponse, TitleWaitTimeResponse, CloudGameTitle 12 | 13 | USER_AGENT_ANDROID = ( 14 | '{"conn":{"cell":{"carrier":"congstar","mcc":"262","mnc":"01","networkDetail":"{\"ci\":\"unknown\",\"pci\":\"unknown\",' 15 | '\"rat\":\"unknown\",\"signalStrengthDbm\":\"-2147483648\",\"pilotPowerSignalQuality\":\"-2147483648\",\"snr\":' 16 | '\"-2147483648\"}","roaming":"NotRoaming","strengthPct":100},"type":"Wifi","wifi":{"freq":5300,"strengthDbm":-60,' 17 | '"strengthPct":88}},"dev":{"hw":{"make":"Google","model":"Pixel 3a"},"os":{"name":"Android","ver":' 18 | '"11-RP1A.200720.009-30"}}}' 19 | ) 20 | 21 | 22 | class XCloudApi: 23 | def __init__( 24 | self, 25 | gssv_token: XSTSResponse, 26 | xcloud_token: XCloudTokenResponse, 27 | user_agent: str = USER_AGENT_ANDROID 28 | ): 29 | self.session = httpx.AsyncClient() 30 | self.session.headers.update({ 31 | 'X-MS-Device-Info': user_agent, 32 | 'User-Agent': user_agent 33 | }) 34 | 35 | self.cv = ms_cv.CorrelationVector() 36 | self.gssv_xsts_token = gssv_token 37 | self.xcloud_token = xcloud_token 38 | 39 | async def _do_login(self) -> StreamLoginResponse: 40 | url = 'https://publicpreview.gssv-play-prod.xboxlive.com/v2/login/user' 41 | headers = { 42 | 'MS-CV': self.cv.increment() 43 | } 44 | post_body = { 45 | 'offeringId': 'xgpubeta', 46 | 'token': self.gssv_xsts_token.authorization_header_value 47 | } 48 | resp = await self.session.post(url, headers=headers, json=post_body) 49 | resp.raise_for_status() 50 | return StreamLoginResponse.parse_obj(resp.json()) 51 | 52 | async def _get_titles( 53 | self, 54 | base_url: str, 55 | count: int = 25, 56 | continuation_token: str = None 57 | ) -> TitlesResponse: 58 | url = urljoin(base_url, '/v1/titles') 59 | headers = { 60 | 'MS-CV': self.cv.increment() 61 | } 62 | query_params = { 63 | 'mr': count 64 | } 65 | if continuation_token: 66 | query_params.update({'ct': continuation_token}) 67 | 68 | resp = await self.session.get(url, headers=headers, params=query_params) 69 | resp.raise_for_status() 70 | return TitlesResponse.parse_obj(resp.json()) 71 | 72 | async def _get_titles_2( 73 | self, 74 | base_url: str, 75 | count: int = 10, 76 | continuation_token: str = None 77 | ) -> TitlesResponse: 78 | url = urljoin(base_url, '/v1/titles/mru') 79 | headers = { 80 | 'MS-CV': self.cv.increment() 81 | } 82 | query_params = { 83 | 'mr': count 84 | } 85 | if continuation_token: 86 | query_params.update({'ct': continuation_token}) 87 | 88 | resp = await self.session.get(url, headers=headers, params=query_params) 89 | resp.raise_for_status() 90 | return TitlesResponse.parse_obj(resp.json()) 91 | 92 | async def _fetch_wait_time( 93 | self, 94 | base_url: str, 95 | title_id: str 96 | ) -> TitleWaitTimeResponse: 97 | url = urljoin(base_url, f'/v1/waittime/{title_id}') 98 | headers = { 99 | 'MS-CV': self.cv.increment() 100 | } 101 | resp = await self.session.get(url, headers=headers) 102 | resp.raise_for_status() 103 | return TitleWaitTimeResponse.parse_obj(resp.json()) 104 | 105 | async def _request_stream( 106 | self, base_url: str, title_id: str 107 | ) -> StreamSessionResponse: 108 | url = urljoin(base_url, '/v5/sessions/cloud/play') 109 | headers = { 110 | 'MS-CV': self.cv.increment() 111 | } 112 | json_body = { 113 | "fallbackRegionNames": ["WestEurope", "UKSouth", "UKWest"], 114 | "serverId": "", 115 | "settings": { 116 | "enableTextToSpeech": False, 117 | "locale": "de-DE", 118 | "nanoVersion": "V3", 119 | "timezoneOffsetMinutes": 120, 120 | "useIceConnection": False 121 | }, 122 | "systemUpdateGroup": "", 123 | "titleId": title_id 124 | } 125 | resp = await self.session.post(url, json=json_body, headers=headers) 126 | resp.raise_for_status() 127 | return StreamSessionResponse.parse_obj(resp.json()) 128 | 129 | async def _get_session_state( 130 | self, base_url: str, session_path: str 131 | ) -> StreamStateResponse: 132 | url = urljoin(base_url, session_path + '/state') 133 | headers = { 134 | 'MS-CV': self.cv.increment() 135 | } 136 | resp = await self.session.get(url, headers=headers) 137 | resp.raise_for_status() 138 | return StreamStateResponse.parse_obj(resp.json()) 139 | 140 | async def _connect_to_session( 141 | self, base_url: str, session_path: str, xcloud_token: str 142 | ) -> bool: 143 | url = urljoin(base_url, session_path + '/connect') 144 | headers = { 145 | 'MS-CV': self.cv.increment() 146 | } 147 | json_body = { 148 | 'userToken': xcloud_token 149 | } 150 | resp = await self.session.post(url, json=json_body, headers=headers) 151 | resp.raise_for_status() 152 | return resp.status_code == 202 # ACCEPTED 153 | 154 | async def _get_stream_config( 155 | self, base_url: str, session_path: str 156 | ) -> StreamConfig: 157 | url = urljoin(base_url, session_path + '/configuration') 158 | headers = { 159 | 'MS-CV': self.cv.increment() 160 | } 161 | resp = await self.session.get(url, headers=headers) 162 | resp.raise_for_status() 163 | return StreamConfig.parse_obj(resp.json()) 164 | 165 | async def get_all_titles(self, base_url: str) -> List[CloudGameTitle]: 166 | titles_collection = [] 167 | 168 | titles_2_resp = await self._get_titles_2(base_url) 169 | titles_collection.extend(titles_2_resp.results) 170 | 171 | titles_count = 25 172 | titles_resp = await self._get_titles(base_url, count=25) 173 | titles_total_items = titles_resp.totalItems 174 | titles_collection.extend(titles_resp.results) 175 | 176 | while titles_count < titles_total_items: 177 | titles_resp = await self._get_titles( 178 | base_url, 179 | count=25, 180 | continuation_token=titles_resp.continuationToken 181 | ) 182 | titles_collection.extend(titles_resp.results) 183 | titles_count += 25 184 | 185 | return titles_collection 186 | 187 | def choose_game(self, titles: List[CloudGameTitle]) -> CloudGameTitle: 188 | for idx, t in enumerate(titles): 189 | print(f'{idx}) Title Id: {t.titleId}, Product Id: {t.details.productId}') 190 | 191 | choice = int(input('Choose game: ')) 192 | return titles[choice] 193 | 194 | async def start_streaming(self): 195 | print(':: CLOUD GS - Logging in ::') 196 | login_data = await self._do_login() 197 | 198 | print(':: Updating http authorization header ::') 199 | self.session.headers.update( 200 | {'Authorization': f'Bearer {login_data.gsToken}'} 201 | ) 202 | 203 | print(':: Filtering for default server ::') 204 | base_url = None 205 | for server in login_data.offeringSettings.regions: 206 | if server.isDefault: 207 | base_url = server.baseUri 208 | break 209 | 210 | titles = await self.get_all_titles(base_url) 211 | chosen_title = self.choose_game(titles) 212 | print(f':: Chose Game: {chosen_title}') 213 | 214 | wait_time = await self._fetch_wait_time(base_url, chosen_title.titleId) 215 | print(f':: Estimated wait time for provisioning: {wait_time}') 216 | 217 | stream_session = await self._request_stream(base_url, chosen_title.titleId) 218 | print(f':: Stream session {stream_session}') 219 | 220 | print(':: Waiting for stream') 221 | while True: 222 | state = await self._get_session_state(base_url, stream_session.sessionPath) 223 | print(state.state) 224 | if state.state == StreamSetupState.ReadyToConnect: 225 | print(':: Connecting to stream') 226 | success = await self._connect_to_session( 227 | base_url, stream_session.sessionPath, self.xcloud_token.lpt 228 | ) 229 | if not success: 230 | print(':: Failed to connect to session') 231 | return 232 | elif state.state == StreamSetupState.Provisioned: 233 | break 234 | 235 | await asyncio.sleep(1) 236 | 237 | print(':: Requesting config') 238 | config = await self._get_stream_config(base_url, stream_session.sessionPath) 239 | 240 | print(f':: Config: {config}') 241 | -------------------------------------------------------------------------------- /xcloud/xcloud_models.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from pydantic import BaseModel 3 | 4 | 5 | class TitleSupportedTab(BaseModel): 6 | id: str 7 | tabVersion: str 8 | layoutVersion: str 9 | manifestVersion: str 10 | 11 | 12 | class CloudGameTitleDetails(BaseModel): 13 | productId: str 14 | xboxTitleId: Optional[int] 15 | hasEntitlement: bool 16 | blockedByFamilySafety: bool 17 | supportsInAppPurchases: bool 18 | supportedTabs: Optional[List[TitleSupportedTab]] 19 | nativeTouch: bool 20 | 21 | 22 | class CloudGameTitle(BaseModel): 23 | titleId: str 24 | details: CloudGameTitleDetails 25 | 26 | 27 | class TitlesResponse(BaseModel): 28 | totalItems: Optional[int] 29 | results: List[CloudGameTitle] 30 | continuationToken: Optional[str] 31 | 32 | 33 | class TitleWaitTimeResponse(BaseModel): 34 | estimatedProvisioningTimeInSeconds: int 35 | estimatedAllocationTimeInSeconds: int 36 | estimatedTotalWaitTimeInSeconds: int 37 | -------------------------------------------------------------------------------- /xcloud/xhomestreaming_api.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import time 4 | from urllib.parse import urljoin 5 | 6 | import ms_cv 7 | 8 | import httpx 9 | from .auth.models import XSTSResponse 10 | from .ice import ICEHandler 11 | 12 | from .streaming_models import StreamLoginResponse, StreamSessionResponse, \ 13 | StreamStateResponse, StreamConfig, StreamICEConfig 14 | 15 | USER_AGENT_IOS = ( 16 | '{"conn":{"cell":{"carrier":"","mcc":"","mnc":"","networkDetail":"","roaming":"Unknown","strengthPct":-1},' 17 | '"type":"Wifi","wifi":{"freq":-2147483648,"strengthDbm":-2147483648,"strengthPct":-1}},"dev":{"hw":{"make"' 18 | ':"Apple","model":"iPad6,11"},"os":{"name":"iOS","ver":"14.0.1 (Build 18A393)"}}}' 19 | ) 20 | USER_AGENT_ANDROID = ( 21 | '{"conn":{"cell":{"carrier":"","mcc":"unknown","mnc":"unknown","strengthPct":0},"type":"Wifi","wifi":{"freq":' 22 | '2417,"strengthDbm":-47,"strengthPct":100}},"dev":{"hw":{"make":"amzn","model":"Fire"},"os":{"name":"Android",' 23 | '"ver":"7.1.2-NJH47F-25"}}}' 24 | ) 25 | 26 | 27 | class XHomeStreamingApi: 28 | def __init__( 29 | self, 30 | gssv_token: XSTSResponse, 31 | user_agent: str = USER_AGENT_IOS 32 | ): 33 | self.session = httpx.AsyncClient() 34 | self.session.headers.update({ 35 | 'X-MS-Device-Info': user_agent, 36 | 'User-Agent': user_agent 37 | }) 38 | 39 | self.cv = ms_cv.CorrelationVector() 40 | self.gssv_xsts_token = gssv_token 41 | 42 | async def _do_login(self, offering_id: str = 'xhome') -> StreamLoginResponse: 43 | url = 'https://xhome.gssv-play-prod.xboxlive.com/v2/login/user' 44 | headers = { 45 | 'MS-CV': self.cv.increment() 46 | } 47 | post_body = { 48 | 'offeringId': offering_id, 49 | 'token': self.gssv_xsts_token.authorization_header_value 50 | } 51 | resp = await self.session.post(url, headers=headers, json=post_body) 52 | resp.raise_for_status() 53 | return StreamLoginResponse.parse_obj(resp.json()) 54 | 55 | async def _request_stream( 56 | self, base_url: str, console_liveid: str 57 | ) -> StreamSessionResponse: 58 | url = urljoin(base_url, '/v4/sessions/home/play') 59 | headers = { 60 | 'MS-CV': self.cv.increment() 61 | } 62 | json_body = { 63 | "fallbackRegionNames": [], 64 | "serverId": console_liveid, 65 | "settings": { 66 | "enableTextToSpeech": False, 67 | "locale": "en-US", 68 | "nanoVersion": "V3", 69 | # TODO: how is timezoneOffsetMinutes defined? 70 | "timezoneOffsetMinutes": 6088401280, 71 | "useIceConnection": True 72 | }, 73 | "systemUpdateGroup": "", 74 | "titleId": "" 75 | } 76 | 77 | resp = await self.session.post(url, json=json_body, headers=headers) 78 | resp.raise_for_status() 79 | return StreamSessionResponse.parse_obj(resp.json()) 80 | 81 | async def _get_session_state( 82 | self, base_url: str, session_path: str 83 | ) -> StreamStateResponse: 84 | url = urljoin(base_url, session_path + '/state') 85 | headers = { 86 | 'MS-CV': self.cv.increment() 87 | } 88 | resp = await self.session.get(url, headers=headers) 89 | resp.raise_for_status() 90 | return StreamStateResponse.parse_obj(resp.json()) 91 | 92 | async def _get_stream_config( 93 | self, base_url: str, session_path: str 94 | ) -> StreamConfig: 95 | url = urljoin(base_url, session_path + '/configuration') 96 | headers = { 97 | 'MS-CV': self.cv.increment() 98 | } 99 | resp = await self.session.get(url, headers=headers) 100 | resp.raise_for_status() 101 | return StreamConfig.parse_obj(resp.json()) 102 | 103 | async def _set_ice( 104 | self, base_url: str, ice_path: str, local_ice_config: str 105 | ) -> bool: 106 | url = urljoin(base_url, ice_path) 107 | headers = { 108 | 'MS-CV': self.cv.increment() 109 | } 110 | json_body = { 111 | "candidates": local_ice_config 112 | } 113 | resp = await self.session.post(url, json=json_body, headers=headers) 114 | resp.raise_for_status() 115 | return resp.status_code == 202 # ACCEPTED 116 | 117 | async def _get_ice( 118 | self, base_url: str, ice_path: str 119 | ) -> StreamICEConfig: 120 | url = urljoin(base_url, ice_path) 121 | headers = { 122 | 'MS-CV': self.cv.increment() 123 | } 124 | resp = await self.session.get(url, headers=headers) 125 | resp.raise_for_status() 126 | return StreamICEConfig.parse_obj(resp.json()) 127 | 128 | async def _stop_stream( 129 | self, base_url: str, session_path: str 130 | ) -> bool: 131 | url = urljoin(base_url, session_path) 132 | headers = { 133 | 'MS-CV': self.cv.increment() 134 | } 135 | resp = await self.session.delete(url, headers=headers) 136 | resp.raise_for_status() 137 | return resp.status_code == 202 # ACCEPTED 138 | 139 | async def _handle_ice_negotiation( 140 | self, base_url: str, ice_exchange_path: str 141 | ): 142 | ice_handler = ICEHandler() 143 | local_ice_config: dict = await ice_handler.generate_local_config() 144 | 145 | local_candidates = json.dumps(local_ice_config, indent=2) 146 | print(local_candidates) 147 | 148 | print(':: Setting ICE data') 149 | success = await self._set_ice(base_url, ice_exchange_path, local_candidates) 150 | if not success: 151 | print('Failed to set ICE data') 152 | return 153 | 154 | print(':: Getting ICE data') 155 | ice_data = await self._get_ice(base_url, ice_exchange_path) 156 | print(f'ICE Config: {ice_data}') 157 | 158 | candidates: str = ice_data.candidates 159 | candidates: dict = json.loads(candidates) 160 | remote_candidates, remote_params = ice_handler.parse_remote_config(candidates) 161 | 162 | for rc in remote_candidates: 163 | ice_handler.transport.addRemoteCandidate(rc) 164 | 165 | # End adding 166 | ice_handler.transport.addRemoteCandidate(None) 167 | 168 | await ice_handler.transport.start(remote_params) 169 | await asyncio.sleep(5) 170 | await ice_handler.transport.stop() 171 | 172 | async def start_streaming(self, console_liveid: str): 173 | print(':: HOME GS - Logging in ::') 174 | login_data = await self._do_login() 175 | 176 | print(':: Updating http authorization header ::') 177 | self.session.headers.update( 178 | {'Authorization': f'Bearer {login_data.gsToken}'} 179 | ) 180 | 181 | print(':: Filtering for default server ::') 182 | base_url = None 183 | for server in login_data.offeringSettings.regions: 184 | if server.isDefault: 185 | base_url = server.baseUri 186 | break 187 | 188 | if not base_url: 189 | print(f'No default server found in login response: {login_data}') 190 | return 191 | 192 | print(f':: Using server {base_url}') 193 | 194 | print(':: Requesting stream') 195 | stream_session_info = await self._request_stream(base_url, console_liveid) 196 | 197 | print(':: Waiting for provisioning') 198 | state = 'Provisioning' 199 | while state == 'Provisioning': 200 | resp = await self._get_session_state(base_url, stream_session_info.sessionPath) 201 | state = resp.state 202 | time.sleep(1) 203 | 204 | print(':: Getting stream config') 205 | config = await self._get_stream_config(base_url, stream_session_info.sessionPath) 206 | print(f':: Stream config: {config}') 207 | 208 | if config.serverDetails.iceExchangePath: 209 | print(':: Handling ICE negotiation') 210 | await self._handle_ice_negotiation( 211 | base_url, config.serverDetails.iceExchangePath 212 | ) 213 | 214 | print(':: Closing stream again') 215 | await self._stop_stream(base_url, stream_session_info.sessionPath) 216 | --------------------------------------------------------------------------------