diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-01 01:03:43 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-01 01:03:43 +0200 |
| commit | fe30860c58e0f1b1efd457ff5eb5146d1e592da0 (patch) | |
| tree | 99a3e96994738c4e96f969a365679475dc4cf5cd /packages/meshbay-hub/tests/test_rewrap_fanout.py | |
| parent | 51d2d734c228f1e46670962480258abfe586d6c4 (diff) | |
| download | meshbay-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/test_rewrap_fanout.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_rewrap_fanout.py | 210 |
1 files changed, 210 insertions, 0 deletions
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 |