diff --git a/repeater/handler_helpers/acl.py b/repeater/handler_helpers/acl.py index 6d38bce..4fb1437 100644 --- a/repeater/handler_helpers/acl.py +++ b/repeater/handler_helpers/acl.py @@ -64,7 +64,10 @@ class ACL: sync_since: int = None, ) -> None: now = int(time.time()) - client.last_timestamp = timestamp + # Monotonic: the replay watermark must never move backwards, even if + # another accepted request advanced it between the replay check and + # this write. + client.last_timestamp = max(client.last_timestamp, timestamp) client.last_activity = now client.last_login_success = now client.shared_secret = shared_secret diff --git a/tests/test_handler_helpers_acl_advert.py b/tests/test_handler_helpers_acl_advert.py index 6af33a3..0c26932 100644 --- a/tests/test_handler_helpers_acl_advert.py +++ b/tests/test_handler_helpers_acl_advert.py @@ -142,6 +142,29 @@ def test_acl_admin_login_sets_client_state_and_replay_protection(): assert replay_perms == 0 +def test_acl_touch_client_session_watermark_is_monotonic(): + """The replay watermark never moves backwards: if another accepted request + advanced it between the replay check and the session touch, an older (but + already-validated) timestamp must not regress it.""" + identity = _FakeIdentity(b"B" * 32) + acl = ACL(max_clients=5, admin_password="top-secret", guest_password="guest") + + ok, _ = acl.authenticate_client( + client_identity=identity, + shared_secret=b"k" * 32, + password="top-secret", + timestamp=2000, + ) + assert ok is True + client = acl.get_client(b"B" * 40) + + acl._touch_client_session(client, b"k" * 32, timestamp=1500) + assert client.last_timestamp == 2000 + + acl._touch_client_session(client, b"k" * 32, timestamp=2500) + assert client.last_timestamp == 2500 + + def test_acl_max_clients_invalid_password_and_remove_client_paths(): acl = ACL(max_clients=1, admin_password="a", guest_password="g") id_a = _FakeIdentity(b"C" * 32)