From 768e07046368819b8a8f15c8b21e5a8bbfcdf282 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 18 Aug 2026 03:24:55 +0200 Subject: feat: device linking, and signing in to the hub with a device key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/meshbay-node/src/meshbay_node/config.py | 9 +++++++++ 1 file changed, 9 insertions(+) (limited to 'packages/meshbay-node/src/meshbay_node/config.py') diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index 42ebcfd..c0326f0 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -36,6 +36,8 @@ ui_port = 18000 # local admin UI (127.0.0.1 only) # operator pairing code is typed during the SSH session that printed it. invite_ttl_hours = 168 # 7 days pair_ttl_hours = 24 +# How long a new device may wait for one of your existing devices to approve it. +device_request_ttl_minutes = 60 # How many people may watch a video at once. One ffmpeg runs per viewer for as # long as they watch — it remuxes rather than re-encodes, so it costs little CPU @@ -105,6 +107,10 @@ class NodeConfig: # the SSH session that printed it. invite_ttl_hours: int = 168 # 7 days pair_ttl_hours: int = 24 + # A device-add code is read off one screen and typed into another, in one + # sitting. Comfort rather than security: the code is bound to the requesting + # keys by its hash, so a longer window widens nothing an attacker can use. + device_request_ttl_minutes: int = 60 # How many people may watch a video at the same time. One ffmpeg runs per # viewer for as long as they watch, so this is the knob that decides when # the node answers "server busy" — see MAX_CONCURRENT_TRANSCODES in @@ -252,6 +258,9 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: nd.get("invite_ttl_hours", cfg.node.invite_ttl_hours)) cfg.node.pair_ttl_hours = int( nd.get("pair_ttl_hours", cfg.node.pair_ttl_hours)) + cfg.node.device_request_ttl_minutes = int( + nd.get("device_request_ttl_minutes", + cfg.node.device_request_ttl_minutes)) cfg.node.max_concurrent_streams = _positive( nd.get("max_concurrent_streams", cfg.node.max_concurrent_streams), cfg.node.max_concurrent_streams, "max_concurrent_streams") -- cgit v1.2.3