summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_index_no_cleartext.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests/test_index_no_cleartext.py')
-rw-r--r--packages/meshbay-node/tests/test_index_no_cleartext.py159
1 files changed, 159 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_index_no_cleartext.py b/packages/meshbay-node/tests/test_index_no_cleartext.py
new file mode 100644
index 0000000..f510884
--- /dev/null
+++ b/packages/meshbay-node/tests/test_index_no_cleartext.py
@@ -0,0 +1,159 @@
+"""
+The test that asserts the property, rather than the mechanism.
+
+Worth more than checking that a `ct` field is present: this fails for any future
+change that puts a name back in the clear, including one nobody thought of as an
+index message. Findings C1 (the node HTTP API served the index and plaintext files
+on 0.0.0.0 with no authentication) and C6 (the TCP transport accepted a bare JWT
+with no GEK proof) were both "a peer that had not completed the handshake was served
+data"; sealed, that bug leaks ciphertext instead of a library's filenames.
+
+The distinctive strings below are invented and could not occur by chance in msgpack
+framing or in a field name.
+"""
+
+import msgpack
+import pytest
+from conftest import one_root
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_common.crypto import generate_gek
+from meshbay_common.groupbox import PURPOSE_ACK, PURPOSE_INDEX, seal, unseal
+from meshbay_common.protocol import MNP
+from meshbay_node.indexer import DirectoryIndexer, GroupIndex
+from meshbay_node.transport.wire import index_delta_message, index_sync_message
+
+# A filename and a folder name that appear nowhere else in the tree.
+SECRET_FILE = "quixotry-ledger-2019.pdf"
+SECRET_DIR = "zarfwidget-archive"
+SECRET_ROOT = "/srv/vasculum-private/library"
+
+
+@pytest.fixture
+def gek():
+ return generate_gek()
+
+
+@pytest.fixture
+async def indexer(tmp_path, gek):
+ shared = tmp_path / "shared"
+ (shared / SECRET_DIR).mkdir(parents=True)
+ (shared / SECRET_DIR / SECRET_FILE).write_bytes(b"x" * 64)
+ roots = one_root(shared, name="library")
+ idx = DirectoryIndexer(
+ roots=roots, group_id="g-1", sk_node=Ed25519PrivateKey.generate(), gek=gek)
+ await idx.initial_scan()
+ return idx
+
+
+def _assert_absent(frame: bytes, *words: str) -> None:
+ for word in words:
+ assert word.encode() not in frame, f"{word!r} travels in the clear"
+
+
+@pytest.mark.asyncio
+async def test_index_sync_frame_carries_no_filename(indexer):
+ frame = msgpack.packb(index_sync_message(indexer.index, indexer.roots),
+ use_bin_type=True)
+ # Not only the whole name — a fragment of it would be just as much of a leak.
+ _assert_absent(frame, SECRET_FILE, "quixotry", SECRET_DIR, "zarfwidget",
+ "entries", "dirs")
+ # And the routing fields are still readable, or nothing could be dispatched.
+ msg = msgpack.unpackb(frame, raw=False)
+ assert msg["type"] == MNP.INDEX_SYNC
+ assert msg["group_id"] == "g-1"
+ assert set(msg) == {"type", "v", "group_id", "nonce", "ct"}
+
+
+@pytest.mark.asyncio
+async def test_index_sync_payload_still_says_everything(indexer, gek):
+ """Sealed, not lost: every field a client reads is inside."""
+ msg = index_sync_message(indexer.index, indexer.roots)
+ payload = unseal(gek, PURPOSE_INDEX, MNP.INDEX_SYNC, "g-1", msg)
+ assert [e["name"] for e in payload["entries"]] == [SECRET_FILE]
+ assert any(d.endswith(SECRET_DIR) for d in payload["dirs"])
+ assert payload["roots"]
+ # Moved inside deliberately (D4): there is no reason to act on a version
+ # carried by a message we have not authenticated.
+ assert payload["version"] == indexer.index.version
+ assert "version" not in msg
+
+
+@pytest.mark.asyncio
+async def test_index_delta_frame_carries_no_filename(indexer, gek):
+ """
+ The delta is where a cleartext path would most easily survive: it used to be
+ hand-built in the daemon, a third construction site for an index message.
+ """
+ before = GroupIndex._snapshot(
+ "g-1", indexer.index.sk_node, gek, indexer.index.version - 1, {})
+ delta = indexer.index.diff(before)
+ assert delta.additions
+
+ frame = msgpack.packb(index_delta_message(indexer.index, delta),
+ use_bin_type=True)
+ _assert_absent(frame, SECRET_FILE, "quixotry", "additions", "deletions")
+
+ payload = unseal(gek, PURPOSE_INDEX, MNP.INDEX_DELTA, "g-1",
+ msgpack.unpackb(frame, raw=False))
+ assert [e["name"] for e in payload["additions"]] == [SECRET_FILE]
+ assert payload["base_version"] == delta.base_version
+
+
+def test_handshake_ack_frame_carries_no_configuration(gek):
+ """
+ The ack is the line that matters most, and it is an integrity gap as much as a
+ confidentiality one: the node signs `handshake_transcript(...)`, which names no
+ ack field, so every value below was authenticated by the DTLS channel alone.
+ """
+ config = {
+ "is_node_admin": True,
+ "video_root": SECRET_ROOT,
+ "enabled_apps": ["files", "videos"],
+ }
+ ack = {
+ "type": MNP.HANDSHAKE_ACK,
+ "v": "1.0",
+ "node_pk": "Tk9ERVBL",
+ "proof": "cHJvb2Y=",
+ "sig": "c2ln",
+ **seal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, "g-1", config),
+ }
+ frame = msgpack.packb(ack, use_bin_type=True)
+ _assert_absent(frame, SECRET_ROOT, "vasculum", "video_root",
+ "enabled_apps", "is_node_admin")
+
+ # What a client needs in order to authenticate the node is still in clear —
+ # it verifies those *before* it would trust a decryption.
+ msg = msgpack.unpackb(frame, raw=False)
+ assert msg["node_pk"] and msg["proof"] and msg["sig"]
+ assert unseal(gek, PURPOSE_ACK, MNP.HANDSHAKE_ACK, "g-1", msg) == config
+
+
+def test_index_progress_stays_clear_and_stays_counters():
+ """
+ Decision D3: `index_progress` is deliberately *not* sealed — counters only,
+ pushed every couple of seconds for the whole length of a scan, so sealing it
+ would buy a rough library size and cost a decrypt per push.
+
+ The field list is re-derived from the daemon's own source rather than restated
+ here, so this fails the day the message grows something that names anything —
+ `IndexProgress` already carries a `current_dir` the push deliberately omits,
+ and adding it would be one line. That is the moment the trade above is void.
+ """
+ import ast
+ import inspect
+ import textwrap
+
+ from meshbay_node.daemon import NodeDaemon
+
+ source = inspect.getsource(NodeDaemon._push_index_progress)
+ tree = ast.parse(textwrap.dedent(source))
+ dicts = [n for n in ast.walk(tree) if isinstance(n, ast.Dict)]
+ assert len(dicts) == 1, "more than one message built here — re-read this test"
+ keys = {k.value for k in dicts[0].keys}
+ assert keys == {"type", "v", "group_id",
+ "scanning", "scanned_bytes", "total_bytes"}, (
+ f"index_progress now carries {keys} — re-read decision D3 before shipping it")
+
+ assert "seal(" not in source
+ assert "D3" in source, "the reason it is not sealed must stay next to the code"