diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/users.py | 44 |
1 files changed, 44 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 660bd76..72ac68f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -27,7 +27,9 @@ from meshbay_hub.auth import ( hash_password_off_loop, hash_refresh_token, issue_access_token, + open_pepper, pw_needs_rehash, + seal_pepper, verify_password_off_loop, ) from meshbay_hub.config import HubConfig @@ -362,6 +364,39 @@ async def _login_failed(db: AsyncSession, username: str, ip: str, raise HTTPException(status_code=401, detail="Invalid credentials") +# ── Bundle pepper ──────────────────────────────────────────────────────────── +# +# Half of what opens this account's keypair bundles on nodes; the passphrase is +# the other half. Handed out only where the caller proved the passphrase or a +# device key — sign-in, device sign-in, a passphrase change — or already holds a +# session that did (`GET /me/bundle-pepper`). Never on a token refresh, which +# proves only possession of a refresh token; never to a node token; never in a +# token or a log line. + +def _bundle_pepper(user: User) -> dict: + """The pepper for a response, created on first use. The caller commits.""" + if user.bundle_pepper is None: + user.bundle_pepper = seal_pepper(secrets.token_bytes(32), user.id) + user.bundle_pepper_version = user.bundle_pepper_version or 1 + raw = open_pepper(user.bundle_pepper, user.id) + return {"bundle_pepper": base64.b64encode(raw).decode(), + "bundle_pepper_version": user.bundle_pepper_version} + + +@router.get("/me/bundle-pepper") +@limiter.limit("10/minute") +async def get_bundle_pepper( + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """For a session opened before the pepper existed: asked once, then kept + only as part of the key derived from it.""" + pepper = _bundle_pepper(current_user) + await db.commit() + return pepper + + @router.post("/login") @limiter.limit("10/minute") async def login( @@ -445,6 +480,7 @@ async def login( family_id=family_id, expires_at=expires_at, )) db.add(IPLog(user_id=user.id, event="login", ip_address=ip)) + pepper = _bundle_pepper(user) await db.commit() return { @@ -452,6 +488,7 @@ async def login( "refresh_token": raw_rt, "token_type": "bearer", "expires_in": _ttl(), + **pepper, } @@ -626,6 +663,7 @@ async def device_auth( family_id=str(uuid.uuid4()), expires_at=expires_at)) db.add(IPLog(user_id=user.id, event="device_auth", ip_address=client_ip(request))) + pepper = _bundle_pepper(user) await db.commit() return { @@ -634,6 +672,7 @@ async def device_auth( "token_type": "bearer", "expires_in": _ttl(), "device_id": matched.id, + **pepper, } @@ -1045,6 +1084,9 @@ async def change_password( )) db.add(IPLog(user_id=current_user.id, event="password_change", ip_address=client_ip(request))) + # The new passphrase makes a new bundle key, and the client does not keep + # the pepper; this call proved the old passphrase, so it carries it. + pepper = _bundle_pepper(current_user) await db.commit() return { @@ -1053,6 +1095,7 @@ async def change_password( "refresh_token": raw_rt, "token_type": "bearer", "expires_in": _ttl(), + **pepper, } @@ -1442,6 +1485,7 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus user.pw_hash = b"" user.pw_salt = b"" user.pk_node_ed25519 = None + user.bundle_pepper = None user.status = "deleted" user.role = "user" await db.commit() |