texas2step 2.0.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.
- texas2step/__init__.py +3 -0
- texas2step/__main__.py +3 -0
- texas2step/access.py +478 -0
- texas2step/api/__init__.py +3 -0
- texas2step/api/access.py +356 -0
- texas2step/api/measure.py +173 -0
- texas2step/api/meta.py +207 -0
- texas2step/api/results.py +56 -0
- texas2step/api/wire.py +198 -0
- texas2step/app.py +458 -0
- texas2step/cli.py +1298 -0
- texas2step/cluster.py +285 -0
- texas2step/config.py +348 -0
- texas2step/config_edit.py +165 -0
- texas2step/config_merge.py +161 -0
- texas2step/config_template.toml +108 -0
- texas2step/engine.py +498 -0
- texas2step/iface.py +79 -0
- texas2step/lockouts.py +316 -0
- texas2step/logs.py +73 -0
- texas2step/page.py +145 -0
- texas2step/payload.py +38 -0
- texas2step/rtc.py +300 -0
- texas2step/static/assets/app.css +1 -0
- texas2step/static/assets/app.js +8 -0
- texas2step/static/assets/swagger/LICENSE +202 -0
- texas2step/static/assets/swagger/NOTICE +2 -0
- texas2step/static/assets/swagger/swagger-ui-bundle.js +2 -0
- texas2step/static/assets/swagger/swagger-ui-bundle.js.LICENSE.txt +131 -0
- texas2step/static/assets/swagger/swagger-ui.css +2 -0
- texas2step/static/favicon.svg +11 -0
- texas2step/static/index.html +78 -0
- texas2step/stats.py +98 -0
- texas2step/store.py +230 -0
- texas2step/traffic.py +424 -0
- texas2step/update.py +458 -0
- texas2step/words.txt +1294 -0
- texas2step-2.0.0.dist-info/METADATA +184 -0
- texas2step-2.0.0.dist-info/RECORD +42 -0
- texas2step-2.0.0.dist-info/WHEEL +4 -0
- texas2step-2.0.0.dist-info/entry_points.txt +2 -0
- texas2step-2.0.0.dist-info/licenses/LICENSE.md +204 -0
texas2step/__init__.py
ADDED
texas2step/__main__.py
ADDED
texas2step/access.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""Access codes: the word code a service desk reads out, and the pass it buys.
|
|
2
|
+
|
|
3
|
+
An Internet-facing server can ask for a code before it runs tests (``access_code``).
|
|
4
|
+
Every server of an organization holds the same random key (``access_key_file``), and
|
|
5
|
+
works out the same code from it for the current period: daily, weekly, monthly, or one
|
|
6
|
+
that stays fixed. Nothing is shared between servers at run time:
|
|
7
|
+
|
|
8
|
+
* With the built-in words, the key draws the organization's 512 words from
|
|
9
|
+
``words.txt``, and a code is two of them and some digits: ``maple.river.42``.
|
|
10
|
+
* With the site's own list (``access_words``), a code is one entry and the digits:
|
|
11
|
+
``austin.42``, ``fort.bragg.68``.
|
|
12
|
+
|
|
13
|
+
A code someone types is read with any case and any separators, so ``Maple River 42``
|
|
14
|
+
counts. The previous period's code still works for a day after the change.
|
|
15
|
+
|
|
16
|
+
A site can also keep standing phrases of its own (``access_phrases_file``), such as one
|
|
17
|
+
the desk gives a VIP from memory. Each works as a code until it is removed, or until the
|
|
18
|
+
end of its ``until`` day, and each use is logged by the phrase's name.
|
|
19
|
+
|
|
20
|
+
A right code buys a pass: an expiry time signed with the key. The browser sends it as
|
|
21
|
+
``?pass=`` with every request, to this server and to the others in the picker, which
|
|
22
|
+
check the signature with their copy of the key. A query parameter, not a header: a
|
|
23
|
+
header would make browsers preflight every ping and download, and skew the timings.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import base64
|
|
29
|
+
import contextlib
|
|
30
|
+
import hashlib
|
|
31
|
+
import hmac
|
|
32
|
+
import ipaddress
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import secrets
|
|
37
|
+
import time
|
|
38
|
+
import tomllib
|
|
39
|
+
from collections.abc import Sequence
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from datetime import date, datetime, timedelta
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import TYPE_CHECKING
|
|
44
|
+
from zoneinfo import ZoneInfo
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from .config import Settings
|
|
48
|
+
|
|
49
|
+
INTERVALS = ("off", "daily", "weekly", "monthly", "fixed")
|
|
50
|
+
WORDS = Path(__file__).with_name("words.txt")
|
|
51
|
+
KEY_BYTES = 32
|
|
52
|
+
DRAW = 512 # built-in words each organization draws by its key
|
|
53
|
+
GRACE = 24 * 3600 # the previous code still works this long after a change
|
|
54
|
+
MIN_SITE_WORDS = 100
|
|
55
|
+
MAX_ENTRY = 40
|
|
56
|
+
GROUP = "texas2step" # the service's group, which must read the key
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AccessError(ValueError):
|
|
60
|
+
"""The access settings can't be used, with a message for the operator."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def normalize(text: str) -> str:
|
|
64
|
+
"""Lowercase letters and digits, with any run of other characters as one dot."""
|
|
65
|
+
return ".".join(re.findall(r"[a-z0-9]+", text.lower()))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --- The key -----------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def new_key() -> str:
|
|
72
|
+
return base64.urlsafe_b64encode(secrets.token_bytes(KEY_BYTES)).rstrip(b"=").decode()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_key(text: str) -> bytes:
|
|
76
|
+
text = text.strip()
|
|
77
|
+
try:
|
|
78
|
+
key = base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
79
|
+
except ValueError:
|
|
80
|
+
key = b""
|
|
81
|
+
if len(key) != KEY_BYTES or not re.fullmatch(r"[A-Za-z0-9_-]+", text):
|
|
82
|
+
raise AccessError(f"not an access key: expected {KEY_BYTES} bytes in base64url")
|
|
83
|
+
return key
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def read_key(path: str | Path) -> bytes:
|
|
87
|
+
try:
|
|
88
|
+
text = Path(path).read_text()
|
|
89
|
+
except FileNotFoundError:
|
|
90
|
+
raise AccessError(
|
|
91
|
+
f"{path} is missing; make one with: sudo texas2step access key --new"
|
|
92
|
+
) from None
|
|
93
|
+
except OSError as exc:
|
|
94
|
+
raise AccessError(f"can't read {path}: {exc.strerror}") from exc
|
|
95
|
+
try:
|
|
96
|
+
return parse_key(text)
|
|
97
|
+
except AccessError as exc:
|
|
98
|
+
raise AccessError(f"{path}: {exc}") from None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def write_key(path: str | Path, text: str) -> None:
|
|
102
|
+
parse_key(text)
|
|
103
|
+
write_secret(path, text.strip() + "\n")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def write_secret(path: str | Path, text: str) -> None:
|
|
107
|
+
"""Replace the file atomically: readable by root and the service's group only."""
|
|
108
|
+
path = Path(path)
|
|
109
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
110
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o640)
|
|
111
|
+
try:
|
|
112
|
+
os.write(fd, text.encode())
|
|
113
|
+
if os.geteuid() == 0:
|
|
114
|
+
import grp
|
|
115
|
+
|
|
116
|
+
# Without the service's group yet, root's, until the package makes it.
|
|
117
|
+
with contextlib.suppress(KeyError):
|
|
118
|
+
os.fchown(fd, 0, grp.getgrnam(GROUP).gr_gid)
|
|
119
|
+
os.fchmod(fd, 0o640)
|
|
120
|
+
finally:
|
|
121
|
+
os.close(fd)
|
|
122
|
+
os.replace(tmp, path)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --- Words -------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _lines(text: str) -> list[tuple[int, str]]:
|
|
129
|
+
return [
|
|
130
|
+
(n, line.strip())
|
|
131
|
+
for n, line in enumerate(text.splitlines(), 1)
|
|
132
|
+
if line.strip() and not line.lstrip().startswith("#")
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def builtin_words() -> list[str]:
|
|
137
|
+
return [word for _, word in _lines(WORDS.read_text())]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def drawn_words(key: bytes) -> list[str]:
|
|
141
|
+
"""The organization's share of the built-in words, chosen by its key."""
|
|
142
|
+
|
|
143
|
+
def rank(word: str) -> bytes:
|
|
144
|
+
return hmac.new(key, b"draw|" + word.encode(), hashlib.sha256).digest()
|
|
145
|
+
|
|
146
|
+
return sorted(sorted(builtin_words(), key=rank)[:DRAW])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def site_words(path: str | Path) -> list[str]:
|
|
150
|
+
"""A site's own list: one entry per line, each normalized, and checked."""
|
|
151
|
+
try:
|
|
152
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
153
|
+
except OSError as exc:
|
|
154
|
+
raise AccessError(f"access_words: can't read {path}: {exc.strerror}") from exc
|
|
155
|
+
except UnicodeDecodeError:
|
|
156
|
+
raise AccessError(f"access_words: {path} isn't UTF-8 text") from None
|
|
157
|
+
seen: dict[str, int] = {}
|
|
158
|
+
problems: list[str] = []
|
|
159
|
+
for n, line in _lines(text):
|
|
160
|
+
entry = normalize(line)
|
|
161
|
+
where = f"{path}:{n}"
|
|
162
|
+
if not line.isascii():
|
|
163
|
+
problems.append(f"{where}: {line!r} has letters outside ASCII")
|
|
164
|
+
elif not entry or not re.search(r"[a-z]", entry):
|
|
165
|
+
problems.append(f"{where}: {line!r} has no letters")
|
|
166
|
+
elif len(entry) > MAX_ENTRY:
|
|
167
|
+
problems.append(f"{where}: {entry!r} is over {MAX_ENTRY} characters")
|
|
168
|
+
elif entry in seen:
|
|
169
|
+
problems.append(f"{where}: {entry!r} repeats line {seen[entry]}")
|
|
170
|
+
else:
|
|
171
|
+
seen[entry] = n
|
|
172
|
+
if problems:
|
|
173
|
+
more = f" (and {len(problems) - 5} more)" if len(problems) > 5 else ""
|
|
174
|
+
raise AccessError("access_words: " + "; ".join(problems[:5]) + more)
|
|
175
|
+
if len(seen) < MIN_SITE_WORDS:
|
|
176
|
+
raise AccessError(
|
|
177
|
+
f"access_words: {path} has {len(seen)} entries; a list needs at least "
|
|
178
|
+
f"{MIN_SITE_WORDS}, so codes aren't easy to guess"
|
|
179
|
+
)
|
|
180
|
+
return list(seen)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# --- Standing phrases ----------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
PHRASE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,39}")
|
|
186
|
+
PHRASES_HEADER = """\
|
|
187
|
+
# Standing access phrases for texas2step (docs/access.md). Each works as an access code
|
|
188
|
+
# beside the day's code, until it is removed or until the end of its until day (in
|
|
189
|
+
# access_timezone). Readable by root and the service only. texas2step access phrase add
|
|
190
|
+
# and remove write this file again, keeping these lines but no comments of your own.
|
|
191
|
+
# After a change: sudo systemctl restart texas2step
|
|
192
|
+
#
|
|
193
|
+
# Two examples, commented out. Make up your own: these are published with texas2step.
|
|
194
|
+
#
|
|
195
|
+
# [[phrase]]
|
|
196
|
+
# name = "future"
|
|
197
|
+
# phrase = "2026 the future is bright"
|
|
198
|
+
# until = 2026-12-31 # works through December 31, 2026
|
|
199
|
+
#
|
|
200
|
+
# [[phrase]]
|
|
201
|
+
# name = "vip"
|
|
202
|
+
# phrase = "vip ticket to ride 1776" # no until: works until it is removed
|
|
203
|
+
"""
|
|
204
|
+
# Anyone can read the examples, so they never work as phrases.
|
|
205
|
+
EXAMPLE_PHRASES = ("2026 the future is bright", "vip ticket to ride 1776")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True)
|
|
209
|
+
class Phrase:
|
|
210
|
+
name: str # what the journal and texas2step access show call it; never the phrase
|
|
211
|
+
text: str # as written
|
|
212
|
+
until: date | None = None # it works through this day, in access_timezone
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def code(self) -> str:
|
|
216
|
+
return normalize(self.text)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def as_day(value: object) -> date | None:
|
|
220
|
+
"""A phrase's until: a TOML date, or one written as text."""
|
|
221
|
+
if value is None:
|
|
222
|
+
return None
|
|
223
|
+
if isinstance(value, str):
|
|
224
|
+
with contextlib.suppress(ValueError):
|
|
225
|
+
value = date.fromisoformat(value)
|
|
226
|
+
if isinstance(value, date) and not isinstance(value, datetime):
|
|
227
|
+
return value
|
|
228
|
+
raise ValueError("until is a day, such as 2026-12-31")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def parse_phrases(text: str, where: str) -> list[Phrase]:
|
|
232
|
+
"""The ``[[phrase]]`` tables of a phrases file, each checked."""
|
|
233
|
+
try:
|
|
234
|
+
data = tomllib.loads(text)
|
|
235
|
+
except tomllib.TOMLDecodeError as exc:
|
|
236
|
+
raise AccessError(f"{where}: {exc}") from None
|
|
237
|
+
entries = data.pop("phrase", [])
|
|
238
|
+
if data or not isinstance(entries, list):
|
|
239
|
+
raise AccessError(f"{where}: expected only [[phrase]] tables")
|
|
240
|
+
phrases: list[Phrase] = []
|
|
241
|
+
for n, entry in enumerate(entries, 1):
|
|
242
|
+
if not isinstance(entry, dict):
|
|
243
|
+
raise AccessError(f"{where}: expected only [[phrase]] tables")
|
|
244
|
+
name = entry.get("name")
|
|
245
|
+
at = f"{where}: phrase {name!r}" if isinstance(name, str) else f"{where}: phrase {n}"
|
|
246
|
+
if unknown := set(entry) - {"name", "phrase", "until"}:
|
|
247
|
+
raise AccessError(f"{at}: unknown {', '.join(sorted(unknown))}")
|
|
248
|
+
if not isinstance(name, str) or not PHRASE_NAME.fullmatch(name):
|
|
249
|
+
raise AccessError(f"{at}: name is up to 40 letters, digits, '-', '_' or '.'")
|
|
250
|
+
text = entry.get("phrase")
|
|
251
|
+
if not isinstance(text, str) or not normalize(text):
|
|
252
|
+
raise AccessError(f"{at}: phrase needs letters or digits")
|
|
253
|
+
if not text.isascii():
|
|
254
|
+
raise AccessError(f"{at}: phrase has letters outside ASCII")
|
|
255
|
+
if normalize(text) in {normalize(example) for example in EXAMPLE_PHRASES}:
|
|
256
|
+
raise AccessError(f"{at}: that is a published example; make up your own")
|
|
257
|
+
try:
|
|
258
|
+
phrase = Phrase(name, text, as_day(entry.get("until")))
|
|
259
|
+
except ValueError as exc:
|
|
260
|
+
raise AccessError(f"{at}: {exc}") from None
|
|
261
|
+
for other in phrases:
|
|
262
|
+
if other.name.lower() == name.lower():
|
|
263
|
+
raise AccessError(f"{at}: the name is taken by an earlier phrase")
|
|
264
|
+
if other.code == phrase.code:
|
|
265
|
+
raise AccessError(f"{at}: the same phrase as {other.name!r}")
|
|
266
|
+
phrases.append(phrase)
|
|
267
|
+
return phrases
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def read_phrases(path: str | Path) -> list[Phrase]:
|
|
271
|
+
"""The site's standing phrases; none without the file."""
|
|
272
|
+
try:
|
|
273
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
274
|
+
except FileNotFoundError:
|
|
275
|
+
return []
|
|
276
|
+
except OSError as exc:
|
|
277
|
+
raise AccessError(f"can't read {path}: {exc.strerror}") from exc
|
|
278
|
+
except UnicodeDecodeError:
|
|
279
|
+
raise AccessError(f"{path} isn't UTF-8 text") from None
|
|
280
|
+
return parse_phrases(text, str(path))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def render_phrases(phrases: Sequence[Phrase]) -> str:
|
|
284
|
+
blocks = [PHRASES_HEADER]
|
|
285
|
+
for p in phrases:
|
|
286
|
+
lines = ["[[phrase]]", f"name = {json.dumps(p.name)}", f"phrase = {json.dumps(p.text)}"]
|
|
287
|
+
if p.until:
|
|
288
|
+
lines.append(f"until = {p.until.isoformat()}")
|
|
289
|
+
blocks.append("\n".join(lines) + "\n")
|
|
290
|
+
return "\n".join(blocks)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# --- Codes and passes --------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclass(frozen=True)
|
|
297
|
+
class Period:
|
|
298
|
+
id: str # what the code is made from, e.g. 2026-10-22, 2026-W43, 2026-10, fixed
|
|
299
|
+
start: datetime | None # None for a fixed code
|
|
300
|
+
end: datetime | None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@dataclass(frozen=True)
|
|
304
|
+
class Current:
|
|
305
|
+
code: str
|
|
306
|
+
period: Period
|
|
307
|
+
previous: str | None # still accepted until previous_until
|
|
308
|
+
previous_until: datetime | None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class Codes:
|
|
312
|
+
"""The codes and passes of one server, from its settings, key and words."""
|
|
313
|
+
|
|
314
|
+
def __init__(
|
|
315
|
+
self,
|
|
316
|
+
key: bytes,
|
|
317
|
+
interval: str,
|
|
318
|
+
zone: str,
|
|
319
|
+
digits: int,
|
|
320
|
+
words: list[str],
|
|
321
|
+
site: bool,
|
|
322
|
+
pass_hours: float,
|
|
323
|
+
phrases: Sequence[Phrase] = (),
|
|
324
|
+
) -> None:
|
|
325
|
+
self.key = key
|
|
326
|
+
self.interval = interval
|
|
327
|
+
self.zone = ZoneInfo(zone)
|
|
328
|
+
self.zone_name = zone
|
|
329
|
+
self.digits = digits
|
|
330
|
+
self.words = words
|
|
331
|
+
self.site = site
|
|
332
|
+
self.pass_seconds = int(pass_hours * 3600)
|
|
333
|
+
self.phrases = tuple(phrases)
|
|
334
|
+
|
|
335
|
+
@classmethod
|
|
336
|
+
def load(cls, settings: Settings, interval: str | None = None) -> Codes | None:
|
|
337
|
+
"""None when access codes are off; AccessError when they can't work. ``interval``
|
|
338
|
+
stands in for ``access_code``, as ``texas2step access show`` does while it's off."""
|
|
339
|
+
interval = interval or settings.access_code
|
|
340
|
+
if interval == "off":
|
|
341
|
+
return None
|
|
342
|
+
key = read_key(settings.access_key_file)
|
|
343
|
+
site = bool(settings.access_words)
|
|
344
|
+
words = site_words(settings.access_words) if site else drawn_words(key)
|
|
345
|
+
return cls(
|
|
346
|
+
key,
|
|
347
|
+
interval,
|
|
348
|
+
settings.access_timezone,
|
|
349
|
+
settings.access_digits,
|
|
350
|
+
words,
|
|
351
|
+
site,
|
|
352
|
+
settings.access_pass_hours,
|
|
353
|
+
read_phrases(settings.access_phrases_file),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
def _mac(self, *parts: str) -> bytes:
|
|
357
|
+
return hmac.new(self.key, "|".join(parts).encode(), hashlib.sha256).digest()
|
|
358
|
+
|
|
359
|
+
# The periods, in the organization's time zone, so every server changes together.
|
|
360
|
+
|
|
361
|
+
def _midnight(self, day: date) -> datetime:
|
|
362
|
+
return datetime(day.year, day.month, day.day, tzinfo=self.zone)
|
|
363
|
+
|
|
364
|
+
def period(self, now: datetime) -> Period:
|
|
365
|
+
day = now.astimezone(self.zone).date()
|
|
366
|
+
if self.interval == "daily":
|
|
367
|
+
return Period(day.isoformat(), self._midnight(day), self._midnight(day + timedelta(1)))
|
|
368
|
+
if self.interval == "weekly":
|
|
369
|
+
monday = day - timedelta(day.weekday())
|
|
370
|
+
year, week, _ = monday.isocalendar()
|
|
371
|
+
start, end = self._midnight(monday), self._midnight(monday + timedelta(7))
|
|
372
|
+
return Period(f"{year}-W{week:02d}", start, end)
|
|
373
|
+
if self.interval == "monthly":
|
|
374
|
+
first = day.replace(day=1)
|
|
375
|
+
following = (first + timedelta(32)).replace(day=1)
|
|
376
|
+
return Period(f"{first:%Y-%m}", self._midnight(first), self._midnight(following))
|
|
377
|
+
return Period("fixed", None, None)
|
|
378
|
+
|
|
379
|
+
def code_for(self, period: Period) -> str:
|
|
380
|
+
mac = self._mac("code", self.interval, period.id)
|
|
381
|
+
n = len(self.words)
|
|
382
|
+
first = int.from_bytes(mac[0:8]) % n
|
|
383
|
+
parts = [self.words[first]]
|
|
384
|
+
if not self.site: # two different words
|
|
385
|
+
second = int.from_bytes(mac[8:16]) % (n - 1)
|
|
386
|
+
parts.append(self.words[second + (second >= first)])
|
|
387
|
+
number = int.from_bytes(mac[16:24]) % 10**self.digits
|
|
388
|
+
return ".".join([*parts, f"{number:0{self.digits}d}"])
|
|
389
|
+
|
|
390
|
+
def current(self, now: datetime) -> Current:
|
|
391
|
+
period = self.period(now)
|
|
392
|
+
previous = until = None
|
|
393
|
+
if period.start is not None and now < period.start + timedelta(seconds=GRACE):
|
|
394
|
+
previous = self.code_for(self.period(period.start - timedelta(seconds=1)))
|
|
395
|
+
until = period.start + timedelta(seconds=GRACE)
|
|
396
|
+
return Current(self.code_for(period), period, previous, until)
|
|
397
|
+
|
|
398
|
+
def check(self, text: str, now: datetime) -> bool:
|
|
399
|
+
"""Whether ``text`` is the current code, or the previous one in its grace day."""
|
|
400
|
+
given = normalize(text)
|
|
401
|
+
head, _, tail = given.rpartition(".")
|
|
402
|
+
if tail.isdigit() and len(tail) <= self.digits: # "7" for "07"
|
|
403
|
+
given = f"{head}.{int(tail):0{self.digits}d}"
|
|
404
|
+
current = self.current(now)
|
|
405
|
+
return any(
|
|
406
|
+
hmac.compare_digest(given.encode(), code.encode())
|
|
407
|
+
for code in (current.code, current.previous)
|
|
408
|
+
if code
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def phrase(self, text: str) -> Phrase | None:
|
|
412
|
+
"""The standing phrase ``text`` is, with any case and separators; expired or not."""
|
|
413
|
+
given = normalize(text).encode()
|
|
414
|
+
found = None
|
|
415
|
+
for phrase in self.phrases: # every one, so the time taken tells nothing
|
|
416
|
+
if hmac.compare_digest(given, phrase.code.encode()):
|
|
417
|
+
found = phrase
|
|
418
|
+
return found
|
|
419
|
+
|
|
420
|
+
def expired(self, phrase: Phrase, now: datetime) -> bool:
|
|
421
|
+
return phrase.until is not None and now.astimezone(self.zone).date() > phrase.until
|
|
422
|
+
|
|
423
|
+
def fingerprint(self) -> str:
|
|
424
|
+
"""The same on every server that would give the same codes; reveals nothing."""
|
|
425
|
+
words = hashlib.sha256("\n".join(self.words).encode()).hexdigest()
|
|
426
|
+
what = (
|
|
427
|
+
self.interval,
|
|
428
|
+
self.zone_name,
|
|
429
|
+
str(self.digits),
|
|
430
|
+
"site" if self.site else "built-in",
|
|
431
|
+
)
|
|
432
|
+
if self.phrases: # without any, as before there were phrases
|
|
433
|
+
standing = sorted(f"{p.code}|{p.until or ''}" for p in self.phrases)
|
|
434
|
+
what += (hashlib.sha256("\n".join(standing).encode()).hexdigest(),)
|
|
435
|
+
mac = self._mac("fingerprint", *what, words).hex()
|
|
436
|
+
return f"{mac[0:4]}-{mac[4:8]}-{mac[8:12]}"
|
|
437
|
+
|
|
438
|
+
def issue(self, now: float | None = None) -> tuple[str, int]:
|
|
439
|
+
expires = int(now if now is not None else time.time()) + self.pass_seconds
|
|
440
|
+
return f"{expires}.{self._sign(expires)}", expires
|
|
441
|
+
|
|
442
|
+
def _sign(self, expires: int) -> str:
|
|
443
|
+
mac = self._mac("pass", str(expires))[:18]
|
|
444
|
+
return base64.urlsafe_b64encode(mac).decode()
|
|
445
|
+
|
|
446
|
+
def pass_expires(self, token: str, now: float | None = None) -> int | None:
|
|
447
|
+
"""The pass's expiry, if it is ours, current, and no longer than passes are."""
|
|
448
|
+
stamp, _, sig = (token or "").partition(".")
|
|
449
|
+
if not stamp.isdigit() or len(stamp) > 12:
|
|
450
|
+
return None
|
|
451
|
+
expires = int(stamp)
|
|
452
|
+
now = now if now is not None else time.time()
|
|
453
|
+
if not now < expires <= now + self.pass_seconds + 300:
|
|
454
|
+
return None
|
|
455
|
+
return expires if hmac.compare_digest(sig.encode(), self._sign(expires).encode()) else None
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# --- Who is inside -----------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def address(text: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
|
462
|
+
try:
|
|
463
|
+
addr = ipaddress.ip_address(text.strip())
|
|
464
|
+
except ValueError:
|
|
465
|
+
return None
|
|
466
|
+
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped:
|
|
467
|
+
return addr.ipv4_mapped
|
|
468
|
+
return addr
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def is_internal(ip: str, networks: list[str]) -> bool:
|
|
472
|
+
"""Loopback, or an address in ``internal_networks``."""
|
|
473
|
+
addr = address(ip)
|
|
474
|
+
if addr is None:
|
|
475
|
+
return False
|
|
476
|
+
return addr.is_loopback or any(
|
|
477
|
+
addr in ipaddress.ip_network(net, strict=False) for net in networks
|
|
478
|
+
)
|