diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-30 21:04:39 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-30 21:04:39 +0200 |
| commit | 0ed56d3a1b4f71cf622d3e27edc87a15ef33c185 (patch) | |
| tree | d53579b0791112483560517399736388733df305 /packages/meshbay-hub/src/meshbay_hub/api/users.py | |
| parent | d692db441680eef8969573047cf5da00cfb61362 (diff) | |
| download | meshbay-0ed56d3a1b4f71cf622d3e27edc87a15ef33c185.tar.gz | |
fix(hub): the pepper and a device key take the passphrase, not a token
POST /me/bundle-pepper (was GET) and POST /users/devices require auth_key.
A refreshed or lifted token could otherwise fetch the pepper, or register a
device whose every sign-in carries it. Both callers have just been given the
passphrase.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/users.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/users.py | 46 |
1 files changed, 35 insertions, 11 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 92b3d3d..8e780df 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -351,6 +351,20 @@ async def _take_login_attempt(db: AsyncSession, username: str) -> None: headers={"Retry-After": str(retry_after)}) +async def _prove_passphrase(db: AsyncSession, user: User, auth_key: str) -> None: + """Refuse with 403 unless `auth_key` is this account's, spending an attempt. + + For what a live access token must not be enough for: a token may be a + refreshed one, or one lifted from a page, and what it would buy here outlives + the session or reopens the offline search the pepper exists to prevent. + """ + await _take_login_attempt(db, user.username) + if not await verify_password_off_loop(auth_key, user.pw_hash, user.pw_salt, + user.pw_version): + raise HTTPException(status_code=403, detail="Passphrase does not match") + await login_throttle.clear(db, user.username) + + 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.""" @@ -367,12 +381,12 @@ async def _login_failed(db: AsyncSession, username: str, ip: str, # ── 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 — or already holds a session that did -# (`GET /me/bundle-pepper`, which a passphrase change uses: it re-seals every -# bundle before the hub is asked to accept the new passphrase). 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. +# the other half. Handed out only in the response to a call that proved the +# passphrase or a device key: sign-in, device sign-in, a passphrase change, and +# `POST /me/bundle-pepper` with the passphrase (a page that has a session but +# not the key derived from it). A token alone never obtains it — not a refreshed +# one, not a node's — because a bundle plus the pepper is an offline oracle for +# the passphrase again. 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.""" @@ -384,15 +398,20 @@ def _bundle_pepper(user: User) -> dict: "bundle_pepper_version": user.bundle_pepper_version} -@router.get("/me/bundle-pepper") +class PepperRequest(BaseModel): + auth_key: str + + +@router.post("/me/bundle-pepper") @limiter.limit("10/minute") async def get_bundle_pepper( + body: PepperRequest, 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.""" + """The pepper, for a session that has just been given the passphrase again.""" + await _prove_passphrase(db, current_user, body.auth_key) pepper = _bundle_pepper(current_user) await db.commit() return pepper @@ -511,6 +530,7 @@ DEVICE_AUTH_TIMESTAMP_WINDOW = 60 # seconds, as for node auth class DeviceRegisterRequest(BaseModel): pk_auth_ed25519: str # base64 raw 32 bytes label: str = "" + auth_key: str # the passphrase proof, as at sign-in class DeviceAuthRequest(BaseModel): @@ -528,9 +548,13 @@ async def register_device( """ Register a device's hub authentication key. - Requires an existing session, which in practice means the passphrase was - entered on this device a moment ago. A device cannot enrol itself. + Requires the passphrase, not only a session. A registered key signs in + without it for as long as it stays registered, and each such sign-in carries + the bundle pepper, so a bare token (refreshed, or lifted from a page) would + otherwise turn into a permanent credential. The caller has just typed the + passphrase to sign in, so it has the proof at hand. """ + await _prove_passphrase(db, current_user, body.auth_key) try: raw = base64.b64decode(body.pk_auth_ed25519) Ed25519PublicKey.from_public_bytes(raw) |