From 768e07046368819b8a8f15c8b21e5a8bbfcdf282 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 18 Aug 2026 03:24:55 +0200 Subject: feat: device linking, and signing in to the hub with a device key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage C. Identity keys are per node, so a browser and a desktop client are two keys on one account there — and the node refused the second where it accepted the first. Without this, an account created natively could never be opened in a browser without an operator code per node, and "a native client must not prevent web use" would have been dead on arrival. Device linking (node) --------------------- `identities` is keyed by `(user_id, pk_ed25519)` instead of `user_id` alone. The old shape did `INSERT OR REPLACE`, so a second device overwrote the first silently; SQLite cannot change a primary key in place, so the table is rebuilt. Existing pins are carried over — verified against a live roster with 10 of them, nobody re-pairs. A new device files a request bound by `sha256(code ‖ its own keys)`, and a key the node **already pinned** countersigns it. The hub cannot: it has stored no user keys since 2026-08-14, which is what makes this safe to do without an operator in the loop. **The code never reaches the node.** It lists this account's pending requests with their stored hashes; the approver recomputes and keeps the match. A node offering fabricated keys would have to produce a hash over a code it has never seen. Nothing rests on a human comparing digits — that ritual was dropped in 12.1 as "correct, unusable as the default" and must not return by the back door. The design document had the approver look a request up *by* its hash, which is circular: computing it needs the keys being asked about. Corrected in both. Revocation marks rather than deletes, because a deleted row is a key the node would happily pin again — which is the laptop somebody just reported lost. Your last device cannot be revoked: coming back would need an operator's code. Hub — the only change in the whole plan --------------------------------------- `POST /v1/users/auth` signs in with a device Ed25519 key, on the same pattern as `/v1/nodes/auth`, plus `/v1/users/devices` to register, list and retire. New `user_devices` table with an Alembic migration, because `create_all()` is not one. This is **not** the key directory that was H3, and the tests say so: nothing reads it but the hub, no group key is ever wrapped for one, and it is a different key from the per-node identities. What it does cost is metadata — the hub now knows how many devices an account has and when each last signed in. Also `client.minimum` / `client.recommended` in `GET /v1/hub/version`: an installed client meets a newer hub the day the interface ships in a package, and that is cheap now and awkward to retrofit. Browser ------- The `key_changed` refusal becomes `unknown_device` and offers a linking code instead of telling someone to find their operator. The Members panel lists this account's devices here, approves one by code, and retires one. 773 tests pass. `e2e.py` gained a step that links a device end to end against the live deployment — file, list, recompute, countersign, then open the group with the new keys and no code — and it also gained `recv_type`, because a step that assumes the next message is its own answer reads an ack left by the step before. Co-Authored-By: Claude Opus 5 --- packages/meshbay-hub/src/meshbay_hub/api/hub.py | 23 +++ packages/meshbay-hub/src/meshbay_hub/api/users.py | 184 ++++++++++++++++++++- .../versions/e5a2b7d31f88_user_device_auth_keys.py | 47 ++++++ packages/meshbay-hub/src/meshbay_hub/db/models.py | 35 ++++ packages/meshbay-hub/src/meshbay_hub/static/app.js | 99 +++++++++++ .../meshbay-hub/src/meshbay_hub/static/crypto.js | 67 ++++++++ .../src/meshbay_hub/static/locales/de.js | 13 ++ .../src/meshbay_hub/static/locales/en.js | 13 ++ .../src/meshbay_hub/static/locales/es.js | 13 ++ .../src/meshbay_hub/static/locales/fr.js | 13 ++ .../src/meshbay_hub/static/locales/it.js | 13 ++ .../src/meshbay_hub/static/locales/ja.js | 13 ++ .../src/meshbay_hub/static/locales/nl.js | 13 ++ .../src/meshbay_hub/static/locales/pl.js | 13 ++ .../src/meshbay_hub/static/locales/pt-BR.js | 13 ++ .../src/meshbay_hub/static/locales/zh-CN.js | 13 ++ .../src/meshbay_hub/static/transport.js | 123 ++++++++++++++ 17 files changed, 707 insertions(+), 1 deletion(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e5a2b7d31f88_user_device_auth_keys.py (limited to 'packages/meshbay-hub/src') 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, `} ${error && html`
${error}
`} + ${needsDevice && html` +
+

${t('device.add_title')}

+

${t('device.add_hint')}

+ ${!deviceCode && html` + + `} + ${deviceCode && html` +

${t('device.add_show')}

+

+ ${deviceCode} +

+ `} +
+ `} ${needsCode && html`

${t('group.join_code_title')}

@@ -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,
`} + +
+

${t('device.mine_title')}

+

${t('device.mine_hint')}

+ ${deviceMsg && html`

${deviceMsg}

`} + ${devices.length === 0 && html` +

${t('device.mine_empty')}

+ `} + ${devices.map(d => html` +
+ ${d.pk_ed25519.slice(0, 16)}… + ${d.is_this_one && html`${t('device.this_one')}`} + ${d.pinned_via}${d.label ? ' · ' + d.label : ''} + ${!d.is_this_one && devices.length > 1 && html` + + `} +
+ `)} +
+

${t('device.approve_hint')}

+
+ setApproveCode(e.target.value)} /> + +
+
+
`; } 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. * -- cgit v1.2.3