aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py57
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7c31f9e40b2_drop_user_identity_keys.py37
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js142
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js109
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js43
8 files changed, 144 insertions, 259 deletions
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');
}
}
}