summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/users.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/users.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py46
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)