aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-14 01:53:04 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-14 01:53:04 +0200
commit392b5e4a53aace725794c7bbabf9e95fb4e1b9c5 (patch)
tree0d31021b8558833a822bb906ec59d95abeb3f860
parent413837a0845240241ed7e9d9ac1f3b1dc45a2f40 (diff)
downloadmeshbay-392b5e4a53aace725794c7bbabf9e95fb4e1b9c5.tar.gz
fix(hub): a per-account sign-in lockout, and a reviewed unauthenticated surface
Passphrase sign-in locks per username: after `login.max_failures` wrong passphrases (default 4) the name is refused with `429 account_locked` and a `Retry-After` for `login.lockout_minutes` (default 60), without the passphrase being checked. Both numbers are instance policy an admin sets from the panel; zero failures turns it off. The per-IP limit bounds one address, and IPv6 gives every subscriber a /64 of them — an online guess targets an account, so the account is what is counted. - Counted by the name as typed, existing or not, so `login` stays uniform (M1). The key is a hash: people type passphrases into the username field. - The attempt is taken before the check in one `INSERT … ON CONFLICT DO UPDATE … WHERE … RETURNING`, so a concurrent burst gets no more than the limit. - Sign-in, passphrase change and account deletion count on the same row; the last had no rate limit at all. - A lockout refuses passphrase sign-in and nothing else: sessions, renewal and device sign-in continue, and a reset code clears it (AV26). A session learns its own lockout from `/v1/users/me`, and the passphrase change checks it before re-wrapping any node's bundle — the hub accepts the new passphrase only after the nodes have it. The SPA now shows what the hub said. `loginAndRecover` threw "Login failed: {json}", so `email_verification_required` never matched and was never shown; the passphrase-change form rendered no error at all in its first phase. The unauthenticated surface, reviewed route by route: - No `/docs`, `/redoc` or `/openapi.json`, in the code. The Caddyfile hid them on meshbay.org only; a packaged hub behind any other proxy published all three. - The node socket's first message must arrive within ten seconds. It is accepted before anyone is known, and an unbounded read is a connection any stranger holds for free. - `/v1/relays` answers 503 behind `relay.RELAYS_ENABLED`, as federation does: nothing in the tree calls it and two of its routes take no account. - `test_unauthenticated_surface.py` walks every route and fails on one without an authentication dependency that is not listed with its reason. Verified in Chrome against a local hub: the lockout and wrong-passphrase messages, the admin section saving both lockout and mail limits, and the passphrase change refused while locked. Not verified in Firefox (a running instance blocks the headless one), nor the upsert's concurrency on PostgreSQL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LcF3QKWii7uQ2kSyXErzCt
-rw-r--r--CLAUDE.md6
-rw-r--r--docs/MESHBAY_DESIGN.md45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py39
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/relay.py23
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py73
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a9b8c7d6e5f4_add_login_throttle.py33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/hub_settings.py31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/login_throttle.py143
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/admin-page.js51
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py6
-rw-r--r--packages/meshbay-hub/tests/test_availability_between_members.py79
-rw-r--r--packages/meshbay-hub/tests/test_login_lockout.py256
-rw-r--r--packages/meshbay-hub/tests/test_migrations_reach_head.py2
-rw-r--r--packages/meshbay-hub/tests/test_unauthenticated_surface.py134
30 files changed, 1044 insertions, 41 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index d370d54..eba1ea2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -122,7 +122,7 @@ that produced it.
| Looking for | Read |
|---|---|
| What a label means (`C1`, `H3`, `NS6`, `T3`, `C5b`, `W2`, `E9`, `F1`, `AV4`, …) | `docs/MESHBAY_DESIGN.md` §13 |
-| What one member can cost the others (`AV1`–`AV19`) | §13.5b — the newest category, and the one the first three reviews had no question for |
+| What one member can cost the others (`AV1`–`AV26`) | §13.5b — the newest category, and the one the first three reviews had no question for |
| Trust model, and what the project may and may not claim | §2 |
| Identity, devices, admission, recovery, the keypair bundle | §3 |
| Cryptography, key hierarchy, the group and chat envelopes | §4 |
@@ -782,7 +782,9 @@ here are kept only where they are a rule about *editing* the code.
| Signaling relay | `meshbay_hub/api/signaling.py` | §7.2 |
| Groups, membership, presence, public-group quota | `meshbay_hub/api/groups.py` | §7.3 |
| Admin API, instance policy, moderation | `meshbay_hub/api/admin.py`, `hub.py` | §7.4, §7.5 |
-| Notifications, federation, relays, reports | `meshbay_hub/api/notifications.py`, `federation.py`, `relay.py`, `moderation.py` | §7.6. **Federation is off**: `federation.FEDERATION_ENABLED` is False and every MHP route answers 503, because no two hubs have ever completed a request between them. Its tests open the gate for themselves; `relay.py` is called by nothing in the tree at all |
+| Notifications, federation, relays, reports | `meshbay_hub/api/notifications.py`, `federation.py`, `relay.py`, `moderation.py` | §7.6. **Federation is off**: `federation.FEDERATION_ENABLED` is False and every MHP route answers 503, because no two hubs have ever completed a request between them. Its tests open the gate for themselves. **Relays are closed the same way** (`relay.RELAYS_ENABLED`): nothing in the tree calls them |
+| Sign-in lockout | `meshbay_hub/login_throttle.py`, settings in `hub_settings.py` (`login.*`) | §7.7. **Every path that checks a passphrase calls `_take_login_attempt` first** — login, passphrase change, account deletion |
+| What answers without an account | `tests/test_unauthenticated_surface.py` — `PUBLIC` | §7.4. A new open route fails the suite until it is listed there with its reason |
| Asset versioning | `meshbay_hub/api/webapp.py` — `_asset_version()` | the whole module graph is served under `/a/<hash>/`, and the hash covers **every file under `static/`**, subdirectories included — nothing to register |
| Token lifetimes | `meshbay_hub/config.py` — `[jwt]` | 4 h access, 30 days refresh. **Production sets both in `~/.config/meshbay/hub.toml`** — changing the code default alone does nothing there |
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index a511093..6b1fd05 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -1588,6 +1588,10 @@ everyone in it (2026-09-11). The ceiling applies to **every** message that chang
set, not only to the registration: a node that may narrow on connecting and widen on
reload has no ceiling.
+The socket is accepted before anyone is known, so **the auth message must arrive
+within ten seconds** or the socket is closed with 4001: an unbounded first read is a
+connection any stranger holds open for free. The node sends it on connecting.
+
A client must therefore treat that list as candidates rather than a ranking, and try
the next node on a `not_hosted` refusal (`MESHBAY_NODE_PROTOCOL.md` §6.3).
@@ -1659,6 +1663,20 @@ keep their membership and their access. A node whose operator set `join_policy =
"open"` still pins and serves whoever reaches it directly over MNP; what the switch
removes is the hub-provided ways to find and reach such a node.
+**What the hub answers without an account is a reviewed list.**
+`test_unauthenticated_surface.py` walks every route and fails on one that takes no
+authentication dependency and is not listed there with its reason; routes that
+authenticate in their own body (a signature, an e-mailed code, an MHP token) are
+listed with what they check. The hub publishes no API description — no `/docs`,
+`/redoc` or `/openapi.json` — in the code, not in a proxy rule, so a packaged
+install behind any proxy publishes none either.
+
+The mail bounds (`mail.*`) and the sign-in lockout (`login.max_failures`,
+default 4, and `login.lockout_minutes`, default 60 — §7.7) live in the same table
+for the same reason: they are what an operator changes while the hub is serving,
+from the panel, without a restart. Each value is clamped to published bounds, and
+`max_failures = 0` turns the lockout off.
+
### 7.5 Moderation
Two verbs on a group, and they are distinct things:
@@ -1760,6 +1778,30 @@ Registration is gated by a CAPTCHA whenever one is configured — **unconditiona
not only when some other field is absent, or the real client's ordinary request
skips it. The desktop client renders the widget too.
+**Passphrase sign-in locks per username.** After `login.max_failures` wrong
+passphrases (§7.4) the name is refused with `429 account_locked` and a
+`Retry-After` for `login.lockout_minutes`, without the passphrase being checked.
+The per-IP rate limit bounds one address, and IPv6 hands every subscriber a /64
+of them; an online guess targets an account, so the account is what is counted.
+The rules that make this safe:
+
+- **Counted by the name as typed, existing or not.** An unknown name locks exactly
+ like a real one, so `login` stays uniform (**M1**). The key is a hash: people
+ type passphrases into the username field.
+- **The attempt is taken before the check, in one statement** — an `INSERT … ON
+ CONFLICT DO UPDATE … WHERE … RETURNING` — so a concurrent burst gets no more
+ attempts than the limit. A request that checked no passphrase gives its attempt
+ back.
+- **Every path that checks the passphrase counts on the same row**: sign-in,
+ passphrase change and account deletion. A right passphrase clears it; failures
+ older than the window age out.
+- **A lockout refuses passphrase sign-in and nothing else.** Open sessions, token
+ renewal and device sign-in continue, and a reset code sent to the address on
+ file clears it — so a stranger who locks a public username costs its owner at
+ most a new sign-in (**AV26**). A session learns its own lockout from
+ `/v1/users/me`, because a passphrase change re-wraps every node's bundle before
+ the hub accepts the new passphrase and must not start when the hub would refuse.
+
---
## 8. Clients
@@ -2641,6 +2683,7 @@ had already been asked.
| **AV23** | **An upload's owner is recorded when the upload ends and applied when the entry is created**, which are different moments (§5.4). Written against the index at the end of the upload it matched nothing, every time, and left every uploaded file owned by nobody — so no member could delete what they had sent |
| **AV24** | **A node registered for no group is refused signaling, not exempted from it** (§7.2). The membership check was written as "if the node claims any group", so it skipped itself — membership, group status and the public-group gate together — for the node AV1 made commonplace: the unconfigured one, which is also the one least able to absorb the work |
| **AV25** | **Which nodes host a group is answered to its members** (§7.3). Only the public case checked, so a private group told any authenticated account that knew its id which machines hosted it — and an ex-member knows that id for ever |
+| **AV26** | **A sign-in lockout refuses passphrase sign-in and nothing else** (§7.7). It is keyed by username, usernames are public, and so anyone can spend somebody else's attempts. Open sessions, renewal and device sign-in are untouched and a reset code ends it, which bounds what a stranger buys to one forced sign-in. The lockout is a DoS primitive by construction; this is the ceiling on it |
### 13.6 Chat design findings
@@ -2796,7 +2839,7 @@ account recovery, and the Windows port through packaging.
| The exact-hash content check | Structural, not functional (§7.5) |
| **The packaged install** | Built and never installed. `build-packages.sh` produces four `.deb` that carry the migrations and a relocatable entry point, and no machine has been taken from those packages to a running hub and node. Every packaging defect found so far was found the first time somebody tried |
| **QUIC** | Off by default, and **not at parity**: it serves the index and file chunks with no transfer lease, no leaseless ceiling and no root-availability check, does its file I/O on the event loop, and returns exception text to the peer (**L3**). No client speaks it. Either it comes to parity or it goes; until then §5.1's "chat is the only gap" is the one sentence here that overstates the code |
-| **The relay registry** | `/v1/relays` register/list/approve exist and nothing in the tree calls them, node or client — and §11.1 measured two ISPs with no TURN relay needed. Kept code that nothing calls is what **L7** says not to keep |
+| **The relay registry** | **Closed in the code**: `relay.RELAYS_ENABLED` is False and every `/v1/relays` route answers 503, as federation does. Nothing in the tree calls them, node or client, and §11.1 measured two ISPs with no TURN relay needed. Kept code that nothing calls is what **L7** says not to keep; it stays only as the proof-of-possession design (**AV6**) until a node needs a relay or it is deleted |
| **Free-text third-party search** | `tmdb_search_req` takes a member's query and spends the operator's per-credential quota with no rate limit and no per-member bound, where link previews carry both. §6.5's standing rule — a bound and a named adversary in the same commit — was not applied here |
| **Disk I/O on the node's event loop** | A chunk read-and-encrypt and every upload chunk write run in the message handler. On a spun-down or network-mounted root that stalls every group, every stream and the hub socket, which is `AV9`'s lesson with the disk in place of the mail server |
| **Node announcements are not bounded** | One account may announce unlimited distinct node keys, each a row plus an IP-log row under a one-year retention. Proof of possession is checked (**M8**); the count is not |
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
index 087c221..397b4d9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
@@ -40,20 +40,25 @@ class SettingsPatchRequest(BaseModel):
allow_public_groups: bool | None = None
# Every mail bound, each optional: the panel sends only what changed.
mail: dict[str, int] | None = None
+ # The sign-in lockout's two numbers, each optional, as for mail.
+ login: dict[str, int] | None = None
# ── Instance settings ────────────────────────────────────────────────────────
-def _settings_payload(allow_public_groups: bool, mail: dict) -> dict:
+async def _settings_payload(db: AsyncSession) -> dict:
return {
- "allow_public_groups": allow_public_groups,
- "mail": mail,
+ "allow_public_groups": await hub_settings.public_groups_allowed(db),
+ "mail": await hub_settings.mail_limits(db),
# So the panel can show what a field falls back to, and label the
# bounds it will refuse — rather than the operator finding out by
# having a value silently clamped.
"mail_defaults": {k: hub_settings.mail_default(k)
for k in hub_settings.MAIL_KEYS},
"mail_bounds": {k: list(v) for k, v in hub_settings.MAIL_BOUNDS.items()},
+ "login": await hub_settings.login_limits(db),
+ "login_defaults": dict(hub_settings.LOGIN_DEFAULTS),
+ "login_bounds": {k: list(v) for k, v in hub_settings.LOGIN_BOUNDS.items()},
}
@@ -63,9 +68,7 @@ async def admin_get_settings(
db: AsyncSession = Depends(get_db),
):
"""Instance-wide policy an admin controls from the panel. Moderators may read."""
- return _settings_payload(
- await hub_settings.public_groups_allowed(db),
- await hub_settings.mail_limits(db))
+ return await _settings_payload(db)
@router.patch("/settings")
@@ -115,9 +118,27 @@ async def admin_patch_settings(
))
await db.commit()
- return _settings_payload(
- await hub_settings.public_groups_allowed(db),
- await hub_settings.mail_limits(db))
+ if body.login:
+ unknown = sorted(set(body.login) - set(hub_settings.LOGIN_KEYS))
+ if unknown:
+ raise HTTPException(
+ status_code=422, detail=f"Unknown login setting(s): {unknown}")
+ changed = []
+ for key, value in body.login.items():
+ clamped = hub_settings.clamp_login_value(key, value)
+ await hub_settings.set_raw(db, f"login.{key}", str(clamped))
+ changed.append(f"{key}={clamped}")
+ log.info("Sign-in lockout changed by %s: %s",
+ current_user.username, ", ".join(changed))
+ db.add(IPLog(
+ user_id=current_user.id,
+ event="admin_login_lockout_update",
+ ip_address="admin",
+ detail=", ".join(changed)[:255],
+ ))
+ await db.commit()
+
+ return await _settings_payload(db)
@router.get("/mail")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/relay.py b/packages/meshbay-hub/src/meshbay_hub/api/relay.py
index c6ef26e..08d935b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/relay.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/relay.py
@@ -32,7 +32,28 @@ from meshbay_hub.db.models import User
log = logging.getLogger(__name__)
-router = APIRouter(prefix="/v1/relays", tags=["relay"])
+# **Closed, the same way and for a similar reason as federation.** Nothing in the
+# tree calls these routes — no node asks for a relay, no client offers one — and
+# §11.1 measured two ISPs with no TURN relay needed. Two of the three take no
+# account and answer anyone who can reach the hub, so a registry nothing uses
+# was an unauthenticated surface kept for its own sake. A constant, not a
+# setting: re-opening it means building the node side first, then flipping this.
+RELAYS_ENABLED = False
+
+
+def _relays_open() -> None:
+ """Refuse every route on this router while the registry is closed.
+
+ On the router rather than in each handler, so a route added later is closed
+ before anybody remembers to write the check (C6).
+ """
+ if not RELAYS_ENABLED:
+ raise HTTPException(status_code=503,
+ detail="The relay registry is not enabled on this hub")
+
+
+router = APIRouter(prefix="/v1/relays", tags=["relay"],
+ dependencies=[Depends(_relays_open)])
# In-memory relay registry (production: DB table)
_relays: dict[str, dict] = {} # relay_id → {endpoint, pk, last_seen, capacity}
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index 60b0c88..1f1f5c5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -56,6 +56,11 @@ _connected_nodes: dict[str, WebSocket] = {} # node_id → websocket
_node_groups: dict[str, list[str]] = {} # node_id → [group_id, ...]
_punch_events: dict[str, asyncio.Event] = {} # node_id → signaling event
+# How long an unauthenticated socket may stay open before saying who it is. The
+# node sends its auth message the moment the connection opens; anything that
+# has not spoken by now is holding a socket and a task for nothing.
+NODE_WS_AUTH_TIMEOUT = 10.0
+
def is_node_connected(node_id: str) -> bool:
return node_id in _connected_nodes
@@ -336,7 +341,13 @@ async def node_websocket(ws: WebSocket):
try:
# Auth: expect {"type": "auth", "token": "<jwt>", "node_id": "..."}
- raw = await ws.receive_text()
+ # Bounded: the socket is accepted before anyone is authenticated, so an
+ # unbounded wait is a connection any stranger can hold open for ever.
+ try:
+ raw = await asyncio.wait_for(ws.receive_text(), NODE_WS_AUTH_TIMEOUT)
+ except TimeoutError:
+ await _reject(ws, "Authentication timed out", 4001)
+ return
msg = json.loads(raw)
if msg.get("type") != "auth" or "token" not in msg:
await _reject(ws, "Send auth first", 4001)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 7cebd91..2a6baf0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -14,7 +14,7 @@ from pydantic import BaseModel, field_validator
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
-from meshbay_hub import hub_settings, mail
+from meshbay_hub import hub_settings, login_throttle, mail
from meshbay_hub.api.deps import get_current_user, require_user_scope
from meshbay_hub.api.middleware import limiter
from meshbay_hub.api.netutil import client_ip
@@ -296,6 +296,31 @@ async def verify_email(
return {"status": "verified"}
+async def _take_login_attempt(db: AsyncSession, username: str) -> None:
+ """Spend one passphrase attempt for `username`, or refuse with 429.
+
+ Every path that checks a passphrase goes through here first — `login` and
+ `change_password` alike, because a lockout on one door is not a lockout.
+ """
+ allowed, retry_after = await login_throttle.reserve(db, username)
+ if not allowed:
+ raise HTTPException(status_code=429, detail="account_locked",
+ headers={"Retry-After": str(retry_after)})
+
+
+async def _login_failed(db: AsyncSession, username: str, ip: str,
+ user_id: 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):
+ # 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,
+ detail=username))
+ await db.commit()
+ raise HTTPException(status_code=401, detail="Invalid credentials")
+
+
@router.post("/login")
@limiter.limit("10/minute")
async def login(
@@ -303,38 +328,39 @@ async def login(
request: Request,
db: AsyncSession = Depends(get_db),
):
- result = await db.execute(
- select(User).where(User.username == body.username))
- user = result.scalar_one_or_none()
-
ip = client_ip(request)
if not body.auth_key and not body.password:
raise HTTPException(status_code=401, detail="No credentials provided")
+ # 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)
+
+ result = await db.execute(
+ select(User).where(User.username == body.username))
+ user = result.scalar_one_or_none()
+
if not user:
- db.add(IPLog(event="login_fail", ip_address=ip, detail=body.username))
- await db.commit()
- raise HTTPException(status_code=401, detail="Invalid credentials")
+ await _login_failed(db, body.username, ip)
if user.pw_version >= 3:
# New scheme: verify auth_key
if not body.auth_key or not verify_password(
body.auth_key, user.pw_hash, user.pw_salt, version=user.pw_version
):
- db.add(IPLog(event="login_fail", ip_address=ip, detail=body.username))
- await db.commit()
- raise HTTPException(status_code=401, detail="Invalid credentials")
+ await _login_failed(db, body.username, ip, user.id)
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 db.commit()
raise HTTPException(status_code=401, detail="auth_upgrade_required")
if not verify_password(
body.password, user.pw_hash, user.pw_salt, version=user.pw_version
):
- db.add(IPLog(event="login_fail", ip_address=ip, detail=body.username))
- await db.commit()
- raise HTTPException(status_code=401, detail="Invalid credentials")
+ await _login_failed(db, body.username, ip, user.id)
# Migrate to new scheme if auth_key provided alongside password
if body.auth_key:
new_hash, new_salt = hash_password(body.auth_key)
@@ -348,6 +374,11 @@ async def login(
user.pw_salt = new_salt
user.pw_version = 2
+ # The passphrase was right, whatever the account's status turns out to be.
+ await login_throttle.clear(db, body.username)
+
+ if user.status != "active":
+ await db.commit()
if user.status == "pending":
raise HTTPException(status_code=403, detail="email_verification_required")
if user.status != "active":
@@ -624,6 +655,7 @@ async def token_refresh(
@router.get("/me")
async def get_current_user_info(
current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
):
email = ""
try:
@@ -636,6 +668,12 @@ async def get_current_user_info(
"email": email,
"role": current_user.role,
"status": current_user.status,
+ # Seconds left on a sign-in lockout, 0 when there is none. Told to the
+ # account's own session only, so it reveals nothing about anyone else.
+ # 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),
}
@@ -846,10 +884,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)
if not verify_password(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)
if body.new_auth_key == body.old_auth_key:
raise HTTPException(status_code=400,
detail="New passphrase must differ from the current one")
@@ -1049,6 +1089,9 @@ 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))
+ # 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)
db.add(IPLog(user_id=user.id, event="password_reset",
ip_address=client_ip(request)))
await db.commit()
@@ -1286,9 +1329,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)
if not verify_password(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)
return await erase_account(db, current_user)
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index 82ae110..afc1cde 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -142,6 +142,14 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
version=__version__,
description="MeshBay identity authority and group registry",
lifespan=lifespan,
+ # No interactive docs and no schema. The full description of the
+ # identity authority's API is a map for whoever probes it, and nothing
+ # in the tree reads it. meshbay.org hid these in its Caddyfile (S18),
+ # which protects exactly one deployment: a hub installed from the
+ # package, behind any other proxy, published all three.
+ docs_url=None,
+ redoc_url=None,
+ openapi_url=None,
)
# Rate limiting
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a9b8c7d6e5f4_add_login_throttle.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a9b8c7d6e5f4_add_login_throttle.py
new file mode 100644
index 0000000..2fead6c
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a9b8c7d6e5f4_add_login_throttle.py
@@ -0,0 +1,33 @@
+"""add login_throttle
+
+Wrong passphrases per username, for the per-account sign-in lockout. Keyed by a
+hash of the name as typed, so unknown names are counted like real ones.
+
+Revision ID: a9b8c7d6e5f4
+Revises: e5f6a7b8c9d0
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "a9b8c7d6e5f4"
+down_revision: Union[str, Sequence[str], None] = "e5f6a7b8c9d0"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "login_throttle",
+ sa.Column("key", sa.String(64), primary_key=True),
+ sa.Column("failures", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("last_failure_at", sa.DateTime(timezone=True), nullable=False),
+ )
+
+
+def downgrade() -> None:
+ # Dropping this forgets every count in progress, which unlocks everyone —
+ # the limits themselves live in `hub_settings`.
+ op.drop_table("login_throttle")
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index b052e00..f1fff41 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -330,6 +330,22 @@ class MailQuota(Base):
last_sent: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+class LoginThrottle(Base):
+ """Wrong passphrases per username, for the sign-in lockout (`login_throttle.py`).
+
+ Keyed by a hash of the name as typed rather than by account, so an unknown
+ name is counted — and locked — exactly like a real one (M1), and so a
+ passphrase typed into the username field is never stored. Rows age out with
+ the lockout window and are purged by the cleanup task.
+ """
+
+ __tablename__ = "login_throttle"
+
+ key: Mapped[str] = mapped_column(String(64), primary_key=True)
+ failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+ last_failure_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+
+
class HubSetting(Base):
"""
Instance-wide settings an admin changes at runtime from the panel.
diff --git a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
index 1c2d2c4..1dcff84 100644
--- a/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
+++ b/packages/meshbay-hub/src/meshbay_hub/hub_settings.py
@@ -85,6 +85,37 @@ async def mail_limits(db: AsyncSession) -> dict[str, int]:
return {k: await get_int(db, f"mail.{k}", mail_default(k)) for k in MAIL_KEYS}
+# ── Sign-in lockout ──────────────────────────────────────────────────────────
+#
+# After `max_failures` wrong passphrases for one username, sign-in with a
+# passphrase is refused for `lockout_minutes` (`login_throttle.py`). Zero
+# failures turns the lockout off; a zero-minute lockout would be the same thing
+# said less clearly, so the duration starts at one.
+
+LOGIN_KEYS = ("max_failures", "lockout_minutes")
+
+LOGIN_DEFAULTS: dict[str, int] = {
+ "max_failures": 4,
+ "lockout_minutes": 60,
+}
+
+LOGIN_BOUNDS: dict[str, tuple[int, int]] = {
+ "max_failures": (0, 100),
+ "lockout_minutes": (1, 10_080), # a week
+}
+
+
+def clamp_login_value(key: str, value: int) -> int:
+ low, high = LOGIN_BOUNDS[key]
+ return max(low, min(high, int(value)))
+
+
+async def login_limits(db: AsyncSession) -> dict[str, int]:
+ """Both lockout numbers, stored value or built-in default."""
+ return {k: clamp_login_value(k, await get_int(db, f"login.{k}", LOGIN_DEFAULTS[k]))
+ for k in LOGIN_KEYS}
+
+
async def get_raw(db: AsyncSession, key: str) -> str | None:
row = await db.get(HubSetting, key)
return row.value if row else None
diff --git a/packages/meshbay-hub/src/meshbay_hub/login_throttle.py b/packages/meshbay-hub/src/meshbay_hub/login_throttle.py
new file mode 100644
index 0000000..3281088
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/login_throttle.py
@@ -0,0 +1,143 @@
+"""Per-account sign-in lockout: after N wrong passphrases, refuse for a while.
+
+The per-IP rate limit on `login` bounds one address, and IPv6 hands every
+subscriber a /64 of them. What an online guess actually targets is an account,
+so that is what this counts.
+
+Three properties, each for a reason:
+
+- **Keyed by the username as typed, whether or not the account exists.** An
+ unknown name locks exactly like a real one, so a 429 says nothing a 401 did
+ not — `login` stays uniform (M1). The key is a hash: people type passphrases
+ into the username field, and this table must not keep them.
+- **The attempt is counted before the passphrase is checked, in one statement.**
+ Read-then-write would let a burst of concurrent requests all read "three
+ failures" and all be checked; an `INSERT … ON CONFLICT DO UPDATE … WHERE`
+ either takes one attempt or reports that none is left, atomically on SQLite
+ and PostgreSQL alike.
+- **A locked account is refused without verifying anything**, so a guess made
+ during the lockout learns nothing — not even whether it was right.
+
+What a lockout does not touch: sessions already open, token renewal, and device
+sign-in, none of which take a passphrase. That is what keeps a stranger who
+locks somebody else's name from signing them out (§13.5b, AV26).
+"""
+
+import hashlib
+from datetime import datetime, timedelta, timezone
+
+from sqlalchemy import case, delete, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from meshbay_hub import hub_settings
+from meshbay_hub.db.models import LoginThrottle
+
+
+def _key(username: str) -> str:
+ return hashlib.sha256(f"meshbay:login:{username}".encode()).hexdigest()
+
+
+def _aware(dt: datetime) -> datetime:
+ # SQLite hands back naive datetimes for a timezone-aware column.
+ return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
+
+
+def _insert_for(db: AsyncSession):
+ dialect = db.bind.dialect.name
+ if dialect == "postgresql":
+ from sqlalchemy.dialects.postgresql import insert
+ elif dialect == "sqlite":
+ from sqlalchemy.dialects.sqlite import insert
+ else:
+ raise RuntimeError(f"login throttle has no upsert for dialect {dialect!r}")
+ return insert
+
+
+async def reserve(db: AsyncSession, username: str) -> tuple[bool, int]:
+ """Take one attempt for `username`, and commit it before anything is checked.
+
+ Returns `(allowed, retry_after_seconds)`. When allowed, the second value is
+ 0; when not, it is how long the lockout has left.
+ """
+ limits = await hub_settings.login_limits(db)
+ max_failures = limits["max_failures"]
+ if max_failures == 0:
+ return True, 0
+
+ now = datetime.now(timezone.utc)
+ window = timedelta(minutes=limits["lockout_minutes"])
+ window_start = now - window
+ key = _key(username)
+
+ table = LoginThrottle.__table__
+ stale = table.c.last_failure_at < window_start
+ insert = _insert_for(db)
+ stmt = (
+ insert(table)
+ .values(key=key, failures=1, last_failure_at=now)
+ .on_conflict_do_update(
+ index_elements=[table.c.key],
+ # Failures older than the window have aged out: start again at one
+ # rather than carrying three typos from last week into today.
+ set_={"failures": case((stale, 1), else_=table.c.failures + 1),
+ "last_failure_at": now},
+ where=(table.c.failures < max_failures) | stale,
+ )
+ .returning(table.c.failures)
+ )
+ taken = (await db.execute(stmt)).first()
+ await db.commit()
+ if taken is not None:
+ return True, 0
+ return False, max(1, await locked_for(db, username))
+
+
+async def locked_for(db: AsyncSession, username: str) -> int:
+ """Seconds left on a lockout, 0 if none — without spending an attempt."""
+ limits = await hub_settings.login_limits(db)
+ if limits["max_failures"] == 0:
+ return 0
+ row = await db.get(LoginThrottle, _key(username))
+ if row is None or row.failures < limits["max_failures"]:
+ return 0
+ remaining = (_aware(row.last_failure_at)
+ + timedelta(minutes=limits["lockout_minutes"])
+ - datetime.now(timezone.utc)).total_seconds()
+ return max(0, int(remaining + 0.999))
+
+
+async def is_now_locked(db: AsyncSession, username: str) -> bool:
+ """After a failure: did that one spend the last attempt?"""
+ limits = await hub_settings.login_limits(db)
+ if limits["max_failures"] == 0:
+ return False
+ failures = await db.scalar(
+ select(LoginThrottle.failures).where(LoginThrottle.key == _key(username)))
+ return (failures or 0) >= limits["max_failures"]
+
+
+async def release(db: AsyncSession, username: str) -> None:
+ """Give back an attempt that checked no passphrase. The caller owns the commit.
+
+ Only ever undoes the caller's own reservation, so it cannot be used to earn
+ attempts: the net effect of reserve-then-release is nothing.
+ """
+ await db.execute(
+ update(LoginThrottle)
+ .where(LoginThrottle.key == _key(username), LoginThrottle.failures > 0)
+ .values(failures=LoginThrottle.failures - 1))
+
+
+async def clear(db: AsyncSession, username: str) -> None:
+ """The right passphrase, or a reset proved by e-mail. The caller owns the commit."""
+ await db.execute(delete(LoginThrottle).where(LoginThrottle.key == _key(username)))
+
+
+async def purge_expired(db: AsyncSession) -> int:
+ """Rows whose failures have aged out. Every unknown name typed creates one."""
+ limits = await hub_settings.login_limits(db)
+ cutoff = datetime.now(timezone.utc) - timedelta(minutes=limits["lockout_minutes"])
+ result = await db.execute(
+ delete(LoginThrottle).where(LoginThrottle.last_failure_at < cutoff))
+ await db.commit()
+ return result.rowcount
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
index 8800615..c40d240 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
@@ -16,6 +16,7 @@ export function AdminPage({ token, role }) {
// Edited values live here until Save, so a half-typed number is never sent
// and a rejected one never looks applied.
const [mailDraft, setMailDraft] = useState(null);
+ const [loginDraft, setLoginDraft] = useState(null);
const [users, setUsers] = useState([]);
const [usersTotal, setUsersTotal] = useState(0);
const [userSearch, setUserSearch] = useState('');
@@ -43,6 +44,7 @@ export function AdminPage({ token, role }) {
const data = await hubFetch('/v1/admin/settings', { token });
setSettings(data);
setMailDraft({ ...data.mail });
+ setLoginDraft({ ...data.login });
} catch (e) { setError(e.message); }
try {
setMailStatus(await hubFetch('/v1/admin/mail', { token }));
@@ -61,6 +63,7 @@ export function AdminPage({ token, role }) {
// The hub clamps what it was given, so the draft is reset from the
// answer rather than left showing a number that was not stored.
setMailDraft({ ...data.mail });
+ setLoginDraft({ ...data.login });
if (patch.mail) {
try {
setMailStatus(await hubFetch('/v1/admin/mail', { token }));
@@ -193,6 +196,14 @@ const MAIL_FIELDS = [
'email_change_cooldown',
];
+const LOGIN_FIELDS = ['max_failures', 'lockout_minutes'];
+
+// Only what changed, and only what is a number: an empty field is someone
+// mid-edit, not a request to set zero.
+const changedNumbers = (fields, draft, stored) => Object.fromEntries(fields
+ .filter(k => draft[k] !== '' && draft[k] !== null && Number(draft[k]) !== stored[k])
+ .map(k => [k, Number(draft[k])]));
+
const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist'];
const canEditSettings = role === 'admin';
@@ -247,12 +258,7 @@ const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist
<div class="settings-row">
<button class="btn" disabled=${settingsSaving}
onClick=${() => saveSettings({
- mail: Object.fromEntries(MAIL_FIELDS
- // Only what changed, and only what is a number: an empty
- // field is someone mid-edit, not a request to set zero.
- .filter(k => mailDraft[k] !== '' && mailDraft[k] !== null
- && Number(mailDraft[k]) !== settings.mail[k])
- .map(k => [k, Number(mailDraft[k])])),
+ mail: changedNumbers(MAIL_FIELDS, mailDraft, settings.mail),
})}>${t('admin.mail_save')}</button>
<button class="btn btn-secondary" disabled=${settingsSaving}
onClick=${() => setMailDraft({ ...settings.mail_defaults })}
@@ -260,6 +266,37 @@ const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist
</div>
`}
</div>
+
+ ${settings.login && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('admin.login_heading')}</h3>
+ <p class="settings-hint">${t('admin.login_hint')}</p>
+
+ ${loginDraft && LOGIN_FIELDS.map(key => html`
+ <div class="settings-row" key=${key}>
+ <span class="settings-label">${t('admin.login_' + key)}</span>
+ <input type="number" class="settings-number"
+ min=${(settings.login_bounds?.[key] || [0])[0]}
+ max=${(settings.login_bounds?.[key] || [0, 0])[1]}
+ value=${loginDraft[key]}
+ disabled=${!canEditSettings || settingsSaving}
+ onInput=${e => setLoginDraft(d => ({ ...d, [key]: e.target.value }))} />
+ </div>
+ `)}
+
+ ${canEditSettings && loginDraft && html`
+ <div class="settings-row">
+ <button class="btn" disabled=${settingsSaving}
+ onClick=${() => saveSettings({
+ login: changedNumbers(LOGIN_FIELDS, loginDraft, settings.login),
+ })}>${t('admin.login_save')}</button>
+ <button class="btn btn-secondary" disabled=${settingsSaving}
+ onClick=${() => setLoginDraft({ ...settings.login_defaults })}
+ >${t('admin.mail_reset_defaults')}</button>
+ </div>
+ `}
+ </div>
+ `}
`}
${tab === 'stats' && stats && html`
@@ -436,7 +473,7 @@ const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist
loadLogs(e.target.value, 0);
}}>
<option value="">${t('admin.filter_all')}</option>
- ${['login', 'login_fail', 'account_create', 'token_refresh', 'group_create',
+ ${['login', 'login_fail', 'login_locked', 'account_create', 'token_refresh', 'group_create',
'group_join', 'group_leave', 'node_announce', 'revoke_user', 'revoke_group',
'admin_user_update', 'admin_group_update'].map(ev => html`
<option key=${ev} value=${ev}>${ev}</option>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
index 9ded87b..d4dc5a4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
@@ -157,6 +157,12 @@ export function LoginPage({ onLogin }) {
} catch (err) {
if (err.message === 'email_verification_required') {
setPendingVerif(true);
+ } else if (err.message === 'account_locked') {
+ setError(t('login.locked', {
+ minutes: Math.max(1, Math.ceil((err.retryAfter || 60) / 60)),
+ }));
+ } else if (err.message === 'Invalid credentials') {
+ setError(t('login.invalid'));
} else {
setError(err.message);
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index a540a94..918ade3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -368,7 +368,23 @@ async function loginAndRecover(username, password) {
body: JSON.stringify({ username, auth_key: authKey }),
});
- if (!resp.ok) throw new Error(`Login failed: ${await resp.text()}`);
+ if (!resp.ok) {
+ // The hub's `detail`, not the raw body: the sign-in page matches on it
+ // (`email_verification_required`, `account_locked`), and a message wrapped
+ // as "Login failed: {json}" matched nothing, so neither was ever shown.
+ const body = await resp.text();
+ let detail = body;
+ // `error` is the per-IP rate limiter's field (slowapi), `detail` everyone else's.
+ try {
+ const j = JSON.parse(body);
+ detail = j.detail || j.error || body;
+ } catch { /* not JSON */ }
+ const err = new Error(String(detail));
+ err.status = resp.status;
+ err.retryAfter = Number(resp.headers && resp.headers.get
+ ? resp.headers.get('Retry-After') : 0) || 0;
+ throw err;
+ }
const data = await resp.json();
const result = {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index f460255..9d6fc61 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -87,6 +87,8 @@ export default {
'register.resend_sent': 'Ein neuer Code wurde gesendet.',
'login.pending_verification': 'Ihre E-Mail-Adresse ist noch nicht bestätigt. Bitte prüfen Sie Ihr Postfach auf den Bestätigungscode.',
'login.verify_link': 'E-Mail bestätigen',
+ 'login.invalid': "Benutzername oder Passphrase falsch.",
+ 'login.locked': "Zu viele falsche Passphrasen für dieses Konto. Versuchen Sie es in {minutes} Min. erneut oder setzen Sie Ihre Passphrase zurück.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'Das Internet, wie es gedacht war.',
'welcome.lead': 'MeshBay ist Open-Source-Software, mit der Sie aus der Ferne auf Ihre persönlichen Dateien zugreifen und Anwendungen direkt auf dem Speicher Ihres eigenen Computers betreiben:',
@@ -499,6 +501,11 @@ export default {
'admin.mail_email_change_cooldown': "Sekunden, bevor ein Konto eine andere Adresse vorschlagen darf",
'admin.mail_save': "Mail-Grenzen speichern",
'admin.mail_reset_defaults': "Standardwerte wiederherstellen",
+ 'admin.login_heading': "Anmeldung",
+ 'admin.login_hint': "Nach so vielen falschen Passphrasen für einen Benutzernamen wird die Anmeldung mit Passphrase für die angegebene Dauer verweigert. Bereits offene Sitzungen und registrierte Geräte funktionieren weiter, und ein Zurücksetzen der Passphrase hebt die Sperre auf. 0 schaltet sie ab.",
+ 'admin.login_max_failures': "Falsche Passphrasen bis zur Sperre",
+ 'admin.login_lockout_minutes': "Dauer der Sperre (Minuten)",
+ 'admin.login_save': "Anmeldegrenzen speichern",
'admin.mail_state_is_in_stats': "Der Verbrauch der aktuellen Stunde steht unter Statistik.",
'admin.mail_state_hint': "Rücksetzungen und Einladungen dürfen das ganze Budget nutzen; Registrierungen und Adressänderungen nicht den dafür reservierten Anteil. Administratoren werden einmal pro Stunde benachrichtigt, wenn eine der beiden Grenzen erreicht ist.",
'admin.mail_left_signups': "Rest für Registrierungen",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index e7ffa64..a5dd5d4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -90,6 +90,8 @@ export default {
'register.resend_sent': 'A new code has been sent.',
'login.pending_verification': 'Your email is not yet verified. Please check your inbox for the verification code.',
'login.verify_link': 'Verify email',
+ 'login.invalid': "Wrong username or passphrase.",
+ 'login.locked': "Too many wrong passphrases for this account. Try again in {minutes} min, or reset your passphrase.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'The Internet as it was meant to be.',
'welcome.lead': 'MeshBay is open-source software that gives you remote access to your personal files, and runs applications on top of the storage on your own computer:',
@@ -489,6 +491,11 @@ export default {
'admin.mail_email_change_cooldown': "Seconds before an account may propose another address",
'admin.mail_save': "Save mail limits",
'admin.mail_reset_defaults': "Restore defaults",
+ 'admin.login_heading': "Sign-in",
+ 'admin.login_hint': "After this many wrong passphrases for one username, signing in with a passphrase is refused for the set duration. Sessions already open and registered devices keep working, and a passphrase reset ends the lockout. 0 turns it off.",
+ 'admin.login_max_failures': "Wrong passphrases before a lockout",
+ 'admin.login_lockout_minutes': "Lockout duration (minutes)",
+ 'admin.login_save': "Save sign-in limits",
'admin.mail_state_is_in_stats': "The current hour's usage is shown under Statistics.",
'admin.mail_state_hint': "Resets and invitations may spend the whole budget; sign-ups and address changes may not spend the share reserved for them. Administrators are notified once per hour when either ceiling is reached.",
'admin.mail_left_signups': "Left for sign-ups",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 2641708..6e8e191 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -86,6 +86,8 @@ export default {
'register.resend_sent': 'Se ha enviado un nuevo código.',
'login.pending_verification': 'Su correo electrónico aún no ha sido verificado. Revise su bandeja de entrada para obtener el código de verificación.',
'login.verify_link': 'Verificar correo',
+ 'login.invalid': "Nombre de usuario o frase de contraseña incorrectos.",
+ 'login.locked': "Demasiadas frases de contraseña incorrectas para esta cuenta. Vuelva a intentarlo en {minutes} min o restablezca su frase de contraseña.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'Internet como debió ser.',
'welcome.lead': 'MeshBay es un software de código abierto que le da acceso remoto a sus archivos personales y ejecuta aplicaciones sobre el almacenamiento de su propio ordenador:',
@@ -495,6 +497,11 @@ export default {
'admin.mail_email_change_cooldown': "Segundos antes de que una cuenta pueda proponer otra dirección",
'admin.mail_save': "Guardar límites de correo",
'admin.mail_reset_defaults': "Restaurar valores por defecto",
+ 'admin.login_heading': "Inicio de sesión",
+ 'admin.login_hint': "Tras este número de frases de contraseña incorrectas para un mismo nombre de usuario, se rechaza el inicio de sesión con frase de contraseña durante el tiempo indicado. Las sesiones ya abiertas y los dispositivos registrados siguen funcionando, y restablecer la frase de contraseña levanta el bloqueo. 0 lo desactiva.",
+ 'admin.login_max_failures': "Frases incorrectas antes del bloqueo",
+ 'admin.login_lockout_minutes': "Duración del bloqueo (minutos)",
+ 'admin.login_save': "Guardar límites de inicio de sesión",
'admin.mail_state_is_in_stats': "El uso de la hora actual se muestra en Estadísticas.",
'admin.mail_state_hint': "Los restablecimientos y las invitaciones pueden gastar todo el presupuesto; los registros y cambios de dirección no pueden tocar la parte reservada. Se avisa a los administradores una vez por hora cuando se alcanza cualquiera de los dos límites.",
'admin.mail_left_signups': "Restante para registros",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 0c639b7..47de45f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -86,6 +86,8 @@ export default {
'register.resend_sent': 'Un nouveau code a été envoyé.',
'login.pending_verification': 'Votre e-mail n\'est pas encore vérifié. Consultez votre boîte de réception pour le code de vérification.',
'login.verify_link': 'Vérifier l\'e-mail',
+ 'login.invalid': "Nom d'utilisateur ou phrase secrète incorrect.",
+ 'login.locked': "Trop de phrases secrètes erronées pour ce compte. Réessayez dans {minutes} min, ou réinitialisez votre phrase secrète.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'L’Internet tel qu’il aurait dû être.',
'welcome.lead': 'MeshBay est un logiciel open source qui vous donne accès à distance à vos fichiers personnels et fait tourner des applications sur le stockage de votre ordinateur :',
@@ -498,6 +500,11 @@ export default {
'admin.mail_email_change_cooldown': "Secondes avant qu'un compte puisse proposer une autre adresse",
'admin.mail_save': "Enregistrer les limites",
'admin.mail_reset_defaults': "Rétablir les valeurs par défaut",
+ 'admin.login_heading': "Connexion",
+ 'admin.login_hint': "Après ce nombre de phrases secrètes erronées pour un même nom d'utilisateur, la connexion par phrase secrète est refusée pendant la durée indiquée. Les sessions déjà ouvertes et les appareils enregistrés continuent de fonctionner, et une réinitialisation de la phrase secrète lève le blocage. 0 le désactive.",
+ 'admin.login_max_failures': "Phrases secrètes erronées avant blocage",
+ 'admin.login_lockout_minutes': "Durée du blocage (minutes)",
+ 'admin.login_save': "Enregistrer les limites de connexion",
'admin.mail_state_is_in_stats': "La consommation de l'heure en cours est affichée dans Statistiques.",
'admin.mail_state_hint': "Les réinitialisations et invitations peuvent dépenser tout le budget ; les inscriptions et changements d'adresse ne peuvent pas entamer la part qui leur est réservée. Les administrateurs sont prévenus une fois par heure lorsqu'un des deux plafonds est atteint.",
'admin.mail_left_signups': "Restant pour les inscriptions",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index efda136..660298e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -87,6 +87,8 @@ export default {
'register.resend_sent': 'Un nuovo codice è stato inviato.',
'login.pending_verification': 'Il suo indirizzo e-mail non è ancora verificato. Controlli la sua casella di posta per il codice di verifica.',
'login.verify_link': 'Verifica e-mail',
+ 'login.invalid': "Nome utente o passphrase errati.",
+ 'login.locked': "Troppe passphrase errate per questo account. Riprovi tra {minutes} min oppure reimposti la passphrase.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'Internet come doveva essere.',
'welcome.lead': 'MeshBay è un software open source che le permette di accedere da remoto ai suoi file personali e di usare applicazioni basate sullo spazio di archiviazione del suo computer:',
@@ -498,6 +500,11 @@ export default {
'admin.mail_email_change_cooldown': "Secondi prima che un account possa proporre un altro indirizzo",
'admin.mail_save': "Salva i limiti di posta",
'admin.mail_reset_defaults': "Ripristina i valori predefiniti",
+ 'admin.login_heading': "Accesso",
+ 'admin.login_hint': "Dopo questo numero di passphrase errate per uno stesso nome utente, l'accesso con passphrase viene rifiutato per la durata indicata. Le sessioni già aperte e i dispositivi registrati continuano a funzionare, e la reimpostazione della passphrase rimuove il blocco. 0 lo disattiva.",
+ 'admin.login_max_failures': "Passphrase errate prima del blocco",
+ 'admin.login_lockout_minutes': "Durata del blocco (minuti)",
+ 'admin.login_save': "Salva i limiti di accesso",
'admin.mail_state_is_in_stats': "Il consumo dell'ora corrente è mostrato in Statistiche.",
'admin.mail_state_hint': "Reimpostazioni e inviti possono spendere l'intero budget; registrazioni e cambi di indirizzo non possono intaccare la quota riservata. Gli amministratori vengono avvisati una volta all'ora quando uno dei due limiti viene raggiunto.",
'admin.mail_left_signups': "Rimanente per le registrazioni",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 22f5d6b..ff5b693 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -87,6 +87,8 @@ export default {
'register.resend_sent': '新しいコードを送信しました。',
'login.pending_verification': 'メールアドレスがまだ確認されていません。受信トレイで確認コードをご確認ください。',
'login.verify_link': 'メールを確認',
+ 'login.invalid': "ユーザー名またはパスフレーズが正しくありません。",
+ 'login.locked': "このアカウントでパスフレーズの誤りが多すぎます。{minutes} 分後にもう一度お試しいただくか、パスフレーズをリセットしてください。",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': '本来あるべき姿のインターネット。',
'welcome.lead': 'MeshBay は、個人のファイルにどこからでもアクセスでき、自分のパソコンのストレージ上でアプリを動かせるオープンソースソフトウェアです。',
@@ -491,6 +493,11 @@ export default {
'admin.mail_email_change_cooldown': "別のアドレスを申請できるようになるまでの秒数",
'admin.mail_save': "メール制限を保存",
'admin.mail_reset_defaults': "既定値に戻す",
+ 'admin.login_heading': "サインイン",
+ 'admin.login_hint': "1つのユーザー名に対してこの回数パスフレーズを誤ると、設定した時間のあいだパスフレーズによるサインインが拒否されます。すでに開いているセッションと登録済みの端末は引き続き使え、パスフレーズをリセットするとロックは解除されます。0 で無効になります。",
+ 'admin.login_max_failures': "ロックまでの誤りの回数",
+ 'admin.login_lockout_minutes': "ロック時間(分)",
+ 'admin.login_save': "サインインの制限を保存",
'admin.mail_state_is_in_stats': "現在の 1 時間の使用状況は「統計」に表示されます。",
'admin.mail_state_hint': "再設定と招待は上限全体を使えます。登録とアドレス変更は、確保された分には手を付けられません。いずれかの上限に達すると、管理者に 1 時間に 1 回通知されます。",
'admin.mail_left_signups': "登録に残っている数",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 88aebfa..3c07b3e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -87,6 +87,8 @@ export default {
'register.resend_sent': 'Er is een nieuwe code verzonden.',
'login.pending_verification': 'Uw e-mailadres is nog niet geverifieerd. Controleer uw inbox voor de verificatiecode.',
'login.verify_link': 'E-mail verifiëren',
+ 'login.invalid': "Onjuiste gebruikersnaam of wachtwoordzin.",
+ 'login.locked': "Te veel onjuiste wachtwoordzinnen voor dit account. Probeer het over {minutes} min opnieuw of stel uw wachtwoordzin opnieuw in.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'Het internet zoals het bedoeld was.',
'welcome.lead': 'MeshBay is opensourcesoftware waarmee u op afstand bij uw persoonlijke bestanden kunt, en die toepassingen draait bovenop de opslag van uw eigen computer:',
@@ -499,6 +501,11 @@ export default {
'admin.mail_email_change_cooldown': "Seconden voordat een account een ander adres mag voorstellen",
'admin.mail_save': "E-maillimieten opslaan",
'admin.mail_reset_defaults': "Standaardwaarden herstellen",
+ 'admin.login_heading': "Aanmelden",
+ 'admin.login_hint': "Na dit aantal onjuiste wachtwoordzinnen voor één gebruikersnaam wordt aanmelden met een wachtwoordzin geweigerd gedurende de ingestelde tijd. Al geopende sessies en geregistreerde apparaten blijven werken, en het opnieuw instellen van de wachtwoordzin heft de blokkade op. 0 schakelt dit uit.",
+ 'admin.login_max_failures': "Onjuiste wachtwoordzinnen vóór blokkade",
+ 'admin.login_lockout_minutes': "Duur van de blokkade (minuten)",
+ 'admin.login_save': "Aanmeldlimieten opslaan",
'admin.mail_state_is_in_stats': "Het verbruik van dit uur staat onder Statistieken.",
'admin.mail_state_hint': "Herstel en uitnodigingen mogen het hele budget gebruiken; registraties en adreswijzigingen niet het gereserveerde deel. Beheerders krijgen één keer per uur bericht wanneer een van beide grenzen is bereikt.",
'admin.mail_left_signups': "Resterend voor registraties",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index bed39d2..2ff9b84 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -90,6 +90,8 @@ export default {
'register.resend_sent': 'Nowy kod został wysłany.',
'login.pending_verification': 'Twój adres e-mail nie został jeszcze zweryfikowany. Sprawdź skrzynkę odbiorczą.',
'login.verify_link': 'Zweryfikuj e-mail',
+ 'login.invalid': "Nieprawidłowa nazwa użytkownika lub hasło-fraza.",
+ 'login.locked': "Zbyt wiele błędnych haseł-fraz dla tego konta. Spróbuj ponownie za {minutes} min lub zresetuj hasło-frazę.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'Internet taki, jaki miał być.',
'welcome.lead': 'MeshBay to oprogramowanie open source, które daje zdalny dostęp do Twoich plików osobistych i uruchamia aplikacje korzystające z pamięci Twojego własnego komputera:',
@@ -511,6 +513,11 @@ export default {
'admin.mail_email_change_cooldown': "Sekundy, zanim konto może zaproponować inny adres",
'admin.mail_save': "Zapisz limity poczty",
'admin.mail_reset_defaults': "Przywróć domyślne",
+ 'admin.login_heading': "Logowanie",
+ 'admin.login_hint': "Po tylu błędnych hasłach-frazach dla jednej nazwy użytkownika logowanie hasłem-frazą jest odrzucane przez ustawiony czas. Otwarte już sesje i zarejestrowane urządzenia działają dalej, a zresetowanie hasła-frazy znosi blokadę. 0 ją wyłącza.",
+ 'admin.login_max_failures': "Błędne hasła-frazy przed blokadą",
+ 'admin.login_lockout_minutes': "Czas blokady (minuty)",
+ 'admin.login_save': "Zapisz limity logowania",
'admin.mail_state_is_in_stats': "Zużycie w bieżącej godzinie pokazano w Statystykach.",
'admin.mail_state_hint': "Resety i zaproszenia mogą wykorzystać cały budżet; rejestracje i zmiany adresu nie mogą naruszyć zarezerwowanej części. Administratorzy są powiadamiani raz na godzinę, gdy któryś z limitów zostanie osiągnięty.",
'admin.mail_left_signups': "Pozostało na rejestracje",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index cd5da7d..33fa2be 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -88,6 +88,8 @@ export default {
'register.resend_sent': 'Um novo código foi enviado.',
'login.pending_verification': 'Seu e-mail ainda não foi verificado. Verifique sua caixa de entrada.',
'login.verify_link': 'Verificar e-mail',
+ 'login.invalid': "Nome de usuário ou frase secreta incorretos.",
+ 'login.locked': "Muitas frases secretas incorretas para esta conta. Tente novamente em {minutes} min ou redefina sua frase secreta.",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': 'A internet como deveria ser.',
'welcome.lead': 'O MeshBay é um software de código aberto que dá acesso remoto aos seus arquivos pessoais e executa aplicativos sobre o armazenamento do seu próprio computador:',
@@ -497,6 +499,11 @@ export default {
'admin.mail_email_change_cooldown': "Segundos até uma conta poder propor outro endereço",
'admin.mail_save': "Salvar limites de correio",
'admin.mail_reset_defaults': "Restaurar padrões",
+ 'admin.login_heading': "Login",
+ 'admin.login_hint': "Após este número de frases secretas incorretas para um mesmo nome de usuário, o login com frase secreta é recusado pelo tempo definido. Sessões já abertas e dispositivos registrados continuam funcionando, e redefinir a frase secreta encerra o bloqueio. 0 desativa.",
+ 'admin.login_max_failures': "Frases incorretas antes do bloqueio",
+ 'admin.login_lockout_minutes': "Duração do bloqueio (minutos)",
+ 'admin.login_save': "Salvar limites de login",
'admin.mail_state_is_in_stats': "O consumo da hora atual aparece em Estatísticas.",
'admin.mail_state_hint': "Redefinições e convites podem gastar todo o orçamento; cadastros e trocas de endereço não podem usar a parte reservada. Os administradores são avisados uma vez por hora quando qualquer um dos limites é atingido.",
'admin.mail_left_signups': "Restante para cadastros",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 06aa011..b58ba64 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -87,6 +87,8 @@ export default {
'register.resend_sent': '新验证码已发送。',
'login.pending_verification': '您的邮箱尚未验证。请查看收件箱中的验证码。',
'login.verify_link': '验证邮箱',
+ 'login.invalid': "用户名或密码短语错误。",
+ 'login.locked': "此账户输错密码短语的次数过多。请在 {minutes} 分钟后重试,或重置密码短语。",
// Sign-in page: what MeshBay is (browser only)
'welcome.title': '互联网本该有的样子。',
'welcome.lead': 'MeshBay 是一款开源软件,让您远程访问个人文件,并在您自己电脑的存储之上运行应用:',
@@ -483,6 +485,11 @@ export default {
'admin.mail_email_change_cooldown': "账号可再次申请其他地址前的秒数",
'admin.mail_save': "保存邮件限制",
'admin.mail_reset_defaults': "恢复默认值",
+ 'admin.login_heading': "登录",
+ 'admin.login_hint': "同一用户名输错密码短语达到此次数后,将在设定时长内拒绝使用密码短语登录。已打开的会话和已注册的设备不受影响,重置密码短语即可解除锁定。设为 0 则关闭。",
+ 'admin.login_max_failures': "锁定前允许的错误次数",
+ 'admin.login_lockout_minutes': "锁定时长(分钟)",
+ 'admin.login_save': "保存登录限制",
'admin.mail_state_is_in_stats': "本小时的用量显示在「统计」中。",
'admin.mail_state_hint': "重置与邀请可动用全部额度;注册与更换地址不得占用为前者保留的份额。任一上限达到时,每小时通知管理员一次。",
'admin.mail_left_signups': "注册剩余",
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
index 10bcee7..7e355e7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
@@ -8,6 +8,21 @@ import {
_storeBundleKey, _loadBundleKey, _storeRecoveryKey,
} from './hub-client.js';
+// A sign-in lockout also refuses the two actions here that re-check the
+// passphrase (the hub counts them on the same row).
+function lockedText(seconds) {
+ return t('login.locked', { minutes: Math.max(1, Math.ceil(seconds / 60)) });
+}
+
+async function lockedMessage(token) {
+ try {
+ const me = await hubFetch('/v1/users/me', { token });
+ return lockedText(me.passphrase_locked_for || 60);
+ } catch {
+ return lockedText(60);
+ }
+}
+
export function ProfilePage({ user, onLogout }) {
const [nodeKey, setNodeKey] = useState('');
const [currentNodeKey, setCurrentNodeKey] = useState(null);
@@ -39,7 +54,8 @@ export function ProfilePage({ user, onLogout }) {
});
onLogout();
} catch (err) {
- setDelError(err.message);
+ setDelError(err.message === 'account_locked'
+ ? await lockedMessage(user.token) : err.message);
} finally {
setDeleting(false);
}
@@ -76,6 +92,14 @@ export function ProfilePage({ user, onLogout }) {
if (cpNew !== cpNew2) { setCpError(t('settings.pw_mismatch')); return; }
if (cpNew === cpOld) { setCpError(t('settings.pw_same')); return; }
try {
+ // The next step re-wraps every node's bundle before the hub is asked to
+ // accept the new passphrase. Started during a lockout, the nodes would
+ // take the new one and the hub would refuse it — so ask first.
+ const me = await hubFetch('/v1/users/me', { token: user.token });
+ if (me.passphrase_locked_for > 0) {
+ setCpError(lockedText(me.passphrase_locked_for));
+ return;
+ }
const mine = await hubFetch('/v1/groups/mine', { token: user.token });
const groups = mine.groups || [];
setCpEstimate({
@@ -118,8 +142,10 @@ export function ProfilePage({ user, onLogout }) {
setCpResult(result);
setCpPhase('done');
} catch (err) {
- const msg = /403|does not match/i.test(err.message)
- ? t('settings.pw_wrong_current') : err.message;
+ const msg = err.message === 'account_locked'
+ ? await lockedMessage(user.token)
+ : /403|does not match/i.test(err.message)
+ ? t('settings.pw_wrong_current') : err.message;
setCpError(msg);
setCpPhase('confirm');
}
@@ -361,6 +387,7 @@ export function ProfilePage({ user, onLogout }) {
<input type="password" autocomplete="new-password"
placeholder=${t('settings.passphrase_new_repeat')}
value=${cpNew2} onInput=${e => setCpNew2(e.target.value)} required />
+ ${cpError && html`<p class="error-msg">${cpError}</p>`}
<div style="display:flex;gap:8px">
<button class="admin-btn" type="submit">${t('settings.continue')}</button>
<button class="btn-secondary" type="button" onClick=${cpReset}>
diff --git a/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py b/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
index dfe7c78..5c52387 100644
--- a/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
+++ b/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
@@ -64,6 +64,12 @@ async def cleanup_loop(get_session):
quota = await mail.purge_expired_quota(db)
if quota:
log.info("Purged %d expired mail counters", quota)
+ # Every name anybody types at the sign-in form is a row,
+ # real or not; once its window has passed, nothing reads it.
+ from meshbay_hub import login_throttle
+ throttled = await login_throttle.purge_expired(db)
+ if throttled:
+ log.info("Purged %d expired sign-in counters", throttled)
except asyncio.CancelledError:
raise
except Exception as e:
diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py
index bfbfcc0..d1a4dcb 100644
--- a/packages/meshbay-hub/tests/test_availability_between_members.py
+++ b/packages/meshbay-hub/tests/test_availability_between_members.py
@@ -338,6 +338,9 @@ async def test_a_relay_must_prove_it_holds_the_approved_key(client, monkeypatch)
"""
from meshbay_hub.api import relay as relay_mod
+ # The registry ships closed (`relay.RELAYS_ENABLED`); the proof it demands
+ # is still what will be wanted the day it opens.
+ monkeypatch.setattr(relay_mod, "RELAYS_ENABLED", True)
sk = Ed25519PrivateKey.generate()
pk = pk_to_b64(sk.public_key())
relay_mod._relays["r1"] = {"pk": pk, "active": False}
@@ -370,10 +373,84 @@ async def test_a_relay_must_prove_it_holds_the_approved_key(client, monkeypatch)
@pytest.mark.asyncio
-async def test_a_captured_relay_registration_is_not_replayable(client):
+async def test_every_relay_route_is_closed_as_the_hub_ships(client):
+ """Nothing in the tree uses the registry, and two of its routes take no account.
+
+ A dependency on the router, so a route added later is closed too. The flag
+ is read as shipped, not set here — a test that closes the gate itself
+ would keep passing the day somebody opens it.
+ """
+ admin = await _make_user(client, "relayadmin")
+ from meshbay_hub.api.deps import set_admin_usernames
+ set_admin_usernames(["relayadmin"])
+ auth = {"Authorization": f"Bearer {admin['token']}"}
+
+ for method, path in (("get", "/v1/relays"),
+ ("post", "/v1/relays/register"),
+ ("post", "/v1/relays/approve")):
+ kwargs = {"headers": auth} if method == "get" else {"json": {}, "headers": auth}
+ r = await getattr(client, method)(path, **kwargs)
+ assert r.status_code == 503, (path, r.status_code, r.text)
+
+
+@pytest.mark.asyncio
+async def test_a_stranger_who_locks_your_name_does_not_sign_you_out(client, db_session):
+ """AV26. The sign-in lockout is keyed by username, and usernames are public.
+
+ So anyone can spend your attempts, and the design has to make that cost as
+ little as possible: a lockout refuses *passphrase* sign-in and nothing else.
+ The session you already have keeps working and keeps renewing, and a reset
+ code sent to your own address ends the lockout at once.
+ """
+ victim_key = base64.b64encode(b"k" * 32).decode()
+ r = await client.post("/v1/users/register", json={
+ "username": "victim26", "email": "victim26@example.test", "auth_key": victim_key})
+ assert r.status_code == 201, r.text
+ session = (await client.post("/v1/users/login", json={
+ "username": "victim26", "auth_key": victim_key})).json()
+
+ # The stranger needs no account at all — only the name.
+ for _ in range(4):
+ r = await client.post("/v1/users/login", json={
+ "username": "victim26", "auth_key": "guess" + "x" * 39})
+ assert r.status_code == 401
+ r = await client.post("/v1/users/login", json={
+ "username": "victim26", "auth_key": victim_key})
+ assert r.status_code == 429, r.text
+
+ # Still signed in, and still able to stay signed in.
+ auth = {"Authorization": f"Bearer {session['access_token']}"}
+ assert (await client.get("/v1/users/me", headers=auth)).status_code == 200
+ r = await client.post("/v1/users/token/refresh",
+ json={"refresh_token": session["refresh_token"]})
+ assert r.status_code == 200, r.text
+
+ # The way out that needs nobody's help: a code to the address on file.
+ from meshbay_hub.db.models import EmailVerification, User
+ from sqlalchemy import select
+ r = await client.post("/v1/users/password/reset-request", json={
+ "username": "victim26", "email": "victim26@example.test"})
+ assert r.status_code == 200, r.text
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "victim26"))).scalar_one()
+ code = (await db_session.execute(select(EmailVerification.code).where(
+ EmailVerification.user_id == uid,
+ EmailVerification.purpose == "password_reset"))).scalar_one()
+ new_key = base64.b64encode(b"n" * 32).decode()
+ r = await client.post("/v1/users/password/reset", json={
+ "username": "victim26", "code": code, "new_auth_key": new_key})
+ assert r.status_code == 200, r.text
+ r = await client.post("/v1/users/login", json={
+ "username": "victim26", "auth_key": new_key})
+ assert r.status_code == 200, r.text
+
+
+@pytest.mark.asyncio
+async def test_a_captured_relay_registration_is_not_replayable(client, monkeypatch):
"""Same reason /v1/nodes/announce bounds its timestamp."""
from meshbay_hub.api import relay as relay_mod
+ monkeypatch.setattr(relay_mod, "RELAYS_ENABLED", True)
sk = Ed25519PrivateKey.generate()
pk = pk_to_b64(sk.public_key())
relay_mod._relays["r2"] = {"pk": pk, "active": False}
diff --git a/packages/meshbay-hub/tests/test_login_lockout.py b/packages/meshbay-hub/tests/test_login_lockout.py
new file mode 100644
index 0000000..43300d0
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_login_lockout.py
@@ -0,0 +1,256 @@
+"""
+Per-account sign-in lockout (`login_throttle.py`).
+
+The per-IP limit bounds one address, and IPv6 gives every subscriber a /64 of
+them; an online guess targets an account, so the account is what is counted.
+The properties pinned here are the ones that make that safe to ship:
+
+ * the Nth wrong passphrase locks, and a locked account is refused **before**
+ the passphrase is checked — the right one is refused too
+ * an unknown username locks exactly like a real one, so the 429 says nothing
+ the 401 did not (M1)
+ * a right passphrase clears the count, and failures age out of the window
+ * `change_password` checks the same passphrase, so it counts on the same row
+ * the two numbers are the admin's to change, and zero turns it off
+
+What one account costs another through this — a stranger locking your name —
+is `test_availability_between_members.py`, because it takes two accounts.
+"""
+
+import asyncio
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from sqlalchemy import select, update
+
+from meshbay_hub.api.deps import set_admin_usernames
+from meshbay_hub.db.models import LoginThrottle
+from meshbay_hub.login_throttle import _key
+
+RIGHT = "r" * 44
+WRONG = "w" * 44
+
+
+async def _register(client, username, auth_key=RIGHT):
+ r = await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@example.com",
+ "auth_key": auth_key})
+ assert r.status_code == 201, r.text
+
+
+async def _login(client, username, auth_key):
+ return await client.post("/v1/users/login", json={
+ "username": username, "auth_key": auth_key})
+
+
+async def _fail(client, username, times):
+ for _ in range(times):
+ r = await _login(client, username, WRONG)
+ assert r.status_code == 401, r.text
+
+
+@pytest.mark.asyncio
+async def test_the_fourth_failure_locks_and_the_right_passphrase_is_refused(client):
+ await _register(client, "alice")
+ await _fail(client, "alice", 4)
+
+ r = await _login(client, "alice", RIGHT)
+ assert r.status_code == 429, r.text
+ assert r.json()["detail"] == "account_locked"
+ # An hour, give or take the time the four failures took.
+ assert 3500 <= int(r.headers["retry-after"]) <= 3600
+
+
+@pytest.mark.asyncio
+async def test_an_unknown_name_locks_exactly_like_a_real_one(client):
+ """M1: the lockout must not become the enumeration oracle `login` avoids."""
+ await _register(client, "bob")
+ await _fail(client, "bob", 4)
+ await _fail(client, "nobody-by-this-name", 4)
+
+ real = await _login(client, "bob", WRONG)
+ ghost = await _login(client, "nobody-by-this-name", WRONG)
+ assert (real.status_code, real.json()) == (ghost.status_code, ghost.json())
+ assert real.status_code == 429
+
+
+@pytest.mark.asyncio
+async def test_the_right_passphrase_clears_the_count(client):
+ await _register(client, "carol")
+ await _fail(client, "carol", 3)
+ r = await _login(client, "carol", RIGHT)
+ assert r.status_code == 200, r.text
+
+ # Three more would have been seven in a row without the reset.
+ await _fail(client, "carol", 3)
+ assert (await _login(client, "carol", RIGHT)).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_a_lockout_ends_when_its_window_does(client, db_session):
+ await _register(client, "dave")
+ await _fail(client, "dave", 4)
+ assert (await _login(client, "dave", RIGHT)).status_code == 429
+
+ await db_session.execute(
+ update(LoginThrottle).where(LoginThrottle.key == _key("dave"))
+ .values(last_failure_at=datetime.now(timezone.utc) - timedelta(minutes=61)))
+ await db_session.commit()
+
+ assert (await _login(client, "dave", RIGHT)).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_old_failures_do_not_carry_into_a_new_window(client, db_session):
+ await _register(client, "erin")
+ await _fail(client, "erin", 3)
+ await db_session.execute(
+ update(LoginThrottle).where(LoginThrottle.key == _key("erin"))
+ .values(last_failure_at=datetime.now(timezone.utc) - timedelta(minutes=61)))
+ await db_session.commit()
+
+ # One stale window of three, then one fresh failure: a count of one, not four.
+ await _fail(client, "erin", 1)
+ assert (await _login(client, "erin", RIGHT)).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_a_burst_of_concurrent_guesses_gets_no_more_than_the_limit(client):
+ """The attempt is taken before the check, in one statement.
+
+ Read-then-write would let every request in a burst read "no failures yet"
+ and be checked. On SQLite writes serialise anyway, so this pins the
+ behaviour rather than proving the statement under PostgreSQL's concurrency;
+ the statement is an `ON CONFLICT DO UPDATE … WHERE`, which both evaluate
+ against the row as locked.
+ """
+ await _register(client, "frank")
+ results = await asyncio.gather(*[_login(client, "frank", WRONG) for _ in range(10)])
+ codes = sorted(r.status_code for r in results)
+ assert codes.count(401) == 4, codes
+ assert codes.count(429) == 6, codes
+
+
+@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."""
+ await _register(client, "grace")
+ token = (await _login(client, "grace", RIGHT)).json()["access_token"]
+ auth = {"Authorization": f"Bearer {token}"}
+
+ for _ in range(4):
+ r = await client.post("/v1/users/password", headers=auth, json={
+ "old_auth_key": WRONG, "new_auth_key": "n" * 44})
+ assert r.status_code == 403, r.text
+
+ 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", RIGHT)).status_code == 429
+
+
+@pytest.mark.asyncio
+async def test_a_signed_in_session_is_told_its_own_lockout(client):
+ """A passphrase change re-wraps every node's bundle before the hub accepts
+ it, so the client must know not to start one the hub would then refuse."""
+ await _register(client, "olivia")
+ token = (await _login(client, "olivia", RIGHT)).json()["access_token"]
+ auth = {"Authorization": f"Bearer {token}"}
+ assert (await client.get("/v1/users/me", headers=auth)).json()["passphrase_locked_for"] == 0
+
+ await _fail(client, "olivia", 4)
+ left = (await client.get("/v1/users/me", headers=auth)).json()["passphrase_locked_for"]
+ assert 3500 <= left <= 3600
+
+
+@pytest.mark.asyncio
+async def test_an_attempt_that_checks_no_passphrase_is_not_counted(client, db_session):
+ """A legacy account asked to upgrade has been told nothing about its passphrase."""
+ from meshbay_hub.db.models import User
+
+ await _register(client, "heidi")
+ await db_session.execute(
+ update(User).where(User.username == "heidi").values(pw_version=2))
+ await db_session.commit()
+
+ for _ in range(6):
+ r = await _login(client, "heidi", RIGHT)
+ assert r.status_code == 401 and r.json()["detail"] == "auth_upgrade_required"
+ failures = await db_session.scalar(
+ select(LoginThrottle.failures).where(LoginThrottle.key == _key("heidi")))
+ assert not failures
+
+
+@pytest.mark.asyncio
+async def test_the_table_never_holds_what_was_typed(client, db_session):
+ """People type passphrases into the username field."""
+ await _login(client, "my-secret-passphrase-typed-in-the-wrong-box", WRONG)
+ keys = (await db_session.execute(select(LoginThrottle.key))).scalars().all()
+ assert keys and all("secret" not in k for k in keys)
+
+
+# ── The admin's two numbers ──────────────────────────────────────────────────
+
+async def _admin_headers(client, username="root"):
+ await _register(client, username)
+ set_admin_usernames([username])
+ token = (await _login(client, username, RIGHT)).json()["access_token"]
+ return {"Authorization": f"Bearer {token}"}
+
+
+@pytest.mark.asyncio
+async def test_the_admin_sets_the_limit_and_the_hub_applies_it(client):
+ admin = await _admin_headers(client)
+
+ r = await client.get("/v1/admin/settings", headers=admin)
+ assert r.json()["login"] == {"max_failures": 4, "lockout_minutes": 60}
+ assert r.json()["login_defaults"] == {"max_failures": 4, "lockout_minutes": 60}
+
+ r = await client.patch("/v1/admin/settings", headers=admin,
+ json={"login": {"max_failures": 2, "lockout_minutes": 5}})
+ assert r.status_code == 200, r.text
+ assert r.json()["login"] == {"max_failures": 2, "lockout_minutes": 5}
+
+ await _register(client, "ivan")
+ await _fail(client, "ivan", 2)
+ r = await _login(client, "ivan", RIGHT)
+ assert r.status_code == 429
+ assert int(r.headers["retry-after"]) <= 300
+
+
+@pytest.mark.asyncio
+async def test_zero_failures_turns_the_lockout_off(client):
+ admin = await _admin_headers(client)
+ await client.patch("/v1/admin/settings", headers=admin,
+ json={"login": {"max_failures": 0}})
+
+ await _register(client, "judy")
+ await _fail(client, "judy", 8)
+ assert (await _login(client, "judy", RIGHT)).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_values_are_clamped_and_unknown_keys_refused(client):
+ admin = await _admin_headers(client)
+
+ r = await client.patch("/v1/admin/settings", headers=admin,
+ json={"login": {"max_failures": -3, "lockout_minutes": 10**9}})
+ assert r.status_code == 200
+ low, _ = r.json()["login_bounds"]["max_failures"]
+ _, high = r.json()["login_bounds"]["lockout_minutes"]
+ assert r.json()["login"] == {"max_failures": low, "lockout_minutes": high}
+
+ r = await client.patch("/v1/admin/settings", headers=admin,
+ json={"login": {"lockout_hours": 1}})
+ assert r.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_only_an_admin_changes_them(client):
+ await _admin_headers(client) # an admin exists; this is someone else
+ await _register(client, "mallory")
+ token = (await _login(client, "mallory", RIGHT)).json()["access_token"]
+ r = await client.patch("/v1/admin/settings",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"login": {"max_failures": 0}})
+ assert r.status_code == 403
diff --git a/packages/meshbay-hub/tests/test_migrations_reach_head.py b/packages/meshbay-hub/tests/test_migrations_reach_head.py
index 3631438..13c5590 100644
--- a/packages/meshbay-hub/tests/test_migrations_reach_head.py
+++ b/packages/meshbay-hub/tests/test_migrations_reach_head.py
@@ -52,7 +52,7 @@ def test_the_chain_reaches_head(migrated):
assert "alembic_version" in insp.get_table_names()
# A table from the newest revision, so "head" means head and not "as far as
# the last revision anybody happened to run".
- assert "mail_quota" in insp.get_table_names()
+ assert "login_throttle" in insp.get_table_names()
def test_the_migrated_schema_is_the_one_the_models_expect(migrated):
diff --git a/packages/meshbay-hub/tests/test_unauthenticated_surface.py b/packages/meshbay-hub/tests/test_unauthenticated_surface.py
new file mode 100644
index 0000000..2c9541a
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_unauthenticated_surface.py
@@ -0,0 +1,134 @@
+"""
+What the hub answers to somebody with no token.
+
+Every route either depends on an authentication dependency, or is on the list
+below with the reason it must not. The list is the review, written down: a new
+route that takes no account fails here until somebody adds it and says why,
+which is the moment the question "should this be public?" gets asked.
+
+Routes that authenticate in their own body (a signature, an MHP token, a JWT
+inside the first WebSocket message) are listed too, with what they check —
+"no `Depends`" is not the same as "open", and the list says which is which.
+"""
+
+import asyncio
+import json
+
+import pytest
+from fastapi.routing import APIRoute, APIWebSocketRoute
+
+AUTH_DEPENDENCIES = {
+ "_decode_token", "get_current_user", "require_user_scope",
+ "require_moderator", "require_admin",
+}
+
+# (method, path) → why it takes no hub account.
+PUBLIC = {
+ ("GET", "/"): "the application shell",
+ ("GET", "/app"): "the application shell",
+ ("GET", "/app/{path:path}"): "the application shell",
+ ("GET", "/v1/hub/info"): "read before sign-in: captcha key, instance policy",
+ ("GET", "/v1/hub/version"): "an installed client checks its minimum version first",
+ ("GET", "/v1/hub/pubkey"): "nodes cache the hub key on first contact",
+ ("GET", "/v1/health"): "supervision",
+ ("POST", "/v1/users/register"): "obtains an account — captcha, per-IP limit",
+ ("POST", "/v1/users/login"): "obtains a session — per-IP limit, per-name lockout",
+ ("POST", "/v1/users/auth"): "device sign-in — Ed25519 signature over a fresh timestamp",
+ ("POST", "/v1/users/token/refresh"): "the refresh token is the credential",
+ ("POST", "/v1/users/verify-email"): "the e-mailed code is the credential, attempts capped",
+ ("POST", "/v1/users/password/reset-request"): "captcha, per-IP and per-account limits",
+ ("POST", "/v1/users/password/reset"): "the e-mailed code is the credential, attempts capped",
+ ("POST", "/v1/nodes/auth"): "node sign-in — Ed25519 signature over a fresh timestamp",
+ ("WS", "/v1/nodes/ws"): "a node-scoped JWT in the first message, within a timeout",
+ ("GET", "/v1/groups"): "the public directory — empty when public groups are off",
+ ("GET", "/v1/blocklist"): "nodes sync it on their own behalf — hashes only",
+ ("GET", "/v1/blocklist/check"): "nodes consult it on their own behalf",
+ ("GET", "/v1/relays"): "closed: 503 while relay.RELAYS_ENABLED is False",
+ ("POST", "/v1/relays/register"): "closed; when open, approved key + signature",
+ ("GET", "/mhp/info"): "closed: 503 while federation.FEDERATION_ENABLED is False",
+ ("GET", "/mhp/directory"): "closed; when open, an MHP token",
+ ("POST", "/mhp/directory"): "closed; when open, an MHP token",
+ ("POST", "/mhp/revoke"): "closed; when open, an MHP token",
+}
+
+
+def _dependency_names(dependant, acc):
+ if dependant.call is not None:
+ acc.add(getattr(dependant.call, "__name__", ""))
+ for d in dependant.dependencies:
+ _dependency_names(d, acc)
+ return acc
+
+
+def _routes(routes):
+ for r in routes:
+ # FastAPI wraps an included router rather than copying its routes.
+ inner = getattr(r, "original_router", None)
+ if inner is not None:
+ yield from _routes(inner.routes)
+ elif isinstance(r, (APIRoute, APIWebSocketRoute)):
+ yield r
+
+
+@pytest.mark.asyncio
+async def test_every_route_without_an_account_is_one_somebody_chose(app):
+ found = set()
+ for route in _routes(app.routes):
+ if _dependency_names(route.dependant, set()) & AUTH_DEPENDENCIES:
+ continue
+ for method in (getattr(route, "methods", None) or {"WS"}):
+ found.add((method, route.path))
+
+ assert found - set(PUBLIC) == set(), (
+ "open route(s) nobody has reviewed — add an auth dependency, or list "
+ "them in PUBLIC with the reason")
+ assert set(PUBLIC) - found == set(), "PUBLIC lists routes that are gone or now authenticated"
+
+
+@pytest.mark.asyncio
+async def test_the_walk_sees_the_whole_api(app):
+ """The test above passes vacuously if the walk finds nothing."""
+ assert sum(1 for _ in _routes(app.routes)) > 80
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("path", ["/docs", "/redoc", "/openapi.json"])
+async def test_the_api_describes_itself_to_nobody(client, path):
+ r = await client.get(path)
+ assert r.status_code == 404
+ assert "swagger" not in r.text.lower() and "openapi" not in r.text.lower()
+
+
+class _SilentSocket:
+ """A client that connects and never says anything."""
+
+ def __init__(self):
+ self.sent, self.closed_with = [], None
+
+ async def accept(self):
+ pass
+
+ async def receive_text(self):
+ await asyncio.Event().wait()
+
+ async def send_text(self, text):
+ self.sent.append(json.loads(text))
+
+ async def close(self, code=1000):
+ self.closed_with = code
+
+
+@pytest.mark.asyncio
+async def test_a_socket_that_never_authenticates_is_closed(monkeypatch):
+ """The node socket is accepted before anyone is known; silence is not a lease.
+
+ Driven directly: the suite's transport has no WebSocket support.
+ """
+ from meshbay_hub.api import revocation
+
+ monkeypatch.setattr(revocation, "NODE_WS_AUTH_TIMEOUT", 0.05)
+ ws = _SilentSocket()
+ await asyncio.wait_for(revocation.node_websocket(ws), timeout=5)
+
+ assert ws.closed_with == 4001
+ assert ws.sent == [{"type": "error", "detail": "Authentication timed out"}]