icpc-api 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.
- icpc/__init__.py +32 -0
- icpc/api/__init__.py +18 -0
- icpc/api/common.py +97 -0
- icpc/api/contest.py +253 -0
- icpc/api/person.py +139 -0
- icpc/api/public.py +65 -0
- icpc/api/staff.py +83 -0
- icpc/api/team.py +335 -0
- icpc/auth/__init__.py +28 -0
- icpc/auth/cognito.py +142 -0
- icpc/auth/flows.py +314 -0
- icpc/auth/provider.py +29 -0
- icpc/auth/srp.py +185 -0
- icpc/auth/store.py +223 -0
- icpc/auth/tokens.py +86 -0
- icpc/cli/__init__.py +20 -0
- icpc/cli/columns.py +555 -0
- icpc/cli/main.py +1156 -0
- icpc/cli/render.py +160 -0
- icpc/config.py +57 -0
- icpc/errors.py +159 -0
- icpc/facade/__init__.py +6 -0
- icpc/facade/client.py +606 -0
- icpc/facade/domain.py +198 -0
- icpc/models/__init__.py +60 -0
- icpc/models/_generated.py +566 -0
- icpc/models/base.py +41 -0
- icpc/models/blobs.py +81 -0
- icpc/models/common.py +61 -0
- icpc/models/entities.py +522 -0
- icpc/models/enums.py +192 -0
- icpc/models/mixins.py +44 -0
- icpc/py.typed +0 -0
- icpc/search/__init__.py +99 -0
- icpc/search/_generated.py +1814 -0
- icpc/search/dsl.py +124 -0
- icpc/search/endpoint.py +173 -0
- icpc/search/fields.py +59 -0
- icpc/transport/__init__.py +29 -0
- icpc/transport/_shared.py +121 -0
- icpc/transport/async_client.py +120 -0
- icpc/transport/operation.py +139 -0
- icpc/transport/sync_client.py +121 -0
- icpc_api-0.1.0.dist-info/METADATA +143 -0
- icpc_api-0.1.0.dist-info/RECORD +50 -0
- icpc_api-0.1.0.dist-info/WHEEL +5 -0
- icpc_api-0.1.0.dist-info/entry_points.txt +2 -0
- icpc_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- icpc_api-0.1.0.dist-info/licenses/THIRD-PARTY-LICENSES.md +220 -0
- icpc_api-0.1.0.dist-info/top_level.txt +1 -0
icpc/auth/flows.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Authenticators: password (SRP), refresh token, or a raw id token.
|
|
2
|
+
|
|
3
|
+
An authenticator is what the transport asks for a bearer token. It renews
|
|
4
|
+
proactively (``Settings.refresh_margin`` seconds before expiry) and again on demand
|
|
5
|
+
when the transport sees a 401.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import threading
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from icpc import errors
|
|
17
|
+
from icpc.auth import cognito
|
|
18
|
+
from icpc.auth.cognito import Challenge, CognitoCall
|
|
19
|
+
from icpc.auth.srp import SrpSession
|
|
20
|
+
from icpc.auth.store import CredentialStore
|
|
21
|
+
from icpc.auth.tokens import TokenSet
|
|
22
|
+
from icpc.config import Settings
|
|
23
|
+
|
|
24
|
+
__all__ = ["AsyncCognitoAuth", "CognitoAuth", "StaticTokenAuth"]
|
|
25
|
+
|
|
26
|
+
_MFA_CHALLENGES = frozenset({cognito.SOFTWARE_TOKEN_MFA, cognito.SMS_MFA})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StaticTokenAuth:
|
|
30
|
+
"""A fixed id token, e.g. copied out of a browser session.
|
|
31
|
+
|
|
32
|
+
It cannot be renewed, so it stops working an hour after it was issued.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, token: str) -> None:
|
|
36
|
+
self._token = token
|
|
37
|
+
|
|
38
|
+
async def id_token(self) -> str: # AsyncTokenProvider
|
|
39
|
+
return self._token
|
|
40
|
+
|
|
41
|
+
async def invalidate(self) -> None:
|
|
42
|
+
raise errors.TokenExpired("the supplied id token was rejected and cannot be renewed")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SyncStaticTokenAuth:
|
|
46
|
+
"""Sync counterpart of :class:`StaticTokenAuth`."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, token: str) -> None:
|
|
49
|
+
self._token = token
|
|
50
|
+
|
|
51
|
+
def id_token(self) -> str: # TokenProvider
|
|
52
|
+
return self._token
|
|
53
|
+
|
|
54
|
+
def invalidate(self) -> None:
|
|
55
|
+
raise errors.TokenExpired("the supplied id token was rejected and cannot be renewed")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _Base:
|
|
59
|
+
"""State shared by the async and sync authenticators."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
*,
|
|
64
|
+
username: str | None = None,
|
|
65
|
+
password: str | None = None,
|
|
66
|
+
tokens: TokenSet | None = None,
|
|
67
|
+
store: CredentialStore | None = None,
|
|
68
|
+
settings: Settings | None = None,
|
|
69
|
+
save_password: bool = False,
|
|
70
|
+
make_default: bool = False,
|
|
71
|
+
) -> None:
|
|
72
|
+
self.settings = settings or Settings()
|
|
73
|
+
self.store = store
|
|
74
|
+
self.username = username
|
|
75
|
+
# The subclasses only ever assign a `str` here, we want to keep the
|
|
76
|
+
# attributes nullable.
|
|
77
|
+
self._password: str | None = password
|
|
78
|
+
self._tokens: TokenSet | None = tokens
|
|
79
|
+
#: Persist the password on the next successful login, so the session can
|
|
80
|
+
#: renew itself once the hour-long id token expires.
|
|
81
|
+
self.save_password = save_password
|
|
82
|
+
#: Make this the account used when no username is given.
|
|
83
|
+
self.make_default = make_default
|
|
84
|
+
if store is not None:
|
|
85
|
+
account = store.load(username)
|
|
86
|
+
if account is not None:
|
|
87
|
+
if self._tokens is None:
|
|
88
|
+
self._tokens = account.tokens
|
|
89
|
+
if self.username is None:
|
|
90
|
+
self.username = account.username
|
|
91
|
+
# A stored password is what makes unattended renewal possible at
|
|
92
|
+
# all: this pool has no working refresh-token flow.
|
|
93
|
+
if self._password is None:
|
|
94
|
+
self._password = account.password
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def tokens(self) -> TokenSet | None:
|
|
98
|
+
return self._tokens
|
|
99
|
+
|
|
100
|
+
def _remember(self, tokens: TokenSet) -> TokenSet:
|
|
101
|
+
self._tokens = tokens
|
|
102
|
+
if tokens.username:
|
|
103
|
+
self.username = tokens.username
|
|
104
|
+
if self.store is not None:
|
|
105
|
+
self.store.save_tokens(tokens, make_default=self.make_default)
|
|
106
|
+
if self.save_password and self.username and self._password:
|
|
107
|
+
self.store.save_password(self.username, self._password)
|
|
108
|
+
return tokens
|
|
109
|
+
|
|
110
|
+
def _srp(self, username: str, password: str) -> SrpSession:
|
|
111
|
+
return SrpSession.create(username, password, self.settings.user_pool_id)
|
|
112
|
+
|
|
113
|
+
def _need_password(self) -> tuple[str, str]:
|
|
114
|
+
if not self.username or not self._password:
|
|
115
|
+
raise errors.ConfigError(
|
|
116
|
+
"no valid token and no username/password to obtain one; "
|
|
117
|
+
"run `icpc auth login` or set ICPC_ID_TOKEN / ICPC_REFRESH_TOKEN"
|
|
118
|
+
)
|
|
119
|
+
return self.username, self._password
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _expect_password_verifier(outcome: cognito.Outcome) -> Challenge:
|
|
123
|
+
if not isinstance(outcome, Challenge):
|
|
124
|
+
raise errors.AuthError("Cognito issued tokens without asking for the password")
|
|
125
|
+
if outcome.name != cognito.PASSWORD_VERIFIER:
|
|
126
|
+
raise errors.AuthError(f"unsupported Cognito challenge: {outcome.name}")
|
|
127
|
+
return outcome
|
|
128
|
+
|
|
129
|
+
def _finish(self, outcome: cognito.Outcome, username: str, mfa_code: str | None) -> TokenSet:
|
|
130
|
+
if isinstance(outcome, TokenSet):
|
|
131
|
+
return self._remember(outcome)
|
|
132
|
+
if outcome.name in _MFA_CHALLENGES and mfa_code is None:
|
|
133
|
+
raise errors.MfaRequired(outcome.name, outcome.session, username)
|
|
134
|
+
raise errors.AuthError(f"unsupported Cognito challenge: {outcome.name}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class AsyncCognitoAuth(_Base):
|
|
138
|
+
"""Async authenticator implementing :class:`~icpc.auth.provider.AsyncTokenProvider`."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, *, http: httpx.AsyncClient | None = None, **kwargs: Any) -> None:
|
|
141
|
+
super().__init__(**kwargs)
|
|
142
|
+
self._owns_http = http is None
|
|
143
|
+
self._http = http or httpx.AsyncClient(timeout=self.settings.timeout)
|
|
144
|
+
self._lock = asyncio.Lock()
|
|
145
|
+
|
|
146
|
+
async def _call(self, call: CognitoCall) -> dict[str, Any]:
|
|
147
|
+
try:
|
|
148
|
+
response = await self._http.post(
|
|
149
|
+
self.settings.cognito_url, json=call.payload, headers=call.headers
|
|
150
|
+
)
|
|
151
|
+
except httpx.HTTPError as exc:
|
|
152
|
+
raise errors.TransportError(f"cognito {call.target}: {exc}") from exc
|
|
153
|
+
payload = response.json() if response.content else {}
|
|
154
|
+
cognito.raise_for_error(response.status_code, payload)
|
|
155
|
+
return payload
|
|
156
|
+
|
|
157
|
+
async def login(self, username: str, password: str, *, mfa_code: str | None = None) -> TokenSet:
|
|
158
|
+
"""Full SRP password login. Raises :class:`~icpc.errors.MfaRequired` if the
|
|
159
|
+
account has MFA and no ``mfa_code`` was supplied."""
|
|
160
|
+
srp = self._srp(username, password)
|
|
161
|
+
challenge = self._expect_password_verifier(
|
|
162
|
+
cognito.parse_outcome(await self._call(cognito.initiate_srp(self.settings, srp)))
|
|
163
|
+
)
|
|
164
|
+
responses = srp.process_challenge(challenge.parameters)
|
|
165
|
+
outcome = cognito.parse_outcome(
|
|
166
|
+
await self._call(
|
|
167
|
+
cognito.respond_password_verifier(self.settings, responses, challenge.session)
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
if isinstance(outcome, Challenge) and outcome.name in _MFA_CHALLENGES and mfa_code:
|
|
171
|
+
outcome = cognito.parse_outcome(
|
|
172
|
+
await self._call(cognito.respond_mfa(self.settings, outcome, username, mfa_code))
|
|
173
|
+
)
|
|
174
|
+
self.username = username
|
|
175
|
+
self._password = password
|
|
176
|
+
return self._finish(outcome, username, mfa_code)
|
|
177
|
+
|
|
178
|
+
async def complete_mfa(self, challenge: Challenge, username: str, code: str) -> TokenSet:
|
|
179
|
+
"""Answer a challenge carried by a previously raised :class:`MfaRequired`."""
|
|
180
|
+
outcome = cognito.parse_outcome(
|
|
181
|
+
await self._call(cognito.respond_mfa(self.settings, challenge, username, code))
|
|
182
|
+
)
|
|
183
|
+
return self._finish(outcome, username, code)
|
|
184
|
+
|
|
185
|
+
async def refresh(self) -> TokenSet:
|
|
186
|
+
"""Renew the id token with ``REFRESH_TOKEN_AUTH``, falling back to a login."""
|
|
187
|
+
current = self._tokens
|
|
188
|
+
if current is not None and current.refresh_token:
|
|
189
|
+
try:
|
|
190
|
+
payload = await self._call(cognito.refresh(self.settings, current.refresh_token))
|
|
191
|
+
except errors.InvalidCredentials as exc:
|
|
192
|
+
# A revoked refresh token, or a pool where REFRESH_TOKEN_AUTH is
|
|
193
|
+
# not enabled at all — which is the case for icpc.global. Fall
|
|
194
|
+
# back to a password login, but keep the reason if there is none.
|
|
195
|
+
if not (self.username and self._password):
|
|
196
|
+
raise errors.TokenExpired(
|
|
197
|
+
f"could not renew the token ({exc}); log in again with a "
|
|
198
|
+
f"password, or run `icpc auth login`"
|
|
199
|
+
) from exc
|
|
200
|
+
else:
|
|
201
|
+
renewed = cognito.parse_outcome(payload, refresh_token=current.refresh_token)
|
|
202
|
+
if isinstance(renewed, TokenSet):
|
|
203
|
+
return self._remember(renewed)
|
|
204
|
+
raise errors.AuthError(
|
|
205
|
+
f"refresh returned a {renewed.name} challenge instead of tokens"
|
|
206
|
+
)
|
|
207
|
+
username, password = self._need_password()
|
|
208
|
+
return await self.login(username, password)
|
|
209
|
+
|
|
210
|
+
async def id_token(self) -> str:
|
|
211
|
+
async with self._lock:
|
|
212
|
+
current = self._tokens
|
|
213
|
+
if current is not None and not current.expired(self.settings.refresh_margin):
|
|
214
|
+
return current.id_token
|
|
215
|
+
return (await self.refresh()).id_token
|
|
216
|
+
|
|
217
|
+
async def invalidate(self) -> None:
|
|
218
|
+
async with self._lock:
|
|
219
|
+
if self._tokens is not None:
|
|
220
|
+
self._tokens.expires_at = 0.0
|
|
221
|
+
|
|
222
|
+
async def aclose(self) -> None:
|
|
223
|
+
if self._owns_http:
|
|
224
|
+
await self._http.aclose()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class CognitoAuth(_Base):
|
|
228
|
+
"""Sync authenticator implementing :class:`~icpc.auth.provider.TokenProvider`."""
|
|
229
|
+
|
|
230
|
+
def __init__(self, *, http: httpx.Client | None = None, **kwargs: Any) -> None:
|
|
231
|
+
super().__init__(**kwargs)
|
|
232
|
+
self._owns_http = http is None
|
|
233
|
+
self._http = http or httpx.Client(timeout=self.settings.timeout)
|
|
234
|
+
self._lock = threading.Lock()
|
|
235
|
+
|
|
236
|
+
def _call(self, call: CognitoCall) -> dict[str, Any]:
|
|
237
|
+
try:
|
|
238
|
+
response = self._http.post(
|
|
239
|
+
self.settings.cognito_url, json=call.payload, headers=call.headers
|
|
240
|
+
)
|
|
241
|
+
except httpx.HTTPError as exc:
|
|
242
|
+
raise errors.TransportError(f"cognito {call.target}: {exc}") from exc
|
|
243
|
+
payload = response.json() if response.content else {}
|
|
244
|
+
cognito.raise_for_error(response.status_code, payload)
|
|
245
|
+
return payload
|
|
246
|
+
|
|
247
|
+
def login(self, username: str, password: str, *, mfa_code: str | None = None) -> TokenSet:
|
|
248
|
+
"""Full SRP password login. Raises :class:`~icpc.errors.MfaRequired` if the
|
|
249
|
+
account has MFA and no ``mfa_code`` was supplied."""
|
|
250
|
+
srp = self._srp(username, password)
|
|
251
|
+
challenge = self._expect_password_verifier(
|
|
252
|
+
cognito.parse_outcome(self._call(cognito.initiate_srp(self.settings, srp)))
|
|
253
|
+
)
|
|
254
|
+
responses = srp.process_challenge(challenge.parameters)
|
|
255
|
+
outcome = cognito.parse_outcome(
|
|
256
|
+
self._call(
|
|
257
|
+
cognito.respond_password_verifier(self.settings, responses, challenge.session)
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
if isinstance(outcome, Challenge) and outcome.name in _MFA_CHALLENGES and mfa_code:
|
|
261
|
+
outcome = cognito.parse_outcome(
|
|
262
|
+
self._call(cognito.respond_mfa(self.settings, outcome, username, mfa_code))
|
|
263
|
+
)
|
|
264
|
+
self.username = username
|
|
265
|
+
self._password = password
|
|
266
|
+
return self._finish(outcome, username, mfa_code)
|
|
267
|
+
|
|
268
|
+
def complete_mfa(self, challenge: Challenge, username: str, code: str) -> TokenSet:
|
|
269
|
+
"""Answer a challenge carried by a previously raised :class:`MfaRequired`."""
|
|
270
|
+
outcome = cognito.parse_outcome(
|
|
271
|
+
self._call(cognito.respond_mfa(self.settings, challenge, username, code))
|
|
272
|
+
)
|
|
273
|
+
return self._finish(outcome, username, code)
|
|
274
|
+
|
|
275
|
+
def refresh(self) -> TokenSet:
|
|
276
|
+
"""Renew the id token with ``REFRESH_TOKEN_AUTH``, falling back to a login."""
|
|
277
|
+
current = self._tokens
|
|
278
|
+
if current is not None and current.refresh_token:
|
|
279
|
+
try:
|
|
280
|
+
payload = self._call(cognito.refresh(self.settings, current.refresh_token))
|
|
281
|
+
except errors.InvalidCredentials as exc:
|
|
282
|
+
# A revoked refresh token, or a pool where REFRESH_TOKEN_AUTH is
|
|
283
|
+
# not enabled at all — which is the case for icpc.global. Fall
|
|
284
|
+
# back to a password login, but keep the reason if there is none.
|
|
285
|
+
if not (self.username and self._password):
|
|
286
|
+
raise errors.TokenExpired(
|
|
287
|
+
f"could not renew the token ({exc}); log in again with a "
|
|
288
|
+
f"password, or run `icpc auth login`"
|
|
289
|
+
) from exc
|
|
290
|
+
else:
|
|
291
|
+
renewed = cognito.parse_outcome(payload, refresh_token=current.refresh_token)
|
|
292
|
+
if isinstance(renewed, TokenSet):
|
|
293
|
+
return self._remember(renewed)
|
|
294
|
+
raise errors.AuthError(
|
|
295
|
+
f"refresh returned a {renewed.name} challenge instead of tokens"
|
|
296
|
+
)
|
|
297
|
+
username, password = self._need_password()
|
|
298
|
+
return self.login(username, password)
|
|
299
|
+
|
|
300
|
+
def id_token(self) -> str:
|
|
301
|
+
with self._lock:
|
|
302
|
+
current = self._tokens
|
|
303
|
+
if current is not None and not current.expired(self.settings.refresh_margin):
|
|
304
|
+
return current.id_token
|
|
305
|
+
return self.refresh().id_token
|
|
306
|
+
|
|
307
|
+
def invalidate(self) -> None:
|
|
308
|
+
with self._lock:
|
|
309
|
+
if self._tokens is not None:
|
|
310
|
+
self._tokens.expires_at = 0.0
|
|
311
|
+
|
|
312
|
+
def close(self) -> None:
|
|
313
|
+
if self._owns_http:
|
|
314
|
+
self._http.close()
|
icpc/auth/provider.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""What the transports need from an authenticator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
__all__ = ["AsyncTokenProvider", "TokenProvider"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class AsyncTokenProvider(Protocol):
|
|
12
|
+
async def id_token(self) -> str:
|
|
13
|
+
"""Return a currently valid Cognito id token, obtaining one if needed."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
async def invalidate(self) -> None:
|
|
17
|
+
"""Drop the cached token; the next call must fetch a fresh one.
|
|
18
|
+
|
|
19
|
+
Called by the transport after a 401, which is the only reliable signal that
|
|
20
|
+
a token the clock says is fine has actually been rejected.
|
|
21
|
+
"""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@runtime_checkable
|
|
26
|
+
class TokenProvider(Protocol):
|
|
27
|
+
def id_token(self) -> str: ...
|
|
28
|
+
|
|
29
|
+
def invalidate(self) -> None: ...
|
icpc/auth/srp.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""SRP-6a implementation used by Amazon Cognito.
|
|
2
|
+
|
|
3
|
+
Ported from warrant's ``aws_srp.py`` and reduced to the parts icpc.global's pool
|
|
4
|
+
actually exercises.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
|
|
17
|
+
__all__ = ["SrpSession", "pad_hex"]
|
|
18
|
+
|
|
19
|
+
# https://github.com/aws-amplify/amplify-js/blob/36e3ce19983925ee6a68b75ebd9a01a95100989b/packages/auth/src/providers/cognito/utils/srp/AuthenticationHelper/AuthenticationHelper.ts
|
|
20
|
+
_N_HEX = (
|
|
21
|
+
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1"
|
|
22
|
+
"29024E088A67CC74020BBEA63B139B22514A08798E3404DD"
|
|
23
|
+
"EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245"
|
|
24
|
+
"E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED"
|
|
25
|
+
"EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D"
|
|
26
|
+
"C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F"
|
|
27
|
+
"83655D23DCA3AD961C62F356208552BB9ED529077096966D"
|
|
28
|
+
"670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B"
|
|
29
|
+
"E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9"
|
|
30
|
+
"DE2BCBF6955817183995497CEA956AE515D2261898FA0510"
|
|
31
|
+
"15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64"
|
|
32
|
+
"ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7"
|
|
33
|
+
"ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B"
|
|
34
|
+
"F12FFA06D98A0864D87602733EC86A64521F2B18177B200C"
|
|
35
|
+
"BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31"
|
|
36
|
+
"43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF"
|
|
37
|
+
)
|
|
38
|
+
_G_HEX = "2"
|
|
39
|
+
_INFO_BITS = b"Caldera Derived Key"
|
|
40
|
+
|
|
41
|
+
BIG_N = int(_N_HEX, 16)
|
|
42
|
+
G = int(_G_HEX, 16)
|
|
43
|
+
|
|
44
|
+
_TIMESTAMP_DAY = re.compile(r" 0(\d) ")
|
|
45
|
+
_WEEKDAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
|
46
|
+
_MONTHS = (
|
|
47
|
+
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
48
|
+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
49
|
+
) # fmt: skip
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _hash_sha256(buf: bytes) -> str:
|
|
53
|
+
digest = hashlib.sha256(buf).hexdigest()
|
|
54
|
+
return digest.rjust(64, "0")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _hex_hash(hex_string: str) -> str:
|
|
58
|
+
return _hash_sha256(bytes.fromhex(hex_string))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def pad_hex(value: int | str) -> str:
|
|
62
|
+
"""Hex-encode for hashing, padded the way Cognito expects.
|
|
63
|
+
|
|
64
|
+
An odd-length string gets one leading zero; a string whose first nibble is >= 8
|
|
65
|
+
gets two, so it is never mistaken for a negative two's-complement number.
|
|
66
|
+
"""
|
|
67
|
+
hex_str = value if isinstance(value, str) else f"{value:x}"
|
|
68
|
+
if len(hex_str) % 2 == 1:
|
|
69
|
+
return "0" + hex_str
|
|
70
|
+
if hex_str[0] in "89ABCDEFabcdef":
|
|
71
|
+
return "00" + hex_str
|
|
72
|
+
return hex_str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _compute_hkdf(ikm: bytes, salt: bytes) -> bytes:
|
|
76
|
+
prk = hmac.new(salt, ikm, hashlib.sha256).digest()
|
|
77
|
+
return hmac.new(prk, _INFO_BITS + b"\x01", hashlib.sha256).digest()[:16]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _calculate_u(big_a: int, big_b: int) -> int:
|
|
81
|
+
return int(_hex_hash(pad_hex(big_a) + pad_hex(big_b)), 16)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cognito_timestamp(now: datetime) -> str:
|
|
85
|
+
"""``Tue Jan 7 09:04:11 UTC 2026`` — English names, UTC, no leading zero on the day.
|
|
86
|
+
|
|
87
|
+
``strftime("%a %b")`` would be locale-dependent, so the names are spelled out.
|
|
88
|
+
"""
|
|
89
|
+
moment = now.astimezone(UTC)
|
|
90
|
+
stamp = (
|
|
91
|
+
f"{_WEEKDAYS[moment.weekday()]} {_MONTHS[moment.month - 1]} "
|
|
92
|
+
f"{moment.day:02d} {moment:%H:%M:%S} UTC {moment.year}"
|
|
93
|
+
)
|
|
94
|
+
return _TIMESTAMP_DAY.sub(r" \1 ", stamp)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class SrpSession:
|
|
99
|
+
"""One client-side SRP exchange. Create it, send ``auth_parameters()``, then
|
|
100
|
+
answer the server's challenge with ``process_challenge()``."""
|
|
101
|
+
|
|
102
|
+
username: str
|
|
103
|
+
password: str
|
|
104
|
+
pool_id: str
|
|
105
|
+
a: int
|
|
106
|
+
big_a: int
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def create(
|
|
110
|
+
cls, username: str, password: str, pool_id: str, *, a: int | None = None
|
|
111
|
+
) -> SrpSession:
|
|
112
|
+
"""``a`` is injectable so tests can pin the exchange; otherwise it is random."""
|
|
113
|
+
small_a = a if a is not None else int.from_bytes(os.urandom(128), "big") % BIG_N
|
|
114
|
+
big_a = pow(G, small_a, BIG_N)
|
|
115
|
+
if big_a % BIG_N == 0:
|
|
116
|
+
raise ValueError("SRP safety check failed: A % N == 0")
|
|
117
|
+
return cls(username=username, password=password, pool_id=pool_id, a=small_a, big_a=big_a)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def pool_name(self) -> str:
|
|
121
|
+
"""``WaDOo4Gqm`` for ``us-east-1_WaDOo4Gqm`` — the part Cognito hashes."""
|
|
122
|
+
return self.pool_id.split("_", 1)[1]
|
|
123
|
+
|
|
124
|
+
def auth_parameters(self) -> dict[str, str]:
|
|
125
|
+
"""``AuthParameters`` for the initial ``InitiateAuth`` call.
|
|
126
|
+
|
|
127
|
+
icpc.global's pool answers ``CUSTOM_AUTH`` + ``CHALLENGE_NAME: SRP_A`` with a
|
|
128
|
+
``PASSWORD_VERIFIER`` challenge; the app client has no secret, so there is no
|
|
129
|
+
``SECRET_HASH``.
|
|
130
|
+
"""
|
|
131
|
+
return {
|
|
132
|
+
"CHALLENGE_NAME": "SRP_A",
|
|
133
|
+
"USERNAME": self.username,
|
|
134
|
+
"SRP_A": f"{self.big_a:x}",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
def _password_key(self, user_id_for_srp: str, server_b: int, salt_hex: str) -> bytes:
|
|
138
|
+
u_value = _calculate_u(self.big_a, server_b)
|
|
139
|
+
if u_value == 0:
|
|
140
|
+
raise ValueError("SRP safety check failed: U == 0")
|
|
141
|
+
credentials = f"{self.pool_name}{user_id_for_srp}:{self.password}"
|
|
142
|
+
credentials_hash = _hash_sha256(credentials.encode())
|
|
143
|
+
x_value = int(_hex_hash(pad_hex(salt_hex) + credentials_hash), 16)
|
|
144
|
+
s_value = pow(
|
|
145
|
+
server_b - _K * pow(G, x_value, BIG_N),
|
|
146
|
+
self.a + u_value * x_value,
|
|
147
|
+
BIG_N,
|
|
148
|
+
)
|
|
149
|
+
return _compute_hkdf(
|
|
150
|
+
bytes.fromhex(pad_hex(s_value)),
|
|
151
|
+
bytes.fromhex(pad_hex(f"{u_value:x}")),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def process_challenge(
|
|
155
|
+
self, challenge_parameters: dict[str, str], *, now: datetime | None = None
|
|
156
|
+
) -> dict[str, str]:
|
|
157
|
+
"""Build ``ChallengeResponses`` for the ``PASSWORD_VERIFIER`` challenge."""
|
|
158
|
+
user_id_for_srp = challenge_parameters["USER_ID_FOR_SRP"]
|
|
159
|
+
secret_block = challenge_parameters["SECRET_BLOCK"]
|
|
160
|
+
timestamp = cognito_timestamp(now or datetime.now(UTC))
|
|
161
|
+
|
|
162
|
+
key = self._password_key(
|
|
163
|
+
user_id_for_srp,
|
|
164
|
+
int(challenge_parameters["SRP_B"], 16),
|
|
165
|
+
challenge_parameters["SALT"],
|
|
166
|
+
)
|
|
167
|
+
message = (
|
|
168
|
+
self.pool_name.encode()
|
|
169
|
+
+ user_id_for_srp.encode()
|
|
170
|
+
+ base64.standard_b64decode(secret_block)
|
|
171
|
+
+ timestamp.encode()
|
|
172
|
+
)
|
|
173
|
+
signature = base64.standard_b64encode(
|
|
174
|
+
hmac.new(key, message, hashlib.sha256).digest()
|
|
175
|
+
).decode()
|
|
176
|
+
return {
|
|
177
|
+
"TIMESTAMP": timestamp,
|
|
178
|
+
# Cognito wants the id it just handed us, not the email that was typed.
|
|
179
|
+
"USERNAME": user_id_for_srp,
|
|
180
|
+
"PASSWORD_CLAIM_SECRET_BLOCK": secret_block,
|
|
181
|
+
"PASSWORD_CLAIM_SIGNATURE": signature,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
_K = int(_hex_hash("00" + _N_HEX + "0" + _G_HEX), 16)
|