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.
Files changed (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. 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