diff --git a/steam/client/builtins/apps.py b/steam/client/builtins/apps.py index 7f6cb07..40ad9eb 100644 --- a/steam/client/builtins/apps.py +++ b/steam/client/builtins/apps.py @@ -142,20 +142,20 @@ class Apps(object): timeout=10 ) - def get_depot_key(self, depot_id, app_id=0): + def get_depot_key(self, app_id, depot_id): """Get depot decryption key - :param depot_id: depot id - :type depot_id: :class:`int` :param app_id: app id - :type app_id: :class:`int` + :type app_id: :class:`int` + :param depot_id: depot id + :type depot_id: :class:`int` :return: `CMsgClientGetDepotDecryptionKeyResponse `_ :rtype: proto message """ return self.send_job_and_wait(MsgProto(EMsg.ClientGetDepotDecryptionKey), { - 'depot_id': depot_id, 'app_id': app_id, + 'depot_id': depot_id, }, timeout=10 ) @@ -182,9 +182,9 @@ class Apps(object): """Get access tokens :param app_ids: list of app ids - :type app_ids: :class:`list` + :type app_ids: :class:`list` :param package_ids: list of package ids - :type package_ids: :class:`list` + :type package_ids: :class:`list` :return: dict with ``apps`` and ``packages`` containing their access tokens, see example below :rtype: :class:`dict`, :class:`None` diff --git a/steam/client/cdn.py b/steam/client/cdn.py index b43c9a6..d82158c 100644 --- a/steam/client/cdn.py +++ b/steam/client/cdn.py @@ -2,9 +2,11 @@ from collections import OrderedDict, deque from six import itervalues import vdf + from steam import webapi -from steam.enums import EServerType +from steam.enums import EResult, EServerType from steam.util.web import make_requests_session +from stema.core.crypto symmetric_decrypt from steam.core.manifest import DepotManifest @@ -67,12 +69,14 @@ class CDNClient(object): self.app_id = app_id self.web = make_requests_session() self.servers = deque() + self.cdn_auth_tokens = {} + self.depot_keys = {} @property def cell_id(self): return self.steam.cell_id - def init_servers(self, num_servers=10): + def init_servers(self, num_servers=20): self.servers.clear() for ip, port in self.steam.servers[EServerType.CS]: @@ -85,32 +89,85 @@ class CDNClient(object): if not self.servers: raise RuntimeError("No content servers on SteamClient instance. Is it logged in?") - def get_content_server(self): - server = self.servers[0] - self.servers.rotate(-1) - return server + def get_content_server(self, rotate=True): + if rotate: + self.servers.rotate(-1) + return self.servers[0] + + def get_cdn_auth_token(self, depot_id): + if depot_id not in self.cdn_auth_tokens: + msg = self.steam.get_cdn_auth_token(depot_id, 'steampipe.steamcontent.com') + + if msg.eresult == EResult.OK: + self.cdn_auth_tokens[depot_id] = msg.token + elif msg is None: + raise Exception("Failed getting depot key: %s" % repr(EResult.Timeout)) + else: + raise Exception("Failed getting depot key: %s" % repr(EResult(msg.eresult))) + + return self.cdn_auth_tokens[depot_id] + + def get_depot_key(self, depot_id): + if depot_id not in self.depot_keys: + msg = self.steam.get_depot_key(self.app_id, depot_id) + if msg.eresult == EResult.OK: + self.depot_keys[depot_id] = msg.depot_encryption_key + elif msg is None: + raise Exception("Failed getting depot key: %s" % repr(EResult.Timeout)) + else: + raise Exception("Failed getting depot key: %s" % repr(EResult(msg.eresult))) + + return self.depot_keys[depot_id] def get(self, command, args, auth_token=''): server = self.get_content_server() - url = "%s://%s:%s/%s/%s%s" % ( - 'https' if server.https else 'http', - server.host, - server.port, - command, - args, - auth_token, - ) + while True: + url = "%s://%s:%s/%s/%s%s" % ( + 'https' if server.https else 'http', + server.host, + server.port, + command, + args, + auth_token, + ) + resp = self.web.get(url) - return self.web.get(url) + if resp.ok: + return resp + elif resp.status_code in (401, 403, 404): + resp.raise_for_status() - def get_manifest(self, depot_id, manifest_id, auth_token): - resp = self.get('depot', '%s/manifest/%s/5' % (depot_id, manifest_id), auth_token) + server = self.get_content_server(rotate=True) - resp.raise_for_status() + def get_manifest(self, depot_id, manifest_id, cdn_auth_token=None, decrypt=True): + if cdn_auth_token is None: + cdn_auth_token = self.get_cdn_auth_token(depot_id) + + resp = self.get('depot', '%s/manifest/%s/5' % (depot_id, manifest_id), cdn_auth_token) if resp.ok: - return DepotManifest(resp.content) + manifest = DepotManifest(resp.content) + if decrypt: + manifest.decrypt_filenames(self.get_depot_key(depot_id)) + return manifest + + def get_chunk(self, depot_id, chunk_id, cdn_auth_token=None): + if cdn_auth_token is None: + cdn_auth_token = self.get_cdn_auth_token(depot_id) + + resp = self.get('depot', '%s/chunk/%s' % (depot_id, chunk_id), cdn_auth_token) + + if resp.ok: + data = symmetric_decrypt(resp.content, self.get_depot_key(depot_id)) + + if data[:2] == b'VZ': + raise Exception("Implement LZMA lol") + else: + with ZipFile(BytesIO(data)) as zf: + data = zf.read(zf.filelist[0]) + + return data class ContentServer(object): diff --git a/steam/core/manifest.py b/steam/core/manifest.py index fee81f0..db84712 100644 --- a/steam/core/manifest.py +++ b/steam/core/manifest.py @@ -1,10 +1,12 @@ from base64 import b64decode from io import BytesIO -from zipfile import ZipFile, ZIP_DEFLATED +from zipfile import ZipFile, ZIP_DEFLATED, BadZipFile from struct import pack from datetime import datetime +from fnmatch import fnmatch +from steam.enums import EDepotFileFlag from steam.core.crypto import symmetric_decrypt from steam.util.binary import StructReader from steam.protobufs.content_manifest_pb2 import (ContentManifestMetadata, @@ -18,7 +20,12 @@ class DepotManifest(object): PROTOBUF_SIGNATURE_MAGIC = 0x1B81B817 PROTOBUF_ENDOFMANIFEST_MAGIC = 0x32C415AB - def __init__(self, data): + def __init__(self, data=None): + """Manage depot manifest + + :param data: manifest data + :type data: bytes + """ self.metadata = ContentManifestMetadata() self.payload = ContentManifestPayload() self.signature = ContentManifestSignature() @@ -28,8 +35,8 @@ class DepotManifest(object): def __repr__(self): params = ', '.join([ - str(self.metadata.depot_id), - str(self.metadata.gid_manifest), + str(self.depot_id), + str(self.gid), repr(datetime.utcfromtimestamp(self.metadata.creation_time).isoformat().replace('T', ' ')), ]) @@ -41,9 +48,35 @@ class DepotManifest(object): params, ) + @property + def depot_id(self): + return self.metadata.depot_id + + @property + def gid(self): + return self.metadata.gid_manifest + + @property + def creation_time(self): + return self.metadata.creation_time + + @property + def size_original(self): + return self.metadata.cb_disk_original + + @property + def size_compressed(self): + return self.metadata.cb_disk_compressed + def decrypt_filenames(self, depot_key): + """Decrypt all filenames in the manifest + + :param depot_key: depot key + :type depot_key: bytes + :raises: :class:`RuntimeError` + """ if not self.metadata.filenames_encrypted: - return True + return for mapping in self.payload.mappings: filename = b64decode(mapping.filename) @@ -51,17 +84,25 @@ class DepotManifest(object): try: filename = symmetric_decrypt(filename, depot_key) except Exception: - print("Unable to decrypt filename for depot manifest") - return False + RuntimeError("Unable to decrypt filename for depot manifest") mapping.filename = filename self.metadata.filenames_encrypted = False - return True def deserialize(self, data): - with ZipFile(BytesIO(data)) as zf: - data = StructReader(zf.read(zf.filelist[0])) + """Deserialize a manifest (compressed or uncompressed) + + :param data: manifest data + :type data: bytes + """ + try: + with ZipFile(BytesIO(data)) as zf: + data = zf.read(zf.filelist[0]) + except BadZipFile: + pass + + data = StructReader(data) magic, length = data.unpack('" % ( + self.__class__.__name__, + self.manifest.depot_id, + self.manifest.gid, + repr(self.filename), + 'is_directory=True' if self.is_directory else self.size, + ) + + @property + def filename(self): + return self.file_mapping.filename.rstrip('\x00 \n\t') + + @property + def size(self): + return self.file_mapping.size + + @property + def chunks(self): + return self.file_mapping.chunks + + @property + def flags(self): + return self.file_mapping.flags + + @property + def is_directory(self): + return self.flags & EDepotFileFlag.Directory > 0 - return zbuff.getvalue() + @property + def is_file(self): + return not self.is_directory diff --git a/steam/enums/common.py b/steam/enums/common.py index b9c2358..05ac877 100644 --- a/steam/enums/common.py +++ b/steam/enums/common.py @@ -539,6 +539,19 @@ class ECurrencyCode(SteamIntEnum): Max = 42 +class EDepotFileFlag(SteamIntEnum): + UserConfig = 1 + VersionedUserConfig = 2 + Encrypted = 4 + ReadOnly = 8 + Hidden = 16 + Executable = 32 + Directory = 64 + CustomExecutable = 128 + InstallScript = 256 + Symlink = 512 + + # Do not remove from enum import EnumMeta