From 752b160c7c5e671e0db8f402a52fac27bb85ab06 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 1 Oct 2026 11:47:39 +0200 Subject: fix(hub): a stranger who knows your name locks only browsers you never used A sign-in from a browser that presented no token is answered with one (known_browser, kept hashed, twenty per account); a later sign-in presenting it counts failures on its own row, which nobody else can spend. Passphrase checks inside an open session (change, e-mail, deletion, device, pepper) count on the account's own row, so a locked name no longer stops its owner there either; /me reports that row. Reset and erasure forget the browsers (F-15). Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-hub/tests/test_known_browser.py | 98 ++++++++++++++++++++++++ packages/meshbay-hub/tests/test_login_lockout.py | 20 ++++- 2 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 packages/meshbay-hub/tests/test_known_browser.py (limited to 'packages/meshbay-hub/tests') diff --git a/packages/meshbay-hub/tests/test_known_browser.py b/packages/meshbay-hub/tests/test_known_browser.py new file mode 100644 index 0000000..260f08e --- /dev/null +++ b/packages/meshbay-hub/tests/test_known_browser.py @@ -0,0 +1,98 @@ +""" +A stranger who knows your name locks only the browsers you never used. + +The sign-in lockout counts wrong passphrases per username: four an hour from +anyone kept the owner out of every browser for as long as they cared to keep +going. A browser that signed in to the account before presents a token and has +a counter of its own, which nobody else can spend. The token is not a +credential — the passphrase is still asked — and a passphrase re-checked inside +an open session is not the sign-in counter's business at all. +""" + +import pytest +from meshbay_hub.db.models import KnownBrowser, User +from sqlalchemy import func, select +from test_bundle_pepper import KEY, _register + +WRONG = "w" * 44 + + +async def _sign_in(client, username, key=KEY, known=None): + body = {"username": username, "auth_key": key} + if known: + body["known_browser"] = known + return await client.post("/v1/users/login", json=body) + + +async def _lock(client, username): + for _ in range(4): + await _sign_in(client, username, WRONG) + assert (await _sign_in(client, username)).status_code == 429 + + +@pytest.mark.asyncio +async def test_a_known_browser_signs_in_through_a_strangers_lockout(client): + await _register(client, "known_owner") + token = (await _sign_in(client, "known_owner")).json()["known_browser"] + + await _lock(client, "known_owner") # the stranger, without a token + r = await _sign_in(client, "known_owner", known=token) + assert r.status_code == 200, r.text + assert "known_browser" not in r.json(), "a known browser is not given a second token" + + +@pytest.mark.asyncio +async def test_another_accounts_token_is_no_way_round(client): + await _register(client, "known_alice") + await _register(client, "known_bobby") + alices = (await _sign_in(client, "known_alice")).json()["known_browser"] + + await _lock(client, "known_bobby") + assert (await _sign_in(client, "known_bobby", known=alices)).status_code == 429 + + +@pytest.mark.asyncio +async def test_a_known_browser_is_locked_by_its_own_failures(client): + """Whoever holds the token still guesses at the same rate.""" + await _register(client, "known_guess") + token = (await _sign_in(client, "known_guess")).json()["known_browser"] + for _ in range(4): + assert (await _sign_in(client, "known_guess", WRONG, token)).status_code == 401 + assert (await _sign_in(client, "known_guess", known=token)).status_code == 429 + # ...and that spent nothing of a browser that has no token. + assert (await _sign_in(client, "known_guess")).status_code == 200 + + +@pytest.mark.asyncio +async def test_a_locked_name_does_not_stop_its_owner_inside_a_session(client): + await _register(client, "known_inside") + session = (await _sign_in(client, "known_inside")).json()["access_token"] + await _lock(client, "known_inside") + + r = await client.post("/v1/users/me/bundle-pepper", json={"auth_key": KEY}, + headers={"Authorization": f"Bearer {session}"}) + assert r.status_code == 200, r.text + + +@pytest.mark.asyncio +async def test_only_a_hash_is_kept_and_only_twenty(client, db_session): + await _register(client, "known_many") + tokens = [(await _sign_in(client, "known_many")).json()["known_browser"] + for _ in range(25)] + uid = (await db_session.execute( + select(User.id).where(User.username == "known_many"))).scalar_one() + rows = (await db_session.execute( + select(KnownBrowser).where(KnownBrowser.user_id == uid))).scalars().all() + assert len(rows) == 20 + assert not any(t in {r.token_hash for r in rows} for t in tokens) + + +@pytest.mark.asyncio +async def test_erasing_the_account_forgets_its_browsers(client, db_session): + await _register(client, "known_gone") + login = (await _sign_in(client, "known_gone")).json() + r = await client.request("DELETE", "/v1/users/me", json={"auth_key": KEY}, + headers={"Authorization": f"Bearer {login['access_token']}"}) + assert r.status_code == 200, r.text + left = await db_session.scalar(select(func.count()).select_from(KnownBrowser)) + assert left == 0 diff --git a/packages/meshbay-hub/tests/test_login_lockout.py b/packages/meshbay-hub/tests/test_login_lockout.py index 601edbd..8f06d2d 100644 --- a/packages/meshbay-hub/tests/test_login_lockout.py +++ b/packages/meshbay-hub/tests/test_login_lockout.py @@ -131,8 +131,13 @@ async def test_a_burst_of_concurrent_guesses_gets_no_more_than_the_limit(client) @pytest.mark.asyncio -async def test_change_password_counts_on_the_same_row(client): - """It checks the same passphrase, so it is the same oracle.""" +async def test_change_password_counts_on_the_sessions_own_row(client): + """ + It checks the passphrase, so it is counted and locked like a sign-in — on a + row of the session's own. A stranger failing at sign-in must not stop the + owner changing their passphrase from a session they hold, and failures here + must not lock the owner's other browsers out of signing in. + """ await _register(client, "grace_test") token = (await _login(client, "grace_test", RIGHT)).json()["access_token"] auth = {"Authorization": f"Bearer {token}"} @@ -145,7 +150,7 @@ async def test_change_password_counts_on_the_same_row(client): r = await client.post("/v1/users/password", headers=auth, json={ "old_auth_key": RIGHT, "new_auth_key": "n" * 44}) assert r.status_code == 429, r.text - assert (await _login(client, "grace_test", RIGHT)).status_code == 429 + assert (await _login(client, "grace_test", RIGHT)).status_code == 200 @pytest.mark.asyncio @@ -157,10 +162,17 @@ async def test_a_signed_in_session_is_told_its_own_lockout(client): auth = {"Authorization": f"Bearer {token}"} assert (await client.get("/v1/users/me", headers=auth)).json()["passphrase_locked_for"] == 0 - await _fail(client, "olivia_test", 4) + for _ in range(4): + await client.post("/v1/users/password", headers=auth, json={ + "old_auth_key": WRONG, "new_auth_key": "n" * 44}) left = (await client.get("/v1/users/me", headers=auth)).json()["passphrase_locked_for"] assert 3500 <= left <= 3600 + # A stranger failing at sign-in is not this session's lockout. + await _fail(client, "olivia_test", 4) + other = (await _login(client, "olivia_test", RIGHT)) + assert other.status_code == 429 + @pytest.mark.asyncio async def test_an_attempt_that_checks_no_passphrase_is_not_counted(client, db_session): -- cgit v1.2.3