aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js4
-rw-r--r--packages/meshbay-hub/tests/test_account_deletion.py13
-rw-r--r--packages/meshbay-hub/tests/test_bundle_pepper.py39
-rw-r--r--packages/meshbay-hub/tests/test_device_auth.py24
-rw-r--r--packages/meshbay-hub/tests/test_password_reset.py3
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