summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-01 01:03:43 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-01 01:03:43 +0200
commitfe30860c58e0f1b1efd457ff5eb5146d1e592da0 (patch)
tree99a3e96994738c4e96f969a365679475dc4cf5cd /packages/meshbay-hub/tests
parent51d2d734c228f1e46670962480258abfe586d6c4 (diff)
downloadmeshbay-fe30860c58e0f1b1efd457ff5eb5146d1e592da0.tar.gz
feat: passphrase change and account recovery (auth-confirm)
The passphrase derives two independent client-side values: auth_key (the hub verifier) and bundle_key (AES-GCM key for the per-node identity bundles, which live on nodes and never on the hub). Changing or recovering a passphrase is therefore two operations — swap the hub verifier, and re-wrap every reachable node's identity bundle. Flow A — change a known passphrase (Profile page) - POST /v1/users/password re-proves the current passphrase, swaps pw_hash/salt/version, revokes every refresh token and returns a fresh pair so the tab that made the change stays signed in. - MeshBayTransport.rewrapAllNodes: for every group's online node, connect with the old key, read the identity off the handshake, store it back under the new key. Returns updated / unreachable / failed so the UI can point at the operator-unpin fallback for the gaps. Always-shown confirmation dialog listing reachable and unreachable groups. Recovery key - keyderive.js generateRecoveryKey (32 random bytes, grouped Base32) and deriveRecoveryKey (HKDF-SHA256, domain meshbay:recovery:v1:<username>). - Every per-node identity gets a second copy wrapped under the recovery key: keypair_bundles.bundle_enc_recovery (node-only column, added in _SCHEMA_KEYPAIR and via a PRAGMA-guarded ALTER for existing DBs), carried on keypair_bundle_store / _resp. MNP 0.13 -> 0.14, additive. - session.recoveryKey is persisted in IndexedDB (slot rk) and lazy-loaded on connect, so a group joined in any later session still leaves a recovery copy. - Shown once at registration; optionally folded into the verification e-mail as a pass-through the hub never stores or logs, with an opt-out. - Profile -> Recovery key re-loads R and backfills every reachable node via rewrapAllNodes in bundleKey mode (no passphrase re-entry). Flow B — recover a lost passphrase (#/reset, linked from sign-in) - POST /v1/users/password/reset-request {username, email}: both must be the pair on file, checked against the blind email_hash (never decrypted). A mismatch — wrong e-mail, unknown username, non-active account — takes the identical no-op path (no code, no mail, same 200), so it reveals nothing and cannot be used to spray reset mail from a username alone. 5/min, 1-hour single-use code. - POST /v1/users/password/reset {username, code, new_auth_key}: same expiry / attempts / single-use checks as e-mail verification; revokes every session and deletes every registered device key so a stored one cannot sign back in past the reset. - ResetPasswordPage: request code -> code + optional recovery key + new passphrase -> reset + sign-in -> fan-out. connect() falls back to the recovery-wrapped copy when the passphrase key cannot open bundle_enc. Without a recovery key: sign-in is restored and each group needs the operator-unpin fallback. Supporting fixes (found in live testing) - member unpin now also deletes the keypair bundle; connect() mints a fresh identity when handed a bundle it cannot open (unless _rewrapOnly, set by rewrapAllNodes), so a rejoin completes instead of dead-ending before the invite-code prompt. - A browser with no bundle key gets a passphrase prompt on the group page instead of a "go back to the browser you registered on" message. - RegisterPage / LoginPage / ResetPasswordPage trim the username so every key derivation matches the hub's stored form. Docs: docs/auth-confirm.md. Locale keys across all ten catalogues. Tests: test_password_change, test_password_reset, test_recovery_email, test_recovery_key, test_rewrap_fanout, test_bundle_store_recovery, plus additions to test_admin_ops_mnp and test_webrtc_transport. Hub suite 492 passed; node suite 741 passed (the lone test_packaging_units failure is a pre-existing RPM-spec flake, reproducible on main). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GGkxJW9br8Y9bhT8ywJ3oc
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/conftest.py2
-rw-r--r--packages/meshbay-hub/tests/test_password_change.py140
-rw-r--r--packages/meshbay-hub/tests/test_password_reset.py204
-rw-r--r--packages/meshbay-hub/tests/test_recovery_email.py99
-rw-r--r--packages/meshbay-hub/tests/test_recovery_key.py136
-rw-r--r--packages/meshbay-hub/tests/test_rewrap_fanout.py210
6 files changed, 790 insertions, 1 deletions
diff --git a/packages/meshbay-hub/tests/conftest.py b/packages/meshbay-hub/tests/conftest.py
index bf96e3d..2769f7e 100644
--- a/packages/meshbay-hub/tests/conftest.py
+++ b/packages/meshbay-hub/tests/conftest.py
@@ -90,7 +90,7 @@ async def db_session(app):
@pytest.fixture(autouse=True)
def _skip_email_verification(monkeypatch):
"""Skip email verification in tests — users are active immediately."""
- async def _noop(db, user, email, eh):
+ async def _noop(db, user, email, eh, recovery_key=None):
pass
monkeypatch.setattr(
diff --git a/packages/meshbay-hub/tests/test_password_change.py b/packages/meshbay-hub/tests/test_password_change.py
new file mode 100644
index 0000000..aba2d9a
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_password_change.py
@@ -0,0 +1,140 @@
+"""
+Passphrase change — Flow A of docs/auth-confirm.md.
+
+The hub's part is small: re-prove the current passphrase, swap the auth_key
+verifier, invalidate every other session, keep the caller's. The re-wrapping of
+per-node identity bundles is the client's job and does not touch the hub, so it
+is not exercised here.
+"""
+
+import base64
+import hashlib
+
+import pytest
+from sqlalchemy import select
+
+from meshbay_hub.db.models import IPLog, RefreshToken, User
+
+
+def _auth_key(password: str, username: str) -> str:
+ salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest()
+ return base64.b64encode(
+ hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode()
+
+
+async def _register(client, username, password="the-first-passphrase"):
+ r = await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@example.com",
+ "auth_key": _auth_key(password, username),
+ })
+ assert r.status_code in (200, 201), r.text
+ login = await client.post("/v1/users/login", json={
+ "username": username, "auth_key": _auth_key(password, username)})
+ assert login.status_code == 200, login.text
+ return login.json()
+
+
+@pytest.mark.asyncio
+async def test_change_then_sign_in_with_the_new_passphrase(client):
+ old, new = "the-first-passphrase", "a-second-passphrase-entirely"
+ session = await _register(client, "alice", old)
+
+ r = await client.post("/v1/users/password", json={
+ "old_auth_key": _auth_key(old, "alice"),
+ "new_auth_key": _auth_key(new, "alice"),
+ }, headers={"Authorization": f"Bearer {session['access_token']}"})
+ assert r.status_code == 200, r.text
+ assert r.json()["status"] == "changed"
+
+ assert (await client.post("/v1/users/login", json={
+ "username": "alice", "auth_key": _auth_key(old, "alice")})).status_code == 401
+ assert (await client.post("/v1/users/login", json={
+ "username": "alice", "auth_key": _auth_key(new, "alice")})).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_wrong_current_passphrase_is_refused_and_changes_nothing(client):
+ old = "the-first-passphrase"
+ session = await _register(client, "bob", old)
+
+ r = await client.post("/v1/users/password", json={
+ "old_auth_key": _auth_key("not the passphrase", "bob"),
+ "new_auth_key": _auth_key("some-new-passphrase", "bob"),
+ }, headers={"Authorization": f"Bearer {session['access_token']}"})
+ assert r.status_code == 403
+
+ assert (await client.post("/v1/users/login", json={
+ "username": "bob", "auth_key": _auth_key(old, "bob")})).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_new_must_differ_from_old(client):
+ old = "the-first-passphrase"
+ session = await _register(client, "carol", old)
+
+ r = await client.post("/v1/users/password", json={
+ "old_auth_key": _auth_key(old, "carol"),
+ "new_auth_key": _auth_key(old, "carol"),
+ }, headers={"Authorization": f"Bearer {session['access_token']}"})
+ assert r.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_unauthenticated_call_is_rejected(client):
+ """A session is required — the current passphrase alone is not a credential."""
+ await _register(client, "dave", "the-first-passphrase")
+ r = await client.post("/v1/users/password", json={
+ "old_auth_key": _auth_key("the-first-passphrase", "dave"),
+ "new_auth_key": _auth_key("a-new-one", "dave"),
+ })
+ assert r.status_code in (401, 403, 422)
+
+
+@pytest.mark.asyncio
+async def test_other_sessions_are_invalidated_and_the_caller_keeps_one(
+ client, db_session):
+ old, new = "the-first-passphrase", "a-second-passphrase-entirely"
+ first = await _register(client, "erin", old)
+ # A second browser signs in before the change.
+ second = (await client.post("/v1/users/login", json={
+ "username": "erin", "auth_key": _auth_key(old, "erin")})).json()
+
+ r = await client.post("/v1/users/password", json={
+ "old_auth_key": _auth_key(old, "erin"),
+ "new_auth_key": _auth_key(new, "erin"),
+ }, headers={"Authorization": f"Bearer {first['access_token']}"})
+ assert r.status_code == 200, r.text
+
+ # The other browser's refresh token is dead.
+ stale = await client.post("/v1/users/token/refresh", json={
+ "refresh_token": second["refresh_token"]})
+ assert stale.status_code == 401
+
+ # The caller was handed a fresh pair that still works.
+ fresh = await client.post("/v1/users/token/refresh", json={
+ "refresh_token": r.json()["refresh_token"]})
+ assert fresh.status_code == 200, fresh.text
+
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "erin"))).scalar_one()
+ live = (await db_session.execute(
+ select(RefreshToken).where(RefreshToken.user_id == uid,
+ RefreshToken.revoked.is_(False)))).scalars().all()
+ # Only the family issued to the caller (the refresh above rotated it once).
+ assert len(live) == 1
+
+
+@pytest.mark.asyncio
+async def test_the_change_is_logged(client, db_session):
+ old, new = "the-first-passphrase", "a-second-passphrase-entirely"
+ session = await _register(client, "frank", old)
+ await client.post("/v1/users/password", json={
+ "old_auth_key": _auth_key(old, "frank"),
+ "new_auth_key": _auth_key(new, "frank"),
+ }, headers={"Authorization": f"Bearer {session['access_token']}"})
+
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "frank"))).scalar_one()
+ events = {e.event for e in (await db_session.execute(
+ select(IPLog).where(IPLog.user_id == uid))).scalars().all()}
+ assert "password_change" in events
diff --git a/packages/meshbay-hub/tests/test_password_reset.py b/packages/meshbay-hub/tests/test_password_reset.py
new file mode 100644
index 0000000..80977c5
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_password_reset.py
@@ -0,0 +1,204 @@
+"""
+Passphrase reset by e-mail code — Flow B of docs/auth-confirm.md §4.2.
+
+The hub's part re-opens sign-in only: it swaps the auth_key verifier, kills
+every session, and drops every registered device key so a stored one cannot
+sign back in past the reset. Restoring group access is the client's job with
+the recovery key and is not exercised here.
+"""
+
+import base64
+import time
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_common.crypto import pk_to_b64
+from meshbay_hub.db.models import EmailVerification, IPLog, User
+from sqlalchemy import select
+
+
+def _email(username: str) -> str:
+ return f"{username}@example.com"
+
+
+async def _register(client, username, auth_key="k" * 44):
+ r = await client.post("/v1/users/register", json={
+ "username": username, "email": _email(username),
+ "auth_key": auth_key})
+ assert r.status_code in (200, 201), r.text
+
+
+async def _request_reset(client, username, email=None):
+ return await client.post("/v1/users/password/reset-request", json={
+ "username": username, "email": email or _email(username)})
+
+
+async def _reset_code(db_session, username) -> str:
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == username))).scalar_one()
+ row = (await db_session.execute(
+ select(EmailVerification).where(
+ EmailVerification.user_id == uid,
+ EmailVerification.purpose == "password_reset",
+ EmailVerification.verified_at.is_(None),
+ ).order_by(EmailVerification.created_at.desc()))).scalars().first()
+ return row.code if row else None
+
+
+@pytest.mark.asyncio
+async def test_reset_lets_the_user_sign_in_with_a_new_passphrase(client, db_session):
+ await _register(client, "alice", "old" + "a" * 41)
+ r = await _request_reset(client, "alice")
+ assert r.status_code == 200 and r.json()["status"] == "sent_if_exists"
+
+ code = await _reset_code(db_session, "alice")
+ assert code
+
+ new = "new" + "b" * 41
+ r = await client.post("/v1/users/password/reset", json={
+ "username": "alice", "code": code, "new_auth_key": new})
+ assert r.status_code == 200, r.text
+
+ assert (await client.post("/v1/users/login", json={
+ "username": "alice", "auth_key": "old" + "a" * 41})).status_code == 401
+ assert (await client.post("/v1/users/login", json={
+ "username": "alice", "auth_key": new})).status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_reset_request_never_reveals_whether_an_account_exists(
+ client, db_session):
+ r = await _request_reset(client, "ghost")
+ assert r.status_code == 200
+ assert r.json()["status"] == "sent_if_exists"
+ rows = (await db_session.execute(select(EmailVerification))).scalars().all()
+ assert rows == []
+
+
+@pytest.mark.asyncio
+async def test_reset_request_needs_the_username_and_email_to_match(client, db_session):
+ await _register(client, "hank")
+
+ # Right username, wrong e-mail — answered exactly like an unknown account,
+ # and no code is created.
+ r = await _request_reset(client, "hank", email="someone.else@example.com")
+ assert r.status_code == 200
+ assert r.json()["status"] == "sent_if_exists"
+ assert (await db_session.execute(
+ select(EmailVerification))).scalars().all() == []
+
+ # The real pair does create one.
+ await _request_reset(client, "hank")
+ assert (await db_session.execute(
+ select(EmailVerification))).scalars().first() is not None
+
+
+@pytest.mark.asyncio
+async def test_reset_request_rejects_a_malformed_email(client):
+ await _register(client, "iris")
+ r = await client.post("/v1/users/password/reset-request", json={
+ "username": "iris", "email": "not-an-email"})
+ assert r.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_a_wrong_code_is_rejected_and_counts_against_the_limit(
+ client, db_session):
+ await _register(client, "bob")
+ await _request_reset(client, "bob")
+
+ for _ in range(10):
+ r = await client.post("/v1/users/password/reset", json={
+ "username": "bob", "code": "000000", "new_auth_key": "x" * 44})
+ assert r.status_code == 400
+ r = await client.post("/v1/users/password/reset", json={
+ "username": "bob", "code": "000000", "new_auth_key": "x" * 44})
+ assert r.status_code == 429
+
+
+@pytest.mark.asyncio
+async def test_an_expired_code_is_refused(client, db_session):
+ await _register(client, "carol")
+ await _request_reset(client, "carol")
+
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "carol"))).scalar_one()
+ row = (await db_session.execute(select(EmailVerification).where(
+ EmailVerification.user_id == uid))).scalars().one()
+ row.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
+ await db_session.commit()
+
+ r = await client.post("/v1/users/password/reset", json={
+ "username": "carol", "code": row.code, "new_auth_key": "y" * 44})
+ assert r.status_code == 410
+
+
+@pytest.mark.asyncio
+async def test_a_reset_code_works_once(client, db_session):
+ await _register(client, "dave")
+ await _request_reset(client, "dave")
+ code = await _reset_code(db_session, "dave")
+
+ first = await client.post("/v1/users/password/reset", json={
+ "username": "dave", "code": code, "new_auth_key": "z" * 44})
+ assert first.status_code == 200
+ second = await client.post("/v1/users/password/reset", json={
+ "username": "dave", "code": code, "new_auth_key": "z" * 44})
+ assert second.status_code == 404
+
+
+@pytest.mark.asyncio
+async def test_reset_revokes_sessions_and_wipes_devices(client, db_session):
+ await _register(client, "erin", "erin" + "a" * 40)
+ login = await client.post("/v1/users/login", json={
+ "username": "erin", "auth_key": "erin" + "a" * 40})
+ refresh_token = login.json()["refresh_token"]
+ token = login.json()["access_token"]
+
+ sk = Ed25519PrivateKey.generate()
+ dev = await client.post(
+ "/v1/users/devices",
+ json={"pk_auth_ed25519": pk_to_b64(sk.public_key()), "label": "laptop"},
+ headers={"Authorization": f"Bearer {token}"})
+ assert dev.status_code == 201, dev.text
+
+ await _request_reset(client, "erin")
+ code = await _reset_code(db_session, "erin")
+ r = await client.post("/v1/users/password/reset", json={
+ "username": "erin", "code": code, "new_auth_key": "erin-new" + "b" * 36})
+ assert r.status_code == 200
+
+ # Old refresh token is dead.
+ assert (await client.post("/v1/users/token/refresh", json={
+ "refresh_token": refresh_token})).status_code == 401
+
+ # Every device key is gone; the stored one can no longer sign in.
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "erin"))).scalar_one()
+ from meshbay_hub.db.models import UserDevice
+ devices = (await db_session.execute(
+ select(UserDevice).where(UserDevice.user_id == uid))).scalars().all()
+ assert devices == []
+
+ ts = int(time.time())
+ msg = f"meshbay:user_auth:erin:{ts}".encode()
+ da = await client.post("/v1/users/auth", json={
+ "username": "erin", "timestamp": ts,
+ "signature": base64.b64encode(sk.sign(msg)).decode()})
+ assert da.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_the_request_and_the_reset_are_logged(client, db_session):
+ await _register(client, "frank")
+ await _request_reset(client, "frank")
+ code = await _reset_code(db_session, "frank")
+ await client.post("/v1/users/password/reset", json={
+ "username": "frank", "code": code, "new_auth_key": "f" * 44})
+
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "frank"))).scalar_one()
+ events = {e.event for e in (await db_session.execute(
+ select(IPLog).where(IPLog.user_id == uid))).scalars().all()}
+ assert {"password_reset_request", "password_reset"} <= events
diff --git a/packages/meshbay-hub/tests/test_recovery_email.py b/packages/meshbay-hub/tests/test_recovery_email.py
new file mode 100644
index 0000000..e4288a1
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_recovery_email.py
@@ -0,0 +1,99 @@
+"""
+The recovery key in the registration e-mail (docs/auth-confirm.md §4.4).
+
+When the client sends `recovery_key`, the hub appends it to the verification
+e-mail and stores it nowhere. When it does not, the e-mail carries only the
+code. `recovery_key` is a pass-through — no column, no log line beyond a
+boolean.
+"""
+
+import base64
+import hashlib
+
+import pytest
+from meshbay_hub import mail
+from meshbay_hub.db.models import EmailVerification, User
+from sqlalchemy import select
+
+RECOVERY = "ABCD EFGH JKLM NPQR STUV WXYZ 2345 6789 ABCD EFGH JKLM NPQR STUV"
+
+
+def _auth_key(password: str, username: str) -> str:
+ salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest()
+ return base64.b64encode(
+ hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode()
+
+
+@pytest.fixture(autouse=True)
+def _skip_email_verification(monkeypatch):
+ """
+ Override conftest's skip: this module needs the real verification path to
+ run so the e-mail is actually built. Capture it instead of sending.
+ """
+ sent = []
+ monkeypatch.setattr("meshbay_hub.mail._send", lambda msg: sent.append(msg) or True)
+ return sent
+
+
+@pytest.mark.asyncio
+async def test_register_appends_the_recovery_key_to_the_email(
+ client, _skip_email_verification):
+ r = await client.post("/v1/users/register", json={
+ "username": "rk1", "email": "rk1@example.com",
+ "auth_key": _auth_key("a-long-enough-passphrase", "rk1"),
+ "recovery_key": RECOVERY,
+ })
+ assert r.status_code in (200, 201), r.text
+ assert len(_skip_email_verification) == 1
+ body = _skip_email_verification[0].get_content()
+ assert RECOVERY in body
+ assert "recovery key" in body.lower()
+
+
+@pytest.mark.asyncio
+async def test_register_without_recovery_key_sends_only_the_code(
+ client, _skip_email_verification):
+ r = await client.post("/v1/users/register", json={
+ "username": "rk2", "email": "rk2@example.com",
+ "auth_key": _auth_key("a-long-enough-passphrase", "rk2"),
+ })
+ assert r.status_code in (200, 201), r.text
+ body = _skip_email_verification[0].get_content()
+ assert "recovery key" not in body.lower()
+ assert "verification code is" in body.lower()
+
+
+@pytest.mark.asyncio
+async def test_the_recovery_key_is_not_persisted(
+ client, db_session, _skip_email_verification):
+ await client.post("/v1/users/register", json={
+ "username": "rk3", "email": "rk3@example.com",
+ "auth_key": _auth_key("a-long-enough-passphrase", "rk3"),
+ "recovery_key": RECOVERY,
+ })
+ rows = (await db_session.execute(select(EmailVerification))).scalars().all()
+ assert rows
+ for row in rows:
+ assert RECOVERY not in (row.code or "")
+ assert RECOVERY not in (row.email_encrypted or "")
+ user = (await db_session.execute(
+ select(User).where(User.username == "rk3"))).scalar_one()
+ assert RECOVERY not in repr(vars(user))
+
+
+def test_mail_body_with_and_without_the_key(monkeypatch):
+ captured = []
+ monkeypatch.setattr("meshbay_hub.mail._send",
+ lambda msg: captured.append(msg) or True)
+
+ mail.send_verification_code("x@example.com", "123456",
+ recovery_key="MY-RECOVERY-KEY")
+ body = captured[-1].get_content()
+ assert "123456" in body
+ assert "MY-RECOVERY-KEY" in body
+ assert "recovery key" in body.lower()
+
+ mail.send_verification_code("x@example.com", "123456")
+ body = captured[-1].get_content()
+ assert "123456" in body
+ assert "recovery key" not in body.lower()
diff --git a/packages/meshbay-hub/tests/test_recovery_key.py b/packages/meshbay-hub/tests/test_recovery_key.py
new file mode 100644
index 0000000..378758a
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_recovery_key.py
@@ -0,0 +1,136 @@
+"""
+The account recovery key (docs/auth-confirm.md §4.3).
+
+`generateRecoveryKey` / `deriveRecoveryKey` in keyderive.js are run here under
+node against the real WebCrypto, rather than reimplemented: the mnemonic has to
+round-trip its bytes exactly, and the derived key has to be deterministic per
+account and domain-separated between accounts, or a recovery would hand back a
+key that opens nothing.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+KEYDERIVE = STATIC / "keyderive.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not KEYDERIVE.exists(),
+ reason="node or keyderive.js is unavailable",
+)
+
+_HARNESS = r"""
+const fs = require('fs');
+const webcrypto = require('crypto').webcrypto;
+global.self = global;
+global.window = global;
+global.crypto = webcrypto;
+
+// keyderive.js is a classic script ending in `window.MeshBayKeys = {...}`.
+eval(fs.readFileSync(process.argv[2], 'utf8'));
+const K = window.MeshBayKeys;
+
+const hex = (buf) => Buffer.from(buf).toString('hex');
+
+// deriveRecoveryKey yields a non-extractable AES-GCM key, so two keys are
+// compared by encrypting a fixed block with a fixed IV: same key => same bytes.
+const fp = async (key) => hex(await webcrypto.subtle.encrypt(
+ { name: 'AES-GCM', iv: new Uint8Array(12) }, key, new Uint8Array(16)));
+
+(async () => {
+ const out = {};
+
+ // 1. mnemonic round-trips the exact 32 bytes, 200 random draws: the key
+ // derived from the mnemonic string must match the key from the raw bytes.
+ let roundTripOk = true;
+ for (let i = 0; i < 200; i++) {
+ const rk = K.generateRecoveryKey(); // { rawB64, mnemonic }
+ const raw = Uint8Array.from(atob(rk.rawB64), c => c.charCodeAt(0));
+ const a = await fp(await K.deriveRecoveryKey(rk.mnemonic, 'u'));
+ const b = await fp(await K.deriveRecoveryKey(raw, 'u'));
+ if (a !== b) { roundTripOk = false; break; }
+ }
+ out.round_trip_ok = roundTripOk;
+
+ // 2. deterministic per account, different per account.
+ const rk = K.generateRecoveryKey();
+ const raw = Uint8Array.from(atob(rk.rawB64), c => c.charCodeAt(0));
+ const k1 = await fp(await K.deriveRecoveryKey(raw, 'alice'));
+ const k1again = await fp(await K.deriveRecoveryKey(raw, 'alice'));
+ const k2 = await fp(await K.deriveRecoveryKey(raw, 'bob'));
+ out.deterministic = (k1 === k1again);
+ out.domain_separated = (k1 !== k2);
+
+ // 3. mnemonic is grouped Base32, 52 significant chars for 32 bytes.
+ out.mnemonic_shape_ok =
+ /^[A-Z2-7]{4}( [A-Z2-7]{1,4})+$/.test(rk.mnemonic) &&
+ rk.mnemonic.replace(/ /g, '').length === 52;
+
+ // 4. a garbled key is rejected, not silently truncated.
+ let rejected = false;
+ try { await K.deriveRecoveryKey('too short', 'u'); }
+ catch { rejected = true; }
+ out.rejects_short = rejected;
+
+ // 5. the building block connect()'s Flow B fallback relies on: a bundle
+ // wrapped under one recovery key does not open under a wrong one, and does
+ // open under the right one.
+ {
+ const kA = await K.deriveRecoveryKey(raw, 'acc-A');
+ const kB = await K.deriveRecoveryKey(raw, 'acc-B');
+ const skEd = new Uint8Array([1, 2, 3]);
+ const skX = new Uint8Array([4, 5, 6]);
+ const blob = await K.encryptBundleWithKey(skEd, skX, kA);
+ let wrongRejected = false;
+ try { await K.decryptBundleWithKey(blob, kB); } catch { wrongRejected = true; }
+ const opened = await K.decryptBundleWithKey(blob, kA);
+ out.recovery_wrap_isolates = wrongRejected
+ && opened.skEd === btoa(String.fromCharCode(1, 2, 3))
+ && opened.skX === btoa(String.fromCharCode(4, 5, 6));
+ }
+
+ process.stdout.write(JSON.stringify(out));
+})().catch(e => { console.error(e); process.exit(1); });
+"""
+
+
+@pytest.fixture(scope="module")
+def result(tmp_path_factory):
+ d = tmp_path_factory.mktemp("recovery")
+ harness = d / "harness.cjs"
+ harness.write_text(_HARNESS)
+ proc = subprocess.run(
+ ["node", str(harness), str(KEYDERIVE)],
+ capture_output=True, text=True, timeout=120,
+ )
+ if proc.returncode != 0:
+ pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}")
+ return json.loads(proc.stdout)
+
+
+def test_mnemonic_round_trips_the_exact_bytes(result):
+ assert result["round_trip_ok"]
+
+
+def test_derived_key_is_deterministic_per_account(result):
+ assert result["deterministic"]
+
+
+def test_derived_key_is_domain_separated_between_accounts(result):
+ assert result["domain_separated"]
+
+
+def test_mnemonic_is_grouped_base32(result):
+ assert result["mnemonic_shape_ok"]
+
+
+def test_a_malformed_recovery_key_is_rejected(result):
+ assert result["rejects_short"]
+
+
+def test_a_recovery_wrapped_bundle_only_opens_under_the_matching_key(result):
+ assert result["recovery_wrap_isolates"]
diff --git a/packages/meshbay-hub/tests/test_rewrap_fanout.py b/packages/meshbay-hub/tests/test_rewrap_fanout.py
new file mode 100644
index 0000000..03d24dc
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_rewrap_fanout.py
@@ -0,0 +1,210 @@
+"""
+`MeshBayTransport.rewrapAllNodes` — the passphrase-change / recovery fan-out
+(docs/auth-confirm.md §3.2, §4.5).
+
+The real function is run under node with its two boundaries stubbed: the hub
+HTTP calls and the per-node `MeshBayTransport` handshake. What is exercised is
+the orchestration — which groups land in `updated` / `unreachable` / `failed`,
+which nodes get a `keypair_bundle_store`, and that Flow B also writes a
+recovery-wrapped copy. The WebRTC handshake itself and `connect()`'s
+recovery-copy fallback are integration territory with no harness here.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+TRANSPORT = STATIC / "transport.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not TRANSPORT.exists(),
+ reason="node or transport.js is unavailable",
+)
+
+_HARNESS = r"""
+const fs = require('fs');
+global.self = global;
+global.window = global;
+global.location = { hash: '' };
+global.addEventListener = () => {};
+global.document = {
+ hidden: false, visibilityState: 'visible', addEventListener: () => {},
+};
+global.localStorage = {
+ getItem: () => null, setItem() {}, removeItem() {}, key: () => null, length: 0,
+};
+// connect() is stubbed on the prototype below, so no WebRTC shim is needed.
+global.RTCPeerConnection = function () { throw new Error('connect() not stubbed'); };
+
+eval(fs.readFileSync(process.argv[2], 'utf8'));
+const T = window.MeshBayTransport;
+
+let deriveEncCalls = 0;
+window.MeshBayKeys = {
+ deriveEncryptionKey: async (p) => { deriveEncCalls++; return { kind: 'enc', p }; },
+ deriveEncryptionKeyV1: async (p) => ({ kind: 'encv1', p }),
+ deriveRecoveryKey: async (r) => ({ kind: 'rec', r }),
+ encryptBundleWithKey: async (_skEd, _skX, key) => 'wrapped:' + key.kind,
+};
+
+const b64 = (s) => Buffer.from(s).toString('base64');
+
+const NODES = {
+ 'n-ok': { sessionKeys: { skEdB64: b64('ed'), skXB64: b64('x') } },
+ 'n-fresh': { newNodeBundle: 'fresh', sessionKeys: { skEdB64: b64('ed'), skXB64: b64('x') } },
+ 'n-throw': { throws: 'handshake failed' },
+ 'n-noident': { sessionKeys: null },
+};
+
+const stored = [];
+const rewrapOnlySeen = [];
+T.prototype.connect = async function (nodeId) {
+ this._nodeId = nodeId;
+ rewrapOnlySeen.push(this._rewrapOnly === true);
+ const s = NODES[nodeId] || {};
+ if (s.throws) throw new Error(s.throws);
+ this._sessionKeys = s.sessionKeys || null;
+ this._newNodeBundle = s.newNodeBundle || null;
+ return { ok: true };
+};
+T.prototype.storeKeypairBundle = async function (enc, rec) {
+ stored.push({ nodeId: this._nodeId, enc, rec: rec || null });
+};
+T.prototype.close = function () {};
+
+const MINE = { groups: [
+ { id: 'gA', name: 'a', owner_username: 'ann' }, // normal node
+ { id: 'gB', name: 'b', owner_username: 'ann' }, // no online node
+ { id: 'gC', name: 'c', owner_username: 'ann' }, // /nodes errors
+ { id: 'gD', name: 'd', owner_username: 'ann' }, // connect throws
+ { id: 'gE', name: 'e', owner_username: 'ann' }, // fresh identity, nothing stranded
+ { id: 'gF', name: 'f', owner_username: 'ann' }, // identity not recovered
+] };
+const NODES_FOR = {
+ gA: { nodes: [{ node_id: 'n-ok' }] },
+ gB: { nodes: [] },
+ gC: 'ERR',
+ gD: { nodes: [{ node_id: 'n-throw' }] },
+ gE: { nodes: [{ node_id: 'n-fresh' }] },
+ gF: { nodes: [{ node_id: 'n-noident' }] },
+};
+global.fetch = async (url) => {
+ const path = url.replace(/^.*?(\/v1\/)/, '$1');
+ if (path === '/v1/groups/mine') return { ok: true, json: async () => MINE };
+ const m = path.match(/^\/v1\/groups\/([^/]+)\/nodes$/);
+ if (m) {
+ const v = NODES_FOR[m[1]];
+ if (v === 'ERR') return { ok: false, status: 503 };
+ return { ok: true, json: async () => v };
+ }
+ return { ok: false, status: 404 };
+};
+
+const names = (a) => a.map((x) => x.name).sort();
+
+(async () => {
+ const A = await T.rewrapAllNodes({
+ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid',
+ oldPassphrase: 'old', newPassphrase: 'new',
+ });
+ const storeA = stored.splice(0);
+
+ const B = await T.rewrapAllNodes({
+ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid',
+ newPassphrase: 'new', recoveryKey: 'A RECOVERY MNEMONIC',
+ });
+ const storeB = stored.splice(0);
+
+ // Flow C — Profile backfill: keep the live passphrase key, just add the
+ // recovery copy. No passphrase strings, so deriveEncryptionKey is not called.
+ deriveEncCalls = 0;
+ const C = await T.rewrapAllNodes({
+ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid',
+ bundleKey: { v2: { kind: 'bk' }, v1: { kind: 'bkv1' } },
+ recoveryKey: 'A RECOVERY MNEMONIC',
+ });
+ const storeC = stored.splice(0);
+
+ process.stdout.write(JSON.stringify({
+ a_updated: names(A.updated),
+ a_unreachable: names(A.unreachable),
+ a_failed: names(A.failed),
+ a_stored_nodes: storeA.map((s) => s.nodeId).sort(),
+ a_recovery_always_null: storeA.every((s) => s.rec === null),
+ a_new_bundle_key_kind: A.newBundleKey && A.newBundleKey.v2 && A.newBundleKey.v2.kind,
+ b_stored: storeB.map((s) => ({ node: s.nodeId, enc: s.enc, rec: s.rec })),
+ c_stored: storeC.map((s) => ({ node: s.nodeId, enc: s.enc, rec: s.rec })),
+ c_derive_enc_calls: deriveEncCalls,
+ // Every transport the fan-out builds is flagged rewrap-only, so a stored
+ // bundle it cannot open is reported, not silently replaced with a new one.
+ all_rewrap_only: rewrapOnlySeen.length > 0 && rewrapOnlySeen.every(Boolean),
+ }));
+})().catch((e) => { console.error(e); process.exit(1); });
+"""
+
+
+@pytest.fixture(scope="module")
+def result(tmp_path_factory):
+ d = tmp_path_factory.mktemp("rewrap")
+ harness = d / "harness.cjs"
+ harness.write_text(_HARNESS)
+ proc = subprocess.run(
+ ["node", str(harness), str(TRANSPORT)],
+ capture_output=True, text=True, timeout=120,
+ )
+ if proc.returncode != 0:
+ pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}")
+ return json.loads(proc.stdout)
+
+
+def test_a_reachable_node_with_an_identity_is_updated(result):
+ assert "a@ann" in result["a_updated"]
+ assert result["a_stored_nodes"] == ["n-ok"]
+
+
+def test_a_group_with_no_online_node_is_unreachable(result):
+ assert result["a_unreachable"] == ["b@ann"]
+
+
+def test_a_nodes_lookup_error_and_a_failed_handshake_land_in_failed(result):
+ assert "c@ann" in result["a_failed"] # /nodes returned 503
+ assert "d@ann" in result["a_failed"] # connect() threw
+
+
+def test_a_node_that_never_had_our_identity_is_not_written_but_not_a_failure(result):
+ # gE: connect minted a fresh identity — nothing is stranded, so the group is
+ # "updated", and no keypair_bundle_store is sent for it.
+ assert "e@ann" in result["a_updated"]
+ assert "n-fresh" not in result["a_stored_nodes"]
+
+
+def test_a_node_that_returns_no_identity_is_a_failure(result):
+ assert "f@ann" in result["a_failed"]
+
+
+def test_flow_a_writes_only_the_passphrase_copy(result):
+ assert result["a_recovery_always_null"] is True
+ assert result["a_new_bundle_key_kind"] == "enc"
+
+
+def test_flow_b_writes_both_the_passphrase_and_the_recovery_copy(result):
+ assert result["b_stored"] == [
+ {"node": "n-ok", "enc": "wrapped:enc", "rec": "wrapped:rec"},
+ ]
+
+
+def test_profile_backfill_keeps_the_live_key_and_adds_the_recovery_copy(result):
+ # bundleKey mode: the passphrase copy is re-wrapped with the same live key
+ # (kind "bk"), the recovery copy is added, and no passphrase is derived.
+ assert result["c_stored"] == [
+ {"node": "n-ok", "enc": "wrapped:bk", "rec": "wrapped:rec"},
+ ]
+ assert result["c_derive_enc_calls"] == 0
+
+
+def test_every_fanout_transport_is_rewrap_only(result):
+ assert result["all_rewrap_only"] is True