netizen-cli 0.10.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.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
"""In-memory authentication state for the single-administrator Admin Web.
|
|
2
|
+
|
|
3
|
+
This module deliberately contains no HTTP concepts. It owns the credential
|
|
4
|
+
generation, opaque browser tokens, login throttling, and one-shot action
|
|
5
|
+
authorization state; the HTTP adapter is responsible for cookies, Host,
|
|
6
|
+
Origin, and status-code mapping.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import binascii
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import ipaddress
|
|
16
|
+
import math
|
|
17
|
+
import os
|
|
18
|
+
import secrets
|
|
19
|
+
import stat
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from collections import deque
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from dataclasses import dataclass, field, fields, is_dataclass
|
|
25
|
+
from enum import Enum
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Generic, TypeVar, cast
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_TOKEN_BYTES = 32
|
|
31
|
+
_TOKEN_TEXT_BYTES = 43
|
|
32
|
+
_MAX_CREDENTIAL_FILE_BYTES = _TOKEN_TEXT_BYTES + 1
|
|
33
|
+
_DUMMY_DIGEST = hashlib.sha256(bytes(_TOKEN_BYTES)).digest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AdminAuthError(RuntimeError):
|
|
37
|
+
"""Base class for stable, non-sensitive Admin authentication failures."""
|
|
38
|
+
|
|
39
|
+
code = "admin_auth_error"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CredentialFileError(AdminAuthError):
|
|
43
|
+
code = "credential_unavailable"
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
super().__init__("admin credential unavailable")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AdmissionClosed(AdminAuthError):
|
|
50
|
+
code = "admin_admission_closed"
|
|
51
|
+
|
|
52
|
+
def __init__(self) -> None:
|
|
53
|
+
super().__init__("admin admission is closed")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class LoginRejected(AdminAuthError):
|
|
57
|
+
"""The intentionally uniform result for every failed login path."""
|
|
58
|
+
|
|
59
|
+
code = "login_rejected"
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
super().__init__("admin login rejected")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AuthCapacityExceeded(AdminAuthError):
|
|
66
|
+
code = "auth_capacity_exceeded"
|
|
67
|
+
|
|
68
|
+
def __init__(self) -> None:
|
|
69
|
+
super().__init__("admin authentication capacity unavailable")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SessionRejected(AdminAuthError):
|
|
73
|
+
code = "session_rejected"
|
|
74
|
+
|
|
75
|
+
def __init__(self) -> None:
|
|
76
|
+
super().__init__("admin session rejected")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ActionGrantError(AdminAuthError):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class MalformedActionGrant(ActionGrantError):
|
|
84
|
+
code = "malformed_action_grant"
|
|
85
|
+
|
|
86
|
+
def __init__(self) -> None:
|
|
87
|
+
super().__init__("malformed action authorization")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ActionCsrfRejected(ActionGrantError):
|
|
91
|
+
code = "action_csrf_rejected"
|
|
92
|
+
|
|
93
|
+
def __init__(self) -> None:
|
|
94
|
+
super().__init__("action CSRF authorization rejected")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class StaleActionGrant(ActionGrantError):
|
|
98
|
+
code = "stale_action_grant"
|
|
99
|
+
|
|
100
|
+
def __init__(self) -> None:
|
|
101
|
+
super().__init__("stale action authorization")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ConsumedActionGrant(ActionGrantError):
|
|
105
|
+
code = "consumed_action_grant"
|
|
106
|
+
|
|
107
|
+
def __init__(self) -> None:
|
|
108
|
+
super().__init__("action authorization was already consumed")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class InvalidActionPayload(ValueError):
|
|
112
|
+
def __init__(self) -> None:
|
|
113
|
+
super().__init__(
|
|
114
|
+
"action target and preconditions must be typed immutable values"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True)
|
|
119
|
+
class AuthLimits:
|
|
120
|
+
"""Resource and lifetime limits for process-local authentication state."""
|
|
121
|
+
|
|
122
|
+
action_ttl: float = 10 * 60
|
|
123
|
+
login_window: float = 5 * 60
|
|
124
|
+
login_failures_per_source: int = 5
|
|
125
|
+
login_failures_global: int = 20
|
|
126
|
+
max_rate_sources: int = 1_024
|
|
127
|
+
max_preauth: int = 1_024
|
|
128
|
+
max_preauth_per_source: int = 16
|
|
129
|
+
max_sessions: int = 256
|
|
130
|
+
max_sessions_per_source: int = 16
|
|
131
|
+
# A 100-row Sessions page can expose several exact actions per row, and
|
|
132
|
+
# cursor navigation keeps the previous page's one-shot grants alive until
|
|
133
|
+
# their normal TTL. Keep this bounded while allowing several full pages.
|
|
134
|
+
max_actions: int = 32_768
|
|
135
|
+
max_actions_per_session: int = 8_192
|
|
136
|
+
|
|
137
|
+
def __post_init__(self) -> None:
|
|
138
|
+
durations = (
|
|
139
|
+
self.action_ttl,
|
|
140
|
+
self.login_window,
|
|
141
|
+
)
|
|
142
|
+
if any(
|
|
143
|
+
isinstance(value, bool)
|
|
144
|
+
or not isinstance(value, (int, float))
|
|
145
|
+
or not math.isfinite(float(value))
|
|
146
|
+
or value <= 0
|
|
147
|
+
for value in durations
|
|
148
|
+
):
|
|
149
|
+
raise ValueError("authentication TTLs must be positive finite numbers")
|
|
150
|
+
capacities = (
|
|
151
|
+
self.login_failures_per_source,
|
|
152
|
+
self.login_failures_global,
|
|
153
|
+
self.max_rate_sources,
|
|
154
|
+
self.max_preauth,
|
|
155
|
+
self.max_preauth_per_source,
|
|
156
|
+
self.max_sessions,
|
|
157
|
+
self.max_sessions_per_source,
|
|
158
|
+
self.max_actions,
|
|
159
|
+
self.max_actions_per_session,
|
|
160
|
+
)
|
|
161
|
+
if any(type(value) is not int or value <= 0 for value in capacities):
|
|
162
|
+
raise ValueError("authentication capacities must be positive integers")
|
|
163
|
+
if self.max_preauth_per_source > self.max_preauth:
|
|
164
|
+
raise ValueError("per-source pre-auth capacity exceeds global capacity")
|
|
165
|
+
if self.max_sessions_per_source > self.max_sessions:
|
|
166
|
+
raise ValueError("per-source session capacity exceeds global capacity")
|
|
167
|
+
if self.max_actions_per_session > self.max_actions:
|
|
168
|
+
raise ValueError("per-session action capacity exceeds global capacity")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True, slots=True)
|
|
172
|
+
class CredentialIdentity:
|
|
173
|
+
"""Identity fields whose legal change constitutes credential rotation."""
|
|
174
|
+
|
|
175
|
+
device: int
|
|
176
|
+
inode: int
|
|
177
|
+
mode: int
|
|
178
|
+
links: int
|
|
179
|
+
owner: int
|
|
180
|
+
group: int
|
|
181
|
+
size: int
|
|
182
|
+
modified_ns: int
|
|
183
|
+
changed_ns: int
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True, slots=True)
|
|
187
|
+
class CredentialSnapshot:
|
|
188
|
+
identity: CredentialIdentity
|
|
189
|
+
digest: bytes = field(repr=False)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True, slots=True)
|
|
193
|
+
class IssuedPreauthChallenge:
|
|
194
|
+
cookie_token: str = field(repr=False)
|
|
195
|
+
form_nonce: str = field(repr=False)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(frozen=True, slots=True)
|
|
199
|
+
class IssuedSession:
|
|
200
|
+
token: str = field(repr=False)
|
|
201
|
+
log_handle: str
|
|
202
|
+
generation: int
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True, slots=True)
|
|
206
|
+
class AuthenticatedSession:
|
|
207
|
+
log_handle: str
|
|
208
|
+
generation: int
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class ExpectationMode(str, Enum):
|
|
212
|
+
"""Three-state expectation; EXPECT_NONE is not DONT_CHECK."""
|
|
213
|
+
|
|
214
|
+
DONT_CHECK = "dont_check"
|
|
215
|
+
EXPECT_NONE = "expect_none"
|
|
216
|
+
EXPECT_VALUE = "expect_value"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
ExpectedT = TypeVar("ExpectedT")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass(frozen=True, slots=True)
|
|
223
|
+
class ExpectedValue(Generic[ExpectedT]):
|
|
224
|
+
mode: ExpectationMode
|
|
225
|
+
value: ExpectedT | None = None
|
|
226
|
+
|
|
227
|
+
def __post_init__(self) -> None:
|
|
228
|
+
if not isinstance(self.mode, ExpectationMode):
|
|
229
|
+
raise ValueError("expectation mode must be an ExpectationMode")
|
|
230
|
+
if self.mode is ExpectationMode.EXPECT_VALUE:
|
|
231
|
+
if self.value is None:
|
|
232
|
+
raise ValueError("EXPECT_VALUE requires a value")
|
|
233
|
+
elif self.value is not None:
|
|
234
|
+
raise ValueError("only EXPECT_VALUE accepts a value")
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
def dont_check(cls) -> "ExpectedValue[ExpectedT]":
|
|
238
|
+
return cls(ExpectationMode.DONT_CHECK)
|
|
239
|
+
|
|
240
|
+
@classmethod
|
|
241
|
+
def expect_none(cls) -> "ExpectedValue[ExpectedT]":
|
|
242
|
+
return cls(ExpectationMode.EXPECT_NONE)
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def expect(cls, value: ExpectedT) -> "ExpectedValue[ExpectedT]":
|
|
246
|
+
return cls(ExpectationMode.EXPECT_VALUE, value)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
TargetT = TypeVar("TargetT")
|
|
250
|
+
PreconditionsT = TypeVar("PreconditionsT")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dataclass(frozen=True, slots=True)
|
|
254
|
+
class IssuedActionGrant:
|
|
255
|
+
csrf_token: str = field(repr=False)
|
|
256
|
+
action_token: str = field(repr=False)
|
|
257
|
+
expires_at: float
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@dataclass(frozen=True, slots=True)
|
|
261
|
+
class RedeemedAction(Generic[TargetT, PreconditionsT]):
|
|
262
|
+
session_log_handle: str
|
|
263
|
+
generation: int
|
|
264
|
+
action_kind: str
|
|
265
|
+
target: TargetT
|
|
266
|
+
preconditions: PreconditionsT
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass(frozen=True, slots=True)
|
|
270
|
+
class AuthStateCounts:
|
|
271
|
+
admission_open: bool
|
|
272
|
+
generation: int
|
|
273
|
+
preauth: int
|
|
274
|
+
sessions: int
|
|
275
|
+
actions: int
|
|
276
|
+
rate_sources: int
|
|
277
|
+
global_failures: int
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass(slots=True, repr=False)
|
|
281
|
+
class _PreauthRecord:
|
|
282
|
+
form_digest: bytes = field(repr=False)
|
|
283
|
+
source: str
|
|
284
|
+
generation: int
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclass(slots=True, repr=False)
|
|
288
|
+
class _SessionRecord:
|
|
289
|
+
source: str
|
|
290
|
+
log_handle: str
|
|
291
|
+
generation: int
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@dataclass(slots=True, repr=False)
|
|
295
|
+
class _ActionRecord:
|
|
296
|
+
session_digest: bytes = field(repr=False)
|
|
297
|
+
csrf_digest: bytes = field(repr=False)
|
|
298
|
+
generation: int
|
|
299
|
+
action_kind: str
|
|
300
|
+
target: object
|
|
301
|
+
preconditions: object
|
|
302
|
+
expires_at: float
|
|
303
|
+
consumed: bool = False
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def normalize_source_ip(source: str) -> str:
|
|
307
|
+
"""Return one stable spelling for a transport-provided source address."""
|
|
308
|
+
|
|
309
|
+
if not isinstance(source, str) or not source or len(source) > 128:
|
|
310
|
+
raise ValueError("invalid source address")
|
|
311
|
+
if "%" in source:
|
|
312
|
+
# A socket peer address already has a separate scope-id field. Accepting
|
|
313
|
+
# arbitrary textual zones here would let equivalent IPs occupy counters.
|
|
314
|
+
raise ValueError("invalid source address")
|
|
315
|
+
try:
|
|
316
|
+
address = ipaddress.ip_address(source)
|
|
317
|
+
except ValueError as error:
|
|
318
|
+
raise ValueError("invalid source address") from error
|
|
319
|
+
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None:
|
|
320
|
+
return address.ipv4_mapped.compressed
|
|
321
|
+
return address.compressed.lower()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def load_credential_snapshot(path: str | Path) -> CredentialSnapshot:
|
|
325
|
+
"""Read and validate the credential without following the final symlink.
|
|
326
|
+
|
|
327
|
+
The two metadata snapshots and the content read all use the same file
|
|
328
|
+
descriptor. The returned object contains only a digest, never credential
|
|
329
|
+
text or decoded secret bytes.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
credential_path = os.fspath(path)
|
|
333
|
+
if not os.path.isabs(credential_path):
|
|
334
|
+
raise CredentialFileError()
|
|
335
|
+
nofollow = getattr(os, "O_NOFOLLOW", None)
|
|
336
|
+
cloexec = getattr(os, "O_CLOEXEC", None)
|
|
337
|
+
if not nofollow or cloexec is None:
|
|
338
|
+
raise CredentialFileError()
|
|
339
|
+
flags = os.O_RDONLY | nofollow | cloexec
|
|
340
|
+
try:
|
|
341
|
+
descriptor = os.open(credential_path, flags)
|
|
342
|
+
except OSError:
|
|
343
|
+
raise CredentialFileError() from None
|
|
344
|
+
try:
|
|
345
|
+
try:
|
|
346
|
+
before = os.fstat(descriptor)
|
|
347
|
+
_validate_credential_metadata(before)
|
|
348
|
+
content = _read_bounded(descriptor, _MAX_CREDENTIAL_FILE_BYTES)
|
|
349
|
+
after = os.fstat(descriptor)
|
|
350
|
+
_validate_credential_metadata(after)
|
|
351
|
+
except OSError:
|
|
352
|
+
raise CredentialFileError() from None
|
|
353
|
+
before_identity = _credential_identity(before)
|
|
354
|
+
after_identity = _credential_identity(after)
|
|
355
|
+
if before_identity != after_identity:
|
|
356
|
+
raise CredentialFileError()
|
|
357
|
+
digest = _credential_digest(content, allow_terminal_lf=True)
|
|
358
|
+
if digest is None:
|
|
359
|
+
raise CredentialFileError()
|
|
360
|
+
return CredentialSnapshot(identity=after_identity, digest=digest)
|
|
361
|
+
finally:
|
|
362
|
+
os.close(descriptor)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _validate_credential_metadata(metadata: os.stat_result) -> None:
|
|
366
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
367
|
+
raise CredentialFileError()
|
|
368
|
+
if stat.S_IMODE(metadata.st_mode) != 0o600:
|
|
369
|
+
raise CredentialFileError()
|
|
370
|
+
if metadata.st_size < _TOKEN_TEXT_BYTES:
|
|
371
|
+
raise CredentialFileError()
|
|
372
|
+
if metadata.st_size > _MAX_CREDENTIAL_FILE_BYTES:
|
|
373
|
+
raise CredentialFileError()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _credential_identity(metadata: os.stat_result) -> CredentialIdentity:
|
|
377
|
+
return CredentialIdentity(
|
|
378
|
+
device=metadata.st_dev,
|
|
379
|
+
inode=metadata.st_ino,
|
|
380
|
+
mode=metadata.st_mode,
|
|
381
|
+
links=metadata.st_nlink,
|
|
382
|
+
owner=metadata.st_uid,
|
|
383
|
+
group=metadata.st_gid,
|
|
384
|
+
size=metadata.st_size,
|
|
385
|
+
modified_ns=metadata.st_mtime_ns,
|
|
386
|
+
changed_ns=metadata.st_ctime_ns,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _read_bounded(descriptor: int, maximum: int) -> bytes:
|
|
391
|
+
content = bytearray()
|
|
392
|
+
while len(content) <= maximum:
|
|
393
|
+
chunk = os.read(descriptor, maximum + 1 - len(content))
|
|
394
|
+
if not chunk:
|
|
395
|
+
return bytes(content)
|
|
396
|
+
content.extend(chunk)
|
|
397
|
+
raise CredentialFileError()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _credential_digest(content: bytes, *, allow_terminal_lf: bool) -> bytes | None:
|
|
401
|
+
token = content
|
|
402
|
+
if allow_terminal_lf and token.endswith(b"\n"):
|
|
403
|
+
token = token[:-1]
|
|
404
|
+
if len(token) != _TOKEN_TEXT_BYTES:
|
|
405
|
+
return None
|
|
406
|
+
if b"\n" in token or b"\r" in token:
|
|
407
|
+
return None
|
|
408
|
+
try:
|
|
409
|
+
decoded = base64.b64decode(token + b"=", altchars=b"-_", validate=True)
|
|
410
|
+
except (ValueError, binascii.Error):
|
|
411
|
+
return None
|
|
412
|
+
if len(decoded) != _TOKEN_BYTES:
|
|
413
|
+
return None
|
|
414
|
+
canonical = base64.urlsafe_b64encode(decoded).rstrip(b"=")
|
|
415
|
+
if not hmac.compare_digest(canonical, token):
|
|
416
|
+
return None
|
|
417
|
+
return hashlib.sha256(decoded).digest()
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _token_digest(token: object) -> bytes | None:
|
|
421
|
+
if not isinstance(token, str):
|
|
422
|
+
return None
|
|
423
|
+
try:
|
|
424
|
+
encoded = token.encode("ascii")
|
|
425
|
+
except UnicodeEncodeError:
|
|
426
|
+
return None
|
|
427
|
+
return _credential_digest(encoded, allow_terminal_lf=False)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _new_token() -> tuple[str, bytes]:
|
|
431
|
+
token = secrets.token_urlsafe(_TOKEN_BYTES)
|
|
432
|
+
digest = _token_digest(token)
|
|
433
|
+
if digest is None: # pragma: no cover - protects the stdlib contract.
|
|
434
|
+
raise RuntimeError("secure token generator returned a non-canonical token")
|
|
435
|
+
return token, digest
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
class AdminAuth:
|
|
439
|
+
"""Thread-safe, process-local authentication and action grant owner."""
|
|
440
|
+
|
|
441
|
+
def __init__(
|
|
442
|
+
self,
|
|
443
|
+
credential_path: str | Path,
|
|
444
|
+
*,
|
|
445
|
+
clock: Callable[[], float] = time.monotonic,
|
|
446
|
+
limits: AuthLimits | None = None,
|
|
447
|
+
) -> None:
|
|
448
|
+
snapshot = load_credential_snapshot(credential_path)
|
|
449
|
+
self._credential_path = Path(credential_path)
|
|
450
|
+
self._clock = clock
|
|
451
|
+
self._limits = AuthLimits() if limits is None else limits
|
|
452
|
+
self._lock = threading.RLock()
|
|
453
|
+
self._credential_digest: bytes | None = snapshot.digest
|
|
454
|
+
self._credential_identity: CredentialIdentity | None = snapshot.identity
|
|
455
|
+
self._generation = 1
|
|
456
|
+
self._admission_open = True
|
|
457
|
+
self._last_now: float | None = None
|
|
458
|
+
self._preauth: dict[bytes, _PreauthRecord] = {}
|
|
459
|
+
self._sessions: dict[bytes, _SessionRecord] = {}
|
|
460
|
+
self._actions: dict[bytes, _ActionRecord] = {}
|
|
461
|
+
self._rate_by_source: dict[str, deque[float]] = {}
|
|
462
|
+
self._rate_global: deque[float] = deque()
|
|
463
|
+
|
|
464
|
+
@property
|
|
465
|
+
def generation(self) -> int:
|
|
466
|
+
with self._lock:
|
|
467
|
+
return self._generation
|
|
468
|
+
|
|
469
|
+
@property
|
|
470
|
+
def admission_open(self) -> bool:
|
|
471
|
+
with self._lock:
|
|
472
|
+
return self._admission_open
|
|
473
|
+
|
|
474
|
+
def refresh_credential(self) -> bool:
|
|
475
|
+
"""Validate the credential path and apply a legal rotation atomically."""
|
|
476
|
+
|
|
477
|
+
with self._lock:
|
|
478
|
+
return self._refresh_credential_locked()
|
|
479
|
+
|
|
480
|
+
def issue_preauth(self, source_ip: str) -> IssuedPreauthChallenge:
|
|
481
|
+
with self._lock:
|
|
482
|
+
self._refresh_credential_locked()
|
|
483
|
+
now = self._now_locked()
|
|
484
|
+
self._prune_locked(now)
|
|
485
|
+
source = self._normalize_login_source(source_ip)
|
|
486
|
+
if not self._rate_allowed_locked(source, now):
|
|
487
|
+
raise LoginRejected()
|
|
488
|
+
# Challenges do not expire with time. Reclaim the oldest at capacity
|
|
489
|
+
# so abandoned login pages cannot permanently prevent new logins.
|
|
490
|
+
source_challenges = [
|
|
491
|
+
digest
|
|
492
|
+
for digest, record in self._preauth.items()
|
|
493
|
+
if record.source == source
|
|
494
|
+
]
|
|
495
|
+
if len(source_challenges) >= self._limits.max_preauth_per_source:
|
|
496
|
+
self._preauth.pop(source_challenges[0])
|
|
497
|
+
elif len(self._preauth) >= self._limits.max_preauth:
|
|
498
|
+
self._preauth.pop(next(iter(self._preauth)))
|
|
499
|
+
cookie_token, cookie_digest = self._unique_token_locked(self._preauth)
|
|
500
|
+
form_nonce, form_digest = _new_token()
|
|
501
|
+
while hmac.compare_digest(cookie_digest, form_digest):
|
|
502
|
+
form_nonce, form_digest = _new_token()
|
|
503
|
+
self._preauth[cookie_digest] = _PreauthRecord(
|
|
504
|
+
form_digest=form_digest,
|
|
505
|
+
source=source,
|
|
506
|
+
generation=self._generation,
|
|
507
|
+
)
|
|
508
|
+
return IssuedPreauthChallenge(
|
|
509
|
+
cookie_token=cookie_token,
|
|
510
|
+
form_nonce=form_nonce,
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
def login(
|
|
514
|
+
self,
|
|
515
|
+
*,
|
|
516
|
+
source_ip: str,
|
|
517
|
+
cookie_token: object,
|
|
518
|
+
form_nonce: object,
|
|
519
|
+
credential: object,
|
|
520
|
+
) -> IssuedSession:
|
|
521
|
+
"""Consume one pre-auth challenge and exchange the credential for a session."""
|
|
522
|
+
|
|
523
|
+
with self._lock:
|
|
524
|
+
self._refresh_credential_locked()
|
|
525
|
+
now = self._now_locked()
|
|
526
|
+
self._prune_locked(now)
|
|
527
|
+
source = self._normalize_login_source(source_ip)
|
|
528
|
+
rate_allowed = self._rate_allowed_locked(source, now)
|
|
529
|
+
nonce_valid = self._consume_preauth_locked(
|
|
530
|
+
source=source,
|
|
531
|
+
cookie_token=cookie_token,
|
|
532
|
+
form_nonce=form_nonce,
|
|
533
|
+
)
|
|
534
|
+
candidate_digest = _token_digest(credential)
|
|
535
|
+
supplied_digest = (
|
|
536
|
+
_DUMMY_DIGEST if candidate_digest is None else candidate_digest
|
|
537
|
+
)
|
|
538
|
+
expected_digest = self._credential_digest
|
|
539
|
+
digest_matches = hmac.compare_digest(
|
|
540
|
+
supplied_digest,
|
|
541
|
+
_DUMMY_DIGEST if expected_digest is None else expected_digest,
|
|
542
|
+
)
|
|
543
|
+
credential_valid = candidate_digest is not None and digest_matches
|
|
544
|
+
if not (rate_allowed and nonce_valid and credential_valid):
|
|
545
|
+
if rate_allowed:
|
|
546
|
+
self._record_login_failure_locked(source, now)
|
|
547
|
+
raise LoginRejected()
|
|
548
|
+
# Sessions do not expire with time. A verified new login reclaims
|
|
549
|
+
# the oldest session at capacity, including abandoned browser cookies.
|
|
550
|
+
source_sessions = [
|
|
551
|
+
digest
|
|
552
|
+
for digest, record in self._sessions.items()
|
|
553
|
+
if record.source == source
|
|
554
|
+
]
|
|
555
|
+
if len(source_sessions) >= self._limits.max_sessions_per_source:
|
|
556
|
+
self._drop_session_locked(source_sessions[0])
|
|
557
|
+
elif len(self._sessions) >= self._limits.max_sessions:
|
|
558
|
+
self._drop_session_locked(next(iter(self._sessions)))
|
|
559
|
+
token, token_digest = self._unique_token_locked(self._sessions)
|
|
560
|
+
log_handle = secrets.token_hex(16)
|
|
561
|
+
self._sessions[token_digest] = _SessionRecord(
|
|
562
|
+
source=source,
|
|
563
|
+
log_handle=log_handle,
|
|
564
|
+
generation=self._generation,
|
|
565
|
+
)
|
|
566
|
+
return IssuedSession(
|
|
567
|
+
token=token,
|
|
568
|
+
log_handle=log_handle,
|
|
569
|
+
generation=self._generation,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
def authenticate(
|
|
573
|
+
self,
|
|
574
|
+
session_token: object,
|
|
575
|
+
) -> AuthenticatedSession:
|
|
576
|
+
"""Authenticate a process-local session until it is explicitly revoked."""
|
|
577
|
+
|
|
578
|
+
with self._lock:
|
|
579
|
+
self._refresh_credential_locked()
|
|
580
|
+
now = self._now_locked()
|
|
581
|
+
self._prune_locked(now)
|
|
582
|
+
_, record = self._authenticate_session_locked(session_token)
|
|
583
|
+
return self._authenticated_session(record)
|
|
584
|
+
|
|
585
|
+
def logout(self, session_token: object) -> bool:
|
|
586
|
+
with self._lock:
|
|
587
|
+
self._refresh_credential_locked()
|
|
588
|
+
now = self._now_locked()
|
|
589
|
+
self._prune_locked(now)
|
|
590
|
+
digest = _token_digest(session_token)
|
|
591
|
+
if digest is None or digest not in self._sessions:
|
|
592
|
+
return False
|
|
593
|
+
self._drop_session_locked(digest)
|
|
594
|
+
return True
|
|
595
|
+
|
|
596
|
+
def issue_action(
|
|
597
|
+
self,
|
|
598
|
+
session_token: object,
|
|
599
|
+
*,
|
|
600
|
+
action_kind: str,
|
|
601
|
+
target: TargetT,
|
|
602
|
+
preconditions: PreconditionsT,
|
|
603
|
+
) -> IssuedActionGrant:
|
|
604
|
+
"""Bind immutable exact facts to independent one-shot browser tokens."""
|
|
605
|
+
|
|
606
|
+
self._validate_action_kind(action_kind)
|
|
607
|
+
_require_typed_immutable_payload(target)
|
|
608
|
+
_require_typed_immutable_payload(preconditions)
|
|
609
|
+
with self._lock:
|
|
610
|
+
self._refresh_credential_locked()
|
|
611
|
+
now = self._now_locked()
|
|
612
|
+
self._prune_locked(now)
|
|
613
|
+
session_digest, _ = self._authenticate_session_locked(session_token)
|
|
614
|
+
if len(self._actions) >= self._limits.max_actions:
|
|
615
|
+
raise AuthCapacityExceeded()
|
|
616
|
+
session_count = sum(
|
|
617
|
+
hmac.compare_digest(record.session_digest, session_digest)
|
|
618
|
+
for record in self._actions.values()
|
|
619
|
+
)
|
|
620
|
+
if session_count >= self._limits.max_actions_per_session:
|
|
621
|
+
raise AuthCapacityExceeded()
|
|
622
|
+
action_token, action_digest = self._unique_token_locked(self._actions)
|
|
623
|
+
csrf_token, csrf_digest = _new_token()
|
|
624
|
+
while hmac.compare_digest(action_digest, csrf_digest):
|
|
625
|
+
csrf_token, csrf_digest = _new_token()
|
|
626
|
+
expires_at = now + self._limits.action_ttl
|
|
627
|
+
self._actions[action_digest] = _ActionRecord(
|
|
628
|
+
session_digest=session_digest,
|
|
629
|
+
csrf_digest=csrf_digest,
|
|
630
|
+
generation=self._generation,
|
|
631
|
+
action_kind=action_kind,
|
|
632
|
+
target=target,
|
|
633
|
+
preconditions=preconditions,
|
|
634
|
+
expires_at=expires_at,
|
|
635
|
+
)
|
|
636
|
+
return IssuedActionGrant(
|
|
637
|
+
csrf_token=csrf_token,
|
|
638
|
+
action_token=action_token,
|
|
639
|
+
expires_at=expires_at,
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
def redeem_action(
|
|
643
|
+
self,
|
|
644
|
+
session_token: object,
|
|
645
|
+
*,
|
|
646
|
+
csrf_token: object,
|
|
647
|
+
action_token: object,
|
|
648
|
+
action_kind: str,
|
|
649
|
+
target: TargetT,
|
|
650
|
+
) -> RedeemedAction[TargetT, object]:
|
|
651
|
+
"""Atomically redeem an exact grant; at most one caller can succeed."""
|
|
652
|
+
|
|
653
|
+
self._validate_action_kind(action_kind)
|
|
654
|
+
_require_typed_immutable_payload(target)
|
|
655
|
+
with self._lock:
|
|
656
|
+
self._refresh_credential_locked()
|
|
657
|
+
now = self._now_locked()
|
|
658
|
+
self._prune_locked(now)
|
|
659
|
+
session_digest, session = self._authenticate_session_locked(
|
|
660
|
+
session_token,
|
|
661
|
+
)
|
|
662
|
+
action_digest = _token_digest(action_token)
|
|
663
|
+
if action_digest is None:
|
|
664
|
+
raise MalformedActionGrant()
|
|
665
|
+
csrf_digest = _token_digest(csrf_token)
|
|
666
|
+
if csrf_digest is None:
|
|
667
|
+
raise ActionCsrfRejected()
|
|
668
|
+
record = self._actions.get(action_digest)
|
|
669
|
+
if record is None:
|
|
670
|
+
raise StaleActionGrant()
|
|
671
|
+
if (
|
|
672
|
+
record.generation != self._generation
|
|
673
|
+
or not hmac.compare_digest(record.session_digest, session_digest)
|
|
674
|
+
or record.expires_at <= now
|
|
675
|
+
or record.action_kind != action_kind
|
|
676
|
+
or type(record.target) is not type(target)
|
|
677
|
+
or record.target != target
|
|
678
|
+
):
|
|
679
|
+
raise StaleActionGrant()
|
|
680
|
+
if not hmac.compare_digest(record.csrf_digest, csrf_digest):
|
|
681
|
+
raise ActionCsrfRejected()
|
|
682
|
+
if record.consumed:
|
|
683
|
+
raise ConsumedActionGrant()
|
|
684
|
+
record.consumed = True
|
|
685
|
+
return RedeemedAction(
|
|
686
|
+
session_log_handle=session.log_handle,
|
|
687
|
+
generation=self._generation,
|
|
688
|
+
action_kind=record.action_kind,
|
|
689
|
+
target=cast(TargetT, record.target),
|
|
690
|
+
preconditions=record.preconditions,
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
def state_counts(self) -> AuthStateCounts:
|
|
694
|
+
"""Return non-sensitive process-local counts for tests and diagnostics."""
|
|
695
|
+
|
|
696
|
+
with self._lock:
|
|
697
|
+
now = self._now_locked()
|
|
698
|
+
self._prune_locked(now)
|
|
699
|
+
self._prune_rate_locked(now)
|
|
700
|
+
return AuthStateCounts(
|
|
701
|
+
admission_open=self._admission_open,
|
|
702
|
+
generation=self._generation,
|
|
703
|
+
preauth=len(self._preauth),
|
|
704
|
+
sessions=len(self._sessions),
|
|
705
|
+
actions=len(self._actions),
|
|
706
|
+
rate_sources=len(self._rate_by_source),
|
|
707
|
+
global_failures=len(self._rate_global),
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
def close(self) -> None:
|
|
711
|
+
with self._lock:
|
|
712
|
+
self._close_admission_locked()
|
|
713
|
+
|
|
714
|
+
def _refresh_credential_locked(self) -> bool:
|
|
715
|
+
if not self._admission_open:
|
|
716
|
+
raise AdmissionClosed()
|
|
717
|
+
try:
|
|
718
|
+
snapshot = load_credential_snapshot(self._credential_path)
|
|
719
|
+
except CredentialFileError:
|
|
720
|
+
self._close_admission_locked()
|
|
721
|
+
raise AdmissionClosed() from None
|
|
722
|
+
digest = self._credential_digest
|
|
723
|
+
changed = (
|
|
724
|
+
snapshot.identity != self._credential_identity
|
|
725
|
+
or digest is None
|
|
726
|
+
or not hmac.compare_digest(snapshot.digest, digest)
|
|
727
|
+
)
|
|
728
|
+
if not changed:
|
|
729
|
+
return False
|
|
730
|
+
self._credential_identity = snapshot.identity
|
|
731
|
+
self._credential_digest = snapshot.digest
|
|
732
|
+
self._generation += 1
|
|
733
|
+
self._clear_bearer_state_locked()
|
|
734
|
+
return True
|
|
735
|
+
|
|
736
|
+
def _close_admission_locked(self) -> None:
|
|
737
|
+
self._admission_open = False
|
|
738
|
+
self._credential_digest = None
|
|
739
|
+
self._credential_identity = None
|
|
740
|
+
self._clear_bearer_state_locked()
|
|
741
|
+
|
|
742
|
+
def _clear_bearer_state_locked(self) -> None:
|
|
743
|
+
self._preauth.clear()
|
|
744
|
+
self._sessions.clear()
|
|
745
|
+
self._actions.clear()
|
|
746
|
+
|
|
747
|
+
def _now_locked(self) -> float:
|
|
748
|
+
now = float(self._clock())
|
|
749
|
+
if not math.isfinite(now):
|
|
750
|
+
raise RuntimeError("monotonic clock returned a non-finite value")
|
|
751
|
+
if self._last_now is not None and now < self._last_now:
|
|
752
|
+
raise RuntimeError("monotonic clock moved backwards")
|
|
753
|
+
self._last_now = now
|
|
754
|
+
return now
|
|
755
|
+
|
|
756
|
+
def _normalize_login_source(self, source_ip: str) -> str:
|
|
757
|
+
try:
|
|
758
|
+
return normalize_source_ip(source_ip)
|
|
759
|
+
except ValueError:
|
|
760
|
+
raise LoginRejected() from None
|
|
761
|
+
|
|
762
|
+
def _prune_locked(self, now: float) -> None:
|
|
763
|
+
expired_actions = [
|
|
764
|
+
digest
|
|
765
|
+
for digest, record in self._actions.items()
|
|
766
|
+
if record.expires_at <= now
|
|
767
|
+
]
|
|
768
|
+
for digest in expired_actions:
|
|
769
|
+
self._actions.pop(digest, None)
|
|
770
|
+
|
|
771
|
+
def _prune_rate_locked(self, now: float) -> None:
|
|
772
|
+
cutoff = now - self._limits.login_window
|
|
773
|
+
while self._rate_global and self._rate_global[0] <= cutoff:
|
|
774
|
+
self._rate_global.popleft()
|
|
775
|
+
empty: list[str] = []
|
|
776
|
+
for source, failures in self._rate_by_source.items():
|
|
777
|
+
while failures and failures[0] <= cutoff:
|
|
778
|
+
failures.popleft()
|
|
779
|
+
if not failures:
|
|
780
|
+
empty.append(source)
|
|
781
|
+
for source in empty:
|
|
782
|
+
self._rate_by_source.pop(source, None)
|
|
783
|
+
|
|
784
|
+
def _rate_allowed_locked(self, source: str, now: float) -> bool:
|
|
785
|
+
self._prune_rate_locked(now)
|
|
786
|
+
if len(self._rate_global) >= self._limits.login_failures_global:
|
|
787
|
+
return False
|
|
788
|
+
failures = self._rate_by_source.get(source)
|
|
789
|
+
if failures is not None:
|
|
790
|
+
return len(failures) < self._limits.login_failures_per_source
|
|
791
|
+
return len(self._rate_by_source) < self._limits.max_rate_sources
|
|
792
|
+
|
|
793
|
+
def _record_login_failure_locked(self, source: str, now: float) -> None:
|
|
794
|
+
self._rate_global.append(now)
|
|
795
|
+
failures = self._rate_by_source.get(source)
|
|
796
|
+
if failures is None:
|
|
797
|
+
if len(self._rate_by_source) >= self._limits.max_rate_sources:
|
|
798
|
+
return
|
|
799
|
+
failures = deque()
|
|
800
|
+
self._rate_by_source[source] = failures
|
|
801
|
+
failures.append(now)
|
|
802
|
+
|
|
803
|
+
def _consume_preauth_locked(
|
|
804
|
+
self,
|
|
805
|
+
*,
|
|
806
|
+
source: str,
|
|
807
|
+
cookie_token: object,
|
|
808
|
+
form_nonce: object,
|
|
809
|
+
) -> bool:
|
|
810
|
+
cookie_digest = _token_digest(cookie_token)
|
|
811
|
+
form_digest = _token_digest(form_nonce)
|
|
812
|
+
if cookie_digest is None or form_digest is None:
|
|
813
|
+
return False
|
|
814
|
+
record = self._preauth.pop(cookie_digest, None)
|
|
815
|
+
if record is None:
|
|
816
|
+
return False
|
|
817
|
+
return (
|
|
818
|
+
record.generation == self._generation
|
|
819
|
+
and record.source == source
|
|
820
|
+
and hmac.compare_digest(record.form_digest, form_digest)
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
def _authenticate_session_locked(
|
|
824
|
+
self,
|
|
825
|
+
session_token: object,
|
|
826
|
+
) -> tuple[bytes, _SessionRecord]:
|
|
827
|
+
digest = _token_digest(session_token)
|
|
828
|
+
if digest is None:
|
|
829
|
+
raise SessionRejected()
|
|
830
|
+
record = self._sessions.get(digest)
|
|
831
|
+
if record is None or record.generation != self._generation:
|
|
832
|
+
if record is not None:
|
|
833
|
+
self._drop_session_locked(digest)
|
|
834
|
+
raise SessionRejected()
|
|
835
|
+
return digest, record
|
|
836
|
+
|
|
837
|
+
def _authenticated_session(
|
|
838
|
+
self,
|
|
839
|
+
record: _SessionRecord,
|
|
840
|
+
) -> AuthenticatedSession:
|
|
841
|
+
return AuthenticatedSession(
|
|
842
|
+
log_handle=record.log_handle,
|
|
843
|
+
generation=record.generation,
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
def _drop_session_locked(self, session_digest: bytes) -> None:
|
|
847
|
+
self._sessions.pop(session_digest, None)
|
|
848
|
+
action_digests = [
|
|
849
|
+
digest
|
|
850
|
+
for digest, record in self._actions.items()
|
|
851
|
+
if hmac.compare_digest(record.session_digest, session_digest)
|
|
852
|
+
]
|
|
853
|
+
for digest in action_digests:
|
|
854
|
+
self._actions.pop(digest, None)
|
|
855
|
+
|
|
856
|
+
@staticmethod
|
|
857
|
+
def _validate_action_kind(action_kind: str) -> None:
|
|
858
|
+
if (
|
|
859
|
+
not isinstance(action_kind, str)
|
|
860
|
+
or not action_kind
|
|
861
|
+
or len(action_kind.encode("utf-8")) > 128
|
|
862
|
+
or any(ord(character) < 0x20 for character in action_kind)
|
|
863
|
+
):
|
|
864
|
+
raise ValueError("action kind must be a bounded non-empty string")
|
|
865
|
+
|
|
866
|
+
@staticmethod
|
|
867
|
+
def _unique_token_locked(
|
|
868
|
+
records: dict[bytes, object],
|
|
869
|
+
) -> tuple[str, bytes]:
|
|
870
|
+
for _ in range(8):
|
|
871
|
+
token, digest = _new_token()
|
|
872
|
+
if digest not in records:
|
|
873
|
+
return token, digest
|
|
874
|
+
raise RuntimeError("secure token generator repeatedly collided")
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _require_typed_immutable_payload(value: object) -> None:
|
|
878
|
+
if not is_dataclass(value) or isinstance(value, type):
|
|
879
|
+
raise InvalidActionPayload()
|
|
880
|
+
parameters = getattr(type(value), "__dataclass_params__", None)
|
|
881
|
+
if parameters is None or not parameters.frozen:
|
|
882
|
+
raise InvalidActionPayload()
|
|
883
|
+
if not _is_deeply_immutable(value, active=set(), checked=set()):
|
|
884
|
+
raise InvalidActionPayload()
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _is_deeply_immutable(
|
|
888
|
+
value: object,
|
|
889
|
+
*,
|
|
890
|
+
active: set[int],
|
|
891
|
+
checked: set[int],
|
|
892
|
+
) -> bool:
|
|
893
|
+
if value is None or isinstance(value, (str, bytes, int, float, bool, Enum)):
|
|
894
|
+
return True
|
|
895
|
+
identity = id(value)
|
|
896
|
+
if identity in checked:
|
|
897
|
+
return True
|
|
898
|
+
if identity in active:
|
|
899
|
+
return False
|
|
900
|
+
active.add(identity)
|
|
901
|
+
try:
|
|
902
|
+
if isinstance(value, tuple):
|
|
903
|
+
valid = all(
|
|
904
|
+
_is_deeply_immutable(item, active=active, checked=checked)
|
|
905
|
+
for item in value
|
|
906
|
+
)
|
|
907
|
+
elif isinstance(value, frozenset):
|
|
908
|
+
valid = all(
|
|
909
|
+
_is_deeply_immutable(item, active=active, checked=checked)
|
|
910
|
+
for item in value
|
|
911
|
+
)
|
|
912
|
+
elif is_dataclass(value) and not isinstance(value, type):
|
|
913
|
+
parameters = getattr(type(value), "__dataclass_params__", None)
|
|
914
|
+
valid = bool(parameters is not None and parameters.frozen) and all(
|
|
915
|
+
_is_deeply_immutable(
|
|
916
|
+
getattr(value, item.name),
|
|
917
|
+
active=active,
|
|
918
|
+
checked=checked,
|
|
919
|
+
)
|
|
920
|
+
for item in fields(value)
|
|
921
|
+
)
|
|
922
|
+
else:
|
|
923
|
+
valid = False
|
|
924
|
+
finally:
|
|
925
|
+
active.remove(identity)
|
|
926
|
+
if valid:
|
|
927
|
+
checked.add(identity)
|
|
928
|
+
return valid
|