diff options
Diffstat (limited to 'packages/meshbay-hub')
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) |