summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_device_auth.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 03:24:55 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 03:24:55 +0200
commit768e07046368819b8a8f15c8b21e5a8bbfcdf282 (patch)
treefba4fa5f85e3963b2281004b503be05f552aff2c /packages/meshbay-hub/tests/test_device_auth.py
parente9d5e979fdab9a1cc3c729d602e6f27207b9480c (diff)
downloadmeshbay-768e07046368819b8a8f15c8b21e5a8bbfcdf282.tar.gz
feat: device linking, and signing in to the hub with a device key
Stage C. Identity keys are per node, so a browser and a desktop client are two keys on one account there — and the node refused the second where it accepted the first. Without this, an account created natively could never be opened in a browser without an operator code per node, and "a native client must not prevent web use" would have been dead on arrival. Device linking (node) --------------------- `identities` is keyed by `(user_id, pk_ed25519)` instead of `user_id` alone. The old shape did `INSERT OR REPLACE`, so a second device overwrote the first silently; SQLite cannot change a primary key in place, so the table is rebuilt. Existing pins are carried over — verified against a live roster with 10 of them, nobody re-pairs. A new device files a request bound by `sha256(code ‖ its own keys)`, and a key the node **already pinned** countersigns it. The hub cannot: it has stored no user keys since 2026-08-14, which is what makes this safe to do without an operator in the loop. **The code never reaches the node.** It lists this account's pending requests with their stored hashes; the approver recomputes and keeps the match. A node offering fabricated keys would have to produce a hash over a code it has never seen. Nothing rests on a human comparing digits — that ritual was dropped in 12.1 as "correct, unusable as the default" and must not return by the back door. The design document had the approver look a request up *by* its hash, which is circular: computing it needs the keys being asked about. Corrected in both. Revocation marks rather than deletes, because a deleted row is a key the node would happily pin again — which is the laptop somebody just reported lost. Your last device cannot be revoked: coming back would need an operator's code. Hub — the only change in the whole plan --------------------------------------- `POST /v1/users/auth` signs in with a device Ed25519 key, on the same pattern as `/v1/nodes/auth`, plus `/v1/users/devices` to register, list and retire. New `user_devices` table with an Alembic migration, because `create_all()` is not one. This is **not** the key directory that was H3, and the tests say so: nothing reads it but the hub, no group key is ever wrapped for one, and it is a different key from the per-node identities. What it does cost is metadata — the hub now knows how many devices an account has and when each last signed in. Also `client.minimum` / `client.recommended` in `GET /v1/hub/version`: an installed client meets a newer hub the day the interface ships in a package, and that is cheap now and awkward to retrofit. Browser ------- The `key_changed` refusal becomes `unknown_device` and offers a linking code instead of telling someone to find their operator. The Members panel lists this account's devices here, approves one by code, and retires one. 773 tests pass. `e2e.py` gained a step that links a device end to end against the live deployment — file, list, recompute, countersign, then open the group with the new keys and no code — and it also gained `recv_type`, because a step that assumes the next message is its own answer reads an ack left by the step before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_device_auth.py')
-rw-r--r--packages/meshbay-hub/tests/test_device_auth.py279
1 files changed, 279 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_device_auth.py b/packages/meshbay-hub/tests/test_device_auth.py
new file mode 100644
index 0000000..752a0e6
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_device_auth.py
@@ -0,0 +1,279 @@
+"""
+Signing in to the hub with a device key.
+
+The passphrase stays the account's credential and its only recovery path; this
+is the day-to-day path once a device is registered, so a client does not derive
+a key from the passphrase on every sign-in.
+
+The thing to be careful about, and the reason these tests are written as
+refusals: this looks like the key directory that was **H3**, and must not become
+one. What keeps it apart —
+
+ * nothing reads these keys but the hub itself, and no endpoint publishes them;
+ * no group key is ever wrapped for one;
+ * they are **not** the per-node identity keys, which are generated per node,
+ pinned there, and never leave that relationship.
+
+`test_the_hub_publishes_no_device_keys` is the one that would notice if that
+stopped being true.
+"""
+
+import base64
+import time
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.crypto import pk_to_b64
+
+
+def _device():
+ sk = Ed25519PrivateKey.generate()
+ return sk, pk_to_b64(sk.public_key())
+
+
+def _sign(sk, username: str, ts: int | None = None) -> dict:
+ ts = int(time.time()) if ts is None else ts
+ message = f"meshbay:user_auth:{username}:{ts}".encode()
+ return {"username": username, "timestamp": ts,
+ "signature": base64.b64encode(sk.sign(message)).decode()}
+
+
+async def _account(client, username="alice") -> str:
+ await client.post("/v1/users/register", json={
+ "username": username, "auth_key": "k" * 44,
+ "email": f"{username}@example.invalid"})
+ resp = await client.post("/v1/users/login", json={
+ "username": username, "auth_key": "k" * 44})
+ return resp.json()["access_token"]
+
+
+async def _register_device(client, token: str, pk: str, label: str = ""):
+ return await client.post(
+ "/v1/users/devices",
+ json={"pk_auth_ed25519": pk, "label": label},
+ headers={"Authorization": f"Bearer {token}"})
+
+
+# ── The path that must work ──────────────────────────────────────────────────
+
+async def test_a_registered_device_signs_in(client):
+ token = await _account(client)
+ sk, pk = _device()
+ assert (await _register_device(client, token, pk, "laptop")).status_code == 201
+
+ resp = await client.post("/v1/users/auth", json=_sign(sk, "alice"))
+
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ assert body["access_token"] and body["refresh_token"]
+ assert body["token_type"] == "bearer"
+
+
+async def test_the_session_it_returns_is_a_real_one(client):
+ """A device sign-in must produce a token that works, not a special case."""
+ token = await _account(client)
+ sk, pk = _device()
+ await _register_device(client, token, pk)
+
+ device_token = (await client.post(
+ "/v1/users/auth", json=_sign(sk, "alice"))).json()["access_token"]
+ me = await client.get("/v1/users/me",
+ headers={"Authorization": f"Bearer {device_token}"})
+
+ assert me.status_code == 200
+ assert me.json()["username"] == "alice"
+
+
+async def test_several_devices_on_one_account(client):
+ """The whole point: a browser and a desktop client are both this person."""
+ token = await _account(client)
+ sk_a, pk_a = _device()
+ sk_b, pk_b = _device()
+ await _register_device(client, token, pk_a, "browser")
+ await _register_device(client, token, pk_b, "desktop")
+
+ for sk in (sk_a, sk_b):
+ assert (await client.post("/v1/users/auth",
+ json=_sign(sk, "alice"))).status_code == 200
+
+ listed = await client.get("/v1/users/devices",
+ headers={"Authorization": f"Bearer {token}"})
+ assert {d["label"] for d in listed.json()["devices"]} == {"browser", "desktop"}
+
+
+# ── What must not work ───────────────────────────────────────────────────────
+
+async def test_an_unregistered_key_is_refused(client):
+ await _account(client)
+ sk, _ = _device()
+
+ resp = await client.post("/v1/users/auth", json=_sign(sk, "alice"))
+
+ assert resp.status_code == 401
+
+
+async def test_another_accounts_device_cannot_sign_in_as_you(client):
+ token_a = await _account(client, "alice")
+ await _account(client, "bob")
+ sk, pk = _device()
+ await _register_device(client, token_a, pk)
+
+ # Alice's device, Bob's name. The signature covers the username, so it does
+ # not verify — and even if it did, the key is not on Bob's account.
+ resp = await client.post("/v1/users/auth", json=_sign(sk, "bob"))
+
+ assert resp.status_code == 401
+
+
+async def test_a_stale_signature_is_refused(client):
+ """The window is what stops a captured signature being replayed later."""
+ token = await _account(client)
+ sk, pk = _device()
+ await _register_device(client, token, pk)
+
+ old = int(time.time()) - 3600
+ resp = await client.post("/v1/users/auth", json=_sign(sk, "alice", ts=old))
+
+ assert resp.status_code == 401
+ assert "timestamp" in resp.json()["detail"].lower()
+
+
+async def test_a_signature_for_a_different_timestamp_does_not_verify(client):
+ token = await _account(client)
+ sk, pk = _device()
+ await _register_device(client, token, pk)
+
+ signed = _sign(sk, "alice")
+ signed["timestamp"] = signed["timestamp"] + 1 # inside the window, wrong
+
+ assert (await client.post("/v1/users/auth", json=signed)).status_code == 401
+
+
+async def test_a_device_cannot_enrol_itself(client):
+ """Registration needs an existing session, which means the passphrase was
+ entered a moment ago. Otherwise anyone could add a key to any account."""
+ await _account(client)
+ _, pk = _device()
+
+ resp = await client.post("/v1/users/devices",
+ json={"pk_auth_ed25519": pk, "label": "sneaky"})
+
+ # 422 rather than 401: with no Authorization header at all, FastAPI refuses
+ # at dependency resolution before the handler runs. A refusal either way —
+ # what matters is that nothing was created.
+ assert resp.status_code in (401, 403, 422)
+ signed_in = await client.post("/v1/users/auth", json=_sign(
+ Ed25519PrivateKey.generate(), "alice"))
+ assert signed_in.status_code == 401
+
+
+async def test_one_key_belongs_to_one_account(client):
+ """Sharing it would make "who signed in" a question with two answers."""
+ token_a = await _account(client, "alice")
+ token_b = await _account(client, "bob")
+ _, pk = _device()
+ await _register_device(client, token_a, pk)
+
+ resp = await _register_device(client, token_b, pk)
+
+ assert resp.status_code == 409
+
+
+async def test_a_suspended_account_cannot_sign_in_with_a_device(client):
+ token = await _account(client)
+ sk, pk = _device()
+ await _register_device(client, token, pk)
+
+ from meshbay_hub.db.engine import get_session_factory
+ from meshbay_hub.db.models import User
+ from sqlalchemy import update
+ async with get_session_factory()() as s:
+ await s.execute(update(User).where(User.username == "alice")
+ .values(status="suspended"))
+ await s.commit()
+
+ resp = await client.post("/v1/users/auth", json=_sign(sk, "alice"))
+ assert resp.status_code == 403
+
+
+async def test_garbage_is_not_a_key(client):
+ token = await _account(client)
+ resp = await _register_device(client, token, "not-base64-at-all!!")
+ assert resp.status_code == 400
+
+
+# ── Not a key directory ──────────────────────────────────────────────────────
+
+async def test_the_hub_publishes_no_device_keys(client):
+ """
+ **H3 is what this is guarding.** The hub used to publish user public keys
+ and the invite path wrapped the group key for whatever came back. Device
+ auth keys must stay invisible to everyone but the hub: no endpoint returns
+ another account's, and `/pubkeys` must not grow one.
+ """
+ token = await _account(client, "alice")
+ _, pk = _device()
+ await _register_device(client, token, pk)
+
+ # `/pubkeys` is itself behind a session — it is an account lookup for
+ # invitations, not a public directory — so ask it as a signed-in member.
+ public = await client.get("/v1/users/alice/pubkeys",
+ headers={"Authorization": f"Bearer {token}"})
+ assert public.status_code == 200
+ body = public.text
+ assert pk not in body, "a device key is reachable through the public lookup"
+ assert "pk_auth" not in body
+
+
+async def test_you_cannot_read_another_accounts_devices(client):
+ token_a = await _account(client, "alice")
+ token_b = await _account(client, "bob")
+ _, pk = _device()
+ await _register_device(client, token_a, pk, "alice-laptop")
+
+ listed = await client.get("/v1/users/devices",
+ headers={"Authorization": f"Bearer {token_b}"})
+
+ assert listed.status_code == 200
+ assert listed.json()["devices"] == []
+
+
+async def test_removing_a_device_stops_it_signing_in(client):
+ token = await _account(client)
+ sk, pk = _device()
+ created = await _register_device(client, token, pk)
+ device_id = created.json()["id"]
+
+ gone = await client.delete(f"/v1/users/devices/{device_id}",
+ headers={"Authorization": f"Bearer {token}"})
+ assert gone.status_code == 200
+
+ assert (await client.post("/v1/users/auth",
+ json=_sign(sk, "alice"))).status_code == 401
+
+
+async def test_you_cannot_remove_someone_elses_device(client):
+ token_a = await _account(client, "alice")
+ token_b = await _account(client, "bob")
+ _, pk = _device()
+ device_id = (await _register_device(client, token_a, pk)).json()["id"]
+
+ resp = await client.delete(f"/v1/users/devices/{device_id}",
+ headers={"Authorization": f"Bearer {token_b}"})
+
+ assert resp.status_code == 404
+
+
+# ── Version floor (C3) ───────────────────────────────────────────────────────
+
+async def test_the_hub_states_a_minimum_client_version(client):
+ """
+ An installed client meets a newer hub for the first time once the interface
+ ships in a package. Cheap to add now, awkward to retrofit.
+ """
+ resp = await client.get("/v1/hub/version")
+
+ assert resp.status_code == 200
+ client_floor = resp.json()["client"]
+ assert client_floor["minimum"] and client_floor["recommended"]