aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-07 21:04:56 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-07 21:04:56 +0200
commit3bd31db9d4fa2352f1095dcc630c77915d0774b8 (patch)
tree5674ddabdf4037ee5b7c2d1bc682e941d7a7da01 /packages/meshbay-hub/tests
parent7fa74d722108ca4338d14db4ecba53800df86fca (diff)
downloadmeshbay-3bd31db9d4fa2352f1095dcc630c77915d0774b8.tar.gz
feat(chat): Tier 2 — a member verifies another member's device itself
Chat messages have been signed by the sending device since MNP 2.0, but a reader had no way to know that the device belonged to the account the node named: the signature proved *a device*, and `sender_id` was still the node's word. This closes that for any account a client has already seen. **What was blocking it was not effort — the evidence was not being kept.** `_do_device_add` verified the countersignature that admits a second device and stored only `added_by_pk`: *which* key approved, never the proof. And `device_add_transcript` binds `nonce_node`, the approving connection's handshake nonce, so even a stored signature was unverifiable by anyone who had not been on that connection. `identities` gains `add_sig`, `add_nonce` and `add_ts`, added before the migration's early return — which fires on every roster widened since 2026-08-18, i.e. all of them, so putting them inside it would have meant they never arrived. `group_roster_req`/`resp` relays, sealed under a new groupbox purpose and answered to **any member of the group**, every live device of every active member with the evidence that admitted it. The node decides nothing: it hands over evidence and the client walks the chain from each account's root outwards (`_verifyRoster`). That is deliberate — the node is the party the property holds against, so it is not asked to assert trust. Two holes the tests caught while this was being built: - "no signature" was being treated as a trust root, so a node that writes the roster could put any key in an account's row and have it laundered straight into the verified set. A root is a device that names **no** countersigner. - pinning only the verified subset at first sight raised "key changed" on legitimate second devices whose countersignature predates this change. First sight pins everything the node says, because that is what trust-on-first-use means and an alarm that fires on normal events stops being read. The property, and it must not be rounded up: **once a client has seen an account, a node that later substitutes a key for it is detected. Nothing is gained at first sight**, where there is nothing to compare against — the same boundary `per-node-identity-v1.md` draws, unmoved. The cost, stated because it is real: the roster is member-visible, so every member learns how many devices the others hold and their public keys. It stays inside the group, the hub is not involved, and it is scoped per group. A member who cannot see the keys cannot check them. User-visible surface: one notice, "this account is using a key you have not seen before", in ten languages. Nothing else. 16 tests — 7 on the node (the evidence is stored, it verifies from the roster alone, a fabricated device carries none, another group's members are not disclosed), 9 running the shipped `_verifyRoster` under node against rosters built by the shipped Python: a chain of three in any order, a signature by the wrong key, one for another node, one for another account, and two fabricated devices signing each other admitting nothing. Tier 3 (operator-signed roster attestation) stays deferred, with nothing depending on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/test_account_pinning.py227
1 files changed, 227 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_account_pinning.py b/packages/meshbay-hub/tests/test_account_pinning.py
new file mode 100644
index 0000000..192bd25
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_account_pinning.py
@@ -0,0 +1,227 @@
+"""
+Tier 2, the half that decides anything: the client walks the chain.
+
+The node relays evidence and asserts nothing (`test_group_roster.py`). What
+turns that into a property is here — `_verifyRoster` in the shipped
+`transport.js`, run under node against rosters built by the shipped Python, so
+neither side is a model of the other.
+
+The property, stated exactly: **once this client has seen an account, a node
+that later substitutes a key for it is detected.** Nothing is gained at first
+sight, where there is nothing to compare against. A device the node lists but
+cannot evidence never enters `verified`, which is what stops a fabricated key
+being laundered into the set merely by being mentioned.
+"""
+import base64
+import json
+import shutil
+import subprocess
+import tempfile
+import time
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.device import device_add_transcript
+
+STATIC = (Path(__file__).resolve().parents[1]
+ / "src" / "meshbay_hub" / "static")
+TRANSPORT = STATIC / "transport.js"
+CRYPTO = STATIC / "crypto.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not TRANSPORT.exists(),
+ reason="node or the SPA sources are not available")
+
+NODE_PK = "Tk9ERVBL"
+NONCE = b"\x11" * 32
+
+_HARNESS = r"""
+const fs = require('fs');
+// The same stub the other transport.js harnesses use (upload_seal_probe.mjs,
+// index_seal_probe.mjs): the module reads `location.hash` at load time for its
+// debug flag, and registers listeners.
+globalThis.window = globalThis;
+globalThis.addEventListener = () => {};
+globalThis.removeEventListener = () => {};
+globalThis.location = { hash: '' };
+globalThis.document = {
+ addEventListener() {}, removeEventListener() {}, visibilityState: 'visible',
+};
+globalThis.localStorage = {
+ _v: {}, getItem(k) { return this._v[k] ?? null; },
+ setItem(k, v) { this._v[k] = String(v); },
+};
+// crypto.js publishes onto window; transport.js reads it from there.
+new Function(fs.readFileSync(process.argv[2], 'utf8'))();
+const T = new Function(
+ fs.readFileSync(process.argv[3], 'utf8') + '\nreturn { _verifyRoster };')();
+
+(async () => {
+ const input = JSON.parse(fs.readFileSync(process.argv[4], 'utf8'));
+ const out = await T._verifyRoster(input.payload, input.node_pk);
+ const result = {};
+ for (const [user, e] of out.byAccount) {
+ result[user] = { verified: e.verified, unevidenced: e.unevidenced,
+ all: e.all };
+ }
+ process.stdout.write(JSON.stringify(result));
+})().catch(e => { console.error(e); process.exit(1); });
+"""
+
+
+def _device(sk=None):
+ sk = sk or Ed25519PrivateKey.generate()
+ raw = sk.public_key().public_bytes(
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw)
+ return sk, base64.b64encode(raw).decode()
+
+
+def _entry(user, pk_ed, pk_x="cGtY", *, added_by="", sk_signer=None,
+ node_pk=NODE_PK):
+ """One roster row, countersigned for real when a signer is given."""
+ ts = int(time.time())
+ row = {"user_id": user, "username": user, "pk_ed25519": pk_ed,
+ "pk_x25519": pk_x, "added_by_pk": added_by, "add_sig": "",
+ "add_nonce": "", "add_ts": 0, "pinned_at": ""}
+ if sk_signer is not None:
+ transcript = device_add_transcript(
+ node_pk_b64=node_pk, user_id=user, pk_ed25519_b64=pk_ed,
+ pk_x25519_b64=pk_x, nonce_node=NONCE, ts=ts)
+ row["add_sig"] = base64.b64encode(sk_signer.sign(transcript)).decode()
+ row["add_nonce"] = base64.b64encode(NONCE).decode()
+ row["add_ts"] = ts
+ return row
+
+
+def _verify(devices, node_pk=NODE_PK):
+ with tempfile.TemporaryDirectory() as tmp:
+ h = Path(tmp) / "h.js"
+ h.write_text(_HARNESS)
+ payload = Path(tmp) / "in.json"
+ payload.write_text(json.dumps(
+ {"payload": {"devices": devices, "node_pk": node_pk},
+ "node_pk": node_pk}))
+ run = subprocess.run(
+ ["node", str(h), str(CRYPTO), str(TRANSPORT), str(payload)],
+ capture_output=True, timeout=60)
+ assert run.returncode == 0, run.stderr.decode()[-2000:]
+ return json.loads(run.stdout.decode())
+
+
+def test_a_lone_first_device_is_the_trust_root():
+ """No countersignature and none possible — an operator code admitted it."""
+ _sk, pk = _device()
+ out = _verify([_entry("alice", pk)])
+ assert out["alice"]["verified"] == [pk]
+ assert out["alice"]["unevidenced"] == []
+
+
+def test_a_countersigned_second_device_is_reached():
+ """The ordinary case: Alice adds a laptop, and nobody compares digits."""
+ sk_a, pk_a = _device()
+ _sk_b, pk_b = _device()
+ out = _verify([_entry("alice", pk_a),
+ _entry("alice", pk_b, added_by=pk_a, sk_signer=sk_a)])
+ assert sorted(out["alice"]["verified"]) == sorted([pk_a, pk_b])
+ assert out["alice"]["unevidenced"] == []
+
+
+def test_a_chain_of_three_is_walked_in_any_order():
+ """
+ A device may be countersigned by one that is itself countersigned, and the
+ roster arrives in whatever order SQL returned. The walk repeats until it
+ stops making progress rather than assuming an order.
+ """
+ sk_a, pk_a = _device()
+ sk_b, pk_b = _device()
+ _sk_c, pk_c = _device()
+ rows = [_entry("alice", pk_c, added_by=pk_b, sk_signer=sk_b),
+ _entry("alice", pk_b, added_by=pk_a, sk_signer=sk_a),
+ _entry("alice", pk_a)]
+ out = _verify(rows)
+ assert sorted(out["alice"]["verified"]) == sorted([pk_a, pk_b, pk_c])
+
+
+def test_a_fabricated_device_is_not_verified():
+ """
+ The attack. A node writes a device of its own into Alice's row — it writes
+ the roster, so it can. It cannot sign as a key it does not hold, so no
+ chain reaches the key and it stays out of `verified`.
+ """
+ _sk_a, pk_a = _device()
+ _sk_evil, pk_evil = _device()
+ out = _verify([_entry("alice", pk_a),
+ _entry("alice", pk_evil, added_by=pk_a)]) # no signature
+ assert out["alice"]["verified"] == [pk_a]
+ assert out["alice"]["unevidenced"] == [pk_evil]
+
+
+def test_a_signature_by_the_wrong_key_is_not_verified():
+ """A real signature, from a key that is not the one it names."""
+ _sk_a, pk_a = _device()
+ sk_other, _pk_other = _device()
+ _sk_b, pk_b = _device()
+ out = _verify([_entry("alice", pk_a),
+ _entry("alice", pk_b, added_by=pk_a, sk_signer=sk_other)])
+ assert out["alice"]["verified"] == [pk_a]
+ assert out["alice"]["unevidenced"] == [pk_b]
+
+
+def test_a_signature_for_another_node_does_not_transfer():
+ """
+ The transcript binds `node_pk`. A countersignature collected on one node
+ must not admit the same key on another — which is what an operator running
+ two nodes would otherwise be able to do to a member of both.
+ """
+ sk_a, pk_a = _device()
+ _sk_b, pk_b = _device()
+ row = _entry("alice", pk_b, added_by=pk_a, sk_signer=sk_a,
+ node_pk="QU5PVEhFUg==")
+ out = _verify([_entry("alice", pk_a), row])
+ assert out["alice"]["unevidenced"] == [pk_b]
+
+
+def test_a_signature_for_another_account_does_not_transfer():
+ """The transcript binds the account too."""
+ sk_a, pk_a = _device()
+ _sk_b, pk_b = _device()
+ row = _entry("alice", pk_b, added_by=pk_a, sk_signer=sk_a)
+ # Same signature, presented as admitting a device of Bob's.
+ row["user_id"] = "bob"
+ out = _verify([_entry("bob", pk_a), row])
+ assert out["bob"]["unevidenced"] == [pk_b]
+
+
+def test_an_orphan_chain_is_not_admitted_by_itself():
+ """
+ Two fabricated devices signing each other. Neither is reachable from a
+ root, so a cycle admits nothing — the walk starts from what an operator
+ code admitted, not from whatever claims to be signed.
+ """
+ sk_x, pk_x = _device()
+ sk_y, pk_y = _device()
+ _sk_a, pk_a = _device()
+ out = _verify([
+ _entry("alice", pk_a),
+ _entry("alice", pk_x, added_by=pk_y, sk_signer=sk_y),
+ _entry("alice", pk_y, added_by=pk_x, sk_signer=sk_x),
+ ])
+ assert out["alice"]["verified"] == [pk_a]
+ assert sorted(out["alice"]["unevidenced"]) == sorted([pk_x, pk_y])
+
+
+def test_a_device_pinned_before_the_evidence_existed_reads_as_a_root():
+ """
+ Honest about what it is. Such a device has `added_by_pk` but no signature —
+ it was countersigned, the proof was simply not kept. Treating it as
+ verified would mean accepting an unsigned key; treating it as a root is
+ trust-on-first-use, which is what it actually is.
+ """
+ _sk_a, pk_a = _device()
+ _sk_b, pk_b = _device()
+ out = _verify([_entry("alice", pk_a),
+ _entry("alice", pk_b, added_by=pk_a)])
+ assert pk_b in out["alice"]["unevidenced"]