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