python-xbox 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_xbox-0.1.0.dist-info/METADATA +217 -0
- python_xbox-0.1.0.dist-info/RECORD +64 -0
- python_xbox-0.1.0.dist-info/WHEEL +4 -0
- python_xbox-0.1.0.dist-info/entry_points.txt +6 -0
- python_xbox-0.1.0.dist-info/licenses/LICENSE +20 -0
- pythonxbox/__init__.py +4 -0
- pythonxbox/api/__init__.py +0 -0
- pythonxbox/api/client.py +166 -0
- pythonxbox/api/language.py +76 -0
- pythonxbox/api/provider/__init__.py +0 -0
- pythonxbox/api/provider/account/__init__.py +73 -0
- pythonxbox/api/provider/account/models.py +11 -0
- pythonxbox/api/provider/achievements/__init__.py +164 -0
- pythonxbox/api/provider/achievements/models.py +133 -0
- pythonxbox/api/provider/baseprovider.py +22 -0
- pythonxbox/api/provider/catalog/__init__.py +86 -0
- pythonxbox/api/provider/catalog/const.py +15 -0
- pythonxbox/api/provider/catalog/models.py +428 -0
- pythonxbox/api/provider/cqs/__init__.py +85 -0
- pythonxbox/api/provider/cqs/models.py +59 -0
- pythonxbox/api/provider/gameclips/__init__.py +167 -0
- pythonxbox/api/provider/gameclips/models.py +58 -0
- pythonxbox/api/provider/lists/__init__.py +71 -0
- pythonxbox/api/provider/lists/models.py +33 -0
- pythonxbox/api/provider/mediahub/__init__.py +64 -0
- pythonxbox/api/provider/mediahub/models.py +82 -0
- pythonxbox/api/provider/message/__init__.py +135 -0
- pythonxbox/api/provider/message/models.py +96 -0
- pythonxbox/api/provider/people/__init__.py +193 -0
- pythonxbox/api/provider/people/models.py +252 -0
- pythonxbox/api/provider/presence/__init__.py +110 -0
- pythonxbox/api/provider/presence/models.py +53 -0
- pythonxbox/api/provider/profile/__init__.py +140 -0
- pythonxbox/api/provider/profile/models.py +47 -0
- pythonxbox/api/provider/ratelimitedprovider.py +79 -0
- pythonxbox/api/provider/screenshots/__init__.py +167 -0
- pythonxbox/api/provider/screenshots/models.py +56 -0
- pythonxbox/api/provider/smartglass/__init__.py +402 -0
- pythonxbox/api/provider/smartglass/models.py +186 -0
- pythonxbox/api/provider/titlehub/__init__.py +143 -0
- pythonxbox/api/provider/titlehub/models.py +106 -0
- pythonxbox/api/provider/usersearch/__init__.py +29 -0
- pythonxbox/api/provider/usersearch/models.py +17 -0
- pythonxbox/api/provider/userstats/__init__.py +164 -0
- pythonxbox/api/provider/userstats/models.py +44 -0
- pythonxbox/authentication/__init__.py +0 -0
- pythonxbox/authentication/manager.py +161 -0
- pythonxbox/authentication/models.py +162 -0
- pythonxbox/authentication/xal.py +348 -0
- pythonxbox/common/__init__.py +0 -0
- pythonxbox/common/exceptions.py +59 -0
- pythonxbox/common/filetimes.py +81 -0
- pythonxbox/common/models.py +34 -0
- pythonxbox/common/ratelimits/__init__.py +268 -0
- pythonxbox/common/ratelimits/models.py +23 -0
- pythonxbox/common/request_signer.py +190 -0
- pythonxbox/common/signed_session.py +60 -0
- pythonxbox/py.typed +0 -0
- pythonxbox/scripts/__init__.py +15 -0
- pythonxbox/scripts/authenticate.py +159 -0
- pythonxbox/scripts/change_gamertag.py +111 -0
- pythonxbox/scripts/friends.py +80 -0
- pythonxbox/scripts/search.py +43 -0
- pythonxbox/scripts/xal.py +113 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
from abc import ABCMeta, abstractmethod
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
|
|
4
|
+
from pythonxbox.common.ratelimits.models import (
|
|
5
|
+
IncrementResult,
|
|
6
|
+
LimitType,
|
|
7
|
+
ParsedRateLimit,
|
|
8
|
+
TimePeriod,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RateLimit(metaclass=ABCMeta):
|
|
13
|
+
"""
|
|
14
|
+
Abstract class for varying implementations/types of rate limits.
|
|
15
|
+
All methods in this class are overriden in every implementation.
|
|
16
|
+
However, different implementations may have additional functions not present in this parent abstract class.
|
|
17
|
+
|
|
18
|
+
A class implementing RateLimit functions without any external threads.
|
|
19
|
+
When the first increment request is recieved (after a counter reset or a new instaniciation)
|
|
20
|
+
a reset_after variable is set detailing when the rate limit(s) reset.
|
|
21
|
+
|
|
22
|
+
Upon each function invokation, the reset_after variable is checked and the timer is automatically reset if the reset_after time has passed.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def get_counter(self) -> int:
|
|
27
|
+
# Docstrings are defined in child classes due to their differing implementations.
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def get_reset_after(self) -> datetime | None:
|
|
32
|
+
# Docstrings are defined in child classes due to their differing implementations.
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def is_exceeded(self) -> bool:
|
|
37
|
+
# Docstrings are defined in child classes due to their differing implementations.
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def increment(self) -> IncrementResult:
|
|
42
|
+
"""
|
|
43
|
+
The increment function adds one to the rate limit request counter.
|
|
44
|
+
|
|
45
|
+
If the reset_after time has passed, the counter will first be reset before counting the request.
|
|
46
|
+
|
|
47
|
+
When the counter hits 1, the reset_after time is calculated and stored.
|
|
48
|
+
|
|
49
|
+
This function returns an `IncrementResult` object, containing the keys `counter: int` and `exceeded: bool`.
|
|
50
|
+
This can be used by the caller to determine the current state of the rate-limit object without making an additional function call.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SingleRateLimit(RateLimit):
|
|
57
|
+
"""
|
|
58
|
+
A rate limit implementation for a single rate limit, such as a burst or sustain limit.
|
|
59
|
+
This class is mainly used by the CombinedRateLimit class.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, time_period: TimePeriod, type: LimitType, limit: int) -> None:
|
|
63
|
+
self.__time_period = time_period
|
|
64
|
+
self.__type = type
|
|
65
|
+
self.__limit = limit
|
|
66
|
+
|
|
67
|
+
self.__exceeded: bool = False
|
|
68
|
+
self.__counter = 0
|
|
69
|
+
# No requests so far, so reset_after is None.
|
|
70
|
+
self.__reset_after: datetime | None = None
|
|
71
|
+
|
|
72
|
+
def get_counter(self) -> int:
|
|
73
|
+
"""
|
|
74
|
+
This function returns the current request counter variable.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
return self.__counter
|
|
78
|
+
|
|
79
|
+
def get_time_period(self) -> "TimePeriod":
|
|
80
|
+
return self.__time_period
|
|
81
|
+
|
|
82
|
+
def get_limit(self) -> int:
|
|
83
|
+
return self.__limit
|
|
84
|
+
|
|
85
|
+
def get_limit_type(self) -> "LimitType":
|
|
86
|
+
return self.__type
|
|
87
|
+
|
|
88
|
+
def get_reset_after(self) -> datetime | None:
|
|
89
|
+
"""
|
|
90
|
+
This getter returns the current state of the reset_after counter.
|
|
91
|
+
|
|
92
|
+
If the counter in use, it's corresponding `datetime` object is returned.
|
|
93
|
+
|
|
94
|
+
If the counter is not in use, `None` is returned.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
return self.__reset_after
|
|
98
|
+
|
|
99
|
+
def is_exceeded(self) -> bool:
|
|
100
|
+
"""
|
|
101
|
+
This functions returns `True` if the rate limit has been exceeded.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
self.__reset_counter_if_required()
|
|
105
|
+
return self.__exceeded
|
|
106
|
+
|
|
107
|
+
def increment(self) -> IncrementResult:
|
|
108
|
+
# Call a function to check if the counter should be reset
|
|
109
|
+
self.__reset_counter_if_required()
|
|
110
|
+
|
|
111
|
+
# Increment the counter
|
|
112
|
+
self.__counter += 1
|
|
113
|
+
|
|
114
|
+
# If the counter is 1, (first request after a reset) set the reset_after value.
|
|
115
|
+
if self.__counter == 1:
|
|
116
|
+
self.__set_reset_after()
|
|
117
|
+
|
|
118
|
+
# Check to see if we have now exceeded the request limit
|
|
119
|
+
self.__check_if_exceeded()
|
|
120
|
+
|
|
121
|
+
# Return an instance of IncrementResult
|
|
122
|
+
return IncrementResult(counter=self.__counter, exceeded=self.__exceeded)
|
|
123
|
+
|
|
124
|
+
# Should be called after every inc of the counter
|
|
125
|
+
def __check_if_exceeded(self) -> None:
|
|
126
|
+
if not self.__exceeded:
|
|
127
|
+
if self.__counter >= self.__limit:
|
|
128
|
+
self.__exceeded = True
|
|
129
|
+
# reset-after is now dependent on the time since the first request of this cycle.
|
|
130
|
+
# self.__set_reset_after()
|
|
131
|
+
|
|
132
|
+
def __reset_counter_if_required(self) -> None:
|
|
133
|
+
# Check to make sure reset_after is not None
|
|
134
|
+
# - This is the case if this function is called before the counter
|
|
135
|
+
# is incremented after a reset / new instantiation
|
|
136
|
+
if self.__reset_after is not None:
|
|
137
|
+
if self.__reset_after < datetime.now():
|
|
138
|
+
self.__exceeded = False
|
|
139
|
+
self.__counter = 0
|
|
140
|
+
self.__reset_after = None
|
|
141
|
+
|
|
142
|
+
def __set_reset_after(self) -> None:
|
|
143
|
+
self.__reset_after = datetime.now() + timedelta(
|
|
144
|
+
seconds=self.get_time_period().value
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class CombinedRateLimit(RateLimit):
|
|
149
|
+
"""
|
|
150
|
+
A rate limit implementation for multiple rate limits, such as burst and sustain.
|
|
151
|
+
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(self, *parsed_limits: ParsedRateLimit, type: LimitType) -> None:
|
|
155
|
+
# *parsed_limits is a tuple
|
|
156
|
+
|
|
157
|
+
# Create a SingleRateLimit instance for each limit
|
|
158
|
+
self.__limits: list[SingleRateLimit] = []
|
|
159
|
+
|
|
160
|
+
for limit in parsed_limits:
|
|
161
|
+
# Use the type param (enum LimitType) to determine which limit to select
|
|
162
|
+
limit_num = limit.read if type == LimitType.READ else limit.write
|
|
163
|
+
|
|
164
|
+
# Create a new instance of SingleRateLimit and append it to the limits array.
|
|
165
|
+
srl = SingleRateLimit(limit.period, type, limit_num)
|
|
166
|
+
self.__limits.append(srl)
|
|
167
|
+
|
|
168
|
+
def get_counter(self) -> int:
|
|
169
|
+
"""
|
|
170
|
+
This function returns the request counter with the **highest** value.
|
|
171
|
+
|
|
172
|
+
A `CombinedRateLimit` consists of multiple different rate limits, which may have differing counter values.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
# Map self.__limits to (limit).get_counter()
|
|
176
|
+
counter_map = map(lambda limit: limit.get_counter(), self.__limits)
|
|
177
|
+
counters = list(counter_map)
|
|
178
|
+
|
|
179
|
+
# Sort the counters list by value
|
|
180
|
+
# reverse=True to get highest first
|
|
181
|
+
counters.sort(reverse=True)
|
|
182
|
+
|
|
183
|
+
# Return the highest value
|
|
184
|
+
return counters[0]
|
|
185
|
+
|
|
186
|
+
# We don't want a datetime response for a limit that has not been exceeded.
|
|
187
|
+
# Otherwise eg. 10 burst requests -> 300s timeout (should be 30 (burst exceeded), 300s (not exceeded)
|
|
188
|
+
def get_reset_after(self) -> datetime | None:
|
|
189
|
+
"""
|
|
190
|
+
This getter returns either a `datetime` object or `None` object depending on the status of the rate limit.
|
|
191
|
+
|
|
192
|
+
If the counter is in use, the rate limit with the **latest** reset_after is returned.
|
|
193
|
+
|
|
194
|
+
This is so that this function can reliably be used as a indicator of when all rate limits have been reset.
|
|
195
|
+
|
|
196
|
+
If the counter is not in use, `None` is returned.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
# Get a list of limits that *have been exceeded*
|
|
200
|
+
dates_exceeded_only = filter(lambda limit: limit.is_exceeded(), self.__limits)
|
|
201
|
+
|
|
202
|
+
# Map self.__limits to (limit).get_reset_after()
|
|
203
|
+
dates_map = map(lambda limit: limit.get_reset_after(), dates_exceeded_only)
|
|
204
|
+
|
|
205
|
+
# Convert the map object to a list
|
|
206
|
+
dates = list(dates_map)
|
|
207
|
+
|
|
208
|
+
# Construct a new list with only elements of instance datetime
|
|
209
|
+
# (Effectively filtering out any None elements)
|
|
210
|
+
dates_valid = [elem for elem in dates if isinstance(elem, datetime)]
|
|
211
|
+
|
|
212
|
+
# If dates_valid has any elements, return the one with the *later* timestamp.
|
|
213
|
+
# This means that if two or more limits have been exceeded, we wait for both to have reset (by returning the later timestamp)
|
|
214
|
+
if len(dates_valid) != 0:
|
|
215
|
+
# By default dates are sorted with the earliest date first.
|
|
216
|
+
# We will set reverse=True so that the first element is the later date.
|
|
217
|
+
dates_valid.sort(reverse=True)
|
|
218
|
+
|
|
219
|
+
# Return the datetime object.
|
|
220
|
+
return dates_valid[0]
|
|
221
|
+
|
|
222
|
+
# dates_valid has no elements, return None
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
# list -> List (typing.List) https://stackoverflow.com/a/63460173
|
|
226
|
+
def get_limits(self) -> list[SingleRateLimit]:
|
|
227
|
+
return self.__limits
|
|
228
|
+
|
|
229
|
+
# list -> List (typing.List) https://stackoverflow.com/a/63460173
|
|
230
|
+
def get_limits_by_period(self, period: TimePeriod) -> list[SingleRateLimit]:
|
|
231
|
+
# Filter the list for the given LimitType
|
|
232
|
+
matches = filter(lambda limit: limit.get_time_period() == period, self.__limits)
|
|
233
|
+
# Convert the filter object to a list and return it
|
|
234
|
+
return list(matches)
|
|
235
|
+
|
|
236
|
+
def is_exceeded(self) -> bool:
|
|
237
|
+
"""
|
|
238
|
+
This function returns `True` if **any** rate limit has been exceeded.
|
|
239
|
+
|
|
240
|
+
It behaves like an OR logic gate.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
# Map self.__limits to (limit).is_exceeded()
|
|
244
|
+
is_exceeded_map = map(lambda limit: limit.is_exceeded(), self.__limits)
|
|
245
|
+
is_exceeded_list = list(is_exceeded_map)
|
|
246
|
+
|
|
247
|
+
# Return True if any variable in list is True
|
|
248
|
+
return True in is_exceeded_list
|
|
249
|
+
|
|
250
|
+
def increment(self) -> IncrementResult:
|
|
251
|
+
# Increment each limit
|
|
252
|
+
results: list[IncrementResult] = []
|
|
253
|
+
for limit in self.__limits:
|
|
254
|
+
result = limit.increment()
|
|
255
|
+
results.append(result)
|
|
256
|
+
|
|
257
|
+
# SPEC: Let's pick the *higher* counter
|
|
258
|
+
# By default, sorted() returns in ascending order, so let's set reverse=True
|
|
259
|
+
# This means that the result with the highest counter will be the first element.
|
|
260
|
+
results_sorted = sorted(results, key=lambda i: i.counter, reverse=True)
|
|
261
|
+
|
|
262
|
+
# Create an instance of IncrementResult and return it.
|
|
263
|
+
return IncrementResult(
|
|
264
|
+
counter=results_sorted[
|
|
265
|
+
0
|
|
266
|
+
].counter, # Use the highest counter (sorted in descending order)
|
|
267
|
+
exceeded=self.is_exceeded(), # Call self.is_exceeded (True if any limit has been exceeded, like an OR gate.)
|
|
268
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TimePeriod(Enum):
|
|
6
|
+
BURST = 15 # 15 seconds
|
|
7
|
+
SUSTAIN = 300 # 5 minutes (300s)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LimitType(Enum):
|
|
11
|
+
WRITE = 0
|
|
12
|
+
READ = 1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class IncrementResult(BaseModel):
|
|
16
|
+
counter: int
|
|
17
|
+
exceeded: bool
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ParsedRateLimit(BaseModel):
|
|
21
|
+
read: int
|
|
22
|
+
write: int
|
|
23
|
+
period: TimePeriod
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Request Signer
|
|
3
|
+
|
|
4
|
+
Employed for generating the "Signature" header in authentication requests.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
from datetime import datetime, UTC
|
|
9
|
+
import hashlib
|
|
10
|
+
import struct
|
|
11
|
+
|
|
12
|
+
from ecdsa import NIST256p, SigningKey, VerifyingKey
|
|
13
|
+
|
|
14
|
+
from pythonxbox.authentication.models import SignaturePolicy
|
|
15
|
+
from pythonxbox.common import filetimes
|
|
16
|
+
|
|
17
|
+
DEFAULT_SIGNING_POLICY = SignaturePolicy(
|
|
18
|
+
version=1, supported_algorithms=["ES256"], max_body_bytes=8192
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RequestSigner:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
signing_key: SigningKey | None = None,
|
|
26
|
+
signing_policy: SignaturePolicy | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.signing_key = signing_key or SigningKey.generate(curve=NIST256p)
|
|
29
|
+
self.signing_policy = signing_policy or DEFAULT_SIGNING_POLICY
|
|
30
|
+
|
|
31
|
+
pk_point = self.signing_key.verifying_key.pubkey.point
|
|
32
|
+
self.proof_field = {
|
|
33
|
+
"use": "sig",
|
|
34
|
+
"alg": self.signing_policy.supported_algorithms[0],
|
|
35
|
+
"kty": "EC",
|
|
36
|
+
"crv": "P-256",
|
|
37
|
+
"x": self.__encode_ec_coord(pk_point.x()),
|
|
38
|
+
"y": self.__encode_ec_coord(pk_point.y()),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
def export_signing_key(self) -> str:
|
|
42
|
+
return self.signing_key.to_pem().decode()
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def import_signing_key(signing_key: str) -> SigningKey:
|
|
46
|
+
return SigningKey.from_pem(signing_key)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_pem(cls, pem_string: str) -> SigningKey:
|
|
50
|
+
request_signer = RequestSigner.import_signing_key(pem_string)
|
|
51
|
+
return cls(request_signer)
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def get_timestamp_buffer(dt: datetime) -> bytes:
|
|
55
|
+
"""
|
|
56
|
+
Get usable buffer from datetime
|
|
57
|
+
|
|
58
|
+
dt: Input datetime
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
bytes: FILETIME buffer (network order/big endian)
|
|
62
|
+
"""
|
|
63
|
+
filetime = filetimes.dt_to_filetime(dt)
|
|
64
|
+
return struct.pack("!Q", filetime)
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def get_signature_version_buffer(version: int) -> bytes:
|
|
68
|
+
"""
|
|
69
|
+
Get big endian uint32 bytes-representation from
|
|
70
|
+
signature version
|
|
71
|
+
|
|
72
|
+
version: Signature version
|
|
73
|
+
|
|
74
|
+
Returns: Version as uint32 big endian bytes
|
|
75
|
+
"""
|
|
76
|
+
return struct.pack("!I", version)
|
|
77
|
+
|
|
78
|
+
def verify_digest(
|
|
79
|
+
self,
|
|
80
|
+
signature: bytes,
|
|
81
|
+
digest: bytes,
|
|
82
|
+
verifying_key: VerifyingKey | None = None,
|
|
83
|
+
) -> bool:
|
|
84
|
+
"""
|
|
85
|
+
Verify signature against digest
|
|
86
|
+
|
|
87
|
+
signature: Signature to validate
|
|
88
|
+
message: Digest to verify
|
|
89
|
+
verifying_key: Public key to use for verification.
|
|
90
|
+
If that key is not provided, the private key used for signing is used.
|
|
91
|
+
|
|
92
|
+
Returns: True on successful verification, False otherwise
|
|
93
|
+
"""
|
|
94
|
+
verifier = verifying_key or self.signing_key.verifying_key
|
|
95
|
+
return verifier.verify_digest(signature, digest)
|
|
96
|
+
|
|
97
|
+
def sign(
|
|
98
|
+
self,
|
|
99
|
+
method: str,
|
|
100
|
+
path_and_query: str,
|
|
101
|
+
body: bytes = b"",
|
|
102
|
+
authorization: str = "",
|
|
103
|
+
timestamp: datetime = None,
|
|
104
|
+
) -> str:
|
|
105
|
+
if timestamp is None:
|
|
106
|
+
timestamp = datetime.now(UTC)
|
|
107
|
+
|
|
108
|
+
signature = self._sign_raw(
|
|
109
|
+
method, path_and_query, body, authorization, timestamp
|
|
110
|
+
)
|
|
111
|
+
return base64.b64encode(signature).decode("ascii")
|
|
112
|
+
|
|
113
|
+
def _sign_raw(
|
|
114
|
+
self,
|
|
115
|
+
method: str,
|
|
116
|
+
path_and_query: str,
|
|
117
|
+
body: bytes,
|
|
118
|
+
authorization: str,
|
|
119
|
+
timestamp: datetime,
|
|
120
|
+
) -> bytes:
|
|
121
|
+
# Get big-endian representation of signature version and timestamp (FILETIME)
|
|
122
|
+
signature_version_bytes = self.get_signature_version_buffer(
|
|
123
|
+
self.signing_policy.version
|
|
124
|
+
)
|
|
125
|
+
ts_bytes = self.get_timestamp_buffer(timestamp)
|
|
126
|
+
|
|
127
|
+
# Concatenate bytes to sign + hash
|
|
128
|
+
data = self._concat_data_to_sign(
|
|
129
|
+
signature_version_bytes,
|
|
130
|
+
method,
|
|
131
|
+
path_and_query,
|
|
132
|
+
body,
|
|
133
|
+
authorization,
|
|
134
|
+
ts_bytes,
|
|
135
|
+
self.signing_policy.max_body_bytes,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Calculate digest
|
|
139
|
+
digest = self._hash(data)
|
|
140
|
+
|
|
141
|
+
# Sign the hash
|
|
142
|
+
signature = self.signing_key.sign_digest_deterministic(digest)
|
|
143
|
+
|
|
144
|
+
# Return signature version + timestamp encoded + signature
|
|
145
|
+
return signature_version_bytes + ts_bytes + signature
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _hash(data: bytes) -> bytes:
|
|
149
|
+
hash = hashlib.sha256()
|
|
150
|
+
hash.update(data)
|
|
151
|
+
return hash.digest()
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _concat_data_to_sign(
|
|
155
|
+
signature_version: bytes,
|
|
156
|
+
method: str,
|
|
157
|
+
path_and_query: str,
|
|
158
|
+
body: bytes,
|
|
159
|
+
authorization: str,
|
|
160
|
+
ts_bytes: bytes,
|
|
161
|
+
max_body_bytes: int,
|
|
162
|
+
) -> bytes:
|
|
163
|
+
body_size_to_hash = min(len(body), max_body_bytes)
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
signature_version
|
|
167
|
+
+ b"\x00"
|
|
168
|
+
+ ts_bytes
|
|
169
|
+
+ b"\x00"
|
|
170
|
+
+ method.upper().encode("ascii")
|
|
171
|
+
+ b"\x00"
|
|
172
|
+
+ path_and_query.encode("ascii")
|
|
173
|
+
+ b"\x00"
|
|
174
|
+
+ authorization.encode("ascii")
|
|
175
|
+
+ b"\x00"
|
|
176
|
+
+ body[:body_size_to_hash]
|
|
177
|
+
+ b"\x00"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def __base64_escaped(binary: bytes) -> str:
|
|
182
|
+
encoded = base64.b64encode(binary).decode("ascii")
|
|
183
|
+
encoded = encoded.rstrip("=")
|
|
184
|
+
encoded = encoded.replace("+", "-")
|
|
185
|
+
encoded = encoded.replace("/", "_")
|
|
186
|
+
return encoded
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def __encode_ec_coord(coord: int) -> str:
|
|
190
|
+
return RequestSigner.__base64_escaped(coord.to_bytes(32, "big"))
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Signed Session
|
|
3
|
+
A wrapper around httpx' AsyncClient which transparently calculates the "Signature" header.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ssl import SSLContext
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from httpx import Response
|
|
10
|
+
|
|
11
|
+
from pythonxbox.common.request_signer import RequestSigner
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SignedSession(httpx.AsyncClient):
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
request_signer: RequestSigner | None = None,
|
|
18
|
+
ssl_context: SSLContext | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
super().__init__(verify=ssl_context if ssl_context is not None else True)
|
|
21
|
+
|
|
22
|
+
self.request_signer = request_signer or RequestSigner()
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_pem_signing_key(cls, pem_string: str) -> "SignedSession":
|
|
26
|
+
request_signer = RequestSigner.from_pem(pem_string)
|
|
27
|
+
return cls(request_signer)
|
|
28
|
+
|
|
29
|
+
def _prepare_signed_request(self, request: httpx.Request) -> httpx.Request:
|
|
30
|
+
path_and_query = request.url.raw_path.decode()
|
|
31
|
+
authorization = request.headers.get("Authorization", "")
|
|
32
|
+
|
|
33
|
+
body = b""
|
|
34
|
+
for byte in request.stream:
|
|
35
|
+
body += byte
|
|
36
|
+
|
|
37
|
+
signature = self.request_signer.sign(
|
|
38
|
+
method=request.method,
|
|
39
|
+
path_and_query=path_and_query,
|
|
40
|
+
body=body,
|
|
41
|
+
authorization=authorization,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
request.headers["Signature"] = signature
|
|
45
|
+
return request
|
|
46
|
+
|
|
47
|
+
async def send_request_signed(self, request: httpx.Request) -> httpx.Response:
|
|
48
|
+
"""
|
|
49
|
+
Shorthand for prepare signed + send
|
|
50
|
+
"""
|
|
51
|
+
prepared = self._prepare_signed_request(request)
|
|
52
|
+
return await self.send(prepared)
|
|
53
|
+
|
|
54
|
+
async def send_signed(self, method: str, url: str, **kwargs) -> Response:
|
|
55
|
+
"""
|
|
56
|
+
Shorthand for creating request + prepare signed + send
|
|
57
|
+
"""
|
|
58
|
+
request = httpx.Request(method, url, **kwargs)
|
|
59
|
+
prepared = self._prepare_signed_request(request)
|
|
60
|
+
return await self.send(prepared)
|
pythonxbox/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from platformdirs import user_data_dir
|
|
4
|
+
|
|
5
|
+
CLIENT_ID = "388ea51c-0b25-4029-aae2-17df49d23905"
|
|
6
|
+
# No secret needed, we registered as "Desktop App" in Azure AD
|
|
7
|
+
CLIENT_SECRET = ""
|
|
8
|
+
REDIRECT_URI = "http://localhost:8080/auth/callback"
|
|
9
|
+
|
|
10
|
+
DATA_DIR = user_data_dir("xbox", "OpenXbox")
|
|
11
|
+
TOKENS_FILE = os.path.join(DATA_DIR, "tokens.json")
|
|
12
|
+
XAL_TOKENS_FILE = os.path.join(DATA_DIR, "xal_tokens.json")
|
|
13
|
+
|
|
14
|
+
if not os.path.exists(DATA_DIR):
|
|
15
|
+
os.makedirs(DATA_DIR)
|