aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py44
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c3d4e5f6a7b9_bundle_pepper.py28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py8
-rw-r--r--packages/meshbay-hub/tests/test_bundle_pepper.py181
5 files changed, 283 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()
diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py
index 0d0fd95..1d09581 100644
--- a/packages/meshbay-hub/src/meshbay_hub/auth.py
+++ b/packages/meshbay-hub/src/meshbay_hub/auth.py
@@ -324,6 +324,28 @@ def decrypt_email(stored: str) -> str:
return AESGCM(_email_key).decrypt(nonce, ct, None).decode()
+def seal_pepper(raw: bytes, user_id: str) -> str:
+ """Seal a bundle pepper for storage, bound to its account.
+
+ The same at-rest key as the e-mail, with the account id and a purpose as
+ associated data: a sealed value copied into another row, or into the e-mail
+ column, does not open.
+ """
+ if _email_key is None:
+ raise RuntimeError("Hub keypair not loaded")
+ nonce = os.urandom(12)
+ aad = f"meshbay:bundle_pepper:{user_id}".encode()
+ return base64.b64encode(nonce + AESGCM(_email_key).encrypt(nonce, raw, aad)).decode()
+
+
+def open_pepper(stored: str, user_id: str) -> bytes:
+ if _email_key is None:
+ raise RuntimeError("Hub keypair not loaded")
+ raw = base64.b64decode(stored)
+ aad = f"meshbay:bundle_pepper:{user_id}".encode()
+ return AESGCM(_email_key).decrypt(raw[:12], raw[12:], aad)
+
+
def hash_email_blind(email: str) -> str:
"""Deterministic HMAC-SHA256 of the lowercased email for uniqueness checks.
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c3d4e5f6a7b9_bundle_pepper.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c3d4e5f6a7b9_bundle_pepper.py
new file mode 100644
index 0000000..dbf1718
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c3d4e5f6a7b9_bundle_pepper.py
@@ -0,0 +1,28 @@
+"""a bundle pepper per account, so a node cannot test passphrase guesses
+
+Revision ID: c3d4e5f6a7b9
+Revises: b2c3d4e5f6a8
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "c3d4e5f6a7b9"
+down_revision: str | Sequence[str] | None = "b2c3d4e5f6a8"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("users") as batch:
+ batch.add_column(sa.Column("bundle_pepper", sa.String(128), nullable=True))
+ batch.add_column(sa.Column("bundle_pepper_version", sa.Integer(), nullable=False,
+ server_default="1"))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("users") as batch:
+ batch.drop_column("bundle_pepper_version")
+ batch.drop_column("bundle_pepper")
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index 293b940..1e652a6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -60,6 +60,14 @@ class User(Base):
# active|suspended|revoked
status: Mapped[str] = mapped_column(String(16), default="active")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+ # The second half of what opens this account's keypair bundles on nodes
+ # (`auth.seal_pepper`, sealed at rest). A bundle is sealed under a key derived
+ # from the passphrase *and* this, so an operator holding one cannot test
+ # passphrase guesses offline: the pepper is handed only to a session that
+ # proved the passphrase or a device key, never to a node. Created the first
+ # time it is asked for; the version names which pepper sealed a bundle.
+ bundle_pepper: Mapped[str | None] = mapped_column(String(128), nullable=True)
+ bundle_pepper_version: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
nodes: Mapped[list["Node"]] = relationship(back_populates="user")
group_memberships: Mapped[list["GroupMember"]] = relationship(back_populates="user")
diff --git a/packages/meshbay-hub/tests/test_bundle_pepper.py b/packages/meshbay-hub/tests/test_bundle_pepper.py
new file mode 100644
index 0000000..e7b126f
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_bundle_pepper.py
@@ -0,0 +1,181 @@
+"""
+The bundle pepper: half of what opens an account's keypair bundles on nodes.
+
+A bundle sealed under the passphrase alone lets the operator holding it test
+guesses offline, and a right guess is the whole account. Sealed under the
+passphrase and a pepper only the hub holds, it does not. So what matters here is
+who gets the pepper: a session that proved the passphrase or a device key, and
+nobody else — not a refresh, not a node, not a token, not a log.
+"""
+
+import base64
+import logging
+import time
+
+import jwt as _jwt
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_common.crypto import pk_to_b64
+from meshbay_hub.auth import open_pepper
+from meshbay_hub.db.models import User
+from sqlalchemy import select
+
+KEY = "k" * 44
+
+
+async def _register(client, username):
+ r = await client.post("/v1/users/register", json={
+ "username": username, "auth_key": KEY, "email": f"{username}@example.invalid"})
+ assert r.status_code == 201, r.text
+
+
+async def _login(client, username, key=KEY):
+ r = await client.post("/v1/users/login", json={"username": username, "auth_key": key})
+ assert r.status_code == 200, r.text
+ return r.json()
+
+
+def _bearer(token):
+ return {"Authorization": f"Bearer {token}"}
+
+
+@pytest.mark.asyncio
+async def test_sign_in_hands_over_one_stable_pepper(client):
+ await _register(client, "pepper_user")
+ first = await _login(client, "pepper_user")
+ second = await _login(client, "pepper_user")
+
+ assert len(base64.b64decode(first["bundle_pepper"])) == 32
+ assert first["bundle_pepper_version"] == 1
+ assert second["bundle_pepper"] == first["bundle_pepper"], \
+ "a pepper that changed between sign-ins would lock every bundle out"
+
+
+@pytest.mark.asyncio
+async def test_a_device_sign_in_gets_it_too(client):
+ await _register(client, "pepper_dev")
+ login = await _login(client, "pepper_dev")
+ sk = Ed25519PrivateKey.generate()
+ r = await client.post("/v1/users/devices", headers=_bearer(login["access_token"]),
+ json={"pk_auth_ed25519": pk_to_b64(sk.public_key()), "label": ""})
+ assert r.status_code == 201
+ ts = int(time.time())
+ sig = sk.sign(f"meshbay:user_auth:pepper_dev:{ts}".encode())
+ r = await client.post("/v1/users/auth", json={
+ "username": "pepper_dev", "timestamp": ts,
+ "signature": base64.b64encode(sig).decode()})
+ assert r.status_code == 200, r.text
+ assert r.json()["bundle_pepper"] == login["bundle_pepper"]
+
+
+@pytest.mark.asyncio
+async def test_a_refresh_does_not(client):
+ """A refresh token proves only that it was not spent yet."""
+ await _register(client, "pepper_ref")
+ login = await _login(client, "pepper_ref")
+ r = await client.post("/v1/users/token/refresh",
+ json={"refresh_token": login["refresh_token"]})
+ assert r.status_code == 200, r.text
+ assert "bundle_pepper" not in r.json()
+
+
+@pytest.mark.asyncio
+async def test_it_is_in_no_token(client):
+ await _register(client, "pepper_jwt")
+ login = await _login(client, "pepper_jwt")
+ claims = _jwt.decode(login["access_token"], options={"verify_signature": False})
+ assert login["bundle_pepper"] not in repr(claims)
+ assert not any("pepper" in k for k in claims)
+
+
+@pytest.mark.asyncio
+async def test_an_open_session_may_ask_and_a_node_may_not(client):
+ from test_node_scope_not_admin import _node_token
+ await _register(client, "pepper_node")
+ login = await _login(client, "pepper_node")
+
+ r = await client.get("/v1/users/me/bundle-pepper", headers=_bearer(login["access_token"]))
+ assert r.status_code == 200
+ assert r.json()["bundle_pepper"] == login["bundle_pepper"]
+
+ node = await _node_token(client, "pepper_node", login["access_token"])
+ r = await client.get("/v1/users/me/bundle-pepper", headers=_bearer(node))
+ assert r.status_code == 403
+ assert login["bundle_pepper"] not in r.text
+
+
+@pytest.mark.asyncio
+async def test_a_passphrase_change_carries_it(client):
+ """The new passphrase makes a new bundle key, and the client does not keep
+ the pepper to re-seal under it."""
+ await _register(client, "pepper_chg")
+ login = await _login(client, "pepper_chg")
+ r = await client.post("/v1/users/password", headers=_bearer(login["access_token"]),
+ json={"old_auth_key": KEY, "new_auth_key": "n" * 44})
+ assert r.status_code == 200, r.text
+ assert r.json()["bundle_pepper"] == login["bundle_pepper"]
+
+
+@pytest.mark.asyncio
+async def test_it_is_sealed_at_rest_and_bound_to_its_account(client, db_session):
+ await _register(client, "pepper_rest")
+ login = await _login(client, "pepper_rest")
+ user = (await db_session.execute(
+ select(User).where(User.username == "pepper_rest"))).scalar_one()
+ raw = base64.b64decode(login["bundle_pepper"])
+ assert raw not in base64.b64decode(user.bundle_pepper)
+ assert open_pepper(user.bundle_pepper, user.id) == raw
+ with pytest.raises(Exception):
+ open_pepper(user.bundle_pepper, "another-account-id")
+
+
+@pytest.mark.asyncio
+async def test_erasure_takes_it_and_the_name_gets_a_new_one(client, db_session):
+ await _register(client, "pepper_gone")
+ login = await _login(client, "pepper_gone")
+ r = await client.request("DELETE", "/v1/users/me", headers=_bearer(login["access_token"]),
+ json={"auth_key": KEY})
+ assert r.status_code == 200, r.text
+ gone = (await db_session.execute(
+ select(User).where(User.status == "deleted"))).scalar_one()
+ await db_session.refresh(gone)
+ assert gone.bundle_pepper is None
+
+ await _register(client, "pepper_gone")
+ assert (await _login(client, "pepper_gone"))["bundle_pepper"] != login["bundle_pepper"]
+
+
+@pytest.mark.asyncio
+async def test_it_is_never_logged(client):
+ """Levels and `disabled` pinned here: an earlier test may have changed
+ either, and a handler that sees nothing proves nothing."""
+ seen: list[str] = []
+
+ class Grab(logging.Handler):
+ def emit(self, record):
+ seen.append(record.getMessage())
+
+ root = logging.getLogger()
+ grab = Grab(level=logging.DEBUG)
+ saved = root.level, logging.root.manager.disable
+ root.addHandler(grab)
+ root.setLevel(logging.DEBUG)
+ logging.disable(logging.NOTSET)
+ loggers = [logging.getLogger(n) for n in ("meshbay_hub", "meshbay_hub.api.users")]
+ was = [(lg.level, lg.disabled) for lg in loggers]
+ for lg in loggers:
+ lg.setLevel(logging.DEBUG)
+ lg.disabled = False
+ try:
+ await _register(client, "pepper_log")
+ login = await _login(client, "pepper_log")
+ await client.get("/v1/users/me/bundle-pepper", headers=_bearer(login["access_token"]))
+ finally:
+ root.removeHandler(grab)
+ root.setLevel(saved[0])
+ logging.disable(saved[1])
+ for lg, (level, disabled) in zip(loggers, was):
+ lg.setLevel(level)
+ lg.disabled = disabled
+ assert seen, "the handler saw nothing, so it proves nothing"
+ assert not any(login["bundle_pepper"] in m for m in seen)