aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-10 03:57:55 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-10 03:57:55 +0200
commit1a53eb4cc404ec94658fde0ae04cfe2ccf1810dc (patch)
tree04a23f81d3eea49de7414bf973600d33913795f9
parent4b3e8c3b8b9d10c8ac333dd8db614a7569052472 (diff)
downloadmeshbay-1a53eb4cc404ec94658fde0ae04cfe2ccf1810dc.tar.gz
feat(hub): Phase 8 — Hub v2 security hardening + production readiness
8.1 Config-based admin authz (require_admin on all admin endpoints) 8.2 Email encrypted at rest (AES-256-GCM, HKDF from hub Ed25519 key) 8.3 Refresh token rotation with family-based reuse detection 8.4 Federation persistence (HubPeer model replaces in-memory dict) 8.5 Federation token verification now async (DB-backed) 8.6 CSAM hash check wired into swarm registration flow 8.7 Rate limiting on auth endpoints (5/10/20 per minute) 8.8 Healthcheck endpoint (GET /v1/health, no auth) 8.9 IP log cleanup background task (365-day retention) 8.10 Argon2id params bumped to 256 MB (pw_version, rehash on login) Deployed to meshbay.org — schema migrated, existing emails encrypted. 117 tests pass (29 hub, 88 common+node). Resolves security review items S1, S2, S5. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--CLAUDE.md11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/deps.py18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/federation.py86
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/health.py21
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/moderation.py8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/relay.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py71
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py71
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/config.py5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/csam.py6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/tasks/__init__.py0
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py40
-rw-r--r--packages/meshbay-hub/tests/conftest.py8
-rw-r--r--packages/meshbay-hub/tests/test_hub_api.py197
-rw-r--r--packages/meshbay-hub/tests/test_moderation.py2
-rw-r--r--packages/meshbay-hub/tests/test_revocation.py4
20 files changed, 508 insertions, 90 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index bd09752..d0c49bd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -106,19 +106,20 @@ Scope: `hub`, `node`, `common`, or omitted for cross-cutting
accesses any group.
**Significant (Phase 7-8):**
-- **S1** Admin revocation endpoint has no authz check → Phase 8.1
-- **S2** Email stored in plaintext (spec says encrypted at rest) → Phase 8.2
+- **S1** Admin revocation endpoint has no authz check ✅ DONE (Phase 8.1 — config-based require_admin)
+- **S2** Email stored in plaintext (spec says encrypted at rest) ✅ DONE (Phase 8.2 — AES-256-GCM, HKDF from hub key)
- **S3** jti denylist push via hub→node WebSocket → Phase 7.2
- **S4** AES-GCM keystore IV fixed: 128-bit → 96-bit (NIST SP 800-38D) ✅ DONE
-- **S5** Refresh token rotation (one-time use) → Phase 8.3
+- **S5** Refresh token rotation (one-time use) ✅ DONE (Phase 8.3 — family-based reuse detection)
**Architecture validated:** crypto primitives, GEK wrapping (ECIES), trust model,
key hierarchy, on-the-fly encryption, transport abstraction.
## Known calibration TODOs
-- Argon2id `memory_cost`: currently 65536 (64 MB, 78ms) — increase to 262144 (256 MB) before prod
- to target ~500ms on typical home server hardware. Implement a `calibrate` CLI command.
+- Argon2id `memory_cost`: ✅ DONE — bumped to 262144 (256 MB) in pw_version=2.
+ Existing v1 users (64 MB) are transparently rehashed on next successful login.
+ CLI `calibrate` command still TODO for per-hardware tuning.
## NAT traversal — résultats empiriques (demo-v2)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
index cb637f3..7a580ad 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/deps.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
@@ -2,8 +2,6 @@
FastAPI shared dependencies — injected via Depends().
"""
-from collections.abc import AsyncGenerator
-
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -12,6 +10,13 @@ from meshbay_hub.auth import decode_access_token
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import User
+_admin_usernames: set[str] = set()
+
+
+def set_admin_usernames(usernames: list[str]) -> None:
+ global _admin_usernames
+ _admin_usernames = set(usernames)
+
async def get_current_user(
authorization: str = Header(...),
@@ -45,3 +50,12 @@ async def get_current_user(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail=f"Account {user.status}")
return user
+
+
+async def require_admin(
+ current_user: User = Depends(get_current_user),
+) -> User:
+ if current_user.username not in _admin_usernames:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
+ detail="Admin access required")
+ return current_user
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/federation.py b/packages/meshbay-hub/src/meshbay_hub/api/federation.py
index 88bb645..b6b4acf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/federation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/federation.py
@@ -32,21 +32,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_common import MHP_VERSION
from meshbay_hub import __version__
-from meshbay_hub.api.deps import get_current_user
+from meshbay_hub.api.deps import require_admin
from meshbay_hub.auth import _hub_id, _hub_sk_pem, hub_public_key_pem
from meshbay_hub.db.engine import get_db
-from meshbay_hub.db.models import Group, User
-from sqlalchemy.ext.asyncio import AsyncSession
+from meshbay_hub.db.models import FederatedGroup, Group, HubPeer, User
log = logging.getLogger(__name__)
router = APIRouter(prefix="/mhp", tags=["federation"])
-# ── In-memory peer registry ───────────────────────────────────────────────────
-# Production: move to DB table (peers: hub_id, hub_url, pk_hub_pem, trusted_since)
-
-_peers: dict[str, dict] = {} # hub_id → {url, pk_pem, trusted_since}
-
def _issue_mhp_token(target_hub_id: str) -> str:
"""Issue a short-lived JWT for authenticating to a peer hub."""
@@ -57,18 +51,19 @@ def _issue_mhp_token(target_hub_id: str) -> str:
"aud": target_hub_id,
"jti": str(uuid.uuid4()),
"iat": now,
- "exp": now + 300, # 5 minute window
+ "exp": now + 300,
"mhp": MHP_VERSION,
}, _hub_sk_pem, algorithm="EdDSA")
-def _verify_mhp_token(token: str, expected_aud: str | None = None) -> dict:
- """Verify a JWT from a peer hub."""
- # First decode without verification to get iss (sender hub_id)
+async def _verify_mhp_token(
+ token: str, db: AsyncSession, expected_aud: str | None = None,
+) -> dict:
+ """Verify a JWT from a peer hub using DB-stored public key."""
unverified = jwt.decode(token, options={"verify_signature": False})
sender_id = unverified.get("iss")
- peer = _peers.get(sender_id)
+ peer = await db.get(HubPeer, sender_id)
if not peer:
raise PermissionError(f"Unknown hub: {sender_id!r}. Register as peer first.")
@@ -77,7 +72,7 @@ def _verify_mhp_token(token: str, expected_aud: str | None = None) -> dict:
options["audience"] = expected_aud
decoded = jwt.decode(
- token, peer["pk_pem"].encode(),
+ token, peer.pk_hub_pem.encode(),
algorithms=["EdDSA"],
options=options,
)
@@ -104,12 +99,8 @@ async def export_directory(
db: AsyncSession = Depends(get_db),
authorization: str = Header(...),
):
- """
- Export our public Mesh Directory to a peer hub.
- Auth: Bearer JWT signed by peer hub's key.
- """
try:
- _verify_mhp_token(authorization.removeprefix("Bearer "))
+ await _verify_mhp_token(authorization.removeprefix("Bearer "), db)
except Exception as e:
raise HTTPException(status_code=401, detail=str(e))
@@ -144,16 +135,11 @@ async def receive_directory(
authorization: str = Header(...),
db: AsyncSession = Depends(get_db),
):
- """
- Receive a Mesh Directory update from a peer hub.
- Persists groups to federated_groups table for cross-hub search.
- """
try:
- _verify_mhp_token(authorization.removeprefix("Bearer "))
+ await _verify_mhp_token(authorization.removeprefix("Bearer "), db)
except Exception as e:
raise HTTPException(status_code=401, detail=str(e))
- from meshbay_hub.db.models import FederatedGroup
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
count = 0
@@ -179,24 +165,19 @@ async def receive_directory(
# ── Revocation propagation ────────────────────────────────────────────────────
class RevocationPayload(BaseModel):
- token: str # signed revocation JWT from originating hub
-
+ token: str
@router.post("/revoke", status_code=202)
async def receive_revocation(
body: RevocationPayload,
authorization: str = Header(...),
+ db: AsyncSession = Depends(get_db),
):
- """
- Receive a revocation from a peer hub. Verify and propagate to our nodes.
- """
try:
- _verify_mhp_token(authorization.removeprefix("Bearer "))
+ await _verify_mhp_token(authorization.removeprefix("Bearer "), db)
except Exception as e:
raise HTTPException(status_code=401, detail=str(e))
- # The revocation token is signed by the ORIGINATING hub's key (not the relaying hub)
- # For now: re-broadcast to our connected nodes
from meshbay_hub.api.revocation import broadcast_revocation
sent = await broadcast_revocation(body.token)
log.info("Propagated revocation to %d local nodes", sent)
@@ -208,30 +189,45 @@ async def receive_revocation(
class PeerRegisterRequest(BaseModel):
hub_id: str
hub_url: str
- pk_hub_pem: str # peer hub's Ed25519 public key PEM
-
+ pk_hub_pem: str
@router.post("/peers", status_code=201)
async def register_peer(
body: PeerRegisterRequest,
- current_user: User = Depends(get_current_user),
+ current_user: User = Depends(require_admin),
+ db: AsyncSession = Depends(get_db),
):
- """Admin: register a trusted peer hub. Manual step — no auto-discovery."""
- _peers[body.hub_id] = {
- "url": body.hub_url,
- "pk_pem": body.pk_hub_pem,
- "trusted_since": int(time.time()),
- }
+ """Admin: register a trusted peer hub."""
+ existing = await db.get(HubPeer, body.hub_id)
+ if existing:
+ existing.hub_url = body.hub_url
+ existing.pk_hub_pem = body.pk_hub_pem
+ else:
+ db.add(HubPeer(
+ hub_id=body.hub_id,
+ hub_url=body.hub_url,
+ pk_hub_pem=body.pk_hub_pem,
+ ))
+ await db.commit()
log.info("Peer registered: %s (%s)", body.hub_id, body.hub_url)
return {"status": "registered", "hub_id": body.hub_id}
@router.get("/peers")
-async def list_peers(current_user: User = Depends(get_current_user)):
+async def list_peers(
+ current_user: User = Depends(require_admin),
+ db: AsyncSession = Depends(get_db),
+):
"""Admin: list registered peer hubs."""
+ result = await db.execute(select(HubPeer))
+ peers = result.scalars().all()
return {
"peers": [
- {"hub_id": hid, "url": p["url"], "trusted_since": p["trusted_since"]}
- for hid, p in _peers.items()
+ {
+ "hub_id": p.hub_id,
+ "url": p.hub_url,
+ "trusted_since": p.trusted_since.isoformat(),
+ }
+ for p in peers
]
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index 2ae2c93..5bccf71 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -67,10 +67,11 @@ async def swarm_register(
db: AsyncSession = Depends(get_db),
):
"""Node registers itself as a source for a content hash (public swarm)."""
+ from meshbay_hub.csam import check_content_hash
+ if check_content_hash(body.content_hash):
+ raise HTTPException(status_code=451, detail="Content blocked")
+
from datetime import datetime, timezone
- node_result = await db.execute(
- select(User).where(User.id == current_user.id))
- # Use current_user.id as node_id for simplicity
existing = await db.get(SwarmSource, (body.content_hash, current_user.id))
now = datetime.now(timezone.utc)
if existing:
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/health.py b/packages/meshbay-hub/src/meshbay_hub/api/health.py
new file mode 100644
index 0000000..516856b
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/health.py
@@ -0,0 +1,21 @@
+"""Healthcheck endpoint — no auth required (monitoring probes)."""
+
+from fastapi import APIRouter, Depends
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from meshbay_hub import __version__
+from meshbay_hub.api.revocation import get_connected_node_count
+from meshbay_hub.db.engine import get_db
+
+router = APIRouter(tags=["health"])
+
+
+@router.get("/v1/health")
+async def health(db: AsyncSession = Depends(get_db)):
+ await db.execute(text("SELECT 1"))
+ return {
+ "status": "ok",
+ "version": __version__,
+ "connected_nodes": get_connected_node_count(),
+ }
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
index a8bf84f..6bb007b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
@@ -27,7 +27,7 @@ from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
-from meshbay_hub.api.deps import get_current_user
+from meshbay_hub.api.deps import get_current_user, require_admin
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import ContentBlocklist, ContentReport, User
@@ -138,7 +138,7 @@ async def get_blocklist(
@router.get("/v1/admin/blocklist")
async def admin_list_blocklist(
- current_user: User = Depends(get_current_user),
+ current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
limit: int = 500,
):
@@ -164,7 +164,7 @@ async def admin_list_blocklist(
@router.post("/v1/admin/blocklist", status_code=201)
async def admin_add_blocklist(
body: BlocklistAddRequest,
- current_user: User = Depends(get_current_user),
+ current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
existing = await db.get(ContentBlocklist, body.content_hash)
@@ -183,7 +183,7 @@ async def admin_add_blocklist(
@router.delete("/v1/admin/blocklist/{content_hash}", status_code=200)
async def admin_remove_blocklist(
content_hash: str,
- current_user: User = Depends(get_current_user),
+ current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
entry = await db.get(ContentBlocklist, content_hash)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/relay.py b/packages/meshbay-hub/src/meshbay_hub/api/relay.py
index d2a4b81..f82495b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/relay.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/relay.py
@@ -24,7 +24,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
-from meshbay_hub.api.deps import get_current_user
+from meshbay_hub.api.deps import get_current_user, require_admin
from meshbay_hub.auth import _hub_id, hub_public_key_pem
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import User
@@ -100,7 +100,7 @@ async def list_relays():
@router.post("/approve", status_code=201)
async def admin_approve_relay(
body: RelayAdminApproveRequest,
- current_user: User = Depends(get_current_user),
+ current_user: User = Depends(require_admin),
):
"""Admin: pre-approve a relay by registering its public key."""
_relays[body.relay_id] = {
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index bb88283..bbd1bc2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -40,7 +40,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
import jwt
from meshbay_hub.auth import hub_public_key_pem, decode_access_token
-from meshbay_hub.api.deps import get_current_user
+from meshbay_hub.api.deps import get_current_user, require_admin
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import Group, IPLog, User
@@ -193,7 +193,7 @@ class RevokeRequest(BaseModel):
@router.post("/v1/admin/revoke", status_code=200)
async def admin_revoke(
body: RevokeRequest,
- current_user: User = Depends(get_current_user),
+ current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 5a2c7be..f91b381 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -1,5 +1,6 @@
"""User endpoints — /v1/users/*"""
+import uuid
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -8,14 +9,18 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.auth import (
+ current_pw_version,
decode_access_token,
+ encrypt_email,
generate_refresh_token,
hash_password,
hash_refresh_token,
hub_public_key_pem,
issue_access_token,
+ pw_needs_rehash,
verify_password,
)
+from meshbay_hub.api.middleware import limiter
from meshbay_hub.config import HubConfig
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import GroupMember, IPLog, RefreshToken, User
@@ -76,6 +81,7 @@ class RefreshRequest(BaseModel):
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.post("/register", status_code=201)
+@limiter.limit("5/minute")
async def register(
body: RegisterRequest,
request: Request,
@@ -90,9 +96,10 @@ async def register(
hub_id = _cfg.identity.id if _cfg else "meshbay.org"
user = User(
username=body.username,
- email=body.email,
+ email=encrypt_email(body.email),
pw_hash=pw_hash,
pw_salt=pw_salt,
+ pw_version=current_pw_version(),
pk_ed25519=body.pk_user_ed25519,
pk_x25519=body.pk_user_x25519,
hub_id=hub_id,
@@ -118,6 +125,7 @@ async def register(
@router.post("/login")
+@limiter.limit("10/minute")
async def login(
body: LoginRequest,
request: Request,
@@ -128,7 +136,9 @@ async def login(
user = result.scalar_one_or_none()
ip = _client_ip(request)
- if not user or not verify_password(body.password, user.pw_hash, user.pw_salt):
+ if not user or 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")
@@ -136,15 +146,25 @@ async def login(
if user.status != "active":
raise HTTPException(status_code=403, detail=f"Account {user.status}")
+ if pw_needs_rehash(user.pw_version):
+ new_hash, new_salt = hash_password(body.password)
+ user.pw_hash = new_hash
+ user.pw_salt = new_salt
+ user.pw_version = current_pw_version()
+
memberships = await db.execute(
select(GroupMember.group_id).where(GroupMember.user_id == user.id))
group_ids = [gid for (gid,) in memberships.all()]
access_token = issue_access_token(
user.id, user.pk_ed25519, ttl=_ttl(), groups=group_ids)
raw_rt, rt_hash = generate_refresh_token()
+ family_id = str(uuid.uuid4())
expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl())
- db.add(RefreshToken(user_id=user.id, token_hash=rt_hash, expires_at=expires_at))
+ db.add(RefreshToken(
+ user_id=user.id, token_hash=rt_hash,
+ family_id=family_id, expires_at=expires_at,
+ ))
db.add(IPLog(user_id=user.id, event="login", ip_address=ip))
await db.commit()
@@ -160,31 +180,60 @@ async def login(
@router.post("/token/refresh")
+@limiter.limit("20/minute")
async def token_refresh(
body: RefreshRequest,
+ request: Request,
db: AsyncSession = Depends(get_db),
):
rt_hash = hash_refresh_token(body.refresh_token)
result = await db.execute(
- select(RefreshToken).where(
- RefreshToken.token_hash == rt_hash,
- RefreshToken.revoked == False, # noqa: E712
- ))
+ select(RefreshToken).where(RefreshToken.token_hash == rt_hash))
rt = result.scalar_one_or_none()
- if not rt or rt.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc):
- raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
+ if not rt:
+ raise HTTPException(status_code=401, detail="Invalid refresh token")
+
+ if rt.revoked:
+ # Reuse detected — revoke entire token family
+ await db.execute(
+ RefreshToken.__table__.update()
+ .where(RefreshToken.family_id == rt.family_id)
+ .values(revoked=True))
+ await db.commit()
+ raise HTTPException(status_code=401, detail="Token reuse detected — family revoked")
+
+ if rt.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc):
+ raise HTTPException(status_code=401, detail="Expired refresh token")
user = await db.get(User, rt.user_id)
if not user or user.status != "active":
raise HTTPException(status_code=401, detail="User not found or suspended")
+ # Revoke old token
+ rt.revoked = True
+
+ # Issue new refresh token in the same family
+ new_raw_rt, new_rt_hash = generate_refresh_token()
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl())
+ db.add(RefreshToken(
+ user_id=user.id, token_hash=new_rt_hash,
+ family_id=rt.family_id, expires_at=expires_at,
+ ))
+
memberships = await db.execute(
select(GroupMember.group_id).where(GroupMember.user_id == user.id))
group_ids = [gid for (gid,) in memberships.all()]
- new_token = issue_access_token(
+ new_access = issue_access_token(
user.id, user.pk_ed25519, ttl=_ttl(), groups=group_ids)
- return {"access_token": new_token, "token_type": "bearer", "expires_in": _ttl()}
+ await db.commit()
+
+ return {
+ "access_token": new_access,
+ "refresh_token": new_raw_rt,
+ "token_type": "bearer",
+ "expires_in": _ttl(),
+ }
@router.get("/{username}/pubkeys")
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index 1c494fa..c80da28 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -9,6 +9,7 @@ Usage:
app = create_app(cfg)
"""
+import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
@@ -22,12 +23,14 @@ from meshbay_hub.config import HubConfig
from meshbay_hub.db.engine import close_db, init_db
from meshbay_hub.api.hub import router as hub_router
from meshbay_hub.api.users import router as users_router, set_config as users_set_config
+from meshbay_hub.api.deps import set_admin_usernames
from meshbay_hub.api.nodes import router as nodes_router
from meshbay_hub.api.groups import router as groups_router
from meshbay_hub.api.revocation import router as revocation_router
from meshbay_hub.api.moderation import router as moderation_router
from meshbay_hub.api.federation import router as federation_router
from meshbay_hub.csam import csam_router
+from meshbay_hub.api.health import router as health_router
from meshbay_hub.api.relay import router as relay_router
from meshbay_hub.api.webapp import router as webapp_router
from meshbay_hub.api.middleware import limiter
@@ -48,9 +51,23 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
generate_hub_keypair(kp)
load_hub_keypair(kp, cfg.identity.id)
users_set_config(cfg)
+ set_admin_usernames(cfg.identity.admin_usernames)
+
+ from meshbay_hub.csam import get_csam_checker
+ get_csam_checker().load()
+
+ from meshbay_hub.tasks.cleanup import cleanup_loop
+ from meshbay_hub.db.engine import get_session_factory
+ cleanup_task = asyncio.create_task(cleanup_loop(get_session_factory()))
yield
+ cleanup_task.cancel()
+ try:
+ await cleanup_task
+ except asyncio.CancelledError:
+ pass
+
# Shutdown
await close_db()
@@ -74,6 +91,7 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
app.include_router(moderation_router)
app.include_router(federation_router)
app.include_router(csam_router)
+ app.include_router(health_router)
app.include_router(relay_router)
app.include_router(webapp_router)
diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py
index 2b2c61e..11ad112 100644
--- a/packages/meshbay-hub/src/meshbay_hub/auth.py
+++ b/packages/meshbay-hub/src/meshbay_hub/auth.py
@@ -18,25 +18,33 @@ import blake3
import jwt
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+from cryptography.hazmat.primitives.hashes import SHA256
-# Argon2id parameters — see CLAUDE.md for production calibration guidance
-_ARGON2_ITERATIONS = 3
-_ARGON2_MEMORY_COST = 65536 # 64 MB — increase to 262144 in production
-_ARGON2_LANES = 4
-_ARGON2_KEY_LEN = 32
+# Argon2id parameters — versioned for gradual migration
+_ARGON2_LANES = 4
+_ARGON2_KEY_LEN = 32
+
+_ARGON2_VERSIONS = {
+ 1: {"iterations": 3, "memory_cost": 65536}, # 64 MB — initial
+ 2: {"iterations": 3, "memory_cost": 262144}, # 256 MB — production target
+}
+_ARGON2_CURRENT_VERSION = 2
# Module-level hub keypair (loaded once at startup)
_hub_sk_pem: bytes | None = None
_hub_pk_pem: bytes | None = None
_hub_id: str = "meshbay.org"
+_email_key: bytes | None = None
# ── Hub keypair ───────────────────────────────────────────────────────────────
def load_hub_keypair(private_key_path: Path, hub_id: str) -> None:
"""Load hub Ed25519 keypair from PEM file. Call once at startup."""
- global _hub_sk_pem, _hub_pk_pem, _hub_id
+ global _hub_sk_pem, _hub_pk_pem, _hub_id, _email_key
_hub_sk_pem = private_key_path.read_bytes()
sk = serialization.load_pem_private_key(_hub_sk_pem, password=None)
_hub_pk_pem = sk.public_key().public_bytes(
@@ -45,6 +53,15 @@ def load_hub_keypair(private_key_path: Path, hub_id: str) -> None:
)
_hub_id = hub_id
+ sk_raw = sk.private_bytes(
+ serialization.Encoding.Raw,
+ serialization.PrivateFormat.Raw,
+ serialization.NoEncryption(),
+ )
+ _email_key = HKDF(
+ algorithm=SHA256(), length=32, salt=None, info=b"meshbay:email:v1",
+ ).derive(sk_raw)
+
def generate_hub_keypair(private_key_path: Path) -> None:
"""Generate a new hub Ed25519 keypair and save PEM files. Run once."""
@@ -73,32 +90,42 @@ def hub_public_key_pem() -> bytes:
# ── Password ──────────────────────────────────────────────────────────────────
def hash_password(password: str) -> tuple[bytes, bytes]:
- """Hash a password with Argon2id. Returns (hash, salt)."""
+ """Hash a password with Argon2id (current version). Returns (hash, salt)."""
salt = os.urandom(16)
+ params = _ARGON2_VERSIONS[_ARGON2_CURRENT_VERSION]
pw_hash = Argon2id(
salt=salt,
length=_ARGON2_KEY_LEN,
- iterations=_ARGON2_ITERATIONS,
+ iterations=params["iterations"],
lanes=_ARGON2_LANES,
- memory_cost=_ARGON2_MEMORY_COST,
+ memory_cost=params["memory_cost"],
).derive(password.encode())
return pw_hash, salt
-def verify_password(password: str, pw_hash: bytes, salt: bytes) -> bool:
+def verify_password(password: str, pw_hash: bytes, salt: bytes, version: int = 2) -> bool:
+ params = _ARGON2_VERSIONS.get(version, _ARGON2_VERSIONS[_ARGON2_CURRENT_VERSION])
try:
Argon2id(
salt=salt,
length=_ARGON2_KEY_LEN,
- iterations=_ARGON2_ITERATIONS,
+ iterations=params["iterations"],
lanes=_ARGON2_LANES,
- memory_cost=_ARGON2_MEMORY_COST,
+ memory_cost=params["memory_cost"],
).verify(password.encode(), pw_hash)
return True
except Exception:
return False
+def pw_needs_rehash(version: int) -> bool:
+ return version < _ARGON2_CURRENT_VERSION
+
+
+def current_pw_version() -> int:
+ return _ARGON2_CURRENT_VERSION
+
+
# ── JWT ───────────────────────────────────────────────────────────────────────
def issue_access_token(
@@ -135,6 +162,26 @@ def decode_access_token(token: str) -> dict:
return jwt.decode(token, _hub_pk_pem, algorithms=["EdDSA"])
+# ── Email encryption at rest ──────────────────────────────────────────────────
+
+def encrypt_email(plaintext: str) -> str:
+ """Encrypt an email address for storage. Returns base64(nonce + ciphertext)."""
+ if _email_key is None:
+ raise RuntimeError("Hub keypair not loaded")
+ nonce = os.urandom(12)
+ ct = AESGCM(_email_key).encrypt(nonce, plaintext.encode(), None)
+ return base64.b64encode(nonce + ct).decode()
+
+
+def decrypt_email(stored: str) -> str:
+ """Decrypt an email address from storage."""
+ if _email_key is None:
+ raise RuntimeError("Hub keypair not loaded")
+ raw = base64.b64decode(stored)
+ nonce, ct = raw[:12], raw[12:]
+ return AESGCM(_email_key).decrypt(nonce, ct, None).decode()
+
+
# ── Refresh tokens ────────────────────────────────────────────────────────────
def generate_refresh_token() -> tuple[str, str]:
diff --git a/packages/meshbay-hub/src/meshbay_hub/config.py b/packages/meshbay-hub/src/meshbay_hub/config.py
index 297ace7..31912d1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/config.py
+++ b/packages/meshbay-hub/src/meshbay_hub/config.py
@@ -41,6 +41,7 @@ class HubIdentityConfig:
id: str = "meshbay.org"
private_key_path: Path = field(default_factory=lambda:
Path.home() / ".config" / "meshbay" / "hub_private.pem")
+ admin_usernames: list[str] = field(default_factory=list)
@dataclass
@@ -75,6 +76,8 @@ def load_config(path: Path | None = None) -> HubConfig:
cfg.identity.id = idn.get("id", cfg.identity.id)
if kp := idn.get("private_key_path"):
cfg.identity.private_key_path = Path(kp).expanduser()
+ if admins := idn.get("admin_usernames"):
+ cfg.identity.admin_usernames = list(admins)
if jwt := raw.get("jwt", {}):
cfg.jwt.access_token_ttl = jwt.get("access_token_ttl", cfg.jwt.access_token_ttl)
cfg.jwt.refresh_token_ttl = jwt.get("refresh_token_ttl", cfg.jwt.refresh_token_ttl)
@@ -91,5 +94,7 @@ def load_config(path: Path | None = None) -> HubConfig:
cfg.identity.id = hub_id
if kp := os.environ.get("MESHBAY_HUB_KEY"):
cfg.identity.private_key_path = Path(kp).expanduser()
+ if admin_users := os.environ.get("MESHBAY_ADMIN_USERS"):
+ cfg.identity.admin_usernames = [u.strip() for u in admin_users.split(",") if u.strip()]
return cfg
diff --git a/packages/meshbay-hub/src/meshbay_hub/csam.py b/packages/meshbay-hub/src/meshbay_hub/csam.py
index 4dd0aed..2a0ce25 100644
--- a/packages/meshbay-hub/src/meshbay_hub/csam.py
+++ b/packages/meshbay-hub/src/meshbay_hub/csam.py
@@ -124,14 +124,14 @@ def check_content_hash(blake3_hex: str) -> bool:
# ── Hub API integration ───────────────────────────────────────────────────────
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
-from meshbay_hub.api.deps import get_current_user
+from meshbay_hub.api.deps import require_admin
from meshbay_hub.db.models import User
csam_router = APIRouter(prefix="/v1/admin/csam", tags=["csam"])
@csam_router.get("/status")
-async def csam_status(current_user: User = Depends(get_current_user)):
+async def csam_status(current_user: User = Depends(require_admin)):
"""Return CSAM checker status (hash count, database path)."""
return {
"hash_count": _checker.hash_count,
@@ -144,7 +144,7 @@ async def csam_status(current_user: User = Depends(get_current_user)):
@csam_router.post("/check")
async def check_hash(
body: dict,
- current_user: User = Depends(get_current_user),
+ current_user: User = Depends(require_admin),
):
"""
Check a single hash. Admin use only.
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index 2aeae41..c0b0491 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -42,6 +42,7 @@ class User(Base):
email: Mapped[str] = mapped_column(String(256), nullable=False) # kept for recovery
pw_hash: Mapped[bytes] = mapped_column(nullable=False)
pw_salt: Mapped[bytes] = mapped_column(nullable=False)
+ pw_version: Mapped[int] = mapped_column(Integer, default=1)
pk_ed25519: Mapped[str] = mapped_column(String(64), nullable=False) # base64 raw 32B
pk_x25519: Mapped[str] = mapped_column(String(64), nullable=False) # base64 raw 32B
hub_id: Mapped[str] = mapped_column(String(128), nullable=False)
@@ -132,17 +133,31 @@ class RefreshToken(Base):
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) # blake3 hex
+ family_id: Mapped[str] = mapped_column(String(36), nullable=False, default=_uuid)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
user: Mapped["User"] = relationship(back_populates="refresh_tokens")
- __table_args__ = (Index("ix_refresh_tokens_hash", "token_hash"),)
+ __table_args__ = (
+ Index("ix_refresh_tokens_hash", "token_hash"),
+ Index("ix_refresh_tokens_family", "family_id"),
+ )
# ── IP logs (legal compliance) ────────────────────────────────────────────────
+class HubPeer(Base):
+ """Trusted peer hub for MHP federation."""
+ __tablename__ = "hub_peers"
+
+ hub_id: Mapped[str] = mapped_column(String(128), primary_key=True)
+ hub_url: Mapped[str] = mapped_column(String(256), nullable=False)
+ pk_hub_pem: Mapped[str] = mapped_column(Text, nullable=False)
+ trusted_since: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+
+
class FederatedGroup(Base):
"""
Groups received from peer hubs via MHP federation.
diff --git a/packages/meshbay-hub/src/meshbay_hub/tasks/__init__.py b/packages/meshbay-hub/src/meshbay_hub/tasks/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/tasks/__init__.py
diff --git a/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py b/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
new file mode 100644
index 0000000..7674c6e
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/tasks/cleanup.py
@@ -0,0 +1,40 @@
+"""Scheduled cleanup tasks — IP log purge (1-year retention)."""
+
+import asyncio
+import logging
+from datetime import datetime, timedelta, timezone
+
+from sqlalchemy import delete
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from meshbay_hub.db.models import IPLog
+
+log = logging.getLogger(__name__)
+
+RETENTION_DAYS = 365
+CLEANUP_INTERVAL_HOURS = 24
+
+
+async def purge_old_ip_logs(db: AsyncSession, retention_days: int = RETENTION_DAYS) -> int:
+ cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
+ result = await db.execute(delete(IPLog).where(IPLog.timestamp < cutoff))
+ await db.commit()
+ return result.rowcount
+
+
+async def cleanup_loop(get_session):
+ """Run cleanup once at startup, then every 24 hours."""
+ try:
+ while True:
+ try:
+ async with get_session() as db:
+ deleted = await purge_old_ip_logs(db)
+ if deleted:
+ log.info("Purged %d IP log entries older than %d days", deleted, RETENTION_DAYS)
+ except asyncio.CancelledError:
+ raise
+ except Exception as e:
+ log.error("IP log cleanup failed: %s", e)
+ await asyncio.sleep(CLEANUP_INTERVAL_HOURS * 3600)
+ except asyncio.CancelledError:
+ return
diff --git a/packages/meshbay-hub/tests/conftest.py b/packages/meshbay-hub/tests/conftest.py
index b42aba1..ffd6c2e 100644
--- a/packages/meshbay-hub/tests/conftest.py
+++ b/packages/meshbay-hub/tests/conftest.py
@@ -48,10 +48,18 @@ async def app(hub_config):
from meshbay_hub.app import create_app
application = create_app(hub_config)
+ # Disable rate limiting in tests
+ from meshbay_hub.api.middleware import limiter
+ limiter.enabled = False
+
# Run lifespan startup manually
async with application.router.lifespan_context(application):
yield application
+ # Reset admin usernames after each test
+ from meshbay_hub.api.deps import set_admin_usernames
+ set_admin_usernames([])
+
@pytest_asyncio.fixture
async def client(app):
diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py
index 568d5d9..327158f 100644
--- a/packages/meshbay-hub/tests/test_hub_api.py
+++ b/packages/meshbay-hub/tests/test_hub_api.py
@@ -11,6 +11,7 @@ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives import serialization
from meshbay_common.crypto import generate_gek, pk_to_b64, wrap_gek
+from meshbay_hub.api.deps import set_admin_usernames
def _gen_user_keys():
@@ -35,6 +36,16 @@ async def test_hub_info(client):
@pytest.mark.asyncio
+async def test_health(client):
+ r = await client.get("/v1/health")
+ assert r.status_code == 200
+ data = r.json()
+ assert data["status"] == "ok"
+ assert "version" in data
+ assert "connected_nodes" in data
+
+
+@pytest.mark.asyncio
async def test_hub_pubkey(client):
r = await client.get("/v1/hub/pubkey")
assert r.status_code == 200
@@ -123,6 +134,33 @@ async def test_token_refresh(client):
r2 = await client.post("/v1/users/token/refresh", json={"refresh_token": rt})
assert r2.status_code == 200
assert r2.json()["access_token"] != at # new token (different jti)
+ assert "refresh_token" in r2.json() # rotated refresh token returned
+
+
+@pytest.mark.asyncio
+async def test_refresh_token_rotation_old_rejected(client):
+ """After rotation, old refresh token is rejected."""
+ pk_ed, pk_x, _ = _gen_user_keys()
+ await client.post("/v1/users/register", json={
+ "username": "rot_user", "email": "rot@x.com", "password": "rotpass99",
+ "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x})
+ r = await client.post("/v1/users/login", json={
+ "username": "rot_user", "password": "rotpass99"})
+ rt1 = r.json()["refresh_token"]
+
+ r2 = await client.post("/v1/users/token/refresh", json={"refresh_token": rt1})
+ assert r2.status_code == 200
+ rt2 = r2.json()["refresh_token"]
+ assert rt2 != rt1
+
+ # Old token reuse → detected and family revoked
+ r3 = await client.post("/v1/users/token/refresh", json={"refresh_token": rt1})
+ assert r3.status_code == 401
+ assert "reuse" in r3.json()["detail"].lower()
+
+ # New token also revoked (entire family)
+ r4 = await client.post("/v1/users/token/refresh", json={"refresh_token": rt2})
+ assert r4.status_code == 401
@pytest.mark.asyncio
@@ -311,3 +349,162 @@ async def test_jwt_contains_groups_claim(client):
"username": "grp_alice", "password": "alicepass99"})
decoded_alice = pyjwt.decode(r.json()["access_token"], hub_pk, algorithms=["EdDSA"])
assert group_id in decoded_alice["groups"]
+
+
+# ── Admin authz (8.1) ───────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_non_admin_cannot_revoke(client):
+ """Non-admin user gets 403 on admin endpoints."""
+ pk_ed, pk_x, _ = _gen_user_keys()
+ await client.post("/v1/users/register", json={
+ "username": "regular_user", "email": "ru@x.com", "password": "regularpass",
+ "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x})
+ r = await client.post("/v1/users/login", json={
+ "username": "regular_user", "password": "regularpass"})
+ token = r.json()["access_token"]
+
+ set_admin_usernames(["someone_else"])
+ r = await client.post("/v1/admin/revoke", json={
+ "target": "user", "target_id": "fake-id", "reason": "test",
+ }, headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 403
+ assert "Admin" in r.json()["detail"]
+
+
+@pytest.mark.asyncio
+async def test_admin_can_revoke(client):
+ """Admin user (in config) can access admin endpoints."""
+ pk_ed_v, pk_x_v, _ = _gen_user_keys()
+ r = await client.post("/v1/users/register", json={
+ "username": "victim_a", "email": "va@x.com", "password": "victimpass9",
+ "pk_user_ed25519": pk_ed_v, "pk_user_x25519": pk_x_v})
+ victim_id = r.json()["user_id"]
+
+ pk_ed_a, pk_x_a, _ = _gen_user_keys()
+ await client.post("/v1/users/register", json={
+ "username": "the_admin", "email": "ta@x.com", "password": "adminpass99",
+ "pk_user_ed25519": pk_ed_a, "pk_user_x25519": pk_x_a})
+ r = await client.post("/v1/users/login", json={
+ "username": "the_admin", "password": "adminpass99"})
+ admin_token = r.json()["access_token"]
+
+ set_admin_usernames(["the_admin"])
+ r = await client.post("/v1/admin/revoke", json={
+ "target": "user", "target_id": victim_id, "reason": "test",
+ }, headers={"Authorization": f"Bearer {admin_token}"})
+ assert r.status_code == 200
+ assert r.json()["status"] == "revoked"
+
+
+# ── Email encryption (8.2) ──────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_email_encrypted_at_rest(client):
+ """Email stored in DB must not contain plaintext address."""
+ from meshbay_hub.auth import encrypt_email, decrypt_email
+ encrypted = encrypt_email("test@example.com")
+ assert "@" not in encrypted
+ assert decrypt_email(encrypted) == "test@example.com"
+
+
+@pytest.mark.asyncio
+async def test_registered_email_not_plaintext(client, app):
+ """Registration stores encrypted email, not plaintext."""
+ pk_ed, pk_x, _ = _gen_user_keys()
+ await client.post("/v1/users/register", json={
+ "username": "email_test", "email": "secret@example.com",
+ "password": "emailpass9",
+ "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x})
+
+ from meshbay_hub.db.engine import get_db
+ from meshbay_hub.db.models import User
+ from sqlalchemy import select
+
+ async for db in get_db():
+ result = await db.execute(select(User).where(User.username == "email_test"))
+ user = result.scalar_one()
+ assert "@" not in user.email
+ from meshbay_hub.auth import decrypt_email
+ assert decrypt_email(user.email) == "secret@example.com"
+ break
+
+
+# ── Argon2id rehash (8.10) ──────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_password_rehash_on_login(client, app):
+ """Users with pw_version=1 get rehashed to current version on login."""
+ from meshbay_hub.auth import current_pw_version
+ from meshbay_hub.db.engine import get_db
+ from meshbay_hub.db.models import User
+ from sqlalchemy import select
+
+ pk_ed, pk_x, _ = _gen_user_keys()
+ await client.post("/v1/users/register", json={
+ "username": "rehash_user", "email": "rh@x.com", "password": "rehashpass9",
+ "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x})
+
+ # Force pw_version to 1 (simulating pre-upgrade user)
+ async for db in get_db():
+ result = await db.execute(select(User).where(User.username == "rehash_user"))
+ user = result.scalar_one()
+ user.pw_version = 1
+ # Re-hash with v1 params so verify_password(version=1) succeeds
+ from meshbay_hub.auth import _ARGON2_VERSIONS, _ARGON2_KEY_LEN, _ARGON2_LANES
+ from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
+ import os
+ salt = os.urandom(16)
+ params = _ARGON2_VERSIONS[1]
+ pw_hash = Argon2id(
+ salt=salt, length=_ARGON2_KEY_LEN, iterations=params["iterations"],
+ lanes=_ARGON2_LANES, memory_cost=params["memory_cost"],
+ ).derive(b"rehashpass9")
+ user.pw_hash = pw_hash
+ user.pw_salt = salt
+ await db.commit()
+ break
+
+ # Login should succeed and trigger rehash
+ r = await client.post("/v1/users/login", json={
+ "username": "rehash_user", "password": "rehashpass9"})
+ assert r.status_code == 200
+
+ # Verify pw_version is now current
+ async for db in get_db():
+ result = await db.execute(select(User).where(User.username == "rehash_user"))
+ user = result.scalar_one()
+ assert user.pw_version == current_pw_version()
+ break
+
+ # Login still works after rehash
+ r = await client.post("/v1/users/login", json={
+ "username": "rehash_user", "password": "rehashpass9"})
+ assert r.status_code == 200
+
+
+# ── IP log cleanup (8.9) ────────────────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_ip_log_cleanup(app):
+ """Old IP log entries are purged by cleanup task."""
+ from datetime import datetime, timezone, timedelta
+ from meshbay_hub.db.engine import get_db
+ from meshbay_hub.db.models import IPLog
+ from meshbay_hub.tasks.cleanup import purge_old_ip_logs
+
+ async for db in get_db():
+ old_ts = datetime.now(timezone.utc) - timedelta(days=400)
+ db.add(IPLog(event="test_old", ip_address="1.2.3.4", timestamp=old_ts))
+ db.add(IPLog(event="test_recent", ip_address="5.6.7.8"))
+ await db.commit()
+
+ deleted = await purge_old_ip_logs(db, retention_days=365)
+ assert deleted == 1
+
+ from sqlalchemy import select, func
+ count = (await db.execute(
+ select(func.count()).where(IPLog.event.in_(["test_old", "test_recent"]))
+ )).scalar_one()
+ assert count == 1
+ break
diff --git a/packages/meshbay-hub/tests/test_moderation.py b/packages/meshbay-hub/tests/test_moderation.py
index 68e08c9..93d23cd 100644
--- a/packages/meshbay-hub/tests/test_moderation.py
+++ b/packages/meshbay-hub/tests/test_moderation.py
@@ -4,6 +4,7 @@ import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from meshbay_common.crypto import pk_to_b64
+from meshbay_hub.api.deps import set_admin_usernames
FAKE_HASH = "a" * 64 # valid blake3 hex
@@ -20,6 +21,7 @@ async def auth_headers(client):
})
r = await client.post("/v1/users/login",
json={"username": "mod_admin", "password": "modpass99"})
+ set_admin_usernames(["mod_admin"])
return {"Authorization": f"Bearer {r.json()['access_token']}"}
diff --git a/packages/meshbay-hub/tests/test_revocation.py b/packages/meshbay-hub/tests/test_revocation.py
index f5d6050..494d77d 100644
--- a/packages/meshbay-hub/tests/test_revocation.py
+++ b/packages/meshbay-hub/tests/test_revocation.py
@@ -7,6 +7,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from meshbay_common.crypto import pk_to_b64
+from meshbay_hub.api.deps import set_admin_usernames
async def _register_and_login(client, username, pk_ed, pk_x):
@@ -45,6 +46,7 @@ async def test_revoke_user_marks_db(client):
pk_to_b64(sk_admin_ed.public_key()),
pk_to_b64(sk_admin_x.public_key()),
)
+ set_admin_usernames(["admin1"])
r = await client.post("/v1/admin/revoke", json={
"target": "user", "target_id": victim_id, "reason": "spam",
@@ -80,6 +82,7 @@ async def test_revocation_token_verifiable_offline(client):
pk_to_b64(sk_admin_ed.public_key()),
pk_to_b64(sk_admin_x.public_key()),
)
+ set_admin_usernames(["admin2"])
r = await client.post("/v1/admin/revoke", json={
"target": "user", "target_id": victim_id, "reason": "test",
@@ -107,6 +110,7 @@ async def test_revoke_group(client):
pk_to_b64(sk_ed.public_key()),
pk_to_b64(sk_x.public_key()),
)
+ set_admin_usernames(["admin3"])
hdrs = {"Authorization": f"Bearer {admin_token}"}
r = await client.post("/v1/groups", json={"name": "grp-to-revoke"}, headers=hdrs)