diff options
Diffstat (limited to 'packages')
26 files changed, 2146 insertions, 25 deletions
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): |