diff options
Diffstat (limited to 'packages')
15 files changed, 234 insertions, 276 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py index 223f064..fca218f 100644 --- a/packages/meshbay-common/src/meshbay_common/handshake.py +++ b/packages/meshbay-common/src/meshbay_common/handshake.py @@ -75,9 +75,13 @@ class AuthorizedPeer: user_id: str group_id: str username: str - pk_user: str jti: str + # No `pk_user`. The hub used to put a user key in the token and the node + # recorded it as the uploader's identity, which let whoever issued tokens + # decide who could delete a file. Identity keys are pinned by the node + # (see roster.py); the hub certifies accounts, not keys. + def handshake_transcript( role: str, @@ -193,7 +197,6 @@ def authorize_token( user_id=user_id, group_id=group_id, username=decoded.get("username", ""), - pk_user=decoded.get("pk_user", ""), jti=jti, ) diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py index 6582a86..0770148 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py @@ -69,7 +69,7 @@ async def node_auth( group_ids = [gid for (gid,) in memberships.all()] access_token = issue_access_token( - user.id, user.pk_node_ed25519, ttl=3600, groups=group_ids, scope="node") + user.id, ttl=3600, groups=group_ids, scope="node") db.add(IPLog(user_id=user.id, event="node_auth", ip_address=client_ip(request))) await db.commit() diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 5a0f9dc..af9141c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -50,8 +50,6 @@ class RegisterRequest(BaseModel): email: str password: str | None = None # deprecated — legacy native clients auth_key: str | None = None # PBKDF2-derived, new clients - pk_user_ed25519: str # base64 raw 32B - pk_user_x25519: str # base64 raw 32B @field_validator("username") @classmethod @@ -120,8 +118,6 @@ async def register( pw_hash=pw_hash, pw_salt=pw_salt, pw_version=pw_ver, - pk_ed25519=body.pk_user_ed25519, - pk_x25519=body.pk_user_x25519, hub_id=hub_id, ) db.add(user) @@ -211,8 +207,7 @@ async def login( 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, user.pk_ed25519, ttl=_ttl(), groups=group_ids) + access_token = issue_access_token(user.id, ttl=_ttl(), groups=group_ids) raw_rt, rt_hash = generate_refresh_token() family_id = str(uuid.uuid4()) @@ -277,8 +272,7 @@ async def token_refresh( memberships = await db.execute( select(GroupMember.group_id).where(GroupMember.user_id == user.id)) group_ids = [gid for (gid,) in memberships.all()] - new_access = issue_access_token( - user.id, user.pk_ed25519, ttl=_ttl(), groups=group_ids) + new_access = issue_access_token(user.id, ttl=_ttl(), groups=group_ids) await db.commit() return { @@ -324,39 +318,10 @@ async def register_node_key( return {"status": "stored", "pk_node_ed25519": body.pk_node_ed25519} -class RotateKeysRequest(BaseModel): - pk_user_ed25519: str # base64 raw 32B - pk_user_x25519: str # base64 raw 32B - - -@router.put("/me/keys") -async def rotate_browser_keys( - body: RotateKeysRequest, - current_user: User = Depends(require_user_scope), - db: AsyncSession = Depends(get_db), -): - for field, label in [ - (body.pk_user_ed25519, "Ed25519"), - (body.pk_user_x25519, "X25519"), - ]: - try: - raw = base64.b64decode(field) - if len(raw) != 32: - raise ValueError - except Exception: - raise HTTPException( - status_code=400, - detail=f"Invalid {label} public key (need 32 bytes base64)", - ) - - current_user.pk_ed25519 = body.pk_user_ed25519 - current_user.pk_x25519 = body.pk_user_x25519 - await db.commit() - return { - "status": "updated", - "pk_ed25519": body.pk_user_ed25519, - "pk_x25519": body.pk_user_x25519, - } +# Key rotation used to live here (`PUT /me/keys`). Identity keys are per node +# now, so rotating means `meshbay-node member unpin <user>` and pairing again with +# a fresh code — an operator decision on the machine that pinned it, not a hub +# call that silently changes what every node believes about someone. @router.get("/{username}/pubkeys") @@ -369,11 +334,13 @@ async def get_user_pubkeys( target = result.scalar_one_or_none() if not target: raise HTTPException(status_code=404, detail="User not found") + # Account lookup, not a key directory. `user_id` is how a username is resolved + # for an invitation, and `pk_node_ed25519` is a node's own linking key. The + # user identity keys this used to return were H3: whoever asked wrapped the + # group key for whatever came back. resp = { - "user_id": target.id, - "username": target.username, - "pk_ed25519": target.pk_ed25519, - "pk_x25519": target.pk_x25519, + "user_id": target.id, + "username": target.username, } if target.pk_node_ed25519: resp["pk_node_ed25519"] = target.pk_node_ed25519 diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py index 563a1eb..28f13a5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/auth.py +++ b/packages/meshbay-hub/src/meshbay_hub/auth.py @@ -131,13 +131,17 @@ def current_pw_version() -> int: def issue_access_token( user_id: str, - pk_user: str, ttl: int = 3600, groups: list[str] | None = None, scope: str = "user", ) -> str: """ Issue a signed JWT access token. + + Carries no user key. It used to, and the node recorded that key as the + uploader's identity — so the party issuing tokens decided who could delete a + file. The hub certifies accounts; nodes pin keys. + Includes jti (UUID4) — required to prevent replay and enable revocation. Includes groups — list of group_ids the user is a member of (node-side authz). scope: "user" (browser, full access) or "node" (daemon, restricted). @@ -148,7 +152,6 @@ def issue_access_token( payload = { "iss": _hub_id, "sub": user_id, - "pk_user": pk_user, "hub_id": _hub_id, "jti": str(uuid.uuid4()), "iat": now, diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7c31f9e40b2_drop_user_identity_keys.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7c31f9e40b2_drop_user_identity_keys.py new file mode 100644 index 0000000..c581e55 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7c31f9e40b2_drop_user_identity_keys.py @@ -0,0 +1,37 @@ +"""drop_user_identity_keys + +The hub published `users.pk_ed25519` / `users.pk_x25519` as a key directory, and +the invite flow wrapped the group key for whatever it returned — finding H3. Since +the node wraps the group key itself, for a key its owner proves possession of, +nothing reads these columns. Identity keys are generated per node and pinned there +(`meshbay_node/roster.py`), so there is no hub-side key to publish at all. + +Downgrade restores the columns, but not their contents: the keys they held were +never the hub's to reproduce. + +Revision ID: a7c31f9e40b2 +Revises: 2041a4060b3c +Create Date: 2026-08-14 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = 'a7c31f9e40b2' +down_revision: Union[str, Sequence[str], None] = '2041a4060b3c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_column('users', 'pk_ed25519') + op.drop_column('users', 'pk_x25519') + + +def downgrade() -> None: + # Nullable on the way back: the previous schema required them, and nothing + # can invent a key that belonged to a user. + op.add_column('users', sa.Column('pk_ed25519', sa.String(64), nullable=True)) + op.add_column('users', sa.Column('pk_x25519', sa.String(64), nullable=True)) diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index cdebd3c..a75217b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -42,8 +42,10 @@ class User(Base): pw_hash: Mapped[bytes] = mapped_column(nullable=False) pw_salt: Mapped[bytes] = mapped_column(nullable=False) pw_version: Mapped[int] = mapped_column(Integer, default=1) - pk_ed25519: Mapped[str] = mapped_column(String(64), nullable=False) # base64 raw 32B - pk_x25519: Mapped[str] = mapped_column(String(64), nullable=False) # base64 raw 32B + # No user identity keys here. The hub published them and the invite flow + # wrapped the group key for whatever it returned, which is finding H3; since + # the node does the wrapping, nothing reads a key from this directory. Keys + # are generated per node and pinned there (meshbay_node/roster.py). pk_node_ed25519: Mapped[str | None] = mapped_column(String(64), nullable=True) # node daemon key hub_id: Mapped[str] = mapped_column(String(128), nullable=False) role: Mapped[str] = mapped_column(String(16), default="user") # user|moderator|admin diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 300059d..3087e0e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -65,9 +65,10 @@ async function getAllCachedIndexes() { // ── Auth persistence ───────────────────────────────────────────────────────── -let _sessionKeys = null; +// The key that opens a node's keypair bundle, derived once at sign-in. There is +// no global identity to keep: identity keys belong to a node and are fetched from +// it (transport.js), so nothing of that kind lives here. let _bundleKey = null; -let _pendingBundlePush = null; // A one-time pairing code the user just typed, consumed by the next connection // attempt. Deliberately not persisted: it is single-use and short-lived. let _pendingJoinCode = null; @@ -108,11 +109,6 @@ async function _clearKeyDB() { db.close(); } catch {} } -function _saveSessionKeys() { - try { - if (_sessionKeys) sessionStorage.setItem('meshbay_sk', JSON.stringify(_sessionKeys)); - } catch {} -} /** * Rough passphrase strength, in bits, and what it is up against. * @@ -144,35 +140,6 @@ function passwordBits(pw) { const PASSWORD_MIN_BITS = 60; // refuse below this const PASSWORD_MIN_LEN = 12; -/** - * Re-encrypt a bundle written under the old KDF before it is stored again. - * - * PBKDF2 bundles are still readable, but leaving one on a node keeps the weak - * protection alive for as long as it sits there. Any backup is an opportunity to - * replace it with the Argon2id form, and it costs nothing the user notices. - */ -async function _upgradedBundle(bundleEnc) { - try { - if (!window.MeshBayKeys || !_sessionKeys || !_bundleKey) return bundleEnc; - if (window.MeshBayKeys.bundleVersion(bundleEnc) === 2) return bundleEnc; - const b64 = (s) => Uint8Array.from(atob(s), c => c.charCodeAt(0)); - return await window.MeshBayKeys.encryptBundleWithKey( - b64(_sessionKeys.skEdB64), b64(_sessionKeys.skXB64), _bundleKey.v2); - } catch (e) { - console.warn('[MeshBay] bundle upgrade skipped:', e.message); - return bundleEnc; - } -} - -function _restoreSessionKeys() { - try { - if (!_sessionKeys) { - const sk = sessionStorage.getItem('meshbay_sk'); - if (sk) _sessionKeys = JSON.parse(sk); - } - } catch {} -} - /** Public X25519 key from our own secret — never read back from the hub. */ async function _pkXFromSk(skPkcs8B64) { const raw = Uint8Array.from(atob(skPkcs8B64), c => c.charCodeAt(0)); @@ -183,35 +150,6 @@ async function _pkXFromSk(skPkcs8B64) { return pad ? b64 + '='.repeat(4 - pad) : b64; } -/** - * Recover our identity keys from what this browser already holds. - * - * sessionStorage dies with the tab, but the encrypted keypair bundle sits in - * localStorage from registration and the key that opens it is in IndexedDB from - * login. Without this, closing the tab looked exactly like never having - * registered here — "this browser does not hold your keys", while both halves - * were on disk a few bytes apart. - */ -async function _recoverLocalKeys(username) { - if (_sessionKeys || !username) return; - try { - if (!_bundleKey) _bundleKey = await _loadBundleKey(); - if (!_bundleKey || !window.MeshBayKeys) return; - const enc = localStorage.getItem(`meshbay_kp_${username}`); - if (!enc) return; - const keys = await window.MeshBayKeys.decryptBundleWithKey(enc, _bundleKey); - _sessionKeys = { - skXB64: keys.skX, - skEdB64: keys.skEd, - pkXB64: await _pkXFromSk(keys.skX), - }; - _pendingBundlePush = enc; // still to be backed up to a node - _saveSessionKeys(); - } catch (e) { - console.warn('[MeshBay] could not recover local keys:', e); - } -} - function loadAuth() { try { return JSON.parse(localStorage.getItem(AUTH_KEY)); @@ -225,11 +163,8 @@ function saveAuth(auth) { localStorage.setItem(AUTH_KEY, JSON.stringify(auth)); } else { localStorage.removeItem(AUTH_KEY); - _sessionKeys = null; _bundleKey = null; - _pendingBundlePush = null; _clearKeyDB(); - try { sessionStorage.removeItem('meshbay_sk'); } catch {} } } @@ -932,8 +867,6 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { setError(''); gekRef.current = null; if (!_bundleKey) _bundleKey = await _loadBundleKey(); - _restoreSessionKeys(); - await _recoverLocalKeys(username); try { const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); if (cancelled) return; @@ -942,14 +875,9 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { return; } - // Session keys for the P2P key exchange. skEdB64 belongs here too: the - // node identifies us by the Ed25519 identity, and join_request signs both - // public keys with it — without it we can neither join nor pair. - const sessionKeys = _sessionKeys ? { - skXB64: _sessionKeys.skXB64, - skEdB64: _sessionKeys.skEdB64, - pkXB64: _sessionKeys.pkXB64, - } : null; + // No keys are carried in: the transport fetches this node's identity + // from the node, or creates one there on a first join. + const sessionKeys = null; setStatus('connecting'); const nodeId = nodesData.nodes[0].node_id; @@ -963,34 +891,15 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { if (cancelled) return; setIsNodeAdmin(!!ack.is_node_admin); - // If transport recovered different session keys from node during handshake - if (transport.sessionKeys) { - const recovered = transport.sessionKeys; - if (!_sessionKeys || recovered.skXB64 !== _sessionKeys.skXB64) { - _sessionKeys = recovered; - if (!_sessionKeys.pkXB64) { - const pubkeys = await hubFetch( - `/v1/users/${username}/pubkeys`, { token }); - _sessionKeys.pkXB64 = pubkeys.pk_x25519; - } - _pendingBundlePush = null; - try { localStorage.removeItem(`meshbay_kp_${username}`); } catch {} - _saveSessionKeys(); - } - } - - // Back the encrypted keys up to the node. This is what lets any other - // browser recover them with the passphrase, which is the ordinary - // expectation; the protection that matters is the KDF guarding the - // bundle, not withholding the bundle. - if (transport.connected && _pendingBundlePush) { + // A first join to this node generated an identity for it; leave it with + // the node so any other browser can become the same person here with the + // passphrase. It is this node's key and no other's. + if (transport.connected && transport.newNodeBundle) { try { - await transport.storeKeypairBundle( - await _upgradedBundle(_pendingBundlePush)); - try { localStorage.removeItem(`meshbay_kp_${username}`); } catch {} - _pendingBundlePush = null; + await transport.storeKeypairBundle(transport.newNodeBundle); + transport.newNodeBundle = null; } catch (e) { - console.warn('[MeshBay] Bundle push to node deferred:', e.message); + console.warn('[MeshBay] could not leave our key with the node:', e.message); } } @@ -1134,8 +1043,11 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { try { // Signs an explicit transcript built by transport.js, not opaque bytes from // the node — see MeshBayCrypto.adminTranscript and finding H5. - const signFn = (_sessionKeys && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(_sessionKeys.skEdB64, transcript) + // Signed with the identity this node pinned for us — the only one it + // will accept, and the only one we hold here. + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) : null; await transport.deleteFile(entry.id, signFn); const indexMsg = await transport.fetchIndex(); @@ -1601,8 +1513,11 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef, // code it never learns — the code goes to a human, out of band. const account = await hubFetch(`/v1/users/${username}/pubkeys`, { token }); - const signFn = (_sessionKeys && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(_sessionKeys.skEdB64, transcript) + // Signed with the identity this node pinned for us — the only one it + // will accept, and the only one we hold here. + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) : null; const result = await transport.createInvite( account.user_id, groupId, username, signFn); @@ -2748,12 +2663,10 @@ function App() { const data = await window.MeshBayKeys.loginAndRecover(username, password); token = data.accessToken; refreshToken = data.refreshToken; + // The only thing sign-in produces: the key that opens a node's bundle. + // Which identity we use is decided per node, when we get there. _bundleKey = data.bundleKey; await _storeBundleKey(_bundleKey); - if (data.skXB64) { - _sessionKeys = { skXB64: data.skXB64, skEdB64: data.skEdB64 }; - _pendingBundlePush = data.keypairBundleEnc; - } } else { const data = await hubFetch('/v1/users/login', { method: 'POST', @@ -2763,11 +2676,6 @@ function App() { refreshToken = data.refresh_token; } const me = await hubFetch('/v1/users/me', { token }); - if (_sessionKeys) { - const pubkeys = await hubFetch(`/v1/users/${username}/pubkeys`, { token }); - _sessionKeys.pkXB64 = pubkeys.pk_x25519; - _saveSessionKeys(); - } const u = { username, userId: me.user_id, token, refreshToken, role: me.role }; setUser(u); saveAuth(u); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index af119c7..a27522d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -189,34 +189,43 @@ async function decryptBundle(bundleB64, password, username) { * Returns the raw private keys for immediate use after registration. */ async function registerUser(username, email, password) { - const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs(); - - const pkEdCrypto = await crypto.subtle.importKey('spki', pkEdRaw, 'Ed25519', true, ['verify']); - const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, 'X25519', true, []); - const pkEdBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkEdCrypto)); - const pkXBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkXCrypto)); - - const encBundle = await encryptBundle(skEdRaw, skXRaw, password, username); + // No keypair here any more. Identity keys are per node: one is generated the + // first time this account joins a given node, encrypted under the passphrase, + // and left with that node. So an operator who cracks what sits on their own + // disk holds a key that is worthless anywhere else — and on their own node, + // one that unlocks nothing they did not already have. + // + // It also means the hub stores no user key to publish, which is what H3 read. const authKey = await deriveAuthKey(password, username); const resp = await fetch(`${HUB}/v1/users/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username, - email, - auth_key: authKey, - pk_user_ed25519: btoa(String.fromCharCode(...pkEdBytes)), - pk_user_x25519: btoa(String.fromCharCode(...pkXBytes)), - }), + body: JSON.stringify({ username, email, auth_key: authKey }), }); if (!resp.ok) throw new Error(`Registration failed: ${await resp.text()}`); + return { registered: true }; +} - // Store encrypted bundle locally — will be backed up to node on first group connect - try { localStorage.setItem(`meshbay_kp_${username}`, encBundle); } catch {} - - return { skEdRaw, skXRaw, pkEdBytes, pkXBytes, keypairBundleEnc: encBundle }; +/** + * A fresh identity for one node, encrypted under the passphrase-derived key. + * + * Returns { skEdB64, skXB64, pkXB64, bundleEnc } — the bundle goes to that node + * and nowhere else, and is what any other browser fetches to become the same + * person there. + */ +async function generateNodeIdentity(bundleKey) { + const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs(); + const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))); + const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, { name: 'X25519' }, true, []); + const pkXBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkXCrypto)); + return { + skEdB64: b64(skEdRaw), + skXB64: b64(skXRaw), + pkXB64: b64(pkXBytes), + bundleEnc: await encryptBundleWithKey(skEdRaw, skXRaw, bundleKey.v2 || bundleKey), + }; } /** @@ -269,63 +278,17 @@ async function loginAndRecover(username, password) { }, }; - // localStorage bundle = new registration, not yet pushed to node - const bundleEnc = (typeof localStorage !== 'undefined' - && localStorage.getItem(`meshbay_kp_${username}`)) || null; - - if (bundleEnc) { - // Reuse the keys just derived — decryptBundle() would run the KDF again, - // and at these parameters that is another 0.6 s for nothing. - const keys = await decryptBundleWithKey(bundleEnc, result.bundleKey); - result.skEdB64 = keys.skEd; - result.skXB64 = keys.skX; - result.keypairBundleEnc = bundleEnc; - } - + // Nothing else to recover at sign-in. Identity keys belong to a node, so they + // are fetched from the node being connected to (or generated there on a first + // join) — see transport.js. All that is needed here is the key that opens them. return result; } -async function regenerateKeys(token, username, password) { - const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs(); +// regenerateKeys() removed. Rotating an identity is now per node: the operator +// runs `meshbay-node member unpin <user>` and issues a fresh code. A hub call +// that silently changed what every node believed about someone was the wrong +// shape for this. - const pkEdCrypto = await crypto.subtle.importKey('spki', pkEdRaw, 'Ed25519', true, ['verify']); - const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, 'X25519', true, []); - const pkEdBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkEdCrypto)); - const pkXBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkXCrypto)); - - const resp = await fetch(`${HUB}/v1/users/me/keys`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ - pk_user_ed25519: btoa(String.fromCharCode(...pkEdBytes)), - pk_user_x25519: btoa(String.fromCharCode(...pkXBytes)), - }), - }); - - if (!resp.ok) throw new Error(`Key rotation failed: ${await resp.text()}`); - - const encBundle = await encryptBundle(skEdRaw, skXRaw, password, username); - try { localStorage.setItem(`meshbay_kp_${username}`, encBundle); } catch {} - - return { - skEdB64: btoa(String.fromCharCode(...new Uint8Array(skEdRaw))), - skXB64: btoa(String.fromCharCode(...new Uint8Array(skXRaw))), - pkEdB64: btoa(String.fromCharCode(...pkEdBytes)), - pkXB64: btoa(String.fromCharCode(...pkXBytes)), - keypairBundleEnc: encBundle, - }; -} - -/** - * Sign an explicit byte string with the user's Ed25519 identity key. - * - * Takes bytes rather than a base64 blob from the wire: callers are expected to - * build the message themselves (see MeshBayCrypto.adminTranscript) so that the - * user's identity key is never applied to content the peer chose. Finding H5. - */ async function signBytes(skEdPkcs8B64, message) { const skRaw = Uint8Array.from(atob(skEdPkcs8B64), c => c.charCodeAt(0)); const sk = await crypto.subtle.importKey( @@ -335,6 +298,6 @@ async function signBytes(skEdPkcs8B64, message) { } window.MeshBayKeys = { - registerUser, loginAndRecover, regenerateKeys, generateKeypairs, signBytes, + registerUser, loginAndRecover, generateNodeIdentity, generateKeypairs, signBytes, deriveAuthKey, decryptBundleWithKey, encryptBundleWithKey, bundleVersion, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index b1a03ff..0a8796e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -78,6 +78,10 @@ class MeshBayTransport { get sessionKeys() { return this._sessionKeys; } + /** Set on a first join: the identity created for this node, still to be left with it. */ + get newNodeBundle() { return this._newNodeBundle || null; } + set newNodeBundle(v) { this._newNodeBundle = v; } + async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username, userId, joinCode) { this._gekRaw = gekRaw || null; @@ -85,6 +89,7 @@ class MeshBayTransport { this._bundleKey = bundleKey || null; this._username = username || null; this._userId = userId || null; + this._newNodeBundle = null; this._joinError = null; this._pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], @@ -186,7 +191,11 @@ class MeshBayTransport { this._nonceNode = window.MeshBayCrypto.b64decode(reply.nonce); this.nodePk = reply.node_pk || null; - // Recover session keys from node if not available locally (P2P keypair bundle) + // Our identity for THIS node: fetched from it, or created if this is a + // first join. Keys are per node, so there is nothing to carry between + // them — and an operator who cracks the copy on their own disk gets a key + // that opens nothing anywhere else. + let fresh = false; if (!this._sessionKeys && this._bundleKey && window.MeshBayKeys) { const kpResp = await this._sendAndWait({ type: 'keypair_bundle_fetch', v: '0.1', @@ -196,11 +205,22 @@ class MeshBayTransport { kpResp.bundle_enc, this._bundleKey); const pkXB64 = await _pkFromSk(keys.skX); this._sessionKeys = { skXB64: keys.skX, skEdB64: keys.skEd, pkXB64 }; + } else { + // This node has never seen us. Generate the identity we will use here + // and nowhere else; it is stored on this node once the join succeeds, + // which is what lets another browser become the same person here. + const id = await window.MeshBayKeys.generateNodeIdentity(this._bundleKey); + this._sessionKeys = { + skEdB64: id.skEdB64, skXB64: id.skXB64, pkXB64: id.pkXB64, + }; + this._newNodeBundle = id.bundleEnc; + fresh = true; } } - // Fetch wrapped GEK bundle from node (P2P only — hub never touches crypto) - if (!gekRaw && this._sessionKeys) { + // An identity this node already knows still needs its group key, which the + // node wraps on every connection. + if (!gekRaw && this._sessionKeys && !fresh) { const bundleResp = await this._sendAndWait({ type: 'gek_bundle_fetch', v: '0.1', }); @@ -211,22 +231,7 @@ class MeshBayTransport { gekRaw = await window.MeshBayCrypto.unwrapGEK(bundleResp, skXRaw, myPkX); this._gekRaw = gekRaw; } catch (e) { - console.warn('[MeshBay] GEK unwrap failed with local keys, trying node keypair bundle'); - if (this._bundleKey && window.MeshBayKeys) { - const kpResp = await this._sendAndWait({ - type: 'keypair_bundle_fetch', v: '0.1', - }); - if (kpResp.type === 'keypair_bundle_resp' && kpResp.found) { - const keys = await window.MeshBayKeys.decryptBundleWithKey( - kpResp.bundle_enc, this._bundleKey); - const pkXB64 = await _pkFromSk(keys.skX); - this._sessionKeys = { skXB64: keys.skX, skEdB64: keys.skEd, pkXB64 }; - const skXRaw2 = Uint8Array.from(atob(keys.skX), c => c.charCodeAt(0)); - const myPkX2 = Uint8Array.from(atob(pkXB64), c => c.charCodeAt(0)); - gekRaw = await window.MeshBayCrypto.unwrapGEK(bundleResp, skXRaw2, myPkX2); - this._gekRaw = gekRaw; - } - } + console.warn('[MeshBay] stored GEK bundle did not open; joining instead'); } } } diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py index 7b75fd1..5a2cf86 100644 --- a/packages/meshbay-hub/tests/test_hub_api.py +++ b/packages/meshbay-hub/tests/test_hub_api.py @@ -144,8 +144,11 @@ async def test_jwt_offline_verify(client, hub_key_path): hub_pk_pem = r_pk.json()["pk_hub_pem"].encode() decoded = pyjwt.decode(token, hub_pk_pem, algorithms=["EdDSA"]) - assert decoded["pk_user"] == pk_ed assert "jti" in decoded # mandatory + # The token carries no user key. It used to, and the node recorded it as the + # uploader's identity — so whoever issued tokens decided who could delete a + # file. The hub certifies accounts; nodes pin keys. + assert "pk_user" not in decoded @pytest.mark.asyncio @@ -194,11 +197,17 @@ async def test_refresh_token_rotation_old_rejected(client): @pytest.mark.asyncio async def test_get_user_pubkeys(client): + """ + The endpoint resolves an account; it is not a key directory any more. + + Publishing user identity keys is what finding H3 exploited — the invite flow + wrapped the group key for whatever came back. Keys are now generated per node + and pinned there, so there is nothing here to substitute. + """ pk_ed, pk_x, _ = _gen_user_keys() await client.post("/v1/users/register", json={ "username": "frank", "email": "frank@example.com", - "password": "frankpass99", - "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x}) + "password": "frankpass99"}) login = await client.post("/v1/users/login", json={ "username": "frank", "password": "frankpass99"}) token = login.json()["access_token"] @@ -206,8 +215,10 @@ async def test_get_user_pubkeys(client): r = await client.get("/v1/users/frank/pubkeys", headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 200 - assert r.json()["pk_ed25519"] == pk_ed - assert r.json()["pk_x25519"] == pk_x + body = r.json() + assert body["user_id"] and body["username"] == "frank" + assert "pk_ed25519" not in body, "user identity keys must not be published (H3)" + assert "pk_x25519" not in body, "user identity keys must not be published (H3)" # ── Nodes ───────────────────────────────────────────────────────────────────── diff --git a/packages/meshbay-hub/tests/test_node_auth.py b/packages/meshbay-hub/tests/test_node_auth.py index e629d20..72ce412 100644 --- a/packages/meshbay-hub/tests/test_node_auth.py +++ b/packages/meshbay-hub/tests/test_node_auth.py @@ -214,7 +214,8 @@ async def test_node_scope_allows_pubkey_lookup(client): r = await client.get("/v1/users/op4/pubkeys", headers={"Authorization": f"Bearer {node_token}"}) assert r.status_code == 200 - assert "pk_ed25519" in r.json() + # An account id and the node's linking key — no user identity keys (H3). + assert "pk_ed25519" not in r.json() assert r.json()["pk_node_ed25519"] is not None diff --git a/packages/meshbay-hub/tests/test_node_ws_auth.py b/packages/meshbay-hub/tests/test_node_ws_auth.py index def5e66..1391722 100644 --- a/packages/meshbay-hub/tests/test_node_ws_auth.py +++ b/packages/meshbay-hub/tests/test_node_ws_auth.py @@ -64,7 +64,7 @@ async def _announce_node(client, user: dict) -> str: def _node_token(user: dict) -> str: from meshbay_hub.auth import issue_access_token - return issue_access_token(user["user_id"], user["pk_ed"], scope="node") + return issue_access_token(user["user_id"], scope="node") @pytest.mark.asyncio diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index 334107d..d8417e3 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -128,8 +128,8 @@ class HubClient: access_token = data["access_token"] decoded = jwt.decode(access_token, hub_pk_pem, algorithms=["EdDSA"]) - assert decoded["pk_user"] == self._keys.pk_ed25519_b64, \ - "Hub returned token for wrong public key" + # No pk_user claim to check any more: tokens carry no key. What binds this + # token to this node is the Ed25519 challenge it was issued against. assert "jti" in decoded, "Hub token missing jti — hub is outdated" assert decoded.get("scope") == "node", \ "Expected node-scoped token" diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index f659f56..5ea6376 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -231,7 +231,9 @@ class WebRTCPeerSession: self._peer_id: str = peer_id self._remote_ip: str = "" self._username: str = "" - self._pk_user: str = "" + # Set from the roster: the key this node pinned for this account. Never + # from the JWT — the hub picks what goes in there. + self._pinned_pk: str = "" self._gek_challenge: bytes | None = None # Same value as the GEK challenge, but kept for the life of the connection: # a join_request is signed over it, and it must stay verifiable after the @@ -377,7 +379,6 @@ class WebRTCPeerSession: self._pending_sub = peer.user_id self._pending_group = peer.group_id self._pending_username = peer.username - self._pending_pk_user = peer.pk_user gctx = self._ctx["groups"][peer.group_id] if "groups" in self._ctx else self._ctx if not gctx.get("gek"): @@ -446,7 +447,7 @@ class WebRTCPeerSession: self._user_id = self._pending_sub self._group_id = self._pending_group self._username = self._pending_username - self._pk_user = self._pending_pk_user + asyncio.ensure_future(self._load_pinned_pk()) self._peer_registry()[self._user_id] = self @@ -747,7 +748,11 @@ class WebRTCPeerSession: roster, user_id, invite["username"] or username, pk_ed_b64, pk_x_b64, group_id=invite["group_id"], role=invite["role"], approved_by=invite["created_by"], via="code") - await self._join_ok(user_id, pk_x_raw, invite["group_id"], + # The roster row comes from the invitation; the key comes from the + # connection. An operator pairing is node-wide (empty group), but they + # redeemed the code while opening a group and expect to read it — and + # is_authorized() already grants an operator every group on this node. + await self._join_ok(user_id, pk_x_raw, session_group or invite["group_id"], role=invite["role"], recognised=False) def _group_join_policy(self, group_id: str) -> str: @@ -1168,14 +1173,22 @@ class WebRTCPeerSession: self._register_uploader(ctx, rel_dir, filename) def _register_uploader(self, ctx: dict, rel_dir: str, filename: str) -> None: - """Tag the index entry with the uploader's identity after upload completes.""" + """ + Tag the index entry with the uploader's identity after upload completes. + + The key recorded here is the one this node pinned, not the one the token + carried. `pk_user` was a hub-chosen claim, and it decided who could later + delete the file: a hub issuing a token naming its own key could delete + anyone's uploads on any node. Deletion is supposed to be authorized by the + node, and this closes the last place where it was not. + """ idx = ctx.get("index") if not idx: return for entry in idx.entries: if entry.name == filename and entry.path == rel_dir: entry.uploader_id = self._user_id - entry.uploader_pk = self._pk_user + entry.uploader_pk = self._pinned_pk return def _do_file_delete(self, msg: dict) -> None: @@ -1242,6 +1255,15 @@ class WebRTCPeerSession: except Exception: return False + async def _load_pinned_pk(self) -> None: + """Remember which key this node pinned for the peer we just authenticated.""" + roster = self._ctx.get("roster") + if roster is None or not self._user_id: + return + ident = await roster.get_identity(self._user_id) + if ident: + self._pinned_pk = ident["pk_ed25519"] + def _has_admin_authority(self) -> bool: """ Cheap synchronous pre-check: is there anyone who could authorize this? diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index d13225d..665c060 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -461,6 +461,42 @@ def test_challenge_carries_node_pk_in_source(): "learn it any other way, and join_request signs it") +async def test_a_key_pinned_by_one_node_is_worthless_at_another(tmp_path, roster): + """ + The whole point of per-node identity: node A's operator who cracks the bundle + on their own disk holds a key node B has never seen. Presenting it there is a + first contact like any other — it needs a code from B's operator. + """ + gek = generate_gek() + node_b = _session(tmp_path, roster, user_id="bob", group_id=GROUP, gek=gek) + + # The key bob uses at node A. Node B's roster knows nothing about it. + sk_ed_a, pk_ed_a, pk_x_a = _keypair() + + await node_b._do_join_request( + _join_msg(node_b, sk_ed_a, pk_ed_a, pk_x_a, + user_id="bob", group_id=GROUP)) + + assert _last(node_b).get("reason") == "code_required" + assert await roster.get_identity("bob") is None + + +async def test_the_stolen_key_cannot_be_forced_in_with_someone_elses_code( + tmp_path, roster): + """And a code issued for another account does not help either.""" + gek = generate_gek() + session = _session(tmp_path, roster, user_id="eve", group_id=GROUP, gek=gek) + sk_ed, pk_ed, pk_x = _keypair() + code = await roster.create_invite(GROUP, "bob", ROLE_MEMBER, "grenet") + + await session._do_join_request( + _join_msg(session, sk_ed, pk_ed, pk_x, code=code, + user_id="eve", group_id=GROUP)) + + assert _last(session).get("reason") == "code_invalid" + assert await roster.get_identity("eve") is None + + # ── Code lifetimes ──────────────────────────────────────────────────────────── async def test_invitations_outlive_pairing_codes(roster): |