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 | |
| 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')
9 files changed, 134 insertions, 39 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) diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index c101ec9..72dd123 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -1139,8 +1139,8 @@ function App() { * exactly what a browser does, and is a worse experience rather than a * broken one. */ - const registerThisDevice = useCallback(async (token) => { - if (!platform.device.available) return; + const registerThisDevice = useCallback(async (token, authKey) => { + if (!platform.device.available || !authKey) return; try { const backend = await platform.secrets.backend(); if (backend === 'unavailable') return; @@ -1148,7 +1148,7 @@ function App() { if (!pk) return; await hubFetch('/v1/users/devices', { method: 'POST', token, - body: { pk_auth_ed25519: pk, label: t('device.this_device') }, + body: { pk_auth_ed25519: pk, label: t('device.this_device'), auth_key: authKey }, }); } catch (err) { console.warn('device not registered:', err.message); @@ -1158,11 +1158,12 @@ function App() { const authCtx = { user, login: async (username, password) => { - let token, refreshToken; + let token, refreshToken, authKey; if (window.MeshBayKeys) { const data = await window.MeshBayKeys.loginAndRecover(username, password); token = data.accessToken; refreshToken = data.refreshToken; + authKey = data.authKey; // The only thing sign-in produces: the key that opens a node's bundle. // Which identity we use is decided per node, when we get there. session.bundleKey = data.bundleKey; @@ -1180,7 +1181,7 @@ function App() { // On a desktop build, remember this device so the next launch does not ask // for the passphrase again. The key is generated and held by the main // process; what travels here is only its public half. - await registerThisDevice(token); + await registerThisDevice(token, authKey); // Before the session lands, so the idle watch starting with it does not // read the last-active time of whoever used this browser before. markActive(true); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 4510848..fe858b2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -257,10 +257,11 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, setError(''); try { // Same derivation as sign-in — the token is already ours, only the key - // that opens node bundles is missing here, and the pepper that goes into - // it is asked for with that token. Persisted so this browser is set up - // from now on. - const { pepper, version } = await window.MeshBayKeys.fetchBundlePepper(token); + // that opens node bundles is missing here. The hub hands the pepper that + // goes into it only against the passphrase proof, never the token alone. + // Persisted so this browser is set up from now on. + const authKey = await window.MeshBayKeys.deriveAuthKey(pass, username); + const { pepper, version } = await window.MeshBayKeys.fetchBundlePepper(token, authKey); session.bundleKey = await window.MeshBayKeys.sessionBundleKey( pass, username, userId, pepper, version); await _storeBundleKey(session.bundleKey); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index 9f28b5c..6bd5896 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -195,14 +195,25 @@ async function nodeBundleKey(sessionKey, nodePkB64) { } /** - * GET the pepper for a session that is already open — a stored session from - * before the pepper existed, a passphrase change. Sign-in carries it already. + * The pepper for a session that is already open but lacks the key derived from + * it — a restored session, a passphrase change. Sign-in carries it already. The + * hub asks for the passphrase proof: a token alone never obtains the pepper. */ -async function fetchBundlePepper(token) { +async function fetchBundlePepper(token, authKey) { const resp = await hubCall('/v1/users/me/bundle-pepper', { - headers: { Authorization: `Bearer ${token}` }, + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ auth_key: authKey }), }); - if (!resp.ok) throw new Error(`bundle pepper: ${resp.status}`); + if (!resp.ok) { + // The hub's own words: a wrong passphrase and a locked account are what a + // person can act on, a bare status is not. + let detail = `bundle pepper: ${resp.status}`; + try { const j = await resp.json(); detail = j.detail || j.error || detail; } catch { /* not JSON */ } + const err = new Error(String(detail)); + err.status = resp.status; + throw err; + } const data = await resp.json(); return { pepper: data.bundle_pepper, version: data.bundle_pepper_version }; } @@ -446,6 +457,9 @@ async function loginAndRecover(username, password) { const result = { accessToken: data.access_token, refreshToken: data.refresh_token, + // Kept for the one call that follows a sign-in and must prove the + // passphrase again: registering this device's key. Not stored. + authKey, // The pepper rides on the sign-in response, so this costs no extra call; // it is folded into the key here and not kept. bundleKey: await sessionBundleKey( 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 7c36951..1800d72 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js @@ -147,7 +147,8 @@ export function ProfilePage({ user, onLogout }) { // In the desktop application both are kept by its main process, the new // one set aside until the hub has accepted the change. const K = window.MeshBayKeys; - const { pepper, version } = await K.fetchBundlePepper(user.token); + const oldAuthKey = await K.deriveAuthKey(cpOld, user.username); + const { pepper, version } = await K.fetchBundlePepper(user.token, oldAuthKey); const oldKey = await K.sessionBundleKey( cpOld, user.username, user.userId, pepper, version); const newKey = await K.sessionBundleKey( @@ -160,7 +161,6 @@ export function ProfilePage({ user, onLogout }) { bundleKey: oldKey, newBundleKey: newKey, onProgress: setCpProgress, }); - const oldAuthKey = await window.MeshBayKeys.deriveAuthKey(cpOld, user.username); const newAuthKey = await window.MeshBayKeys.deriveAuthKey(cpNew, user.username); resp = await hubFetch('/v1/users/password', { method: 'POST', token: user.token, diff --git a/packages/meshbay-hub/tests/test_account_deletion.py b/packages/meshbay-hub/tests/test_account_deletion.py index a31aaff..632c133 100644 --- a/packages/meshbay-hub/tests/test_account_deletion.py +++ b/packages/meshbay-hub/tests/test_account_deletion.py @@ -140,7 +140,8 @@ async def test_deletion_clears_device_keys(client, db_session): select(User.id).where(User.username == "devicer_test"))).scalar_one() r = await client.post("/v1/users/devices", headers=headers, - json={"pk_auth_ed25519": _device_pk(), "label": "desktop"}) + json={"pk_auth_ed25519": _device_pk(), "label": "desktop", + "auth_key": _auth_key(password, "devicer_test")}) assert r.status_code == 201, r.text # Present before, or the emptiness asserted below proves nothing. @@ -166,15 +167,19 @@ async def test_a_new_account_can_reuse_the_deleted_accounts_device(client): """ pk = _device_pk() token, password = await _register(client, "firstlife") - r = await client.post("/v1/users/devices", json={"pk_auth_ed25519": pk}, + r = await client.post("/v1/users/devices", + json={"pk_auth_ed25519": pk, + "auth_key": _auth_key(password, "firstlife")}, headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 201, r.text await client.request("DELETE", "/v1/users/me", headers={"Authorization": f"Bearer {token}"}, json={"auth_key": _auth_key(password, "firstlife")}) - token2, _ = await _register(client, "secondlife") - r = await client.post("/v1/users/devices", json={"pk_auth_ed25519": pk}, + token2, password2 = await _register(client, "secondlife") + r = await client.post("/v1/users/devices", + json={"pk_auth_ed25519": pk, + "auth_key": _auth_key(password2, "secondlife")}, headers={"Authorization": f"Bearer {token2}"}) assert r.status_code == 201, r.text diff --git a/packages/meshbay-hub/tests/test_bundle_pepper.py b/packages/meshbay-hub/tests/test_bundle_pepper.py index be9da19..6c9f63d 100644 --- a/packages/meshbay-hub/tests/test_bundle_pepper.py +++ b/packages/meshbay-hub/tests/test_bundle_pepper.py @@ -57,7 +57,8 @@ async def test_a_device_sign_in_gets_it_too(client): login = await _login(client, "pepper_dev") sk = Ed25519PrivateKey.generate() r = await client.post("/v1/users/devices", headers=_bearer(login["access_token"]), - json={"pk_auth_ed25519": pk_to_b64(sk.public_key()), "label": ""}) + json={"pk_auth_ed25519": pk_to_b64(sk.public_key()), "label": "", + "auth_key": KEY}) assert r.status_code == 201 ts = int(time.time()) sig = sk.sign(f"meshbay:user_auth:pepper_dev:{ts}".encode()) @@ -88,23 +89,51 @@ async def test_it_is_in_no_token(client): assert not any("pepper" in k for k in claims) +async def _ask(client, token, auth_key=None): + body = {} if auth_key is None else {"auth_key": auth_key} + return await client.post("/v1/users/me/bundle-pepper", headers=_bearer(token), json=body) + + @pytest.mark.asyncio -async def test_an_open_session_may_ask_and_a_node_may_not(client): +async def test_a_session_asks_with_the_passphrase_and_a_node_may_not(client): from test_node_scope_not_admin import _node_token await _register(client, "pepper_node") login = await _login(client, "pepper_node") - r = await client.get("/v1/users/me/bundle-pepper", headers=_bearer(login["access_token"])) + r = await _ask(client, login["access_token"], KEY) assert r.status_code == 200 assert r.json()["bundle_pepper"] == login["bundle_pepper"] node = await _node_token(client, "pepper_node", login["access_token"]) - r = await client.get("/v1/users/me/bundle-pepper", headers=_bearer(node)) + r = await _ask(client, node, KEY) assert r.status_code == 403 assert login["bundle_pepper"] not in r.text @pytest.mark.asyncio +async def test_a_token_alone_never_gets_it(client): + """ + Not a refreshed one, not the one sign-in handed out: a token is what a page + holds, and a pepper beside the bundles an operator keeps is the offline + passphrase oracle again. Two calls — refresh, then ask — must not add up + to what a refresh is refused. + """ + await _register(client, "pepper_bare") + login = await _login(client, "pepper_bare") + refreshed = (await client.post("/v1/users/token/refresh", + json={"refresh_token": login["refresh_token"]})).json() + + for token in (login["access_token"], refreshed["access_token"]): + bare = await _ask(client, token) + wrong = await _ask(client, token, "w" * 44) + assert bare.status_code == 422 + assert wrong.status_code == 403 + assert login["bundle_pepper"] not in bare.text + wrong.text + assert (await client.get("/v1/users/me/bundle-pepper", + headers=_bearer(login["access_token"]))).status_code in (404, 405) + + +@pytest.mark.asyncio async def test_it_is_sealed_at_rest_and_bound_to_its_account(client, db_session): await _register(client, "pepper_rest") login = await _login(client, "pepper_rest") @@ -157,7 +186,7 @@ async def test_it_is_never_logged(client): try: await _register(client, "pepper_log") login = await _login(client, "pepper_log") - await client.get("/v1/users/me/bundle-pepper", headers=_bearer(login["access_token"])) + await _ask(client, login["access_token"], KEY) finally: root.removeHandler(grab) root.setLevel(saved[0]) diff --git a/packages/meshbay-hub/tests/test_device_auth.py b/packages/meshbay-hub/tests/test_device_auth.py index f9b172e..04012ee 100644 --- a/packages/meshbay-hub/tests/test_device_auth.py +++ b/packages/meshbay-hub/tests/test_device_auth.py @@ -46,10 +46,11 @@ async def _account(client, username="alice_test") -> str: return resp.json()["access_token"] -async def _register_device(client, token: str, pk: str, label: str = ""): +async def _register_device(client, token: str, pk: str, label: str = "", + auth_key: str = "k" * 44): return await client.post( "/v1/users/devices", - json={"pk_auth_ed25519": pk, "label": label}, + json={"pk_auth_ed25519": pk, "label": label, "auth_key": auth_key}, headers={"Authorization": f"Bearer {token}"}) @@ -275,3 +276,22 @@ async def test_the_hub_states_a_minimum_client_version(client): assert resp.status_code == 200 client_floor = resp.json()["client"] assert client_floor["minimum"] and client_floor["recommended"] + + +async def test_a_session_alone_cannot_register_a_device(client): + """ + A registered key signs in with no passphrase for as long as it stays, and + each of those sign-ins carries the bundle pepper. So a token — refreshed, + or lifted from a page — must not be enough to add one: the passphrase is. + """ + token = await _account(client) + sk, pk = _device() + + bare = await client.post("/v1/users/devices", json={"pk_auth_ed25519": pk}, + headers={"Authorization": f"Bearer {token}"}) + wrong = await _register_device(client, token, pk, auth_key="w" * 44) + + assert bare.status_code == 422 + assert wrong.status_code == 403 + assert (await client.post("/v1/users/auth", + json=_sign(sk, "alice_test"))).status_code == 401 diff --git a/packages/meshbay-hub/tests/test_password_reset.py b/packages/meshbay-hub/tests/test_password_reset.py index b07f77c..f285c1d 100644 --- a/packages/meshbay-hub/tests/test_password_reset.py +++ b/packages/meshbay-hub/tests/test_password_reset.py @@ -159,7 +159,8 @@ async def test_reset_revokes_sessions_and_wipes_devices(client, db_session): sk = Ed25519PrivateKey.generate() dev = await client.post( "/v1/users/devices", - json={"pk_auth_ed25519": pk_to_b64(sk.public_key()), "label": "laptop"}, + json={"pk_auth_ed25519": pk_to_b64(sk.public_key()), "label": "laptop", + "auth_key": "erin_test" + "a" * 40}, headers={"Authorization": f"Bearer {token}"}) assert dev.status_code == 201, dev.text |