aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py98
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d4e5f6a7b8ca_known_browsers.py32
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js26
-rw-r--r--packages/meshbay-hub/tests/test_known_browser.py98
-rw-r--r--packages/meshbay-hub/tests/test_login_lockout.py20
6 files changed, 272 insertions, 21 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 8e780df..fb53076 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -1,6 +1,7 @@
"""User endpoints — /v1/users/*"""
import base64
+import hashlib
import logging
import re
import secrets
@@ -42,6 +43,7 @@ from meshbay_hub.db.models import (
GroupInviteLink,
GroupMember,
IPLog,
+ KnownBrowser,
Node,
Notification,
RefreshToken,
@@ -161,6 +163,7 @@ class LoginRequest(BaseModel):
username: str
password: str | None = None # legacy (raw password) for migration
auth_key: str | None = None # PBKDF2-derived auth key (new scheme)
+ known_browser: str | None = None # from an earlier sign-in on this browser
class RefreshRequest(BaseModel):
@@ -351,6 +354,58 @@ async def _take_login_attempt(db: AsyncSession, username: str) -> None:
headers={"Retry-After": str(retry_after)})
+def _session_counter(user: User) -> str:
+ """The failure counter for a passphrase re-checked inside an open session.
+
+ Its own, not the sign-in one: a stranger who keeps a name locked at sign-in
+ must not also stop its owner changing their passphrase, deleting their
+ account or registering a device from a session they already hold.
+ """
+ return f"\x00session:{user.id}"
+
+
+async def _browser_counter(db: AsyncSession, username: str,
+ token: str | None) -> tuple[str, "KnownBrowser | None"]:
+ """The failure counter for a sign-in, and the known browser behind it if any.
+
+ A browser that signed in to this account before presents its token and is
+ counted on its own: the username's counter, which anyone can spend, then
+ locks only browsers this account has never used. A token for another
+ account, or none, is the username's counter — the answer is the same either
+ way, so it says nothing about the account (M1).
+ """
+ if token:
+ row = (await db.execute(
+ select(KnownBrowser).join(User, User.id == KnownBrowser.user_id)
+ .where(KnownBrowser.token_hash == _browser_hash(token),
+ User.username == username))).scalar_one_or_none()
+ if row is not None:
+ return f"{username}\x00browser:{row.id}", row
+ return username, None
+
+
+def _browser_hash(token: str) -> str:
+ return hashlib.sha256(f"meshbay:known_browser:{token}".encode()).hexdigest()
+
+
+# How many browsers one account is remembered on. The oldest goes first; a
+# browser forgotten here is only an unknown one again.
+MAX_KNOWN_BROWSERS = 20
+
+
+async def _remember_browser(db: AsyncSession, user: User) -> str:
+ """A new known-browser token for `user`. The caller commits."""
+ raw = secrets.token_urlsafe(32)
+ rows = (await db.execute(
+ select(KnownBrowser.id).where(KnownBrowser.user_id == user.id)
+ .order_by(KnownBrowser.last_used_at.desc()))).scalars().all()
+ stale = rows[MAX_KNOWN_BROWSERS - 1:]
+ if stale:
+ await db.execute(delete(KnownBrowser).where(KnownBrowser.id.in_(stale)))
+ db.add(KnownBrowser(user_id=user.id, token_hash=_browser_hash(raw)))
+ return raw
+
+
async def _prove_passphrase(db: AsyncSession, user: User, auth_key: str) -> None:
"""Refuse with 403 unless `auth_key` is this account's, spending an attempt.
@@ -358,18 +413,18 @@ async def _prove_passphrase(db: AsyncSession, user: User, auth_key: str) -> None
refreshed one, or one lifted from a page, and what it would buy here outlives
the session or reopens the offline search the pepper exists to prevent.
"""
- await _take_login_attempt(db, user.username)
+ await _take_login_attempt(db, _session_counter(user))
if not await verify_password_off_loop(auth_key, user.pw_hash, user.pw_salt,
user.pw_version):
raise HTTPException(status_code=403, detail="Passphrase does not match")
- await login_throttle.clear(db, user.username)
+ await login_throttle.clear(db, _session_counter(user))
async def _login_failed(db: AsyncSession, username: str, ip: str,
- user_id: str | None = None) -> None:
+ user_id: str | None = None, counter: str | None = None) -> None:
"""Record a wrong passphrase and answer 401. Always raises."""
db.add(IPLog(user_id=user_id, event="login_fail", ip_address=ip, detail=username))
- if await login_throttle.is_now_locked(db, username):
+ if await login_throttle.is_now_locked(db, counter or username):
# Once, on the failure that spent the last attempt — so the logs tab
# shows when a name was locked, not every refusal after it.
db.add(IPLog(user_id=user_id, event="login_locked", ip_address=ip,
@@ -431,32 +486,33 @@ async def login(
# Before the account is even looked up: an unknown name spends attempts and
# locks exactly like a real one, so neither answer tells them apart (M1).
- await _take_login_attempt(db, body.username)
+ counter, browser = await _browser_counter(db, body.username, body.known_browser)
+ await _take_login_attempt(db, counter)
result = await db.execute(
select(User).where(User.username == body.username))
user = result.scalar_one_or_none()
if not user:
- await _login_failed(db, body.username, ip)
+ await _login_failed(db, body.username, ip, counter=counter)
if user.pw_version >= 3:
# New scheme: verify auth_key
if not body.auth_key or not await verify_password_off_loop(
body.auth_key, user.pw_hash, user.pw_salt, version=user.pw_version
):
- await _login_failed(db, body.username, ip, user.id)
+ await _login_failed(db, body.username, ip, user.id, counter)
else:
# Legacy scheme: need raw password
if not body.password:
# Nothing was checked, so nothing was guessed.
- await login_throttle.release(db, body.username)
+ await login_throttle.release(db, counter)
await db.commit()
raise HTTPException(status_code=401, detail="auth_upgrade_required")
if not await verify_password_off_loop(
body.password, user.pw_hash, user.pw_salt, version=user.pw_version
):
- await _login_failed(db, body.username, ip, user.id)
+ await _login_failed(db, body.username, ip, user.id, counter)
# Migrate to new scheme if auth_key provided alongside password
if body.auth_key:
new_hash, new_salt = await hash_password_off_loop(body.auth_key)
@@ -471,6 +527,7 @@ async def login(
user.pw_version = 2
# The passphrase was right, whatever the account's status turns out to be.
+ await login_throttle.clear(db, counter)
await login_throttle.clear(db, body.username)
if user.status != "active":
@@ -501,6 +558,11 @@ async def login(
))
db.add(IPLog(user_id=user.id, event="login", ip_address=ip))
pepper = _bundle_pepper(user)
+ if browser is not None:
+ browser.last_used_at = datetime.now(UTC)
+ known = {}
+ else:
+ known = {"known_browser": await _remember_browser(db, user)}
await db.commit()
return {
@@ -509,6 +571,7 @@ async def login(
"token_type": "bearer",
"expires_in": _ttl(),
**pepper,
+ **known,
}
@@ -787,7 +850,8 @@ async def get_current_user_info(
# A passphrase change re-wraps every node's bundle *before* the hub
# accepts the new passphrase, and must not start while the hub would
# then refuse it.
- "passphrase_locked_for": await login_throttle.locked_for(db, current_user.username),
+ "passphrase_locked_for": await login_throttle.locked_for(
+ db, _session_counter(current_user)),
}
@@ -849,13 +913,13 @@ async def update_profile(
raise HTTPException(
status_code=403,
detail="Changing your e-mail requires your passphrase.")
- await _take_login_attempt(db, current_user.username)
+ await _take_login_attempt(db, _session_counter(current_user))
if not await verify_password_off_loop(
body.auth_key, current_user.pw_hash, current_user.pw_salt,
current_user.pw_version):
raise HTTPException(status_code=403,
detail="Passphrase does not match")
- await login_throttle.clear(db, current_user.username)
+ await login_throttle.clear(db, _session_counter(current_user))
# How often one account may point the hub at a *different* address.
# Long, because this is the only path where a signed-in account chooses
@@ -1074,12 +1138,12 @@ async def change_password(
current_user: User = Depends(require_user_scope),
db: AsyncSession = Depends(get_db),
):
- await _take_login_attempt(db, current_user.username)
+ await _take_login_attempt(db, _session_counter(current_user))
if not await verify_password_off_loop(body.old_auth_key, current_user.pw_hash,
current_user.pw_salt, current_user.pw_version):
raise HTTPException(status_code=403,
detail="Current passphrase does not match")
- await login_throttle.clear(db, current_user.username)
+ await login_throttle.clear(db, _session_counter(current_user))
if body.new_auth_key == body.old_auth_key:
raise HTTPException(status_code=400,
detail="New passphrase must differ from the current one")
@@ -1279,6 +1343,7 @@ async def password_reset(
update(RefreshToken).where(RefreshToken.user_id == user.id)
.values(revoked=True))
await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id))
+ await db.execute(delete(KnownBrowser).where(KnownBrowser.user_id == user.id))
# A code sent to the address on file is a stronger proof than a passphrase,
# and it is the way out of a lockout somebody else caused.
await login_throttle.clear(db, user.username)
@@ -1493,6 +1558,7 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus
GroupHost.node_id.in_(select(Node.id).where(Node.user_id == user.id))))
await db.execute(delete(Node).where(Node.user_id == user.id))
await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id))
+ await db.execute(delete(KnownBrowser).where(KnownBrowser.user_id == user.id))
await db.execute(delete(EmailVerification).where(EmailVerification.user_id == user.id))
# Links this account issued for a group it no longer owns; the ones for its
# own groups went with them above. A used link keeps pointing at the
@@ -1538,11 +1604,11 @@ async def delete_own_account(
borrowed laptop or a session left open. Same value as at sign-in, so the hub
still never sees the passphrase itself.
"""
- await _take_login_attempt(db, current_user.username)
+ await _take_login_attempt(db, _session_counter(current_user))
if not await verify_password_off_loop(body.auth_key, current_user.pw_hash, current_user.pw_salt,
current_user.pw_version):
raise HTTPException(status_code=403, detail="Passphrase does not match")
- await login_throttle.clear(db, current_user.username)
+ await login_throttle.clear(db, _session_counter(current_user))
return await erase_account(db, current_user)
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d4e5f6a7b8ca_known_browsers.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d4e5f6a7b8ca_known_browsers.py
new file mode 100644
index 0000000..aaad5c7
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d4e5f6a7b8ca_known_browsers.py
@@ -0,0 +1,32 @@
+"""browsers an account has signed in from, each with its own failure counter
+
+Revision ID: d4e5f6a7b8ca
+Revises: c3d4e5f6a7b9
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "d4e5f6a7b8ca"
+down_revision: str | Sequence[str] | None = "c3d4e5f6a7b9"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "known_browsers",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
+ sa.Column("token_hash", sa.String(64), nullable=False, unique=True),
+ sa.Column("created_at", sa.DateTime(timezone=True)),
+ sa.Column("last_used_at", sa.DateTime(timezone=True)),
+ )
+ op.create_index("ix_known_browsers_user_id", "known_browsers", ["user_id"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_known_browsers_user_id", table_name="known_browsers")
+ op.drop_table("known_browsers")
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index 1e652a6..dbc0f10 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -429,6 +429,25 @@ class MailQuota(Base):
last_sent: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+class KnownBrowser(Base):
+ """A browser this account has signed in from, for the sign-in lockout.
+
+ Its own failure counter, which a stranger cannot spend: the lockout keyed by
+ username alone let anyone who knew a name keep its owner out of every
+ browser, four requests an hour. Only a hash of the token is kept. It is not
+ a credential — a sign-in presenting it still needs the passphrase.
+ """
+
+ __tablename__ = "known_browsers"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
+ user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False,
+ index=True)
+ token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+ last_used_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+
+
class LoginThrottle(Base):
"""Wrong passphrases per username, for the sign-in lockout (`login_throttle.py`).
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index 6bd5896..b1770a7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -426,13 +426,36 @@ async function decryptBundle(bundleB64, aesKey, { userId, nodePk }) {
* decrypts it and returns the keys + encrypted bundle for push to node.
* Otherwise returns bundleKey so the caller can fetch from node during handshake.
*/
+// The token the hub gave this browser at an earlier sign-in, per account. It
+// is not a credential — the passphrase is still asked — but a sign-in that
+// presents it has a failure counter of its own, so a stranger who keeps
+// failing on this account's name locks only browsers it has never used.
+// Kept across sign-outs on purpose: forgetting it would be the lockout again.
+const KNOWN_BROWSERS = 'mb_known_browsers';
+
+function _knownBrowser(username) {
+ try { return (JSON.parse(localStorage.getItem(KNOWN_BROWSERS)) || {})[username] || null; }
+ catch { return null; }
+}
+
+function _rememberBrowser(username, token) {
+ if (!token) return;
+ try {
+ const all = JSON.parse(localStorage.getItem(KNOWN_BROWSERS)) || {};
+ all[username] = token;
+ localStorage.setItem(KNOWN_BROWSERS, JSON.stringify(all));
+ } catch { /* storage refused: this browser stays an unknown one */ }
+}
+
async function loginAndRecover(username, password) {
const authKey = await deriveAuthKey(password, username);
+ const known = _knownBrowser(username);
const resp = await hubCall('/v1/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ username, auth_key: authKey }),
+ body: JSON.stringify({ username, auth_key: authKey,
+ ...(known ? { known_browser: known } : {}) }),
});
if (!resp.ok) {
@@ -454,6 +477,7 @@ async function loginAndRecover(username, password) {
}
const data = await resp.json();
+ _rememberBrowser(username, data.known_browser);
const result = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
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):