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.
Files changed (22) hide show
  1. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/PKG-INFO +1 -1
  2. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol/__init__.py +54 -2
  3. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol/version.py +1 -1
  4. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol.egg-info/PKG-INFO +1 -1
  5. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol.egg-info/SOURCES.txt +1 -0
  6. hivemind_websocket_protocol-0.2.8a2/tests/test_password_strength_cache.py +105 -0
  7. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/LICENSE.md +0 -0
  8. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/README.md +0 -0
  9. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol/_client_ip.py +0 -0
  10. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol/health.py +0 -0
  11. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol.egg-info/dependency_links.txt +0 -0
  12. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol.egg-info/entry_points.txt +0 -0
  13. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol.egg-info/requires.txt +0 -0
  14. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/hivemind_websocket_protocol.egg-info/top_level.txt +0 -0
  15. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/pyproject.toml +0 -0
  16. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/setup.cfg +0 -0
  17. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/tests/test_client_ip.py +0 -0
  18. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/tests/test_close_diagnostics.py +0 -0
  19. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/tests/test_decode_auth.py +0 -0
  20. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/tests/test_health.py +0 -0
  21. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/tests/test_protocol_unit.py +0 -0
  22. {hivemind_websocket_protocol-0.2.8a1 → hivemind_websocket_protocol-0.2.8a2}/tests/test_trusted_proxy_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hivemind-websocket-protocol
3
- Version: 0.2.8a1
3
+ Version: 0.2.8a2
4
4
  Summary: websocket network protocol for hivemind-core
5
5
  Author-email: jarbasAi <jarbasai@mailfence.com>
6
6
  License-Expression: Apache-2.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 = PasswordHandShake(user.password, min_bits=runtime_password_min_bits())
485
+ self.client.pswd_handshake = _password_handshake(user.password)
434
486
 
435
487
  self.client.node_type = HiveMindNodeType.NODE # TODO . placeholder
436
488
 
@@ -2,7 +2,7 @@
2
2
  VERSION_MAJOR = 0
3
3
  VERSION_MINOR = 2
4
4
  VERSION_BUILD = 8
5
- VERSION_ALPHA = 1
5
+ VERSION_ALPHA = 2
6
6
  # END_VERSION_BLOCK
7
7
 
8
8
  __version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hivemind-websocket-protocol
3
- Version: 0.2.8a1
3
+ Version: 0.2.8a2
4
4
  Summary: websocket network protocol for hivemind-core
5
5
  Author-email: jarbasAi <jarbasai@mailfence.com>
6
6
  License-Expression: Apache-2.0
@@ -15,5 +15,6 @@ tests/test_client_ip.py
15
15
  tests/test_close_diagnostics.py
16
16
  tests/test_decode_auth.py
17
17
  tests/test_health.py
18
+ tests/test_password_strength_cache.py
18
19
  tests/test_protocol_unit.py
19
20
  tests/test_trusted_proxy_config.py
@@ -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 == []