diff options
Diffstat (limited to 'packages/meshbay-hub')
18 files changed, 986 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. * diff --git a/packages/meshbay-hub/tests/test_device_auth.py b/packages/meshbay-hub/tests/test_device_auth.py new file mode 100644 index 0000000..752a0e6 --- /dev/null +++ b/packages/meshbay-hub/tests/test_device_auth.py @@ -0,0 +1,279 @@ +""" +Signing in to the hub with a device key. + +The passphrase stays the account's credential and its only recovery path; this +is the day-to-day path once a device is registered, so a client does not derive +a key from the passphrase on every sign-in. + +The thing to be careful about, and the reason these tests are written as +refusals: this looks like the key directory that was **H3**, and must not become +one. What keeps it apart — + + * nothing reads these keys but the hub itself, and no endpoint publishes them; + * no group key is ever wrapped for one; + * they are **not** the per-node identity keys, which are generated per node, + pinned there, and never leave that relationship. + +`test_the_hub_publishes_no_device_keys` is the one that would notice if that +stopped being true. +""" + +import base64 +import time + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_common.crypto import pk_to_b64 + + +def _device(): + sk = Ed25519PrivateKey.generate() + return sk, pk_to_b64(sk.public_key()) + + +def _sign(sk, username: str, ts: int | None = None) -> dict: + ts = int(time.time()) if ts is None else ts + message = f"meshbay:user_auth:{username}:{ts}".encode() + return {"username": username, "timestamp": ts, + "signature": base64.b64encode(sk.sign(message)).decode()} + + +async def _account(client, username="alice") -> str: + await client.post("/v1/users/register", json={ + "username": username, "auth_key": "k" * 44, + "email": f"{username}@example.invalid"}) + resp = await client.post("/v1/users/login", json={ + "username": username, "auth_key": "k" * 44}) + return resp.json()["access_token"] + + +async def _register_device(client, token: str, pk: str, label: str = ""): + return await client.post( + "/v1/users/devices", + json={"pk_auth_ed25519": pk, "label": label}, + headers={"Authorization": f"Bearer {token}"}) + + +# ── The path that must work ────────────────────────────────────────────────── + +async def test_a_registered_device_signs_in(client): + token = await _account(client) + sk, pk = _device() + assert (await _register_device(client, token, pk, "laptop")).status_code == 201 + + resp = await client.post("/v1/users/auth", json=_sign(sk, "alice")) + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["access_token"] and body["refresh_token"] + assert body["token_type"] == "bearer" + + +async def test_the_session_it_returns_is_a_real_one(client): + """A device sign-in must produce a token that works, not a special case.""" + token = await _account(client) + sk, pk = _device() + await _register_device(client, token, pk) + + device_token = (await client.post( + "/v1/users/auth", json=_sign(sk, "alice"))).json()["access_token"] + me = await client.get("/v1/users/me", + headers={"Authorization": f"Bearer {device_token}"}) + + assert me.status_code == 200 + assert me.json()["username"] == "alice" + + +async def test_several_devices_on_one_account(client): + """The whole point: a browser and a desktop client are both this person.""" + token = await _account(client) + sk_a, pk_a = _device() + sk_b, pk_b = _device() + await _register_device(client, token, pk_a, "browser") + await _register_device(client, token, pk_b, "desktop") + + for sk in (sk_a, sk_b): + assert (await client.post("/v1/users/auth", + json=_sign(sk, "alice"))).status_code == 200 + + listed = await client.get("/v1/users/devices", + headers={"Authorization": f"Bearer {token}"}) + assert {d["label"] for d in listed.json()["devices"]} == {"browser", "desktop"} + + +# ── What must not work ─────────────────────────────────────────────────────── + +async def test_an_unregistered_key_is_refused(client): + await _account(client) + sk, _ = _device() + + resp = await client.post("/v1/users/auth", json=_sign(sk, "alice")) + + assert resp.status_code == 401 + + +async def test_another_accounts_device_cannot_sign_in_as_you(client): + token_a = await _account(client, "alice") + await _account(client, "bob") + sk, pk = _device() + await _register_device(client, token_a, pk) + + # Alice's device, Bob's name. The signature covers the username, so it does + # not verify — and even if it did, the key is not on Bob's account. + resp = await client.post("/v1/users/auth", json=_sign(sk, "bob")) + + assert resp.status_code == 401 + + +async def test_a_stale_signature_is_refused(client): + """The window is what stops a captured signature being replayed later.""" + token = await _account(client) + sk, pk = _device() + await _register_device(client, token, pk) + + old = int(time.time()) - 3600 + resp = await client.post("/v1/users/auth", json=_sign(sk, "alice", ts=old)) + + assert resp.status_code == 401 + assert "timestamp" in resp.json()["detail"].lower() + + +async def test_a_signature_for_a_different_timestamp_does_not_verify(client): + token = await _account(client) + sk, pk = _device() + await _register_device(client, token, pk) + + signed = _sign(sk, "alice") + signed["timestamp"] = signed["timestamp"] + 1 # inside the window, wrong + + assert (await client.post("/v1/users/auth", json=signed)).status_code == 401 + + +async def test_a_device_cannot_enrol_itself(client): + """Registration needs an existing session, which means the passphrase was + entered a moment ago. Otherwise anyone could add a key to any account.""" + await _account(client) + _, pk = _device() + + resp = await client.post("/v1/users/devices", + json={"pk_auth_ed25519": pk, "label": "sneaky"}) + + # 422 rather than 401: with no Authorization header at all, FastAPI refuses + # at dependency resolution before the handler runs. A refusal either way — + # what matters is that nothing was created. + assert resp.status_code in (401, 403, 422) + signed_in = await client.post("/v1/users/auth", json=_sign( + Ed25519PrivateKey.generate(), "alice")) + assert signed_in.status_code == 401 + + +async def test_one_key_belongs_to_one_account(client): + """Sharing it would make "who signed in" a question with two answers.""" + token_a = await _account(client, "alice") + token_b = await _account(client, "bob") + _, pk = _device() + await _register_device(client, token_a, pk) + + resp = await _register_device(client, token_b, pk) + + assert resp.status_code == 409 + + +async def test_a_suspended_account_cannot_sign_in_with_a_device(client): + token = await _account(client) + sk, pk = _device() + await _register_device(client, token, pk) + + from meshbay_hub.db.engine import get_session_factory + from meshbay_hub.db.models import User + from sqlalchemy import update + async with get_session_factory()() as s: + await s.execute(update(User).where(User.username == "alice") + .values(status="suspended")) + await s.commit() + + resp = await client.post("/v1/users/auth", json=_sign(sk, "alice")) + assert resp.status_code == 403 + + +async def test_garbage_is_not_a_key(client): + token = await _account(client) + resp = await _register_device(client, token, "not-base64-at-all!!") + assert resp.status_code == 400 + + +# ── Not a key directory ────────────────────────────────────────────────────── + +async def test_the_hub_publishes_no_device_keys(client): + """ + **H3 is what this is guarding.** The hub used to publish user public keys + and the invite path wrapped the group key for whatever came back. Device + auth keys must stay invisible to everyone but the hub: no endpoint returns + another account's, and `/pubkeys` must not grow one. + """ + token = await _account(client, "alice") + _, pk = _device() + await _register_device(client, token, pk) + + # `/pubkeys` is itself behind a session — it is an account lookup for + # invitations, not a public directory — so ask it as a signed-in member. + public = await client.get("/v1/users/alice/pubkeys", + headers={"Authorization": f"Bearer {token}"}) + assert public.status_code == 200 + body = public.text + assert pk not in body, "a device key is reachable through the public lookup" + assert "pk_auth" not in body + + +async def test_you_cannot_read_another_accounts_devices(client): + token_a = await _account(client, "alice") + token_b = await _account(client, "bob") + _, pk = _device() + await _register_device(client, token_a, pk, "alice-laptop") + + listed = await client.get("/v1/users/devices", + headers={"Authorization": f"Bearer {token_b}"}) + + assert listed.status_code == 200 + assert listed.json()["devices"] == [] + + +async def test_removing_a_device_stops_it_signing_in(client): + token = await _account(client) + sk, pk = _device() + created = await _register_device(client, token, pk) + device_id = created.json()["id"] + + gone = await client.delete(f"/v1/users/devices/{device_id}", + headers={"Authorization": f"Bearer {token}"}) + assert gone.status_code == 200 + + assert (await client.post("/v1/users/auth", + json=_sign(sk, "alice"))).status_code == 401 + + +async def test_you_cannot_remove_someone_elses_device(client): + token_a = await _account(client, "alice") + token_b = await _account(client, "bob") + _, pk = _device() + device_id = (await _register_device(client, token_a, pk)).json()["id"] + + resp = await client.delete(f"/v1/users/devices/{device_id}", + headers={"Authorization": f"Bearer {token_b}"}) + + assert resp.status_code == 404 + + +# ── Version floor (C3) ─────────────────────────────────────────────────────── + +async def test_the_hub_states_a_minimum_client_version(client): + """ + An installed client meets a newer hub for the first time once the interface + ships in a package. Cheap to add now, awkward to retrofit. + """ + resp = await client.get("/v1/hub/version") + + assert resp.status_code == 200 + client_floor = resp.json()["client"] + assert client_floor["minimum"] and client_floor["recommended"] |