summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md15
-rw-r--r--docs/desktop-client-v1.md16
-rw-r--r--packages/meshbay-common/src/meshbay_common/device.py125
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/hub.py23
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py184
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e5a2b7d31f88_user_device_auth_keys.py47
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py35
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js99
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js67
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js123
-rw-r--r--packages/meshbay-hub/tests/test_device_auth.py279
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py9
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py257
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py329
-rw-r--r--packages/meshbay-node/tests/test_device_linking.py414
-rw-r--r--packages/meshbay-node/tests/test_roster_pairing.py36
28 files changed, 2173 insertions, 29 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index fcdb28a..7a944af 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -323,6 +323,21 @@ anything that assumes one key per person.
`os.kill` — the first version of that test SIGHUPed the developer's own
running node
+- **Device linking is live (2026-08-18).** `identities` is keyed by
+ `(user_id, pk_ed25519)`, so one account holds several devices on a node; the
+ migration rebuilds the table and preserves existing pins. A new device files a
+ request bound by `sha256(code ‖ its own keys)`, and a key the node already
+ pinned countersigns it — the hub holds no user keys and so cannot. **The code
+ never reaches the node**: it lists pending requests with their hashes and the
+ approver recomputes to find the match, which is what makes a substituted key
+ impossible rather than merely detectable. `member unpin` still removes every
+ device; `revoke_device` marks one, because a deleted row is a key the node
+ would happily pin again
+- **`user_devices` on the hub is not the key directory that was H3.** Nothing
+ reads it but the hub, nothing wraps a group key for it, 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
+
## Two lessons that cost four rounds of live testing
- **`QE/deploy/e2e.py` cannot test `app.js`.** It is a second implementation of the
diff --git a/docs/desktop-client-v1.md b/docs/desktop-client-v1.md
index b167525..491af65 100644
--- a/docs/desktop-client-v1.md
+++ b/docs/desktop-client-v1.md
@@ -1000,10 +1000,18 @@ refactoring the same six commands twice.
| # | Component | Prio |
|---|---|---|
-| C1 | **Device linking** — `identities` schema, `device_requests`, the two transcripts, operator surface (§4) | 1 |
-| C2 | **`POST /v1/users/auth`** — device Ed25519 authentication (§5). *The only hub change in the whole plan* | 1 |
-| C3 | **Minimum client version** in `GET /v1/hub/version` (§2.6, O8) | 1 |
-| C4 | Device management in the SPA — list, approve, revoke | 1 |
+| C1 | ✅ **DONE 2026-08-18** — `identities` keyed by (user_id, pk_ed25519) with a rebuild migration that preserves existing pins, `device_requests`, both transcripts in `meshbay_common/device.py`, handlers and audit events | 1 |
+| C2 | ✅ **DONE 2026-08-18** — `POST /v1/users/auth`, plus `/v1/users/devices` to register, list and retire. New `user_devices` table with an Alembic migration. *The only hub change in the whole plan* | 1 |
+| C3 | ✅ **DONE 2026-08-18** — `client.minimum` and `client.recommended` in `GET /v1/hub/version` | 1 |
+| C4 | ✅ **DONE 2026-08-18** — the `unknown_device` refusal offers a linking code; the Members panel lists devices, approves by code and retires one | 1 |
+
+**One correction the implementation forced.** §4.3 has the approver look a request
+up by its hash — which is circular, because computing that hash needs the keys
+being asked about. What shipped: the node lists this account's pending requests
+**with their stored hashes**, and the client recomputes `sha256(code ‖ keys)` for
+each and keeps the match. The code never reaches the node, which is what makes
+substitution impossible: a node offering fabricated keys would have to produce a
+hash over a code it has never seen.
C1 lands in the SPA first, where both ends of a link can be exercised without a desktop
build existing.
diff --git a/packages/meshbay-common/src/meshbay_common/device.py b/packages/meshbay-common/src/meshbay_common/device.py
new file mode 100644
index 0000000..8951dbb
--- /dev/null
+++ b/packages/meshbay-common/src/meshbay_common/device.py
@@ -0,0 +1,125 @@
+"""
+Device linking transcripts (MNP).
+
+Identity keys are per node, so a person who uses a browser and a desktop client
+holds two keys on the same node. The node has to admit the second without
+asking an operator for a code every time — and without letting the hub, or
+itself, decide which key belongs to whom.
+
+The authority is **a key the node already pinned**. An existing device
+countersigns the new one, which the hub cannot do: it has stored no user keys
+since 2026-08-14, so device linking adds nothing a hub can reach.
+
+The approval is bound by a **one-time code the new device generates and
+displays**, hashed together with its own keys:
+
+ code_hash = sha256(code ‖ pk_ed25519 ‖ pk_x25519)
+
+That binding is the part worth understanding. The approver types the code, is
+handed candidate keys, and recomputes the hash — so a node that returned
+different keys produces no match and the client refuses before signing. Nothing
+here rests on a human comparing digits, which is the ritual Phase 12.1 dropped
+as "correct, unusable as the default"; reintroducing it through the back door
+would be the same mistake.
+
+Fields are length-prefixed and domain-separated, per L4 — the same rule as
+`handshake.py`, `join.py` and `adminop.py`. `nonce_node` is the handshake nonce
+of the connection carrying the message, so neither signature can be lifted onto
+another connection, and `node_pk` binds an authorization to one node.
+
+See `docs/desktop-client-v1.md` §4.
+"""
+
+from __future__ import annotations
+
+import hashlib
+
+DEVICE_REQUEST_PREFIX = b"meshbay:device_req:v1"
+DEVICE_ADD_PREFIX = b"meshbay:device_add:v1"
+
+# Same as the join and admin transcripts: interactive exchanges that complete in
+# milliseconds, so anything older is a replay.
+DEVICE_TTL = 120 # seconds
+
+# 40 bits, single use, and bound to the keys it was generated beside. Guessing is
+# bounded the same way an invitation is — a handful of attempts per connection
+# and a node-wide lockout.
+DEVICE_CODE_BITS = 40
+
+
+def device_code_hash(code: str, pk_ed25519_b64: str, pk_x25519_b64: str) -> str:
+ """
+ The lookup key for a pending device request.
+
+ Both keys go in, so the hash identifies *this device asking with this code*
+ rather than *this code*. A node cannot answer an approver with a substituted
+ key: the approver recomputes this from what it typed and what it was given,
+ and looks the request up by the result.
+
+ Normalized the same way pairing codes are (`roster.normalize_code`), which
+ is applied by the caller — this function hashes exactly what it is given, so
+ both ends have to agree on the normalized form and neither can quietly
+ differ.
+ """
+ payload = "\x1f".join((code, pk_ed25519_b64, pk_x25519_b64))
+ return hashlib.sha256(payload.encode()).hexdigest()
+
+
+def _pack(prefix: bytes, fields: list[bytes]) -> bytes:
+ out = bytearray(prefix)
+ for field in fields:
+ out += len(field).to_bytes(4, "big")
+ out += field
+ return bytes(out)
+
+
+def device_request_transcript(
+ node_pk_b64: str,
+ user_id: str,
+ pk_ed25519_b64: str,
+ pk_x25519_b64: str,
+ code_hash: str,
+ nonce_node: bytes,
+ ts: int,
+) -> bytes:
+ """
+ Signed by the **new** device, proving it holds the keys it is presenting.
+
+ Proof of possession only: this establishes nothing about whose account the
+ keys belong to. That is what the countersignature below is for.
+ """
+ return _pack(DEVICE_REQUEST_PREFIX, [
+ node_pk_b64.encode(),
+ user_id.encode(),
+ pk_ed25519_b64.encode(),
+ pk_x25519_b64.encode(),
+ code_hash.encode(),
+ nonce_node,
+ str(ts).encode(),
+ ])
+
+
+def device_add_transcript(
+ node_pk_b64: str,
+ user_id: str,
+ pk_ed25519_b64: str,
+ pk_x25519_b64: str,
+ nonce_node: bytes,
+ ts: int,
+) -> bytes:
+ """
+ Signed by an **already-pinned** device, admitting the new keys.
+
+ Deliberately does not include the code: the code is a bearer secret used to
+ find the request, never signed and never echoed. What is signed is the pair
+ of keys being admitted, so a signature collected for one device cannot admit
+ another.
+ """
+ return _pack(DEVICE_ADD_PREFIX, [
+ node_pk_b64.encode(),
+ user_id.encode(),
+ pk_ed25519_b64.encode(),
+ pk_x25519_b64.encode(),
+ nonce_node,
+ str(ts).encode(),
+ ])
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index a1c971f..bbdff63 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -78,6 +78,18 @@ class MNP:
MEMBER_REVOKE_ACK = "member_revoke_ack"
MEMBER_UNPIN = "member_unpin" # operator → node: forget an identity
MEMBER_UNPIN_ACK = "member_unpin_ack"
+ # Device linking. A new device files a request bound to a code it displays;
+ # an already-pinned device of the same account approves it. Neither the hub
+ # nor the node can produce the countersignature.
+ DEVICE_REQUEST = "device_add_request" # new device → node
+ DEVICE_REQUEST_ACK = "device_add_request_ack"
+ DEVICE_LOOKUP = "device_lookup" # approver → node: find by code
+ DEVICE_LOOKUP_RESULT = "device_lookup_result"
+ DEVICE_ADD = "device_add" # approver → node: countersigned
+ DEVICE_ADD_ACK = "device_add_ack"
+ DEVICE_LIST = "device_list" # anyone → node: my devices
+ DEVICE_LIST_RESULT = "device_list_result"
+ DEVICE_REVOKE = "device_revoke" # a device retires another
# Rotation is the half of revocation that revocation cannot do: the node
# generates a fresh key itself, so no key material crosses the wire.
GEK_ROTATE = "gek_rotate" # operator → node: new group key
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
index 6e2db9e..4aef93d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
@@ -26,6 +26,22 @@ async def hub_pubkey():
return {"pk_hub_pem": hub_public_key_pem().decode()}
+# What an installed client must be, to talk to this hub.
+#
+# Until a client ships, the SPA and the hub deploy together and are always in
+# sync: a /v1/ response shape changes and app.js is fixed in the same commit.
+# The moment the interface is installed rather than served, an old client meets
+# a new hub — for the first time in this project's life — and there is no way to
+# fix it from here.
+#
+# `minimum` refuses; `recommended` warns. Both are stated so a client can tell a
+# user "update to keep using this" before it becomes "this stopped working".
+# Raise `minimum` only for a change a client genuinely cannot survive, and
+# remember store review latency makes that expensive on Android.
+MIN_CLIENT_VERSION = "0.1.0"
+RECOMMENDED_CLIENT_VERSION = "0.1.0"
+
+
@router.get("/version")
async def hub_version():
"""Version check endpoint for clients to detect updates."""
@@ -33,4 +49,11 @@ async def hub_version():
"hub": __version__,
"mnp": MNP_VERSION,
"mhp": MHP_VERSION,
+ # A client compares its own version against these before doing anything
+ # else. The browser SPA always matches the hub by construction and can
+ # ignore them.
+ "client": {
+ "minimum": MIN_CLIENT_VERSION,
+ "recommended": RECOMMENDED_CLIENT_VERSION,
+ },
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 0bfbcac..b5fa205 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -1,10 +1,12 @@
"""User endpoints — /v1/users/*"""
import base64
+import time
import logging
import uuid
from datetime import datetime, timezone, timedelta
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, field_validator
from sqlalchemy import delete, select, update
@@ -27,7 +29,7 @@ from meshbay_hub.api.netutil import client_ip
from meshbay_hub.config import HubConfig
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import (
- Group, GroupMember, IPLog, Node, Notification, RefreshToken, User,
+ Group, GroupMember, IPLog, Node, Notification, RefreshToken, User, UserDevice,
)
from meshbay_hub.api.deps import get_current_user, require_user_scope
@@ -232,6 +234,186 @@ async def login(
}
+# ── Device authentication ────────────────────────────────────────────────────
+#
+# A device signs in with an Ed25519 key instead of re-deriving one from the
+# passphrase every time. The passphrase remains the account's credential and its
+# only recovery path; this is the day-to-day path once a device is registered.
+#
+# This is **not** the key directory that was H3, and the difference matters:
+# nothing reads these but the hub, no group key is ever wrapped for one, and it
+# is a different key from the per-node identity keys, which never leave the
+# device-node relationship. What it does cost is metadata — the hub now knows
+# how many devices an account has and when each last signed in.
+
+DEVICE_AUTH_TIMESTAMP_WINDOW = 60 # seconds, as for node auth
+
+
+class DeviceRegisterRequest(BaseModel):
+ pk_auth_ed25519: str # base64 raw 32 bytes
+ label: str = ""
+
+
+class DeviceAuthRequest(BaseModel):
+ username: str
+ timestamp: int # unix epoch seconds
+ signature: str # base64 Ed25519 over the message below
+
+
+@router.post("/devices", status_code=201)
+async def register_device(
+ body: DeviceRegisterRequest,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ 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.
+ """
+ try:
+ raw = base64.b64decode(body.pk_auth_ed25519)
+ Ed25519PublicKey.from_public_bytes(raw)
+ except Exception:
+ raise HTTPException(status_code=400, detail="Invalid Ed25519 public key")
+
+ existing = await db.execute(
+ select(UserDevice).where(
+ UserDevice.pk_auth_ed25519 == body.pk_auth_ed25519))
+ found = existing.scalar_one_or_none()
+ if found:
+ if found.user_id != current_user.id:
+ # One key, one account. Sharing it would make "who signed in" a
+ # question with two answers.
+ raise HTTPException(status_code=409,
+ detail="That key belongs to another account")
+ return {"id": found.id, "label": found.label, "existing": True}
+
+ count = await db.execute(
+ select(UserDevice).where(UserDevice.user_id == current_user.id))
+ if len(count.scalars().all()) >= 10:
+ raise HTTPException(status_code=409,
+ detail="Too many devices — remove one first")
+
+ device = UserDevice(user_id=current_user.id,
+ pk_auth_ed25519=body.pk_auth_ed25519,
+ label=body.label[:64])
+ db.add(device)
+ await db.commit()
+ await db.refresh(device)
+ return {"id": device.id, "label": device.label, "existing": False}
+
+
+@router.get("/devices")
+async def list_devices(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(UserDevice).where(UserDevice.user_id == current_user.id)
+ .order_by(UserDevice.created_at))
+ return {"devices": [
+ {"id": d.id, "label": d.label,
+ "created_at": d.created_at.isoformat() if d.created_at else None,
+ "last_seen": d.last_seen.isoformat() if d.last_seen else None}
+ for d in result.scalars().all()
+ ]}
+
+
+@router.delete("/devices/{device_id}")
+async def delete_device(
+ device_id: str,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """Retire a device's hub key. Its per-node identities are separate and are
+ revoked on each node, which the hub cannot do and should not be able to."""
+ result = await db.execute(
+ select(UserDevice).where(UserDevice.id == device_id,
+ UserDevice.user_id == current_user.id))
+ device = result.scalar_one_or_none()
+ if not device:
+ raise HTTPException(status_code=404, detail="No such device")
+ await db.delete(device)
+ await db.commit()
+ return {"status": "deleted", "id": device_id}
+
+
+@router.post("/auth")
+@limiter.limit("10/minute")
+async def device_auth(
+ body: DeviceAuthRequest,
+ request: Request,
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Sign in with a registered device key. Same shape as `/v1/nodes/auth`.
+
+ The timestamp window is what stops a captured signature being replayed
+ later; the signature covers the username as well, so one collected for a
+ different account is not usable here.
+ """
+ now = int(time.time())
+ if abs(now - body.timestamp) > DEVICE_AUTH_TIMESTAMP_WINDOW:
+ raise HTTPException(status_code=401,
+ detail="Timestamp too old or too far in the future")
+
+ result = await db.execute(select(User).where(User.username == body.username))
+ user = result.scalar_one_or_none()
+ if not user:
+ raise HTTPException(status_code=401, detail="Invalid credentials")
+ if user.status != "active":
+ raise HTTPException(status_code=403, detail=f"Account {user.status}")
+
+ devices = await db.execute(
+ select(UserDevice).where(UserDevice.user_id == user.id))
+ message = f"meshbay:user_auth:{body.username}:{body.timestamp}".encode()
+ try:
+ sig = base64.b64decode(body.signature)
+ except Exception:
+ raise HTTPException(status_code=401, detail="Invalid signature encoding")
+
+ matched = None
+ for device in devices.scalars().all():
+ try:
+ pk = Ed25519PublicKey.from_public_bytes(
+ base64.b64decode(device.pk_auth_ed25519))
+ pk.verify(sig, message)
+ except Exception:
+ continue
+ matched = device
+ break
+
+ if matched is None:
+ db.add(IPLog(user_id=user.id, event="device_auth_fail",
+ ip_address=client_ip(request), detail=body.username))
+ await db.commit()
+ raise HTTPException(status_code=401, detail="Invalid signature")
+
+ matched.last_seen = datetime.now(timezone.utc)
+
+ memberships = await db.execute(
+ select(GroupMember.group_id).where(GroupMember.user_id == user.id))
+ group_ids = [gid for (gid,) in memberships.all()]
+ access_token = issue_access_token(user.id, ttl=_ttl(), groups=group_ids)
+ raw_rt, rt_hash = generate_refresh_token()
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=_refresh_ttl())
+ db.add(RefreshToken(user_id=user.id, token_hash=rt_hash,
+ family_id=str(uuid.uuid4()), expires_at=expires_at))
+ db.add(IPLog(user_id=user.id, event="device_auth",
+ ip_address=client_ip(request)))
+ await db.commit()
+
+ return {
+ "access_token": access_token,
+ "refresh_token": raw_rt,
+ "token_type": "bearer",
+ "expires_in": _ttl(),
+ "device_id": matched.id,
+ }
+
+
@router.post("/token/refresh")
@limiter.limit("20/minute")
async def token_refresh(
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e5a2b7d31f88_user_device_auth_keys.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e5a2b7d31f88_user_device_auth_keys.py
new file mode 100644
index 0000000..ddada15
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e5a2b7d31f88_user_device_auth_keys.py
@@ -0,0 +1,47 @@
+"""user device auth keys
+
+Device Ed25519 authentication to the hub, so a client signs in without
+re-deriving a key from the passphrase every time.
+
+Deliberately **not** the key directory that was H3: nothing reads this but the
+hub itself, nobody wraps a group key for it, and it is a different key from the
+per-node identity keys, which never leave the device-node relationship. See
+`docs/desktop-client-v1.md` §5.
+
+`create_all()` is not a migration — it creates missing tables and never a
+missing column, so a schema change without a file here reaches the tests (fresh
+DB every run) and never reaches the deployed hub.
+
+Revision ID: e5a2b7d31f88
+Revises: 3dc91cd4ea52
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "e5a2b7d31f88"
+down_revision: Union[str, Sequence[str], None] = "3dc91cd4ea52"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "user_devices",
+ sa.Column("id", sa.String(36), primary_key=True),
+ sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"),
+ nullable=False),
+ sa.Column("pk_auth_ed25519", sa.String(64), nullable=False, unique=True),
+ sa.Column("label", sa.String(64), nullable=False, server_default=""),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
+ server_default=sa.func.now()),
+ sa.Column("last_seen", sa.DateTime(timezone=True), nullable=True),
+ )
+ op.create_index("ix_user_devices_user", "user_devices", ["user_id"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_user_devices_user", table_name="user_devices")
+ op.drop_table("user_devices")
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index a220a64..12507a6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -178,6 +178,41 @@ class FederatedGroup(Base):
)
+class UserDevice(Base):
+ """
+ A device's key for authenticating **to the hub**, and nothing else.
+
+ This is not a reintroduction of the key directory that was H3, and the
+ distinction is worth being precise about because it looks like one:
+
+ * **Nobody reads this but the hub.** No endpoint publishes it, nothing
+ wraps a group key for it, and no node ever asks for it. H3 was a
+ directory *others* read from, where a substituted key was handed the
+ GEK by an honest member.
+ * **It is not a node identity key.** Those are generated per node, pinned
+ there, and never leave that relationship (`docs/per-node-identity-v1.md`).
+ A device holds one of these *plus* a different key per node, so nothing
+ here correlates a person across operators.
+
+ What it does cost, stated plainly: the hub now knows how many devices an
+ account has and when each one last signed in. That is new metadata, and it
+ is the price of not deriving a key from the passphrase on every sign-in.
+ """
+
+ __tablename__ = "user_devices"
+
+ id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
+ user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
+ # base64 raw Ed25519, unique so one device key belongs to one account
+ pk_auth_ed25519: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
+ label: Mapped[str] = mapped_column(String(64), default="")
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+ last_seen: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True)
+
+ __table_args__ = (Index("ix_user_devices_user", "user_id"),)
+
+
class SwarmSource(Base):
"""
Tracks which nodes can serve a given content hash (public swarm).
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 4a4e712..a48535a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -1252,6 +1252,10 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
// the second one should make the pairing form go away.
const [operatorPaired, setOperatorPaired] = useState(false);
const [needsCode, setNeedsCode] = useState(false);
+ // This browser holds a key the node does not know, for an account it does.
+ // Not the operator's problem: a device already paired here can admit it.
+ const [needsDevice, setNeedsDevice] = useState(false);
+ const [deviceCode, setDeviceCode] = useState('');
const [codeInput, setCodeInput] = useState('');
const [retryKey, setRetryKey] = useState(0);
const transportRef = useRef(null);
@@ -1382,6 +1386,11 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
// one-time code from the operator before it will hand over the group
// key. Not an error to shout about — a step in joining.
if (err.reason === 'code_required') setNeedsCode(true);
+ // A key this node has never pinned, for an account it knows. The way in
+ // is a device already trusted here, not an operator — which is the
+ // whole point of device linking: a second browser or a native client
+ // must not cost anyone a support request.
+ if (err.reason === 'unknown_device') setNeedsDevice(true);
setError(err.message);
setStatus('error');
// A refusal means the node answered, so it is up; only a failure to
@@ -1865,6 +1874,27 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
`}
</div>
${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
+ ${needsDevice && html`
+ <div class="invite-form" style="margin-bottom:12px">
+ <h4>${t('device.add_title')}</h4>
+ <p class="settings-hint">${t('device.add_hint')}</p>
+ ${!deviceCode && html`
+ <button class="admin-btn" onClick=${async () => {
+ try {
+ const transport = transportRef.current;
+ const out = await transport.requestDeviceAdd(userId);
+ setDeviceCode(out.code);
+ } catch (err) { setError(err.message); }
+ }}>${t('device.add_btn')}</button>
+ `}
+ ${deviceCode && html`
+ <p class="settings-hint">${t('device.add_show')}</p>
+ <p style="font-family:monospace;font-size:1.6em;letter-spacing:2px">
+ ${deviceCode}
+ </p>
+ `}
+ </div>
+ `}
${needsCode && html`
<form class="invite-form" style="margin-bottom:12px" onSubmit=${submitJoinCode}>
<h4>${t('group.join_code_title')}</h4>
@@ -2191,11 +2221,50 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef,
const [pairCode, setPairCode] = useState('');
const [pairStatus, setPairStatus] = useState('');
const [pairing, setPairing] = useState(false);
+ // Your own devices on this node. Not a members feature — it is beside them
+ // because this is where a live connection to the node exists.
+ const [devices, setDevices] = useState([]);
+ const [approveCode, setApproveCode] = useState('');
+ const [deviceMsg, setDeviceMsg] = useState('');
// Pairing lives here rather than in Settings because this is where a live
// connection to the node exists — and it is offered only when the node itself
// says this account is its operator (is_node_admin comes from the authenticated
// handshake_ack, not from the hub).
+ const loadDevices = useCallback(async () => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+ try {
+ const out = await transport.listDevices();
+ setDevices(out.devices);
+ } catch { /* a node that has none says so by listing none */ }
+ }, [transportRef]);
+
+ useEffect(() => { loadDevices(); }, [loadDevices]);
+
+ const approveDevice = useCallback(async (e) => {
+ e.preventDefault();
+ const code = approveCode.trim();
+ if (!code) return;
+ setDeviceMsg('');
+ try {
+ await transportRef.current.approveDevice(userId, code);
+ setApproveCode('');
+ setDeviceMsg(t('device.approved'));
+ await loadDevices();
+ } catch (err) { setDeviceMsg(err.message); }
+ }, [approveCode, userId, transportRef, loadDevices]);
+
+ const revokeDevice = useCallback(async (device) => {
+ if (!confirm(t('device.revoke_confirm'))) return;
+ setDeviceMsg('');
+ try {
+ await transportRef.current.revokeDevice(
+ userId, device.pk_ed25519, device.pk_x25519 || '');
+ await loadDevices();
+ } catch (err) { setDeviceMsg(err.message); }
+ }, [userId, transportRef, loadDevices]);
+
const doPair = useCallback(async (e) => {
e.preventDefault();
const code = pairCode.trim();
@@ -2398,6 +2467,36 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef,
</div>
</form>
`}
+
+ <div class="invite-form" style="margin-top:16px">
+ <h4>${t('device.mine_title')}</h4>
+ <p class="settings-hint">${t('device.mine_hint')}</p>
+ ${deviceMsg && html`<p class="settings-hint">${deviceMsg}</p>`}
+ ${devices.length === 0 && html`
+ <p class="settings-hint">${t('device.mine_empty')}</p>
+ `}
+ ${devices.map(d => html`
+ <div key=${d.pk_ed25519}
+ style="display:flex;align-items:center;gap:8px;margin:4px 0">
+ <span style="font-family:monospace">${d.pk_ed25519.slice(0, 16)}…</span>
+ ${d.is_this_one && html`<span class="badge">${t('device.this_one')}</span>`}
+ <span class="settings-hint">${d.pinned_via}${d.label ? ' · ' + d.label : ''}</span>
+ ${!d.is_this_one && devices.length > 1 && html`
+ <button class="admin-btn" onClick=${() => revokeDevice(d)}>
+ ${t('device.revoke')}
+ </button>
+ `}
+ </div>
+ `)}
+ <form onSubmit=${approveDevice} style="margin-top:12px">
+ <p class="settings-hint">${t('device.approve_hint')}</p>
+ <div style="display:flex;gap:8px">
+ <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace"
+ value=${approveCode} onInput=${e => setApproveCode(e.target.value)} />
+ <button class="admin-btn" type="submit">${t('device.approve_btn')}</button>
+ </div>
+ </form>
+ </div>
</div>
`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
index 21bf05d..1ddaa50 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
@@ -321,6 +321,8 @@ async function handshakeProof(gekRaw, role, groupId, nonceClient, nonceNode, bin
// wrap the group key for a key that came over the wire instead of one fetched
// from the hub's directory (H3). nonce_node ties it to this connection.
const JOIN_PREFIX = new TextEncoder().encode('meshbay:join:v1');
+const DEVICE_REQ_PREFIX = new TextEncoder().encode('meshbay:device_req:v1');
+const DEVICE_ADD_PREFIX = new TextEncoder().encode('meshbay:device_add:v1');
function joinTranscript(nodePkB64, groupId, userId, pkEdB64, pkXB64, nonceNode, ts) {
const enc = new TextEncoder();
@@ -339,6 +341,69 @@ function joinTranscript(nodePkB64, groupId, userId, pkEdB64, pkXB64, nonceNode,
return out;
}
+/**
+ * Device linking transcripts, mirroring meshbay_common/device.py.
+ *
+ * Two signatures admit a device: the new one proves it holds the keys it is
+ * presenting, and a key the node already pinned countersigns them. The hub can
+ * produce neither — it has stored no user keys since 2026-08-14 — which is what
+ * makes this safe to do without an operator.
+ */
+function deviceRequestTranscript(nodePkB64, userId, pkEdB64, pkXB64, codeHash,
+ nonceNode, ts) {
+ const enc = new TextEncoder();
+ const body = _lenPrefixed([
+ enc.encode(nodePkB64), enc.encode(userId), enc.encode(pkEdB64),
+ enc.encode(pkXB64), enc.encode(codeHash), nonceNode, enc.encode(String(ts)),
+ ]);
+ const out = new Uint8Array(DEVICE_REQ_PREFIX.length + body.length);
+ out.set(DEVICE_REQ_PREFIX, 0);
+ out.set(body, DEVICE_REQ_PREFIX.length);
+ return out;
+}
+
+function deviceAddTranscript(nodePkB64, userId, pkEdB64, pkXB64, nonceNode, ts) {
+ const enc = new TextEncoder();
+ const body = _lenPrefixed([
+ enc.encode(nodePkB64), enc.encode(userId), enc.encode(pkEdB64),
+ enc.encode(pkXB64), nonceNode, enc.encode(String(ts)),
+ ]);
+ const out = new Uint8Array(DEVICE_ADD_PREFIX.length + body.length);
+ out.set(DEVICE_ADD_PREFIX, 0);
+ out.set(body, DEVICE_ADD_PREFIX.length);
+ return out;
+}
+
+/**
+ * sha256(code ‖ pk_ed ‖ pk_x), hex — the lookup key for a pending request.
+ *
+ * The keys go in with the code, so the hash identifies *this device asking with
+ * this code* rather than *this code*. That is what stops the node answering an
+ * approver with a substituted key: the approver recomputes this from what they
+ * typed and what they were handed, and a substitution finds nothing. Nothing
+ * here rests on a human comparing digits.
+ */
+async function deviceCodeHash(code, pkEdB64, pkXB64) {
+ const enc = new TextEncoder();
+ const payload = enc.encode([code, pkEdB64, pkXB64].join('\x1f'));
+ const digest = await crypto.subtle.digest('SHA-256', payload);
+ return Array.from(new Uint8Array(digest))
+ .map(b => b.toString(16).padStart(2, '0')).join('');
+}
+
+/** Crockford folding, mirroring roster.normalize_code. */
+function normalizeCode(code) {
+ let out = '';
+ for (const ch of code.toUpperCase()) {
+ if (ch === '-' || ch === ' ' || ch === '\t') continue;
+ if (ch === 'I' || ch === 'L') out += '1';
+ else if (ch === 'O') out += '0';
+ else if (ch === 'U') out += 'V';
+ else out += ch;
+ }
+ return out;
+}
+
function constantTimeEqual(a, b) {
if (a.length !== b.length) return false;
let diff = 0;
@@ -359,4 +424,6 @@ window.MeshBayCrypto = {
generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode,
adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding,
joinTranscript, verifyNodeSignature, constantTimeEqual,
+ deviceRequestTranscript, deviceAddTranscript, deviceCodeHash,
+ normalizeCode,
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index aaed0d1..5a946f7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -92,6 +92,19 @@ export default {
'group.upload': 'Hochladen',
'group.mkdir': 'Neuer Ordner',
'group.mkdir_prompt': 'Name des neuen Ordners:',
+ 'device.add_title': 'This browser is not linked to this node yet',
+ 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
+ 'device.add_btn': 'Get a linking code',
+ 'device.add_show': 'Type this code into a device already linked to this node, within the hour:',
+ 'device.mine_title': 'Your devices on this node',
+ 'device.mine_hint': 'Each browser or client you use here holds its own key. They are separate from your devices on other nodes.',
+ 'device.mine_empty': 'No device is linked here yet.',
+ 'device.this_one': 'this one',
+ 'device.revoke': 'Remove',
+ 'device.revoke_confirm': 'Remove this device? It will lose access to this node until it is linked again.',
+ 'device.approve_hint': 'Linking a new device? Enter the code it is showing.',
+ 'device.approve_btn': 'Approve',
+ 'device.approved': 'Device linked.',
'group.root_unavailable': '(nicht verfügbar — Laufwerk getrennt)',
'group.view': 'Ansehen',
'group.delete': 'Löschen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 02532d6..fd2d6ec 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -92,6 +92,19 @@ export default {
'group.upload': 'Upload',
'group.mkdir': 'New folder',
'group.mkdir_prompt': 'Name of the new folder:',
+ 'device.add_title': 'This browser is not linked to this node yet',
+ 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
+ 'device.add_btn': 'Get a linking code',
+ 'device.add_show': 'Type this code into a device already linked to this node, within the hour:',
+ 'device.mine_title': 'Your devices on this node',
+ 'device.mine_hint': 'Each browser or client you use here holds its own key. They are separate from your devices on other nodes.',
+ 'device.mine_empty': 'No device is linked here yet.',
+ 'device.this_one': 'this one',
+ 'device.revoke': 'Remove',
+ 'device.revoke_confirm': 'Remove this device? It will lose access to this node until it is linked again.',
+ 'device.approve_hint': 'Linking a new device? Enter the code it is showing.',
+ 'device.approve_btn': 'Approve',
+ 'device.approved': 'Device linked.',
'group.root_unavailable': '(unavailable — the drive is disconnected)',
'group.view': 'View',
'group.delete': 'Delete',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 49bfc1c..e564164 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -90,6 +90,19 @@ export default {
'group.upload': 'Subir',
'group.mkdir': 'Nueva carpeta',
'group.mkdir_prompt': 'Nombre de la nueva carpeta:',
+ 'device.add_title': 'This browser is not linked to this node yet',
+ 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
+ 'device.add_btn': 'Get a linking code',
+ 'device.add_show': 'Type this code into a device already linked to this node, within the hour:',
+ 'device.mine_title': 'Your devices on this node',
+ 'device.mine_hint': 'Each browser or client you use here holds its own key. They are separate from your devices on other nodes.',
+ 'device.mine_empty': 'No device is linked here yet.',
+ 'device.this_one': 'this one',
+ 'device.revoke': 'Remove',
+ 'device.revoke_confirm': 'Remove this device? It will lose access to this node until it is linked again.',
+ 'device.approve_hint': 'Linking a new device? Enter the code it is showing.',
+ 'device.approve_btn': 'Approve',
+ 'device.approved': 'Device linked.',
'group.root_unavailable': '(no disponible — la unidad está desconectada)',
'group.view': 'Ver',
'group.delete': 'Eliminar',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 1afc5b1..5848f9d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -91,6 +91,19 @@ export default {
'group.upload': 'Envoyer',
'group.mkdir': 'Nouveau dossier',
'group.mkdir_prompt': 'Nom du nouveau dossier :',
+ 'device.add_title': 'Ce navigateur n’est pas encore lié à ce nœud',
+ 'device.add_hint': 'Votre compte est connu ici, mais ce navigateur détient une autre clé. Approuvez-le depuis un appareil déjà lié — sans passer par l’opérateur.',
+ 'device.add_btn': 'Obtenir un code de liaison',
+ 'device.add_show': 'Saisissez ce code sur un appareil déjà lié à ce nœud, dans l’heure :',
+ 'device.mine_title': 'Vos appareils sur ce nœud',
+ 'device.mine_hint': 'Chaque navigateur ou client que vous utilisez ici détient sa propre clé. Ils sont distincts de vos appareils sur les autres nœuds.',
+ 'device.mine_empty': 'Aucun appareil lié ici pour le moment.',
+ 'device.this_one': 'celui-ci',
+ 'device.revoke': 'Retirer',
+ 'device.revoke_confirm': 'Retirer cet appareil ? Il perdra l’accès à ce nœud jusqu’à une nouvelle liaison.',
+ 'device.approve_hint': 'Vous liez un nouvel appareil ? Saisissez le code qu’il affiche.',
+ 'device.approve_btn': 'Approuver',
+ 'device.approved': 'Appareil lié.',
'group.root_unavailable': '(indisponible — le disque est déconnecté)',
'group.view': 'Afficher',
'group.delete': 'Supprimer',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 44b4353..61913e3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -91,6 +91,19 @@ export default {
'group.upload': 'Carica',
'group.mkdir': 'Nuova cartella',
'group.mkdir_prompt': 'Nome della nuova cartella:',
+ 'device.add_title': 'This browser is not linked to this node yet',
+ 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
+ 'device.add_btn': 'Get a linking code',
+ 'device.add_show': 'Type this code into a device already linked to this node, within the hour:',
+ 'device.mine_title': 'Your devices on this node',
+ 'device.mine_hint': 'Each browser or client you use here holds its own key. They are separate from your devices on other nodes.',
+ 'device.mine_empty': 'No device is linked here yet.',
+ 'device.this_one': 'this one',
+ 'device.revoke': 'Remove',
+ 'device.revoke_confirm': 'Remove this device? It will lose access to this node until it is linked again.',
+ 'device.approve_hint': 'Linking a new device? Enter the code it is showing.',
+ 'device.approve_btn': 'Approve',
+ 'device.approved': 'Device linked.',
'group.root_unavailable': '(non disponibile — l’unità è scollegata)',
'group.view': 'Visualizza',
'group.delete': 'Elimina',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 33fdee3..aa656ee 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -89,6 +89,19 @@ export default {
'group.upload': 'アップロード',
'group.mkdir': '新しいフォルダー',
'group.mkdir_prompt': '新しいフォルダーの名前:',
+ 'device.add_title': 'This browser is not linked to this node yet',
+ 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
+ 'device.add_btn': 'Get a linking code',
+ 'device.add_show': 'Type this code into a device already linked to this node, within the hour:',
+ 'device.mine_title': 'Your devices on this node',
+ 'device.mine_hint': 'Each browser or client you use here holds its own key. They are separate from your devices on other nodes.',
+ 'device.mine_empty': 'No device is linked here yet.',
+ 'device.this_one': 'this one',
+ 'device.revoke': 'Remove',
+ 'device.revoke_confirm': 'Remove this device? It will lose access to this node until it is linked again.',
+ 'device.approve_hint': 'Linking a new device? Enter the code it is showing.',
+ 'device.approve_btn': 'Approve',
+ 'device.approved': 'Device linked.',
'group.root_unavailable': '(利用不可 — ドライブが切断されています)',
'group.view': '表示',
'group.delete': '削除',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index aa0385e..dae903f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -92,6 +92,19 @@ export default {
'group.upload': 'Uploaden',
'group.mkdir': 'Nieuwe map',
'group.mkdir_prompt': 'Naam van de nieuwe map:',
+ 'device.add_title': 'This browser is not linked to this node yet',
+ 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
+ 'device.add_btn': 'Get a linking code',
+ 'device.add_show': 'Type this code into a device already linked to this node, within the hour:',
+ 'device.mine_title': 'Your devices on this node',
+ 'device.mine_hint': 'Each browser or client you use here holds its own key. They are separate from your devices on other nodes.',
+ 'device.mine_empty': 'No device is linked here yet.',
+ 'device.this_one': 'this one',
+ 'device.revoke': 'Remove',
+ 'device.revoke_confirm': 'Remove this device? It will lose access to this node until it is linked again.',
+ 'device.approve_hint': 'Linking a new device? Enter the code it is showing.',
+ 'device.approve_btn': 'Approve',
+ 'device.approved': 'Device linked.',
'group.root_unavailable': '(niet beschikbaar — de schijf is losgekoppeld)',
'group.view': 'Bekijken',
'group.delete': 'Verwijderen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index e66dd12..65c81ca 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -95,6 +95,19 @@ export default {
'group.upload': 'Wyślij',
'group.mkdir': 'Nowy folder',
'group.mkdir_prompt': 'Nazwa nowego folderu:',
+ 'device.add_title': 'This browser is not linked to this node yet',
+ 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
+ 'device.add_btn': 'Get a linking code',
+ 'device.add_show': 'Type this code into a device already linked to this node, within the hour:',
+ 'device.mine_title': 'Your devices on this node',
+ 'device.mine_hint': 'Each browser or client you use here holds its own key. They are separate from your devices on other nodes.',
+ 'device.mine_empty': 'No device is linked here yet.',
+ 'device.this_one': 'this one',
+ 'device.revoke': 'Remove',
+ 'device.revoke_confirm': 'Remove this device? It will lose access to this node until it is linked again.',
+ 'device.approve_hint': 'Linking a new device? Enter the code it is showing.',
+ 'device.approve_btn': 'Approve',
+ 'device.approved': 'Device linked.',
'group.root_unavailable': '(niedostępne — dysk jest odłączony)',
'group.view': 'Podgląd',
'group.delete': 'Usuń',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 03de040..729a7fc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -92,6 +92,19 @@ export default {
'group.upload': 'Enviar',
'group.mkdir': 'Nova pasta',
'group.mkdir_prompt': 'Nome da nova pasta:',
+ 'device.add_title': 'This browser is not linked to this node yet',
+ 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
+ 'device.add_btn': 'Get a linking code',
+ 'device.add_show': 'Type this code into a device already linked to this node, within the hour:',
+ 'device.mine_title': 'Your devices on this node',
+ 'device.mine_hint': 'Each browser or client you use here holds its own key. They are separate from your devices on other nodes.',
+ 'device.mine_empty': 'No device is linked here yet.',
+ 'device.this_one': 'this one',
+ 'device.revoke': 'Remove',
+ 'device.revoke_confirm': 'Remove this device? It will lose access to this node until it is linked again.',
+ 'device.approve_hint': 'Linking a new device? Enter the code it is showing.',
+ 'device.approve_btn': 'Approve',
+ 'device.approved': 'Device linked.',
'group.root_unavailable': '(indisponível — a unidade está desconectada)',
'group.view': 'Visualizar',
'group.delete': 'Excluir',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 72995dd..0ff5a49 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -88,6 +88,19 @@ export default {
'group.upload': '上传',
'group.mkdir': '新建文件夹',
'group.mkdir_prompt': '新文件夹的名称:',
+ 'device.add_title': 'This browser is not linked to this node yet',
+ 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
+ 'device.add_btn': 'Get a linking code',
+ 'device.add_show': 'Type this code into a device already linked to this node, within the hour:',
+ 'device.mine_title': 'Your devices on this node',
+ 'device.mine_hint': 'Each browser or client you use here holds its own key. They are separate from your devices on other nodes.',
+ 'device.mine_empty': 'No device is linked here yet.',
+ 'device.this_one': 'this one',
+ 'device.revoke': 'Remove',
+ 'device.revoke_confirm': 'Remove this device? It will lose access to this node until it is linked again.',
+ 'device.approve_hint': 'Linking a new device? Enter the code it is showing.',
+ 'device.approve_btn': 'Approve',
+ 'device.approved': 'Device linked.',
'group.root_unavailable': '(不可用 — 驱动器已断开连接)',
'group.view': '查看',
'group.delete': '删除',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 4ee5810..769114e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -787,6 +787,129 @@ class MeshBayTransport {
return gekRaw;
}
+ // ── Device linking ─────────────────────────────────────────────────────
+ //
+ // Identity keys are per node, so a browser and a desktop client are two keys
+ // on one account here. A new one is admitted by a key this node already
+ // pinned — never by the hub, which holds no user keys and so cannot
+ // countersign anything. See docs/desktop-client-v1.md §4.
+
+ /**
+ * Ask to be added, and return the code to show the person.
+ *
+ * They read it off this screen and type it into a device already paired with
+ * this node. The code is hashed together with our own keys, so that other
+ * device cannot be handed a substituted key and sign for it by mistake.
+ */
+ async requestDeviceAdd(userId) {
+ if (!this._sessionKeys || !this._sessionKeys.skEdB64) {
+ throw new Error('Identity keys unavailable in this browser — sign in again');
+ }
+ if (!this._nonceNode || !this.nodePk) {
+ throw new Error('Handshake incomplete — reconnect and retry');
+ }
+ const C = window.MeshBayCrypto;
+ const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64);
+ const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64);
+
+ // 40 bits from the platform CSPRNG, in the same alphabet as a pairing code
+ // so it reads and types the same way.
+ const alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+ const bytes = crypto.getRandomValues(new Uint8Array(8));
+ const raw = Array.from(bytes, b => alphabet[b % alphabet.length]).join('');
+ const code = `${raw.slice(0, 4)}-${raw.slice(4)}`;
+
+ const codeHash = await C.deviceCodeHash(
+ C.normalizeCode(code), pkEdB64, pkXB64);
+ const ts = Math.floor(Date.now() / 1000);
+ const transcript = C.deviceRequestTranscript(
+ this.nodePk, userId, pkEdB64, pkXB64, codeHash, this._nonceNode, ts);
+ const sig = await window.MeshBayKeys.signBytes(
+ this._sessionKeys.skEdB64, transcript);
+
+ const resp = await this._sendAndWait({
+ type: 'device_add_request', v: '0.1',
+ pk_ed25519: pkEdB64, pk_x25519: pkXB64, code_hash: codeHash, ts, sig,
+ });
+ if (resp.type === 'error') throw new Error(resp.detail || 'Refused');
+ return { code, expiresAt: resp.expires_at };
+ }
+
+ /**
+ * Approve a device waiting with this code.
+ *
+ * The node is a mailbox: it is asked for a request matching
+ * sha256(code ‖ keys), and the keys in that hash came from the device that
+ * filed it. A node returning something else produces no match, so there is
+ * nothing to sign and nothing for a person to misread.
+ */
+ async approveDevice(userId, code) {
+ if (!this._sessionKeys || !this._sessionKeys.skEdB64) {
+ throw new Error('Identity keys unavailable in this browser — sign in again');
+ }
+ if (!this._nonceNode || !this.nodePk) {
+ throw new Error('Handshake incomplete — reconnect and retry');
+ }
+ const C = window.MeshBayCrypto;
+ const normalized = C.normalizeCode(code);
+
+ // The code never leaves this browser. The node lists what is pending, each
+ // with the hash the requesting device computed over the code and its own
+ // keys; we recompute and keep the one that matches. A node offering
+ // fabricated keys would have to produce a hash matching sha256(code ‖
+ // fabricated) — and it does not know the code.
+ const listed = await this._sendAndWait({ type: 'device_lookup', v: '0.1' });
+ if (listed.type === 'error') throw new Error(listed.detail || 'Not found');
+
+ let match = null;
+ for (const req of listed.requests || []) {
+ const expect = await C.deviceCodeHash(
+ normalized, req.pk_ed25519, req.pk_x25519);
+ if (expect === req.code_hash) { match = req; break; }
+ }
+ if (!match) {
+ throw new Error('No device is waiting with that code');
+ }
+ return this._countersign(userId, match.code_hash,
+ match.pk_ed25519, match.pk_x25519);
+ }
+
+ async _countersign(userId, codeHash, pkEdB64, pkXB64) {
+ const C = window.MeshBayCrypto;
+ const ts = Math.floor(Date.now() / 1000);
+ const transcript = C.deviceAddTranscript(
+ this.nodePk, userId, pkEdB64, pkXB64, this._nonceNode, ts);
+ const sig = await window.MeshBayKeys.signBytes(
+ this._sessionKeys.skEdB64, transcript);
+ const resp = await this._sendAndWait({
+ type: 'device_add', v: '0.1',
+ pk_ed25519: pkEdB64, pk_x25519: pkXB64, code_hash: codeHash, ts, sig,
+ });
+ if (resp.type === 'error') throw new Error(resp.detail || 'Refused');
+ return resp;
+ }
+
+ async listDevices() {
+ const resp = await this._sendAndWait({ type: 'device_list', v: '0.1' });
+ if (resp.type === 'error') throw new Error(resp.detail || 'Refused');
+ return { devices: resp.devices || [], pending: resp.pending || 0 };
+ }
+
+ /** Retire a device — a lost laptop. Countersigned like an addition. */
+ async revokeDevice(userId, pkEdB64, pkXB64) {
+ const C = window.MeshBayCrypto;
+ const ts = Math.floor(Date.now() / 1000);
+ const transcript = C.deviceAddTranscript(
+ this.nodePk, userId, pkEdB64, pkXB64, this._nonceNode, ts);
+ const sig = await window.MeshBayKeys.signBytes(
+ this._sessionKeys.skEdB64, transcript);
+ const resp = await this._sendAndWait({
+ type: 'device_revoke', v: '0.1', pk_ed25519: pkEdB64, ts, sig,
+ });
+ if (resp.type === 'error') throw new Error(resp.detail || 'Refused');
+ return resp;
+ }
+
/**
* Withdraw our key backup from this node.
*
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"]
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")
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 21331f2..f4dcca5 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -314,6 +314,8 @@ class NodeDaemon:
self._webrtc._ctx["daemon_state"] = self._state
self._webrtc._ctx["invite_ttl"] = (
self._config.node.invite_ttl_hours * 3600)
+ self._webrtc._ctx["device_request_ttl"] = (
+ self._config.node.device_request_ttl_minutes * 60)
paired = await self._roster.has_operator() if self._roster else False
self._webrtc._ctx["has_admin_authority"] = paired
if paired:
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index 6bda56b..226b784 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -52,15 +52,42 @@ CODE_LEN = 8 # 8 × 5 bits = 40 bits of entropy
# node-wide lockout.
DEFAULT_INVITE_TTL = 7 * 24 * 3600 # seconds — member invitations
DEFAULT_PAIR_TTL = 24 * 3600 # seconds — operator pairing
+# A device-add code is read off one screen and typed into another, in one
+# sitting. An hour is comfort, not security: the code is bound to the requesting
+# keys by its hash, so a longer window widens nothing an attacker can use.
+DEFAULT_DEVICE_REQUEST_TTL = 3600
_SCHEMA = """\
+-- One row per DEVICE, not per person. A browser and a desktop client are two
+-- keys belonging to one account, and `user_id` alone as the key made the second
+-- silently overwrite the first (INSERT OR REPLACE). See docs/desktop-client-v1.md §4.
CREATE TABLE IF NOT EXISTS identities (
- user_id TEXT PRIMARY KEY,
- username TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ username TEXT NOT NULL,
+ pk_ed25519 TEXT NOT NULL,
+ pk_x25519 TEXT NOT NULL,
+ pinned_at TEXT NOT NULL,
+ pinned_via TEXT NOT NULL,
+ label TEXT NOT NULL DEFAULT '',
+ -- Which already-pinned key countersigned this one into existence. Empty for
+ -- the first device of an account, which an operator code admitted.
+ added_by_pk TEXT NOT NULL DEFAULT '',
+ revoked_at TEXT,
+ PRIMARY KEY (user_id, pk_ed25519)
+);
+
+-- A device asking to be added, waiting for an existing one to approve it.
+-- `code_hash` binds the code to the keys: sha256(code ‖ pk_ed ‖ pk_x). The
+-- approver looks the request up by recomputing that, so a node returning
+-- different keys produces no match and the client refuses before signing.
+CREATE TABLE IF NOT EXISTS device_requests (
+ code_hash TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ username TEXT NOT NULL DEFAULT '',
pk_ed25519 TEXT NOT NULL,
pk_x25519 TEXT NOT NULL,
- pinned_at TEXT NOT NULL,
- pinned_via TEXT NOT NULL
+ created_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS members (
@@ -131,6 +158,11 @@ def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
+def _iso_in(seconds: int) -> str:
+ return (datetime.now(timezone.utc)
+ + timedelta(seconds=seconds)).isoformat(timespec="seconds")
+
+
class Roster:
def __init__(self, db_path: Path):
self._db_path = db_path
@@ -152,8 +184,54 @@ class Roster:
if "username" not in columns:
await self._db.execute(
"ALTER TABLE invites ADD COLUMN username TEXT NOT NULL DEFAULT ''")
+
+ await self._migrate_identities_to_devices()
await self._db.commit()
+ async def _migrate_identities_to_devices(self) -> None:
+ """
+ Widen `identities` from one key per person to one row per device.
+
+ `CREATE TABLE IF NOT EXISTS` leaves an existing table alone, so a roster
+ written before device linking still has `user_id` as its sole primary
+ key — where a second device would overwrite the first rather than being
+ refused. SQLite cannot change a primary key in place, so the table is
+ rebuilt.
+
+ Existing pins are carried over untouched and become each account's first
+ device. Nobody has to re-pair.
+ """
+ assert self._db
+ async with self._db.execute("PRAGMA table_info(identities)") as cur:
+ info = list(await cur.fetchall())
+ columns = {r[1] for r in info}
+ # `pk` is the column's position in the primary key, 0 when not part of it.
+ key_columns = {r[1] for r in info if r[5]}
+
+ if key_columns == {"user_id", "pk_ed25519"} and "revoked_at" in columns:
+ return
+
+ log.info("Roster: widening identities to one row per device")
+ for column, decl in (("label", "TEXT NOT NULL DEFAULT ''"),
+ ("added_by_pk", "TEXT NOT NULL DEFAULT ''"),
+ ("revoked_at", "TEXT")):
+ if column not in columns:
+ await self._db.execute(
+ f"ALTER TABLE identities ADD COLUMN {column} {decl}")
+
+ if key_columns != {"user_id", "pk_ed25519"}:
+ await self._db.execute("ALTER TABLE identities RENAME TO identities_old")
+ await self._db.executescript(_SCHEMA)
+ await self._db.execute(
+ "INSERT OR IGNORE INTO identities "
+ "(user_id, username, pk_ed25519, pk_x25519, pinned_at, "
+ " pinned_via, label, added_by_pk, revoked_at) "
+ "SELECT user_id, username, pk_ed25519, pk_x25519, pinned_at, "
+ " pinned_via, label, added_by_pk, revoked_at "
+ "FROM identities_old")
+ await self._db.execute("DROP TABLE identities_old")
+ log.info("Roster: identities rebuilt, existing pins preserved")
+
async def close(self) -> None:
if self._db:
await self._db.close()
@@ -161,6 +239,12 @@ class Roster:
# ── Identities ───────────────────────────────────────────────────────────
+ # How many devices one person may hold on this node. A chain of devices
+ # inherits the weakness of its weakest ancestor — whoever cracks a browser's
+ # keypair bundle can add one — so the answer to "how many" is visibility and
+ # a ceiling, not cryptography.
+ MAX_DEVICES_PER_USER = 5
+
async def pin_identity(
self,
user_id: str,
@@ -168,25 +252,90 @@ class Roster:
pk_ed25519: str,
pk_x25519: str,
via: str,
+ *,
+ label: str = "",
+ added_by_pk: str = "",
) -> None:
+ """
+ Record a device for an account.
+
+ `INSERT OR REPLACE` on (user_id, pk_ed25519) now updates *that device*
+ rather than overwriting whatever key the person had before — which is
+ what it did while `user_id` was the whole primary key, silently, and
+ would have become a hole the moment a second device was legitimate.
+ """
assert self._db
await self._db.execute(
"INSERT OR REPLACE INTO identities "
- "(user_id, username, pk_ed25519, pk_x25519, pinned_at, pinned_via) "
- "VALUES (?, ?, ?, ?, ?, ?)",
- (user_id, username, pk_ed25519, pk_x25519, _now(), via),
+ "(user_id, username, pk_ed25519, pk_x25519, pinned_at, pinned_via, "
+ " label, added_by_pk, revoked_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)",
+ (user_id, username, pk_ed25519, pk_x25519, _now(), via,
+ label, added_by_pk),
)
await self._db.commit()
async def get_identity(self, user_id: str) -> dict | None:
+ """
+ This account's oldest live device.
+
+ Kept for callers that only need "is this person known here" — the
+ operator pin, `status`, attribution. Anything deciding whether a *key*
+ is admitted must use `find_device`, or a second device is refused where
+ the first is not.
+ """
+ assert self._db
+ async with self._db.execute(
+ "SELECT * FROM identities WHERE user_id = ? AND revoked_at IS NULL "
+ "ORDER BY pinned_at LIMIT 1", (user_id,)
+ ) as cur:
+ row = await cur.fetchone()
+ return dict(row) if row else None
+
+ async def find_device(self, user_id: str, pk_ed25519: str) -> dict | None:
+ """The device with this exact key, if it is live. None if revoked."""
assert self._db
async with self._db.execute(
- "SELECT * FROM identities WHERE user_id = ?", (user_id,)
+ "SELECT * FROM identities WHERE user_id = ? AND pk_ed25519 = ? "
+ "AND revoked_at IS NULL", (user_id, pk_ed25519)
) as cur:
row = await cur.fetchone()
return dict(row) if row else None
+ async def list_devices(self, user_id: str,
+ include_revoked: bool = False) -> list[dict]:
+ assert self._db
+ sql = "SELECT * FROM identities WHERE user_id = ?"
+ if not include_revoked:
+ sql += " AND revoked_at IS NULL"
+ async with self._db.execute(sql + " ORDER BY pinned_at",
+ (user_id,)) as cur:
+ return [dict(r) for r in await cur.fetchall()]
+
+ async def revoke_device(self, user_id: str, pk_ed25519: str) -> bool:
+ """
+ Retire one device, leaving the account's others alone.
+
+ Marked rather than deleted: a revoked key must stay refused, and a row
+ that is gone is a key the node would happily pin again on the next
+ device-add — which is the laptop somebody just reported lost.
+ """
+ assert self._db
+ cur = await self._db.execute(
+ "UPDATE identities SET revoked_at = ? "
+ "WHERE user_id = ? AND pk_ed25519 = ? AND revoked_at IS NULL",
+ (_now(), user_id, pk_ed25519))
+ await self._db.commit()
+ return cur.rowcount > 0
+
async def unpin(self, user_id: str) -> bool:
+ """
+ Forget an account entirely — every device it holds.
+
+ Deliberately all of them: `member unpin` is what an operator runs when
+ someone must start over, and leaving one device behind would let the
+ person walk back in with a key the operator meant to forget.
+ """
assert self._db
cur = await self._db.execute(
"DELETE FROM identities WHERE user_id = ?", (user_id,))
@@ -196,10 +345,84 @@ class Roster:
async def list_identities(self) -> list[dict]:
assert self._db
async with self._db.execute(
- "SELECT * FROM identities ORDER BY pinned_at"
+ "SELECT * FROM identities WHERE revoked_at IS NULL "
+ "ORDER BY pinned_at"
) as cur:
return [dict(r) for r in await cur.fetchall()]
+ # ── Device requests ──────────────────────────────────────────────────────
+
+ async def file_device_request(
+ self, user_id: str, username: str, pk_ed25519: str, pk_x25519: str,
+ code_hash: str, ttl: int = DEFAULT_DEVICE_REQUEST_TTL,
+ ) -> str:
+ """
+ Record a device waiting to be approved. Returns its expiry.
+
+ The node stores only `code_hash`, which the new device computed over the
+ code **and its own keys**. That binding is what stops the node itself
+ from substituting a key: an approver recomputes the hash from the code
+ they typed and the keys they were handed, and a mismatch means no
+ request is found.
+ """
+ assert self._db
+ expires = _iso_in(ttl)
+ await self._db.execute(
+ "INSERT OR REPLACE INTO device_requests "
+ "(code_hash, user_id, username, pk_ed25519, pk_x25519, created_at, "
+ " expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (code_hash, user_id, username, pk_ed25519, pk_x25519, _now(), expires))
+ await self._db.commit()
+ return expires
+
+ async def take_device_request(self, code_hash: str,
+ user_id: str) -> dict | None:
+ """
+ Claim a pending request by its hash, for this account only.
+
+ Single use and scoped to the account: a request filed for one person
+ cannot be redeemed by another even with the code, and a code that has
+ been spent is gone.
+ """
+ assert self._db
+ async with self._db.execute(
+ "SELECT * FROM device_requests WHERE code_hash = ? AND user_id = ? "
+ "AND expires_at > ?", (code_hash, user_id, _now())
+ ) as cur:
+ row = await cur.fetchone()
+ if row is None:
+ return None
+ await self._db.execute(
+ "DELETE FROM device_requests WHERE code_hash = ?", (code_hash,))
+ await self._db.commit()
+ return dict(row)
+
+ async def list_device_requests(self, user_id: str) -> list[dict]:
+ """
+ This account's pending requests, hashes included.
+
+ The hash is what the approver matches against, so it has to travel.
+ Handing it out is safe: it is `sha256(code ‖ keys)` over 40 bits of
+ secret the node does not hold, and knowing the code authorizes nothing
+ on its own — only a countersignature by an already-pinned key does.
+ """
+ assert self._db
+ async with self._db.execute(
+ "SELECT * FROM device_requests WHERE user_id = ? AND expires_at > ? "
+ "ORDER BY created_at", (user_id, _now())
+ ) as cur:
+ return [dict(r) for r in await cur.fetchall()]
+
+ async def pending_device_requests(self, user_id: str) -> int:
+ """How many this account has waiting. For display and for a ceiling."""
+ assert self._db
+ async with self._db.execute(
+ "SELECT COUNT(*) AS n FROM device_requests WHERE user_id = ? "
+ "AND expires_at > ?", (user_id, _now())
+ ) as cur:
+ row = await cur.fetchone()
+ return int(row["n"]) if row else 0
+
# ── Authority ────────────────────────────────────────────────────────────
async def operator_pks(self) -> list[str]:
@@ -213,7 +436,10 @@ class Roster:
async with self._db.execute(
"SELECT i.pk_ed25519 FROM identities i "
"JOIN members m ON m.user_id = i.user_id "
- "WHERE m.role = 'operator' AND m.status = 'active'"
+ "WHERE m.role = 'operator' AND m.status = 'active' "
+ # An operator with two browsers has two keys and both may sign; a
+ # retired one must not.
+ "AND i.revoked_at IS NULL"
) as cur:
return [r["pk_ed25519"] for r in await cur.fetchall()]
@@ -369,12 +595,19 @@ class Roster:
async def purge_expired(self) -> int:
assert self._db
+ now = _now()
cur = await self._db.execute(
"DELETE FROM invites WHERE used_at IS NULL AND expires_at < ?",
- (_now(),),
+ (now,),
)
+ removed = cur.rowcount
+ # Device requests expire too, and an abandoned one left lying about is
+ # a row an approver could still be shown.
+ cur = await self._db.execute(
+ "DELETE FROM device_requests WHERE expires_at < ?", (now,))
+ removed += cur.rowcount
await self._db.commit()
- return cur.rowcount
+ return removed
async def open_roster(data_dir: Path) -> Roster:
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 4ec841f..9d16f82 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -65,6 +65,12 @@ from meshbay_common.adminop import (
admin_transcript,
)
from meshbay_common.crypto import pk_to_b64, wrap_gek_aes
+from meshbay_common.device import (
+ DEVICE_TTL,
+ device_add_transcript,
+ device_code_hash,
+ device_request_transcript,
+)
from meshbay_common.join import (
JOIN_TTL,
ROLE_MEMBER,
@@ -453,6 +459,16 @@ class WebRTCPeerSession:
self._do_invite_create(msg)
elif mtype == MNP.MEMBER_REVOKE:
self._do_member_revoke(msg)
+ elif mtype == MNP.DEVICE_REQUEST and self._nonce_node:
+ self._spawn(self._do_device_request(msg))
+ elif mtype == MNP.DEVICE_LOOKUP:
+ self._spawn(self._do_device_lookup(msg))
+ elif mtype == MNP.DEVICE_ADD:
+ self._spawn(self._do_device_add(msg))
+ elif mtype == MNP.DEVICE_LIST:
+ self._spawn(self._do_device_list(msg))
+ elif mtype == MNP.DEVICE_REVOKE:
+ self._spawn(self._do_device_revoke(msg))
elif mtype == MNP.MEMBER_UNPIN:
self._do_member_unpin(msg)
elif mtype == MNP.GEK_ROTATE:
@@ -901,15 +917,31 @@ class WebRTCPeerSession:
self._join_refuse("signature_invalid")
return
- known = await roster.get_identity(user_id)
+ # One person may hold several devices here — a browser and a desktop
+ # client are two keys on one account. So the question is not "is this
+ # THE key" but "is this ONE OF this account's live devices".
+ device = await roster.find_device(user_id, pk_ed_b64)
+ if device and device["pk_x25519"] != pk_x_b64:
+ # The Ed25519 key is pinned but arrives with a different encryption
+ # key. The join transcript signs both together, so this is either a
+ # client that regenerated half its identity or something splicing
+ # two messages; either way the pair is not the one admitted.
+ self._join_refuse(
+ "key_changed",
+ f"pinned x25519={device['pk_x25519'][:16]} presented={pk_x_b64[:16]}")
+ return
+ known = device
+ if not known and await roster.list_devices(user_id):
+ # The account is known here but this key is not one of its devices.
+ # Not an error to shout about: it is a second browser or a new
+ # client, and the way in is a device-add approved by a device that
+ # is already trusted — no operator, no new invitation code.
+ self._join_refuse(
+ "unknown_device",
+ f"presented={pk_ed_b64[:16]} — approve it from a device already "
+ f"paired with this node")
+ return
if known:
- if known["pk_ed25519"] != pk_ed_b64 or known["pk_x25519"] != pk_x_b64:
- # The blocking warning, raised where it matters: whoever this is
- # holds a different key than the person the operator paired.
- self._join_refuse(
- "key_changed",
- f"pinned={known['pk_ed25519'][:16]} presented={pk_ed_b64[:16]}")
- return
# An operator's row is node-wide (empty group), so a lookup for the
# group they happen to be opening finds nothing. Fall back to it, or
# the client is told it has no role on a node it administers.
@@ -956,6 +988,287 @@ class WebRTCPeerSession:
await self._join_ok(user_id, pk_x_raw, session_group or invite["group_id"],
role=invite["role"], recognised=False)
+
+ # ── Device linking ───────────────────────────────────────────────────────
+ #
+ # A person may hold several devices on one node. The authority admitting a
+ # new one is a key the node already pinned — never the hub, which has stored
+ # no user keys since 2026-08-14 and therefore cannot countersign anything.
+ # See docs/desktop-client-v1.md §4.
+
+ async def _do_device_request(self, msg: dict) -> None:
+ """
+ A new device files itself as pending, bound to a code it displays.
+
+ Served in the pre-proof window: by construction the caller holds no key
+ this node knows, so there is nothing yet to prove. Filing is inert —
+ nothing is admitted until an existing device countersigns.
+ """
+ roster = self._ctx.get("roster")
+ if roster is None or not self._user_id or not self._nonce_node:
+ self._send({"type": "error", "detail": "Not ready for a device request"})
+ return
+
+ if not self._spend_device_attempt():
+ return
+
+ pk_ed_b64 = str(msg.get("pk_ed25519", ""))
+ pk_x_b64 = str(msg.get("pk_x25519", ""))
+ code_hash = str(msg.get("code_hash", ""))
+ if not (pk_ed_b64 and pk_x_b64 and code_hash):
+ self._send({"type": "error", "detail": "Missing device keys or code"})
+ return
+
+ # The account must already be known here. Anti-spam rather than a
+ # security boundary: the filing key is unpinned by construction, so this
+ # bounds the table, not the trust.
+ existing = await roster.list_devices(self._user_id)
+ if not existing:
+ self._send({"type": "error",
+ "detail": "This account has no device on this node yet — "
+ "an invitation code is what admits the first"})
+ return
+ if len(existing) >= roster.MAX_DEVICES_PER_USER:
+ self._send({"type": "error",
+ "detail": f"Already {len(existing)} devices, which is the "
+ f"limit. Revoke one first."})
+ return
+
+ ts = int(msg.get("ts", 0))
+ if abs(time.time() - ts) > DEVICE_TTL:
+ self._send({"type": "error", "detail": "Device request expired"})
+ return
+
+ transcript = device_request_transcript(
+ node_pk_b64=self._node_pk_b64(), user_id=self._user_id,
+ pk_ed25519_b64=pk_ed_b64, pk_x25519_b64=pk_x_b64,
+ code_hash=code_hash, nonce_node=self._nonce_node, ts=ts)
+ try:
+ pk_ed = Ed25519PublicKey.from_public_bytes(base64.b64decode(pk_ed_b64))
+ sig = base64.b64decode(msg.get("sig", ""))
+ except Exception:
+ self._send({"type": "error", "detail": "Invalid device key encoding"})
+ return
+ if not self._verify_sig(pk_ed, transcript, sig):
+ # Proof of possession, and nothing more: this says the caller holds
+ # the keys, never that they belong to this account.
+ self._send({"type": "error", "detail": "Device signature invalid"})
+ return
+
+ ttl = int(self._ctx.get("device_request_ttl") or 3600)
+ expires = await roster.file_device_request(
+ user_id=self._user_id, username=self._username or "",
+ pk_ed25519=pk_ed_b64, pk_x25519=pk_x_b64,
+ code_hash=code_hash, ttl=ttl)
+ self._audit("device_request", f"{pk_ed_b64[:16]}")
+ log.info("Device request filed for %s (%s)", self._user_id[:8],
+ pk_ed_b64[:16])
+ self._send({"type": MNP.DEVICE_REQUEST_ACK, "v": MNP_VERSION,
+ "expires_at": expires})
+
+ async def _do_device_lookup(self, msg: dict) -> None:
+ """
+ List this account's pending device requests, each with its code hash.
+
+ **The node never learns the code**, which is what makes it unable to
+ substitute a key. It answers with candidates; the approver recomputes
+ `sha256(code ‖ keys)` for each and keeps the one that matches. A node
+ offering fabricated keys would have to produce a hash matching
+ `sha256(code ‖ fabricated)` — and it does not know the code.
+
+ An earlier version of this took the hash from the client and looked the
+ request up by it. That is circular: the client cannot compute the hash
+ without already knowing the keys it is asking about.
+ """
+ roster = self._ctx.get("roster")
+ if roster is None or not self._user_id:
+ self._send({"type": "error", "detail": "Roster not available"})
+ return
+
+ pending = await roster.list_device_requests(self._user_id)
+ self._send({
+ "type": MNP.DEVICE_LOOKUP_RESULT, "v": MNP_VERSION,
+ "requests": [
+ {"pk_ed25519": r["pk_ed25519"], "pk_x25519": r["pk_x25519"],
+ "code_hash": r["code_hash"], "created_at": r["created_at"]}
+ for r in pending
+ ],
+ })
+
+ async def _do_device_add(self, msg: dict) -> None:
+ """
+ Admit a device, countersigned by one this node already pinned.
+
+ The whole control is in `_verify_device_signer`: the signature must
+ verify against a **live device of this same account**. The hub holds no
+ user keys and so cannot produce one.
+ """
+ roster = self._ctx.get("roster")
+ if roster is None or not self._user_id or not self._nonce_node:
+ self._send({"type": "error", "detail": "Not ready to add a device"})
+ return
+ if not self._spend_device_attempt():
+ return
+
+ pk_ed_b64 = str(msg.get("pk_ed25519", ""))
+ pk_x_b64 = str(msg.get("pk_x25519", ""))
+ ts = int(msg.get("ts", 0))
+ if not (pk_ed_b64 and pk_x_b64):
+ self._send({"type": "error", "detail": "Missing device keys"})
+ return
+ if abs(time.time() - ts) > DEVICE_TTL:
+ self._send({"type": "error", "detail": "Approval expired"})
+ return
+
+ transcript = device_add_transcript(
+ node_pk_b64=self._node_pk_b64(), user_id=self._user_id,
+ pk_ed25519_b64=pk_ed_b64, pk_x25519_b64=pk_x_b64,
+ nonce_node=self._nonce_node, ts=ts)
+ signer = await self._verify_device_signer(roster, transcript,
+ msg.get("sig", ""))
+ if signer is None:
+ self._audit("device_add_refused", pk_ed_b64[:16])
+ self._send({"type": "error",
+ "detail": "Not signed by a device already paired here"})
+ return
+
+ devices = await roster.list_devices(self._user_id)
+ if len(devices) >= roster.MAX_DEVICES_PER_USER:
+ self._send({"type": "error", "detail": "Device limit reached"})
+ return
+
+ # Spend the request. Single use: an approval cannot be replayed, and a
+ # code that was used is gone whatever else happens next.
+ code_hash = str(msg.get("code_hash", ""))
+ if code_hash and not await roster.take_device_request(
+ code_hash, self._user_id):
+ self._send({"type": "error",
+ "detail": "That request is no longer pending"})
+ return
+
+ await roster.pin_identity(
+ user_id=self._user_id, username=self._username or "",
+ pk_ed25519=pk_ed_b64, pk_x25519=pk_x_b64, via="device",
+ label=str(msg.get("label", ""))[:64], added_by_pk=signer)
+ self._audit("device_added", f"{pk_ed_b64[:16]} by {signer[:16]}")
+ log.info("Device added for %s: %s (approved by %s)",
+ self._user_id[:8], pk_ed_b64[:16], signer[:16])
+ self._send({"type": MNP.DEVICE_ADD_ACK, "v": MNP_VERSION,
+ "pk_ed25519": pk_ed_b64})
+
+ async def _do_device_list(self, msg: dict) -> None:
+ """This account's devices. Anyone may read their own, nobody else's."""
+ roster = self._ctx.get("roster")
+ if roster is None or not self._user_id:
+ self._send({"type": "error", "detail": "Roster not available"})
+ return
+ devices = await roster.list_devices(self._user_id)
+ pending = await roster.pending_device_requests(self._user_id)
+ self._send({
+ "type": MNP.DEVICE_LIST_RESULT, "v": MNP_VERSION,
+ "pending": pending,
+ "devices": [
+ {"pk_ed25519": d["pk_ed25519"], "label": d.get("label", ""),
+ "pinned_at": d["pinned_at"], "pinned_via": d["pinned_via"],
+ "added_by_pk": d.get("added_by_pk", ""),
+ "is_this_one": d["pk_ed25519"] == self._pinned_pk}
+ for d in devices
+ ],
+ })
+
+ async def _do_device_revoke(self, msg: dict) -> None:
+ """
+ Retire one of this account's devices — a lost laptop.
+
+ Countersigned like an addition, by a live device of the same account.
+ The last one cannot go: an account with no device on this node can only
+ return through an operator's invitation code, and doing that to yourself
+ by accident is not a mistake worth allowing.
+ """
+ roster = self._ctx.get("roster")
+ if roster is None or not self._user_id or not self._nonce_node:
+ self._send({"type": "error", "detail": "Not ready"})
+ return
+ if not self._spend_device_attempt():
+ return
+
+ target = str(msg.get("pk_ed25519", ""))
+ ts = int(msg.get("ts", 0))
+ if not target:
+ self._send({"type": "error", "detail": "Missing device key"})
+ return
+ if abs(time.time() - ts) > DEVICE_TTL:
+ self._send({"type": "error", "detail": "Request expired"})
+ return
+
+ victim = await roster.find_device(self._user_id, target)
+ if victim is None:
+ self._send({"type": "error", "detail": "No such device"})
+ return
+
+ transcript = device_add_transcript(
+ node_pk_b64=self._node_pk_b64(), user_id=self._user_id,
+ pk_ed25519_b64=target, pk_x25519_b64=victim["pk_x25519"],
+ nonce_node=self._nonce_node, ts=ts)
+ signer = await self._verify_device_signer(roster, transcript,
+ msg.get("sig", ""))
+ if signer is None:
+ self._send({"type": "error",
+ "detail": "Not signed by a device already paired here"})
+ return
+
+ if len(await roster.list_devices(self._user_id)) <= 1:
+ self._send({"type": "error",
+ "detail": "This is your only device here — removing it "
+ "would need an operator code to come back"})
+ return
+
+ await roster.revoke_device(self._user_id, target)
+ self._audit("device_revoked", f"{target[:16]} by {signer[:16]}")
+ log.info("Device revoked for %s: %s", self._user_id[:8], target[:16])
+ self._send({"type": MNP.DEVICE_ADD_ACK, "v": MNP_VERSION,
+ "revoked": target})
+
+ async def _verify_device_signer(self, roster, transcript: bytes,
+ sig_b64: str) -> str | None:
+ """
+ The pinned key that signed this, or None.
+
+ Every live device of the account is tried, because any of them may
+ approve. A revoked one is not in the list — that is the point of marking
+ rather than deleting: a lost laptop must stop being able to admit its
+ replacement.
+ """
+ try:
+ sig = base64.b64decode(sig_b64)
+ except Exception:
+ return None
+ for device in await roster.list_devices(self._user_id):
+ try:
+ pk = Ed25519PublicKey.from_public_bytes(
+ base64.b64decode(device["pk_ed25519"]))
+ except Exception:
+ continue
+ if self._verify_sig(pk, transcript, sig):
+ return device["pk_ed25519"]
+ return None
+
+ def _spend_device_attempt(self) -> bool:
+ """
+ Bound guessing on this connection, as the join path does.
+
+ A code is 40 bits, single use and bound to the keys it names, so this is
+ depth rather than the control — but an unbounded loop over the lookup is
+ still a free oracle, and a burst of failures belongs in the audit log.
+ """
+ self._device_attempts = getattr(self, "_device_attempts", 0) + 1
+ if self._device_attempts > 5:
+ self._audit("device_attempts_exceeded", str(self._device_attempts))
+ self._send({"type": "error",
+ "detail": "Too many device attempts on this connection"})
+ return False
+ return True
+
def _group_join_policy(self, group_id: str) -> str:
"""
Admission policy for a group, read from the node's own configuration.
diff --git a/packages/meshbay-node/tests/test_device_linking.py b/packages/meshbay-node/tests/test_device_linking.py
new file mode 100644
index 0000000..3580ff8
--- /dev/null
+++ b/packages/meshbay-node/tests/test_device_linking.py
@@ -0,0 +1,414 @@
+"""
+One person, several devices on one node.
+
+Identity keys are per node, so a browser and a desktop client are two keys on
+one account. Admitting the second must not need an operator — that friction is
+what would make "the native client must not prevent web use" fail — and must not
+be something the hub or the node itself can do.
+
+The controls, and the tests that hold them:
+
+ * **A key the node already pinned countersigns.** The hub has stored no user
+ keys since 2026-08-14, so it cannot produce that signature.
+ * **The code is hashed together with the requesting keys**, so the node cannot
+ answer an approver with a substituted key: the approver recomputes the hash
+ and finds nothing.
+ * **Nothing rests on a human comparing digits.** Phase 12.1 dropped that
+ ritual as "correct, unusable as the default"; it must not come back here.
+
+Everything below is written as "this does not work".
+"""
+
+import base64
+import time
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from conftest import one_root
+from meshbay_common.crypto import pk_to_b64
+from meshbay_common.device import (
+ device_add_transcript,
+ device_code_hash,
+ device_request_transcript,
+)
+from meshbay_common.join import ROLE_MEMBER
+from meshbay_common.protocol import MNP
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.roster import generate_code, normalize_code, open_roster
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+GROUP = "g" * 32
+NONCE = b"\x11" * 32
+
+
+@pytest.fixture
+async def roster(tmp_path):
+ r = await open_roster(tmp_path)
+ yield r
+ await r.close()
+
+
+def _keys():
+ sk_ed = Ed25519PrivateKey.generate()
+ sk_x = Ed25519PrivateKey.generate() # stand-in; only its b64 is used
+ return sk_ed, pk_to_b64(sk_ed.public_key()), pk_to_b64(sk_x.public_key())
+
+
+async def _session(tmp_path: Path, roster, user_id: str = "alice"):
+ shared = tmp_path / "shared"
+ shared.mkdir(exist_ok=True)
+ index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
+
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = {
+ "roots": one_root(shared), "index": index, "sk_node": index.sk_node,
+ "roster": roster, "device_request_ttl": 3600,
+ "groups": {GROUP: {"gek": b"\x01" * 32, "index": index,
+ "roots": one_root(shared), "join_policy": "invite"}},
+ }
+ session._group_id = GROUP
+ session._user_id = user_id
+ session._username = user_id
+ session._pk_user = ""
+ session._pinned_pk = ""
+ session._uploads = {}
+ session._nonce_node = NONCE
+ session._remote_ip = ""
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ return session
+
+
+def _last(session):
+ return session.sent[-1] if session.sent else {}
+
+
+async def _file_request(session, sk_new, pk_ed, pk_x, code):
+ """A new device asks to be added, signing over its own keys."""
+ code_hash = device_code_hash(normalize_code(code), pk_ed, pk_x)
+ ts = int(time.time())
+ transcript = device_request_transcript(
+ node_pk_b64=session._node_pk_b64(), user_id=session._user_id,
+ pk_ed25519_b64=pk_ed, pk_x25519_b64=pk_x, code_hash=code_hash,
+ nonce_node=NONCE, ts=ts)
+ await session._do_device_request({
+ "pk_ed25519": pk_ed, "pk_x25519": pk_x, "code_hash": code_hash,
+ "ts": ts, "sig": base64.b64encode(sk_new.sign(transcript)).decode(),
+ })
+ return code_hash
+
+
+async def _approve(session, sk_signer, pk_ed, pk_x, code_hash=""):
+ ts = int(time.time())
+ transcript = device_add_transcript(
+ node_pk_b64=session._node_pk_b64(), user_id=session._user_id,
+ pk_ed25519_b64=pk_ed, pk_x25519_b64=pk_x, nonce_node=NONCE, ts=ts)
+ await session._do_device_add({
+ "pk_ed25519": pk_ed, "pk_x25519": pk_x, "ts": ts,
+ "code_hash": code_hash,
+ "sig": base64.b64encode(sk_signer.sign(transcript)).decode(),
+ })
+
+
+async def _match_by_code(session, code):
+ """
+ What an approving client does: list what is pending and recompute.
+
+ The code never reaches the node. The client hashes it against each
+ candidate's keys and keeps the row that matches — so a node offering
+ fabricated keys produces no match, having no way to compute a hash over a
+ code it does not know.
+ """
+ await session._do_device_lookup({})
+ listed = _last(session)
+ if listed.get("type") != MNP.DEVICE_LOOKUP_RESULT:
+ return None
+ for req in listed.get("requests", []):
+ expect = device_code_hash(normalize_code(code), req["pk_ed25519"],
+ req["pk_x25519"])
+ if expect == req["code_hash"]:
+ return req
+ return None
+
+
+# ── The happy path, so the refusals mean something ───────────────────────────
+
+async def test_an_existing_device_admits_a_new_one(tmp_path, roster):
+ sk_old, pk_old_ed, pk_old_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code")
+ sk_new, pk_new_ed, pk_new_x = _keys()
+
+ session = await _session(tmp_path, roster)
+ code = generate_code()
+ await _file_request(session, sk_new, pk_new_ed, pk_new_x, code)
+ assert _last(session)["type"] == MNP.DEVICE_REQUEST_ACK
+
+ match = await _match_by_code(session, code)
+ assert match is not None, "the approver could not find the pending request"
+ assert match["pk_ed25519"] == pk_new_ed
+
+ await _approve(session, sk_old, pk_new_ed, pk_new_x,
+ code_hash=match["code_hash"])
+
+ assert _last(session)["type"] == MNP.DEVICE_ADD_ACK
+ devices = await roster.list_devices("alice")
+ assert {d["pk_ed25519"] for d in devices} == {pk_old_ed, pk_new_ed}
+ added = next(d for d in devices if d["pk_ed25519"] == pk_new_ed)
+ assert added["added_by_pk"] == pk_old_ed, "provenance is not recorded"
+
+
+async def test_both_devices_then_open_the_group(tmp_path, roster):
+ """The point of the whole exercise: web and native at the same time."""
+ sk_old, pk_old_ed, pk_old_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code")
+ await roster.set_member(GROUP, "alice", ROLE_MEMBER, "active", "grenet")
+ _, pk_new_ed, pk_new_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_new_ed, pk_new_x, "device",
+ added_by_pk=pk_old_ed)
+
+ for pk in (pk_old_ed, pk_new_ed):
+ assert await roster.find_device("alice", pk) is not None
+ assert await roster.is_authorized(GROUP, "alice")
+
+
+# ── What must not work ───────────────────────────────────────────────────────
+
+async def test_the_request_alone_admits_nothing(tmp_path, roster):
+ """Filing is inert. A node that pinned here would let anyone with a hub
+ token join any account that has ever used it."""
+ _, pk_old_ed, pk_old_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code")
+ sk_new, pk_new_ed, pk_new_x = _keys()
+
+ session = await _session(tmp_path, roster)
+ await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code())
+
+ assert await roster.find_device("alice", pk_new_ed) is None
+ assert [d["pk_ed25519"] for d in await roster.list_devices("alice")] == \
+ [pk_old_ed]
+
+
+async def test_the_new_device_cannot_approve_itself(tmp_path, roster):
+ """
+ Otherwise anyone the hub can mint a token for walks in: the request is
+ self-signed by construction, so self-approval would be no control at all.
+ """
+ _, pk_old_ed, pk_old_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code")
+ sk_new, pk_new_ed, pk_new_x = _keys()
+
+ session = await _session(tmp_path, roster)
+ await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code())
+ await _approve(session, sk_new, pk_new_ed, pk_new_x)
+
+ assert _last(session)["type"] == "error"
+ assert await roster.find_device("alice", pk_new_ed) is None
+
+
+async def test_a_stranger_cannot_approve(tmp_path, roster):
+ """A key belonging to somebody else, or to nobody, is not this account's."""
+ _, pk_old_ed, pk_old_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code")
+ sk_bob, pk_bob_ed, pk_bob_x = _keys()
+ await roster.pin_identity("bob", "bob", pk_bob_ed, pk_bob_x, "code")
+ _, pk_new_ed, pk_new_x = _keys()
+
+ session = await _session(tmp_path, roster)
+ await _approve(session, sk_bob, pk_new_ed, pk_new_x)
+
+ assert _last(session)["type"] == "error"
+ assert await roster.find_device("alice", pk_new_ed) is None
+
+
+async def test_a_revoked_device_cannot_admit_its_replacement(tmp_path, roster):
+ """
+ The lost laptop. Marking rather than deleting is what makes this hold: a
+ deleted row is a key the node would pin again on the next device-add.
+ """
+ sk_lost, pk_lost_ed, pk_lost_x = _keys()
+ _, pk_keep_ed, pk_keep_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_lost_ed, pk_lost_x, "code")
+ await roster.pin_identity("alice", "alice", pk_keep_ed, pk_keep_x, "device")
+ await roster.revoke_device("alice", pk_lost_ed)
+
+ _, pk_new_ed, pk_new_x = _keys()
+ session = await _session(tmp_path, roster)
+ await _approve(session, sk_lost, pk_new_ed, pk_new_x)
+
+ assert _last(session)["type"] == "error"
+ assert await roster.find_device("alice", pk_new_ed) is None
+
+
+async def test_an_account_with_no_device_here_cannot_file(tmp_path, roster):
+ """The first device is admitted by an operator's invitation code. Letting
+ this path serve that purpose would bypass the roster entirely."""
+ sk_new, pk_new_ed, pk_new_x = _keys()
+ session = await _session(tmp_path, roster, user_id="nobody")
+
+ await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code())
+
+ assert _last(session)["type"] == "error"
+ assert "invitation code" in _last(session)["detail"]
+
+
+async def test_a_signature_by_the_wrong_key_is_not_a_request(tmp_path, roster):
+ """Proof of possession: the request must be signed by the keys it presents."""
+ _, pk_old_ed, pk_old_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code")
+ sk_other, _, _ = _keys()
+ _, pk_new_ed, pk_new_x = _keys()
+
+ session = await _session(tmp_path, roster)
+ await _file_request(session, sk_other, pk_new_ed, pk_new_x, generate_code())
+
+ assert _last(session)["type"] == "error"
+ assert "signature" in _last(session)["detail"].lower()
+
+
+# ── The code binds the keys ──────────────────────────────────────────────────
+
+async def test_the_node_cannot_substitute_the_keys(tmp_path, roster):
+ """
+ The load-bearing property. The hash covers the code **and** the requesting
+ keys, so an approver looking a request up with different keys finds nothing
+ — and never signs. This is what replaces "compare these digits".
+ """
+ _, pk_old_ed, pk_old_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code")
+ sk_new, pk_new_ed, pk_new_x = _keys()
+ _, pk_evil_ed, pk_evil_x = _keys()
+
+ session = await _session(tmp_path, roster)
+ code = generate_code()
+ await _file_request(session, sk_new, pk_new_ed, pk_new_x, code)
+
+ # A node that answered with keys of its own choosing would have to produce a
+ # hash matching sha256(code ‖ those keys) — over a code it never receives.
+ forged = device_code_hash(normalize_code(code), pk_evil_ed, pk_evil_x)
+ real = (await _match_by_code(session, code))["code_hash"]
+
+ assert forged != real, "substituted keys produced a matching hash"
+ # And the client's own matching would reject the substitution outright.
+ await session._do_device_lookup({})
+ offered = _last(session)["requests"]
+ assert all(r["pk_ed25519"] != pk_evil_ed for r in offered)
+
+
+async def test_a_wrong_code_finds_nothing(tmp_path, roster):
+ _, pk_old_ed, pk_old_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code")
+ sk_new, pk_new_ed, pk_new_x = _keys()
+
+ session = await _session(tmp_path, roster)
+ await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code())
+
+ assert await _match_by_code(session, generate_code()) is None
+
+
+async def test_a_code_is_spent_once(tmp_path, roster):
+ _, pk_old_ed, pk_old_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code")
+ sk_new, pk_new_ed, pk_new_x = _keys()
+
+ sk_old_signer, pk_old_ed2, pk_old_x2 = _keys()
+ await roster.pin_identity("alice", "alice", pk_old_ed2, pk_old_x2, "device")
+ session = await _session(tmp_path, roster)
+ code = generate_code()
+ await _file_request(session, sk_new, pk_new_ed, pk_new_x, code)
+
+ match = await _match_by_code(session, code)
+ await _approve(session, sk_old_signer, pk_new_ed, pk_new_x,
+ code_hash=match["code_hash"])
+ assert _last(session)["type"] == MNP.DEVICE_ADD_ACK
+
+ # Spent: the same approval cannot be replayed.
+ await _approve(session, sk_old_signer, pk_new_ed, pk_new_x,
+ code_hash=match["code_hash"])
+ assert _last(session)["type"] == "error"
+
+
+async def test_another_account_cannot_redeem_your_code(tmp_path, roster):
+ """Scoped to the account as well as to the keys."""
+ _, pk_a_ed, pk_a_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_a_ed, pk_a_x, "code")
+ _, pk_b_ed, pk_b_x = _keys()
+ await roster.pin_identity("bob", "bob", pk_b_ed, pk_b_x, "code")
+ sk_new, pk_new_ed, pk_new_x = _keys()
+
+ alice = await _session(tmp_path, roster, user_id="alice")
+ code = generate_code()
+ await _file_request(alice, sk_new, pk_new_ed, pk_new_x, code)
+
+ bob = await _session(tmp_path, roster, user_id="bob")
+
+ # Scoped to the account: Bob is not offered Alice's pending request at all,
+ # so the code buys him nothing even if he has it.
+ assert await _match_by_code(bob, code) is None
+
+
+async def test_guessing_is_bounded_on_a_connection(tmp_path, roster):
+ _, pk_old_ed, pk_old_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code")
+ session = await _session(tmp_path, roster)
+
+ sk_new, pk_new_ed, pk_new_x = _keys()
+ for _ in range(8):
+ await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code())
+
+ assert "Too many device attempts" in _last(session)["detail"]
+
+
+# ── Limits and revocation ────────────────────────────────────────────────────
+
+async def test_the_device_ceiling_holds(tmp_path, roster):
+ """
+ A chain of devices inherits the weakness of its weakest ancestor, so the
+ answer to "how many" is a ceiling and visibility, not cryptography.
+ """
+ sk_first, pk_first_ed, pk_first_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_first_ed, pk_first_x, "code")
+ for _ in range(roster.MAX_DEVICES_PER_USER - 1):
+ _, pk_ed, pk_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_ed, pk_x, "device")
+
+ session = await _session(tmp_path, roster)
+ sk_new, pk_new_ed, pk_new_x = _keys()
+ await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code())
+
+ assert _last(session)["type"] == "error"
+ assert "limit" in _last(session)["detail"]
+
+
+async def test_your_last_device_cannot_be_revoked(tmp_path, roster):
+ """Removing it would need an operator's code to come back, and doing that
+ to yourself by accident is not a mistake worth allowing."""
+ sk_only, pk_only_ed, pk_only_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_only_ed, pk_only_x, "code")
+ session = await _session(tmp_path, roster)
+
+ ts = int(time.time())
+ transcript = device_add_transcript(
+ node_pk_b64=session._node_pk_b64(), user_id="alice",
+ pk_ed25519_b64=pk_only_ed, pk_x25519_b64=pk_only_x,
+ nonce_node=NONCE, ts=ts)
+ await session._do_device_revoke({
+ "pk_ed25519": pk_only_ed, "ts": ts,
+ "sig": base64.b64encode(sk_only.sign(transcript)).decode()})
+
+ assert _last(session)["type"] == "error"
+ assert await roster.find_device("alice", pk_only_ed) is not None
+
+
+async def test_unpinning_an_account_takes_every_device(tmp_path, roster):
+ """`member unpin` is what an operator runs when someone must start over.
+ Leaving one device would let them walk back in with a forgotten key."""
+ for _ in range(3):
+ _, pk_ed, pk_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_ed, pk_x, "device")
+
+ assert len(await roster.list_devices("alice")) == 3
+ await roster.unpin("alice")
+ assert await roster.list_devices("alice") == []
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):