summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 17:51:48 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 17:51:48 +0200
commitf0984e86d9cb596a282ce6feb7cfc2f075b2794b (patch)
tree192198a5d596a06d15095bb1d3540dd4a8d397c8 /packages/meshbay-hub/src/meshbay_hub/api
parent9fa2117de1caf4d713cc0b7a310b9549467738c3 (diff)
downloadmeshbay-f0984e86d9cb596a282ce6feb7cfc2f075b2794b.tar.gz
feat!: identity keys per node — C4's blast radius drops to one operator
One keypair was copied to every node its owner joined, so cracking the bundle on any single node yielded the identity used on all of them: their content on other operators' machines, and the ability to sign as them anywhere. That lateral reach was the part of C4 worth attacking. Each node now gets its own keypair, generated the first time its owner joins it and left with that node alone. An operator who cracks what sits on their own disk holds a key that is a stranger to every other node — and on their own node, one that unlocks nothing they did not already hold: they serve the content, the index and every byte of it by design. Nothing changes for the user. A first contact with a node already needed that operator's code, and the key is created in the same step; a second browser still recovers it from the node with the passphrase alone. Two operators can also no longer tell they host the same person by comparing keys. BREAKING, and deliberately without a compatibility path — the deployment is wiped for the next demo: - users.pk_ed25519 / pk_x25519 dropped (migration a7c31f9e40b2) - registration no longer sends or stores a key - PUT /v1/users/me/keys and regenerateKeys() gone; rotation is now `member unpin` plus a fresh code, decided on the machine that pinned it - /pubkeys returns an account id and a node's linking key. It was the directory H3 read, and nothing wraps for it any more - the pk_user JWT claim is gone That last one closed a live defect the inventory turned up: the node recorded pk_user as the uploader's identity and authorized deletion against it, so a hub issuing a token naming its own key could delete anyone's uploads on any node. Attribution now uses the key the node itself pinned. A simplification falls out. Registration generates nothing, so a scripted signup is a real account: `demo.py bootstrap` takes a wiped hub and node to a working demo with no browser, which was impossible while keys were born in one. Also fixes, found by running it on a wiped deployment: the key handed back on a join now belongs to the group the connection is for, not the group named in the invitation — an operator pairs node-wide but redeems the code while opening a group, and expects to read it. Tests: 343, including the two that state the property — a key pinned by one node is refused at another, and someone else's code does not admit it. Verified end to end against a wiped hub and node: bootstrap, pair, invite, join, download, stream, second browser, revoke. Design: docs/per-node-identity-v1.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py57
2 files changed, 13 insertions, 46 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