aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/users.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 13:55:59 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 13:55:59 +0200
commita55d40b74bda77dff6ec565abdd551607fc665d6 (patch)
treeeaab3cf9434be8bdcd67a9cddc7218050a6874e4 /packages/meshbay-hub/src/meshbay_hub/api/users.py
parentf211a13dc2e5dd82eaba49222171d6f29d858eb5 (diff)
downloadmeshbay-a55d40b74bda77dff6ec565abdd551607fc665d6.tar.gz
feat(hub): a bundle pepper per account, handed only to a proven session
Sealed at rest and bound to the account; returned by sign-in, device sign-in, a passphrase change and GET /me/bundle-pepper, never by a refresh, to a node token, in a token or in a log. Erasure clears it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/users.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py44
1 files changed, 44 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 660bd76..72ac68f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -27,7 +27,9 @@ from meshbay_hub.auth import (
hash_password_off_loop,
hash_refresh_token,
issue_access_token,
+ open_pepper,
pw_needs_rehash,
+ seal_pepper,
verify_password_off_loop,
)
from meshbay_hub.config import HubConfig
@@ -362,6 +364,39 @@ async def _login_failed(db: AsyncSession, username: str, ip: str,
raise HTTPException(status_code=401, detail="Invalid credentials")
+# ── Bundle pepper ────────────────────────────────────────────────────────────
+#
+# Half of what opens this account's keypair bundles on nodes; the passphrase is
+# the other half. Handed out only where the caller proved the passphrase or a
+# device key — sign-in, device sign-in, a passphrase change — or already holds a
+# session that did (`GET /me/bundle-pepper`). Never on a token refresh, which
+# proves only possession of a refresh token; never to a node token; never in a
+# token or a log line.
+
+def _bundle_pepper(user: User) -> dict:
+ """The pepper for a response, created on first use. The caller commits."""
+ if user.bundle_pepper is None:
+ user.bundle_pepper = seal_pepper(secrets.token_bytes(32), user.id)
+ user.bundle_pepper_version = user.bundle_pepper_version or 1
+ raw = open_pepper(user.bundle_pepper, user.id)
+ return {"bundle_pepper": base64.b64encode(raw).decode(),
+ "bundle_pepper_version": user.bundle_pepper_version}
+
+
+@router.get("/me/bundle-pepper")
+@limiter.limit("10/minute")
+async def get_bundle_pepper(
+ request: Request,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """For a session opened before the pepper existed: asked once, then kept
+ only as part of the key derived from it."""
+ pepper = _bundle_pepper(current_user)
+ await db.commit()
+ return pepper
+
+
@router.post("/login")
@limiter.limit("10/minute")
async def login(
@@ -445,6 +480,7 @@ async def login(
family_id=family_id, expires_at=expires_at,
))
db.add(IPLog(user_id=user.id, event="login", ip_address=ip))
+ pepper = _bundle_pepper(user)
await db.commit()
return {
@@ -452,6 +488,7 @@ async def login(
"refresh_token": raw_rt,
"token_type": "bearer",
"expires_in": _ttl(),
+ **pepper,
}
@@ -626,6 +663,7 @@ async def device_auth(
family_id=str(uuid.uuid4()), expires_at=expires_at))
db.add(IPLog(user_id=user.id, event="device_auth",
ip_address=client_ip(request)))
+ pepper = _bundle_pepper(user)
await db.commit()
return {
@@ -634,6 +672,7 @@ async def device_auth(
"token_type": "bearer",
"expires_in": _ttl(),
"device_id": matched.id,
+ **pepper,
}
@@ -1045,6 +1084,9 @@ async def change_password(
))
db.add(IPLog(user_id=current_user.id, event="password_change",
ip_address=client_ip(request)))
+ # The new passphrase makes a new bundle key, and the client does not keep
+ # the pepper; this call proved the old passphrase, so it carries it.
+ pepper = _bundle_pepper(current_user)
await db.commit()
return {
@@ -1053,6 +1095,7 @@ async def change_password(
"refresh_token": raw_rt,
"token_type": "bearer",
"expires_in": _ttl(),
+ **pepper,
}
@@ -1442,6 +1485,7 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus
user.pw_hash = b""
user.pw_salt = b""
user.pk_node_ed25519 = None
+ user.bundle_pepper = None
user.status = "deleted"
user.role = "user"
await db.commit()