hivemind-websocket-protocol 0.2.8a1__tar.gz → 0.2.8a2__tar.gz
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.
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/PKG-INFO +1 -1
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol/__init__.py +54 -2
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol/version.py +1 -1
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol.egg-info/PKG-INFO +1 -1
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol.egg-info/SOURCES.txt +1 -0
- hivemind_websocket_protocol-0.2.8a2/tests/test_password_strength_cache.py +105 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/LICENSE.md +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/README.md +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol/_client_ip.py +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol/health.py +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol.egg-info/dependency_links.txt +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol.egg-info/entry_points.txt +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol.egg-info/requires.txt +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol.egg-info/top_level.txt +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/pyproject.toml +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/setup.cfg +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/tests/test_client_ip.py +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/tests/test_close_diagnostics.py +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/tests/test_decode_auth.py +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/tests/test_health.py +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/tests/test_protocol_unit.py +0 -0
- {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/tests/test_trusted_proxy_config.py +0 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import dataclasses
|
|
3
|
+
import hashlib
|
|
3
4
|
import math
|
|
4
5
|
import os
|
|
5
6
|
import os.path
|
|
6
7
|
import random
|
|
7
8
|
import threading
|
|
8
9
|
import time
|
|
10
|
+
from collections import OrderedDict
|
|
9
11
|
from os import makedirs
|
|
10
12
|
from os.path import exists, join
|
|
11
13
|
from socket import gethostname
|
|
@@ -18,7 +20,7 @@ from OpenSSL import crypto
|
|
|
18
20
|
from ovos_bus_client.session import Session
|
|
19
21
|
from ovos_utils.log import LOG
|
|
20
22
|
from ovos_utils.xdg_utils import xdg_data_home
|
|
21
|
-
from poorman_handshake import PasswordHandShake
|
|
23
|
+
from poorman_handshake import PasswordHandShake, check_password_strength
|
|
22
24
|
from tornado import ioloop
|
|
23
25
|
from tornado import web
|
|
24
26
|
from tornado.platform.asyncio import AnyThreadEventLoopPolicy
|
|
@@ -56,6 +58,56 @@ DEFAULT_WEBSOCKET_PING_INTERVAL = 30.0
|
|
|
56
58
|
DEFAULT_WEBSOCKET_PING_TIMEOUT = 20.0
|
|
57
59
|
|
|
58
60
|
|
|
61
|
+
#: Passwords already checked against a given policy, most recent last.
|
|
62
|
+
#:
|
|
63
|
+
#: ``PasswordHandShake(password, min_bits=N)`` runs the credential through
|
|
64
|
+
#: zxcvbn on construction. That is the right thing to do, but it is ~2.2 ms
|
|
65
|
+
#: and Core builds one per admission on the single Tornado IOLoop, so a fleet
|
|
66
|
+
#: reconnecting at once serialises behind it: 400 satellites is ~0.87 s of
|
|
67
|
+
#: event loop spent re-deciding that the same handful of passwords are still
|
|
68
|
+
#: strong.
|
|
69
|
+
#:
|
|
70
|
+
#: Entries are keyed on a *keyed* blake2s digest and the policy that accepted
|
|
71
|
+
#: it. The key is per-process and never persisted, so this is an LRU lookup
|
|
72
|
+
#: key, not a stored password hash; rotating a password or tightening
|
|
73
|
+
#: ``min_bits`` misses the cache and re-validates.
|
|
74
|
+
_PASSWORD_STRENGTH_LOCK = threading.Lock()
|
|
75
|
+
_PASSWORD_STRENGTH_CACHE: "OrderedDict[Tuple[bytes, float], None]" = OrderedDict()
|
|
76
|
+
_PASSWORD_STRENGTH_CACHE_KEY = os.urandom(32)
|
|
77
|
+
_PASSWORD_STRENGTH_CACHE_SIZE = 4096
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _password_handshake(password: str,
|
|
81
|
+
min_bits: Optional[float] = None) -> PasswordHandShake:
|
|
82
|
+
"""Build a PasswordHandShake, validating each password once per policy.
|
|
83
|
+
|
|
84
|
+
Raises ``WeakPasswordError`` exactly as the plain constructor does -- a
|
|
85
|
+
weak password is never cached, so it is rejected on every attempt.
|
|
86
|
+
"""
|
|
87
|
+
if min_bits is None:
|
|
88
|
+
min_bits = runtime_password_min_bits()
|
|
89
|
+
|
|
90
|
+
if min_bits > 0:
|
|
91
|
+
digest = hashlib.blake2s(
|
|
92
|
+
password.encode("utf-8"),
|
|
93
|
+
key=_PASSWORD_STRENGTH_CACHE_KEY,
|
|
94
|
+
).digest()
|
|
95
|
+
cache_key = (digest, min_bits)
|
|
96
|
+
with _PASSWORD_STRENGTH_LOCK:
|
|
97
|
+
if cache_key in _PASSWORD_STRENGTH_CACHE:
|
|
98
|
+
_PASSWORD_STRENGTH_CACHE.move_to_end(cache_key)
|
|
99
|
+
else:
|
|
100
|
+
# Outside the cache-hit branch on purpose: a rejection must
|
|
101
|
+
# propagate and must not be remembered as a pass.
|
|
102
|
+
check_password_strength(password, min_bits=min_bits)
|
|
103
|
+
_PASSWORD_STRENGTH_CACHE[cache_key] = None
|
|
104
|
+
while len(_PASSWORD_STRENGTH_CACHE) > _PASSWORD_STRENGTH_CACHE_SIZE:
|
|
105
|
+
_PASSWORD_STRENGTH_CACHE.popitem(last=False)
|
|
106
|
+
|
|
107
|
+
# Already validated above; min_bits=0 skips the duplicate zxcvbn run.
|
|
108
|
+
return PasswordHandShake(password, min_bits=0)
|
|
109
|
+
|
|
110
|
+
|
|
59
111
|
def _split_csv(value: Any) -> Tuple[str, ...]:
|
|
60
112
|
if not value:
|
|
61
113
|
return ()
|
|
@@ -430,7 +482,7 @@ class HiveMindTornadoWebSocket(WebSocketHandler):
|
|
|
430
482
|
self.client.is_admin = user.is_admin
|
|
431
483
|
if user.password:
|
|
432
484
|
# pre-shared password to derive aes_key
|
|
433
|
-
self.client.pswd_handshake =
|
|
485
|
+
self.client.pswd_handshake = _password_handshake(user.password)
|
|
434
486
|
|
|
435
487
|
self.client.node_type = HiveMindNodeType.NODE # TODO . placeholder
|
|
436
488
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Admission must not re-run zxcvbn for every connecting satellite.
|
|
2
|
+
|
|
3
|
+
``PasswordHandShake`` validates the credential on construction, and Core builds
|
|
4
|
+
one per admission on the single Tornado IOLoop. Validating the same password
|
|
5
|
+
400 times costs ~0.87 s of event loop that no satellite can use.
|
|
6
|
+
"""
|
|
7
|
+
import threading
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from poorman_handshake import WeakPasswordError
|
|
12
|
+
|
|
13
|
+
import hivemind_websocket_protocol as hwp
|
|
14
|
+
|
|
15
|
+
STRONG = "correct-horse-battery-staple-9271"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(autouse=True)
|
|
19
|
+
def _clear_cache():
|
|
20
|
+
hwp._PASSWORD_STRENGTH_CACHE.clear()
|
|
21
|
+
yield
|
|
22
|
+
hwp._PASSWORD_STRENGTH_CACHE.clear()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_repeated_admissions_validate_once():
|
|
26
|
+
with patch.object(hwp, "check_password_strength") as check:
|
|
27
|
+
for _ in range(400):
|
|
28
|
+
hwp._password_handshake(STRONG, min_bits=64)
|
|
29
|
+
|
|
30
|
+
assert check.call_count == 1, (
|
|
31
|
+
"each admission re-ran the strength check; a reconnecting fleet pays "
|
|
32
|
+
"this on the IOLoop"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_handshake_is_not_shared_between_connections():
|
|
37
|
+
"""Only the verdict is cached — handshake state is per connection."""
|
|
38
|
+
first = hwp._password_handshake(STRONG, min_bits=64)
|
|
39
|
+
second = hwp._password_handshake(STRONG, min_bits=64)
|
|
40
|
+
|
|
41
|
+
assert first is not second
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_weak_password_is_rejected_every_time():
|
|
45
|
+
"""A rejection must never be remembered as a pass."""
|
|
46
|
+
for _ in range(3):
|
|
47
|
+
with pytest.raises(WeakPasswordError):
|
|
48
|
+
hwp._password_handshake("123456", min_bits=64)
|
|
49
|
+
|
|
50
|
+
assert len(hwp._PASSWORD_STRENGTH_CACHE) == 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_a_tightened_policy_revalidates():
|
|
54
|
+
hwp._password_handshake(STRONG, min_bits=40)
|
|
55
|
+
|
|
56
|
+
with patch.object(hwp, "check_password_strength") as check:
|
|
57
|
+
hwp._password_handshake(STRONG, min_bits=64)
|
|
58
|
+
|
|
59
|
+
assert check.call_count == 1, "min_bits is part of the cache key"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_a_rotated_password_revalidates():
|
|
63
|
+
hwp._password_handshake(STRONG, min_bits=64)
|
|
64
|
+
|
|
65
|
+
with patch.object(hwp, "check_password_strength") as check:
|
|
66
|
+
hwp._password_handshake(STRONG + "-rotated", min_bits=64)
|
|
67
|
+
|
|
68
|
+
assert check.call_count == 1
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_disabled_policy_does_not_validate_or_cache():
|
|
72
|
+
with patch.object(hwp, "check_password_strength") as check:
|
|
73
|
+
hwp._password_handshake(STRONG, min_bits=0)
|
|
74
|
+
|
|
75
|
+
assert check.call_count == 0
|
|
76
|
+
assert len(hwp._PASSWORD_STRENGTH_CACHE) == 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_cache_is_bounded():
|
|
80
|
+
limit = hwp._PASSWORD_STRENGTH_CACHE_SIZE
|
|
81
|
+
with patch.object(hwp, "check_password_strength"):
|
|
82
|
+
for i in range(limit + 50):
|
|
83
|
+
hwp._password_handshake(f"{STRONG}-{i}", min_bits=64)
|
|
84
|
+
|
|
85
|
+
assert len(hwp._PASSWORD_STRENGTH_CACHE) <= limit
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_concurrent_admissions_are_safe():
|
|
89
|
+
"""Admission runs from Tornado's loop and executor threads alike."""
|
|
90
|
+
errors = []
|
|
91
|
+
|
|
92
|
+
def admit():
|
|
93
|
+
try:
|
|
94
|
+
for _ in range(50):
|
|
95
|
+
hwp._password_handshake(STRONG, min_bits=64)
|
|
96
|
+
except Exception as e: # noqa: BLE001
|
|
97
|
+
errors.append(e)
|
|
98
|
+
|
|
99
|
+
threads = [threading.Thread(target=admit) for _ in range(8)]
|
|
100
|
+
for t in threads:
|
|
101
|
+
t.start()
|
|
102
|
+
for t in threads:
|
|
103
|
+
t.join()
|
|
104
|
+
|
|
105
|
+
assert errors == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/tests/test_client_ip.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/tests/test_health.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|