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.
Files changed (50) hide show
  1. icpc/__init__.py +32 -0
  2. icpc/api/__init__.py +18 -0
  3. icpc/api/common.py +97 -0
  4. icpc/api/contest.py +253 -0
  5. icpc/api/person.py +139 -0
  6. icpc/api/public.py +65 -0
  7. icpc/api/staff.py +83 -0
  8. icpc/api/team.py +335 -0
  9. icpc/auth/__init__.py +28 -0
  10. icpc/auth/cognito.py +142 -0
  11. icpc/auth/flows.py +314 -0
  12. icpc/auth/provider.py +29 -0
  13. icpc/auth/srp.py +185 -0
  14. icpc/auth/store.py +223 -0
  15. icpc/auth/tokens.py +86 -0
  16. icpc/cli/__init__.py +20 -0
  17. icpc/cli/columns.py +555 -0
  18. icpc/cli/main.py +1156 -0
  19. icpc/cli/render.py +160 -0
  20. icpc/config.py +57 -0
  21. icpc/errors.py +159 -0
  22. icpc/facade/__init__.py +6 -0
  23. icpc/facade/client.py +606 -0
  24. icpc/facade/domain.py +198 -0
  25. icpc/models/__init__.py +60 -0
  26. icpc/models/_generated.py +566 -0
  27. icpc/models/base.py +41 -0
  28. icpc/models/blobs.py +81 -0
  29. icpc/models/common.py +61 -0
  30. icpc/models/entities.py +522 -0
  31. icpc/models/enums.py +192 -0
  32. icpc/models/mixins.py +44 -0
  33. icpc/py.typed +0 -0
  34. icpc/search/__init__.py +99 -0
  35. icpc/search/_generated.py +1814 -0
  36. icpc/search/dsl.py +124 -0
  37. icpc/search/endpoint.py +173 -0
  38. icpc/search/fields.py +59 -0
  39. icpc/transport/__init__.py +29 -0
  40. icpc/transport/_shared.py +121 -0
  41. icpc/transport/async_client.py +120 -0
  42. icpc/transport/operation.py +139 -0
  43. icpc/transport/sync_client.py +121 -0
  44. icpc_api-0.1.0.dist-info/METADATA +143 -0
  45. icpc_api-0.1.0.dist-info/RECORD +50 -0
  46. icpc_api-0.1.0.dist-info/WHEEL +5 -0
  47. icpc_api-0.1.0.dist-info/entry_points.txt +2 -0
  48. icpc_api-0.1.0.dist-info/licenses/LICENSE +21 -0
  49. icpc_api-0.1.0.dist-info/licenses/THIRD-PARTY-LICENSES.md +220 -0
  50. 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)