diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-18 03:24:55 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-18 03:24:55 +0200 |
| commit | 768e07046368819b8a8f15c8b21e5a8bbfcdf282 (patch) | |
| tree | fba4fa5f85e3963b2281004b503be05f552aff2c /packages/meshbay-node/tests/test_roster_pairing.py | |
| parent | e9d5e979fdab9a1cc3c729d602e6f27207b9480c (diff) | |
| download | meshbay-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-node/tests/test_roster_pairing.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_roster_pairing.py | 36 |
1 files changed, 32 insertions, 4 deletions
diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index 9e45dbc..61d53ac 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -247,10 +247,16 @@ async def test_join_cannot_be_replayed_onto_another_connection(tmp_path, roster) assert await roster.get_identity("grenet") is None -async def test_pinned_identity_presenting_a_new_key_is_refused(tmp_path, roster): +async def test_a_key_this_node_never_pinned_is_refused(tmp_path, roster): """ - 11.5.8's rule, applied to people: a changed key is refused outright rather - than warned about, and clearing it is a deliberate operator action. + 11.5.8's rule, applied to people: an unrecognised key does not get in, and + a code cannot talk its way past that. + + What changed with device linking (2026-08-18) is the way back, not the + refusal. This used to be `key_changed` and needed an operator to unpin; now + it is `unknown_device` and the person approves the new key from a device + already paired here. Nothing is pinned either way, which is the part that + matters. """ session = _session(tmp_path, roster) _, old_pk_ed, old_pk_x = _keypair() @@ -260,8 +266,30 @@ async def test_pinned_identity_presenting_a_new_key_is_refused(tmp_path, roster) await session._do_join_request( _join_msg(session, sk_ed2, new_pk_ed, new_pk_x, code="ANY-CODE")) + assert _last(session).get("reason") == "unknown_device" + assert await roster.find_device("grenet", new_pk_ed) is None + assert [d["pk_ed25519"] for d in await roster.list_devices("grenet")] == \ + [old_pk_ed] + + +async def test_a_pinned_key_arriving_with_a_different_x25519_is_refused( + tmp_path, roster): + """ + The join transcript signs both keys together, so a pinned Ed25519 key + presenting a different encryption key is either a client that regenerated + half its identity or two messages spliced. Either way the pair is not the + one admitted, and the group key must not be wrapped for it. + """ + session = _session(tmp_path, roster) + sk_ed, pk_ed, pk_x = _keypair() + await roster.pin_identity("grenet", "grenet", pk_ed, pk_x, "code") + + _, _, other_pk_x = _keypair() + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed, other_pk_x, code="ANY-CODE")) + assert _last(session).get("reason") == "key_changed" - assert (await roster.get_identity("grenet"))["pk_ed25519"] == old_pk_ed + assert (await roster.find_device("grenet", pk_ed))["pk_x25519"] == pk_x async def test_attempts_are_bounded(tmp_path, roster): |